who_need_help/lib/mix/tasks/wnh.load_fixtures.ex

421 lines
13 KiB
Elixir

defmodule Mix.Tasks.Wnh.LoadFixtures do
use Mix.Task
import Ecto.Query
alias WhoNeedHelp.Accounts.{Scope, User}
alias WhoNeedHelp.Catalog
alias WhoNeedHelp.Help
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
alias WhoNeedHelp.Messaging.Message
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Tracking.{Position, TrackingSession}
alias WhoNeedHelp.Trust.{AbuseSignal, AuditEvent, Report, Review}
@shortdoc "Prepares or removes isolated authenticated load fixtures"
@confirmation "isolated-load-fixtures"
@impl Mix.Task
def run([action]) when action in ["prepare", "cleanup"] do
Mix.Task.run("app.start")
context = verified_context()
case action do
"prepare" -> prepare(context)
"cleanup" -> cleanup(context)
end
end
def run(_args) do
Mix.raise("usage: mix wnh.load_fixtures prepare|cleanup")
end
defp verified_context do
run_id = required_env!("WNH_LOAD_FIXTURE_RUN_ID")
expected_database = required_env!("WNH_LOAD_EXPECTED_DATABASE")
manifest_path = required_env!("WNH_LOAD_FIXTURE_PATH") |> Path.expand()
unless Regex.match?(~r/^[A-Za-z0-9._-]+$/, run_id) do
Mix.raise(
"WNH_LOAD_FIXTURE_RUN_ID may contain only letters, numbers, dot, underscore, and dash"
)
end
unless System.get_env("WNH_LOAD_FIXTURE_CONFIRM") == @confirmation do
Mix.raise("WNH_LOAD_FIXTURE_CONFIRM must equal #{@confirmation}")
end
unless String.starts_with?(manifest_path, "/output/") do
Mix.raise("WNH_LOAD_FIXTURE_PATH must resolve below /output")
end
%Postgrex.Result{rows: [[actual_database]]} =
Repo.query!("SELECT current_database()", [], log: false)
unless actual_database == expected_database do
Mix.raise(
"refusing load-fixture mutation: expected database #{inspect(expected_database)}, " <>
"observed #{inspect(actual_database)}"
)
end
%{
run_id: run_id,
database: actual_database,
manifest_path: manifest_path
}
end
defp prepare(context) do
count = positive_integer_env!("WNH_LOAD_FIXTURE_COUNT")
password = required_env!("LOAD_FIXTURE_PASSWORD")
unless byte_size(password) in 12..72 do
Mix.raise("LOAD_FIXTURE_PASSWORD must contain between 12 and 72 bytes")
end
email_prefix = email_prefix(context.run_id)
if Repo.exists?(
from(user in User,
where: fragment("left(?, ?) = ?", user.email, ^byte_size(email_prefix), ^email_prefix)
)
) do
Mix.raise("fixtures already exist for run #{inspect(context.run_id)}")
end
category = Catalog.seed_defaults()
password_hash = Bcrypt.hash_pwd_salt(password)
now = DateTime.utc_now(:second)
{:ok, fixtures} =
Repo.transaction(fn ->
Enum.map(1..count, fn index ->
requester =
insert_user!(
"#{email_prefix}requester-#{index}@example.invalid",
"Load requester #{index}",
password_hash,
now
)
helper =
insert_user!(
"#{email_prefix}helper-#{index}@example.invalid",
"Load helper #{index}",
password_hash,
now
)
latitude = 50.45 + index / 100_000
longitude = 30.52 + index / 100_000
request =
Help.create_request(Scope.for_user(requester), %{
"title" => "Load medicine pickup #{context.run_id} #{index}",
"description" =>
"Isolated authenticated load fixture for a reserved legal medicine pickup.",
"pickup_instructions" => "Synthetic load fixture; no real pickup.",
"structured_data" => %{"pickup_status" => "reserved"},
"location_label" => "Isolated load location #{index}",
"latitude" => Float.to_string(latitude),
"longitude" => Float.to_string(longitude),
"urgency" => "now",
"location_visibility" => "exact_for_active_match",
"expires_at" => DateTime.add(now, 86_400, :second),
"category_id" => category.id
})
|> unwrap!("create request")
assignment =
Help.accept_request(Scope.for_user(helper), request.id)
|> unwrap!("accept request")
%{
"index" => index,
"requester_id" => requester.id,
"helper_id" => helper.id,
"helper_email" => helper.email,
"request_id" => request.id,
"assignment_id" => assignment.id,
"latitude" => latitude,
"longitude" => longitude
}
end)
end)
manifest = %{
"schema_version" => 1,
"run_id" => context.run_id,
"database" => context.database,
"fixtures" => fixtures
}
context.manifest_path
|> Path.dirname()
|> File.mkdir_p!()
File.write!(context.manifest_path, Jason.encode_to_iodata!(manifest, pretty: true))
# The parent evidence directory is mode 0700 on the host. The manifest
# contains only temporary IDs and emails (never the password) and must be
# readable by the separately containerized, unprivileged k6 process.
File.chmod!(context.manifest_path, 0o644)
Mix.shell().info(
"prepared #{length(fixtures)} fixture pairs for #{context.run_id} in #{context.database}"
)
end
defp cleanup(context) do
manifest = context.manifest_path |> File.read!() |> Jason.decode!()
unless manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and
manifest["database"] == context.database do
Mix.raise("fixture manifest does not match the requested run and database")
end
fixtures = manifest["fixtures"]
unless is_list(fixtures) and fixtures != [] do
Mix.raise("fixture manifest has no non-empty fixture list")
end
user_ids = Enum.flat_map(fixtures, &[&1["requester_id"], &1["helper_id"]])
request_ids = Enum.map(fixtures, & &1["request_id"])
assignment_ids = Enum.map(fixtures, & &1["assignment_id"])
unless Enum.all?(user_ids ++ request_ids ++ assignment_ids, &uuid?/1) do
Mix.raise("fixture manifest contains an invalid identifier")
end
validate_manifest_ownership!(context, fixtures)
{:ok, deleted} =
Repo.transaction(fn ->
tracking_session_ids =
TrackingSession
|> where([session], session.assignment_id in ^assignment_ids)
|> select([session], session.id)
|> Repo.all()
message_ids =
Message
|> where([message], message.assignment_id in ^assignment_ids)
|> select([message], message.id)
|> Repo.all()
report_count =
Report
|> where(
[report],
report.reporter_id in ^user_ids or report.request_id in ^request_ids or
report.assignment_id in ^assignment_ids or report.message_id in ^message_ids
)
|> Repo.delete_all()
|> elem(0)
audit_count =
AuditEvent
|> where(
[event],
event.actor_id in ^user_ids or
(event.target_type == "request" and event.target_id in ^request_ids) or
(event.target_type == "assignment" and event.target_id in ^assignment_ids)
)
|> Repo.delete_all()
|> elem(0)
position_count =
Position
|> where([position], position.tracking_session_id in ^tracking_session_ids)
|> Repo.delete_all()
|> elem(0)
session_count =
TrackingSession
|> where([session], session.id in ^tracking_session_ids)
|> Repo.delete_all()
|> elem(0)
message_count =
Message
|> where([message], message.assignment_id in ^assignment_ids)
|> Repo.delete_all()
|> elem(0)
abuse_count =
AbuseSignal
|> where(
[signal],
signal.subject_id in ^user_ids or signal.assignment_id in ^assignment_ids
)
|> Repo.delete_all()
|> elem(0)
review_count =
Review
|> where(
[review],
review.assignment_id in ^assignment_ids or review.reviewer_id in ^user_ids or
review.reviewee_id in ^user_ids
)
|> Repo.delete_all()
|> elem(0)
assignment_count =
Assignment
|> where([assignment], assignment.id in ^assignment_ids)
|> Repo.delete_all()
|> elem(0)
request_count =
HelpRequest
|> where([request], request.id in ^request_ids)
|> Repo.delete_all()
|> elem(0)
user_count =
User
|> where([user], user.id in ^user_ids)
|> Repo.delete_all()
|> elem(0)
%{
reports: report_count,
audit_events: audit_count,
tracking_positions: position_count,
tracking_sessions: session_count,
messages: message_count,
abuse_signals: abuse_count,
reviews: review_count,
assignments: assignment_count,
requests: request_count,
users: user_count
}
end)
Mix.shell().info(
"removed fixtures for #{context.run_id} from #{context.database}: #{inspect(deleted)}"
)
end
defp insert_user!(email, display_name, password_hash, now) do
%User{}
|> User.registration_changeset(%{
"email" => email,
"display_name" => display_name,
"locale" => "en",
"terms_accepted" => true
})
|> Ecto.Changeset.put_change(:hashed_password, password_hash)
|> Ecto.Changeset.put_change(:confirmed_at, now)
|> Repo.insert!()
end
defp validate_manifest_ownership!(context, fixtures) do
indexes = Enum.map(fixtures, & &1["index"])
expected_indexes = Enum.to_list(1..length(fixtures))
unless Enum.all?(indexes, &(is_integer(&1) and &1 > 0)) and
Enum.sort(indexes) == expected_indexes do
Mix.raise("fixture manifest indexes are not the exact prepared sequence")
end
prefix = email_prefix(context.run_id)
expected_users =
fixtures
|> Enum.flat_map(fn fixture ->
requester_email = "#{prefix}requester-#{fixture["index"]}@example.invalid"
helper_email = "#{prefix}helper-#{fixture["index"]}@example.invalid"
unless fixture["helper_email"] == helper_email do
Mix.raise("fixture manifest helper identity does not match its run and index")
end
[
{fixture["requester_id"], requester_email},
{fixture["helper_id"], helper_email}
]
end)
|> MapSet.new()
expected_requests =
fixtures
|> Enum.map(&{&1["request_id"], &1["requester_id"]})
|> MapSet.new()
expected_assignments =
fixtures
|> Enum.map(&{&1["assignment_id"], &1["request_id"], &1["helper_id"]})
|> MapSet.new()
user_ids = Enum.map(expected_users, &elem(&1, 0))
request_ids = Enum.map(expected_requests, &elem(&1, 0))
assignment_ids = Enum.map(expected_assignments, &elem(&1, 0))
observed_users =
User
|> where([user], user.id in ^user_ids)
|> select([user], {user.id, user.email})
|> Repo.all()
|> MapSet.new()
observed_requests =
HelpRequest
|> where([request], request.id in ^request_ids)
|> select([request], {request.id, request.requester_id})
|> Repo.all()
|> MapSet.new()
observed_assignments =
Assignment
|> where([assignment], assignment.id in ^assignment_ids)
|> select(
[assignment],
{assignment.id, assignment.request_id, assignment.helper_id}
)
|> Repo.all()
|> MapSet.new()
expected_pair_count = length(fixtures)
unless MapSet.size(expected_users) == expected_pair_count * 2 and
MapSet.size(expected_requests) == expected_pair_count and
MapSet.size(expected_assignments) == expected_pair_count and
observed_users == expected_users and
observed_requests == expected_requests and
observed_assignments == expected_assignments do
Mix.raise("fixture manifest ownership does not exactly match the load database")
end
end
defp unwrap!({:ok, value}, _operation), do: value
defp unwrap!({:error, reason}, operation) do
Repo.rollback("#{operation} failed: #{inspect(reason)}")
end
defp positive_integer_env!(name) do
case Integer.parse(required_env!(name)) do
{value, ""} when value > 0 -> value
_ -> Mix.raise("#{name} must be a positive integer")
end
end
defp required_env!(name) do
case System.get_env(name) do
value when is_binary(value) and value != "" -> value
_ -> Mix.raise("#{name} is required")
end
end
defp email_prefix(run_id), do: "wnh-load-#{String.downcase(run_id)}-"
defp uuid?(value) when is_binary(value) do
match?({:ok, _binary}, Ecto.UUID.dump(value))
end
defp uuid?(_value), do: false
end