diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index 6719a37..10bad22 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -20,6 +20,12 @@ export type AuthenticatedBrowser = { }; export function projectEmail(localPart: string, projectName: string): string { + const runID = process.env.E2E_RUN_ID; + + if (runID) { + return `wnh-staging-e2e-${runID}-${localPart}@example.invalid`; + } + const scope = projectName.toLowerCase().replace(/[^a-z0-9-]/g, "-"); return `${localPart}+${scope}@example.invalid`; } @@ -32,6 +38,12 @@ export async function newIsolatedContext(browser: Browser): Promise { + await expect(page.locator("[data-phx-main].phx-connected")).toHaveCount(1, { + timeout: 15_000, + }); +} + export async function waitForMapReady(page: Page): Promise { const map = page.locator(".aid-map"); @@ -70,13 +82,18 @@ export function captureBrowserFailures(page: Page): () => void { page.on("requestfailed", (request) => { const failure = request.failure()?.errorText ?? ""; + const cancelled = + failure.includes("Load request cancelled") || + failure.includes("ERR_ABORTED") || + failure.includes("NS_BINDING_ABORTED"); + const rasterTile = + request.url().includes("/__e2e__/map-tile.png") || + /\/\d+\/\d+\/\d+\.png(?:\?|$)/.test(request.url()); - if ( - request.url().includes("/__e2e__/map-tile.png") && - (failure.includes("Load request cancelled") || - failure.includes("ERR_ABORTED") || - failure.includes("NS_BINDING_ABORTED")) - ) { + // MapLibre cancels in-flight raster tiles when a LiveView navigation + // destroys or re-centres a map. Actual tile/network failures retain their + // engine error and remain test failures. + if (rasterTile && cancelled) { return; } @@ -202,11 +219,30 @@ export async function loginWithMagicLink( return { context, page, email }; } +export async function loginWithPassword( + browser: Browser, + email: string, + password: string, +): Promise { + const context = await newIsolatedContext(browser); + const page = await context.newPage(); + + await page.goto("/users/log-in"); + const form = page.locator("#login_form_password"); + await form.getByLabel("Email").fill(email); + await form.getByLabel("Password").fill(password); + await form.getByRole("button", { name: "Log in only this time" }).click(); + await expect(page.getByText(email, { exact: true })).toBeVisible(); + + return { context, page, email }; +} + export async function selectOptionContaining( page: Page, label: string, expectedText: string, ): Promise { + await waitForLiveViewConnected(page); const select = page.getByLabel(label); const options = await select.locator("option").evaluateAll((nodes) => nodes.map((node) => ({ diff --git a/e2e/tests/mutual-aid.spec.ts b/e2e/tests/mutual-aid.spec.ts index 5b0c96d..0521428 100644 --- a/e2e/tests/mutual-aid.spec.ts +++ b/e2e/tests/mutual-aid.spec.ts @@ -1,10 +1,12 @@ import { expect, test } from "@playwright/test"; import { captureBrowserFailures, + loginWithPassword, projectEmail, projectText, registerAndConfirm, selectOptionContaining, + waitForLiveViewConnected, waitForMapReady, } from "./helpers"; @@ -12,19 +14,22 @@ test("two users complete a medicine handover with realtime chat and blind review browser, request, }, testInfo) => { - const requestTitle = projectText("E2E medicine pickup", testInfo.project.name); - const requester = await registerAndConfirm( - browser, - request, - projectEmail("requester", testInfo.project.name), - "E2E Requester", - ); - const helper = await registerAndConfirm( - browser, - request, - projectEmail("helper", testInfo.project.name), - "E2E Helper", + const runID = process.env.E2E_RUN_ID; + const fixturePassword = process.env.E2E_FIXTURE_PASSWORD; + const requestTitle = projectText( + runID ? `Staging E2E medicine pickup ${runID}` : "E2E medicine pickup", + testInfo.project.name, ); + const requesterEmail = projectEmail("requester", testInfo.project.name); + const helperEmail = projectEmail("helper", testInfo.project.name); + const requester = + runID && fixturePassword + ? await loginWithPassword(browser, requesterEmail, fixturePassword) + : await registerAndConfirm(browser, request, requesterEmail, "E2E Requester"); + const helper = + runID && fixturePassword + ? await loginWithPassword(browser, helperEmail, fixturePassword) + : await registerAndConfirm(browser, request, helperEmail, "E2E Helper"); const assertRequesterClean = captureBrowserFailures(requester.page); const assertHelperClean = captureBrowserFailures(helper.page); @@ -51,6 +56,7 @@ test("two users complete a medicine handover with realtime chat and blind review await expect(requester.page.getByRole("heading", { name: requestTitle })).toBeVisible(); await helper.page.goto(requestURL); + await waitForLiveViewConnected(helper.page); await helper.page.getByRole("button", { name: "I can help" }).click(); await expect(helper.page.getByRole("heading", { name: "Private match chat" })).toBeVisible(); await expect(requester.page.getByRole("heading", { name: "Private match chat" })).toBeVisible(); @@ -94,6 +100,7 @@ test("two users complete a medicine handover with realtime chat and blind review await expect(requester.page.getByText("None revealed yet.")).toBeVisible(); await expect(requester.page.getByText("Clear and safe coordination.")).toHaveCount(0); await requester.page.goto(requestURL); + await waitForLiveViewConnected(requester.page); await requester.page.getByLabel("Rating").selectOption("5"); await requester.page.getByLabel("Comment (optional)").fill("Reliable volunteer."); diff --git a/lib/mix/tasks/wnh.staging_e2e.ex b/lib/mix/tasks/wnh.staging_e2e.ex new file mode 100644 index 0000000..ebc7f86 --- /dev/null +++ b/lib/mix/tasks/wnh.staging_e2e.ex @@ -0,0 +1,328 @@ +defmodule Mix.Tasks.Wnh.StagingE2e do + use Mix.Task + + import Ecto.Query + + alias WhoNeedHelp.Accounts.User + alias WhoNeedHelp.Activities.Activity + alias WhoNeedHelp.Catalog.CategoryProposal + alias WhoNeedHelp.Help.{Assignment, HelpRequest} + alias WhoNeedHelp.Messaging.Message + alias WhoNeedHelp.Repo + alias WhoNeedHelp.Tracking.{Position, TrackingSession} + alias WhoNeedHelp.Trust.{AbuseSignal, AuditEvent, Report, Review} + + @shortdoc "Prepares or removes an exact public-staging browser fixture" + @confirmation "public-staging-e2e" + + @impl Mix.Task + def run([action]) when action in ["prepare", "cleanup"] do + Mix.Task.run("app.start") + context = verified_context() + + case action do + "prepare" -> prepare(context) + "cleanup" -> cleanup(context) + end + end + + def run(_args) do + Mix.raise("usage: mix wnh.staging_e2e prepare|cleanup") + end + + defp verified_context do + run_id = required_env!("WNH_STAGING_E2E_RUN_ID") + expected_database = required_env!("WNH_STAGING_E2E_EXPECTED_DATABASE") + manifest_path = required_env!("WNH_STAGING_E2E_MANIFEST_PATH") |> Path.expand() + + unless Regex.match?(~r/^[a-z0-9-]+$/, run_id) do + Mix.raise("WNH_STAGING_E2E_RUN_ID may contain only lowercase letters, numbers, and dash") + end + + unless System.get_env("WNH_STAGING_E2E_CONFIRM") == @confirmation do + Mix.raise("WNH_STAGING_E2E_CONFIRM must equal #{@confirmation}") + end + + unless String.starts_with?(manifest_path, "/output/") do + Mix.raise("WNH_STAGING_E2E_MANIFEST_PATH must resolve below /output") + end + + %Postgrex.Result{rows: [[actual_database]]} = + Repo.query!("SELECT current_database()", [], log: false) + + unless actual_database == expected_database do + Mix.raise( + "refusing staging E2E mutation: expected database #{inspect(expected_database)}, " <> + "observed #{inspect(actual_database)}" + ) + end + + %{run_id: run_id, database: actual_database, manifest_path: manifest_path} + end + + defp prepare(context) do + password = required_env!("WNH_STAGING_E2E_PASSWORD") + + unless byte_size(password) in 12..72 do + Mix.raise("WNH_STAGING_E2E_PASSWORD must contain between 12 and 72 bytes") + end + + emails = emails(context.run_id) + + if Repo.exists?(from(user in User, where: user.email in ^Map.values(emails))) do + Mix.raise("staging E2E users already exist for #{inspect(context.run_id)}") + end + + password_hash = Bcrypt.hash_pwd_salt(password) + now = DateTime.utc_now(:second) + + {:ok, users} = + Repo.transaction(fn -> + %{ + requester: insert_user!(emails.requester, "Staging E2E Requester", password_hash, now), + helper: insert_user!(emails.helper, "Staging E2E Helper", password_hash, now) + } + end) + + manifest = %{ + "schema_version" => 1, + "run_id" => context.run_id, + "database" => context.database, + "requester_id" => users.requester.id, + "requester_email" => users.requester.email, + "helper_id" => users.helper.id, + "helper_email" => users.helper.email + } + + context.manifest_path |> Path.dirname() |> File.mkdir_p!() + File.write!(context.manifest_path, Jason.encode_to_iodata!(manifest, pretty: true)) + File.chmod!(context.manifest_path, 0o600) + + Mix.shell().info("prepared exact staging E2E users for #{context.run_id}") + end + + defp cleanup(context) do + manifest = context.manifest_path |> File.read!() |> Jason.decode!() + validate_manifest!(context, manifest) + + requester_id = manifest["requester_id"] + helper_id = manifest["helper_id"] + user_ids = [requester_id, helper_id] + + request_ids = + HelpRequest + |> where([request], request.requester_id == ^requester_id) + |> select([request], request.id) + |> Repo.all() + + assignment_ids = + Assignment + |> where([assignment], assignment.request_id in ^request_ids) + |> select([assignment], assignment.id) + |> Repo.all() + + validate_owned_domain!(user_ids, request_ids, assignment_ids, helper_id) + + {:ok, deleted} = + Repo.transaction(fn -> + tracking_session_ids = + TrackingSession + |> where([session], session.assignment_id in ^assignment_ids) + |> select([session], session.id) + |> Repo.all() + + message_ids = + Message + |> where([message], message.assignment_id in ^assignment_ids) + |> select([message], message.id) + |> Repo.all() + + reports = + Report + |> where( + [report], + report.reporter_id in ^user_ids or report.request_id in ^request_ids or + report.assignment_id in ^assignment_ids or report.message_id in ^message_ids + ) + |> Repo.delete_all() + |> elem(0) + + audits = + AuditEvent + |> where( + [event], + event.actor_id in ^user_ids or + (event.target_type == "request" and event.target_id in ^request_ids) or + (event.target_type == "assignment" and event.target_id in ^assignment_ids) + ) + |> Repo.delete_all() + |> elem(0) + + positions = + Position + |> where([position], position.tracking_session_id in ^tracking_session_ids) + |> Repo.delete_all() + |> elem(0) + + sessions = + TrackingSession + |> where([session], session.id in ^tracking_session_ids) + |> Repo.delete_all() + |> elem(0) + + messages = + Message + |> where([message], message.assignment_id in ^assignment_ids) + |> Repo.delete_all() + |> elem(0) + + abuse_signals = + AbuseSignal + |> where( + [signal], + signal.subject_id in ^user_ids or signal.assignment_id in ^assignment_ids + ) + |> Repo.delete_all() + |> elem(0) + + reviews = + Review + |> where( + [review], + review.assignment_id in ^assignment_ids or review.reviewer_id in ^user_ids or + review.reviewee_id in ^user_ids + ) + |> Repo.delete_all() + |> elem(0) + + assignments = + Assignment + |> where([assignment], assignment.id in ^assignment_ids) + |> Repo.delete_all() + |> elem(0) + + requests = + HelpRequest + |> where([request], request.id in ^request_ids) + |> Repo.delete_all() + |> elem(0) + + users = + User + |> where([user], user.id in ^user_ids) + |> Repo.delete_all() + |> elem(0) + + %{ + reports: reports, + audit_events: audits, + tracking_positions: positions, + tracking_sessions: sessions, + messages: messages, + abuse_signals: abuse_signals, + reviews: reviews, + assignments: assignments, + requests: requests, + users: users + } + end) + + unless deleted.users == 2 do + Mix.raise("cleanup removed #{deleted.users} users instead of exactly 2") + end + + Mix.shell().info("removed exact staging E2E fixture: #{inspect(deleted)}") + end + + defp validate_manifest!(context, manifest) do + expected_emails = emails(context.run_id) + + unless manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and + manifest["database"] == context.database and + manifest["requester_email"] == expected_emails.requester and + manifest["helper_email"] == expected_emails.helper and + uuid?(manifest["requester_id"]) and uuid?(manifest["helper_id"]) do + Mix.raise("staging E2E manifest does not match the requested run and database") + end + + expected = + MapSet.new([ + {manifest["requester_id"], manifest["requester_email"]}, + {manifest["helper_id"], manifest["helper_email"]} + ]) + + observed = + User + |> where([user], user.id in ^[manifest["requester_id"], manifest["helper_id"]]) + |> select([user], {user.id, user.email}) + |> Repo.all() + |> MapSet.new() + + unless observed == expected do + Mix.raise("staging E2E user ownership does not match the manifest") + end + end + + defp validate_owned_domain!(user_ids, request_ids, assignment_ids, helper_id) do + unexpected_request? = + Repo.exists?( + from(request in HelpRequest, + where: request.requester_id in ^user_ids and request.id not in ^request_ids + ) + ) + + unexpected_assignment? = + Repo.exists?( + from(assignment in Assignment, + where: + assignment.helper_id in ^user_ids and + (assignment.id not in ^assignment_ids or assignment.helper_id != ^helper_id) + ) + ) + + unexpected_activity? = + Repo.exists?(from(activity in Activity, where: activity.creator_id in ^user_ids)) + + unexpected_proposal? = + Repo.exists?(from(proposal in CategoryProposal, where: proposal.proposer_id in ^user_ids)) + + unless length(request_ids) <= 1 and length(assignment_ids) <= 1 and + not unexpected_request? and not unexpected_assignment? and + not unexpected_activity? and not unexpected_proposal? do + Mix.raise("staging E2E users own records outside the exact medicine-flow scope") + end + end + + defp insert_user!(email, display_name, password_hash, now) do + %User{} + |> User.registration_changeset(%{ + "email" => email, + "display_name" => display_name, + "locale" => "en", + "terms_accepted" => true + }) + |> Ecto.Changeset.put_change(:hashed_password, password_hash) + |> Ecto.Changeset.put_change(:confirmed_at, now) + |> Repo.insert!() + end + + defp emails(run_id) do + %{ + requester: "wnh-staging-e2e-#{run_id}-requester@example.invalid", + helper: "wnh-staging-e2e-#{run_id}-helper@example.invalid" + } + end + + defp required_env!(name) do + case System.get_env(name) do + value when is_binary(value) and value != "" -> value + _ -> Mix.raise("#{name} is required") + end + end + + defp uuid?(value) when is_binary(value) do + match?({:ok, _}, Ecto.UUID.cast(value)) + end + + defp uuid?(_value), do: false +end diff --git a/scripts/staging-e2e-run.sh b/scripts/staging-e2e-run.sh new file mode 100755 index 0000000..907ebf5 --- /dev/null +++ b/scripts/staging-e2e-run.sh @@ -0,0 +1,160 @@ +#!/bin/bash +set -euo pipefail +umask 077 + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT" + +ENV_FILE="$ROOT/.env" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing $ENV_FILE." >&2 + exit 1 +fi + +set -a +# shellcheck source=/dev/null +. "$ENV_FILE" +set +a + +: "${POSTGRES_DB:?POSTGRES_DB is missing from .env}" +: "${PHX_HOST:?PHX_HOST is missing from .env}" +: "${PHX_SCHEME:?PHX_SCHEME is missing from .env}" +: "${PHX_URL_PORT:?PHX_URL_PORT is missing from .env}" + +if [[ "$PHX_SCHEME" != https ]]; then + echo "Public staging E2E requires PHX_SCHEME=https." >&2 + exit 1 +fi + +run_id="$(date -u +%Y%m%d%H%M%S)-$$" +output_dir="$ROOT/output/staging-e2e/$run_id" +manifest="$output_dir/fixture.json" +base_url="${PHX_SCHEME}://${PHX_HOST}" + +if [[ "$PHX_URL_PORT" != 443 ]]; then + base_url="${base_url}:${PHX_URL_PORT}" +fi + +mkdir -p "$output_dir" +chmod 700 "$ROOT/output" "$ROOT/output/staging-e2e" "$output_dir" + +fixture_password=$(openssl rand -base64 36 | tr -d '\n') +prepared=0 + +db_container=$(docker compose ps -q db) + +if [[ -z "$db_container" ]]; then + echo "The ordinary Compose database container is not running." >&2 + exit 1 +fi + +internal_network_id=$( + docker inspect "$db_container" | + jq -r '.[0].NetworkSettings.Networks | to_entries[] | select(.key | endswith("_internal")) | .value.NetworkID' | + head -n 1 +) + +if [[ -z "$internal_network_id" ]]; then + echo "Could not identify the ordinary Compose internal network." >&2 + exit 1 +fi + +snapshot_database() { + local destination=$1 + + docker compose exec -T db sh -c \ + 'psql --no-psqlrc --set ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"' \ + >"$destination" <<'SQL' +BEGIN READ ONLY; +SELECT 'users' AS table_name, count(*) AS row_count FROM users +UNION ALL SELECT 'users_tokens', count(*) FROM users_tokens +UNION ALL SELECT 'help_requests', count(*) FROM help_requests +UNION ALL SELECT 'messages', count(*) FROM messages +UNION ALL SELECT 'help_assignments', count(*) FROM help_assignments +UNION ALL SELECT 'reports', count(*) FROM reports +UNION ALL SELECT 'reviews', count(*) FROM reviews +UNION ALL SELECT 'audit_events', count(*) FROM audit_events +UNION ALL SELECT 'abuse_signals', count(*) FROM abuse_signals +UNION ALL SELECT 'tracking_sessions', count(*) FROM tracking_sessions +UNION ALL SELECT 'tracking_positions', count(*) FROM tracking_positions +UNION ALL SELECT 'activities', count(*) FROM activities +UNION ALL SELECT 'activity_participants', count(*) FROM activity_participants +UNION ALL SELECT 'activity_messages', count(*) FROM activity_messages +UNION ALL SELECT 'category_proposals', count(*) FROM category_proposals +UNION ALL SELECT 'category_votes', count(*) FROM category_votes +UNION ALL SELECT 'blocks', count(*) FROM blocks +UNION ALL SELECT 'social_identities', count(*) FROM social_identities +ORDER BY table_name; +COMMIT; +SQL +} + +run_fixture_tool() { + local action=$1 + + docker run --rm \ + --network "$internal_network_id" \ + --env-file "$ENV_FILE" \ + --env APP_ROLE=migrate \ + --env "WNH_STAGING_E2E_EXPECTED_DATABASE=$POSTGRES_DB" \ + --env WNH_STAGING_E2E_CONFIRM=public-staging-e2e \ + --env "WNH_STAGING_E2E_RUN_ID=$run_id" \ + --env "WNH_STAGING_E2E_PASSWORD=$fixture_password" \ + --env WNH_STAGING_E2E_MANIFEST_PATH=/output/fixture.json \ + --volume "$output_dir:/output" \ + who-need-help:staging-e2e-tools \ + mix wnh.staging_e2e "$action" +} + +cleanup() { + local status=$? + trap - EXIT HUP INT TERM + + if [[ "$prepared" -eq 1 ]]; then + if ! run_fixture_tool cleanup >"$output_dir/fixture-cleanup.log" 2>&1; then + echo "Exact staging E2E cleanup failed; inspect $output_dir." >&2 + status=1 + else + snapshot_database "$output_dir/database-after.txt" + + if ! diff -u \ + "$output_dir/database-before.txt" \ + "$output_dir/database-after.txt" \ + >"$output_dir/database-cleanup.diff"; then + echo "Staging E2E cleanup did not restore application table counts." >&2 + status=1 + fi + fi + fi + + unset fixture_password + exit "$status" +} +trap cleanup EXIT HUP INT TERM + +curl --fail --silent --show-error "$base_url/healthz/ready" >"$output_dir/public-ready.txt" +snapshot_database "$output_dir/database-before.txt" + +docker build --target load_tools --tag who-need-help:staging-e2e-tools . \ + >"$output_dir/tools-build.log" +docker build --tag who-need-help-e2e-tests:local e2e \ + >"$output_dir/browser-build.log" + +run_fixture_tool prepare >"$output_dir/fixture-prepare.log" +prepared=1 + +docker run --rm \ + --network host \ + --user "$(id -u):$(id -g)" \ + --env "BASE_URL=$base_url" \ + --env MAILPIT_URL=http://127.0.0.1:8027 \ + --env "E2E_RUN_ID=$run_id" \ + --env "E2E_FIXTURE_PASSWORD=$fixture_password" \ + --env HOME=/tmp \ + --volume "$output_dir:/work/output" \ + who-need-help-e2e-tests:local \ + npx playwright test --project=chromium tests/mutual-aid.spec.ts + +echo "Public staging medicine E2E passed: $base_url" +echo "Evidence: $output_dir"