Prepare reproducible Play location video fixture

This commit is contained in:
SimpleTest 2026-08-09 18:30:45 +03:00
parent 95caa5d38e
commit 16956c62e5
6 changed files with 455 additions and 4 deletions

View File

@ -152,6 +152,7 @@ COPY .dialyzer_ignore.exs .formatter.exs ./
COPY priv priv COPY priv priv
COPY lib lib COPY lib lib
COPY test test COPY test test
COPY scripts/production-play-physical-fixture.exs scripts/production-play-physical-fixture.exs
RUN mix compile RUN mix compile

View File

@ -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 and exact cleanup next to the remote test database. It does not delete
unrelated records. 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 ## First administrator
Register and confirm the first account, then explicitly bootstrap it: Register and confirm the first account, then explicitly bootstrap it:

View File

@ -95,6 +95,59 @@ permission or disclosure screen.
Do not use a real home address, real medical information, chat text, email, 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. 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 ## Pre-submission evidence
- Run `./scripts/android-play-policy-check.sh`. - Run `./scripts/android-play-policy-check.sh`.

View File

@ -69,6 +69,7 @@ defmodule WhoNeedHelp.ProductionPlayPhysicalFixture do
now = DateTime.utc_now(:second) now = DateTime.utc_now(:second)
category = Repo.get_by!(Category, slug: "medicine-pickup", active: true) category = Repo.get_by!(Category, slug: "medicine-pickup", active: true)
requester_password = temporary_password()
{:ok, fixture} = {:ok, fixture} =
Repo.transaction(fn -> Repo.transaction(fn ->
@ -82,6 +83,8 @@ defmodule WhoNeedHelp.ProductionPlayPhysicalFixture do
}) })
|> Ecto.Changeset.put_change(:confirmed_at, now) |> Ecto.Changeset.put_change(:confirmed_at, now)
|> Repo.insert!() |> Repo.insert!()
|> User.password_changeset(%{"password" => requester_password})
|> Repo.update!()
request = request =
%HelpRequest{requester_id: requester.id} %HelpRequest{requester_id: requester.id}
@ -117,10 +120,14 @@ defmodule WhoNeedHelp.ProductionPlayPhysicalFixture do
end) end)
manifest = %{ manifest = %{
"schema_version" => 1, "schema_version" => 2,
"run_id" => context.run_id, "run_id" => context.run_id,
"database" => context.database, "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}, "helper" => %{"id" => fixture.helper.id, "email" => fixture.helper.email},
"request" => %{ "request" => %{
"id" => fixture.request.id, "id" => fixture.request.id,
@ -133,6 +140,9 @@ defmodule WhoNeedHelp.ProductionPlayPhysicalFixture do
File.chmod!(context.manifest_path, 0o600) File.chmod!(context.manifest_path, 0o600)
IO.puts("fixture_prepared=true") IO.puts("fixture_prepared=true")
IO.puts("request_path=#{manifest["request"]["path"]}") 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 end
defp verify_active(context) do defp verify_active(context) do
@ -254,10 +264,11 @@ defmodule WhoNeedHelp.ProductionPlayPhysicalFixture do
manifest = context.manifest_path |> File.read!() |> Jason.decode!() manifest = context.manifest_path |> File.read!() |> Jason.decode!()
valid? = 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["database"] == context.database and
manifest["requester"]["email"] == context.requester_email and manifest["requester"]["email"] == context.requester_email and
manifest["helper"]["email"] == context.helper_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["requester"]["id"]) and uuid?(manifest["helper"]["id"]) and
uuid?(manifest["request"]["id"]) and uuid?(manifest["assignment"]["id"]) and uuid?(manifest["request"]["id"]) and uuid?(manifest["assignment"]["id"]) and
manifest["request"]["path"] == "/requests/#{manifest["request"]["id"]}" manifest["request"]["path"] == "/requests/#{manifest["request"]["id"]}"
@ -276,6 +287,7 @@ defmodule WhoNeedHelp.ProductionPlayPhysicalFixture do
match?(%User{}, requester) and match?(%User{}, helper) and match?(%User{}, requester) and match?(%User{}, helper) and
match?(%HelpRequest{}, request) and match?(%Assignment{}, assignment) and match?(%HelpRequest{}, request) and match?(%Assignment{}, assignment) and
requester.email == manifest["requester"]["email"] 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 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.request_id == request.id and assignment.helper_id == helper.id and
assignment.status in [:accepted, :in_progress] and assignment.active assignment.status in [:accepted, :in_progress] and assignment.active
@ -351,6 +363,17 @@ defmodule WhoNeedHelp.ProductionPlayPhysicalFixture do
end end
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?(value) when is_binary(value), do: match?({:ok, _}, Ecto.UUID.cast(value))
defp uuid?(_), do: false defp uuid?(_), do: false
end end

View File

@ -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 - </proc/sys/kernel/random/uuid | cut -c1-12)"
CONTAINER_MANIFEST="/tmp/wnh-play-physical-$RUN_ID.json"
CONTAINER_SCRIPT="/tmp/wnh-play-physical-$RUN_ID.exs"
cat >"$STATE_FILE" <<EOF
schema_version=1
run_id=$RUN_ID
expected_database=$EXPECTED_DATABASE
helper_email=$HELPER_EMAIL
container_manifest=$CONTAINER_MANIFEST
container_script=$CONTAINER_SCRIPT
image=$OBSERVED_IMAGE
EOF
chmod 600 "$STATE_FILE"
else
if [[ ! -f "$STATE_FILE" ]]; then
echo "No physical Play fixture state exists." >&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

View File

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