who_need_help/scripts/production-android-fcm-smoke.exs

246 lines
8.1 KiB
Elixir

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")
manifest_ownership_token = Ecto.UUID.generate()
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
fixture =
try do
{:ok, committed_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!()
manifest = %{
"schema_version" => 1,
"run_id" => context.run_id,
"database" => context.database,
"ownership_token" => manifest_ownership_token,
"idempotency_key" => context.idempotency_key,
"notification_id" => notification.id,
"device_id" => device.id,
"job_id" => job.id
}
persist_manifest!(context.manifest_path, manifest)
%{notification: notification, job: job}
end)
committed_fixture
rescue
exception ->
remove_owned_manifest(context, manifest_ownership_token)
reraise exception, __STACKTRACE__
end
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
uuid?(manifest["ownership_token"]) 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 persist_manifest!(path, manifest) do
encoded = Jason.encode_to_iodata!(manifest, pretty: true)
File.open!(path, [:write, :binary, :exclusive], fn file ->
IO.binwrite(file, encoded)
end)
try do
File.chmod!(path, 0o600)
rescue
exception ->
File.rm(path)
reraise exception, __STACKTRACE__
end
end
defp remove_owned_manifest(context, ownership_token) do
with {:ok, encoded} <- File.read(context.manifest_path),
{:ok, manifest} <- Jason.decode(encoded),
true <- manifest["run_id"] == context.run_id,
true <- manifest["database"] == context.database,
true <- manifest["device_id"] == context.device_id,
true <- manifest["idempotency_key"] == context.idempotency_key,
true <- manifest["ownership_token"] == ownership_token do
File.rm(context.manifest_path)
else
_missing_or_different_manifest -> :ok
end
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