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