diff --git a/README.md b/README.md index 0b3f701..81a87a0 100644 --- a/README.md +++ b/README.md @@ -513,9 +513,13 @@ real Mailpit messages and uses the audited one-time bootstrap command inside only the isolated E2E database to create its moderator. It covers public/authentication boundaries; the urgent medicine flow through matching, realtime chat, handover, and -double-blind reviews; and the Activity flow through join approval, private +double-blind reviews; the Activity flow through join approval, private group chat, message-scoped reporting, blocking, privacy defaults, an unverified -social link, category moderation, report resolution, and account restriction. +social link, category moderation, report resolution, and account restriction; +and the support/legal flow through authenticated support, operator reply and +resolution, private requester status, general content removal, dedicated TAKE +IT DOWN intake, legal-queue processing, and decision email delivery through +the isolated Mailpit server. The same gate checks keyboard skip navigation, WCAG violations and contrast on four public pages in both themes, horizontal overflow at three viewport widths, an actual locally served raster map tile, LiveView offline/reconnect UI, public diff --git a/docs/public-launch-checklist.md b/docs/public-launch-checklist.md index 2e9323e..4a36a80 100644 --- a/docs/public-launch-checklist.md +++ b/docs/public-launch-checklist.md @@ -31,6 +31,12 @@ manifest-transport-only shell adjustment then passed `bash -n`, ShellCheck, and the complete production browser E2E replay. Exact identities and evidence paths are recorded in `docs/verification.md`. +A later 2026-08-09 local browser replay passed all 17 Chromium scenarios, +including the new end-to-end support and legal-removal workflow. Its focused +and full structured results are recorded in `docs/verification.md`. This is +local isolated evidence only and does not mark the production support/legal or +SMTP checklist items complete. + ## 2. Verify production configuration without exposing secrets Run both checks against the single ignored production `.env`. The first reports diff --git a/docs/support-and-content-removal.md b/docs/support-and-content-removal.md index 0aba95d..4a2cc7c 100644 --- a/docs/support-and-content-removal.md +++ b/docs/support-and-content-removal.md @@ -178,3 +178,20 @@ regardless. The software does not provide emergency response. Threats to life or safety are prioritized in the queue, while every public safety screen continues to direct people in immediate danger to local emergency services. + +## Reproducible browser verification + +The isolated browser topology exercises these queues without using a public +deployment or real SMTP provider: + +```bash +./scripts/e2e-run.sh tests/support-legal.spec.ts +``` + +The scenario registers a run-scoped requester, creates an isolated +administrator, submits authenticated support and both removal regimes, +verifies Mailpit messages, processes each permission-scoped queue, and checks +requester-visible status and decision delivery. The wrapper removes only its +unique Compose project, networks, database volume, and temporary image on +success, failure, or interrupt. This test does not establish staffing, legal +classification, or production email delivery. diff --git a/docs/verification.md b/docs/verification.md index d250066..58104c3 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -1,8 +1,45 @@ # Who Need Help — implementation verification -Observed through 2026-08-08 in the local workspace. This report separates observed +Observed through 2026-08-09 in the local workspace. This report separates observed results from product limits and unknown production properties. +## Local support/legal and complete browser E2E on 2026-08-09 + +- The focused isolated Chromium run exercised an authenticated support case, + Mailpit acknowledgement, permission-scoped staff discovery and assignment, + an operator reply and resolution, the requester's private case view, general + content-removal intake, the dedicated TAKE IT DOWN intake, legal-queue + processing, and the resulting decision email. Its one Playwright scenario + passed in 3.3 seconds. The structured result is retained at + `output/e2e/20260809055749-2064536/results.json`. +- The complete isolated Chromium suite then passed all 17 scenarios in 1.4 + minutes. In addition to support and legal operations, it covered public and + authenticated responsive/accessibility states, authentication and settings, + localisation, mutual-aid matching/chat/tracking/handover/reviews, + notifications and data export, request discovery and location privacy, + Activity moderation, LiveView reconnect behaviour, and active web-node + failover. The structured result is retained at + `output/e2e/20260809055914-2110249/results.json`. +- Both runs used unique Compose projects and independent PostGIS volumes. After + each run, an exact-name inspection found no remaining matching container, + network, volume, or image. The ordinary development, production, and frozen + hackathon-test projects were not recreated or changed. +- The subsequent complete isolated quality run passed ShellCheck, release, + rollback and migration drills, Dockerfile and workflow linting, compilation, + xref, Credo, Sobelow, Dialyzer, Hex and npm audits, 455 ExUnit tests, Compose, + Helm, observability, backup and image checks, and the configured Trivy scans + with zero reported HIGH/CRITICAL findings. Its user-systemd unit + `codex-heavy-wnh-support-legal-quality-20260809-20260809-090528-2300548.service` + completed in 2 minutes 14.077 seconds with a 280.6 MiB observed memory peak. + Exact-name inspection after its cleanup found no run-owned Compose resource + or image. + +These local results prove the implemented browser paths in the isolated E2E +topology. They do not prove production SMTP delivery, production queue routing, +or a staging deployment: the public staging runner now includes the same +support/legal scenario, but it was intentionally not run against the frozen +hackathon-test deployment. + ## Public/mobile and Android candidate check on 2026-08-03 - A headed Chrome audit captured the production home at desktop and 390 × 844 diff --git a/e2e/tests/support-legal.spec.ts b/e2e/tests/support-legal.spec.ts new file mode 100644 index 0000000..4d063fc --- /dev/null +++ b/e2e/tests/support-legal.spec.ts @@ -0,0 +1,258 @@ +import { APIRequestContext, expect, test } from "@playwright/test"; +import { + captureBrowserFailures, + gotoLiveView, + latestMessageID, + loginWithMagicLink, + loginWithPassword, + projectEmail, + registerAndConfirm, +} from "./helpers"; + +async function waitForNewEmail( + request: APIRequestContext, + email: string, + previousMessageID: string | undefined, + expectedText: string, +): Promise { + let messageID: string | undefined; + + await expect + .poll( + async () => { + messageID = await latestMessageID(request, email); + return messageID && messageID !== previousMessageID + ? messageID + : undefined; + }, + { + message: `waiting for a new application email for ${email}`, + timeout: 15_000, + }, + ) + .toBeTruthy(); + + const response = await request.get( + `${process.env.MAILPIT_URL}/api/v1/message/${messageID}`, + ); + expect(response.ok()).toBeTruthy(); + + const message = (await response.json()) as { Text?: string; HTML?: string }; + expect(`${message.Text ?? ""}\n${message.HTML ?? ""}`).toContain( + expectedText, + ); +} + +test("authenticated support and legal notices reach the scoped staff queues", async ({ + browser, + request, +}, testInfo) => { + const projectName = testInfo.project.name; + const requesterEmail = projectEmail("requester", projectName); + const fixturePassword = process.env.E2E_FIXTURE_PASSWORD; + const adminEmail = process.env.E2E_ADMIN_EMAIL ?? "e2e-admin@example.invalid"; + const supportSubject = `E2E support delivery [${projectName}]`; + const supportDetails = + "Browser E2E verifies the authenticated support receipt, private case, and staff reply."; + const supportReply = `E2E support reply [${projectName}]`; + const generalExplanation = + "Browser E2E verifies that a signed-in general removal notice reaches the legal queue."; + const urgentExplanation = + "Browser E2E verifies the urgent workflow without reproducing or uploading any material."; + const legalResolution = `E2E legal review completed [${projectName}]`; + + const requester = fixturePassword + ? await loginWithPassword(browser, requesterEmail, fixturePassword) + : await registerAndConfirm( + browser, + request, + requesterEmail, + "E2E Support Requester", + ); + const admin = fixturePassword + ? await loginWithPassword(browser, adminEmail, fixturePassword) + : await loginWithMagicLink(browser, request, adminEmail); + const assertRequesterClean = captureBrowserFailures(requester.page); + const assertAdminClean = captureBrowserFailures(admin.page); + + const supportEmailBefore = await latestMessageID(request, requesterEmail); + await requester.page.goto("/support"); + await requester.page + .getByLabel("What do you need help with?") + .selectOption("technical_issue"); + await expect(requester.page.getByLabel("Contact email")).toHaveValue( + requesterEmail, + ); + await expect(requester.page.getByLabel("Contact email")).toHaveAttribute( + "readonly", + "", + ); + await requester.page.getByLabel("Subject").fill(supportSubject); + await requester.page.getByLabel("Describe the problem").fill(supportDetails); + await requester.page + .getByRole("button", { name: "Send support request" }) + .click(); + await expect(requester.page).toHaveURL(/\/support\/received\?reference=SUP-/); + await expect( + requester.page.getByRole("heading", { name: "Support request created" }), + ).toBeVisible(); + await waitForNewEmail( + request, + requesterEmail, + supportEmailBefore, + supportSubject, + ); + + await gotoLiveView(admin.page, "/support/operations?queue=support"); + await admin.page + .locator("#support-case-filters") + .getByLabel("Search") + .fill(supportSubject); + const supportRow = admin.page + .locator("main tbody tr") + .filter({ hasText: supportSubject }); + await expect(supportRow).toHaveCount(1); + await supportRow.getByRole("link", { name: "Open" }).click(); + await expect( + admin.page.getByRole("heading", { name: supportSubject }), + ).toBeVisible(); + await admin.page.getByLabel("Case status").selectOption("resolved"); + await admin.page + .getByLabel("Reply to requester (optional)") + .fill(supportReply); + const replyEmailBefore = await latestMessageID(request, requesterEmail); + await admin.page.getByRole("button", { name: "Save and notify" }).click(); + await expect(admin.page.getByText("Support request updated.")).toBeVisible(); + await waitForNewEmail( + request, + requesterEmail, + replyEmailBefore, + supportReply, + ); + + await requester.page.goto("/support/requests"); + const requesterCase = requester.page + .locator("main") + .getByRole("link") + .filter({ hasText: supportSubject }); + await expect(requesterCase).toHaveCount(1); + await requesterCase.click(); + await expect( + requester.page.getByText(supportReply, { exact: true }), + ).toBeVisible(); + + const generalEmailBefore = await latestMessageID(request, requesterEmail); + await requester.page.goto("/legal/content-removal"); + await requester.page.getByLabel("Reason").selectOption("privacy_violation"); + await requester.page + .getByLabel("Your name or organisation") + .fill("E2E Requester"); + await requester.page + .getByLabel("Your relationship to the affected person or rights holder") + .selectOption("self"); + await requester.page + .getByLabel("Exact content URLs — one per line") + .fill(`${process.env.BASE_URL}/requests/e2e-reported-content`); + await requester.page + .getByLabel("Why do you believe this content should be removed?") + .fill(generalExplanation); + await requester.page + .getByLabel("Law, right, or policy involved, if known") + .fill("Privacy review requested by the affected account holder."); + await requester.page + .getByLabel("Electronic signature (type your full name)") + .fill("E2E Requester"); + await requester.page + .getByLabel(/I believe in good faith that the identified content/) + .check(); + await requester.page + .getByLabel(/I confirm that this notice is accurate and complete/) + .check(); + await requester.page + .getByRole("button", { name: "Submit removal notice" }) + .click(); + await expect(requester.page).toHaveURL( + /\/legal\/content-removal\/received\?reference=REM-/, + ); + await waitForNewEmail( + request, + requesterEmail, + generalEmailBefore, + "Status: open", + ); + + const urgentEmailBefore = await latestMessageID(request, requesterEmail); + await requester.page.goto("/legal/take-it-down"); + await requester.page + .getByLabel("Material involved") + .selectOption("non_consensual_intimate_media"); + await requester.page + .getByRole("textbox", { name: "Your full name", exact: true }) + .fill("E2E Requester"); + await requester.page + .getByLabel("Who are you submitting for?") + .selectOption("self"); + await requester.page + .getByLabel("Exact content URLs — one per line") + .fill(`${process.env.BASE_URL}/requests/e2e-urgent-reported-content`); + await requester.page + .getByLabel(/Identify the material without reproducing it/) + .fill(urgentExplanation); + await requester.page + .getByLabel("Electronic signature (type your full name)") + .fill("E2E Requester"); + await requester.page + .getByLabel(/I have a good-faith belief that this intimate visual material/) + .check(); + await requester.page + .getByLabel(/I confirm that the information in this request is accurate/) + .check(); + await requester.page + .getByRole("button", { name: "Submit urgent removal request" }) + .click(); + await expect(requester.page).toHaveURL( + /\/legal\/content-removal\/received\?reference=REM-/, + ); + await waitForNewEmail( + request, + requesterEmail, + urgentEmailBefore, + "Status: urgent_review", + ); + + await gotoLiveView(admin.page, "/support/operations?queue=legal"); + await admin.page + .locator("#legal-case-filters") + .getByLabel("Search") + .fill(requesterEmail); + await expect(admin.page.locator("main tbody tr")).toHaveCount(2); + const urgentRow = admin.page + .locator("main tbody tr") + .filter({ hasText: "take it down" }); + await expect(urgentRow).toHaveCount(1); + await urgentRow.getByRole("link", { name: "Open" }).click(); + await expect( + admin.page.getByText(urgentExplanation, { exact: true }), + ).toBeVisible(); + await admin.page.getByLabel("Case status").selectOption("actioned"); + await admin.page + .getByLabel("Decision or information request") + .fill(legalResolution); + const legalDecisionEmailBefore = await latestMessageID( + request, + requesterEmail, + ); + await admin.page.getByRole("button", { name: "Save and notify" }).click(); + await expect(admin.page.getByText("Removal notice updated.")).toBeVisible(); + await waitForNewEmail( + request, + requesterEmail, + legalDecisionEmailBefore, + legalResolution, + ); + + assertRequesterClean(); + assertAdminClean(); + await requester.context.close(); + await admin.context.close(); +}); diff --git a/scripts/ensure-local-e2e-env.sh b/scripts/ensure-local-e2e-env.sh index 297cfe8..1375c55 100755 --- a/scripts/ensure-local-e2e-env.sh +++ b/scripts/ensure-local-e2e-env.sh @@ -13,6 +13,23 @@ if [ -f "$target" ]; then chmod 600 "$target" updated=false + append_default_if_missing() { + key=$1 + value=$2 + + if ! grep -q "^${key}=" "$target"; then + printf '\n%s=%s\n' "$key" "$value" >>"$target" + updated=true + fi + } + + # Preserve generated secrets while bringing older ignored runtime files + # forward when new required, non-secret deployment selectors are added. + append_default_if_missing DEPLOYMENT_TARGET compose + append_default_if_missing DEPLOYMENT_ENV development + append_default_if_missing APP_TOPOLOGY split + append_default_if_missing DATABASE_MODE container + if ! grep -q '^TRAEFIK_RETRY_ATTEMPTS=' "$target"; then printf '\nTRAEFIK_RETRY_ATTEMPTS=3\n' >>"$target" updated=true diff --git a/scripts/staging-full-e2e-run.sh b/scripts/staging-full-e2e-run.sh index b616ac2..ca3759e 100755 --- a/scripts/staging-full-e2e-run.sh +++ b/scripts/staging-full-e2e-run.sh @@ -257,7 +257,8 @@ docker run --rm \ npx playwright test --project=chromium \ tests/auth-settings.spec.ts \ tests/mutual-aid.spec.ts \ - tests/activity-moderation.spec.ts + tests/activity-moderation.spec.ts \ + tests/support-legal.spec.ts echo "Full public staging E2E passed: $base_url" echo "Evidence: $output_dir"