From 4f81c38717c5c39164e1f6d0834213960380be3e Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Tue, 25 Aug 2026 16:08:40 +0300 Subject: [PATCH] Add scoped Android FCM production smoke --- scripts/production-android-fcm-smoke.exs | 204 +++++++++++++++++++++++ scripts/production-android-fcm-smoke.sh | 201 ++++++++++++++++++++++ 2 files changed, 405 insertions(+) create mode 100644 scripts/production-android-fcm-smoke.exs create mode 100755 scripts/production-android-fcm-smoke.sh diff --git a/scripts/production-android-fcm-smoke.exs b/scripts/production-android-fcm-smoke.exs new file mode 100644 index 0000000..7a2a0a6 --- /dev/null +++ b/scripts/production-android-fcm-smoke.exs @@ -0,0 +1,204 @@ +defmodule WhoNeedHelp.ProductionAndroidFCMSmoke do + import Ecto.Query + + alias Oban.Job + alias WhoNeedHelp.Notifications.{Notification, PushDevice} + alias WhoNeedHelp.Push.DeviceDeliveryWorker + alias WhoNeedHelp.Repo + + @allowed_actions ~w(prepare verify cleanup) + + def run(action, options) when action in @allowed_actions and is_map(options) do + context = verified_context(options) + + case action do + "prepare" -> prepare(context) + "verify" -> verify(context) + "cleanup" -> cleanup(context) + end + end + + def run(_action, _options), do: raise("unsupported Android FCM smoke action") + + defp verified_context(options) do + run_id = required_option!(options, :run_id) + expected_database = required_option!(options, :expected_database) + device_id = required_option!(options, :device_id) + manifest_path = required_option!(options, :manifest_path) + + unless Regex.match?(~r/^[a-z0-9-]+$/, run_id), do: raise("invalid run id") + unless uuid?(device_id), do: raise("invalid device id") + + unless String.starts_with?(manifest_path, "/tmp/wnh-production-android-fcm-") do + raise "invalid manifest path" + end + + %Postgrex.Result{rows: [[actual_database]]} = + Repo.query!("SELECT current_database()", [], log: false) + + unless actual_database == expected_database, do: raise("database identity mismatch") + + %{ + run_id: run_id, + database: actual_database, + device_id: device_id, + manifest_path: manifest_path, + idempotency_key: "production-android-fcm-smoke:#{run_id}" + } + end + + defp prepare(context) do + if File.exists?(context.manifest_path), do: raise("manifest already exists") + + device = Repo.get(PushDevice, context.device_id) + + unless match?( + %PushDevice{platform: :android, provider: :fcm, disabled_at: nil}, + device + ) do + raise "target is not an active Android FCM device" + end + + if Repo.exists?( + from(notification in Notification, + where: notification.idempotency_key == ^context.idempotency_key + ) + ) do + raise "run-scoped notification already exists" + end + + {:ok, fixture} = + Repo.transaction(fn -> + notification = + %Notification{} + |> Notification.changeset(%{ + user_id: device.user_id, + kind: :support_update, + title: "Who Need Help notification check", + body: "Android notifications are working.", + path: "/notifications", + data: %{"run_id" => context.run_id, "synthetic" => true}, + idempotency_key: context.idempotency_key + }) + |> Repo.insert!() + + job = + %{"notification_id" => notification.id, "device_id" => device.id} + |> DeviceDeliveryWorker.new() + |> Oban.insert!() + + %{notification: notification, job: job} + end) + + manifest = %{ + "schema_version" => 1, + "run_id" => context.run_id, + "database" => context.database, + "idempotency_key" => context.idempotency_key, + "notification_id" => fixture.notification.id, + "device_id" => device.id, + "job_id" => fixture.job.id + } + + File.write!(context.manifest_path, Jason.encode_to_iodata!(manifest, pretty: true)) + File.chmod!(context.manifest_path, 0o600) + + IO.puts("android_fcm_smoke_prepared=true") + IO.puts("job_id=#{fixture.job.id}") + IO.puts("delivery_scope=one exact active Android FCM device") + IO.puts("email_enqueued=false") + end + + defp verify(context) do + fixture = load_and_validate_manifest!(context) + notification = Repo.get(Notification, fixture["notification_id"]) + device = Repo.get(PushDevice, fixture["device_id"]) + job = Repo.get(Job, fixture["job_id"]) + + unless match?(%Notification{}, notification) and + match?(%PushDevice{platform: :android, provider: :fcm, disabled_at: nil}, device) and + notification.user_id == device.user_id and + notification.idempotency_key == context.idempotency_key and + match?(%Job{state: "completed", attempt: 1}, job) and + job.args["notification_id"] == notification.id and + job.args["device_id"] == device.id do + raise "Android FCM smoke has not completed successfully on the exact target" + end + + IO.puts("android_fcm_provider_delivery_verified=true") + IO.puts("job_state=#{job.state}") + IO.puts("job_attempt=#{job.attempt}") + IO.puts("device_still_active=true") + end + + defp cleanup(context) do + fixture = load_and_validate_manifest!(context) + job = Repo.get(Job, fixture["job_id"]) + notification = Repo.get(Notification, fixture["notification_id"]) + + unless match?(%Job{}, job) and match?(%Notification{}, notification) and + notification.idempotency_key == context.idempotency_key and + job.args["notification_id"] == notification.id and + job.args["device_id"] == fixture["device_id"] do + raise "run-scoped records no longer match the manifest" + end + + {:ok, deleted} = + Repo.transaction(fn -> + {jobs, _} = Repo.delete_all(from(candidate in Job, where: candidate.id == ^job.id)) + + {notifications, _} = + Repo.delete_all( + from(candidate in Notification, + where: + candidate.id == ^notification.id and + candidate.idempotency_key == ^context.idempotency_key + ) + ) + + %{jobs: jobs, notifications: notifications} + end) + + unless deleted == %{jobs: 1, notifications: 1} do + raise "exact Android FCM smoke cleanup failed" + end + + File.rm!(context.manifest_path) + + if Repo.exists?( + from(candidate in Notification, + where: candidate.idempotency_key == ^context.idempotency_key + ) + ) do + raise "run-scoped notification remains after cleanup" + end + + IO.puts("android_fcm_smoke_cleanup_verified=true") + IO.puts("deleted_jobs=1") + IO.puts("deleted_notifications=1") + end + + defp load_and_validate_manifest!(context) do + manifest = context.manifest_path |> File.read!() |> Jason.decode!() + + valid? = + manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and + manifest["database"] == context.database and + manifest["device_id"] == context.device_id and + manifest["idempotency_key"] == context.idempotency_key and + uuid?(manifest["notification_id"]) and is_integer(manifest["job_id"]) + + unless valid?, do: raise("manifest does not match this exact run") + manifest + end + + defp required_option!(options, name) do + case Map.get(options, name) do + value when is_binary(value) and value != "" -> value + _missing -> raise("#{name} is required") + end + end + + defp uuid?(value) when is_binary(value), do: match?({:ok, _}, Ecto.UUID.cast(value)) + defp uuid?(_value), do: false +end diff --git a/scripts/production-android-fcm-smoke.sh b/scripts/production-android-fcm-smoke.sh new file mode 100755 index 0000000..d4dd8f4 --- /dev/null +++ b/scripts/production-android-fcm-smoke.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +SSH_TARGET=${PRODUCTION_SSH_TARGET:-whoneedhelp} +REMOTE_ROOT=/srv/who_need_help-production +REMOTE_ENV=$REMOTE_ROOT/.env +EXPECTED_PROJECT=who_need_help_production +EXPECTED_ORIGIN=https://whoneedhelp.com +STATE_FILE="$ROOT/output/runtime/production-android-fcm-smoke.env" +LOCAL_SCRIPT="$ROOT/scripts/production-android-fcm-smoke.exs" + +usage() { + cat >&2 <<'EOF' +Usage: + ./scripts/production-android-fcm-smoke.sh plan DEVICE_ID --check-only whoneedhelp.com + ./scripts/production-android-fcm-smoke.sh prepare DEVICE_ID --confirm whoneedhelp.com + ./scripts/production-android-fcm-smoke.sh verify --from-state --confirm whoneedhelp.com + ./scripts/production-android-fcm-smoke.sh cleanup --from-state --confirm whoneedhelp.com + +prepare sends one privacy-safe production notification to the exact active Android +FCM device. verify proves the exact Oban delivery completed on its first attempt. +cleanup removes only the run-scoped notification, job, manifest, and temporary +script. The separate phases leave time to inspect the notification on the phone. +No email worker, Web Push device, frozen test project, Caddy, or public Git is used. +EOF + exit 1 +} + +read_remote_env() { + local key=$1 + + ssh -o BatchMode=yes "$SSH_TARGET" \ + "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}' '$REMOTE_ENV'" +} + +encode() { + printf %s "$1" | base64 | tr -d '\n' +} + +if [[ $# -ne 4 ]]; then + usage +fi + +ACTION=$1 +DEVICE_ID=$2 +CONFIRMATION=$3 +HOST=$4 + +case "$ACTION" in + plan) [[ "$CONFIRMATION" == --check-only ]] || usage ;; + prepare) [[ "$CONFIRMATION" == --confirm ]] || usage ;; + verify | cleanup) + [[ "$DEVICE_ID" == --from-state && "$CONFIRMATION" == --confirm ]] || usage + ;; + *) usage ;; +esac + +[[ "$HOST" == whoneedhelp.com ]] || usage + +if [[ "$ACTION" == plan || "$ACTION" == prepare ]]; then + [[ "$DEVICE_ID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] || usage +fi + +if [[ ! -f "$LOCAL_SCRIPT" ]]; then + echo "Local smoke script is missing: $LOCAL_SCRIPT" >&2 + exit 1 +fi + +DEPLOYMENT_ENV=$(read_remote_env DEPLOYMENT_ENV) +DEPLOYMENT_TARGET=$(read_remote_env DEPLOYMENT_TARGET) +PROJECT=$(read_remote_env COMPOSE_PROJECT_NAME) +ORIGIN=$(read_remote_env WNH_BASE_URL) +EXPECTED_DATABASE=$(read_remote_env POSTGRES_DB) +EXPECTED_IMAGE=$(read_remote_env APP_IMAGE) + +if [[ "$DEPLOYMENT_ENV" != production || "$DEPLOYMENT_TARGET" != compose || + "$PROJECT" != "$EXPECTED_PROJECT" || "$ORIGIN" != "$EXPECTED_ORIGIN" || + -z "$EXPECTED_DATABASE" ]]; then + echo "Remote deployment identity does not match the production target." >&2 + exit 1 +fi + +CONTAINER=$(ssh -o BatchMode=yes "$SSH_TARGET" \ + "cd '$REMOTE_ROOT' && ./scripts/compose.sh '$REMOTE_ENV' ps -q app | head -n 1") + +if [[ -z "$CONTAINER" ]]; then + echo "No running production app container was found." >&2 + exit 1 +fi + +read -r OBSERVED_IMAGE CONTAINER_STATE CONTAINER_HEALTH < <( + ssh -o BatchMode=yes "$SSH_TARGET" \ + "docker inspect --format '{{.Config.Image}} {{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' '$CONTAINER'" +) + +if [[ "$OBSERVED_IMAGE" != "$EXPECTED_IMAGE" || "$CONTAINER_STATE" != running || + "$CONTAINER_HEALTH" != healthy ]]; then + echo "Production container identity or health does not match the environment." >&2 + exit 1 +fi + +ACTUAL_DATABASE=$(ssh -o BatchMode=yes "$SSH_TARGET" \ + "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 "Production database identity mismatch." >&2 + exit 1 +fi + +if [[ "$ACTION" == plan ]]; then + printf 'scope=one production notification and one exact Android FCM delivery job\n' + printf 'device_id=%s\ndatabase=%s\nimage=%s\ncontainer=%s\n' \ + "$DEVICE_ID" "$ACTUAL_DATABASE" "$OBSERVED_IMAGE" "$CONTAINER" + printf 'excluded=email delivery, Web Push, frozen test project, Caddy, public Git\n' + printf 'cleanup=exact run-scoped notification, job, manifest, and temporary script\n' + exit 0 +fi + +mkdir -p "$ROOT/output/runtime" +chmod 700 "$ROOT/output/runtime" +umask 077 + +if [[ "$ACTION" == prepare ]]; then + if [[ -e "$STATE_FILE" ]]; then + echo "A prior Android FCM smoke state exists; inspect it before starting another run." >&2 + exit 1 + fi + + RUN_ID="$(date -u +%Y%m%d%H%M%S)-$(tr -d - "$STATE_FILE" <\"$REMOTE_SCRIPT\"'" <"$LOCAL_SCRIPT" +else + if [[ ! -f "$STATE_FILE" ]]; then + echo "No Android FCM smoke state exists." >&2 + exit 1 + fi + + # shellcheck disable=SC1090 + source "$STATE_FILE" + + if [[ "${schema_version:-}" != 1 || "${ssh_target:-}" != "$SSH_TARGET" || + "${container:-}" != "$CONTAINER" || -z "${run_id:-}" || + -z "${device_id:-}" || -z "${remote_script:-}" || -z "${remote_manifest:-}" ]]; then + echo "Android FCM smoke state does not match the current production target." >&2 + exit 1 + fi + + RUN_ID=$run_id + DEVICE_ID=$device_id + REMOTE_SCRIPT=$remote_script + REMOTE_MANIFEST=$remote_manifest +fi + +RUN=$(encode "$RUN_ID") +DATABASE=$(encode "$EXPECTED_DATABASE") +DEVICE=$(encode "$DEVICE_ID") +MANIFEST=$(encode "$REMOTE_MANIFEST") + +rpc_action() { + local action=$1 + local expression + expression="Code.require_file(\"$REMOTE_SCRIPT\"); WhoNeedHelp.ProductionAndroidFCMSmoke.run(\"$action\", %{run_id: Base.decode64!(\"$RUN\"), expected_database: Base.decode64!(\"$DATABASE\"), device_id: Base.decode64!(\"$DEVICE\"), manifest_path: Base.decode64!(\"$MANIFEST\")})" + + ssh -o BatchMode=yes "$SSH_TARGET" \ + "docker exec '$CONTAINER' /app/bin/who_need_help rpc '$expression'" +} + +case "$ACTION" in + prepare) + if ! rpc_action prepare; then + printf 'Preparation failed; state is preserved for exact inspection: %s\n' "$STATE_FILE" >&2 + exit 1 + fi + printf 'state=%s\nnext=verify notification on the phone, then run verify and cleanup\n' "$STATE_FILE" + ;; + verify) + rpc_action verify + ;; + cleanup) + rpc_action cleanup + ssh -o BatchMode=yes "$SSH_TARGET" \ + "docker exec '$CONTAINER' rm -f '$REMOTE_SCRIPT' '$REMOTE_MANIFEST'" + rm -f "$STATE_FILE" + echo "production_android_fcm_smoke_cleanup_complete=true" + ;; +esac