Harden production browser verification

This commit is contained in:
SimpleTest 2026-08-03 08:10:13 +03:00
parent 5e5478389d
commit 87d6b94bf7
7 changed files with 429 additions and 4 deletions

View File

@ -10,6 +10,7 @@ import {
registerAndConfirm, registerAndConfirm,
selectOptionContaining, selectOptionContaining,
waitForMapReady, waitForMapReady,
waitForLiveViewConnected,
} from "./helpers"; } from "./helpers";
test("activity approval, privacy controls, reporting, and moderation work end to end", async ({ test("activity approval, privacy controls, reporting, and moderation work end to end", async ({
@ -270,10 +271,16 @@ test("activity approval, privacy controls, reporting, and moderation work end to
.filter({ hasText: participantEmail }); .filter({ hasText: participantEmail });
await expect(participantRow).toHaveCount(1); await expect(participantRow).toHaveCount(1);
await participantRow.getByRole("link", { name: /^Manage / }).click(); await participantRow.getByRole("link", { name: /^Manage / }).click();
await admin.page.getByLabel("Status").selectOption("restricted"); const participantStatus = admin.page.getByLabel("Status");
await participantStatus.selectOption("restricted");
await admin.page.getByLabel("Internal note").fill("E2E restriction boundary."); await admin.page.getByLabel("Internal note").fill("E2E restriction boundary.");
await admin.page.getByRole("button", { name: "Save account status" }).click(); await admin.page.getByRole("button", { name: "Save account status" }).click();
await expect(admin.page.getByText("Account status updated.")).toBeVisible(); await expect(admin.page.getByText("Account status updated.")).toBeVisible();
await expect(participantStatus).toHaveValue("restricted");
await admin.page.reload();
await waitForLiveViewConnected(admin.page);
await expect(admin.page.getByLabel("Status")).toHaveValue("restricted");
await gotoLiveView(participant.page, "/categories/proposals"); await gotoLiveView(participant.page, "/categories/proposals");
await participant.page await participant.page

View File

@ -544,8 +544,10 @@ export async function selectOptionContaining(
.first(); .first();
await expect(picker, `No category picker labelled ${label}`).toBeVisible(); await expect(picker, `No category picker labelled ${label}`).toBeVisible();
await picker.locator("[data-category-picker-trigger]").click(); await clickUntilVisible(
await expect(option, `No ${label} option contains ${expectedText}`).toBeVisible(); picker.locator("[data-category-picker-trigger]"),
option,
);
const value = await option.getAttribute("data-value"); const value = await option.getAttribute("data-value");
if (!value) { if (!value) {

View File

@ -26,7 +26,7 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do
@shortdoc "Prepares or removes the exact full public-staging browser fixture" @shortdoc "Prepares or removes the exact full public-staging browser fixture"
@confirmation "public-staging-full-e2e" @confirmation "public-staging-full-e2e"
@precreated_roles ~w(requester helper activity-organizer activity-participant admin) @precreated_roles ~w(requester helper replacement-helper activity-organizer activity-participant admin)
@registered_roles ~w(auth-user auth-user-changed) @registered_roles ~w(auth-user auth-user-changed)
@impl Mix.Task @impl Mix.Task

View File

@ -32,6 +32,10 @@ defmodule WhoNeedHelpWeb.AdminUserLive do
) )
end end
def handle_event("validate-moderation", %{"moderation" => params}, socket) do
{:noreply, assign(socket, :moderation_form, to_form(params, as: :moderation))}
end
def handle_event("set-staff-roles", %{"staff" => params}, socket) do def handle_event("set-staff-roles", %{"staff" => params}, socket) do
roles = params |> Map.get("roles", []) |> List.wrap() |> Enum.reject(&(&1 == "")) roles = params |> Map.get("roles", []) |> List.wrap() |> Enum.reject(&(&1 == ""))
@ -177,6 +181,7 @@ defmodule WhoNeedHelpWeb.AdminUserLive do
:if={can_moderate?(@current_scope.user, @user)} :if={can_moderate?(@current_scope.user, @user)}
for={@moderation_form} for={@moderation_form}
id={"moderate-user-#{@user.id}"} id={"moderate-user-#{@user.id}"}
phx-change="validate-moderation"
phx-submit="moderate-user" phx-submit="moderate-user"
class="mt-4 space-y-4" class="mt-4 space-y-4"
> >

371
scripts/production-full-e2e.sh Executable file
View File

@ -0,0 +1,371 @@
#!/usr/bin/env bash
set -euo pipefail
umask 077
# Production browser verification is intentionally opt-in and run-scoped.
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
MODE=${1:-plan}
RUN_ID=${2:-"production-e2e-$(date -u +%Y%m%d%H%M%S)"}
SSH_TARGET=${WNH_PRODUCTION_E2E_SSH_TARGET:-whoneedhelp}
REMOTE_ROOT=${WNH_PRODUCTION_E2E_REMOTE_ROOT:-/srv/who_need_help-production}
BASE_URL=https://whoneedhelp.com
usage() {
cat <<'EOF'
Usage:
./scripts/production-full-e2e.sh plan [run-id]
WNH_PRODUCTION_E2E_CONFIRM='<exact value printed by plan>' \
./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.
EOF
}
case "$MODE" in
plan | run) ;;
*) usage >&2; exit 1 ;;
esac
if [[ ! "$RUN_ID" =~ ^[a-z0-9-]+$ ]]; then
echo "run-id may contain only lowercase letters, numbers, and dashes." >&2
exit 1
fi
for command in curl docker git jq mktemp openssl scp sha256sum ssh tar; do
command -v "$command" >/dev/null 2>&1 || {
echo "Required command is unavailable: $command" >&2
exit 2
}
done
inventory=$(
ssh -o BatchMode=yes "$SSH_TARGET" bash -s -- "$REMOTE_ROOT" "$RUN_ID" <<'REMOTE'
set -euo pipefail
root=$1
run_id=$2
env_file="$root/.env"
if [[ ! -f "$env_file" ]]; then
echo "Missing production environment: $env_file" >&2
exit 1
fi
python3 - "$env_file" <<'PY'
import shlex
import sys
path = sys.argv[1]
wanted = {
"COMPOSE_PROJECT_NAME",
"DATABASE_MODE",
"DEPLOYMENT_ENV",
"PHX_HOST",
"POSTGRES_DB",
"WNH_BASE_URL",
}
values = {}
with open(path, encoding="utf-8") as handle:
for raw_line in handle:
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
if key not in wanted:
continue
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))
if missing:
raise SystemExit("Missing production identity settings: " + ", ".join(missing))
for key in sorted(wanted):
print(f"{key.lower()}={values[key]}")
PY
commit=$(git -C "$root" rev-parse HEAD)
project=$(
python3 - "$env_file" <<'PY'
import shlex
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
for raw_line in handle:
line = raw_line.strip()
if line.startswith("COMPOSE_PROJECT_NAME="):
parsed = shlex.split(line.split("=", 1)[1], comments=False, posix=True)
if parsed:
print(parsed[0])
break
PY
)
if [[ -z "$project" ]]; then
echo "Missing normalized COMPOSE_PROJECT_NAME." >&2
exit 1
fi
mapfile -t containers < <(
docker ps \
--filter "label=com.docker.compose.project=$project" \
--filter "label=com.docker.compose.service=app" \
--format '{{.Names}}'
)
if [[ "${#containers[@]}" -ne 1 ]]; then
echo "Expected exactly one compact production app container; observed ${#containers[@]}." >&2
exit 1
fi
container=${containers[0]}
health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container")
restart_count=$(docker inspect --format '{{.RestartCount}}' "$container")
prefix="wnh-staging-e2e-$run_id-%"
prefix_count=$(
docker exec "$container" /app/bin/who_need_help rpc \
"result = WhoNeedHelp.Repo.query!(\"SELECT count(*) FROM users WHERE email LIKE \\\$1\", [\"$prefix\"], log: false); IO.puts(result.rows |> hd() |> hd())" |
tail -n 1
)
printf 'commit=%s\n' "$commit"
printf 'container=%s\n' "$container"
printf 'health=%s\n' "$health"
printf 'restart_count=%s\n' "$restart_count"
printf 'fixture_prefix_count=%s\n' "$prefix_count"
REMOTE
)
value() {
local name=$1
printf '%s\n' "$inventory" | sed -n "s/^${name}=//p" | tail -n 1
}
commit=$(value commit)
container=$(value container)
database=$(value postgres_db)
project=$(value compose_project_name)
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 health)" != healthy ]] ||
[[ "$(value fixture_prefix_count)" != 0 ]] ||
[[ ! "$commit" =~ ^[0-9a-f]{40}$ ]] ||
[[ -z "$database" ]] ||
[[ -z "$container" ]]; then
echo "The observed target does not match the exact compact production identity." >&2
printf '%s\n' "$inventory" >&2
exit 1
fi
git cat-file -e "$commit^{commit}" 2>/dev/null || {
echo "Production commit $commit is not available in the local repository." >&2
exit 1
}
while IFS= read -r changed_path; do
case "$changed_path" in
lib/mix/tasks/wnh.staging_full_e2e.ex | e2e/tests/activity-moderation.spec.ts | e2e/tests/helpers.ts | test/who_need_help/trust_safety_test.exs)
;;
*)
echo "Production E2E refused: unexpected local change relative to $commit: $changed_path" >&2
exit 1
;;
esac
done < <(git diff --name-only "$commit")
confirmation="whoneedhelp.com:${commit}:${database}:${RUN_ID}:full-browser-e2e"
cat <<EOF
Verified production target:
URL: $BASE_URL
SSH target: $SSH_TARGET
checkout: $REMOTE_ROOT
Compose project: $project
database: $database
commit: $commit
app container: $container
restart count: $(value restart_count)
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;
- 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.
The exact manifest-owned fixture is removed on success, failure, or interrupt.
Cleanup refuses cross-fixture relationships and verifies that the run prefix is absent.
To execute exactly this plan, set:
WNH_PRODUCTION_E2E_CONFIRM='$confirmation'
EOF
if [[ "$MODE" == plan ]]; then
exit 0
fi
if [[ "${WNH_PRODUCTION_E2E_CONFIRM:-}" != "$confirmation" ]]; then
echo "Run refused: WNH_PRODUCTION_E2E_CONFIRM does not match this verified plan." >&2
exit 1
fi
output_dir="$ROOT/output/production-full-e2e/$RUN_ID"
remote_output="$REMOTE_ROOT/output/production-full-e2e/$RUN_ID"
short=${commit:0:12}
tools_image="who-need-help:production-e2e-tools-$short"
browser_image="who-need-help-e2e-tests:production-$short"
archive_dir=$(mktemp -d "$ROOT/tmp/production-e2e-source.XXXXXX")
fixture_password=$(openssl rand -base64 36 | tr -d '\n')
prepared=0
if [[ -e "$output_dir" ]]; then
echo "Local evidence directory already exists: $output_dir" >&2
exit 1
fi
mkdir -p "$output_dir/browser"
chmod 700 "$ROOT/output" "$ROOT/output/production-full-e2e" "$output_dir" "$output_dir/browser"
printf '%s\n' "$inventory" >"$output_dir/environment.txt"
printf '%s\n' "$confirmation" >"$output_dir/confirmation.txt"
snapshot() {
local destination=$1
ssh -o BatchMode=yes "$SSH_TARGET" bash -s -- "$container" "$RUN_ID" <<'REMOTE' >"$destination"
set -euo pipefail
container=$1
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)
counts =
Map.new(tables, fn table ->
result = Repo.query!("SELECT count(*) FROM #{table}", [], log: false)
{table, result.rows |> hd() |> hd()}
end)
prefix_count = Repo.query!("SELECT count(*) FROM users WHERE email LIKE $1", [prefix], log: false).rows |> hd() |> hd()
IO.puts(Jason.encode!(%{captured_at: DateTime.utc_now(), counts: counts, fixture_prefix_count: prefix_count}))
' | tail -n 1
REMOTE
}
run_fixture() {
local action=$1
ssh -o BatchMode=yes "$SSH_TARGET" bash -s -- \
"$REMOTE_ROOT" "$remote_output" "$tools_image" "$database" "$RUN_ID" "$fixture_password" "$action" <<'REMOTE'
set -euo pipefail
root=$1
output=$2
image=$3
database=$4
run_id=$5
password=$6
action=$7
mkdir -p "$output"
chmod 700 "$output"
docker run --rm \
--network host \
--env-file "$root/.env" \
--env APP_ROLE=migrate \
--env "WNH_STAGING_E2E_EXPECTED_DATABASE=$database" \
--env WNH_STAGING_E2E_CONFIRM=public-staging-full-e2e \
--env "WNH_STAGING_E2E_RUN_ID=$run_id" \
--env "WNH_STAGING_E2E_PASSWORD=$password" \
--env WNH_STAGING_E2E_MANIFEST_PATH=/output/fixture.json \
--volume /var/run/postgresql:/var/run/postgresql \
--volume "$output:/output" \
"$image" \
mix wnh.staging_full_e2e "$action"
REMOTE
}
cleanup() {
local status=$?
trap - EXIT HUP INT TERM
if [[ "$prepared" -eq 1 ]]; then
if ! run_fixture cleanup >"$output_dir/fixture-cleanup.log" 2>&1; then
echo "Exact production E2E fixture cleanup failed; inspect $output_dir." >&2
status=1
fi
fi
snapshot "$output_dir/database-after.json" 2>"$output_dir/database-after-error.log" || status=1
if [[ -s "$output_dir/database-after.json" ]] &&
[[ "$(jq -r '.fixture_prefix_count' "$output_dir/database-after.json")" != 0 ]]; then
echo "Run-scoped production fixture users remain after cleanup." >&2
status=1
fi
scp -q "$SSH_TARGET:$remote_output/fixture.json" "$output_dir/fixture.json" 2>/dev/null || true
ssh -o BatchMode=yes "$SSH_TARGET" \
"rm -rf -- '$remote_output'; docker image rm '$tools_image' >/dev/null 2>&1 || true" || status=1
docker image rm "$tools_image" "$browser_image" >/dev/null 2>&1 || true
rm -rf -- "$archive_dir"
unset fixture_password
exit "$status"
}
trap cleanup EXIT HUP INT TERM
curl --fail --silent --show-error --max-time 10 "$BASE_URL/healthz/ready" \
>"$output_dir/readiness-before.json"
snapshot "$output_dir/database-before.json"
git archive "$commit" | tar -x -C "$archive_dir"
# The deployed application remains the exact production commit. Only the
# run-scoped fixture task and browser assertion are overlaid into throwaway
# runner images so the verifier can evolve without deploying application code.
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/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/helpers.ts" \
>"$output_dir/harness-sha256.txt"
# This script starts with umask 077 so evidence and credentials remain private.
# The archived source is copied into a browser image that later runs under the
# invoking host UID; make only this throwaway source tree readable first.
chmod -R u+rwX,go+rX "$archive_dir"
docker build --target load_tools --tag "$tools_image" "$archive_dir" \
>"$output_dir/tools-build.log"
docker build --tag "$browser_image" "$archive_dir/e2e" \
>"$output_dir/browser-build.log"
docker save "$tools_image" |
ssh -o BatchMode=yes "$SSH_TARGET" docker load \
>"$output_dir/tools-load.log"
run_fixture 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:1 \
--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 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 |
tee "$output_dir/browser-console.log"
curl --fail --silent --show-error --max-time 10 "$BASE_URL/healthz/ready" \
>"$output_dir/readiness-after-browser.json"
echo "Production full browser E2E passed. Evidence: $output_dir"

View File

@ -556,6 +556,26 @@ defmodule WhoNeedHelp.TrustSafetyTest do
Help.create_request(context.helper_scope, context.attrs) Help.create_request(context.helper_scope, context.attrs)
end end
test "a stale authenticated scope cannot create a category proposal after restriction",
context do
admin = staff_user_fixture([:admin], display_name: "Restriction administrator")
assert {:ok, restricted} =
Trust.moderate_user(user_scope_fixture(admin), context.helper.id, %{
"moderation_status" => "restricted",
"moderation_note" => "Immediate trust-action boundary test"
})
assert restricted.moderation_status == :restricted
assert {:error, :account_not_eligible} =
Catalog.propose(context.helper_scope, %{
"proposed_name" => "Restricted stale-scope proposal",
"mode" => "help",
"reason" => "This write must be rejected after the database status changes."
})
end
test "approximate requests expose only their bounded area geometry", context do test "approximate requests expose only their bounded area geometry", context do
attrs = Map.put(context.attrs, "location_radius_meters", "500") attrs = Map.put(context.attrs, "location_radius_meters", "500")
{:ok, request} = Help.create_request(context.requester_scope, attrs) {:ok, request} = Help.create_request(context.requester_scope, attrs)

View File

@ -65,6 +65,26 @@ defmodule WhoNeedHelpWeb.AdminLiveTest do
assert html =~ "Account under review" assert html =~ "Account under review"
assert has_element?(user_view, "#staff-roles-#{target.id}") assert has_element?(user_view, "#staff-roles-#{target.id}")
user_view
|> form("#moderate-user-#{target.id}", %{
"moderation" => %{
"moderation_status" => "restricted",
"moderation_note" => "Unsaved review note"
}
})
|> render_change()
assert has_element?(
user_view,
"#moderate-user-#{target.id} option[value='restricted'][selected]"
)
assert has_element?(
user_view,
"#moderate-user-#{target.id} textarea",
"Unsaved review note"
)
user_view user_view
|> form("#staff-roles-#{target.id}", %{ |> form("#staff-roles-#{target.id}", %{
"staff" => %{"roles" => ["support", "moderator"]} "staff" => %{"roles" => ["support", "moderator"]}