test: add scoped public staging handover E2E

This commit is contained in:
SimpleTest 2026-07-19 16:14:27 +03:00
parent 5b723831af
commit 378d0b4563
4 changed files with 549 additions and 18 deletions

View File

@ -20,6 +20,12 @@ export type AuthenticatedBrowser = {
}; };
export function projectEmail(localPart: string, projectName: string): string { 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, "-"); const scope = projectName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
return `${localPart}+${scope}@example.invalid`; return `${localPart}+${scope}@example.invalid`;
} }
@ -32,6 +38,12 @@ export async function newIsolatedContext(browser: Browser): Promise<BrowserConte
return browser.newContext({ serviceWorkers: "block" }); return browser.newContext({ serviceWorkers: "block" });
} }
export async function waitForLiveViewConnected(page: Page): Promise<void> {
await expect(page.locator("[data-phx-main].phx-connected")).toHaveCount(1, {
timeout: 15_000,
});
}
export async function waitForMapReady(page: Page): Promise<void> { export async function waitForMapReady(page: Page): Promise<void> {
const map = page.locator(".aid-map"); const map = page.locator(".aid-map");
@ -70,13 +82,18 @@ export function captureBrowserFailures(page: Page): () => void {
page.on("requestfailed", (request) => { page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? ""; 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 ( // MapLibre cancels in-flight raster tiles when a LiveView navigation
request.url().includes("/__e2e__/map-tile.png") && // destroys or re-centres a map. Actual tile/network failures retain their
(failure.includes("Load request cancelled") || // engine error and remain test failures.
failure.includes("ERR_ABORTED") || if (rasterTile && cancelled) {
failure.includes("NS_BINDING_ABORTED"))
) {
return; return;
} }
@ -202,11 +219,30 @@ export async function loginWithMagicLink(
return { context, page, email }; return { context, page, email };
} }
export async function loginWithPassword(
browser: Browser,
email: string,
password: string,
): Promise<AuthenticatedBrowser> {
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( export async function selectOptionContaining(
page: Page, page: Page,
label: string, label: string,
expectedText: string, expectedText: string,
): Promise<void> { ): Promise<void> {
await waitForLiveViewConnected(page);
const select = page.getByLabel(label); const select = page.getByLabel(label);
const options = await select.locator("option").evaluateAll((nodes) => const options = await select.locator("option").evaluateAll((nodes) =>
nodes.map((node) => ({ nodes.map((node) => ({

View File

@ -1,10 +1,12 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { import {
captureBrowserFailures, captureBrowserFailures,
loginWithPassword,
projectEmail, projectEmail,
projectText, projectText,
registerAndConfirm, registerAndConfirm,
selectOptionContaining, selectOptionContaining,
waitForLiveViewConnected,
waitForMapReady, waitForMapReady,
} from "./helpers"; } from "./helpers";
@ -12,19 +14,22 @@ test("two users complete a medicine handover with realtime chat and blind review
browser, browser,
request, request,
}, testInfo) => { }, testInfo) => {
const requestTitle = projectText("E2E medicine pickup", testInfo.project.name); const runID = process.env.E2E_RUN_ID;
const requester = await registerAndConfirm( const fixturePassword = process.env.E2E_FIXTURE_PASSWORD;
browser, const requestTitle = projectText(
request, runID ? `Staging E2E medicine pickup ${runID}` : "E2E medicine pickup",
projectEmail("requester", testInfo.project.name), testInfo.project.name,
"E2E Requester",
);
const helper = await registerAndConfirm(
browser,
request,
projectEmail("helper", testInfo.project.name),
"E2E Helper",
); );
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 assertRequesterClean = captureBrowserFailures(requester.page);
const assertHelperClean = captureBrowserFailures(helper.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 expect(requester.page.getByRole("heading", { name: requestTitle })).toBeVisible();
await helper.page.goto(requestURL); await helper.page.goto(requestURL);
await waitForLiveViewConnected(helper.page);
await helper.page.getByRole("button", { name: "I can help" }).click(); await helper.page.getByRole("button", { name: "I can help" }).click();
await expect(helper.page.getByRole("heading", { name: "Private match chat" })).toBeVisible(); await expect(helper.page.getByRole("heading", { name: "Private match chat" })).toBeVisible();
await expect(requester.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("None revealed yet.")).toBeVisible();
await expect(requester.page.getByText("Clear and safe coordination.")).toHaveCount(0); await expect(requester.page.getByText("Clear and safe coordination.")).toHaveCount(0);
await requester.page.goto(requestURL); await requester.page.goto(requestURL);
await waitForLiveViewConnected(requester.page);
await requester.page.getByLabel("Rating").selectOption("5"); await requester.page.getByLabel("Rating").selectOption("5");
await requester.page.getByLabel("Comment (optional)").fill("Reliable volunteer."); await requester.page.getByLabel("Comment (optional)").fill("Reliable volunteer.");

View File

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

160
scripts/staging-e2e-run.sh Executable file
View File

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