From c634c136c9f266aa29e5bf91f7a0b731f63d4041 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Wed, 22 Jul 2026 06:58:42 +0300 Subject: [PATCH] Improve trust profiles and account case history --- lib/who_need_help/accounts.ex | 19 ++ lib/who_need_help/catalog.ex | 54 +++++- .../catalog/category_proposal.ex | 1 + lib/who_need_help/content_removal.ex | 9 + .../controllers/content_removal_controller.ex | 23 ++- .../content_removal_html/index.html.heex | 44 +++++ .../content_removal_html/new.html.heex | 10 + .../content_removal_html/received.html.heex | 15 +- .../content_removal_html/show.html.heex | 7 + .../take_it_down.html.heex | 10 + .../support_html/delete_account.html.heex | 3 + .../controllers/support_html/new.html.heex | 8 + .../support_html/received.html.heex | 7 +- .../user_settings_html/edit.html.heex | 3 + .../live/activity_live/show.ex | 16 +- .../live/category_proposal_live.ex | 68 ++++++- .../live/leaderboard_live.ex | 6 +- lib/who_need_help_web/live/moderation_live.ex | 5 +- lib/who_need_help_web/live/profile_live.ex | 10 +- .../live/public_profile_live.ex | 181 ++++++++++++++++++ .../live/request_live/show.ex | 15 +- lib/who_need_help_web/router.ex | 9 +- test/who_need_help/accounts_test.exs | 50 +++++ test/who_need_help/mutual_aid_flow_test.exs | 20 ++ .../support_and_content_removal_test.exs | 26 +++ test/who_need_help/trust_safety_test.exs | 61 ++++++ .../controllers/support_controller_test.exs | 43 ++++- .../live/mutual_aid_live_test.exs | 114 +++++++++++ 28 files changed, 805 insertions(+), 32 deletions(-) create mode 100644 lib/who_need_help_web/controllers/content_removal_html/index.html.heex create mode 100644 lib/who_need_help_web/live/public_profile_live.ex diff --git a/lib/who_need_help/accounts.ex b/lib/who_need_help/accounts.ex index 2139de2..0eaecf8 100644 --- a/lib/who_need_help/accounts.ex +++ b/lib/who_need_help/accounts.ex @@ -63,6 +63,25 @@ defmodule WhoNeedHelp.Accounts do select: struct(identity, ^@public_social_identity_fields) end + @doc """ + Loads the public profile projection for an account that has not been suspended. + + The returned struct deliberately contains only `public_user_query/1` fields and + public social identities. It never loads email, authentication, role, or + moderation-note fields. + """ + def get_public_user(id) do + with {:ok, id} <- cast_id(id), + %User{} = user <- + public_user_query(social_identities: true) + |> where([user], user.id == ^id and user.moderation_status != :suspended) + |> Repo.one() do + {:ok, user} + else + _invalid_missing_or_suspended -> {:error, :not_found} + end + end + @doc """ Gets a user by email. diff --git a/lib/who_need_help/catalog.ex b/lib/who_need_help/catalog.ex index dd4f4b1..1054694 100644 --- a/lib/who_need_help/catalog.ex +++ b/lib/who_need_help/catalog.ex @@ -25,6 +25,14 @@ defmodule WhoNeedHelp.Catalog do |> Repo.all() end + def list_proposal_parents(mode) when mode in [:help, :activity] do + Category + |> where([category], category.mode == ^mode) + |> order_by([category], asc: category.sort_order, asc: category.slug) + |> preload(:parent) + |> Repo.all() + end + def get_category!(id), do: Repo.get!(Category, id) def category_path(%Category{parent: %Category{} = parent} = category, locale) do @@ -38,6 +46,14 @@ defmodule WhoNeedHelp.Catalog do end def paginate_proposals(options \\ []) do + paginate_proposals_for_user(nil, options) + end + + def paginate_proposals_for(%Scope{user: %{id: user_id}}, options \\ []) do + paginate_proposals_for_user(user_id, options) + end + + defp paginate_proposals_for_user(user_id, options) do limit = Pagination.limit(options) cursor = Pagination.cursor(options) public_user = Accounts.public_user_query() @@ -48,6 +64,7 @@ defmodule WhoNeedHelp.Catalog do |> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id) |> limit(^(limit + 1)) |> with_vote_count() + |> with_current_vote(user_id) |> preload([proposal], proposer: ^public_user, parent: []) |> Repo.all() |> Pagination.page(limit, &{&1.inserted_at, &1.id}) @@ -201,8 +218,8 @@ defmodule WhoNeedHelp.Catalog do category_attrs |> Map.new(fn {key, value} -> {to_string(key), value} end) |> normalize_descriptions() - |> Map.put_new("parent_id", proposal.parent_id) - |> Map.put_new("mode", to_string(proposal.mode)) + |> Map.put("parent_id", proposal.parent_id) + |> Map.put("mode", to_string(proposal.mode)) with {:ok, category} <- %Category{} |> Category.changeset(category_attrs) |> Repo.insert(), @@ -242,12 +259,10 @@ defmodule WhoNeedHelp.Catalog do end def merge_proposal(%Scope{user: moderator}, proposal_id, category_id, note) do - with {:ok, category_id} <- cast_id(category_id), - %Category{} <- Repo.get(Category, category_id) do + with {:ok, category_id} <- cast_id(category_id) do moderate_proposal(moderator, proposal_id, :merged, category_id, note) else {:error, :not_found} = error -> error - nil -> {:error, :not_found} end end @@ -257,10 +272,24 @@ defmodule WhoNeedHelp.Catalog do Repo.transact(fn -> proposal = locked_proposal(proposal_id) + merge_target = + if status == :merged do + Category + |> where([category], category.id == ^merged_into_id) + |> lock("FOR SHARE") + |> Repo.one() + end + cond do is_nil(proposal) -> {:error, :not_found} + status == :merged and is_nil(merge_target) -> + {:error, :not_found} + + status == :merged and merge_target.mode != proposal.mode -> + {:error, :mode_mismatch} + proposal.status == :open -> with {:ok, proposal} <- proposal @@ -889,6 +918,21 @@ defmodule WhoNeedHelp.Catalog do }) end + defp with_current_vote(query, nil) do + select_merge(query, [proposal], %{voted_by_current_user: false}) + end + + defp with_current_vote(query, user_id) do + query + |> join(:left, [proposal], vote in CategoryVote, + on: vote.proposal_id == proposal.id and vote.user_id == ^user_id, + as: :current_vote + ) + |> select_merge([current_vote: vote], %{ + voted_by_current_user: not is_nil(vote.id) + }) + end + defp before_proposal(query, nil), do: query defp before_proposal(query, {inserted_at, id}) do diff --git a/lib/who_need_help/catalog/category_proposal.ex b/lib/who_need_help/catalog/category_proposal.ex index abeaac1..550036d 100644 --- a/lib/who_need_help/catalog/category_proposal.ex +++ b/lib/who_need_help/catalog/category_proposal.ex @@ -17,6 +17,7 @@ defmodule WhoNeedHelp.Catalog.CategoryProposal do field :reviewed_at, :utc_datetime field :moderation_note, :string field :vote_count, :integer, virtual: true, default: 0 + field :voted_by_current_user, :boolean, virtual: true, default: false has_many :votes, WhoNeedHelp.Catalog.CategoryVote, foreign_key: :proposal_id timestamps(type: :utc_datetime) diff --git a/lib/who_need_help/content_removal.ex b/lib/who_need_help/content_removal.ex index 86f1d7d..f07c462 100644 --- a/lib/who_need_help/content_removal.ex +++ b/lib/who_need_help/content_removal.ex @@ -72,6 +72,15 @@ defmodule WhoNeedHelp.ContentRemoval do |> Repo.all() end + def get_for_user(%Scope{user: %User{id: user_id}}, id) do + with {:ok, id} <- Ecto.UUID.cast(id), + %Notice{} = notice <- Repo.get_by(Notice, id: id, requester_id: user_id) do + {:ok, notice} + else + _ -> {:error, :not_found} + end + end + def get_by_access_token(id, token) when is_binary(token) do with {:ok, id} <- Ecto.UUID.cast(id), {:ok, ^id} <- diff --git a/lib/who_need_help_web/controllers/content_removal_controller.ex b/lib/who_need_help_web/controllers/content_removal_controller.ex index dc2c1a0..8243e47 100644 --- a/lib/who_need_help_web/controllers/content_removal_controller.ex +++ b/lib/who_need_help_web/controllers/content_removal_controller.ex @@ -4,6 +4,11 @@ defmodule WhoNeedHelpWeb.ContentRemovalController do alias WhoNeedHelp.ContentRemoval alias WhoNeedHelp.ContentRemoval.Notice + def index(conn, _params) do + notices = ContentRemoval.list_for_user(conn.assigns.current_scope) + render(conn, :index, notices: notices) + end + def new(conn, params) do attrs = case internal_location(params["location"]) do @@ -27,15 +32,25 @@ defmodule WhoNeedHelpWeb.ContentRemovalController do def create_take_it_down(conn, _params), do: send_resp(conn, :bad_request, "Bad Request") - def show(conn, %{"id" => id, "token" => token}) do - case ContentRemoval.get_by_access_token(id, token) do + def show(conn, %{"id" => id} = params) do + result = + case conn.assigns.current_scope do + nil -> + ContentRemoval.get_by_access_token(id, params["token"]) + + scope -> + case ContentRemoval.get_for_user(scope, id) do + {:ok, notice} -> {:ok, notice} + _ -> ContentRemoval.get_by_access_token(id, params["token"]) + end + end + + case result do {:ok, notice} -> render(conn, :show, notice: notice) {:error, :not_found} -> send_resp(conn, :not_found, "Not found") end end - def show(conn, _params), do: send_resp(conn, :not_found, "Not found") - defp create_notice(conn, regime, params) do case ContentRemoval.create_notice(conn.assigns.current_scope, regime, params) do {:ok, notice} -> diff --git a/lib/who_need_help_web/controllers/content_removal_html/index.html.heex b/lib/who_need_help_web/controllers/content_removal_html/index.html.heex new file mode 100644 index 0000000..68f5d2b --- /dev/null +++ b/lib/who_need_help_web/controllers/content_removal_html/index.html.heex @@ -0,0 +1,44 @@ + +
+
+
+
{gettext("CONTENT REMOVAL")}
+

{gettext("Your notices")}

+
+ <.link navigate={~p"/legal/content-removal"} class="btn btn-error"> + {gettext("New notice")} + +
+ +

+ {gettext( + "Only notices submitted while signed in appear here. Notices submitted without an account remain accessible through their private email link." + )} +

+ +
+ <.link + :for={notice <- @notices} + navigate={~p"/legal/content-removal/#{notice.id}"} + class="block rounded-2xl border border-base-300 p-5 transition hover:border-error" + > +
+ {notice.reference} + + {status_label(notice.status)} + + {category_label(notice.category)} +
+

{notice.explanation}

+ + +

+ {gettext("You have not submitted any content-removal notices while signed in.")} +

+
+
+
diff --git a/lib/who_need_help_web/controllers/content_removal_html/new.html.heex b/lib/who_need_help_web/controllers/content_removal_html/new.html.heex index bb3109b..ca7ca3f 100644 --- a/lib/who_need_help_web/controllers/content_removal_html/new.html.heex +++ b/lib/who_need_help_web/controllers/content_removal_html/new.html.heex @@ -12,6 +12,13 @@ "Provide exact Who Need Help URLs. Do not upload or paste intimate imagery, identity documents, passwords, access codes, or unnecessary medical information." )} + <.link + :if={@current_scope} + navigate={~p"/legal/content-removal/requests"} + class="btn btn-outline btn-sm mt-5" + > + {gettext("View my notices")} + <.form for={@form} action={~p"/legal/content-removal"} class="mt-8 space-y-4"> <.input @@ -39,6 +46,9 @@ value={@contact_email || @form[:contact_email].value} readonly={not is_nil(@contact_email)} /> +

+ {gettext("Status updates go to the email address of your signed-in account.")} +

<.input field={@form[:relationship]} type="select" diff --git a/lib/who_need_help_web/controllers/content_removal_html/received.html.heex b/lib/who_need_help_web/controllers/content_removal_html/received.html.heex index 143505c..b6acfaa 100644 --- a/lib/who_need_help_web/controllers/content_removal_html/received.html.heex +++ b/lib/who_need_help_web/controllers/content_removal_html/received.html.heex @@ -14,8 +14,17 @@ "A private status link has been sent when a contact email was provided and delivery is configured. Opening that link verifies the contact address. Keep the reference for follow-up." )}

- <.link navigate={~p"/legal/content-removal"} class="btn btn-primary mt-7"> - {gettext("Back to removal form")} - +
+ <.link + :if={@current_scope} + navigate={~p"/legal/content-removal/requests"} + class="btn btn-primary" + > + {gettext("View my notices")} + + <.link navigate={~p"/legal/content-removal"} class="btn btn-outline"> + {gettext("Back to removal form")} + +
diff --git a/lib/who_need_help_web/controllers/content_removal_html/show.html.heex b/lib/who_need_help_web/controllers/content_removal_html/show.html.heex index dfccfcc..e064157 100644 --- a/lib/who_need_help_web/controllers/content_removal_html/show.html.heex +++ b/lib/who_need_help_web/controllers/content_removal_html/show.html.heex @@ -1,5 +1,12 @@
+ <.link + :if={@current_scope && @notice.requester_id == @current_scope.user.id} + navigate={~p"/legal/content-removal/requests"} + class="link text-sm" + > + ← {gettext("Back to my notices")} +
{@notice.reference} + <.link + :if={@current_scope} + navigate={~p"/legal/content-removal/requests"} + class="btn btn-outline btn-sm mt-5" + > + {gettext("View my notices")} + <.form for={@form} action={~p"/legal/take-it-down"} class="mt-8 space-y-4"> <.input @@ -36,6 +43,9 @@ readonly={not is_nil(@contact_email)} required /> +

+ {gettext("Status updates go to the email address of your signed-in account.")} +

<.input field={@form[:relationship]} type="select" diff --git a/lib/who_need_help_web/controllers/support_html/delete_account.html.heex b/lib/who_need_help_web/controllers/support_html/delete_account.html.heex index ab1e2f3..456b752 100644 --- a/lib/who_need_help_web/controllers/support_html/delete_account.html.heex +++ b/lib/who_need_help_web/controllers/support_html/delete_account.html.heex @@ -28,6 +28,9 @@ readonly={not is_nil(@contact_email)} required /> +

+ {gettext("This request is linked to your signed-in account and its email address.")} +

<.input field={@form[:details]} type="textarea" diff --git a/lib/who_need_help_web/controllers/support_html/new.html.heex b/lib/who_need_help_web/controllers/support_html/new.html.heex index 8db4031..11598e4 100644 --- a/lib/who_need_help_web/controllers/support_html/new.html.heex +++ b/lib/who_need_help_web/controllers/support_html/new.html.heex @@ -9,6 +9,9 @@

+ <.link :if={@current_scope} navigate={~p"/support/requests"} class="btn btn-outline btn-sm"> + {gettext("View my requests")} + <.link navigate={~p"/legal/content-removal"} class="btn btn-outline btn-sm"> {gettext("Report content")} @@ -45,6 +48,11 @@ readonly={not is_nil(@contact_email)} required /> +

+ {gettext( + "This request is linked to your signed-in account. Replies go to its email address." + )} +

<.input field={@form[:subject]} label={gettext("Subject")} required /> <.input field={@form[:details]} diff --git a/lib/who_need_help_web/controllers/support_html/received.html.heex b/lib/who_need_help_web/controllers/support_html/received.html.heex index 7418261..b95756c 100644 --- a/lib/who_need_help_web/controllers/support_html/received.html.heex +++ b/lib/who_need_help_web/controllers/support_html/received.html.heex @@ -13,6 +13,11 @@ reference: @reference )}

- <.link navigate={~p"/support"} class="btn btn-primary mt-7">{gettext("Back to support")} +
+ <.link :if={@current_scope} navigate={~p"/support/requests"} class="btn btn-primary"> + {gettext("View my requests")} + + <.link navigate={~p"/support"} class="btn btn-outline">{gettext("Back to support")} +
diff --git a/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex b/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex index 3493f6b..de5521c 100644 --- a/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex +++ b/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex @@ -125,6 +125,9 @@ <.link navigate={~p"/support/requests"} class="btn btn-outline"> {gettext("View support requests")} + <.link navigate={~p"/legal/content-removal/requests"} class="btn btn-outline"> + {gettext("View content-removal notices")} +
diff --git a/lib/who_need_help_web/live/activity_live/show.ex b/lib/who_need_help_web/live/activity_live/show.ex index 41bd7ae..1e6f492 100644 --- a/lib/who_need_help_web/live/activity_live/show.ex +++ b/lib/who_need_help_web/live/activity_live/show.ex @@ -393,7 +393,9 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
{gettext("Organizer")}
- {@activity.creator.display_name || gettext("Community member")} + <.link navigate={~p"/people/#{@activity.creator.id}"} class="link"> + {@activity.creator.display_name || gettext("Community member")} +
- {message.sender.display_name || gettext("Participant")} + <.link navigate={~p"/people/#{message.sender.id}"} class="link"> + {message.sender.display_name || gettext("Participant")} +
diff --git a/lib/who_need_help_web/live/leaderboard_live.ex b/lib/who_need_help_web/live/leaderboard_live.ex index 664918d..1cf101d 100644 --- a/lib/who_need_help_web/live/leaderboard_live.ex +++ b/lib/who_need_help_web/live/leaderboard_live.ex @@ -68,7 +68,11 @@ defmodule WhoNeedHelpWeb.LeaderboardLive do #{index} - {entry.user.display_name} + + <.link navigate={~p"/people/#{entry.user.id}"} class="link"> + {entry.user.display_name} + + {entry.location_supported_people} {entry.verified_people} {entry.unique_people} diff --git a/lib/who_need_help_web/live/moderation_live.ex b/lib/who_need_help_web/live/moderation_live.ex index daa5648..b6e706c 100644 --- a/lib/who_need_help_web/live/moderation_live.ex +++ b/lib/who_need_help_web/live/moderation_live.ex @@ -229,6 +229,7 @@ defmodule WhoNeedHelpWeb.ModerationLive do defp error_message(:forbidden), do: gettext("Moderator access is required.") defp error_message(:proposal_closed), do: gettext("This proposal has already been reviewed.") + defp error_message(:mode_mismatch), do: gettext("Choose a category from the same mode.") defp error_message(:cannot_restrict_self), do: gettext("You cannot restrict your own moderator account.") @@ -545,7 +546,9 @@ defmodule WhoNeedHelpWeb.ModerationLive do type="select" label={gettext("Merge into")} options={ - Enum.map(@categories, &{WhoNeedHelp.Catalog.Category.name(&1, "en"), &1.id}) + @categories + |> Enum.filter(&(&1.mode == proposal.mode)) + |> Enum.map(&{WhoNeedHelp.Catalog.category_path(&1, "en"), &1.id}) } /> <.input field={merge_form[:note]} placeholder={gettext("Merge note")} /> diff --git a/lib/who_need_help_web/live/profile_live.ex b/lib/who_need_help_web/live/profile_live.ex index 4002d5d..5c21376 100644 --- a/lib/who_need_help_web/live/profile_live.ex +++ b/lib/who_need_help_web/live/profile_live.ex @@ -212,7 +212,15 @@ defmodule WhoNeedHelpWeb.ProfileLive do {gettext("None revealed yet.")}

-
{"★" |> String.duplicate(review.rating)}
+
+ {"★" |> String.duplicate(review.rating)} + <.link + navigate={~p"/people/#{review.reviewer.id}"} + class="link text-xs text-base-content/60" + > + {review.reviewer.display_name} + +
{review.comment}
+ + +
+ + """ + end +end diff --git a/lib/who_need_help_web/live/request_live/show.ex b/lib/who_need_help_web/live/request_live/show.ex index 57b24d1..ba66e02 100644 --- a/lib/who_need_help_web/live/request_live/show.ex +++ b/lib/who_need_help_web/live/request_live/show.ex @@ -831,7 +831,12 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
{gettext("Requester:")} - {@request.requester.display_name} + <.link + navigate={~p"/people/#{@request.requester.id}"} + class="link font-medium" + > + {@request.requester.display_name} +
{gettext("Expires:")} @@ -1067,7 +1072,13 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do <% @participant -> %>
- {gettext("Helper: %{name}", name: @assignment.helper.display_name)} + {gettext("Helper:")} + <.link + navigate={~p"/people/#{@assignment.helper.id}"} + class="link ml-1 font-medium" + > + {@assignment.helper.display_name} +
diff --git a/lib/who_need_help_web/router.ex b/lib/who_need_help_web/router.ex index 2f119fd..963baf1 100644 --- a/lib/who_need_help_web/router.ex +++ b/lib/who_need_help_web/router.ex @@ -84,7 +84,6 @@ defmodule WhoNeedHelpWeb.Router do get "/legal/content-removal", ContentRemovalController, :new post "/legal/content-removal", ContentRemovalController, :create get "/legal/content-removal/received", ContentRemovalController, :received - get "/legal/content-removal/:id", ContentRemovalController, :show get "/legal/take-it-down", ContentRemovalController, :take_it_down post "/legal/take-it-down", ContentRemovalController, :create_take_it_down end @@ -138,6 +137,13 @@ defmodule WhoNeedHelpWeb.Router do get "/auth/social/:provider", SocialOAuthController, :request get "/auth/social/:provider/callback", SocialOAuthController, :callback get "/support/requests", SupportController, :index + get "/legal/content-removal/requests", ContentRemovalController, :index + end + + scope "/", WhoNeedHelpWeb do + pipe_through :browser + + get "/legal/content-removal/:id", ContentRemovalController, :show end scope "/", WhoNeedHelpWeb do @@ -153,6 +159,7 @@ defmodule WhoNeedHelpWeb.Router do live "/activities/:id", ActivityLive.Show, :show live "/categories/proposals", CategoryProposalLive, :index live "/profile", ProfileLive, :edit + live "/people/:id", PublicProfileLive, :show live "/leaderboard", LeaderboardLive, :index end diff --git a/test/who_need_help/accounts_test.exs b/test/who_need_help/accounts_test.exs index 3c7a7d8..94a42ee 100644 --- a/test/who_need_help/accounts_test.exs +++ b/test/who_need_help/accounts_test.exs @@ -89,6 +89,56 @@ defmodule WhoNeedHelp.AccountsTest do end end + describe "get_public_user/1" do + test "loads only the public projection and public social identities" do + user = + user_fixture() + |> set_password() + |> Ecto.Changeset.change( + bio: "A short public bio", + role: :admin, + moderation_note: "private moderator note" + ) + |> Repo.update!() + + {:ok, identity} = + Accounts.add_social_identity(user, %{ + "provider" => "telegram", + "profile_url" => "https://t.me/public_profile_test", + "handle" => "@public_profile_test" + }) + + assert {:ok, public_user} = Accounts.get_public_user(user.id) + assert public_user.id == user.id + assert public_user.bio == "A short public bio" + + assert [%{id: identity_id, profile_url: "https://t.me/public_profile_test"}] = + public_user.social_identities + + assert identity_id == identity.id + assert is_nil(public_user.email) + assert is_nil(public_user.hashed_password) + assert public_user.role == :user + assert is_nil(public_user.moderation_note) + end + + test "does not expose invalid, missing, or suspended accounts" do + user = user_fixture() + + assert {:error, :not_found} = Accounts.get_public_user("not-a-uuid") + + assert {:error, :not_found} = + Accounts.get_public_user("11111111-1111-1111-1111-111111111111") + + {1, nil} = + Repo.update_all(from(candidate in User, where: candidate.id == ^user.id), + set: [moderation_status: :suspended] + ) + + assert {:error, :not_found} = Accounts.get_public_user(user.id) + end + end + describe "register_user/1" do test "requires email to be set" do {:error, changeset} = Accounts.register_user(%{}) diff --git a/test/who_need_help/mutual_aid_flow_test.exs b/test/who_need_help/mutual_aid_flow_test.exs index d6b75d7..4d94b23 100644 --- a/test/who_need_help/mutual_aid_flow_test.exs +++ b/test/who_need_help/mutual_aid_flow_test.exs @@ -508,10 +508,30 @@ defmodule WhoNeedHelp.MutualAidFlowTest do assert [%{id: listed_id, vote_count: 1}] = Catalog.list_proposals() assert listed_id == proposal.id + assert [%{voted_by_current_user: true}] = + Catalog.paginate_proposals_for(context.helper_scope).entries + + assert {:ok, 1} = Catalog.unvote(context.helper_scope, proposal.id) + + assert [%{vote_count: 0, voted_by_current_user: false}] = + Catalog.paginate_proposals_for(context.helper_scope).entries + refute export =~ context.requester.email refute export =~ context.requester.display_name end + test "proposal parents include taxonomy containers and stay within their mode", _context do + Catalog.seed_defaults() + + help_parents = Catalog.list_proposal_parents(:help) + activity_parents = Catalog.list_proposal_parents(:activity) + + assert Enum.any?(help_parents, &(&1.slug == "roadside-help" and &1.active == false)) + assert Enum.all?(help_parents, &(&1.mode == :help)) + assert Enum.all?(activity_parents, &(&1.mode == :activity)) + refute Enum.any?(activity_parents, &(&1.slug == "roadside-help")) + end + test "default urgent and roadside categories are hierarchical and idempotent", context do first_ids = Catalog.list_categories() diff --git a/test/who_need_help/support_and_content_removal_test.exs b/test/who_need_help/support_and_content_removal_test.exs index a902c4e..83115cc 100644 --- a/test/who_need_help/support_and_content_removal_test.exs +++ b/test/who_need_help/support_and_content_removal_test.exs @@ -211,6 +211,32 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do assert request.contact_verified_at end + test "content-removal ownership lookup is scoped to the submitting account" do + owner = user_fixture() + outsider = user_fixture() + + assert {:ok, notice} = + ContentRemoval.create_notice(user_scope_fixture(owner), :general, %{ + "category" => "privacy_violation", + "submitter_name" => "Notice Owner", + "relationship" => "self", + "content_locations" => "https://example.test/requests/scoped-notice", + "explanation" => + "This notice verifies account-scoped access to a removal request.", + "electronic_signature" => "Notice Owner", + "good_faith" => "true", + "accurate_complete" => "true" + }) + + assert {:ok, ^notice} = ContentRemoval.get_for_user(user_scope_fixture(owner), notice.id) + + assert {:error, :not_found} = + ContentRemoval.get_for_user(user_scope_fixture(outsider), notice.id) + + assert [^notice] = ContentRemoval.list_for_user(user_scope_fixture(owner)) + assert [] = ContentRemoval.list_for_user(user_scope_fixture(outsider)) + end + defp moderator_scope do user_fixture() |> Ecto.Changeset.change(role: :moderator) diff --git a/test/who_need_help/trust_safety_test.exs b/test/who_need_help/trust_safety_test.exs index 3096bbf..a092a14 100644 --- a/test/who_need_help/trust_safety_test.exs +++ b/test/who_need_help/trust_safety_test.exs @@ -114,6 +114,67 @@ defmodule WhoNeedHelp.TrustSafetyTest do ) end + test "category moderation cannot cross help and activity taxonomies", context do + moderator = + user_fixture(display_name: "Category moderator") + |> Ecto.Changeset.change(role: :moderator) + |> Repo.update!() + + 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 + 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) diff --git a/test/who_need_help_web/controllers/support_controller_test.exs b/test/who_need_help_web/controllers/support_controller_test.exs index ca69253..3060d1b 100644 --- a/test/who_need_help_web/controllers/support_controller_test.exs +++ b/test/who_need_help_web/controllers/support_controller_test.exs @@ -80,7 +80,7 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do describe "authenticated support" do setup :register_and_log_in_user - test "lists the current user's cases and links account deletion from settings", %{ + test "lists the current user's support cases and removal notices", %{ conn: conn, user: user } do @@ -91,9 +91,50 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do "details" => "I need assistance understanding an account setting." }) + {:ok, notice} = + WhoNeedHelp.ContentRemoval.create_notice(user_scope_fixture(user), :general, %{ + "category" => "privacy_violation", + "submitter_name" => "Account Owner", + "relationship" => "self", + "content_locations" => "https://example.test/requests/private-content", + "explanation" => + "This notice verifies that a signed-in account can revisit its private case.", + "electronic_signature" => "Account Owner", + "good_faith" => "true", + "accurate_complete" => "true" + }) + assert html_response(get(conn, ~p"/support/requests"), 200) =~ request.reference + + assert html_response(get(conn, ~p"/legal/content-removal/requests"), 200) =~ + notice.reference + + assert html_response(get(conn, ~p"/legal/content-removal/#{notice.id}"), 200) =~ + notice.reference + assert html_response(get(conn, ~p"/users/settings"), 200) =~ ~p"/account/delete" end + + test "does not expose another account's removal notice without its token", %{ + conn: conn + } do + owner = user_fixture() + + {:ok, notice} = + WhoNeedHelp.ContentRemoval.create_notice(user_scope_fixture(owner), :general, %{ + "category" => "privacy_violation", + "submitter_name" => "Different Owner", + "relationship" => "self", + "content_locations" => "https://example.test/requests/other-account", + "explanation" => + "This notice must remain private from a different authenticated account.", + "electronic_signature" => "Different Owner", + "good_faith" => "true", + "accurate_complete" => "true" + }) + + assert response(get(conn, ~p"/legal/content-removal/#{notice.id}"), 404) == "Not found" + end end describe "operator queue authorization" do diff --git a/test/who_need_help_web/live/mutual_aid_live_test.exs b/test/who_need_help_web/live/mutual_aid_live_test.exs index 371c2ab..8dcdece 100644 --- a/test/who_need_help_web/live/mutual_aid_live_test.exs +++ b/test/who_need_help_web/live/mutual_aid_live_test.exs @@ -15,6 +15,71 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do assert html =~ "Repeated help between the same pair" end + test "public profile reveals trust data without exposing private account fields", %{conn: conn} do + category = Catalog.seed_defaults() + + requester = + user_fixture(display_name: "Public reviewer") + |> Ecto.Changeset.change(moderation_note: "never render this note") + |> Repo.update!() + + helper = user_fixture(display_name: "Public helper") + + {:ok, helper} = + Accounts.update_user_profile(helper, %{ + "display_name" => helper.display_name, + "bio" => "I help with bicycles and medicine pickup.", + "locale" => helper.locale, + "location_visibility" => helper.location_visibility, + "direct_message_policy" => helper.direct_message_policy, + "tip_url" => "https://example.com/thanks" + }) + + {:ok, _identity} = + Accounts.add_social_identity(helper, %{ + "provider" => "telegram", + "profile_url" => "https://t.me/public_helper", + "handle" => "@public_helper" + }) + + {:ok, request} = + Help.create_request(Accounts.Scope.for_user(requester), request_attrs(category)) + + {:ok, assignment} = Help.accept_request(Accounts.Scope.for_user(helper), request.id) + {:ok, _} = Help.confirm_completion(Accounts.Scope.for_user(requester), assignment.id) + {:ok, _} = Help.confirm_completion(Accounts.Scope.for_user(helper), assignment.id) + + {:ok, assignment} = + Help.verify_handover( + Accounts.Scope.for_user(helper), + assignment.id, + Help.handover_code(request.id) + ) + + {:ok, _} = + Trust.submit_review(Accounts.Scope.for_user(requester), assignment, %{ + "rating" => "5", + "comment" => "Reliable and kind." + }) + + {:ok, _} = + Trust.submit_review(Accounts.Scope.for_user(helper), assignment, %{ + "rating" => "4", + "comment" => "Clear request." + }) + + {:ok, _view, html} = live(conn, ~p"/people/#{helper.id}") + + assert html =~ "Public helper" + assert html =~ "I help with bicycles and medicine pickup." + assert html =~ "Reliable and kind." + assert html =~ "Public reviewer" + assert html =~ "https://t.me/public_helper" + assert html =~ "https://example.com/thanks" + refute html =~ helper.email + refute html =~ "never render this note" + end + test "authenticated LiveView honors the locale stored by the browser pipeline", %{conn: conn} do conn = get(conn, ~p"/?locale=uk") {:ok, _view, html} = live(conn, ~p"/requests") @@ -860,6 +925,55 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do assert render(view) =~ "Chain repair" end + test "category voting toggles and parent choices follow the selected mode", %{ + conn: conn, + user: user + } do + Catalog.seed_defaults() + + {:ok, proposal} = + Catalog.propose(Accounts.Scope.for_user(user), %{ + "proposed_name" => "Cargo bicycle help", + "mode" => "help", + "reason" => "A reusable category for cargo bicycle roadside assistance." + }) + + roadside = Enum.find(Catalog.list_proposal_parents(:help), &(&1.slug == "roadside-help")) + activity_parent = Catalog.list_proposal_parents(:activity) |> List.first() + + {:ok, view, _html} = live(conn, ~p"/categories/proposals") + + assert has_element?(view, "#proposal-vote-#{proposal.id}[aria-pressed='false']") + assert has_element?(view, "#category-proposal-form option[value='#{roadside.id}']") + + view + |> element("#proposal-vote-#{proposal.id}") + |> render_click() + + assert has_element?(view, "#proposal-unvote-#{proposal.id}[aria-pressed='true']") + assert render(view) =~ "Voted · 1" + + view + |> element("#proposal-unvote-#{proposal.id}") + |> render_click() + + assert has_element?(view, "#proposal-vote-#{proposal.id}[aria-pressed='false']") + assert render(view) =~ "Vote · 0" + + view + |> form("#category-proposal-form", category_proposal: %{mode: "activity"}) + |> render_change() + + refute has_element?(view, "#category-proposal-form option[value='#{roadside.id}']") + + if activity_parent do + assert has_element?( + view, + "#category-proposal-form option[value='#{activity_parent.id}']" + ) + end + 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")