Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,63 @@ jobs:
- name: Bundle budget
run: pnpm size

# The API image built from this PR, started with PostgreSQL and no Worker,
# in WebKit, Chromium and Firefox (src/Anichron.UI/e2e).
e2e:
name: E2E
runs-on: ubuntu-latest
permissions:
contents: read
env:
E2E_API_IMAGE: anichron-api:e2e
steps:
- uses: actions/checkout@v7

- uses: docker/setup-buildx-action@v4

- name: Build the API image
uses: docker/build-push-action@v7
with:
context: src
file: src/Anichron.API/Dockerfile
platforms: linux/amd64
load: true
push: false
tags: ${{ env.E2E_API_IMAGE }}
cache-from: type=registry,ref=${{ vars.DOCKERHUB_USERNAME }}/anichron-api:edge
provenance: false

- name: Start the API and PostgreSQL
run: docker compose -f src/docker-compose.e2e.yml up --detach --wait

- uses: pnpm/setup@v3
with:
working-directory: src/Anichron.UI
require-lockfile: true
cache: true

- name: Install browsers
working-directory: src/Anichron.UI
run: pnpm exec playwright install --with-deps chromium firefox webkit

- name: End-to-end tests
working-directory: src/Anichron.UI
run: pnpm e2e

- name: API logs
if: failure()
run: docker compose -f src/docker-compose.e2e.yml logs

- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: |
src/Anichron.UI/playwright-report/
src/Anichron.UI/test-results/
retention-days: 7

# One Stryker run per mutation target project, in parallel (ADR 0010): in
# solution mode Stryker captures the same coverage once per project. The list
# comes from the IsMutationTarget convention, so a new project is mutated
Expand Down Expand Up @@ -389,7 +446,7 @@ jobs:
gate:
name: CI gate
runs-on: ubuntu-latest
needs: [checks, integration, mutation, ui, docker]
needs: [checks, integration, mutation, ui, e2e, docker]
if: always()
permissions:
contents: read
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Mutants that flip between runs come from `static readonly` initialisers, EF's ca

## The UI (`src/Anichron.UI`)

Commands are the `package.json` scripts, run from `src/Anichron.UI` (`pnpm dev`, `typecheck`, `lint`, `format`, `depcruise`, `test`, `build`, `size`, `mutate`). `pnpm dev` proxies `/api` to the API on `http://localhost:5207`. The hooks run `format`, `lint`, `typecheck` and `depcruise` before a commit that stages a UI file, and `scripts/ui-pre-push.sh` before a push (a frozen install, then `test`, when the branch changes the UI); CI's `UI` job runs all of them plus the scoped mutation run and the bundle budget (150 kB gzip initial JS, 15 kB CSS in `.size-limit.json`; a PR that raises one says why).
Commands are the `package.json` scripts, run from `src/Anichron.UI` (`pnpm dev`, `typecheck`, `lint`, `format`, `depcruise`, `test`, `build`, `size`, `mutate`, `e2e`). `pnpm dev` proxies `/api` to the API on `http://localhost:5207`. The hooks run `format`, `lint`, `typecheck` and `depcruise` before a commit that stages a UI file, and `scripts/ui-pre-push.sh` before a push (a frozen install, then `test`, when the branch changes the UI); CI's `UI` job runs all of them plus the scoped mutation run and the bundle budget (150 kB gzip initial JS, 15 kB CSS in `.size-limit.json`; a PR that raises one says why). CI's `E2E` job runs `pnpm e2e` (Playwright in `e2e/`: WebKit on an iPhone, Chromium on a Pixel, Firefox at a phone viewport) against the API image built from the PR; locally, build it as `anichron-api:e2e` and start `src/docker-compose.e2e.yml` first (its header lists the commands).

**Layout.** `src/app/` (entry, providers, the code-based route tree in `router.tsx`), `src/api/`, `src/i18n/`, `src/shared/`, `src/features/<name>/`, and `src/test/` for test support. dependency-cruiser (`.dependency-cruiser.cjs`) fails the build when a feature imports another feature, when `shared/` or `api/` import `features/` or `app/`, when anything but `app/` imports a feature, on an import cycle, when production code imports test code, and when a `.ts` file imports a `.tsx` file. Only `src/api/` touches the network: ESLint's `no-restricted-globals` forbids `fetch`, `EventSource`, `XMLHttpRequest` and `WebSocket` elsewhere, since dependency-cruiser cannot see globals.

Expand Down Expand Up @@ -123,7 +123,7 @@ The API's HTTP surface is `AddApiSurface` plus `MapApiEndpoints`; `Program.cs` a
- **Proxy orphans** (`ProxyOrphans`, a proxy sweep's report) are written only by the Worker; outside it only `EfProxyOrphanReader` depends on the entity (`only-worker-writes-proxy-orphans`). See `docs/architecture/work-queue.md`, "Proxy sweeps".
- Both hosts apply EF migrations at startup, serialised by a PostgreSQL advisory lock (`MigrateWithAdvisoryLockAsync`).

`Anichron.UI` (admin screens and the Instagram Story-style flashbacks) is a React + TypeScript single-page app built with Vite, to be served by the API on the same origin (ADR 0012). It is a pnpm project, not part of the .NET solution; so far it has a translated placeholder page. The work is planned in parent issues #344–#349; see "The UI" below.
`Anichron.UI` (admin screens and the Instagram Story-style flashbacks) is a React + TypeScript single-page app built with Vite. The API image builds it and serves it on the same origin as `/api/v1`, with immutable hashed assets, an `index.html` fallback that never answers `/api`, and a strict CSP with Trusted Types (ADR 0012, `docs/architecture/ui-serving.md`). It is a pnpm project, not part of the .NET solution; so far it has a translated placeholder page. The work is planned in parent issues #344–#349; see "The UI" below.

### Storage Layout

Expand All @@ -144,6 +144,7 @@ Each file is the reference for its area. Read it before changing that area, and
- **Data model** (`data-model.md`): the twelve entities, indexes, query filters, value converters, NodaTime. Read before adding an entity, column, index or migration; `docs/schema.sql` stays the authority on columns.
- **Ingestion** (`ingestion.md`): crawl, classification outcomes, reconciliation (soft-delete, auto-restore, Live Photo as one unit), proxies, burst detection, duplicate reports, failure ownership. Read before touching `Anichron.Worker/Ingestion`, `Crawling` or `Bursts`.
- **Work queue** (`work-queue.md`): producing, scope, scheduling, executing, cancelling, the admin view, adding a kind, the handler contract, and proxy sweeps. Read before adding a work kind or touching `Anichron.Core/Work` or `Anichron.Worker/Work`.
- **UI serving** (`ui-serving.md`): how the API image serves the UI, the cache and fallback rules, the security headers, and the end-to-end setup. Read before touching `UiServing`, `SecurityHeaders`, the API Dockerfile's UI stage or `e2e/`.
- **Media reads** (`media-reads.md`): `IAssetViewReader`, selections, burst covers, published media types, and the hidden/starred/liked rules. Read before adding or changing a media endpoint.

## Conventions
Expand Down
65 changes: 65 additions & 0 deletions docs/architecture/ui-serving.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# UI serving

How the API container serves `Anichron.UI` on the same origin as `/api/v1` (ADR 0012, #359), and
the headers every response carries.

## The image

The API Dockerfile builds the UI in a Node stage on `$BUILDPLATFORM` (the bundle is the same files
for every target arch) and copies `dist/` to `/app/wwwroot`, the web root. The Node tag matches
`.nvmrc` exactly, so pnpm's `devEngines.runtime` never downloads another Node, and the stage
installs the pnpm version in `packageManager`. These pins move together: a Node bump changes the
`FROM` tag, `.nvmrc`, `engines` and `devEngines.runtime.version` in one change, and a pnpm bump
changes `packageManager` and the Dockerfile's `pnpm@` version. Dependabot's `docker` entry rewrites
only the `FROM` line, so its PR gets the rest added before it merges. A
`dotnet run` without a built UI has no `wwwroot`: the API serves as usual and every UI path is a
`404`. Development goes through `pnpm dev`, which proxies `/api` to the local API.

## The serving contract

`UseUi` (`Anichron.API/Infrastructure/UiServing.cs`) is `UseStaticFiles` on the web root plus one
middleware that runs after routing, only for a request no endpoint matched:

| Request | Response |
|---|---|
| A file under `/assets/` (Vite's hashed output) | the file, `Cache-Control: public, max-age=31536000, immutable` |
| Any other file in the web root (`index.html`, the service worker, the manifest) | the file, `Cache-Control: no-cache` |
| `GET` or `HEAD` of a path with no file extension, outside `/api` | `index.html`, `no-cache` (the client router takes over) |
| An unknown path under `/api` (any method, extension or not) | `404`, `application/problem+json` |
| An unknown path with a file extension, or a non-`GET`/`HEAD` app path | `404`, empty body |
| A known `/api` route with the wrong method | `405`, as before |

The fallbacks are middleware rather than `MapFallback` endpoints on purpose: a catch-all endpoint
matches every method, so routing would pick it instead of answering `405` for a known route.
`/api` is a path segment (`StartsWithSegments`), so `/apiary` is an app path. A dot in the last segment counts as an extension (`Path.HasExtension`), so a client route never ends in one (`/people/j.doe` would be a `404`). The web root is read
through ASP.NET Core's file provider, never `IFileSystem` (ADR 0007, `api-reads-no-file-system`).

## Security headers

`UseSecurityHeaders` (`SecurityHeaders.cs`) is the first middleware and sets the headers in
`OnStarting`, so every response gets them, including `401`, `429`, the must-change-password `403`
and the fallback `404`s.

- **Outside `/api`:** `Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' blob: data:; media-src 'self' blob:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; require-trusted-types-for 'script'`,
`Referrer-Policy: same-origin`, `X-Content-Type-Options: nosniff` and
`Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()`.
- **Under `/api`:** `X-Content-Type-Options: nosniff` and `Content-Security-Policy: frame-ancestors 'none'`.

The policy has no `Development` branch: only the Vite dev server, which sends no CSP, is looser.
Today's bundle runs under Trusted Types in all three browsers, so
`require-trusted-types-for 'script'` stays. It has no route chunks yet, so Vite's dynamic-import
preload helper is not in it; the smoke test fails on any Trusted Types violation, so the first lazy
route proves the helper too. A feature that needs a browser permission (fullscreen
for the flashback player, say) adds it to `Permissions-Policy` in its own change.

## Tests

- **Seam 1** (`Anichron.API.Tests.Unit/Infrastructure/UiServingTests.cs`, `SecurityHeadersTests.cs`):
the in-process test host on a fixture web root (`Infrastructure/TestWebRoot/`), asserting status,
content type, `Cache-Control` and exact header values. A web root that does not exist stands for
"no UI built".
- **Seam 4** (`src/Anichron.UI/e2e/`): Playwright against the image built from the checkout,
started by `src/docker-compose.e2e.yml` with PostgreSQL on tmpfs and no Worker. Projects: WebKit
on an iPhone profile, Chromium on a Pixel profile, Firefox at a phone viewport. The smoke test
loads the placeholder and asserts the headers, the assets' caching, no CSP or Trusted Types
violation, no request to another origin, and no axe violations. CI runs it in the `E2E` job.
4 changes: 4 additions & 0 deletions src/Anichron.API.Tests.Unit/Anichron.API.Tests.Unit.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
</ItemGroup>

<ItemGroup>
<None Update="Infrastructure/TestWebRoot/**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

</Project>
4 changes: 2 additions & 2 deletions src/Anichron.API.Tests.Unit/Infrastructure/ApiTestHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ namespace Anichron.API.Tests.Unit.Infrastructure;

internal static class ApiTestHost
{
public static WebApplicationBuilder CreateBuilder()
public static WebApplicationBuilder CreateBuilder(string? webRootPath = null)
{
var builder = WebApplication.CreateSlimBuilder();
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions { WebRootPath = webRootPath });
builder.Services.AddHealthChecks();
builder.Services.AddSingleton(Substitute.For<IAuthService>());
builder.Services.AddSingleton(Substitute.For<IAuthResponseMapper>());
Expand Down
67 changes: 67 additions & 0 deletions src/Anichron.API.Tests.Unit/Infrastructure/SecurityHeadersTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using Anichron.API.Infrastructure;
using Microsoft.AspNetCore.TestHost;

namespace Anichron.API.Tests.Unit.Infrastructure;

public sealed class SecurityHeadersTests
{
private const string AppPolicy =
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' blob: data:; media-src 'self' blob:; "
+ "connect-src 'self'; worker-src 'self'; manifest-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; "
+ "form-action 'self'; frame-ancestors 'none'; require-trusted-types-for 'script'";

private const string AppPermissions =
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()";

private static async Task<HttpResponseMessage> SendAsync(HttpMethod method, string path)
{
var builder = ApiTestHost.CreateBuilder(Path.Combine(AppContext.BaseDirectory, "Infrastructure", "TestWebRoot"));
builder.WebHost.UseTestServer();
await using var app = builder.Build();
app.UseSecurityHeaders();
app.UseUi();
app.MapApiEndpoints();
await app.StartAsync(TestContext.Current.CancellationToken);
using var client = app.GetTestClient();
using var request = new HttpRequestMessage(method, path);
var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Content.LoadIntoBufferAsync(TestContext.Current.CancellationToken);
return response;
}

private static string Header(HttpResponseMessage response, string name)
=> response.Headers.Should().ContainKey(name).WhoseValue.Should().ContainSingle().Which;

[Theory]
[InlineData("GET", "/")]
[InlineData("GET", "/login")]
[InlineData("GET", "/index.html")]
[InlineData("GET", "/assets/index-Bx7f3k9q.js")]
[InlineData("GET", "/missing.js")]
[InlineData("POST", "/login")]
[InlineData("GET", "/apiary")]
public async Task AppResponse_CarriesTheFullPolicyAsync(string method, string path)
{
using var response = await SendAsync(new HttpMethod(method), path);

Header(response, "Content-Security-Policy").Should().Be(AppPolicy);
Header(response, "Referrer-Policy").Should().Be("same-origin");
Header(response, "X-Content-Type-Options").Should().Be("nosniff");
Header(response, "Permissions-Policy").Should().Be(AppPermissions);
}

[Theory]
[InlineData("GET", "/api/v1/healthz")]
[InlineData("GET", "/api/v1/nothing-here")]
[InlineData("GET", "/api/v1/auth/login")]
[InlineData("GET", "/api")]
public async Task ApiResponse_CarriesNoSniffAndFrameAncestorsNoneAsync(string method, string path)
{
using var response = await SendAsync(new HttpMethod(method), path);

Header(response, "Content-Security-Policy").Should().Be("frame-ancestors 'none'");
Header(response, "X-Content-Type-Options").Should().Be("nosniff");
response.Headers.Contains("Permissions-Policy").Should().BeFalse();
response.Headers.Contains("Referrer-Policy").Should().BeFalse();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
console.log("fixture asset");
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!doctype html><title>fixture index</title>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"fixture"}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
self.addEventListener("fetch", () => {});
Loading
Loading