Add scoped Android FCM production smoke

This commit is contained in:
SimpleTest 2026-08-25 16:08:40 +03:00
parent 15c976213a
commit 4f81c38717
2 changed files with 405 additions and 0 deletions

View File

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

View File

@ -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 - </proc/sys/kernel/random/uuid | cut -c1-12)"
REMOTE_SCRIPT="/tmp/wnh-production-android-fcm-$RUN_ID.exs"
REMOTE_MANIFEST="/tmp/wnh-production-android-fcm-$RUN_ID.json"
cat >"$STATE_FILE" <<EOF
schema_version=1
run_id=$RUN_ID
ssh_target=$SSH_TARGET
container=$CONTAINER
device_id=$DEVICE_ID
remote_script=$REMOTE_SCRIPT
remote_manifest=$REMOTE_MANIFEST
EOF
chmod 600 "$STATE_FILE"
ssh -o BatchMode=yes "$SSH_TARGET" \
"docker exec -i '$CONTAINER' sh -c 'umask 077; cat >\"$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