diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d285c4e8..6d959acf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index a06cb4da..cd736771 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//`, 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. @@ -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 @@ -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 diff --git a/docs/architecture/ui-serving.md b/docs/architecture/ui-serving.md new file mode 100644 index 00000000..c4b00cb3 --- /dev/null +++ b/docs/architecture/ui-serving.md @@ -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. diff --git a/src/Anichron.API.Tests.Unit/Anichron.API.Tests.Unit.csproj b/src/Anichron.API.Tests.Unit/Anichron.API.Tests.Unit.csproj index 59f2d3b1..b7a5da7b 100644 --- a/src/Anichron.API.Tests.Unit/Anichron.API.Tests.Unit.csproj +++ b/src/Anichron.API.Tests.Unit/Anichron.API.Tests.Unit.csproj @@ -11,4 +11,8 @@ + + + + diff --git a/src/Anichron.API.Tests.Unit/Infrastructure/ApiTestHost.cs b/src/Anichron.API.Tests.Unit/Infrastructure/ApiTestHost.cs index 6de0a8e8..df3bc7f7 100644 --- a/src/Anichron.API.Tests.Unit/Infrastructure/ApiTestHost.cs +++ b/src/Anichron.API.Tests.Unit/Infrastructure/ApiTestHost.cs @@ -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()); builder.Services.AddSingleton(Substitute.For()); diff --git a/src/Anichron.API.Tests.Unit/Infrastructure/SecurityHeadersTests.cs b/src/Anichron.API.Tests.Unit/Infrastructure/SecurityHeadersTests.cs new file mode 100644 index 00000000..78a6e788 --- /dev/null +++ b/src/Anichron.API.Tests.Unit/Infrastructure/SecurityHeadersTests.cs @@ -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 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(); + } +} diff --git a/src/Anichron.API.Tests.Unit/Infrastructure/TestWebRoot/assets/index-Bx7f3k9q.js b/src/Anichron.API.Tests.Unit/Infrastructure/TestWebRoot/assets/index-Bx7f3k9q.js new file mode 100644 index 00000000..6a21bf15 --- /dev/null +++ b/src/Anichron.API.Tests.Unit/Infrastructure/TestWebRoot/assets/index-Bx7f3k9q.js @@ -0,0 +1 @@ +console.log("fixture asset"); diff --git a/src/Anichron.API.Tests.Unit/Infrastructure/TestWebRoot/index.html b/src/Anichron.API.Tests.Unit/Infrastructure/TestWebRoot/index.html new file mode 100644 index 00000000..5f7fd175 --- /dev/null +++ b/src/Anichron.API.Tests.Unit/Infrastructure/TestWebRoot/index.html @@ -0,0 +1 @@ +fixture index diff --git a/src/Anichron.API.Tests.Unit/Infrastructure/TestWebRoot/manifest.webmanifest b/src/Anichron.API.Tests.Unit/Infrastructure/TestWebRoot/manifest.webmanifest new file mode 100644 index 00000000..af07e4fe --- /dev/null +++ b/src/Anichron.API.Tests.Unit/Infrastructure/TestWebRoot/manifest.webmanifest @@ -0,0 +1 @@ +{"name":"fixture"} diff --git a/src/Anichron.API.Tests.Unit/Infrastructure/TestWebRoot/sw.js b/src/Anichron.API.Tests.Unit/Infrastructure/TestWebRoot/sw.js new file mode 100644 index 00000000..1fdf7324 --- /dev/null +++ b/src/Anichron.API.Tests.Unit/Infrastructure/TestWebRoot/sw.js @@ -0,0 +1 @@ +self.addEventListener("fetch", () => {}); diff --git a/src/Anichron.API.Tests.Unit/Infrastructure/UiServingTests.cs b/src/Anichron.API.Tests.Unit/Infrastructure/UiServingTests.cs new file mode 100644 index 00000000..d0d9adfb --- /dev/null +++ b/src/Anichron.API.Tests.Unit/Infrastructure/UiServingTests.cs @@ -0,0 +1,161 @@ +using Anichron.API.Infrastructure; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using System.Net; +using System.Text.Json; + +namespace Anichron.API.Tests.Unit.Infrastructure; + +public sealed class UiServingTests +{ + private static string TestWebRoot => Path.Combine(AppContext.BaseDirectory, "Infrastructure", "TestWebRoot"); + + private static async Task StartAsync(string webRoot) + { + var builder = ApiTestHost.CreateBuilder(webRoot); + builder.WebHost.UseTestServer(); + var app = builder.Build(); + app.UseUi(); + app.MapApiEndpoints(); + await app.StartAsync(TestContext.Current.CancellationToken); + return app; + } + + private static async Task GetAsync(string path, string? webRoot = null) + { + await using var app = await StartAsync(webRoot ?? TestWebRoot); + using var client = app.GetTestClient(); + var response = await client.GetAsync(path, TestContext.Current.CancellationToken); + await response.Content.LoadIntoBufferAsync(TestContext.Current.CancellationToken); + return response; + } + + [Fact] + public async Task HashedAsset_IsServedImmutableForAYearAsync() + { + using var response = await GetAsync("/assets/index-Bx7f3k9q.js"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).Should().Contain("fixture asset"); + response.Headers.GetValues("Cache-Control").Should().Equal("public, max-age=31536000, immutable"); + } + + [Theory] + [InlineData("/index.html", "fixture index")] + [InlineData("/sw.js", "addEventListener")] + [InlineData("/manifest.webmanifest", "\"name\":\"fixture\"")] + public async Task UnhashedFile_IsServedNoCacheAsync(string path, string content) + { + using var response = await GetAsync(path); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).Should().Contain(content); + response.Headers.GetValues("Cache-Control").Should().Equal("no-cache"); + } + + [Theory] + [InlineData("/")] + [InlineData("/login")] + [InlineData("/flashbacks/2024/06")] + public async Task PathWithoutExtension_ServesIndexNoCacheAsync(string path) + { + using var response = await GetAsync(path); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Headers.ContentType!.MediaType.Should().Be("text/html"); + (await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).Should().Contain("fixture index"); + response.Headers.GetValues("Cache-Control").Should().Equal("no-cache"); + } + + [Fact] + public async Task PathOutsideApiThatOnlyStartsWithApi_ServesIndexAsync() + { + using var response = await GetAsync("/apiary"); + + (await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).Should().Contain("fixture index"); + } + + [Theory] + [InlineData("/api")] + [InlineData("/api/v1/nothing-here")] + [InlineData("/api/v2/users")] + [InlineData("/api/v1/nothing-here.json")] + public async Task UnknownApiPath_IsAJsonNotFoundAsync(string path) + { + using var response = await GetAsync(path); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + response.Content.Headers.ContentType!.MediaType.Should().Be("application/problem+json"); + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + body.RootElement.GetProperty("status").GetInt32().Should().Be(404); + } + + [Fact] + public async Task UnknownApiPath_IsAJsonNotFoundForEveryMethodAsync() + { + await using var app = await StartAsync(TestWebRoot); + using var client = app.GetTestClient(); + + using var response = await client.DeleteAsync("/api/v1/nothing-here", TestContext.Current.CancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + response.Content.Headers.ContentType!.MediaType.Should().Be("application/problem+json"); + } + + [Fact] + public async Task KnownApiRouteWithTheWrongMethod_StaysMethodNotAllowedAsync() + { + using var response = await GetAsync("/api/v1/auth/login"); + + response.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); + } + + [Theory] + [InlineData("/missing.js")] + [InlineData("/assets/index-Missing0.js")] + [InlineData("/favicon.ico")] + public async Task UnknownPathWithAnExtension_IsAPlainNotFoundAsync(string path) + { + using var response = await GetAsync(path); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + response.Content.Headers.ContentLength.Should().Be(0); + } + + [Fact] + public async Task PathWithoutExtension_IsNotFoundForAMethodOtherThanGetOrHeadAsync() + { + await using var app = await StartAsync(TestWebRoot); + using var client = app.GetTestClient(); + + using var response = await client.PostAsync("/login", null, TestContext.Current.CancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + response.Content.Headers.ContentLength.Should().Be(0); + } + + [Fact] + public async Task PathWithoutExtension_ServesIndexToHeadAsync() + { + await using var app = await StartAsync(TestWebRoot); + using var client = app.GetTestClient(); + + using var request = new HttpRequestMessage(HttpMethod.Head, "/login"); + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Headers.ContentType!.MediaType.Should().Be("text/html"); + } + + [Fact] + public async Task WithoutABuiltUi_TheApiStillServesAndTheAppIsNotFoundAsync() + { + var missing = Path.Combine(AppContext.BaseDirectory, "no-ui-built"); + + using var health = await GetAsync("/api/v1/healthz", missing); + using var app = await GetAsync("/", missing); + + health.StatusCode.Should().Be(HttpStatusCode.OK); + app.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} diff --git a/src/Anichron.API/Dockerfile b/src/Anichron.API/Dockerfile index c4466bf9..7650d825 100644 --- a/src/Anichron.API/Dockerfile +++ b/src/Anichron.API/Dockerfile @@ -3,7 +3,16 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS base WORKDIR /app EXPOSE 8080 -# 2. Build Stage +# 2. UI Stage +FROM --platform=$BUILDPLATFORM node:26.13.0-alpine AS ui +WORKDIR /ui +RUN npm install --global pnpm@12.16.1 +COPY ["Anichron.UI/package.json", "Anichron.UI/pnpm-lock.yaml", "Anichron.UI/pnpm-workspace.yaml", "./"] +RUN pnpm install --frozen-lockfile +COPY ["Anichron.UI/", "./"] +RUN pnpm build + +# 3. Build Stage # $BUILDPLATFORM = CI runner arch (amd64). dotnet publish produces arch-neutral IL, # so compiling natively avoids QEMU emulation overhead without affecting the output. FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build @@ -31,15 +40,17 @@ COPY . . WORKDIR "/src/Anichron.API" RUN dotnet build "Anichron.API.csproj" -c $BUILD_CONFIGURATION -o /app/build -# 3. Publish Stage +# 4. Publish Stage FROM build AS publish ARG BUILD_CONFIGURATION=Release RUN dotnet publish "Anichron.API.csproj" -c $BUILD_CONFIGURATION -o /app/publish -# 4. Final Production Stage +# 5. Final Production Stage FROM base AS final WORKDIR /app COPY --from=publish /app/publish . +COPY --from=ui /ui/dist ./wwwroot +RUN mkdir configuration && chown $APP_UID configuration # Use the non-privileged user provided by the base image USER $APP_UID diff --git a/src/Anichron.API/Infrastructure/ApiPaths.cs b/src/Anichron.API/Infrastructure/ApiPaths.cs index c8f9c394..ca7e953f 100644 --- a/src/Anichron.API/Infrastructure/ApiPaths.cs +++ b/src/Anichron.API/Infrastructure/ApiPaths.cs @@ -2,7 +2,8 @@ namespace Anichron.API.Infrastructure; internal static class ApiPaths { - internal const string Base = "/api/v1"; + internal const string Root = "/api"; + internal const string Base = $"{Root}/v1"; internal const string Healthz = $"{Base}/healthz"; internal static class Auth diff --git a/src/Anichron.API/Infrastructure/SecurityHeaders.cs b/src/Anichron.API/Infrastructure/SecurityHeaders.cs new file mode 100644 index 00000000..c3fe9651 --- /dev/null +++ b/src/Anichron.API/Infrastructure/SecurityHeaders.cs @@ -0,0 +1,48 @@ +using Microsoft.Net.Http.Headers; + +namespace Anichron.API.Infrastructure; + +public static class SecurityHeaders +{ + 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 apiPolicy = "frame-ancestors 'none'"; + + private const string appPermissions = + "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"; + + extension(WebApplication app) + { + public WebApplication UseSecurityHeaders() + { + app.Use((context, next) => + { + context.Response.OnStarting(() => + { + Apply(context); + return Task.CompletedTask; + }); + return next(context); + }); + return app; + } + } + + private static void Apply(HttpContext context) + { + var headers = context.Response.Headers; + headers[HeaderNames.XContentTypeOptions] = "nosniff"; + if (context.Request.Path.StartsWithSegments(ApiPaths.Root)) + { + headers[HeaderNames.ContentSecurityPolicy] = apiPolicy; + return; + } + + headers[HeaderNames.ContentSecurityPolicy] = appPolicy; + headers["Referrer-Policy"] = "same-origin"; + headers["Permissions-Policy"] = appPermissions; + } +} diff --git a/src/Anichron.API/Infrastructure/UiServing.cs b/src/Anichron.API/Infrastructure/UiServing.cs new file mode 100644 index 00000000..cc7533f1 --- /dev/null +++ b/src/Anichron.API/Infrastructure/UiServing.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Net.Http.Headers; + +namespace Anichron.API.Infrastructure; + +public static class UiServing +{ + private const string hashedAssets = "/assets"; + private const string index = "/index.html"; + + extension(WebApplication app) + { + public WebApplication UseUi() + { + app.Use((context, next) => + { + if (context.GetEndpoint() is not null) + return next(context); + if (context.Request.Path.StartsWithSegments(ApiPaths.Root)) + return Results.Problem(statusCode: StatusCodes.Status404NotFound).ExecuteAsync(context); + if (IsAppRoute(context.Request)) + context.Request.Path = index; + return next(context); + }); + app.UseStaticFiles(new StaticFileOptions { OnPrepareResponse = SetCacheControl }); + return app; + } + } + + private static bool IsAppRoute(HttpRequest request) + => (HttpMethods.IsGet(request.Method) || HttpMethods.IsHead(request.Method)) + && !Path.HasExtension(request.Path.Value); + + private static void SetCacheControl(StaticFileResponseContext context) + { + context.Context.Response.Headers[HeaderNames.CacheControl] = + context.Context.Request.Path.StartsWithSegments(hashedAssets) + ? "public, max-age=31536000, immutable" + : "no-cache"; + } +} diff --git a/src/Anichron.API/Program.cs b/src/Anichron.API/Program.cs index bc869f98..ade7e1d2 100644 --- a/src/Anichron.API/Program.cs +++ b/src/Anichron.API/Program.cs @@ -7,12 +7,15 @@ var app = builder.Build(); +app.UseSecurityHeaders(); + if (app.Environment.IsDevelopment()) app.MapOpenApi(); app.UseForwardedHeaders(); app.UseHttpsRedirection(); app.UseCors(); +app.UseUi(); app.UseRateLimiter(); app.UseAuthentication(); app.UseMiddleware(); diff --git a/src/Anichron.UI/.gitignore b/src/Anichron.UI/.gitignore index 4d1c7b20..4c0718fb 100644 --- a/src/Anichron.UI/.gitignore +++ b/src/Anichron.UI/.gitignore @@ -3,3 +3,5 @@ dist/ coverage/ reports/ .stryker-tmp/ +playwright-report/ +test-results/ diff --git a/src/Anichron.UI/.prettierignore b/src/Anichron.UI/.prettierignore index 5c4a9c32..9768ee98 100644 --- a/src/Anichron.UI/.prettierignore +++ b/src/Anichron.UI/.prettierignore @@ -3,3 +3,5 @@ coverage reports .stryker-tmp pnpm-lock.yaml +playwright-report +test-results diff --git a/src/Anichron.UI/e2e/smoke.spec.ts b/src/Anichron.UI/e2e/smoke.spec.ts new file mode 100644 index 00000000..72f0faca --- /dev/null +++ b/src/Anichron.UI/e2e/smoke.spec.ts @@ -0,0 +1,64 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test } from '@playwright/test'; + +const 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'"; + +test('the placeholder loads with its headers and requests nothing but its own origin', async ({ + page, + context, + baseURL, +}) => { + const origin = new URL(baseURL ?? '').origin; + const requested: string[] = []; + const assetCaching: (string | undefined)[] = []; + const pageErrors: string[] = []; + context.on('request', (request) => requested.push(request.url())); + context.on('response', (response) => { + if (new URL(response.url()).pathname.startsWith('/assets/')) + assetCaching.push(response.headers()['cache-control']); + }); + page.on('pageerror', (error) => pageErrors.push(error.message)); + await page.addInitScript(() => { + const violations: string[] = []; + Object.defineProperty(window, 'cspViolations', { value: violations }); + document.addEventListener('securitypolicyviolation', (event) => { + violations.push(`${event.effectiveDirective} ${event.blockedURI}`); + }); + }); + + const response = await page.goto('/'); + + await expect(page.getByRole('heading', { level: 1, name: 'Anichron' })).toBeVisible(); + await expect(page.getByText('Your photo library is on its way.')).toBeVisible(); + const headers = response?.headers() ?? {}; + expect(headers['content-security-policy']).toBe(appPolicy); + expect(headers['referrer-policy']).toBe('same-origin'); + expect(headers['x-content-type-options']).toBe('nosniff'); + expect(headers['permissions-policy']).toBe( + 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()', + ); + expect(headers['cache-control']).toBe('no-cache'); + expect(assetCaching).not.toHaveLength(0); + expect(assetCaching).toEqual(assetCaching.map(() => 'public, max-age=31536000, immutable')); + expect(await page.evaluate(() => Reflect.get(window, 'cspViolations') as string[])).toEqual([]); + expect(pageErrors).toEqual([]); + expect(requested.filter((url) => new URL(url).origin !== origin)).toEqual([]); + + const accessibility = await new AxeBuilder({ page }).analyze(); + expect(accessibility.violations).toEqual([]); +}); + +test('an unknown API path is a JSON 404 with the API headers, never the app', async ({ + request, +}) => { + const response = await request.get('/api/v1/nothing-here'); + + expect(response.status()).toBe(404); + expect(response.headers()['content-type']).toContain('application/problem+json'); + expect(response.headers()['content-security-policy']).toBe("frame-ancestors 'none'"); + expect(response.headers()['x-content-type-options']).toBe('nosniff'); +}); diff --git a/src/Anichron.UI/eslint.config.js b/src/Anichron.UI/eslint.config.js index d58afccf..69741ebc 100644 --- a/src/Anichron.UI/eslint.config.js +++ b/src/Anichron.UI/eslint.config.js @@ -15,7 +15,14 @@ const onlyApi = (name) => ({ }); export default defineConfig([ - globalIgnores(['dist', 'coverage', 'reports', '.stryker-tmp']), + globalIgnores([ + 'dist', + 'coverage', + 'reports', + '.stryker-tmp', + 'playwright-report', + 'test-results', + ]), { files: ['**/*.{ts,tsx}'], extends: [js.configs.recommended, tseslint.configs.strictTypeChecked], diff --git a/src/Anichron.UI/package.json b/src/Anichron.UI/package.json index 668235c1..2163ad94 100644 --- a/src/Anichron.UI/package.json +++ b/src/Anichron.UI/package.json @@ -6,12 +6,13 @@ "scripts": { "dev": "vite", "build": "vite build", - "typecheck": "tsc -p tsconfig.app.json && tsc -p tsconfig.node.json", + "typecheck": "tsc -p tsconfig.app.json && tsc -p tsconfig.node.json && tsc -p tsconfig.e2e.json", "lint": "eslint .", "format": "prettier --check .", "format:fix": "prettier --write .", "depcruise": "depcruise src --config .dependency-cruiser.cjs", "test": "vitest run", + "e2e": "playwright test", "size": "size-limit", "mutate": "stryker run" }, @@ -42,8 +43,10 @@ "react-i18next": "17.1.9" }, "devDependencies": { + "@axe-core/playwright": "4.14.0", "@babel/core": "8.0.9", "@eslint/js": "9.39.5", + "@playwright/test": "1.64.1", "@rolldown/plugin-babel": "0.2.4", "@size-limit/file": "14.3.0", "@stryker-mutator/core": "10.1.1", diff --git a/src/Anichron.UI/playwright.config.ts b/src/Anichron.UI/playwright.config.ts new file mode 100644 index 00000000..6e580f46 --- /dev/null +++ b/src/Anichron.UI/playwright.config.ts @@ -0,0 +1,19 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + forbidOnly: !!process.env.CI, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:8080', + trace: 'retain-on-failure', + }, + projects: [ + { name: 'webkit-iphone', use: { ...devices['iPhone 16'] } }, + { name: 'chromium-pixel', use: { ...devices['Pixel 9'] } }, + { + name: 'firefox-phone', + use: { browserName: 'firefox', viewport: { width: 393, height: 852 } }, + }, + ], +}); diff --git a/src/Anichron.UI/pnpm-lock.yaml b/src/Anichron.UI/pnpm-lock.yaml index ec8a043f..98159c75 100644 --- a/src/Anichron.UI/pnpm-lock.yaml +++ b/src/Anichron.UI/pnpm-lock.yaml @@ -182,12 +182,18 @@ importers: specifier: 17.1.9 version: 17.1.9(i18next@26.5.11(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3) devDependencies: + '@axe-core/playwright': + specifier: 4.14.0 + version: 4.14.0(playwright-core@1.64.1) '@babel/core': specifier: 8.0.9 version: 8.0.9 '@eslint/js': specifier: 9.39.5 version: 9.39.5 + '@playwright/test': + specifier: 1.64.1 + version: 1.64.1 '@rolldown/plugin-babel': specifier: 0.2.4 version: 0.2.4(@babel/core@8.0.9)(@babel/runtime@7.29.10)(rolldown@1.2.15)(vite@8.3.5(@types/node@26.6.6)) @@ -292,6 +298,12 @@ packages: resolution: {integrity: sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + '@axe-core/playwright@4.14.0': + resolution: {integrity: sha512-SGUhG5PIHd2eMdpgBF2PyQLOhX+qyDR2PT4cmoS4Qk2ak+jX/rrSvst9l0PGv5WTHolKpUMvVmoHpr4cZgf1RQ==} + engines: {node: '>=22.12.0'} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -943,6 +955,11 @@ packages: cpu: [x64] os: [win32] + '@playwright/test@1.64.1': + resolution: {integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==} + engines: {node: '>=20'} + hasBin: true + '@rolldown/binding-android-arm-eabi@1.2.15': resolution: {integrity: sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2587,6 +2604,16 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3228,6 +3255,11 @@ snapshots: is-potential-custom-element-name: 1.0.1 lru-cache: 11.5.3 + '@axe-core/playwright@4.14.0(playwright-core@1.64.1)': + dependencies: + axe-core: 4.14.0 + playwright-core: 1.64.1 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -3863,6 +3895,10 @@ snapshots: '@oxc-transform-react/binding-win32-x64-msvc@0.152.0': optional: true + '@playwright/test@1.64.1': + dependencies: + playwright: 1.64.1 + '@rolldown/binding-android-arm-eabi@1.2.15': optional: true @@ -5491,6 +5527,12 @@ snapshots: picomatch@4.0.7: {} + playwright-core@1.64.1: {} + + playwright@1.64.1: + dependencies: + playwright-core: 1.64.1 + possible-typed-array-names@1.1.0: {} postcss@8.5.32: diff --git a/src/Anichron.UI/tsconfig.e2e.json b/src/Anichron.UI/tsconfig.e2e.json new file mode 100644 index 00000000..3807f04b --- /dev/null +++ b/src/Anichron.UI/tsconfig.e2e.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "lib": ["es2023", "dom", "dom.iterable"], + "types": ["node"] + }, + "include": ["e2e"] +} diff --git a/src/Anichron.UI/tsconfig.json b/src/Anichron.UI/tsconfig.json index d32ff682..a999c8a6 100644 --- a/src/Anichron.UI/tsconfig.json +++ b/src/Anichron.UI/tsconfig.json @@ -1,4 +1,8 @@ { "files": [], - "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.e2e.json" } + ] } diff --git a/src/docker-compose.e2e.yml b/src/docker-compose.e2e.yml new file mode 100644 index 00000000..b5e018c9 --- /dev/null +++ b/src/docker-compose.e2e.yml @@ -0,0 +1,50 @@ +# ============================================================ +# Anichron — end-to-end runs (src/Anichron.UI/e2e) +# +# The API image built from this checkout, and PostgreSQL on +# tmpfs so every run starts from an empty database. No Worker +# runs until a flow needs media. +# +# docker build -f src/Anichron.API/Dockerfile -t anichron-api:e2e src +# docker compose -f src/docker-compose.e2e.yml up --detach --wait +# pnpm --dir src/Anichron.UI e2e +# docker compose -f src/docker-compose.e2e.yml down +# ============================================================ + +services: + anichron-db: + image: postgres:17-alpine + environment: + POSTGRES_USER: anichron_e2e + POSTGRES_PASSWORD: anichron_e2e + POSTGRES_DB: anichron + tmpfs: + - /var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U anichron_e2e -d anichron"] + interval: 2s + timeout: 5s + retries: 30 + + anichron-api: + image: ${E2E_API_IMAGE:-anichron-api:e2e} + ports: + - "${E2E_PORT:-8080}:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Production + - POSTGRES_CONNECTION__HOST=anichron-db + - POSTGRES_CONNECTION__DBNAME=anichron + - POSTGRES_CONNECTION__PORT=5432 + - POSTGRES_CONNECTION__USER=anichron_e2e + - POSTGRES_CONNECTION__PASSWORD=anichron_e2e + - Jwt__Secret=end-to-end-only-secret-at-least-32-bytes-long + - Jwt__Issuer=anichron + - Jwt__Audience=anichron + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/v1/healthz || exit 1"] + interval: 2s + timeout: 5s + retries: 30 + depends_on: + anichron-db: + condition: service_healthy