421 lines
13 KiB
Elixir
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
|