defmodule WhoNeedHelp.TrustSafetyTest do use WhoNeedHelp.DataCase, async: false import WhoNeedHelp.AccountsFixtures alias WhoNeedHelp.{Catalog, Help, Messaging, Release, Tracking, Trust} alias WhoNeedHelp.Help.Assignment alias WhoNeedHelp.Repo alias WhoNeedHelp.Tracking.Position alias WhoNeedHelp.Trust.{AbuseSignal, AuditEvent, RateLimiter} setup do category = Catalog.seed_defaults() requester = user_fixture(display_name: "Requester") helper = user_fixture(display_name: "Helper") attrs = %{ "title" => "Medicine is ready at the pharmacy", "description" => "The legal medicine is already reserved and ready for pickup.", "location_label" => "Central district", "latitude" => "50.4501", "longitude" => "30.5234", "urgency" => "now", "location_visibility" => "approximate_public", "structured_data" => %{"pickup_status" => "reserved"}, "expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour), "category_id" => category.id, "safety_confirmed" => true } %{ category: category, requester: requester, helper: helper, requester_scope: user_scope_fixture(requester), helper_scope: user_scope_fixture(helper), attrs: attrs } end test "a block removes discovery and prevents matching and new chat", context do {:ok, request} = Help.create_request(context.requester_scope, context.attrs) assert Enum.any?(Help.list_open_requests(context.helper_scope), &(&1.id == request.id)) assert {:ok, _block} = Trust.block(context.helper_scope, context.requester.id) refute Enum.any?(Help.list_open_requests(context.helper_scope), &(&1.id == request.id)) assert {:error, :blocked} = Help.accept_request(context.helper_scope, request.id) assert {:ok, :unblocked} = Trust.unblock(context.helper_scope, context.requester.id) assert {:ok, assignment} = Help.accept_request(context.helper_scope, request.id) assert {:ok, _block} = Trust.block(context.requester_scope, context.helper.id) assert {:error, :blocked} = Messaging.send_message(context.helper_scope, assignment, %{"body" => "Hello"}) assert [] = Messaging.list_messages(context.requester_scope, assignment) end test "invalid block identifiers are rejected without a query cast failure", context do assert {:error, :not_found} = Trust.block(context.requester_scope, "not-a-uuid") assert {:error, :not_found} = Trust.unblock(context.requester_scope, "not-a-uuid") end test "invalid report target identifiers are rejected without a query cast failure", context do for target <- ~w(request_id assignment_id message_id activity_id activity_message_id) do assert {:error, :not_found} = Trust.report(context.requester_scope, %{ target => "not-a-uuid", "reason" => "unsafe", "details" => "Malformed target identifiers must not reach Ecto queries." }) end end test "forged moderation identifiers return not found without crashing", context do moderator = user_fixture(display_name: "Moderator") |> Ecto.Changeset.change(role: :moderator) |> Repo.update!() admin = user_fixture(display_name: "Administrator") |> Ecto.Changeset.change(role: :admin) |> Repo.update!() moderator_scope = user_scope_fixture(moderator) admin_scope = user_scope_fixture(admin) missing_id = Ecto.UUID.generate() for id <- ["not-a-uuid", missing_id] do assert {:error, :not_found} = Trust.moderate_report(moderator_scope, id, %{}) assert {:error, :not_found} = Trust.report_evidence(moderator_scope, id) assert {:error, :not_found} = Trust.moderate_signal(moderator_scope, id, %{}) assert {:error, :not_found} = Trust.hide_request(moderator_scope, id, "review") assert {:error, :not_found} = Trust.restore_request(moderator_scope, id) assert {:error, :not_found} = Trust.hide_activity(moderator_scope, id, "review") assert {:error, :not_found} = Trust.restore_activity(moderator_scope, id) assert {:error, :not_found} = Trust.moderate_user(moderator_scope, id, %{}) assert {:error, :not_found} = Trust.moderate_role(admin_scope, id, %{}) assert {:error, :not_found} = Catalog.approve_proposal(moderator_scope, id, %{}) assert {:error, :not_found} = Catalog.reject_proposal(moderator_scope, id, "review") assert {:error, :not_found} = Catalog.merge_proposal(moderator_scope, id, context.category.id, "review") end assert {:error, :not_found} = Catalog.merge_proposal( moderator_scope, Ecto.UUID.generate(), "not-a-uuid", "review" ) end test "tracking derives movement and proximity from browser accuracy envelopes", context do {:ok, request} = Help.create_request(context.requester_scope, context.attrs) {:ok, assignment} = Help.accept_request(context.helper_scope, request.id) :ok = Tracking.subscribe(assignment.id) {:ok, _} = Tracking.start_session(context.helper_scope, assignment) {:ok, _} = Tracking.start_session(context.requester_scope, assignment) {:ok, _} = Tracking.update_position(context.helper_scope, assignment, %{ "latitude" => 50.4500, "longitude" => 30.5200, "accuracy_meters" => 5.0 }) assert_receive {:position_updated, helper_id, _position, %{helper_movement_observed_at: nil, proximity_observed_at: nil}} assert helper_id == context.helper.id {:ok, _} = Tracking.update_position(context.helper_scope, assignment, %{ "latitude" => 50.4510, "longitude" => 30.5200, "accuracy_meters" => 5.0 }) assert_receive {:position_updated, helper_id, _position, %{helper_movement_observed_at: movement_at, proximity_observed_at: nil}} assert helper_id == context.helper.id assert %DateTime{} = movement_at {:ok, _} = Tracking.update_position(context.requester_scope, assignment, %{ "latitude" => 50.4511, "longitude" => 30.5200, "accuracy_meters" => 20.0 }) assignment = Repo.get!(Assignment, assignment.id) assert assignment.helper_movement_observed_at assert assignment.proximity_observed_at end test "leaderboard primary counters use unique counterparts", context do first = complete_supported_help(context, context.attrs) second_attrs = Map.put(context.attrs, "title", "A second pharmacy pickup is ready") second = complete_supported_help(context, second_attrs) [entry] = Enum.filter(Trust.leaderboard(), &(&1.user.id == context.helper.id)) assert entry.completed == 2 assert entry.unique_people == 1 assert entry.verified_people == 1 assert entry.location_supported_people == 1 assert is_nil(entry.user.email) assert is_nil(entry.user.hashed_password) assert Repo.exists?( from signal in AbuseSignal, where: signal.assignment_id == ^second.id and signal.kind == :repeated_pair ) refute first.id == second.id end test "leaderboard cursor preserves the complete ranking without duplicates", context do complete_supported_help(context, context.attrs) helpers = for {name, suffix} <- [{"Alpha helper", "alpha"}, {"Beta helper", "beta"}] do helper = user_fixture(display_name: name) helper_scope = user_scope_fixture(helper) attrs = context.attrs |> Map.put("title", "Medicine pickup for #{suffix}") complete_supported_help(context, attrs, helper_scope) helper end first = Trust.paginate_leaderboard(limit: 2) second = Trust.paginate_leaderboard(limit: 2, after: first.next_cursor) ids = Enum.map(first.entries ++ second.entries, & &1.user.id) assert length(ids) == 3 assert length(Enum.uniq(ids)) == 3 assert context.helper.id in ids assert Enum.all?(helpers, &(&1.id in ids)) assert first.next_cursor assert second.next_cursor == nil end test "reports expose only linked evidence to moderators and audit that access", context do moderator = user_fixture(display_name: "Moderator") |> Ecto.Changeset.change(role: :moderator) |> Repo.update!() moderator_scope = user_scope_fixture(moderator) {:ok, request} = Help.create_request(context.requester_scope, context.attrs) {:ok, assignment} = Help.accept_request(context.helper_scope, request.id) {:ok, _message} = Messaging.send_message(context.helper_scope, assignment, %{"body" => "Pickup update"}) {:ok, report} = Trust.report(context.requester_scope, %{ "reason" => "harassment", "details" => "This conversation needs a moderator review.", "assignment_id" => assignment.id, "reporter_id" => context.helper.id }) assert report.reporter_id == context.requester.id assert [listed] = Trust.list_reports(moderator_scope, :open) assert listed.id == report.id assert {:ok, %{messages: [%{body: "Pickup update"}]}} = Trust.report_evidence(moderator_scope, report.id) assert Repo.exists?( from event in AuditEvent, where: event.action == "report.evidence_viewed" and event.target_id == ^report.id and event.actor_id == ^moderator.id ) end test "medicine category structured fields are enforced by the server", context do invalid = Map.delete(context.attrs, "structured_data") assert {:error, changeset} = Help.create_request(context.requester_scope, invalid) assert "pickup_status is required" in errors_on(changeset).structured_data invalid = Map.put(context.attrs, "structured_data", %{"pickup_status" => "not-an-option"}) assert {:error, changeset} = Help.create_request(context.requester_scope, invalid) assert "pickup_status has an invalid value" in errors_on(changeset).structured_data nested = Map.put(context.attrs, "structured_data", %{ "pickup_status" => %{"unexpected" => "reserved"} }) assert {:error, changeset} = Help.create_request(context.requester_scope, nested) assert "pickup_status has an invalid value" in errors_on(changeset).structured_data end test "help creation rejects missing safety consent and non-help categories", context do without_consent = Map.delete(context.attrs, "safety_confirmed") assert {:error, changeset} = Help.create_request(context.requester_scope, without_consent) assert "confirm the safety guidance before publishing" in errors_on(changeset).safety_confirmed activity_category = Catalog.list_categories(:activity) |> List.first() wrong_category = Map.put(context.attrs, "category_id", activity_category.id) assert {:error, changeset} = Help.create_request(context.requester_scope, wrong_category) assert "select an active help category" in errors_on(changeset).category_id end test "configured rate limits are atomic database counters", context do old = Application.get_env(:who_need_help, :rate_limit_policies) Application.put_env(:who_need_help, :rate_limit_policies, %{ "test_action" => %{"limit" => 1, "window_seconds" => 60} }) on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, old) end) assert {:ok, %{count: 1}} = RateLimiter.check(:test_action, context.helper.id) assert {:error, :rate_limited} = RateLimiter.check(:test_action, context.helper.id) assert {:ok, %{count: 1}} = RateLimiter.check(:test_action, context.requester.id) assert {:error, :rate_limited} = Trust.authorize_action(context.helper_scope, :test_action) assert Repo.exists?( from signal in AbuseSignal, where: signal.subject_id == ^context.helper.id and signal.kind == :velocity ) end test "invalid request forms do not consume the publication rate limit", context do old = Application.get_env(:who_need_help, :rate_limit_policies) Application.put_env(:who_need_help, :rate_limit_policies, %{ "create_request" => %{"limit" => 1, "window_seconds" => 60} }) on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, old) end) invalid = Map.drop(context.attrs, ["latitude", "longitude"]) assert {:error, %Ecto.Changeset{}} = Help.create_request(context.requester_scope, invalid) assert {:ok, _request} = Help.create_request(context.requester_scope, context.attrs) assert {:error, :rate_limited} = Help.create_request(context.requester_scope, context.attrs) end test "malformed runtime rate-limit configuration never crashes a request", context do old = Application.get_env(:who_need_help, :rate_limit_policies) Application.put_env(:who_need_help, :rate_limit_policies, ["not", "a", "map"]) on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, old) end) assert {:ok, :not_configured} = RateLimiter.check(:test_action, context.requester.id) end test "restricted accounts cannot perform trust-sensitive actions", context do context.helper |> WhoNeedHelp.Accounts.User.moderation_changeset(%{moderation_status: :restricted}) |> Repo.update!() assert {:error, :account_not_eligible} = Help.create_request(context.helper_scope, context.attrs) end test "hidden request coordinates remain hidden from an accepted helper", context do hidden_attrs = Map.put(context.attrs, "location_visibility", "hidden") {:ok, request} = Help.create_request(context.requester_scope, hidden_attrs) {:ok, _assignment} = Help.accept_request(context.helper_scope, request.id) request = Help.get_request!(request.id) outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture() assert is_nil(WhoNeedHelp.Help.HelpRequest.public_coordinates(request)) assert is_nil(Help.request_coordinates(outsider_scope, request)) assert is_nil(Help.request_coordinates(context.helper_scope, request)) assert %{latitude: 50.4501, longitude: 30.5234, exact: true} = Help.request_coordinates(context.requester_scope, request) end test "blocking an accepted helper revokes request, exact location, and tracking", context do attrs = Map.put(context.attrs, "location_visibility", "exact_for_active_match") {:ok, request} = Help.create_request(context.requester_scope, attrs) {:ok, assignment} = Help.accept_request(context.helper_scope, request.id) request = Help.get_request!(request.id) {:ok, _session} = Tracking.start_session(context.helper_scope, assignment) {:ok, position} = Tracking.update_position(context.helper_scope, assignment, %{ "latitude" => 50.45, "longitude" => 30.52, "accuracy_meters" => 10.0 }) assert %{exact: true} = Help.request_coordinates(context.helper_scope, request) assert {:ok, _block} = Trust.block(context.requester_scope, context.helper.id) assert {:error, :not_found} = Help.get_request(context.helper_scope, request.id) assert %{exact: false} = Help.request_coordinates(context.helper_scope, request) assert Tracking.list_current_positions(context.helper_scope, assignment) == %{} assert {:error, :blocked} = Tracking.start_session(context.helper_scope, assignment) assert {:error, :blocked} = Tracking.update_position(context.helper_scope, assignment, %{ "latitude" => 50.46, "longitude" => 30.53, "accuracy_meters" => 10.0 }) refute Repo.get(Position, position.id) end test "cancelling a matched request ends tracking and deletes raw coordinates", context do {:ok, request} = Help.create_request(context.requester_scope, context.attrs) {:ok, assignment} = Help.accept_request(context.helper_scope, request.id) {:ok, _session} = Tracking.start_session(context.helper_scope, assignment) {:ok, position} = Tracking.update_position(context.helper_scope, assignment, %{ "latitude" => 50.45, "longitude" => 30.52, "accuracy_meters" => 10.0 }) assert Repo.get(Position, position.id) assert {:ok, _request} = Help.cancel_request(context.requester_scope, request.id) refute Repo.get(Position, position.id) assert Repo.get!(Assignment, assignment.id).status == :cancelled end test "only an administrator can change roles and the last admin is protected", context do moderator = user_fixture(display_name: "Moderator") |> Ecto.Changeset.change(role: :moderator) |> Repo.update!() admin = user_fixture(display_name: "Administrator") |> Ecto.Changeset.change(role: :admin) |> Repo.update!() assert {:error, :forbidden} = Trust.moderate_role(user_scope_fixture(moderator), context.helper.id, %{ "role" => "moderator" }) assert {:ok, promoted} = Trust.moderate_role(user_scope_fixture(admin), context.helper.id, %{ "role" => "moderator" }) assert promoted.role == :moderator assert {:error, :last_admin} = Trust.moderate_role(user_scope_fixture(admin), admin.id, %{"role" => "user"}) assert Repo.exists?( from event in AuditEvent, where: event.actor_id == ^admin.id and event.target_id == ^context.helper.id and event.action == "user.role_changed" ) end test "restricted moderators lose authorization and the last active admin cannot be suspended", context do admin = user_fixture(display_name: "Administrator") |> Ecto.Changeset.change(role: :admin) |> Repo.update!() moderator = user_fixture(display_name: "Moderator") |> Ecto.Changeset.change(role: :moderator) |> Repo.update!() active_moderator = user_fixture(display_name: "Active moderator") |> Ecto.Changeset.change(role: :moderator) |> Repo.update!() assert WhoNeedHelp.Accounts.moderator_authorized?(moderator) restricted = moderator |> WhoNeedHelp.Accounts.User.moderation_changeset(%{ "moderation_status" => "restricted" }) |> Repo.update!() refute WhoNeedHelp.Accounts.moderator_authorized?(restricted) session_token = WhoNeedHelp.Accounts.generate_user_session_token(context.helper) socket_id = WhoNeedHelpWeb.UserAuth.live_socket_id(session_token) :ok = WhoNeedHelpWeb.Endpoint.subscribe(socket_id) {:ok, request} = Help.create_request(context.requester_scope, context.attrs) {:ok, assignment} = Help.accept_request(context.helper_scope, request.id) {:ok, tracking_session} = Tracking.start_session(context.helper_scope, assignment) {:ok, _position} = Tracking.update_position(context.helper_scope, assignment, %{ "latitude" => 50.4501, "longitude" => 30.5234, "accuracy_meters" => 5.0 }) :ok = Tracking.subscribe(assignment.id) assert {:ok, suspended} = Trust.moderate_user(user_scope_fixture(active_moderator), context.helper.id, %{ "moderation_status" => "suspended" }) assert suspended.moderation_status == :suspended assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^socket_id} assert_receive {:tracking_stopped, helper_id} assert helper_id == context.helper.id assert WhoNeedHelp.Accounts.get_user_by_session_token(session_token) == nil refute Repo.get_by(Position, tracking_session_id: tracking_session.id) refute Repo.get_by(WhoNeedHelp.Tracking.TrackingSession, id: tracking_session.id, active: true ) assert {:error, :last_admin} = Trust.moderate_user(user_scope_fixture(active_moderator), admin.id, %{ "moderation_status" => "suspended" }) assert Repo.get!(WhoNeedHelp.Accounts.User, admin.id).moderation_status == :active end test "the first administrator bootstrap is one-time and audited", context do assert {:ok, admin} = Release.bootstrap_admin(context.helper.email) assert admin.id == context.helper.id assert admin.role == :admin assert {:error, :admin_already_exists} = Release.bootstrap_admin(context.requester.email) assert Repo.exists?( from event in AuditEvent, where: is_nil(event.actor_id) and event.target_id == ^context.helper.id and event.action == "user.admin_bootstrapped" ) end defp complete_supported_help(context, attrs, helper_scope \\ nil) do helper_scope = helper_scope || context.helper_scope {:ok, request} = Help.create_request(context.requester_scope, attrs) {:ok, assignment} = Help.accept_request(helper_scope, request.id) assignment = assignment |> Assignment.changeset(%{ proximity_observed_at: DateTime.utc_now(:second), helper_movement_observed_at: DateTime.utc_now(:second) }) |> Repo.update!() {:ok, _} = Help.confirm_completion(context.requester_scope, assignment.id) {:ok, _} = Help.confirm_completion(helper_scope, assignment.id) {:ok, assignment} = Help.verify_handover(helper_scope, assignment.id, Help.handover_code(request.id)) assignment end end