From 87d6b94bf72fdfde007b5ee6d4a368ad283ac88e Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Mon, 3 Aug 2026 08:10:13 +0300 Subject: [PATCH] Harden production browser verification --- e2e/tests/activity-moderation.spec.ts | 9 +- e2e/tests/helpers.ts | 6 +- lib/mix/tasks/wnh.staging_full_e2e.ex | 2 +- lib/who_need_help_web/live/admin_user_live.ex | 5 + scripts/production-full-e2e.sh | 371 ++++++++++++++++++ test/who_need_help/trust_safety_test.exs | 20 + .../live/admin_live_test.exs | 20 + 7 files changed, 429 insertions(+), 4 deletions(-) create mode 100755 scripts/production-full-e2e.sh diff --git a/e2e/tests/activity-moderation.spec.ts b/e2e/tests/activity-moderation.spec.ts index 1cc236f..136c3c8 100644 --- a/e2e/tests/activity-moderation.spec.ts +++ b/e2e/tests/activity-moderation.spec.ts @@ -10,6 +10,7 @@ import { registerAndConfirm, selectOptionContaining, waitForMapReady, + waitForLiveViewConnected, } from "./helpers"; 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 }); await expect(participantRow).toHaveCount(1); 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.getByRole("button", { name: "Save account status" }).click(); 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 participant.page diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index 838d456..5c16181 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -544,8 +544,10 @@ export async function selectOptionContaining( .first(); await expect(picker, `No category picker labelled ${label}`).toBeVisible(); - await picker.locator("[data-category-picker-trigger]").click(); - await expect(option, `No ${label} option contains ${expectedText}`).toBeVisible(); + await clickUntilVisible( + picker.locator("[data-category-picker-trigger]"), + option, + ); const value = await option.getAttribute("data-value"); if (!value) { diff --git a/lib/mix/tasks/wnh.staging_full_e2e.ex b/lib/mix/tasks/wnh.staging_full_e2e.ex index b3191a7..44bc7fc 100644 --- a/lib/mix/tasks/wnh.staging_full_e2e.ex +++ b/lib/mix/tasks/wnh.staging_full_e2e.ex @@ -26,7 +26,7 @@ defmodule Mix.Tasks.Wnh.StagingFullE2e do @shortdoc "Prepares or removes the exact full public-staging browser fixture" @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) @impl Mix.Task diff --git a/lib/who_need_help_web/live/admin_user_live.ex b/lib/who_need_help_web/live/admin_user_live.ex index a48298a..4b774fc 100644 --- a/lib/who_need_help_web/live/admin_user_live.ex +++ b/lib/who_need_help_web/live/admin_user_live.ex @@ -32,6 +32,10 @@ defmodule WhoNeedHelpWeb.AdminUserLive do ) 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 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)} for={@moderation_form} id={"moderate-user-#{@user.id}"} + phx-change="validate-moderation" phx-submit="moderate-user" class="mt-4 space-y-4" > diff --git a/scripts/production-full-e2e.sh b/scripts/production-full-e2e.sh new file mode 100755 index 0000000..3672882 --- /dev/null +++ b/scripts/production-full-e2e.sh @@ -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='' \ + ./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 <&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" diff --git a/test/who_need_help/trust_safety_test.exs b/test/who_need_help/trust_safety_test.exs index 40e2e0b..9a47bc5 100644 --- a/test/who_need_help/trust_safety_test.exs +++ b/test/who_need_help/trust_safety_test.exs @@ -556,6 +556,26 @@ defmodule WhoNeedHelp.TrustSafetyTest do Help.create_request(context.helper_scope, context.attrs) 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 attrs = Map.put(context.attrs, "location_radius_meters", "500") {:ok, request} = Help.create_request(context.requester_scope, attrs) diff --git a/test/who_need_help_web/live/admin_live_test.exs b/test/who_need_help_web/live/admin_live_test.exs index dc02d79..987036d 100644 --- a/test/who_need_help_web/live/admin_live_test.exs +++ b/test/who_need_help_web/live/admin_live_test.exs @@ -65,6 +65,26 @@ defmodule WhoNeedHelpWeb.AdminLiveTest do assert html =~ "Account under review" 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 |> form("#staff-roles-#{target.id}", %{ "staff" => %{"roles" => ["support", "moderator"]}