who_need_help/test/who_need_help/trust_safety_test.exs

928 lines
35 KiB
Elixir

defmodule WhoNeedHelp.TrustSafetyTest do
use WhoNeedHelp.DataCase, async: false
import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.{Accounts, Catalog, Help, Messaging, Release, Tracking, Trust}
alias WhoNeedHelp.Help.Assignment
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Tracking.Position
alias WhoNeedHelp.Trust.{AbuseSignal, AuditEvent, RateLimitBucket, 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 "report history is scoped to the reporter and includes the latest decision", context do
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
assert {:ok, report} =
Trust.report(context.helper_scope, %{
"request_id" => request.id,
"reason" => "dangerous_request",
"details" => "Please review the safety information in this request."
})
outsider = user_fixture(display_name: "Report history outsider")
assert [] = Trust.list_reports_for_user(user_scope_fixture(outsider))
moderator = staff_user_fixture([:moderator], display_name: "Report history moderator")
assert {:ok, resolved} =
Trust.moderate_report(user_scope_fixture(moderator), report.id, %{
"status" => "resolved",
"resolution_note" =>
"The request was reviewed and the appropriate action was recorded."
})
assert resolved.status == :resolved
assert [visible] = Trust.list_reports_for_user(context.helper_scope)
assert visible.id == report.id
assert visible.status == :resolved
assert visible.resolution_note =~ "appropriate action"
assert %WhoNeedHelp.Notifications.Notification{path: "/reports"} =
Repo.get_by(WhoNeedHelp.Notifications.Notification,
user_id: context.helper.id,
kind: :support_update
)
end
test "child-safety reports use an explicit moderation reason", context do
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
assert {:ok, report} =
Trust.report(context.helper_scope, %{
"request_id" => request.id,
"reason" => "child_safety",
"details" =>
"This report identifies suspected child sexual exploitation for urgent review."
})
assert report.reason == :child_safety
moderator = staff_user_fixture([:moderator], display_name: "Child safety moderator")
assert [visible] =
Trust.paginate_reports(user_scope_fixture(moderator), nil, reason: "child_safety").entries
assert visible.id == report.id
end
test "forged moderation identifiers return not found without crashing", context do
moderator = staff_user_fixture([:moderator], display_name: "Moderator")
admin = staff_user_fixture([:admin], display_name: "Administrator")
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.set_staff_roles(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 "hiding a request requires a moderation reason", context do
moderator = staff_user_fixture([:moderator], display_name: "Reason moderator")
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
assert {:error, %Ecto.Changeset{} = changeset} =
Trust.hide_request(user_scope_fixture(moderator), request.id, "")
assert "can't be blank" in errors_on(changeset).hidden_reason
refute Repo.get!(WhoNeedHelp.Help.HelpRequest, request.id).hidden_at
end
test "category moderation cannot cross help and activity taxonomies", context do
moderator = staff_user_fixture([:moderator], display_name: "Category moderator")
moderator_scope = user_scope_fixture(moderator)
activity_category = Catalog.list_categories(:activity) |> List.first()
{:ok, activity_proposal} =
Catalog.propose(context.requester_scope, %{
"proposed_name" => "Board games",
"mode" => "activity",
"reason" => "A reusable social activity category for public board game meetings."
})
assert {:error, :mode_mismatch} =
Catalog.merge_proposal(
moderator_scope,
activity_proposal.id,
context.category.id,
"Wrong mode"
)
assert {:ok, merged} =
Catalog.merge_proposal(
moderator_scope,
activity_proposal.id,
activity_category.id,
"Same mode"
)
assert merged.status == :merged
assert merged.merged_into_id == activity_category.id
roadside_parent =
Catalog.list_proposal_parents(:help)
|> Enum.find(&(&1.slug == "roadside-help"))
{:ok, help_proposal} =
Catalog.propose(context.requester_scope, %{
"proposed_name" => "Cargo bicycle",
"parent_id" => roadside_parent.id,
"mode" => "activity",
"reason" => "A reusable help category for cargo bicycle roadside problems."
})
assert help_proposal.mode == :help
assert {:ok, %{category: created}} =
Catalog.approve_proposal(moderator_scope, help_proposal.id, %{
"slug" => "cargo-bicycle-#{System.unique_integer([:positive])}",
"names" => %{"en" => "Cargo bicycle help"},
"mode" => "activity",
"parent_id" => activity_category.id
})
assert created.mode == :help
assert created.parent_id == roadside_parent.id
roadside_child =
Catalog.list_categories(:help)
|> Enum.find(&(&1.parent_id == roadside_parent.id))
assert {:error, :invalid_parent} =
Catalog.propose(context.requester_scope, %{
"proposed_name" => "Too deeply nested",
"parent_id" => roadside_child.id,
"reason" => "This proposal must not create a third taxonomy level."
})
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)
helper_reputation = Trust.reputation(context.helper.id)
requester_reputation = Trust.reputation(context.requester.id)
assert helper_reputation.completed == 2
assert helper_reputation.unique_people == 1
assert helper_reputation.verified_handovers == 2
assert helper_reputation.location_supported == 2
assert requester_reputation.completed == 2
assert requester_reputation.unique_people == 1
assert requester_reputation.verified_handovers == 2
assert requester_reputation.location_supported == 2
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 = staff_user_fixture([:moderator], display_name: "Moderator")
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 "multiple configured scopes are counted in one limiter call", context do
old = Application.get_env(:who_need_help, :rate_limit_policies)
Application.put_env(:who_need_help, :rate_limit_policies, %{
"test_email" => %{"limit" => 1, "window_seconds" => 60},
"test_ip" => %{"limit" => 2, "window_seconds" => 60}
})
on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, old) end)
assert {:ok, results} =
RateLimiter.check_many([
{:test_email, context.helper.email},
{:test_ip, "203.0.113.10"}
])
assert Map.new(results, &{&1.action, {&1.count, &1.limit}}) == %{
"test_email" => {1, 1},
"test_ip" => {1, 2}
}
assert Enum.all?(results, &match?(%DateTime{}, &1.resets_at))
assert {:error, :rate_limited} =
RateLimiter.check_many([
{:test_email, context.helper.email},
{:test_ip, "203.0.113.10"}
])
assert Repo.aggregate(
from(bucket in RateLimitBucket, where: bucket.action in ["test_email", "test_ip"]),
:sum,
:count
) == 4
end
test "rate-limit telemetry contains only the action and bounded outcome", context do
old = Application.get_env(:who_need_help, :rate_limit_policies)
Application.put_env(:who_need_help, :rate_limit_policies, %{
"telemetry_test" => %{"limit" => 1, "window_seconds" => 60}
})
on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, old) end)
handler_id = "rate-limiter-telemetry-#{System.unique_integer([:positive])}"
test_process = self()
:ok =
:telemetry.attach(
handler_id,
[:who_need_help, :rate_limit, :check],
fn _event, measurements, metadata, recipient ->
send(recipient, {:rate_limit_telemetry, measurements, metadata})
end,
test_process
)
on_exit(fn -> :telemetry.detach(handler_id) end)
assert {:ok, _limit} = RateLimiter.check(:telemetry_test, context.helper.email)
assert_receive {:rate_limit_telemetry, %{count: 1},
%{action: "telemetry_test", outcome: "allowed"}}
assert {:error, :rate_limited} =
RateLimiter.check(:telemetry_test, context.helper.email)
assert_receive {:rate_limit_telemetry, %{count: 1},
%{action: "telemetry_test", outcome: "limited"}}
end
test "multiple limiter scopes use one PostgreSQL statement", context do
old = Application.get_env(:who_need_help, :rate_limit_policies)
Application.put_env(:who_need_help, :rate_limit_policies, %{
"query_count_email" => %{"limit" => 2, "window_seconds" => 60},
"query_count_ip" => %{"limit" => 2, "window_seconds" => 60}
})
on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, old) end)
handler_id = "rate-limiter-query-count-#{System.unique_integer([:positive])}"
test_process = self()
:ok =
:telemetry.attach(
handler_id,
[:who_need_help, :repo, :query],
fn _event, _measurements, metadata, recipient ->
send(recipient, {:rate_limiter_query, metadata.query})
end,
test_process
)
on_exit(fn -> :telemetry.detach(handler_id) end)
assert {:ok, [_email, _ip]} =
RateLimiter.check_many([
{:query_count_email, context.helper.email},
{:query_count_ip, "203.0.113.20"}
])
assert_receive {:rate_limiter_query, query}
assert query =~ ~s(INSERT INTO "rate_limit_buckets")
refute_receive {:rate_limiter_query, _another_query}, 50
end
test "concurrent limiter calls share one database counter across processes", context do
old = Application.get_env(:who_need_help, :rate_limit_policies)
Application.put_env(:who_need_help, :rate_limit_policies, %{
"concurrent_test" => %{"limit" => 10, "window_seconds" => 60}
})
on_exit(fn -> Application.put_env(:who_need_help, :rate_limit_policies, old) end)
results =
1..20
|> Task.async_stream(
fn _attempt -> RateLimiter.check(:concurrent_test, context.helper.id) end,
max_concurrency: 20,
ordered: false,
timeout: 5_000
)
|> Enum.map(fn {:ok, result} -> result end)
assert Enum.count(results, &match?({:ok, _}, &1)) == 10
assert Enum.count(results, &match?({:error, :rate_limited}, &1)) == 10
assert Repo.one!(
from bucket in RateLimitBucket,
where: bucket.action == "concurrent_test",
select: bucket.count
) == 20
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,
moderation_note: "Test restriction"
})
|> Repo.update!()
assert {:error, :account_not_eligible} =
Help.create_request(context.helper_scope, context.attrs)
end
test "a stale authenticated scope cannot create a category proposal after restriction",
context do
admin = staff_user_fixture([:admin], display_name: "Restriction administrator")
assert {:ok, restricted} =
Trust.moderate_user(user_scope_fixture(admin), context.helper.id, %{
"moderation_status" => "restricted",
"moderation_note" => "Immediate trust-action boundary test"
})
assert restricted.moderation_status == :restricted
assert {:error, :account_not_eligible} =
Catalog.propose(context.helper_scope, %{
"proposed_name" => "Restricted stale-scope proposal",
"mode" => "help",
"reason" => "This write must be rejected after the database status changes."
})
end
test "approximate requests expose only their bounded area geometry", context do
attrs = Map.put(context.attrs, "location_radius_meters", "500")
{:ok, request} = Help.create_request(context.requester_scope, attrs)
assert %{
latitude: 50.4501,
longitude: 30.5234,
exact: false,
radius_meters: 500
} = WhoNeedHelp.Help.HelpRequest.public_coordinates(request)
assert %{
latitude: 50.4501,
longitude: 30.5234,
exact: false,
radius_meters: 500
} = Help.request_coordinates(context.requester_scope, request)
invalid_attrs = Map.put(context.attrs, "location_radius_meters", "5000")
assert {:error, changeset} = Help.create_request(context.requester_scope, invalid_attrs)
assert "is invalid" in errors_on(changeset).location_radius_meters
end
test "hidden request coordinates remain hidden from an accepted helper", context do
hidden_attrs =
context.attrs
|> Map.put("location_visibility", "hidden")
|> Map.drop(["latitude", "longitude"])
{:ok, request} = Help.create_request(context.requester_scope, hidden_attrs)
assert is_nil(request.location)
assert is_nil(request.location_radius_meters)
{: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 is_nil(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 = staff_user_fixture([:moderator], display_name: "Moderator")
admin = staff_user_fixture([:admin], display_name: "Administrator")
assert {:error, :forbidden} =
Trust.set_staff_roles(user_scope_fixture(moderator), context.helper.id, ["moderator"])
assert {:ok, promoted} =
Trust.set_staff_roles(user_scope_fixture(admin), context.helper.id, [
"support",
"moderator"
])
assert Accounts.loaded_staff_roles(promoted) == [:moderator, :support]
assert {:error, :last_admin} =
Trust.set_staff_roles(user_scope_fixture(admin), admin.id, [])
assert Repo.exists?(
from event in AuditEvent,
where:
event.actor_id == ^admin.id and event.target_id == ^context.helper.id and
event.action == "user.staff_roles_changed"
)
end
test "audit storage keeps security decisions and skips routine lifecycle events", context do
assert {:ok, :not_audited} =
Trust.audit(
context.requester.id,
"request.created",
"request",
Ecto.UUID.generate()
)
refute Repo.exists?(from event in AuditEvent, where: event.action == "request.created")
target_id = Ecto.UUID.generate()
assert {:ok, %AuditEvent{} = event} =
Trust.audit(
context.requester.id,
"user.blocked",
"user",
target_id
)
assert event.action == "user.blocked"
assert event.target_id == target_id
assert Trust.retained_audit_action?("user.blocked")
refute Trust.retained_audit_action?("request.created")
end
test "restricted moderators lose authorization and moderators cannot suspend administrators",
context do
admin = staff_user_fixture([:admin], display_name: "Administrator")
moderator = staff_user_fixture([:moderator], display_name: "Moderator")
active_moderator = staff_user_fixture([:moderator], display_name: "Active moderator")
assert WhoNeedHelp.Accounts.moderator_authorized?(moderator)
restricted =
moderator
|> WhoNeedHelp.Accounts.User.moderation_changeset(%{
"moderation_status" => "restricted",
"moderation_note" => "Test moderator restriction"
})
|> 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",
"moderation_note" => "Confirmed safety policy violation"
})
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, :forbidden} =
Trust.moderate_user(user_scope_fixture(active_moderator), admin.id, %{
"moderation_status" => "suspended",
"moderation_note" => "Attempted staff restriction"
})
assert Repo.get!(WhoNeedHelp.Accounts.User, admin.id).moderation_status == :active
end
test "moderators cannot restrict staff and restrictions require an internal note", context do
admin = staff_user_fixture([:admin], display_name: "Staff administrator")
moderator = staff_user_fixture([:moderator], display_name: "Staff moderator")
assert {:error, :forbidden} =
Trust.moderate_user(user_scope_fixture(moderator), admin.id, %{
"moderation_status" => "restricted",
"moderation_note" => "A moderator must not restrict an administrator"
})
assert {:error, %Ecto.Changeset{} = changeset} =
Trust.moderate_user(user_scope_fixture(admin), context.helper.id, %{
"moderation_status" => "restricted",
"moderation_note" => ""
})
assert "can't be blank" in errors_on(changeset).moderation_note
assert Repo.get!(WhoNeedHelp.Accounts.User, context.helper.id).moderation_status == :active
assert {:ok, restricted} =
Trust.moderate_user(user_scope_fixture(admin), moderator.id, %{
"moderation_status" => "restricted",
"moderation_note" => "Administrator-reviewed staff restriction"
})
assert restricted.moderation_status == :restricted
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.roles == [:admin]
assert Accounts.staff_roles(context.helper) == [: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