From df6396df8adbac982c145d5e0629feaacedcd304 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Thu, 13 Aug 2026 07:28:59 +0300 Subject: [PATCH] Extend production E2E to staff queues --- docs/operations.md | 31 +++++ docs/verification.md | 31 +++++ e2e/tests/production-support-legal.spec.ts | 67 ++++++++++ lib/mix/tasks/wnh.staging_full_e2e.ex | 122 +++++++++++++++--- scripts/production-full-e2e.sh | 21 ++- .../staging_full_e2e_cleanup_test.exs | 71 +++++----- 6 files changed, 290 insertions(+), 53 deletions(-) create mode 100644 e2e/tests/production-support-legal.spec.ts diff --git a/docs/operations.md b/docs/operations.md index 6bd2db1..5c664eb 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1217,6 +1217,37 @@ Anonymous submissions receive the address-confirmation message before they enter the operator queue. Operator email alerts are disabled unless `SUPPORT_OPERATOR_EMAIL_MODE=immediate` is explicitly configured. +## Run-scoped production browser verification + +The production E2E workflow is opt-in and starts with a read-only identity and +scope check: + +```bash +./scripts/production-full-e2e.sh plan production-e2e-YYYYMMDD +``` + +The plan verifies the production checkout, compact Compose project, external +database identity, healthy application container, and absence of an existing +fixture with the requested run ID. It prints an exact confirmation value but +does not create users or records. + +After that exact scope has been reviewed and explicitly authorised, use the +printed value without changing the run ID: + +```bash +WNH_PRODUCTION_E2E_CONFIRM='VALUE_PRINTED_BY_PLAN' \ + ./scripts/production-full-e2e.sh run production-e2e-YYYYMMDD +``` + +The run creates six uniquely prefixed synthetic users and only their associated +mutual-aid, activity, notification, audit, support, and legal fixture records. +The support and legal records are inserted directly without email jobs and are +only read through the staff UI; the browser does not submit or moderate them. +Cleanup uses the mode-`0600` manifest of exact IDs on success, failure, or +interrupt, refuses cross-fixture relationships, deletes only matching jobs and +records, and verifies that the run prefix is absent. It never resets the +database. Evidence is stored below `output/production-full-e2e//`. + ## Production release without pushing the frozen repository The post-submission workflow keeps the public Git repository and diff --git a/docs/verification.md b/docs/verification.md index 77fcf75..4390965 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -3091,3 +3091,34 @@ promoted. Play-installed `org.whoneedhelp.mobile` package at `0.1.2 (3)`, target SDK 37, with installer `com.android.vending`. No package reinstall, data clear, or permission mutation was performed. + +# 2026-08-13 support/legal production-E2E boundary verification + +- The run-scoped production browser harness now prepares one verified support + request and one verified general content-removal notice for its synthetic + requester. Both rows are inserted directly without invoking notification or + mail contexts. The browser signs in as the run-scoped administrator, locates + each row through its permission-scoped queue, opens it read-only, and does + not submit an operator decision. +- The fixture manifest schema records the exact support and legal UUIDs. + Cleanup refuses a manifest/relationship mismatch, removes jobs addressed to + those exact records, verifies one deletion for each record, and leaves an + unrelated queued job intact. The focused cleanup regression test and the + complete 470-test ExUnit suite passed. +- The isolated quality unit + `codex-heavy-wnh-quality-support-legal-r2-20260813-071442-1645753.service` + completed successfully. Formatting, compilation, xref, Credo, Sobelow, + Dialyzer, dependency audits, image scans, Compose/Helm validation, migration, + release, rollback, backup, and observability gates passed; the scanned + runtime images reported zero high or critical vulnerabilities. +- The isolated browser E2E unit + `codex-heavy-wnh-browser-e2e-support-legal-20260813-071912-1795990.service` + completed with 63 passing tests and three expected production-only skips + across Chromium, Firefox, and WebKit. Exact post-run inspection found zero + containers, networks, volumes, or temporary images from its Compose scope. +- A read-only production plan observed the compact production application at + revision `dafcdb36cbe221af0c880fd05da3321e181ddd2c`, healthy with zero + restarts and no existing users for the proposed run prefix. The plan printed + the exact mutation and cleanup scope. No production E2E run was executed, + no production or frozen-test deployment was changed, and the public Git + remote was not pushed. diff --git a/e2e/tests/production-support-legal.spec.ts b/e2e/tests/production-support-legal.spec.ts new file mode 100644 index 0000000..55ec9de --- /dev/null +++ b/e2e/tests/production-support-legal.spec.ts @@ -0,0 +1,67 @@ +import { expect, test } from "@playwright/test"; +import { + captureBrowserFailures, + gotoLiveView, + loginWithPassword, + projectEmail, +} from "./helpers"; + +test.skip( + process.env.E2E_PRODUCTION_READ_ONLY !== "1", + "This read-only staff queue check requires the production run-scoped fixture", +); + +test("run-scoped support and legal fixtures are visible to production staff", async ({ + browser, +}, testInfo) => { + const runID = process.env.E2E_RUN_ID; + const fixturePassword = process.env.E2E_FIXTURE_PASSWORD; + const adminEmail = + process.env.E2E_ADMIN_EMAIL ?? projectEmail("admin", testInfo.project.name); + const requesterEmail = projectEmail("requester", testInfo.project.name); + + if (!runID || !fixturePassword) { + throw new Error("E2E_RUN_ID and E2E_FIXTURE_PASSWORD are required"); + } + + const supportSubject = `Production E2E support ${runID}`; + const supportDetails = + "Run-scoped read-only browser fixture for the production support queue."; + const removalExplanation = + "Run-scoped read-only browser fixture for the production legal review queue."; + + const admin = await loginWithPassword(browser, adminEmail, fixturePassword); + const assertAdminClean = captureBrowserFailures(admin.page); + + 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 expect( + admin.page.getByText(supportDetails, { exact: true }), + ).toBeVisible(); + + await gotoLiveView(admin.page, "/support/operations?queue=legal"); + await admin.page + .locator("#legal-case-filters") + .getByLabel("Search") + .fill(requesterEmail); + const legalRow = admin.page.locator("main tbody tr"); + await expect(legalRow).toHaveCount(1); + await legalRow.getByRole("link", { name: "Open" }).click(); + await expect( + admin.page.getByText(removalExplanation, { exact: true }), + ).toBeVisible(); + + assertAdminClean(); + await admin.context.close(); +}); diff --git a/lib/mix/tasks/wnh.staging_full_e2e.ex b/lib/mix/tasks/wnh.staging_full_e2e.ex index 039c939..6f8a210 100644 --- a/lib/mix/tasks/wnh.staging_full_e2e.ex +++ b/lib/mix/tasks/wnh.staging_full_e2e.ex @@ -142,32 +142,48 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do password_hash = Bcrypt.hash_pwd_salt(password) now = DateTime.utc_now(:second) - {:ok, users} = + {:ok, %{users: users, support_request: support_request, removal_notice: removal_notice}} = Repo.transaction(fn -> - Map.new(@precreated_roles, fn role -> - user = - insert_user!( - context.emails[role], - display_name(role), - password_hash, - now - ) + users = + Map.new(@precreated_roles, fn role -> + user = + insert_user!( + context.emails[role], + display_name(role), + password_hash, + now + ) - if role == "admin" do - %StaffRoleAssignment{} - |> StaffRoleAssignment.changeset(%{user_id: user.id, role: :admin}) - |> Repo.insert!() - end + if role == "admin" do + %StaffRoleAssignment{} + |> StaffRoleAssignment.changeset(%{user_id: user.id, role: :admin}) + |> Repo.insert!() + end - {role, %{"id" => user.id, "email" => user.email}} - end) + {role, %{"id" => user.id, "email" => user.email}} + end) + + requester = users["requester"] + + support_request = insert_support_fixture!(context, requester, now) + removal_notice = insert_removal_fixture!(context, requester, now) + + %{ + users: users, + support_request: support_request, + removal_notice: removal_notice + } end) manifest = %{ - "schema_version" => 1, + "schema_version" => 2, "run_id" => context.run_id, "database" => context.database, "precreated_users" => users, + "precreated_records" => %{ + "support_request" => support_request.id, + "content_removal_notice" => removal_notice.id + }, "allowed_emails" => context.emails |> Map.values() |> Enum.sort() } @@ -255,6 +271,12 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do support_request_ids = ids(SupportRequest, :requester_id, user_ids) content_removal_notice_ids = ids(Notice, :requester_id, user_ids) + validate_precreated_records!( + manifest, + support_request_ids, + content_removal_notice_ids + ) + validate_reviewed_records!(user_ids, report_ids, proposal_ids) validate_staff_reviewed_records!( @@ -442,9 +464,22 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do end end) - unless manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and + valid_precreated_records? = + case manifest["precreated_records"] do + %{ + "support_request" => support_request_id, + "content_removal_notice" => removal_notice_id + } -> + uuid?(support_request_id) and uuid?(removal_notice_id) + + _other -> + false + end + + unless manifest["schema_version"] == 2 and manifest["run_id"] == context.run_id and manifest["database"] == context.database and - manifest["allowed_emails"] == expected_emails and valid_precreated? do + manifest["allowed_emails"] == expected_emails and valid_precreated? and + valid_precreated_records? do Mix.raise("full staging E2E manifest does not match the requested run and database") end end @@ -460,6 +495,15 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do end end + defp validate_precreated_records!(manifest, support_request_ids, removal_notice_ids) do + records = manifest["precreated_records"] + + unless support_request_ids == [records["support_request"]] and + removal_notice_ids == [records["content_removal_notice"]] do + Mix.raise("full staging E2E precreated records do not match the manifest") + end + end + defp validate_assignments!(assignment_ids, request_ids, user_ids) do unexpected? = Repo.exists?( @@ -726,6 +770,46 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do |> Repo.insert!() end + defp insert_support_fixture!(context, requester, now) do + %SupportRequest{ + reference: "SUP-E2E-#{String.upcase(context.run_id)}", + requester_id: requester["id"], + contact_verified_at: now, + status: :open + } + |> SupportRequest.submission_changeset(%{ + "kind" => "privacy_request", + "contact_email" => requester["email"], + "subject" => "Production E2E support #{context.run_id}", + "details" => "Run-scoped read-only browser fixture for the production support queue." + }) + |> Repo.insert!() + end + + defp insert_removal_fixture!(context, requester, now) do + %Notice{ + reference: "REM-E2E-#{String.upcase(context.run_id)}", + requester_id: requester["id"], + contact_verified_at: now, + regime: :general, + status: :open + } + |> Notice.submission_changeset(%{ + "category" => "privacy_violation", + "submitter_name" => "Production E2E Requester", + "contact_email" => requester["email"], + "relationship" => "self", + "content_locations" => "https://whoneedhelp.com/requests/production-e2e-#{context.run_id}", + "explanation" => + "Run-scoped read-only browser fixture for the production legal review queue.", + "legal_basis" => "Production E2E fixture only.", + "electronic_signature" => "Production E2E Requester", + "good_faith" => "true", + "accurate_complete" => "true" + }) + |> Repo.insert!() + end + defp emails(run_id) do (@precreated_roles ++ @registered_roles) |> Map.new(fn role -> {role, "#{prefix(run_id)}#{role}@example.invalid"} end) diff --git a/scripts/production-full-e2e.sh b/scripts/production-full-e2e.sh index 198d44f..caaeeab 100755 --- a/scripts/production-full-e2e.sh +++ b/scripts/production-full-e2e.sh @@ -19,8 +19,9 @@ Usage: ./scripts/production-full-e2e.sh run [run-id] The run creates only run-scoped synthetic users and records, exercises the -two-user help and moderated activity browser flows, and removes the exact -fixture on success, failure, or interrupt. It never resets the database. +two-user help, moderated activity, and read-only staff support/legal browser +flows, and removes the exact fixture on success, failure, or interrupt. It +never resets the database. EOF } @@ -174,6 +175,7 @@ git cat-file -e "$commit^{commit}" 2>/dev/null || { for verifier_path in \ lib/mix/tasks/wnh.staging_full_e2e.ex \ e2e/tests/activity-moderation.spec.ts \ + e2e/tests/production-support-legal.spec.ts \ e2e/tests/helpers.ts; do if [[ ! -f "$ROOT/$verifier_path" ]]; then echo "Production E2E verifier is unavailable: $verifier_path" >&2 @@ -199,6 +201,8 @@ Exact temporary mutation scope: - six confirmed synthetic users under wnh-staging-e2e-$RUN_ID-*; - their help requests, assignments, chats, positions, handover, reviews; - their activity, participation, group chat, report and category proposal; + - one directly inserted support row and one directly inserted legal row, + read through the staff UI without submitting or moderating either record; - their notifications, audit events and associated Oban jobs; - no database reset, migration, real-user role/status change, email delivery, Caddy change, test-project change, payment, iOS, or KYC action. @@ -247,7 +251,7 @@ run_id=$2 docker exec "$container" /app/bin/who_need_help rpc ' alias WhoNeedHelp.Repo prefix = "wnh-staging-e2e-'"$run_id"'-%" - tables = ~w(users help_requests help_assignments messages tracking_sessions tracking_positions reviews activities activity_participants activity_messages reports category_proposals category_votes audit_events notifications oban_jobs) + tables = ~w(users help_requests help_assignments messages tracking_sessions tracking_positions reviews activities activity_participants activity_messages reports category_proposals category_votes audit_events notifications support_requests content_removal_notices oban_jobs) counts = Map.new(tables, fn table -> result = Repo.query!("SELECT count(*) FROM #{table}", [], log: false) @@ -314,7 +318,11 @@ cleanup() { ! jq -e ' .cleanup_verified == true and (.cleanup_targets.users | length) >= 6 and + (.cleanup_targets.support_requests | length) == 1 and + (.cleanup_targets.content_removal_notices | length) == 1 and (.cleanup_deleted_counts.users | type) == "number" and + .cleanup_deleted_counts.support_requests == 1 and + .cleanup_deleted_counts.content_removal_notices == 1 and .cleanup_verified_at != null ' "$output_dir/fixture.json" >/dev/null; then echo "Production E2E cleanup manifest lacks exact run-scoped verification." >&2 @@ -351,10 +359,13 @@ cp "$ROOT/lib/mix/tasks/wnh.staging_full_e2e.ex" \ "$archive_dir/lib/mix/tasks/wnh.staging_full_e2e.ex" cp "$ROOT/e2e/tests/activity-moderation.spec.ts" \ "$archive_dir/e2e/tests/activity-moderation.spec.ts" +cp "$ROOT/e2e/tests/production-support-legal.spec.ts" \ + "$archive_dir/e2e/tests/production-support-legal.spec.ts" cp "$ROOT/e2e/tests/helpers.ts" "$archive_dir/e2e/tests/helpers.ts" sha256sum \ "$ROOT/lib/mix/tasks/wnh.staging_full_e2e.ex" \ "$ROOT/e2e/tests/activity-moderation.spec.ts" \ + "$ROOT/e2e/tests/production-support-legal.spec.ts" \ "$ROOT/e2e/tests/helpers.ts" \ >"$output_dir/harness-sha256.txt" # This script starts with umask 077 so evidence and credentials remain private. @@ -382,11 +393,13 @@ docker run --rm \ --env "E2E_RUN_ID=$RUN_ID" \ --env "E2E_FIXTURE_PASSWORD=$fixture_password" \ --env "E2E_ADMIN_EMAIL=wnh-staging-e2e-$RUN_ID-admin@example.invalid" \ + --env E2E_PRODUCTION_READ_ONLY=1 \ --env HOME=/tmp \ --volume "$output_dir/browser:/work/output" \ "$browser_image" \ npx playwright test --project=chromium \ - tests/mutual-aid.spec.ts tests/activity-moderation.spec.ts | + tests/mutual-aid.spec.ts tests/activity-moderation.spec.ts \ + tests/production-support-legal.spec.ts | tee "$output_dir/browser-console.log" curl --fail --silent --show-error --max-time 10 "$BASE_URL/healthz/ready" \ diff --git a/test/who_need_help/staging_full_e2e_cleanup_test.exs b/test/who_need_help/staging_full_e2e_cleanup_test.exs index bc14039..d2d049b 100644 --- a/test/who_need_help/staging_full_e2e_cleanup_test.exs +++ b/test/who_need_help/staging_full_e2e_cleanup_test.exs @@ -5,8 +5,7 @@ defmodule WhoNeedHelp.StagingFullE2ECleanupTest do import ExUnit.CaptureIO alias Oban.Job - alias WhoNeedHelp.Accounts.{Scope, User} - alias WhoNeedHelp.{ContentRemoval, Repo, Support} + alias WhoNeedHelp.Repo alias WhoNeedHelp.Mail.{ContentRemovalEmailWorker, SupportConfirmationWorker} alias WhoNeedHelp.Push.NearbyMatchWorker @@ -40,44 +39,48 @@ defmodule WhoNeedHelp.StagingFullE2ECleanupTest do run_task("prepare") - requester = - Repo.get_by!(User, email: "wnh-staging-e2e-#{run_id}-requester@example.invalid") + manifest = manifest_path |> File.read!() |> Jason.decode!() - scope = Scope.for_user(requester) + prepared_support_request = + Repo.get!( + WhoNeedHelp.Support.SupportRequest, + manifest["precreated_records"]["support_request"] + ) - assert {:ok, support_request} = - Support.create_request(scope, %{ - "kind" => "technical_issue", - "subject" => "Run-scoped support cleanup", - "details" => "Verify that the exact support mail job is removed with its fixture." - }) + prepared_removal_notice = + Repo.get!( + WhoNeedHelp.ContentRemoval.Notice, + manifest["precreated_records"]["content_removal_notice"] + ) + + assert manifest["schema_version"] == 2 + assert prepared_support_request.subject == "Production E2E support #{run_id}" + assert prepared_support_request.contact_verified_at + assert prepared_removal_notice.regime == :general + assert prepared_removal_notice.contact_verified_at + + refute_enqueued( + worker: WhoNeedHelp.Mail.SupportOperatorAlertWorker, + args: %{"request_id" => prepared_support_request.id} + ) + + refute_enqueued( + worker: ContentRemovalEmailWorker, + args: %{"notice_id" => prepared_removal_notice.id} + ) support_job = - %{request_id: support_request.id} + %{request_id: prepared_support_request.id} |> SupportConfirmationWorker.new() |> Repo.insert!() - assert {:ok, notice} = - ContentRemoval.create_notice(scope, :general, %{ - "category" => "privacy_violation", - "submitter_name" => "Fixture requester", - "relationship" => "self", - "content_locations" => "https://example.test/requests/run-scoped-cleanup", - "explanation" => - "Verify that the exact content-removal mail job is removed with its fixture.", - "electronic_signature" => "Fixture requester", - "good_faith" => "true", - "accurate_complete" => "true" - }) - legal_job = - Repo.get_by!(Job, - worker: inspect(ContentRemovalEmailWorker), - args: %{"notice_id" => notice.id, "kind" => "received"} - ) + %{notice_id: prepared_removal_notice.id, kind: "received"} + |> ContentRemovalEmailWorker.new() + |> Repo.insert!() unrelated_job = - %{request_id: support_request.id, event_key: "created"} + %{request_id: prepared_support_request.id, event_key: "created"} |> NearbyMatchWorker.new() |> Repo.insert!() @@ -86,6 +89,14 @@ defmodule WhoNeedHelp.StagingFullE2ECleanupTest do manifest = manifest_path |> File.read!() |> Jason.decode!() cleaned_job_ids = manifest["cleanup_targets"]["push_jobs"] + assert manifest["cleanup_targets"]["support_requests"] == [prepared_support_request.id] + + assert manifest["cleanup_targets"]["content_removal_notices"] == [ + prepared_removal_notice.id + ] + + assert manifest["cleanup_deleted_counts"]["support_requests"] == 1 + assert manifest["cleanup_deleted_counts"]["content_removal_notices"] == 1 assert support_job.id in cleaned_job_ids assert legal_job.id in cleaned_job_ids refute unrelated_job.id in cleaned_job_ids