555 lines
21 KiB
Elixir
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
|