diff --git a/Dockerfile b/Dockerfile index 6daa24b..4040880 100644 --- a/Dockerfile +++ b/Dockerfile @@ -152,6 +152,7 @@ COPY .dialyzer_ignore.exs .formatter.exs ./ COPY priv priv COPY lib lib COPY test test +COPY scripts/production-play-physical-fixture.exs scripts/production-play-physical-fixture.exs RUN mix compile diff --git a/README.md b/README.md index 81a87a0..7365c98 100644 --- a/README.md +++ b/README.md @@ -588,6 +588,24 @@ run-scoped loopback SSH tunnel, and executes fixture preparation, verification, and exact cleanup next to the remote test database. It does not delete unrelated records. +The exact production foreground-service recording flow has a separate +run-scoped operator fixture. It creates one temporary browser account and one +matched request for an already confirmed physical-device helper, retains its +exact IDs in ignored mode-`0600` state, verifies the active and stopped location +states, and performs exact cleanup: + +```bash +# Run only from /srv/who_need_help-production after reading the Play declaration. +./scripts/production-play-physical-fixture.sh \ + plan HELPER_EMAIL --check-only whoneedhelp.com .env +``` + +The mutating `prepare`, `verify-active`, `verify-stopped`, and `cleanup` +commands are documented in +`android/play-store/location-and-fgs-declaration.md`. The wrapper refuses the +independent hackathon test checkout and any unexpected production root, origin, +Compose project, image, health state, or database. + ## First administrator Register and confirm the first account, then explicitly bootstrap it: diff --git a/android/play-store/location-and-fgs-declaration.md b/android/play-store/location-and-fgs-declaration.md index 05b42a1..2048130 100644 --- a/android/play-store/location-and-fgs-declaration.md +++ b/android/play-store/location-and-fgs-declaration.md @@ -95,6 +95,59 @@ permission or disclosure screen. Do not use a real home address, real medical information, chat text, email, handover code, access token, or another person's location in the recording. +### Reproducible production fixture + +The production operator script creates one run-scoped requester with a temporary +password, one synthetic matched medicine request, and one accepted assignment +for an existing confirmed helper. It refuses any root, Compose project, public +origin, image, health state, or database other than the explicitly verified +production values. It stores the exact IDs and temporary credentials only in +ignored mode-`0600` runtime files so cleanup can be resumed after a container +restart. + +Run the read-only plan first from `/srv/who_need_help-production`: + +```bash +./scripts/production-play-physical-fixture.sh \ + plan HELPER_EMAIL --check-only whoneedhelp.com .env +``` + +Prepare the recording fixture only when the helper is signed into the exact +Play-delivered build: + +```bash +./scripts/production-play-physical-fixture.sh \ + prepare HELPER_EMAIL --confirm whoneedhelp.com .env +``` + +Use the printed temporary requester email and password in the recipient browser. +Do not copy those credentials into documentation, Play Console, chat, email, or +the recording. After location sharing starts, verify the server-side active +state; after using the notification Stop action, verify deletion: + +```bash +./scripts/production-play-physical-fixture.sh \ + verify-active --confirm whoneedhelp.com .env + +./scripts/production-play-physical-fixture.sh \ + verify-stopped --confirm whoneedhelp.com .env +``` + +Always remove the fixture immediately after the recording, including after an +aborted take: + +```bash +./scripts/production-play-physical-fixture.sh \ + cleanup --confirm whoneedhelp.com .env +``` + +Cleanup stops an exact still-active fixture session before deleting its current +raw position, tracking session, messages, notifications/jobs, assignment, +request, temporary tokens/rate-limit buckets, and requester. It then verifies +that the three primary fixture records were deleted and removes the local +runtime state. Never delete the runtime manifest manually while its fixture may +still exist. + ## Pre-submission evidence - Run `./scripts/android-play-policy-check.sh`. diff --git a/scripts/production-play-physical-fixture.exs b/scripts/production-play-physical-fixture.exs index e73413a..e670c97 100644 --- a/scripts/production-play-physical-fixture.exs +++ b/scripts/production-play-physical-fixture.exs @@ -69,6 +69,7 @@ defmodule WhoNeedHelp.ProductionPlayPhysicalFixture do now = DateTime.utc_now(:second) category = Repo.get_by!(Category, slug: "medicine-pickup", active: true) + requester_password = temporary_password() {:ok, fixture} = Repo.transaction(fn -> @@ -82,6 +83,8 @@ defmodule WhoNeedHelp.ProductionPlayPhysicalFixture do }) |> Ecto.Changeset.put_change(:confirmed_at, now) |> Repo.insert!() + |> User.password_changeset(%{"password" => requester_password}) + |> Repo.update!() request = %HelpRequest{requester_id: requester.id} @@ -117,10 +120,14 @@ defmodule WhoNeedHelp.ProductionPlayPhysicalFixture do end) manifest = %{ - "schema_version" => 1, + "schema_version" => 2, "run_id" => context.run_id, "database" => context.database, - "requester" => %{"id" => fixture.requester.id, "email" => fixture.requester.email}, + "requester" => %{ + "id" => fixture.requester.id, + "email" => fixture.requester.email, + "password" => requester_password + }, "helper" => %{"id" => fixture.helper.id, "email" => fixture.helper.email}, "request" => %{ "id" => fixture.request.id, @@ -133,6 +140,9 @@ defmodule WhoNeedHelp.ProductionPlayPhysicalFixture do File.chmod!(context.manifest_path, 0o600) IO.puts("fixture_prepared=true") IO.puts("request_path=#{manifest["request"]["path"]}") + IO.puts("requester_email=#{fixture.requester.email}") + IO.puts("requester_password=#{requester_password}") + IO.puts("credentials_are_temporary=true") end defp verify_active(context) do @@ -254,10 +264,11 @@ defmodule WhoNeedHelp.ProductionPlayPhysicalFixture do manifest = context.manifest_path |> File.read!() |> Jason.decode!() valid? = - manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and + manifest["schema_version"] == 2 and manifest["run_id"] == context.run_id and manifest["database"] == context.database and manifest["requester"]["email"] == context.requester_email and manifest["helper"]["email"] == context.helper_email and + temporary_password?(manifest["requester"]["password"]) and uuid?(manifest["requester"]["id"]) and uuid?(manifest["helper"]["id"]) and uuid?(manifest["request"]["id"]) and uuid?(manifest["assignment"]["id"]) and manifest["request"]["path"] == "/requests/#{manifest["request"]["id"]}" @@ -276,6 +287,7 @@ defmodule WhoNeedHelp.ProductionPlayPhysicalFixture do match?(%User{}, requester) and match?(%User{}, helper) and match?(%HelpRequest{}, request) and match?(%Assignment{}, assignment) and requester.email == manifest["requester"]["email"] and + User.valid_password?(requester, manifest["requester"]["password"]) and helper.email == manifest["helper"]["email"] and request.requester_id == requester.id and assignment.request_id == request.id and assignment.helper_id == helper.id and assignment.status in [:accepted, :in_progress] and assignment.active @@ -351,6 +363,17 @@ defmodule WhoNeedHelp.ProductionPlayPhysicalFixture do end end + defp temporary_password do + 32 + |> :crypto.strong_rand_bytes() + |> Base.url_encode64(padding: false) + end + + defp temporary_password?(password) when is_binary(password), + do: byte_size(password) >= 32 and byte_size(password) <= 72 + + defp temporary_password?(_password), do: false + defp uuid?(value) when is_binary(value), do: match?({:ok, _}, Ecto.UUID.cast(value)) defp uuid?(_), do: false -end \ No newline at end of file +end diff --git a/scripts/production-play-physical-fixture.sh b/scripts/production-play-physical-fixture.sh new file mode 100755 index 0000000..896afcc --- /dev/null +++ b/scripts/production-play-physical-fixture.sh @@ -0,0 +1,301 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +EXPECTED_ROOT=/srv/who_need_help-production +EXPECTED_PROJECT=who_need_help_production +EXPECTED_ORIGIN=https://whoneedhelp.com +STATE_FILE="$ROOT/output/runtime/production-play-physical.env" +HOST_MANIFEST="$ROOT/output/runtime/production-play-physical-manifest.json" + +usage() { + cat >&2 <<'EOF' +Usage: + ./scripts/production-play-physical-fixture.sh plan HELPER_EMAIL --check-only whoneedhelp.com ENV_FILE + ./scripts/production-play-physical-fixture.sh prepare HELPER_EMAIL --confirm whoneedhelp.com ENV_FILE + ./scripts/production-play-physical-fixture.sh verify-active --confirm whoneedhelp.com ENV_FILE + ./scripts/production-play-physical-fixture.sh verify-stopped --confirm whoneedhelp.com ENV_FILE + ./scripts/production-play-physical-fixture.sh cleanup --confirm whoneedhelp.com ENV_FILE + +prepare creates one run-scoped requester, request and accepted assignment. The +other actions use the ignored mode-0600 state created by prepare. cleanup is +the only supported way to remove the exact fixture after recording. +EOF + exit 1 +} + +read_env() { + local file=$1 + local key=$2 + + awk -F= -v key="$key" ' + $1 == key { + value = substr($0, index($0, "=") + 1) + sub(/\r$/, "", value) + + if ((value ~ /^".*"$/) || (value ~ /^\047.*\047$/)) { + value = substr(value, 2, length(value) - 2) + } + + count++ + } + + END { + if (count == 1) print value + else exit 1 + } + ' "$file" +} + +read_state() { + local key=$1 + read_env "$STATE_FILE" "$key" +} + +encode() { + printf %s "$1" | base64 | tr -d '\n' +} + +copy_into_container() { + local source=$1 + local destination=$2 + + docker exec -i "$CONTAINER" sh -c \ + 'umask 077; cat >"$1"' sh "$destination" <"$source" +} + +copy_from_container() { + local source=$1 + local destination=$2 + + docker exec "$CONTAINER" cat "$source" >"$destination" +} + +if [[ $# -lt 4 || $# -gt 5 ]]; then + usage +fi + +ACTION=$1 +shift + +case "$ACTION" in + plan | prepare) + [[ $# -eq 4 ]] || usage + HELPER_EMAIL=${1,,} + CONFIRMATION=$2 + HOST=$3 + ENV_FILE=$4 + ;; + verify-active | verify-stopped | cleanup) + [[ $# -eq 3 ]] || usage + HELPER_EMAIL= + CONFIRMATION=$1 + HOST=$2 + ENV_FILE=$3 + ;; + *) usage ;; +esac + +if [[ "$ACTION" == plan ]]; then + [[ "$CONFIRMATION" == --check-only ]] || usage +else + [[ "$CONFIRMATION" == --confirm ]] || usage +fi + +[[ "$HOST" == whoneedhelp.com ]] || usage + +if [[ "$(realpath --canonicalize-existing "$ROOT")" != "$EXPECTED_ROOT" ]]; then + echo "Physical Play fixtures may only run from $EXPECTED_ROOT." >&2 + exit 1 +fi + +if [[ "$ENV_FILE" != /* ]]; then + ENV_FILE="$ROOT/$ENV_FILE" +fi + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Environment file does not exist: $ENV_FILE" >&2 + exit 1 +fi + +if [[ "$(read_env "$ENV_FILE" DEPLOYMENT_ENV)" != production ]]; then + echo "Physical Play fixtures require DEPLOYMENT_ENV=production." >&2 + exit 1 +fi + +if [[ "$(read_env "$ENV_FILE" DEPLOYMENT_TARGET)" != compose ]]; then + echo "Physical Play fixtures require DEPLOYMENT_TARGET=compose." >&2 + exit 1 +fi + +PROJECT=$(read_env "$ENV_FILE" COMPOSE_PROJECT_NAME) +if [[ "$PROJECT" != "$EXPECTED_PROJECT" ]]; then + echo "Unexpected production Compose project: $PROJECT" >&2 + exit 1 +fi + +if [[ "$(read_env "$ENV_FILE" WNH_BASE_URL)" != "$EXPECTED_ORIGIN" ]]; then + echo "Production origin must be $EXPECTED_ORIGIN." >&2 + exit 1 +fi + +EXPECTED_DATABASE=$(read_env "$ENV_FILE" POSTGRES_DB) +if [[ -z "$EXPECTED_DATABASE" ]]; then + echo "POSTGRES_DB must identify the expected production database." >&2 + exit 1 +fi + +CONTAINER=$("$ROOT/scripts/compose.sh" "$ENV_FILE" ps -q app | head -n 1) +if [[ -z "$CONTAINER" ]]; then + CONTAINER=$("$ROOT/scripts/compose.sh" "$ENV_FILE" ps -q web | head -n 1) +fi + +if [[ -z "$CONTAINER" ]]; then + echo "No running production app or web container was found for $PROJECT." >&2 + exit 1 +fi + +EXPECTED_IMAGE=$(read_env "$ENV_FILE" APP_IMAGE) +OBSERVED_IMAGE=$(docker inspect --format '{{.Config.Image}}' "$CONTAINER") +CONTAINER_STATE=$(docker inspect --format '{{.State.Status}}' "$CONTAINER") +CONTAINER_HEALTH=$(docker inspect \ + --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CONTAINER") + +if [[ "$OBSERVED_IMAGE" != "$EXPECTED_IMAGE" ]]; then + echo "Running container image does not match APP_IMAGE." >&2 + exit 1 +fi + +if [[ "$CONTAINER_STATE" != running || "$CONTAINER_HEALTH" != healthy ]]; then + echo "Production application container is not running and healthy." >&2 + exit 1 +fi + +ACTUAL_DATABASE=$(docker exec "$CONTAINER" /app/bin/who_need_help rpc \ + '%Postgrex.Result{rows: [[database]]} = WhoNeedHelp.Repo.query!("SELECT current_database()", [], log: false); IO.puts(database)' | + tail -n 1) + +if [[ "$ACTUAL_DATABASE" != "$EXPECTED_DATABASE" ]]; then + echo "Database identity mismatch: expected $EXPECTED_DATABASE, observed $ACTUAL_DATABASE." >&2 + exit 1 +fi + +if [[ "$ACTION" == plan ]]; then + if [[ ! "$HELPER_EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+$ ]]; then + echo "HELPER_EMAIL is invalid." >&2 + exit 1 + fi + + if [[ -e "$STATE_FILE" || -e "$HOST_MANIFEST" ]]; then + echo "A physical Play fixture state already exists; inspect and clean it first." >&2 + exit 1 + fi + + HELPER=$(encode "$HELPER_EMAIL") + docker exec "$CONTAINER" /app/bin/who_need_help rpc \ + "require Ecto.Query; email = Base.decode64!(\"$HELPER\"); user = WhoNeedHelp.Repo.get_by(WhoNeedHelp.Accounts.User, email: email); unless match?(%WhoNeedHelp.Accounts.User{confirmed_at: %DateTime{}, moderation_status: :active}, user), do: raise(\"helper is missing, unconfirmed, or inactive\"); active_query = Ecto.Query.from(s in WhoNeedHelp.Tracking.TrackingSession, where: s.user_id == ^user.id and s.active); if WhoNeedHelp.Repo.exists?(active_query), do: raise(\"helper already has an active tracking session\"); IO.puts(\"helper_ready=true\")" + + printf 'scope=one temporary requester, one matched request, one accepted assignment\n' + printf 'database=%s\nimage=%s\ncontainer=%s\n' \ + "$ACTUAL_DATABASE" "$OBSERVED_IMAGE" "$CONTAINER" + printf 'cleanup=exact run-scoped IDs retained in mode-0600 state\n' + exit 0 +fi + +mkdir -p "$ROOT/output/runtime" +chmod 700 "$ROOT/output/runtime" +umask 077 + +if [[ "$ACTION" == prepare ]]; then + if [[ ! "$HELPER_EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+$ ]]; then + echo "HELPER_EMAIL is invalid." >&2 + exit 1 + fi + + if [[ -e "$STATE_FILE" || -e "$HOST_MANIFEST" ]]; then + echo "A physical Play fixture state already exists; cleanup is required first." >&2 + exit 1 + fi + + RUN_ID="$(date -u +%Y%m%d%H%M%S)-$(tr -d - "$STATE_FILE" <&2 + exit 1 + fi + + if [[ "$(read_state schema_version)" != 1 ]]; then + echo "Unsupported physical Play fixture state version." >&2 + exit 1 + fi + + RUN_ID=$(read_state run_id) + EXPECTED_DATABASE_FROM_STATE=$(read_state expected_database) + HELPER_EMAIL=$(read_state helper_email) + CONTAINER_MANIFEST=$(read_state container_manifest) + CONTAINER_SCRIPT=$(read_state container_script) + STATE_IMAGE=$(read_state image) + + if [[ "$EXPECTED_DATABASE_FROM_STATE" != "$EXPECTED_DATABASE" || + "$STATE_IMAGE" != "$OBSERVED_IMAGE" ]]; then + echo "Current production database or image does not match the recorded fixture state." >&2 + exit 1 + fi +fi + +if ! copy_into_container \ + "$ROOT/scripts/production-play-physical-fixture.exs" "$CONTAINER_SCRIPT"; then + if [[ "$ACTION" == prepare ]]; then + rm -f "$STATE_FILE" + fi + + echo "Could not copy the operator script into the container tmpfs." >&2 + exit 1 +fi + +if [[ "$ACTION" != prepare ]]; then + if [[ ! -f "$HOST_MANIFEST" ]]; then + echo "The mode-0600 host manifest is missing; refusing an unverifiable action." >&2 + exit 1 + fi + + copy_into_container "$HOST_MANIFEST" "$CONTAINER_MANIFEST" +fi + +RUN=$(encode "$RUN_ID") +DATABASE=$(encode "$EXPECTED_DATABASE") +HELPER=$(encode "$HELPER_EMAIL") +MANIFEST=$(encode "$CONTAINER_MANIFEST") + +EXPRESSION="Code.require_file(\"$CONTAINER_SCRIPT\"); WhoNeedHelp.ProductionPlayPhysicalFixture.run(\"$ACTION\", %{run_id: Base.decode64!(\"$RUN\"), expected_database: Base.decode64!(\"$DATABASE\"), helper_email: Base.decode64!(\"$HELPER\"), manifest_path: Base.decode64!(\"$MANIFEST\")})" + +if ! docker exec "$CONTAINER" /app/bin/who_need_help rpc "$EXPRESSION"; then + echo "Fixture action failed. State was retained for inspection and exact cleanup." >&2 + exit 1 +fi + +if [[ "$ACTION" == prepare ]]; then + copy_from_container "$CONTAINER_MANIFEST" "$HOST_MANIFEST.tmp" + chmod 600 "$HOST_MANIFEST.tmp" + mv "$HOST_MANIFEST.tmp" "$HOST_MANIFEST" + echo "host_state=$STATE_FILE" + echo "host_manifest=$HOST_MANIFEST" + echo "Record the video now; do not leave the fixture active after recording." +elif [[ "$ACTION" == cleanup ]]; then + docker exec "$CONTAINER" rm -f "$CONTAINER_SCRIPT" "$CONTAINER_MANIFEST" + rm -f "$HOST_MANIFEST" "$STATE_FILE" + echo "host_fixture_state_removed=true" +fi diff --git a/test/who_need_help/production_play_physical_fixture_test.exs b/test/who_need_help/production_play_physical_fixture_test.exs new file mode 100644 index 0000000..9481345 --- /dev/null +++ b/test/who_need_help/production_play_physical_fixture_test.exs @@ -0,0 +1,55 @@ +defmodule WhoNeedHelp.ProductionPlayPhysicalFixtureTest do + use WhoNeedHelp.DataCase, async: false + + import ExUnit.CaptureIO + import WhoNeedHelp.AccountsFixtures + + alias WhoNeedHelp.Accounts.User + alias WhoNeedHelp.Catalog + alias WhoNeedHelp.Repo + + Code.require_file("scripts/production-play-physical-fixture.exs") + + test "prepares temporary browser credentials and removes their complete fixture" do + Catalog.seed_defaults() + helper = user_fixture(display_name: "Physical Play helper") + run_id = "fixture-#{System.unique_integer([:positive])}" + manifest_path = "/tmp/wnh-play-physical-#{run_id}.json" + + on_exit(fn -> File.rm(manifest_path) end) + + %Postgrex.Result{rows: [[database]]} = + Repo.query!("SELECT current_database()", [], log: false) + + options = %{ + run_id: run_id, + expected_database: database, + helper_email: helper.email, + manifest_path: manifest_path + } + + prepare_output = + capture_io(fn -> + WhoNeedHelp.ProductionPlayPhysicalFixture.run("prepare", options) + end) + + manifest = manifest_path |> File.read!() |> Jason.decode!() + requester = Repo.get!(User, manifest["requester"]["id"]) + + assert prepare_output =~ "fixture_prepared=true" + assert prepare_output =~ "credentials_are_temporary=true" + assert manifest["schema_version"] == 2 + assert manifest["request"]["path"] =~ "/requests/" + assert byte_size(manifest["requester"]["password"]) >= 32 + assert User.valid_password?(requester, manifest["requester"]["password"]) + + cleanup_output = + capture_io(fn -> + WhoNeedHelp.ProductionPlayPhysicalFixture.run("cleanup", options) + end) + + assert cleanup_output =~ "fixture_cleanup_verified=true" + refute File.exists?(manifest_path) + refute Repo.get(User, requester.id) + end +end