fix: harden realtime forms and blocked activity privacy

This commit is contained in:
SimpleTest 2026-07-20 18:38:29 +03:00
parent ad0204830a
commit 328096eb8f
11 changed files with 269 additions and 56 deletions

View File

@ -102,6 +102,18 @@ defmodule WhoNeedHelp.Activities do
participant.activity_id == activity.id and participant.user_id == ^user.id and participant.activity_id == activity.id and participant.user_id == ^user.id and
participant.status in [:requested, :approved] participant.status in [:requested, :approved]
) )
|> where(
[activity],
activity.creator_id not in subquery(
from block in Block, where: block.blocker_id == ^user.id, select: block.blocked_id
)
)
|> where(
[activity],
activity.creator_id not in subquery(
from block in Block, where: block.blocked_id == ^user.id, select: block.blocker_id
)
)
|> before_my_activity(cursor) |> before_my_activity(cursor)
|> order_by([activity], desc: activity.starts_at, desc: activity.id) |> order_by([activity], desc: activity.starts_at, desc: activity.id)
|> limit(^(limit + 1)) |> limit(^(limit + 1))

View File

@ -48,61 +48,76 @@ defmodule WhoNeedHelp.Trust do
attrs = stringify_keys(attrs) attrs = stringify_keys(attrs)
with {:ok, _limit} <- authorize_action(scope, :review) do with {:ok, _limit} <- authorize_action(scope, :review) do
Repo.transact(fn -> result =
current = Repo.transact(fn ->
Assignment current =
|> where([current], current.id == ^assignment.id) Assignment
|> lock("FOR UPDATE") |> where([current], current.id == ^assignment.id)
|> Repo.one() |> lock("FOR UPDATE")
|> Repo.one()
if current do if current do
request = Repo.get!(HelpRequest, current.request_id) request = Repo.get!(HelpRequest, current.request_id)
cond do cond do
current.status != :completed -> current.status != :completed ->
{:error, :forbidden} {:error, :forbidden}
user.id not in [current.helper_id, request.requester_id] -> user.id not in [current.helper_id, request.requester_id] ->
{:error, :forbidden} {:error, :forbidden}
true -> true ->
reviewee_id = reviewee_id =
if user.id == current.helper_id, if user.id == current.helper_id,
do: request.requester_id, do: request.requester_id,
else: current.helper_id else: current.helper_id
with {:ok, review} <- with {:ok, review} <-
%Review{} %Review{}
|> Review.changeset( |> Review.changeset(
Map.merge(attrs, %{ Map.merge(attrs, %{
"assignment_id" => current.id, "assignment_id" => current.id,
"reviewer_id" => user.id, "reviewer_id" => user.id,
"reviewee_id" => reviewee_id "reviewee_id" => reviewee_id
}) })
) )
|> Repo.insert(), |> Repo.insert(),
reviews <- Repo.all(from r in Review, where: r.assignment_id == ^current.id), reviews <- Repo.all(from r in Review, where: r.assignment_id == ^current.id),
true <- length(reviews) <= 2, true <- length(reviews) <= 2,
revealed? <- length(reviews) == 2, revealed? <- length(reviews) == 2,
{_count, _rows} <- {_count, _rows} <-
maybe_reveal_reviews(current.id, revealed?), maybe_reveal_reviews(current.id, revealed?),
{:ok, _audit} <- {:ok, _audit} <-
audit(user.id, "review.submitted", "assignment", current.id, %{ audit(user.id, "review.submitted", "assignment", current.id, %{
"revealed" => revealed? "revealed" => revealed?
}) do }) do
{:ok, review} {:ok, review}
else else
false -> {:error, :invalid_review_count} false -> {:error, :invalid_review_count}
other -> other other -> other
end end
end
else
{:error, :not_found}
end end
else end)
{:error, :not_found}
end with {:ok, review} <- result do
end) :ok = Help.notify_request_updated(assignment.request_id)
{:ok, review}
end
end end
end end
def review_submitted?(%Scope{user: %User{id: user_id}}, %Assignment{id: assignment_id}) do
Repo.exists?(
from review in Review,
where: review.assignment_id == ^assignment_id and review.reviewer_id == ^user_id
)
end
def review_submitted?(_scope, _assignment), do: false
defp maybe_reveal_reviews(assignment_id, true) do defp maybe_reveal_reviews(assignment_id, true) do
Repo.update_all(from(r in Review, where: r.assignment_id == ^assignment_id), Repo.update_all(from(r in Review, where: r.assignment_id == ^assignment_id),
set: [revealed_at: DateTime.utc_now(:second)] set: [revealed_at: DateTime.utc_now(:second)]

View File

@ -0,0 +1,19 @@
defmodule WhoNeedHelpWeb.FormParams do
@moduledoc false
@live_view_unused_prefix "_unused_"
def drop_unused_structured_fields(%{"structured_data" => data} = params)
when is_map(data) do
data =
Map.reject(data, fn {key, _value} ->
key
|> to_string()
|> String.starts_with?(@live_view_unused_prefix)
end)
Map.put(params, "structured_data", data)
end
def drop_unused_structured_fields(params), do: params
end

View File

@ -3,6 +3,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
alias WhoNeedHelp.{Activities, Catalog} alias WhoNeedHelp.{Activities, Catalog}
alias WhoNeedHelp.Activities.Activity alias WhoNeedHelp.Activities.Activity
alias WhoNeedHelpWeb.FormParams
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -17,6 +18,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
@impl true @impl true
def handle_event("validate", %{"activity" => params}, socket) do def handle_event("validate", %{"activity" => params}, socket) do
params = FormParams.drop_unused_structured_fields(params)
changeset = changeset =
%Activity{} %Activity{}
|> Activities.change_activity(normalize_datetimes(params)) |> Activities.change_activity(normalize_datetimes(params))
@ -29,6 +32,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.New do
end end
def handle_event("save", %{"activity" => params}, socket) do def handle_event("save", %{"activity" => params}, socket) do
params = FormParams.drop_unused_structured_fields(params)
case Activities.create_activity(socket.assigns.current_scope, normalize_datetimes(params)) do case Activities.create_activity(socket.assigns.current_scope, normalize_datetimes(params)) do
{:ok, activity} -> {:ok, activity} ->
{:noreply, {:noreply,

View File

@ -80,7 +80,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
params params
) do ) do
{:ok, message} -> {:ok, message} ->
{:noreply, put_realtime_message(socket, message)} {:noreply,
socket
|> put_realtime_message(message)
|> push_event("reset-message-form", %{id: "activity-message-form"})}
{:error, reason} -> {:error, reason} ->
{:noreply, put_flash(socket, :error, error_message(reason))} {:noreply, put_flash(socket, :error, error_message(reason))}
@ -120,7 +123,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
socket socket
|> assign(:report_form, report_form()) |> assign(:report_form, report_form())
|> assign(:report_message_id, nil) |> assign(:report_message_id, nil)
|> put_flash(:info, gettext("Report sent to moderators."))} |> put_flash(:info, gettext("Report sent to moderators."))
|> push_event("reset-message-form", %{id: "activity-report-form"})}
{:error, reason} -> {:error, reason} ->
{:noreply, put_flash(socket, :error, error_message(reason))} {:noreply, put_flash(socket, :error, error_message(reason))}
@ -527,7 +531,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
</p> </p>
</div> </div>
<button <button
:if={@viewer_participant && @viewer_participant.status == :approved} :if={
@activity.status == :open && @viewer_participant &&
@viewer_participant.status == :approved
}
phx-click="leave" phx-click="leave"
class="btn btn-outline btn-error w-full" class="btn btn-outline btn-error w-full"
> >
@ -640,7 +647,12 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
> >
{gettext("Block organizer")} {gettext("Block organizer")}
</button> </button>
<.form for={@report_form} phx-submit="report" class="mt-4 space-y-2"> <.form
for={@report_form}
id="activity-report-form"
phx-submit="report"
class="mt-4 space-y-2"
>
<div <div
:if={@report_message_id} :if={@report_message_id}
class="flex items-center justify-between rounded-xl bg-warning/10 p-3 text-xs" class="flex items-center justify-between rounded-xl bg-warning/10 p-3 text-xs"

View File

@ -16,7 +16,8 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
{:noreply, {:noreply,
socket socket
|> put_flash(:info, gettext("Proposal published for community voting.")) |> put_flash(:info, gettext("Proposal published for community voting."))
|> load()} |> load()
|> push_event("reset-message-form", %{id: "category-proposal-form"})}
{:error, %Ecto.Changeset{} = changeset} -> {:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))} {:noreply, assign(socket, :form, to_form(changeset))}
@ -85,7 +86,12 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
<p class="mt-3 text-base-content/65"> <p class="mt-3 text-base-content/65">
{gettext("Proposals and votes guide moderators. Approval remains a human decision.")} {gettext("Proposals and votes guide moderators. Approval remains a human decision.")}
</p> </p>
<.form for={@form} phx-submit="propose" class="mt-7 space-y-4 rounded-3xl bg-base-200 p-6"> <.form
for={@form}
id="category-proposal-form"
phx-submit="propose"
class="mt-7 space-y-4 rounded-3xl bg-base-200 p-6"
>
<.input <.input
field={@form[:proposed_name]} field={@form[:proposed_name]}
label={gettext("Proposed category")} label={gettext("Proposed category")}

View File

@ -4,6 +4,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
alias WhoNeedHelp.Catalog alias WhoNeedHelp.Catalog
alias WhoNeedHelp.Help alias WhoNeedHelp.Help
alias WhoNeedHelp.Help.HelpRequest alias WhoNeedHelp.Help.HelpRequest
alias WhoNeedHelpWeb.FormParams
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -28,6 +29,8 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
@impl true @impl true
def handle_event("validate", %{"help_request" => params}, socket) do def handle_event("validate", %{"help_request" => params}, socket) do
params = FormParams.drop_unused_structured_fields(params)
changeset = changeset =
%HelpRequest{} %HelpRequest{}
|> Help.change_request(normalize_expiry(params)) |> Help.change_request(normalize_expiry(params))
@ -40,6 +43,8 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
end end
def handle_event("save", %{"help_request" => params}, socket) do def handle_event("save", %{"help_request" => params}, socket) do
params = FormParams.drop_unused_structured_fields(params)
case Help.create_request(socket.assigns.current_scope, normalize_expiry(params)) do case Help.create_request(socket.assigns.current_scope, normalize_expiry(params)) do
{:ok, request} -> {:ok, request} ->
{:noreply, {:noreply,

View File

@ -258,7 +258,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
socket socket
|> assign(:report_form, report_form()) |> assign(:report_form, report_form())
|> assign(:report_message_id, nil) |> assign(:report_message_id, nil)
|> put_flash(:info, gettext("Report sent to moderators."))} |> put_flash(:info, gettext("Report sent to moderators."))
|> push_event("reset-message-form", %{id: "request-report-form"})}
{:error, reason} -> {:error, reason} ->
{:noreply, put_flash(socket, :error, message(reason))} {:noreply, put_flash(socket, :error, message(reason))}
@ -422,6 +423,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
) )
|> assign(:assignment, assignment) |> assign(:assignment, assignment)
|> assign(:participant, participant) |> assign(:participant, participant)
|> assign(:review_submitted, Trust.review_submitted?(socket.assigns.current_scope, assignment))
|> assign(:other_user_id, other_user_id) |> assign(:other_user_id, other_user_id)
|> assign( |> assign(
:blocked_by_current, :blocked_by_current,
@ -816,7 +818,15 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
<p class="mt-1 text-sm text-base-content/60"> <p class="mt-1 text-sm text-base-content/60">
{gettext("Your review is revealed only after both participants submit.")} {gettext("Your review is revealed only after both participants submit.")}
</p> </p>
<.form for={@review_form} phx-submit="review" class="mt-4 space-y-3"> <p :if={@review_submitted} class="mt-4 text-sm text-base-content/70">
{gettext("Review saved. It appears after both participants review.")}
</p>
<.form
:if={!@review_submitted}
for={@review_form}
phx-submit="review"
class="mt-4 space-y-3"
>
<.input <.input
field={@review_form[:rating]} field={@review_form[:rating]}
type="select" type="select"
@ -1068,7 +1078,12 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
> >
{gettext("Unblock this user")} {gettext("Unblock this user")}
</button> </button>
<.form for={@report_form} phx-submit="report" class="mt-4 space-y-2"> <.form
for={@report_form}
id="request-report-form"
phx-submit="report"
class="mt-4 space-y-2"
>
<div <div
:if={@report_message_id} :if={@report_message_id}
class="flex items-center justify-between rounded-xl bg-error/10 p-3 text-xs" class="flex items-center justify-between rounded-xl bg-error/10 p-3 text-xs"

View File

@ -360,6 +360,18 @@ defmodule WhoNeedHelp.ActivitiesTest do
assert request.status == :requested assert request.status == :requested
end end
test "my activities exclude organizers blocked in either direction", context do
{:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
{:ok, _request} = Activities.request_to_join(context.participant_scope, activity.id)
assert Enum.map(Activities.list_my_activities(context.participant_scope), & &1.id) == [
activity.id
]
assert {:ok, _block} = Trust.block(context.participant_scope, context.organizer.id)
assert Activities.list_my_activities(context.participant_scope) == []
end
test "activity message reports expose only the linked group to audited moderators", context do test "activity message reports expose only the linked group to audited moderators", context do
moderator = moderator =
user_fixture(display_name: "Activity moderator") user_fixture(display_name: "Activity moderator")

View File

@ -370,11 +370,14 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
"comment" => "Kind" "comment" => "Kind"
}) })
assert Trust.review_submitted?(context.requester_scope, assignment)
refute Trust.review_submitted?(context.helper_scope, assignment)
assert Trust.visible_reviews(context.helper.id) == [] assert Trust.visible_reviews(context.helper.id) == []
{:ok, _} = {:ok, _} =
Trust.submit_review(context.helper_scope, assignment, %{"rating" => 5, "comment" => "Clear"}) Trust.submit_review(context.helper_scope, assignment, %{"rating" => 5, "comment" => "Clear"})
assert Trust.review_submitted?(context.helper_scope, assignment)
assert [%{rating: 5}] = Trust.visible_reviews(context.helper.id) assert [%{rating: 5}] = Trust.visible_reviews(context.helper.id)
assert [%{rating: 5}] = Trust.visible_reviews(context.requester.id) assert [%{rating: 5}] = Trust.visible_reviews(context.requester.id)
end end

View File

@ -199,6 +199,20 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "Medicine pickup status" assert html =~ "Medicine pickup status"
assert html =~ "Pickup and deliver a legal medicine" assert html =~ "Pickup and deliver a legal medicine"
assert html =~ "help_request[structured_data][pickup_status]" assert html =~ "help_request[structured_data][pickup_status]"
html =
render_change(view, "validate", %{
"help_request" => %{
"category_id" => category.id,
"structured_data" => %{
"_unused_pickup_status" => "",
"pickup_status" => ""
}
}
})
assert html =~ "pickup_status is required"
refute html =~ "_unused_pickup_status is not allowed"
end end
test "category descriptions use the current Ukrainian locale", %{conn: conn} do test "category descriptions use the current Ukrainian locale", %{conn: conn} do
@ -273,6 +287,20 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert html =~ "activity[structured_data][route_plan]" assert html =~ "activity[structured_data][route_plan]"
assert html =~ "activity[structured_data][safety_plan_confirmed]" assert html =~ "activity[structured_data][safety_plan_confirmed]"
assert html =~ "approve participants individually" assert html =~ "approve participants individually"
html =
render_change(view, "validate", %{
"activity" => %{
"category_id" => hiking.id,
"structured_data" => %{
"_unused_route_plan" => "",
"route_plan" => ""
}
}
})
assert html =~ "route_plan is required"
refute html =~ "_unused_route_plan is not allowed"
end end
test "organizer approves an activity participant and group chat updates live", %{ test "organizer approves an activity participant and group chat updates live", %{
@ -354,8 +382,17 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|> render_submit() |> render_submit()
assert html =~ "I will be there." assert html =~ "I will be there."
assert_push_event(participant_view, "reset-message-form", %{id: "activity-message-form"})
assert render(organizer_view) =~ "I will be there." assert render(organizer_view) =~ "I will be there."
participant_view
|> form("#activity-report-form",
report: %{reason: "harassment", details: "Review this activity context."}
)
|> render_submit()
assert_push_event(participant_view, "reset-message-form", %{id: "activity-report-form"})
now = DateTime.utc_now(:second) now = DateTime.utc_now(:second)
for index <- 1..51 do for index <- 1..51 do
@ -384,6 +421,13 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
refute bounded_activity_html =~ "oldest-realtime-activity-message" refute bounded_activity_html =~ "oldest-realtime-activity-message"
assert bounded_activity_html =~ "newest-realtime-activity-message" assert bounded_activity_html =~ "newest-realtime-activity-message"
organizer_view
|> element("button[phx-click='complete']")
|> render_click()
assert render(participant_view) =~ "Completed"
refute has_element?(participant_view, "button[phx-click='leave']")
stop_live_view(organizer_view) stop_live_view(organizer_view)
stop_live_view(participant_view) stop_live_view(participant_view)
end end
@ -561,12 +605,77 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
requester = user_fixture(display_name: "Requester") requester = user_fixture(display_name: "Requester")
{:ok, request} = Help.create_request(user_scope_fixture(requester), request_attrs(category)) {:ok, request} = Help.create_request(user_scope_fixture(requester), request_attrs(category))
{:ok, _view, html} = live(conn, ~p"/requests/#{request.id}") {:ok, view, html} = live(conn, ~p"/requests/#{request.id}")
assert html =~ "Safety controls" assert html =~ "Safety controls"
assert html =~ "Block this user" assert html =~ "Block this user"
assert html =~ "Send report" assert html =~ "Send report"
assert html =~ "This is not an emergency service" assert html =~ "This is not an emergency service"
view
|> form("#request-report-form",
report: %{reason: "dangerous_request", details: "Review the request context."}
)
|> render_submit()
assert_push_event(view, "reset-message-form", %{id: "request-report-form"})
end
test "successful category proposal resets the browser form", %{conn: conn} do
Catalog.seed_defaults()
{:ok, view, _html} = live(conn, ~p"/categories/proposals")
view
|> form("#category-proposal-form",
category_proposal: %{
proposed_name: "Chain repair",
mode: "help",
reason: "A reusable category for urgent roadside bicycle chain repair."
}
)
|> render_submit()
assert_push_event(view, "reset-message-form", %{id: "category-proposal-form"})
assert render(view) =~ "Chain repair"
end
test "review submission updates both participant pages and hides the submitted form" do
category = Catalog.seed_defaults()
requester = user_fixture(display_name: "Review requester")
helper = user_fixture(display_name: "Review helper")
requester_scope = user_scope_fixture(requester)
helper_scope = user_scope_fixture(helper)
{:ok, request} = Help.create_request(requester_scope, request_attrs(category))
{:ok, assignment} = Help.accept_request(helper_scope, request.id)
{:ok, _} = Help.confirm_completion(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))
{:ok, requester_view, _html} =
build_conn() |> log_in_user(requester) |> live(~p"/requests/#{request.id}")
{:ok, helper_view, _html} =
build_conn() |> log_in_user(helper) |> live(~p"/requests/#{request.id}")
requester_view
|> form("form[phx-submit='review']", review: %{rating: "5", comment: "Kind"})
|> render_submit()
refute has_element?(requester_view, "form[phx-submit='review']")
assert has_element?(helper_view, "form[phx-submit='review']")
helper_view
|> form("form[phx-submit='review']", review: %{rating: "4", comment: "Clear"})
|> render_submit()
refute has_element?(helper_view, "form[phx-submit='review']")
assert render(requester_view) =~ "rating 4.0"
assert render(helper_view) =~ "rating 4.0"
assert render(requester_view) =~ ">5.0<"
assert render(helper_view) =~ ">5.0<"
end end
defp request_attrs(category) do defp request_attrs(category) do