From f31db3e2499c7a4c87024bbcf75f0e5812cb1478 Mon Sep 17 00:00:00 2001 From: bitbiter-dev Date: Fri, 30 Oct 2026 16:56:05 +1300 Subject: [PATCH 1/4] feat(api): Serve the UI from the web root with caching and security headers (#359) Hashed /assets are immutable, every other file is no-cache, and a GET or HEAD to a path without an extension serves index.html. An unknown /api path is a JSON 404 and a known route with the wrong method stays 405, since the fallbacks run as middleware after routing rather than as endpoints. Every response carries the security headers: the full CSP with Trusted Types outside /api, nosniff and frame-ancestors on /api. Co-Authored-By: Claude Opus 5.5 --- .../Anichron.API.Tests.Unit.csproj | 4 + .../Infrastructure/ApiTestHost.cs | 4 +- .../Infrastructure/SecurityHeadersTests.cs | 67 ++++++++ .../TestWebRoot/assets/index-Bx7f3k9q.js | 1 + .../Infrastructure/TestWebRoot/index.html | 1 + .../TestWebRoot/manifest.webmanifest | 1 + .../Infrastructure/TestWebRoot/sw.js | 1 + .../Infrastructure/UiServingTests.cs | 161 ++++++++++++++++++ src/Anichron.API/Infrastructure/ApiPaths.cs | 3 +- .../Infrastructure/SecurityHeaders.cs | 48 ++++++ src/Anichron.API/Infrastructure/UiServing.cs | 41 +++++ src/Anichron.API/Program.cs | 3 + 12 files changed, 332 insertions(+), 3 deletions(-) create mode 100644 src/Anichron.API.Tests.Unit/Infrastructure/SecurityHeadersTests.cs create mode 100644 src/Anichron.API.Tests.Unit/Infrastructure/TestWebRoot/assets/index-Bx7f3k9q.js create mode 100644 src/Anichron.API.Tests.Unit/Infrastructure/TestWebRoot/index.html create mode 100644 src/Anichron.API.Tests.Unit/Infrastructure/TestWebRoot/manifest.webmanifest create mode 100644 src/Anichron.API.Tests.Unit/Infrastructure/TestWebRoot/sw.js create mode 100644 src/Anichron.API.Tests.Unit/Infrastructure/UiServingTests.cs create mode 100644 src/Anichron.API/Infrastructure/SecurityHeaders.cs create mode 100644 src/Anichron.API/Infrastructure/UiServing.cs 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..308386a3 --- /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 readonly 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/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(); From f7e044561fd5619e724d52fc791ef55dd5f124ed Mon Sep 17 00:00:00 2001 From: bitbiter-dev Date: Fri, 30 Oct 2026 17:08:00 +1300 Subject: [PATCH 2/4] feat(ui): The API image serves the UI, checked end to end in three browsers (#359) The API Dockerfile builds the UI in a Node stage on the build platform and copies it to the web root. The final stage creates the INI folder for the app user: without it the non-root image could not write app.ini and did not start. A Playwright smoke test loads the placeholder from the image, started with PostgreSQL on tmpfs by the e2e compose file, in WebKit (iPhone), Chromium (Pixel) and Firefox (phone viewport). It asserts the headers, the assets' caching, no CSP or Trusted Types violation, no request to another origin and no axe violation. The E2E job runs it against the amd64 image built from the PR and is in the CI gate's needs. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 59 ++++++++++++++++++++++++++- CLAUDE.md | 5 ++- docs/architecture/ui-serving.md | 60 ++++++++++++++++++++++++++++ src/Anichron.API/Dockerfile | 22 ++++++++-- src/Anichron.UI/.gitignore | 2 + src/Anichron.UI/.prettierignore | 2 + src/Anichron.UI/e2e/smoke.spec.ts | 53 ++++++++++++++++++++++++ src/Anichron.UI/eslint.config.js | 9 ++++- src/Anichron.UI/package.json | 5 ++- src/Anichron.UI/playwright.config.ts | 19 +++++++++ src/Anichron.UI/pnpm-lock.yaml | 42 +++++++++++++++++++ src/Anichron.UI/tsconfig.e2e.json | 8 ++++ src/Anichron.UI/tsconfig.json | 6 ++- src/docker-compose.e2e.yml | 50 +++++++++++++++++++++++ 14 files changed, 333 insertions(+), 9 deletions(-) create mode 100644 docs/architecture/ui-serving.md create mode 100644 src/Anichron.UI/e2e/smoke.spec.ts create mode 100644 src/Anichron.UI/playwright.config.ts create mode 100644 src/Anichron.UI/tsconfig.e2e.json create mode 100644 src/docker-compose.e2e.yml 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..a0fa62e0 --- /dev/null +++ b/docs/architecture/ui-serving.md @@ -0,0 +1,60 @@ +# 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; Dependabot's +`docker` entry for `src/Anichron.API` bumps that tag, so a Node bump moves both together. 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. 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. +Vite's built output runs under Trusted Types (its module-preload helper sets no sink), so +`require-trusted-types-for 'script'` stays. 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/Dockerfile b/src/Anichron.API/Dockerfile index c4466bf9..76031d0f 100644 --- a/src/Anichron.API/Dockerfile +++ b/src/Anichron.API/Dockerfile @@ -3,7 +3,19 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS base WORKDIR /app EXPOSE 8080 -# 2. Build Stage +# 2. UI Stage +# Built on $BUILDPLATFORM like the .NET stage: the bundle is plain files, the +# same for every target arch. The tag matches .nvmrc so pnpm (devEngines) +# does not download a different Node. +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 +43,19 @@ 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 + +# AddAppConfiguration writes configuration/app.ini under the content root at start. +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.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..b328237a --- /dev/null +++ b/src/Anichron.UI/e2e/smoke.spec.ts @@ -0,0 +1,53 @@ +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([]); +}); 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 From 0193dd7113712016131043ce071ca8c1fc111d1a Mon Sep 17 00:00:00 2001 From: bitbiter-dev Date: Fri, 30 Oct 2026 17:13:01 +1300 Subject: [PATCH 3/4] test(e2e): The real image's /api answers a JSON 404 with the API headers (#359) Pins the middleware order in Program.cs, which the in-process tests compose themselves. Also notes that a client route never ends in a dot. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/ui-serving.md | 2 +- .../Infrastructure/UiServingTests.cs | 2 +- src/Anichron.UI/e2e/smoke.spec.ts | 11 +++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/architecture/ui-serving.md b/docs/architecture/ui-serving.md index a0fa62e0..8ef25cad 100644 --- a/docs/architecture/ui-serving.md +++ b/docs/architecture/ui-serving.md @@ -28,7 +28,7 @@ middleware that runs after routing, only for a request no endpoint matched: 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. The web root is read +`/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 diff --git a/src/Anichron.API.Tests.Unit/Infrastructure/UiServingTests.cs b/src/Anichron.API.Tests.Unit/Infrastructure/UiServingTests.cs index 308386a3..d0d9adfb 100644 --- a/src/Anichron.API.Tests.Unit/Infrastructure/UiServingTests.cs +++ b/src/Anichron.API.Tests.Unit/Infrastructure/UiServingTests.cs @@ -8,7 +8,7 @@ namespace Anichron.API.Tests.Unit.Infrastructure; public sealed class UiServingTests { - private static readonly string TestWebRoot = Path.Combine(AppContext.BaseDirectory, "Infrastructure", "TestWebRoot"); + private static string TestWebRoot => Path.Combine(AppContext.BaseDirectory, "Infrastructure", "TestWebRoot"); private static async Task StartAsync(string webRoot) { diff --git a/src/Anichron.UI/e2e/smoke.spec.ts b/src/Anichron.UI/e2e/smoke.spec.ts index b328237a..72f0faca 100644 --- a/src/Anichron.UI/e2e/smoke.spec.ts +++ b/src/Anichron.UI/e2e/smoke.spec.ts @@ -51,3 +51,14 @@ test('the placeholder loads with its headers and requests nothing but its own or 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'); +}); From 07000a63cea4cc14ddfdd14963ee22960a32af52 Mon Sep 17 00:00:00 2001 From: bitbiter-dev Date: Fri, 30 Oct 2026 17:19:11 +1300 Subject: [PATCH 4/4] docs(ui): Say which pins move with a Node bump, and what the Trusted Types check covers (#359) Co-Authored-By: Claude Opus 5.5 --- docs/architecture/ui-serving.md | 13 +++++++++---- src/Anichron.API/Dockerfile | 5 ----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/architecture/ui-serving.md b/docs/architecture/ui-serving.md index 8ef25cad..c4b00cb3 100644 --- a/docs/architecture/ui-serving.md +++ b/docs/architecture/ui-serving.md @@ -7,8 +7,11 @@ the headers every response carries. 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; Dependabot's -`docker` entry for `src/Anichron.API` bumps that tag, so a Node bump moves both together. A +`.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. @@ -43,8 +46,10 @@ and the fallback `404`s. - **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. -Vite's built output runs under Trusted Types (its module-preload helper sets no sink), so -`require-trusted-types-for 'script'` stays. A feature that needs a browser permission (fullscreen +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 diff --git a/src/Anichron.API/Dockerfile b/src/Anichron.API/Dockerfile index 76031d0f..7650d825 100644 --- a/src/Anichron.API/Dockerfile +++ b/src/Anichron.API/Dockerfile @@ -4,9 +4,6 @@ WORKDIR /app EXPOSE 8080 # 2. UI Stage -# Built on $BUILDPLATFORM like the .NET stage: the bundle is plain files, the -# same for every target arch. The tag matches .nvmrc so pnpm (devEngines) -# does not download a different Node. FROM --platform=$BUILDPLATFORM node:26.13.0-alpine AS ui WORKDIR /ui RUN npm install --global pnpm@12.16.1 @@ -53,8 +50,6 @@ FROM base AS final WORKDIR /app COPY --from=publish /app/publish . COPY --from=ui /ui/dist ./wwwroot - -# AddAppConfiguration writes configuration/app.ini under the content root at start. RUN mkdir configuration && chown $APP_UID configuration # Use the non-privileged user provided by the base image