who_need_help/test/who_need_help/trust_safety_test.exs
SimpleTest cf7bacdf61
Some checks are pending
Quality / full-local-gates (push) Waiting to run
Show request publication validation errors
2026-07-22 02:26:14 +03:00

555 lines
21 KiB
Elixir

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