246 lines
8.1 KiB
Elixir
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
|