diff --git a/docs/local-hardening-plan.md b/docs/local-hardening-plan.md index 7b626f9..f9c77cd 100644 --- a/docs/local-hardening-plan.md +++ b/docs/local-hardening-plan.md @@ -119,13 +119,15 @@ The goal remains open while any row lacks reproducible local evidence. project, volume, network, and one-run images. FCM/APNs device registration is not claimed. - The final regression repeated the 174-test quality/security gate, isolated - browser 1/1 bootstrap plus 8/8 Chromium specs, Android debug/staging builds - and 7/7 device tests on API 30, 34, and 37, the 50,000-row database benchmark, authenticated - HTTP/LiveView/WebSocket/tracking load, Compose crash/replacement and kind - rolling drills, alert firing/resolution, encrypted backup/restore failure - paths, and the external protocol boundary. Scoped fixtures and one-run - projects were removed, retained evidence is ignored and non-secret, and Git - was clean at the tested application commit before this documentation update. + browser 1/1 bootstrap plus 30/30 application scenarios across Chromium, + Firefox and WebKit, the public 3/3 full workflow replay and 1/1 PWA replay, + Android debug/staging builds and 7/7 device tests on API 30, 34, and 37, the + 50,000-row database benchmark, authenticated HTTP/LiveView/WebSocket/tracking + load, Compose crash/replacement and kind rolling drills, alert + firing/resolution, encrypted backup/restore failure paths, and the external + protocol boundary. Scoped fixtures and one-run projects were removed, + retained evidence is ignored and non-secret, and Git was clean at the tested + application commit before this documentation update. - The public-facing database backup also passes a full isolated upgrade rehearsal against application commit `af9018f`: the restored copy advanced from 8 to all 10 migrations, exposed all 11 valid cursor indexes, ran two web diff --git a/docs/verification.md b/docs/verification.md index 50f5473..225fff4 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -768,6 +768,38 @@ initial resource set. The harness now waits for old UIDs to disappear, captures the exact four replacement pod names, and waits for those resources. The fixed full replay above passed. +## Final public and browser replay after one-time-link hardening + +The final public replay exposed a transport edge case specific to one-time +confirmation links: the server had committed the email change and consumed the +token before Chromium reported `ERR_NETWORK_CHANGED`, while the generic +navigation helper then replayed the already-consumed URL. The browser harness +now opens one-time links at most once. If that single navigation loses its +response to a transient transport reset, it verifies the committed account +state through the idempotent settings page instead of replaying the token. + +- The complete public Chromium replay passed 3/3 against + `https://whoneedhelp.imalto.site`: registration and email/password changes; + medicine request, helper matching, two-way chat, browser location tracking, + marker deletion, handover and blind reviews; Activities, privacy, social + links, blocks, reports, category voting and moderation. Its cleanup restored + all 19 recorded application-table counts byte-for-byte and removed the + run-specific Mailpit messages. Evidence is + `output/staging-full-e2e/20260719235055-542603`. +- The fresh isolated browser matrix passed bootstrap 1/1 and 30/30 application + scenarios, 10 each in Chromium, Firefox and WebKit, including realtime + recovery after the serving BEAM node was restarted. The run-scoped + containers, networks and PostgreSQL volume were removed. Evidence is + `output/e2e/20260719235159-569218`. +- The public HTTPS PWA replay passed 1/1, covering manifest and install assets, + service-worker cache update, offline public fallback, and exclusion of + private application pages from the offline cache. Evidence is + `output/staging-pwa-e2e/20260719235520-665802`. +- `./scripts/quality.sh` passed again on the same source state with 174/174 + ExUnit tests and all configured source, dependency, Compose, Helm, + observability and container-image security gates. Evidence is + `output/regression/final-single-use-quality-20260720.log`. + ## Known work before a public production launch - Replace the temporary staging origin with the production-owned domain and diff --git a/e2e/tests/auth-settings.spec.ts b/e2e/tests/auth-settings.spec.ts index 7231814..6d60af2 100644 --- a/e2e/tests/auth-settings.spec.ts +++ b/e2e/tests/auth-settings.spec.ts @@ -3,6 +3,7 @@ import { captureBrowserFailures, clickUntilVisible, gotoWithTransientRetry, + isTransientNetworkError, loginWithPassword, projectEmail, registerAndConfirm, @@ -22,7 +23,10 @@ test("registration, email confirmation, password, and email change work end to e originalEmail, "E2E Auth User", ); - const assertBrowserClean = captureBrowserFailures(user.page); + const recoverableOneTimeNavigations = new Set(); + const assertBrowserClean = captureBrowserFailures(user.page, { + recoverableOneTimeNavigations, + }); await user.page.goto("/users/settings"); const passwordForm = user.page.locator("#update_password"); @@ -40,15 +44,31 @@ test("registration, email confirmation, password, and email change work end to e user.page.getByText("A link to confirm your email change has been sent"), ); - await gotoWithTransientRetry( - user.page, - await waitForApplicationEmailLink( - request, - changedEmail, - "/users/settings/confirm-email/", - ), + const emailChangeLink = await waitForApplicationEmailLink( + request, + changedEmail, + "/users/settings/confirm-email/", ); - await expect(user.page.getByText("Email changed successfully.")).toBeVisible(); + recoverableOneTimeNavigations.add(emailChangeLink); + + try { + await user.page.goto(emailChangeLink); + await expect(user.page.getByText("Email changed successfully.")).toBeVisible(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + if (!isTransientNetworkError(message)) { + throw error; + } + + // The server may commit and consume the single-use token before a + // transport reset reaches the browser. Never replay that URL: verify the + // committed account state through an idempotent page instead. + await gotoWithTransientRetry(user.page, "/users/settings"); + await expect(user.page.locator("#update_email").getByLabel("Email")).toHaveValue( + changedEmail, + ); + } await user.context.close(); diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index 85004bd..7c0907f 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -67,7 +67,7 @@ export async function gotoWithTransientRetry( throw lastError; } -function isTransientNetworkError(message: string): boolean { +export function isTransientNetworkError(message: string): boolean { return [ "ERR_NETWORK_CHANGED", "ERR_CONNECTION_RESET", @@ -147,7 +147,10 @@ export async function markerKinds(page: Page): Promise { return markers.map(({ title }) => title ?? ""); } -export function captureBrowserFailures(page: Page): () => void { +export function captureBrowserFailures( + page: Page, + options: { recoverableOneTimeNavigations?: Set } = {}, +): () => void { const failures: string[] = []; page.on("console", (message) => { @@ -173,13 +176,20 @@ export function captureBrowserFailures(page: Page): () => void { cancelled && new URL(request.url()).origin === new URL(baseURL).origin && ["script", "stylesheet"].includes(request.resourceType()); + const recoverableOneTimeNavigation = + options.recoverableOneTimeNavigations?.has(request.url()) === true && + isTransientNetworkError(failure); // MapLibre cancels in-flight raster tiles when a LiveView navigation // destroys or re-centres a map. Chromium also aborts the old document's // static assets when a bounded navigation retry replaces that document. // Actual tile/network failures retain their engine error and remain test // failures. - if ((rasterTile && cancelled) || replacedDocumentAsset) { + if ( + (rasterTile && cancelled) || + replacedDocumentAsset || + recoverableOneTimeNavigation + ) { return; }