259 lines
8.6 KiB
Elixir
259 lines
8.6 KiB
Elixir
defmodule WhoNeedHelp.ProductionWebPushSmoke do
|
|
import Ecto.Query
|
|
|
|
alias Oban.Job
|
|
alias WhoNeedHelp.Accounts.User
|
|
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 Web Push smoke action")
|
|
|
|
defp verified_context(options) do
|
|
run_id = required_option!(options, :run_id)
|
|
expected_database = required_option!(options, :expected_database)
|
|
user_email = required_option!(options, :user_email) |> String.downcase()
|
|
manifest_path = required_option!(options, :manifest_path)
|
|
|
|
unless Regex.match?(~r/^[a-z0-9-]+$/, run_id), do: raise("invalid run id")
|
|
|
|
unless manifest_path == "/tmp/wnh-production-web-push-#{run_id}.json" 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,
|
|
user_email: user_email,
|
|
manifest_path: manifest_path,
|
|
idempotency_key: "production-web-push-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()
|
|
|
|
user = Repo.get_by(User, email: context.user_email)
|
|
|
|
unless match?(%User{confirmed_at: %DateTime{}, moderation_status: :active}, user) do
|
|
raise "target user is missing, unconfirmed, or inactive"
|
|
end
|
|
|
|
device =
|
|
PushDevice
|
|
|> where(
|
|
[device],
|
|
device.user_id == ^user.id and device.platform == :web and
|
|
device.provider == :web_push and is_nil(device.disabled_at)
|
|
)
|
|
|> order_by([device], desc: device.last_seen_at, desc: device.inserted_at)
|
|
|> limit(1)
|
|
|> Repo.one()
|
|
|
|
unless match?(%PushDevice{}, device), do: raise("no active Web Push device exists")
|
|
|
|
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: user.id,
|
|
kind: :support_update,
|
|
title: "Who Need Help notification check",
|
|
body: "Production browser 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,
|
|
"user_email" => context.user_email,
|
|
"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, device: device, job: job}
|
|
end)
|
|
|
|
committed_fixture
|
|
rescue
|
|
exception ->
|
|
remove_owned_manifest(context, manifest_ownership_token)
|
|
reraise exception, __STACKTRACE__
|
|
end
|
|
|
|
IO.puts("web_push_smoke_prepared=true")
|
|
IO.puts("job_id=#{fixture.job.id}")
|
|
IO.puts("delivery_scope=latest active Web Push device only")
|
|
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
|
|
notification.user_id == device.user_id and
|
|
notification.idempotency_key == context.idempotency_key and
|
|
match?(%PushDevice{platform: :web, provider: :web_push, disabled_at: nil}, device) and
|
|
match?(%Job{state: "completed", attempt: 1}, job) and
|
|
job.args["notification_id"] == notification.id and
|
|
job.args["device_id"] == device.id do
|
|
raise "Web Push smoke has not completed successfully on the exact target"
|
|
end
|
|
|
|
IO.puts("web_push_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 Web Push 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("web_push_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["user_email"] == context.user_email and
|
|
uuid?(manifest["ownership_token"]) and
|
|
manifest["idempotency_key"] == context.idempotency_key and
|
|
uuid?(manifest["notification_id"]) and uuid?(manifest["device_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["user_email"] == context.user_email,
|
|
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
|