diff --git a/android/play-store/review-access.md b/android/play-store/review-access.md index 2044631..c0cbc68 100644 --- a/android/play-store/review-access.md +++ b/android/play-store/review-access.md @@ -44,3 +44,18 @@ access field: - Confirm all data is synthetic and no real user can be messaged or located. - Confirm the password works from a clean Play-delivered install without a second factor, one-time code, developer browser session, or location gate. + +After both dedicated accounts have registered, confirmed their email, and set +their fixed passwords through the production UI, an operator can create the +stable synthetic records from the production checkout: + +```bash +./scripts/prepare-play-review.sh \ + REVIEWER_EMAIL COUNTERPART_EMAIL \ + --confirm whoneedhelp.com \ + /srv/who_need_help-production/.env +``` + +The command does not create or change credentials. It refuses missing, +unconfirmed, suspended, passwordless, staff, or duplicate accounts and is +idempotent for its one request and one activity. diff --git a/docs/google-play-pre-upload-audit-2026-08-03.md b/docs/google-play-pre-upload-audit-2026-08-03.md index 6556b6d..b00492c 100644 --- a/docs/google-play-pre-upload-audit-2026-08-03.md +++ b/docs/google-play-pre-upload-audit-2026-08-03.md @@ -41,8 +41,9 @@ require Play Console. It contains no account credentials or signing keys. - Create a dedicated non-staff production reviewer account with a fixed, reusable password. Put its credentials only in Play Console App access and the operator-controlled password manager. -- Create stable synthetic `Play review` request and activity records with a - second synthetic counterpart, then verify every reviewer instruction from a +- Register and confirm two dedicated non-staff accounts with fixed passwords, + then create their stable synthetic `Play review` request and activity using + `scripts/prepare-play-review.sh`. Verify every reviewer instruction from a clean installation. - Complete App content: App access, Ads, Content rating, Target audience, News-app declaration, Data Safety, background-location declaration if Play diff --git a/lib/who_need_help/release.ex b/lib/who_need_help/release.ex index 9942b45..141b8d2 100644 --- a/lib/who_need_help/release.ex +++ b/lib/who_need_help/release.ex @@ -62,6 +62,251 @@ defmodule WhoNeedHelp.Release do end) end + @play_review_request_title "Play review — medicine pickup" + @play_review_activity_title "Play review — public cinema meetup" + + @doc """ + Prepares stable, synthetic records for Google Play review using two existing + ordinary accounts. + + Both accounts must already be confirmed, active, have accepted the terms, + have a password, and have no staff roles. The function never creates or + changes credentials. Repeating it returns the same request and activity. + """ + def prepare_play_review(reviewer_email, counterpart_email) + when is_binary(reviewer_email) and is_binary(counterpart_email) do + load_app() + + with {:ok, reviewer} <- play_review_user(reviewer_email, :reviewer), + {:ok, counterpart} <- play_review_user(counterpart_email, :counterpart), + :ok <- different_play_review_users(reviewer, counterpart), + {:ok, medicine_category} <- active_category("medicine-pickup", :help), + {:ok, cinema_category} <- active_category("cinema-meetup", :activity), + {:ok, request} <- + ensure_play_review_request(reviewer, counterpart, medicine_category), + {:ok, activity} <- + ensure_play_review_activity(reviewer, counterpart, cinema_category) do + {:ok, + %{ + reviewer_id: reviewer.id, + counterpart_id: counterpart.id, + request_id: request.id, + request_path: "/requests/#{request.id}", + activity_id: activity.id, + activity_path: "/activities/#{activity.id}" + }} + end + end + + def prepare_play_review(_reviewer_email, _counterpart_email), + do: {:error, :invalid_email} + + defp play_review_user(email, role) do + email = email |> String.trim() |> String.downcase() + + case WhoNeedHelp.Repo.get_by(WhoNeedHelp.Accounts.User, email: email) do + nil -> + {:error, {role, :user_not_found}} + + user -> + cond do + user.moderation_status != :active -> + {:error, {role, :account_not_active}} + + is_nil(user.confirmed_at) -> + {:error, {role, :email_not_confirmed}} + + is_nil(user.accepted_terms_at) -> + {:error, {role, :terms_not_accepted}} + + not (is_binary(user.hashed_password) and byte_size(user.hashed_password) > 0) -> + {:error, {role, :password_not_set}} + + WhoNeedHelp.Accounts.staff_roles(user) != [] -> + {:error, {role, :staff_account_forbidden}} + + true -> + {:ok, user} + end + end + end + + defp different_play_review_users(%{id: id}, %{id: id}), + do: {:error, :accounts_must_be_distinct} + + defp different_play_review_users(_reviewer, _counterpart), do: :ok + + defp active_category(slug, mode) do + case WhoNeedHelp.Repo.get_by(WhoNeedHelp.Catalog.Category, + slug: slug, + mode: mode, + active: true + ) do + nil -> {:error, {:category_not_found, slug}} + category -> {:ok, category} + end + end + + defp ensure_play_review_request(reviewer, counterpart, category) do + existing = + WhoNeedHelp.Help.HelpRequest + |> where( + [request], + request.requester_id == ^reviewer.id and + request.title == ^@play_review_request_title and + is_nil(request.hidden_at) + ) + |> order_by([request], desc: request.inserted_at) + |> limit(1) + |> WhoNeedHelp.Repo.one() + + with {:ok, request} <- reusable_or_create_request(existing, reviewer, category), + {:ok, _assignment} <- ensure_play_review_assignment(request, counterpart) do + {:ok, WhoNeedHelp.Repo.get!(WhoNeedHelp.Help.HelpRequest, request.id)} + end + end + + defp reusable_or_create_request( + %{status: status} = request, + _reviewer, + _category + ) + when status in [:open, :matched, :in_progress], + do: {:ok, request} + + defp reusable_or_create_request(nil, reviewer, category) do + WhoNeedHelp.Help.create_request(WhoNeedHelp.Accounts.Scope.for_user(reviewer), %{ + "title" => @play_review_request_title, + "description" => + "Synthetic Google Play review flow. Coordinate only with the assigned review counterpart.", + "pickup_instructions" => + "Synthetic review data only; no medicine, payment, or real-world handover is required.", + "structured_data" => %{"pickup_status" => "already_paid"}, + "location_label" => "Kyiv public review area — synthetic data", + "latitude" => 50.4501, + "longitude" => 30.5234, + "location_radius_meters" => 1_000, + "location_visibility" => "approximate_public", + "urgency" => "scheduled", + "expires_at" => DateTime.add(DateTime.utc_now(:second), 120, :day), + "category_id" => category.id, + "safety_confirmed" => true + }) + end + + defp reusable_or_create_request(_finished_request, _reviewer, _category), + do: {:error, :review_request_not_reusable} + + defp ensure_play_review_assignment(request, counterpart) do + assignment = + WhoNeedHelp.Repo.get_by(WhoNeedHelp.Help.Assignment, + request_id: request.id, + active: true + ) + + case assignment do + nil when request.status == :open -> + WhoNeedHelp.Help.accept_request( + WhoNeedHelp.Accounts.Scope.for_user(counterpart), + request.id + ) + + %{helper_id: helper_id} = assignment when helper_id == counterpart.id -> + {:ok, assignment} + + nil -> + {:error, :review_assignment_missing} + + _other_assignment -> + {:error, :review_request_assigned_to_another_user} + end + end + + defp ensure_play_review_activity(reviewer, counterpart, category) do + existing = + WhoNeedHelp.Activities.Activity + |> where( + [activity], + activity.creator_id == ^reviewer.id and + activity.title == ^@play_review_activity_title and + is_nil(activity.hidden_at) + ) + |> order_by([activity], desc: activity.inserted_at) + |> limit(1) + |> WhoNeedHelp.Repo.one() + + with {:ok, activity} <- reusable_or_create_activity(existing, reviewer, category), + {:ok, participant} <- ensure_play_review_participant(activity, counterpart), + {:ok, _participant} <- approve_play_review_participant(activity, participant, reviewer) do + {:ok, WhoNeedHelp.Repo.get!(WhoNeedHelp.Activities.Activity, activity.id)} + end + end + + defp reusable_or_create_activity(%{status: :open} = activity, _reviewer, _category), + do: {:ok, activity} + + defp reusable_or_create_activity(nil, reviewer, category) do + now = DateTime.utc_now(:second) + + WhoNeedHelp.Activities.create_activity(WhoNeedHelp.Accounts.Scope.for_user(reviewer), %{ + "title" => @play_review_activity_title, + "description" => + "Synthetic Google Play review meetup in a public place. No real attendance is expected.", + "structured_data" => %{"film_or_genre" => "Synthetic public screening"}, + "location_label" => "Kyiv public cinema area — synthetic data", + "latitude" => 50.4508, + "longitude" => 30.5248, + "location_visibility" => "approximate_public", + "starts_at" => DateTime.add(now, 120, :day), + "join_deadline" => DateTime.add(now, 110, :day), + "capacity" => 4, + "category_id" => category.id, + "safety_confirmed" => true + }) + end + + defp reusable_or_create_activity(_finished_activity, _reviewer, _category), + do: {:error, :review_activity_not_reusable} + + defp ensure_play_review_participant(activity, counterpart) do + case WhoNeedHelp.Repo.get_by(WhoNeedHelp.Activities.Participant, + activity_id: activity.id, + user_id: counterpart.id + ) do + nil -> + WhoNeedHelp.Activities.request_to_join( + WhoNeedHelp.Accounts.Scope.for_user(counterpart), + activity.id + ) + + %{status: status} = participant when status in [:requested, :approved] -> + {:ok, participant} + + _participant -> + {:error, :review_participant_not_reusable} + end + end + + defp approve_play_review_participant(_activity, %{status: :approved} = participant, _reviewer), + do: {:ok, participant} + + defp approve_play_review_participant(activity, %{status: :requested} = participant, reviewer) do + case WhoNeedHelp.Activities.approve_participant( + WhoNeedHelp.Accounts.Scope.for_user(reviewer), + participant.id + ) do + {:ok, _activity} -> + {:ok, + WhoNeedHelp.Repo.get_by!(WhoNeedHelp.Activities.Participant, + activity_id: activity.id, + user_id: participant.user_id + )} + + error -> + error + end + end + def await_migrations do load_app() Enum.each(repos(), &await_repo/1) diff --git a/scripts/prepare-play-review.sh b/scripts/prepare-play-review.sh new file mode 100755 index 0000000..b41924a --- /dev/null +++ b/scripts/prepare-play-review.sh @@ -0,0 +1,100 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +EXPECTED_ROOT=/srv/who_need_help-production +EXPECTED_PROJECT=who_need_help_production +EXPECTED_ORIGIN=https://whoneedhelp.com + +if [ "$#" -ne 5 ] || [ "$3" != "--confirm" ] || [ "$4" != "whoneedhelp.com" ]; then + echo "Usage: $0 REVIEWER_EMAIL COUNTERPART_EMAIL --confirm whoneedhelp.com ENV_FILE" >&2 + echo "Both existing accounts must be active, confirmed, password-enabled, and non-staff." >&2 + exit 1 +fi + +REVIEWER_EMAIL=$1 +COUNTERPART_EMAIL=$2 +ENV_FILE=$5 + +if [ "$(realpath --canonicalize-existing "$ROOT")" != "$EXPECTED_ROOT" ]; then + echo "Play review fixtures must run from $EXPECTED_ROOT." >&2 + exit 1 +fi + +if [ ! -f "$ENV_FILE" ]; then + echo "Environment file does not exist: $ENV_FILE" >&2 + exit 1 +fi + +read_env() { + key=$1 + 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 + } + ' "$ENV_FILE" +} + +if [ "$(read_env DEPLOYMENT_ENV)" != production ]; then + echo "Play review fixtures may only target DEPLOYMENT_ENV=production." >&2 + exit 1 +fi + +project=$(read_env COMPOSE_PROJECT_NAME) +if [ "$project" != "$EXPECTED_PROJECT" ]; then + echo "Unexpected production Compose project: $project" >&2 + exit 1 +fi + +if [ "$(read_env WNH_BASE_URL)" != "$EXPECTED_ORIGIN" ]; then + echo "Production origin must be $EXPECTED_ORIGIN." >&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 $project." >&2 + exit 1 +fi + +expected_image=$(read_env 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 + +encode() { + printf %s "$1" | base64 | tr -d '\n' +} + +reviewer=$(encode "$REVIEWER_EMAIL") +counterpart=$(encode "$COUNTERPART_EMAIL") +expression="case WhoNeedHelp.Release.prepare_play_review(Base.decode64!(\"$reviewer\"), Base.decode64!(\"$counterpart\")) do {:ok, result} -> IO.inspect(result); {:error, reason} -> raise \"Play review preparation failed: #{inspect(reason)}\" end" + +docker exec "$container" /app/bin/who_need_help rpc "$expression" diff --git a/test/who_need_help/release_test.exs b/test/who_need_help/release_test.exs new file mode 100644 index 0000000..d27830d --- /dev/null +++ b/test/who_need_help/release_test.exs @@ -0,0 +1,93 @@ +defmodule WhoNeedHelp.ReleaseTest do + use WhoNeedHelp.DataCase, async: false + + import Ecto.Query + import WhoNeedHelp.AccountsFixtures + + alias WhoNeedHelp.Accounts + alias WhoNeedHelp.Activities.{Activity, Participant} + alias WhoNeedHelp.Catalog + alias WhoNeedHelp.Help.{Assignment, HelpRequest} + alias WhoNeedHelp.Release + alias WhoNeedHelp.Repo + + setup do + Catalog.seed_defaults() + + reviewer = user_fixture(display_name: "Play reviewer") |> set_password() + counterpart = user_fixture(display_name: "Play counterpart") |> set_password() + + %{reviewer: reviewer, counterpart: counterpart} + end + + test "prepares stable non-staff reviewer records idempotently", %{ + reviewer: reviewer, + counterpart: counterpart + } do + assert {:ok, first} = Release.prepare_play_review(reviewer.email, counterpart.email) + assert {:ok, second} = Release.prepare_play_review(reviewer.email, counterpart.email) + assert first == second + + assert first.request_path == "/requests/#{first.request_id}" + assert first.activity_path == "/activities/#{first.activity_id}" + assert Accounts.staff_roles(reviewer) == [] + assert Accounts.staff_roles(counterpart) == [] + + assert %HelpRequest{status: :matched, requester_id: reviewer_id} = + Repo.get!(HelpRequest, first.request_id) + + assert reviewer_id == reviewer.id + + assert %Assignment{helper_id: counterpart_id, active: true} = + Repo.get_by!(Assignment, request_id: first.request_id) + + assert counterpart_id == counterpart.id + + assert %Activity{status: :open, creator_id: ^reviewer_id} = + Repo.get!(Activity, first.activity_id) + + assert %Participant{status: :approved, user_id: ^counterpart_id} = + Repo.get_by!(Participant, + activity_id: first.activity_id, + user_id: counterpart.id + ) + + assert Repo.aggregate( + from(request in HelpRequest, + where: + request.requester_id == ^reviewer.id and + request.title == "Play review — medicine pickup" + ), + :count + ) == 1 + + assert Repo.aggregate( + from(activity in Activity, + where: + activity.creator_id == ^reviewer.id and + activity.title == "Play review — public cinema meetup" + ), + :count + ) == 1 + end + + test "rejects an account without a reusable password", %{ + reviewer: reviewer, + counterpart: counterpart + } do + passwordless = user_fixture(display_name: "Passwordless reviewer") + + assert {:error, {:reviewer, :password_not_set}} = + Release.prepare_play_review(passwordless.email, counterpart.email) + + assert {:error, :accounts_must_be_distinct} = + Release.prepare_play_review(reviewer.email, reviewer.email) + end + + test "rejects staff accounts", %{counterpart: counterpart} do + staff = staff_user_fixture([:support], display_name: "Staff reviewer") |> set_password() + + assert {:error, {:reviewer, :staff_account_forbidden}} = + Release.prepare_play_review(staff.email, counterpart.email) + end +end