Extend production E2E to staff queues
This commit is contained in:
parent
7908189e40
commit
df6396df8a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
67
e2e/tests/production-support-legal.spec.ts
Normal file
67
e2e/tests/production-support-legal.spec.ts
Normal 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();
|
||||
});
|
||||
|
|
@ -142,8 +142,9 @@ 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 ->
|
||||
users =
|
||||
Map.new(@precreated_roles, fn role ->
|
||||
user =
|
||||
insert_user!(
|
||||
|
|
@ -161,13 +162,28 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do
|
|||
|
||||
{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)
|
||||
|
|
|
|||
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user