Extend production E2E to staff queues

This commit is contained in:
SimpleTest 2026-08-13 07:28:59 +03:00
parent 7908189e40
commit df6396df8a
6 changed files with 290 additions and 53 deletions

View File

@ -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/<run-id>/`.
## Production release without pushing the frozen repository
The post-submission workflow keeps the public Git repository and

View File

@ -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.

View File

@ -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();
});

View File

@ -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)

View File

@ -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" \

View File

@ -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