diff --git a/android/play-store/release-checklist.md b/android/play-store/release-checklist.md index 69adf9e..aed6cf9 100644 --- a/android/play-store/release-checklist.md +++ b/android/play-store/release-checklist.md @@ -200,10 +200,11 @@ block publishing changes, including Store Listing, Pricing, and Distribution. - [ ] Tester feedback and fixes documented. - [ ] Production-access questionnaire completed from actual evidence. -On 2026-08-14 Play Console showed Closed testing locked until the one remaining -app-setup task, public contact details, is complete and reported `0 testers -currently opted-in`. No Closed or Production release was created during this -inspection. +On 2026-08-20 Play Console still showed Closed testing locked until the one +remaining app-setup task, public contact details, is complete and reported `0 +testers currently opted-in`. The Console still required at least 12 opted-in +testers for at least 14 days. No Closed or Production release was created +during this inspection. ## Publishing diff --git a/android/play-store/store-presence-runbook.md b/android/play-store/store-presence-runbook.md index 8000d5d..d967a3f 100644 --- a/android/play-store/store-presence-runbook.md +++ b/android/play-store/store-presence-runbook.md @@ -36,6 +36,21 @@ does not authorize saving fields in Play Console or publishing a release. This was a read-only observation. No Console value, track, release, production deployment, frozen test deployment, or public Git remote was changed. +## Read-only recheck on 2026-08-20 + +- The Dashboard still reports **10 of 11 complete**. +- **Select an app category and provide contact details** remains the only + incomplete setup task. The category is **Social**. Public email, phone, and + website remain empty in Store settings. +- Closed testing remains locked until app setup is complete and still reports + `0 testers currently opted-in`. +- The production-access section currently requires a published closed-testing + release, at least 12 opted-in testers, and a closed test lasting at least 14 + days. + +This recheck was read-only. No Console field, release, track, deployment, +frozen test environment, or public Git remote was changed. + These are direct observations from the authenticated Play Console session on that date. Recheck the Console before applying because its fields and policy requirements can change. diff --git a/docs/public-launch-checklist.md b/docs/public-launch-checklist.md index 84b3c47..9fed457 100644 --- a/docs/public-launch-checklist.md +++ b/docs/public-launch-checklist.md @@ -121,6 +121,10 @@ The following decisions are intentionally not generated by code: allocation, alerts, and any scaling thresholds. - [ ] Backup ownership, encryption-key custody, off-site destination, restore procedure, and measured recovery objectives have been approved. +- [x] The independent stale-backup alert uses the operator-selected maximum + age of 36 hours (`129600` seconds). This is an alert threshold for the + latest restore-verified heartbeat, not approval of retention, RPO, RTO, + capacity, or encryption-key custody. Until the retention decision and tested executor exist, account-deletion cases remain an audited operator workflow and automatic erasure stays disabled. The diff --git a/e2e/tests/production-support-legal.spec.ts b/e2e/tests/production-support-legal.spec.ts index 55ec9de..a839e3f 100644 --- a/e2e/tests/production-support-legal.spec.ts +++ b/e2e/tests/production-support-legal.spec.ts @@ -6,12 +6,35 @@ import { projectEmail, } from "./helpers"; +async function submitSupportRequest( + page: import("@playwright/test").Page, + requesterEmail: string, + kind: "privacy_request" | "data_export", + subject: string, + details: string, +): Promise { + await page.goto("/support"); + await page.getByLabel("What do you need help with?").selectOption(kind); + await expect(page.getByLabel("Contact email")).toHaveValue(requesterEmail); + await expect(page.getByLabel("Contact email")).toHaveAttribute( + "readonly", + "", + ); + await page.getByLabel("Subject").fill(subject); + await page.getByLabel("Describe the problem").fill(details); + await page.getByRole("button", { name: "Send support request" }).click(); + await expect(page).toHaveURL(/\/support\/received\?reference=SUP-/); + await expect( + page.getByRole("heading", { name: "Support request created" }), + ).toBeVisible(); +} + test.skip( - process.env.E2E_PRODUCTION_READ_ONLY !== "1", - "This read-only staff queue check requires the production run-scoped fixture", + process.env.E2E_PRODUCTION_RUN_SCOPED !== "1", + "This support intake and staff queue check requires the production run-scoped fixture", ); -test("run-scoped support and legal fixtures are visible to production staff", async ({ +test("run-scoped support intake and legal fixture reach production staff", async ({ browser, }, testInfo) => { const runID = process.env.E2E_RUN_ID; @@ -27,13 +50,69 @@ test("run-scoped support and legal fixtures are visible to production staff", as const supportSubject = `Production E2E support ${runID}`; const supportDetails = "Run-scoped read-only browser fixture for the production support queue."; + const privacySubject = `Production E2E privacy ${runID}`; + const dataExportSubject = `Production E2E data export ${runID}`; + const accountDeletionSubject = "Delete my Who Need Help account"; const removalExplanation = "Run-scoped read-only browser fixture for the production legal review queue."; + const requester = await loginWithPassword( + browser, + requesterEmail, + fixturePassword, + ); const admin = await loginWithPassword(browser, adminEmail, fixturePassword); + const assertRequesterClean = captureBrowserFailures(requester.page); const assertAdminClean = captureBrowserFailures(admin.page); + await submitSupportRequest( + requester.page, + requesterEmail, + "privacy_request", + privacySubject, + "Run-scoped production browser verification for the authenticated privacy-request intake.", + ); + await submitSupportRequest( + requester.page, + requesterEmail, + "data_export", + dataExportSubject, + "Run-scoped production browser verification for the authenticated data-export intake.", + ); + + await requester.page.goto("/account/delete"); + await expect(requester.page.getByLabel("Account email")).toHaveValue( + requesterEmail, + ); + await expect(requester.page.getByLabel("Account email")).toHaveAttribute( + "readonly", + "", + ); + await requester.page + .getByLabel("Additional information") + .fill( + "Run-scoped production browser verification for the account-deletion support workflow.", + ); + await requester.page + .getByRole("button", { name: "Request account deletion" }) + .click(); + await expect(requester.page).toHaveURL(/\/support\/received\?reference=SUP-/); + await gotoLiveView(admin.page, "/support/operations?queue=support"); + await admin.page + .locator("#support-case-filters") + .getByLabel("Search") + .fill(requesterEmail); + const supportRows = admin.page.locator("main tbody tr"); + await expect(supportRows).toHaveCount(4); + await expect(supportRows.filter({ hasText: privacySubject })).toHaveCount(1); + await expect(supportRows.filter({ hasText: dataExportSubject })).toHaveCount( + 1, + ); + await expect( + supportRows.filter({ hasText: accountDeletionSubject }), + ).toHaveCount(1); + await admin.page .locator("#support-case-filters") .getByLabel("Search") @@ -62,6 +141,8 @@ test("run-scoped support and legal fixtures are visible to production staff", as admin.page.getByText(removalExplanation, { exact: true }), ).toBeVisible(); + assertRequesterClean(); assertAdminClean(); + await requester.context.close(); 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 6f8a210..ad7886a 100644 --- a/lib/mix/tasks/wnh.staging_full_e2e.ex +++ b/lib/mix/tasks/wnh.staging_full_e2e.ex @@ -498,8 +498,8 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do 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 + unless records["support_request"] in support_request_ids and + records["content_removal_notice"] in removal_notice_ids do Mix.raise("full staging E2E precreated records do not match the manifest") end end diff --git a/scripts/production-full-e2e.sh b/scripts/production-full-e2e.sh index 80d61a6..34e0fb6 100755 --- a/scripts/production-full-e2e.sh +++ b/scripts/production-full-e2e.sh @@ -19,9 +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, 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. +two-user help, moderated activity, authenticated privacy/data/deletion intake, +and read-only staff support/legal browser flows, and removes the exact fixture +on success, failure, or interrupt. It never resets the database. EOF } @@ -59,7 +59,7 @@ import shlex import sys path = sys.argv[1] -wanted = { +required = { "COMPOSE_PROJECT_NAME", "DATABASE_MODE", "DEPLOYMENT_ENV", @@ -67,6 +67,8 @@ wanted = { "POSTGRES_DB", "WNH_BASE_URL", } +optional = {"SUPPORT_OPERATOR_EMAIL_MODE"} +wanted = required | optional values = {} with open(path, encoding="utf-8") as handle: for raw_line in handle: @@ -79,12 +81,12 @@ with open(path, encoding="utf-8") as handle: parsed = shlex.split(value, comments=False, posix=True) values[key] = parsed[0] if parsed else "" -missing = sorted(key for key in wanted if not values.get(key)) +missing = sorted(key for key in required if not values.get(key)) if missing: raise SystemExit("Missing production identity settings: " + ", ".join(missing)) for key in sorted(wanted): - print(f"{key.lower()}={values[key]}") + print(f"{key.lower()}={values.get(key, '')}") PY commit=$(git -C "$root" rev-parse HEAD) @@ -147,12 +149,15 @@ commit=$(value commit) container=$(value container) database=$(value postgres_db) project=$(value compose_project_name) +support_operator_email_mode=$(value support_operator_email_mode) if [[ "$(value deployment_env)" != production ]] || [[ "$(value phx_host)" != whoneedhelp.com ]] || [[ "$(value wnh_base_url)" != "$BASE_URL" ]] || [[ "$(value database_mode)" != external ]] || [[ "$project" != who_need_help_production ]] || + [[ "$(value support_operator_email_mode)" != "" && + "$(value support_operator_email_mode)" != disabled ]] || [[ "$(value health)" != healthy ]] || [[ "$(value fixture_prefix_count)" != 0 ]] || [[ ! "$commit" =~ ^[0-9a-f]{40}$ ]] || @@ -195,14 +200,17 @@ Verified production target: commit: $commit app container: $container restart count: $(value restart_count) + support operator email mode: ${support_operator_email_mode:-disabled} existing run-scoped fixture users: 0 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; + - one directly inserted support row plus three authenticated privacy, + data-export and account-deletion requests submitted through the public UI; + - one directly inserted legal row, read through the staff UI without + submitting or moderating a content-removal notice; - 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. @@ -318,10 +326,10 @@ cleanup() { ! jq -e ' .cleanup_verified == true and (.cleanup_targets.users | length) >= 6 and - (.cleanup_targets.support_requests | length) == 1 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.support_requests == (.cleanup_targets.support_requests | length) and .cleanup_deleted_counts.content_removal_notices == 1 and .cleanup_verified_at != null ' "$output_dir/fixture.json" >/dev/null; then @@ -431,7 +439,7 @@ 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 E2E_PRODUCTION_RUN_SCOPED=1 \ --env E2E_DETERMINISTIC_MAP_TILES=1 \ --env HOME=/tmp \ --volume "$output_dir/browser:/work/output" \ 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 d2d049b..f2bbfc6 100644 --- a/test/who_need_help/staging_full_e2e_cleanup_test.exs +++ b/test/who_need_help/staging_full_e2e_cleanup_test.exs @@ -53,6 +53,22 @@ defmodule WhoNeedHelp.StagingFullE2ECleanupTest do manifest["precreated_records"]["content_removal_notice"] ) + browser_created_support_request = + %WhoNeedHelp.Support.SupportRequest{ + reference: "SUP-BROWSER-#{String.upcase(run_id)}", + requester_id: prepared_support_request.requester_id, + contact_verified_at: DateTime.utc_now(:second), + status: :open + } + |> WhoNeedHelp.Support.SupportRequest.submission_changeset(%{ + "kind" => "data_export", + "contact_email" => prepared_support_request.contact_email, + "subject" => "Browser-created production E2E support #{run_id}", + "details" => + "This run-owned support record proves cleanup accepts browser-created fixture data." + }) + |> Repo.insert!() + assert manifest["schema_version"] == 2 assert prepared_support_request.subject == "Production E2E support #{run_id}" assert prepared_support_request.contact_verified_at @@ -89,13 +105,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 Enum.sort(manifest["cleanup_targets"]["support_requests"]) == + Enum.sort([prepared_support_request.id, browser_created_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"]["support_requests"] == 2 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