From 01483d27b8bce83ae4bfa0a250538e6190ab40fb Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Wed, 26 Aug 2026 12:27:36 -0700 Subject: [PATCH 1/3] feat: complete administration report review Add the abuse-report model prerequisite and a paginated platform-admin queue with reasoned, audited dismissal and resolution. Close RFD 1 Phase 5 with cross-feature context and LiveView coverage for authorization, moderation, account controls, reauthentication, final-admin protection, and audit behavior. --- lib/textbin/administration.ex | 116 ++++++++++++++++ lib/textbin/reports.ex | 80 +++++++++++ lib/textbin/reports/report.ex | 38 ++++++ lib/textbin_web/live/ui/admin_live.ex | 40 +++++- .../live/ui/admin_live/index.html.heex | 103 ++++++++++++++ .../20260826130000_create_reports.exs | 43 ++++++ priv/repo/structure.sql | 56 +++++++- rfd/0001/IMPLEMENTATION.org | 4 +- test/textbin/administration_test.exs | 127 ++++++++++++++++++ test/textbin/reports_test.exs | 115 ++++++++++++++++ test/textbin_web/live/ui/admin_live_test.exs | 111 +++++++++++++++ 11 files changed, 828 insertions(+), 5 deletions(-) create mode 100644 lib/textbin/reports.ex create mode 100644 lib/textbin/reports/report.ex create mode 100644 priv/repo/migrations/20260826130000_create_reports.exs create mode 100644 test/textbin/reports_test.exs diff --git a/lib/textbin/administration.ex b/lib/textbin/administration.ex index e6a5671..1abe52f 100644 --- a/lib/textbin/administration.ex +++ b/lib/textbin/administration.ex @@ -22,6 +22,7 @@ defmodule Textbin.Administration do alias Textbin.Pastes.Paste alias Textbin.Repo + alias Textbin.Reports.Report @platform_admin_role "admin" @authority_lock_key 8_174_021_483_001 @@ -199,6 +200,33 @@ defmodule Textbin.Administration do end end + @doc "Lists the open abuse-report queue oldest first without reporter identity." + def list_reports(scope, opts \\ []) do + with {:ok, _admin} <- authorize_platform_admin(scope) do + limit = page_limit(Keyword.get(opts, :limit)) + offset = page_offset(Keyword.get(opts, :page), limit) + + reports = + Repo.all( + from report in Report, + where: report.status == "open", + order_by: [asc: report.inserted_at, asc: report.id], + limit: ^(limit + 1), + offset: ^offset, + select: %{ + id: report.id, + paste_id: report.paste_id, + category: report.category, + notes: report.notes, + status: report.status, + inserted_at: report.inserted_at + } + ) + + {:ok, page(reports, limit, Keyword.get(opts, :page))} + end + end + @doc false def authorize_account_deletion(%Scope{user: %User{id: user_id}} = scope) do case Repo.transact(fn -> authorize_account_deletion_in_transaction(scope, user_id) end) do @@ -336,6 +364,16 @@ defmodule Textbin.Administration do end end + @doc "Dismisses an open abuse report with an audited reason." + def dismiss_report(scope, report, reason, opts \\ []) do + review_report(scope, report, reason, "dismissed", "platform.report.dismissed", opts) + end + + @doc "Marks an open abuse report resolved with an audited reason." + def resolve_report(scope, report, reason, opts \\ []) do + review_report(scope, report, reason, "actioned", "platform.report.resolved", opts) + end + defp authorize_account_deletion_in_transaction(scope, user_id) do with :ok <- lock_authority_changes(), %User{suspended_at: nil} = user <- lock_user(user_id), @@ -525,6 +563,35 @@ defmodule Textbin.Administration do end end + defp review_report(scope, report, reason, status, action, opts) do + with {:ok, reason} <- normalize_reason(reason), + {:ok, report_id} <- report_id(report) do + report_transaction( + scope, + &review_report_in_transaction(&1, report_id, reason, status, action, opts) + ) + end + end + + defp review_report_in_transaction(actor, report_id, reason, status, action, opts) do + with %Report{} = report <- lock_open_report(report_id), + {:ok, report} <- + report + |> Ecto.Changeset.change( + status: status, + resolution_reason: reason, + resolved_by_user_id: actor.id, + resolved_at: DateTime.utc_now() + ) + |> Repo.update(), + :ok <- record_report_audit(actor, action, report, reason, opts) do + {:ok, report} + else + nil -> {:error, :not_found} + error -> error + end + end + defp authority_transaction(%Scope{} = scope, callback) do Repo.transact(fn -> with :ok <- lock_authority_changes(), @@ -537,6 +604,19 @@ defmodule Textbin.Administration do defp authority_transaction(_scope, _callback), do: {:error, :forbidden} + # Report-only review does not require sudo, but it shares the authority lock + # so revocation cannot race authorization and the audited state transition. + defp report_transaction(%Scope{} = scope, callback) do + Repo.transact(fn -> + with :ok <- lock_authority_changes(), + {:ok, actor} <- lock_platform_admin(scope) do + callback.(actor) + end + end) + end + + defp report_transaction(_scope, _callback), do: {:error, :forbidden} + defp lock_platform_admin(%Scope{user: %User{id: user_id}}) do case lock_user(user_id) do %User{} = user -> authorize_active_admin(user) @@ -674,6 +754,23 @@ defmodule Textbin.Administration do |> audit_result() end + defp record_report_audit(actor, action, report, reason, opts) do + %PlatformAuditEvent{} + |> PlatformAuditEvent.changeset(%{ + actor_kind: "user", + actor_user_id: actor.id, + actor_label: actor.email, + action: action, + target_type: "report", + target_id: report.id, + reason: reason, + request_id: Keyword.get(opts, :request_id), + metadata: %{"paste_id" => report.paste_id, "category" => report.category} + }) + |> Repo.insert() + |> audit_result() + end + defp audit_result({:ok, %PlatformAuditEvent{}}), do: :ok defp audit_result({:error, changeset}), do: {:error, changeset} @@ -704,6 +801,14 @@ defmodule Textbin.Administration do ) end + defp lock_open_report(report_id) do + Repo.one( + from report in Report, + where: report.id == ^report_id and report.status == "open", + lock: "FOR UPDATE" + ) + end + defp normalize_reason(reason) when is_binary(reason) do case String.trim(reason) do "" -> {:error, :reason_required} @@ -736,6 +841,17 @@ defmodule Textbin.Administration do defp paste_id(_id), do: {:error, :not_found} + defp report_id(%Report{id: id}), do: report_id(id) + + defp report_id(id) when is_binary(id) do + case Ecto.UUID.cast(id) do + {:ok, id} -> {:ok, id} + :error -> {:error, :not_found} + end + end + + defp report_id(_id), do: {:error, :not_found} + defp encode_timestamp(nil), do: nil defp encode_timestamp(timestamp), do: DateTime.to_iso8601(timestamp) diff --git a/lib/textbin/reports.ex b/lib/textbin/reports.ex new file mode 100644 index 0000000..16a4355 --- /dev/null +++ b/lib/textbin/reports.ex @@ -0,0 +1,80 @@ +defmodule Textbin.Reports do + @moduledoc """ + Abuse-report submission and its non-public reporter boundary. + + Reports retain paste and reporter identifiers after either record is deleted, + so those identifiers intentionally are not database foreign keys. Only the + administration context can read or review submitted reports. + """ + + import Ecto.Query, warn: false + + alias Textbin.Accounts.{Scope, User} + alias Textbin.Organizations.{Organization, Workspace} + alias Textbin.Pastes.Paste + alias Textbin.Repo + alias Textbin.Reports.Report + + @doc "Submits an abuse report for an active public or unlisted paste." + def create_report(%Scope{user: %User{id: reporter_id}}, paste_id, attrs) + when is_map(attrs) do + with {:ok, paste_id} <- Ecto.UUID.cast(paste_id) do + Repo.transact(fn -> create_report_in_transaction(reporter_id, paste_id, attrs) end) + else + :error -> {:error, :not_found} + end + end + + def create_report(_scope, _paste_id, _attrs), do: {:error, :forbidden} + + defp create_report_in_transaction(reporter_id, paste_id, attrs) do + case active_registered_user(reporter_id) do + %User{} = reporter -> insert_report(reporter, paste_id, attrs) + nil -> {:error, :forbidden} + end + end + + defp insert_report(reporter, paste_id, attrs) do + case reportable_paste(paste_id) do + %Paste{} = paste -> + %Report{paste_id: paste.id, reporter_user_id: reporter.id} + |> Report.submission_changeset(attrs) + |> Repo.insert() + + nil -> + {:error, :not_found} + end + end + + defp active_registered_user(user_id) do + Repo.one( + from user in User, + where: + user.id == ^user_id and user.kind == "registered" and + not is_nil(user.confirmed_at) and is_nil(user.suspended_at), + lock: "FOR SHARE" + ) + end + + defp reportable_paste(paste_id) do + now = Paste.utc_now_ms() + + Repo.one( + from paste in Paste, + join: workspace in Workspace, + on: workspace.id == paste.workspace_id, + join: organization in Organization, + on: organization.id == workspace.organization_id, + where: + paste.id == ^paste_id and + (is_nil(paste.expires_at) or paste.expires_at > ^now) and + is_nil(workspace.deletion_requested_at) and + is_nil(organization.deletion_requested_at) and + ((paste.audience == "unlisted" and + workspace.external_sharing_policy in ["unlisted", "public"]) or + (paste.audience == "public" and + workspace.external_sharing_policy == "public")), + lock: "FOR SHARE" + ) + end +end diff --git a/lib/textbin/reports/report.ex b/lib/textbin/reports/report.ex new file mode 100644 index 0000000..d4e5c95 --- /dev/null +++ b/lib/textbin/reports/report.ex @@ -0,0 +1,38 @@ +defmodule Textbin.Reports.Report do + use Ecto.Schema + + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @categories ["spam", "malware", "harassment", "copyright", "other"] + @statuses ["open", "actioned", "dismissed"] + + schema "reports" do + field :paste_id, :binary_id + field :reporter_user_id, :binary_id + field :category, :string + field :notes, :string + field :status, :string, default: "open" + field :resolution_reason, :string + field :resolved_by_user_id, :binary_id + field :resolved_at, :utc_datetime_usec + timestamps(type: :utc_datetime_usec) + end + + def submission_changeset(report, attrs) do + report + |> cast(attrs, [:category, :notes]) + |> validate_required([:paste_id, :reporter_user_id, :category, :status]) + |> validate_inclusion(:category, @categories) + |> validate_length(:notes, max: 1_000) + |> unique_constraint([:paste_id, :reporter_user_id], + name: :reports_one_open_per_reporter_index, + message: "has already been reported" + ) + end + + def categories, do: @categories + def statuses, do: @statuses +end diff --git a/lib/textbin_web/live/ui/admin_live.ex b/lib/textbin_web/live/ui/admin_live.ex index ed5eb87..2645290 100644 --- a/lib/textbin_web/live/ui/admin_live.ex +++ b/lib/textbin_web/live/ui/admin_live.ex @@ -20,6 +20,7 @@ defmodule TextbinWeb.UI.AdminLive do |> assign(:lookup_form, to_form(%{"query" => ""}, as: :lookup)) |> assign(:moderation_form, to_form(%{"paste_id" => "", "reason" => ""}, as: :moderation)) |> assign(:account_action_form, to_form(%{"reason" => ""}, as: :account_action)) + |> assign(:report_action_form, to_form(%{"reason" => ""}, as: :report_action)) |> assign(:lookup_performed?, false) |> assign(:lookup, empty_lookup()) |> stream_configure(:largest_pastes, dom_id: &"largest-paste-row-#{&1.row_key}")} @@ -40,6 +41,11 @@ defmodule TextbinWeb.UI.AdminLive do limit: @page_size, page: params["largest_page"] ), + {:ok, report_page} <- + Administration.list_reports(scope, + limit: @page_size, + page: params["report_page"] + ), {:ok, audit_page} <- Administration.list_platform_audit_events(scope, limit: @page_size @@ -49,9 +55,11 @@ defmodule TextbinWeb.UI.AdminLive do |> assign(:overview, overview) |> assign(:recent_page, recent_page) |> assign(:largest_page, largest_page) + |> assign(:report_page, report_page) |> assign(:audit_next_cursor, audit_page.next_cursor) |> stream(:recent_pastes, recent_page.entries, reset: true) |> stream(:largest_pastes, largest_stream_entries(largest_page), reset: true) + |> stream(:reports, report_page.entries, reset: true) |> stream(:platform_audit_events, audit_page.entries, reset: true)} else {:error, :forbidden} -> {:noreply, leave_admin(socket)} @@ -98,6 +106,21 @@ defmodule TextbinWeb.UI.AdminLive do handle_mutation_result(result, socket, account_action_message(action)) end + def handle_event( + "review_report", + %{ + "report_action" => %{ + "action" => action, + "report_id" => report_id, + "reason" => reason + } + }, + socket + ) do + result = report_action(action, socket.assigns.current_scope, report_id, reason) + handle_mutation_result(result, socket, report_action_message(action)) + end + def handle_event("load_more_audit", _params, %{assigns: %{audit_next_cursor: nil}} = socket), do: {:noreply, socket} @@ -140,12 +163,24 @@ defmodule TextbinWeb.UI.AdminLive do defp account_action(_action, _scope, _target_id, _reason), do: {:error, :not_found} + defp report_action("dismiss", scope, report_id, reason), + do: Administration.dismiss_report(scope, report_id, reason) + + defp report_action("resolve", scope, report_id, reason), + do: Administration.resolve_report(scope, report_id, reason) + + defp report_action(_action, _scope, _report_id, _reason), do: {:error, :not_found} + defp account_action_message("grant"), do: "Platform administrator access granted." defp account_action_message("revoke"), do: "Platform administrator access revoked." defp account_action_message("suspend"), do: "Account suspended and active sessions revoked." defp account_action_message("restore"), do: "Account restored. A new login is still required." defp account_action_message(_action), do: "Account updated." + defp report_action_message("dismiss"), do: "Report dismissed." + defp report_action_message("resolve"), do: "Report resolved." + defp report_action_message(_action), do: "Report updated." + defp handle_mutation_result({:ok, _result}, socket, message) do {:noreply, socket @@ -245,12 +280,15 @@ defmodule TextbinWeb.UI.AdminLive do def audit_title("platform.account.restored"), do: "Account restored" def audit_title("platform.admin.account_deleted"), do: "Administrator account deleted" def audit_title("platform.paste.deleted"), do: "Paste administratively removed" + def audit_title("platform.report.dismissed"), do: "Abuse report dismissed" + def audit_title("platform.report.resolved"), do: "Abuse report resolved" def audit_title(action), do: action def page_params(kind, page, assigns) do %{ recent_page: if(kind == :recent, do: page, else: assigns.recent_page.page), - largest_page: if(kind == :largest, do: page, else: assigns.largest_page.page) + largest_page: if(kind == :largest, do: page, else: assigns.largest_page.page), + report_page: if(kind == :report, do: page, else: assigns.report_page.page) } end diff --git a/lib/textbin_web/live/ui/admin_live/index.html.heex b/lib/textbin_web/live/ui/admin_live/index.html.heex index 9006e53..13b2467 100644 --- a/lib/textbin_web/live/ui/admin_live/index.html.heex +++ b/lib/textbin_web/live/ui/admin_live/index.html.heex @@ -209,6 +209,109 @@ +
+
+
+ <.icon name="hero-flag" class="size-5" /> +
+
+

Open abuse reports

+

+ Oldest reports appear first. Reporter identity is not displayed. +

+
+
+
+ +
+
+
+ + {report.category} + + +
+

{report.paste_id}

+

+ {report.notes} +

+ <.link + navigate={~p"/pastes/#{report.paste_id}"} + class="mt-3 inline-flex items-center gap-1 text-xs font-semibold text-primary hover:underline" + > + Inspect paste <.icon name="hero-arrow-up-right" class="size-3" /> + +
+ <.form + for={@report_action_form} + id={"report-action-form-#{report.id}"} + phx-submit="review_report" + class="grid content-start gap-3" + > + + <.input + field={@report_action_form[:reason]} + id={"report-action-reason-#{report.id}"} + type="textarea" + label="Review reason" + maxlength="500" + required + /> +
+ + +
+ +
+
+ <.pagination + id="report-queue-pagination" + page={@report_page} + kind={:report} + assigns={assigns} + /> +
+
"spam", + "notes" => "Queue item #{index}" + }) + + report + end + + assert {:ok, first_page} = Administration.list_reports(context.scope, limit: 2) + assert length(first_page.entries) == 2 + assert first_page.next_page == 2 + refute Enum.any?(first_page.entries, &Map.has_key?(&1, :reporter_user_id)) + + assert {:ok, second_page} = + Administration.list_reports(context.scope, limit: 2, page: 2) + + assert length(second_page.entries) == 1 + + assert MapSet.new(first_page.entries ++ second_page.entries, & &1.id) == + MapSet.new(reports, & &1.id) + + assert {:error, :forbidden} = + Administration.list_reports(Scope.for_user(context.reporter)) + end + + test "dismisses and resolves reports with reasons and audit events without sudo", context do + stale_scope = + Scope.for_user(%{ + context.admin + | authenticated_at: DateTime.add(DateTime.utc_now(:second), -21, :minute) + }) + + dismissed = report_fixture(context, "spam") + resolved = report_fixture(context, "malware") + + assert {:ok, %Report{status: "dismissed"}} = + Administration.dismiss_report(stale_scope, dismissed, "not actionable", + request_id: "dismiss-request" + ) + + assert {:ok, %Report{status: "actioned"}} = + Administration.resolve_report(stale_scope, resolved, "handled externally") + + assert {:ok, %{entries: []}} = Administration.list_reports(context.scope) + + events = + Repo.all( + from event in PlatformAuditEvent, + where: event.target_type == "report", + order_by: [asc: event.inserted_at] + ) + + assert Enum.map(events, & &1.action) == [ + "platform.report.dismissed", + "platform.report.resolved" + ] + + assert Enum.map(events, & &1.reason) == ["not actionable", "handled externally"] + assert hd(events).request_id == "dismiss-request" + assert hd(events).metadata["paste_id"] == dismissed.paste_id + end + + test "requires a reason, current authority, and an atomic audit transition", context do + report = report_fixture(context, "other") + + assert {:error, :reason_required} = + Administration.dismiss_report(context.scope, report, " ") + + assert {:error, :forbidden} = + Administration.resolve_report( + Scope.for_user(context.reporter), + report, + "not authorized" + ) + + assert {:error, %Ecto.Changeset{}} = + Administration.resolve_report(context.scope, report, "reviewed", + request_id: String.duplicate("x", 256) + ) + + assert Repo.get!(Report, report.id).status == "open" + refute Repo.exists?(from event in PlatformAuditEvent, where: event.target_id == ^report.id) + + assert {:ok, %Report{status: "actioned"}} = + Administration.resolve_report(context.scope, report, "reviewed") + + assert {:error, :not_found} = + Administration.dismiss_report(context.scope, report, "second review") + end + end + describe "administration reads" do setup do admin = admin_fixture() @@ -626,4 +738,19 @@ defmodule Textbin.AdministrationTest do assert {:ok, user} = Accounts.create_guest_user() user end + + defp report_fixture(context, category) do + assert {:ok, paste} = + Pastes.create_paste(Scope.for_user(context.owner), %{ + data: "report fixture #{Ecto.UUID.generate()}", + audience: "public" + }) + + assert {:ok, report} = + Reports.create_report(Scope.for_user(context.reporter), paste.id, %{ + "category" => category + }) + + report + end end diff --git a/test/textbin/reports_test.exs b/test/textbin/reports_test.exs new file mode 100644 index 0000000..3d05925 --- /dev/null +++ b/test/textbin/reports_test.exs @@ -0,0 +1,115 @@ +defmodule Textbin.ReportsTest do + use Textbin.DataCase, async: true + + import Textbin.AccountsFixtures + + alias Textbin.Accounts + alias Textbin.Accounts.Scope + alias Textbin.Pastes + alias Textbin.Pastes.Paste + alias Textbin.Reports + alias Textbin.Reports.Report + + setup do + owner = user_fixture() + reporter = user_fixture() + %{owner_scope: Scope.for_user(owner), reporter_scope: Scope.for_user(reporter)} + end + + test "submits categorized reports for active public and unlisted pastes", context do + for audience <- ["public", "unlisted"] do + assert {:ok, paste} = + Pastes.create_paste(context.owner_scope, %{ + data: "reportable #{audience}", + audience: audience + }) + + assert {:ok, %Report{} = report} = + Reports.create_report(context.reporter_scope, paste.id, %{ + "category" => "malware", + "notes" => "Suspicious download" + }) + + assert report.paste_id == paste.id + assert report.reporter_user_id == context.reporter_scope.user.id + assert report.status == "open" + end + end + + test "rejects inaccessible pastes and ineligible reporters", context do + assert {:ok, workspace_paste} = + Pastes.create_paste(context.owner_scope, %{ + data: "workspace only", + audience: "workspace" + }) + + assert {:error, :not_found} = + Reports.create_report(context.reporter_scope, workspace_paste.id, %{ + "category" => "spam" + }) + + assert {:error, :not_found} = + Reports.create_report(context.reporter_scope, Ecto.UUID.generate(), %{ + "category" => "spam" + }) + + assert {:ok, public_paste} = + Pastes.create_paste(context.owner_scope, %{data: "expired", audience: "public"}) + + public_paste + |> Ecto.Changeset.change(expires_at: Paste.utc_now_ms()) + |> Repo.update!() + + assert {:error, :not_found} = + Reports.create_report(context.reporter_scope, public_paste.id, %{ + "category" => "spam" + }) + + assert {:ok, guest} = Accounts.create_guest_user() + + assert {:error, :forbidden} = + Reports.create_report(Scope.for_user(guest), workspace_paste.id, %{ + "category" => "spam" + }) + + assert {:error, :forbidden} = + Reports.create_report(Scope.for_user(nil), workspace_paste.id, %{ + "category" => "spam" + }) + + context.reporter_scope.user + |> Ecto.Changeset.change(suspended_at: DateTime.utc_now(:second)) + |> Repo.update!() + + assert {:error, :forbidden} = + Reports.create_report(context.reporter_scope, workspace_paste.id, %{ + "category" => "spam" + }) + end + + test "validates category, notes, and one open report per reporter", context do + assert {:ok, paste} = + Pastes.create_paste(context.owner_scope, %{data: "spam", audience: "public"}) + + assert {:error, invalid_category} = + Reports.create_report(context.reporter_scope, paste.id, %{"category" => "invalid"}) + + assert "is invalid" in errors_on(invalid_category).category + + assert {:error, invalid_notes} = + Reports.create_report(context.reporter_scope, paste.id, %{ + "category" => "spam", + "notes" => String.duplicate("x", 1_001) + }) + + assert "should be at most 1000 character(s)" in errors_on(invalid_notes).notes + + assert {:ok, _report} = + Reports.create_report(context.reporter_scope, paste.id, %{"category" => "spam"}) + + assert {:error, duplicate} = + Reports.create_report(context.reporter_scope, paste.id, %{"category" => "spam"}) + + assert "has already been reported" in errors_on(duplicate).paste_id + end +end diff --git a/test/textbin_web/live/ui/admin_live_test.exs b/test/textbin_web/live/ui/admin_live_test.exs index d9bb008..c13fd43 100644 --- a/test/textbin_web/live/ui/admin_live_test.exs +++ b/test/textbin_web/live/ui/admin_live_test.exs @@ -12,6 +12,8 @@ defmodule TextbinWeb.UI.AdminLiveTest do alias Textbin.Pastes alias Textbin.Pastes.Paste alias Textbin.Repo + alias Textbin.Reports + alias Textbin.Reports.Report alias TextbinWeb.ForbiddenError setup %{conn: conn} do @@ -169,6 +171,115 @@ defmodule TextbinWeb.UI.AdminLiveTest do assert Repo.get!(Paste, paste.id).expires_at == nil end + test "reviews reports without requiring sudo and refreshes the audit log", %{conn: conn} do + owner = user_fixture() + reporter = user_fixture() + + assert {:ok, paste} = + Pastes.create_paste(Scope.for_user(owner), %{ + data: "reported from LiveView", + audience: "unlisted" + }) + + assert {:ok, report} = + Reports.create_report(Scope.for_user(reporter), paste.id, %{ + "category" => "malware", + "notes" => "Unexpected executable" + }) + + token = get_session(conn, :user_token) + override_token_authenticated_at(token, DateTime.add(DateTime.utc_now(:second), -21, :minute)) + + assert {:ok, view, _html} = live(conn, ~p"/admin") + assert has_element?(view, "#report-action-form-#{report.id}") + assert has_element?(view, "#report-queue-entries", "Unexpected executable") + assert has_element?(view, "#report-queue-entries a[href='/pastes/#{paste.id}']") + + view + |> form("#report-action-form-#{report.id}", + report_action: %{ + report_id: report.id, + reason: "content removed by provider" + } + ) + |> put_submitter("#resolve-report-#{report.id}") + |> render_submit() + + assert_patch(view, ~p"/admin") + assert Repo.get!(Report, report.id).status == "actioned" + refute has_element?(view, "#report-action-form-#{report.id}") + assert has_element?(view, "#platform-audit-events", "Abuse report resolved") + end + + test "surfaces final-admin and reason protections in the panel", %{ + admin: admin, + conn: conn + } do + target = user_fixture() + assert {:ok, view, _html} = live(conn, ~p"/admin") + + view + |> form("#admin-lookup-form", lookup: %{query: admin.email}) + |> render_submit() + + view + |> form("#admin-account-action-form", + account_action: %{action: "revoke", target_id: admin.id, reason: "rotation"} + ) + |> render_submit() + + assert has_element?(view, "#flash-error", "final active administrator") + assert Repo.get!(User, admin.id).platform_role == "admin" + + view + |> form("#admin-lookup-form", lookup: %{query: target.email}) + |> render_submit() + + view + |> form("#admin-account-action-form", + account_action: %{action: "grant", target_id: target.id, reason: " "} + ) + |> render_submit() + + assert has_element?(view, "#flash-error", "reason is required") + assert Repo.get!(User, target.id).platform_role == nil + end + + test "suspends and restores an account through audited panel actions", %{conn: conn} do + target = user_fixture() + target_token = Textbin.Accounts.generate_user_session_token(target) + assert {:ok, view, _html} = live(conn, ~p"/admin") + + view + |> form("#admin-lookup-form", lookup: %{query: target.email}) + |> render_submit() + + view + |> form("#admin-account-action-form", + account_action: %{action: "suspend", target_id: target.id, reason: "abuse response"} + ) + |> render_submit() + + assert_patch(view, ~p"/admin") + assert %User{suspended_at: %DateTime{}} = Repo.get!(User, target.id) + refute Textbin.Accounts.get_user_by_session_token(target_token) + assert has_element?(view, "#platform-audit-events", "Account suspended") + + view + |> form("#admin-lookup-form", lookup: %{query: target.email}) + |> render_submit() + + view + |> form("#admin-account-action-form", + account_action: %{action: "restore", target_id: target.id, reason: "appeal accepted"} + ) + |> render_submit() + + assert_patch(view, ~p"/admin") + assert Repo.get!(User, target.id).suspended_at == nil + assert has_element?(view, "#platform-audit-events", "Account restored") + end + test "renders every protected largest-paste row without exposing capability IDs", %{conn: conn} do owner = user_fixture() scope = Scope.for_user(owner) From 855798c804876408b88699d97934e1ace5ccffb1 Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Wed, 26 Aug 2026 12:45:58 -0700 Subject: [PATCH 2/3] fix: preserve report review privacy Return a privacy-safe report review result and restrict the locked report projection to audit-required fields. Cover concurrent review transitions, stale administrator authority, report eligibility boundaries, and retention after source deletion. --- lib/textbin/administration.ex | 28 +++++--- .../administration/concurrency_test.exs | 41 +++++++++++- test/textbin/administration_test.exs | 36 +++++++++- test/textbin/reports_test.exs | 65 +++++++++++++++++++ 4 files changed, 155 insertions(+), 15 deletions(-) diff --git a/lib/textbin/administration.ex b/lib/textbin/administration.ex index 1abe52f..b74a976 100644 --- a/lib/textbin/administration.ex +++ b/lib/textbin/administration.ex @@ -574,20 +574,27 @@ defmodule Textbin.Administration do end defp review_report_in_transaction(actor, report_id, reason, status, action, opts) do + now = DateTime.utc_now() + with %Report{} = report <- lock_open_report(report_id), - {:ok, report} <- - report - |> Ecto.Changeset.change( - status: status, - resolution_reason: reason, - resolved_by_user_id: actor.id, - resolved_at: DateTime.utc_now() - ) - |> Repo.update(), + {1, nil} <- + Repo.update_all( + from(candidate in Report, + where: candidate.id == ^report.id and candidate.status == "open" + ), + set: [ + status: status, + resolution_reason: reason, + resolved_by_user_id: actor.id, + resolved_at: now, + updated_at: now + ] + ), :ok <- record_report_audit(actor, action, report, reason, opts) do - {:ok, report} + {:ok, %{id: report.id, status: status}} else nil -> {:error, :not_found} + {0, nil} -> {:error, :not_found} error -> error end end @@ -805,6 +812,7 @@ defmodule Textbin.Administration do Repo.one( from report in Report, where: report.id == ^report_id and report.status == "open", + select: struct(report, [:id, :paste_id, :category]), lock: "FOR UPDATE" ) end diff --git a/test/textbin/administration/concurrency_test.exs b/test/textbin/administration/concurrency_test.exs index ddcbfc4..33b14ae 100644 --- a/test/textbin/administration/concurrency_test.exs +++ b/test/textbin/administration/concurrency_test.exs @@ -5,7 +5,12 @@ defmodule Textbin.Administration.ConcurrencyTest do alias Textbin.Accounts alias Textbin.Accounts.{Scope, User, UserToken} alias Textbin.Administration + alias Textbin.Administration.PlatformAuditEvent + alias Textbin.Pastes + alias Textbin.Pastes.Paste alias Textbin.Repo + alias Textbin.Reports + alias Textbin.Reports.Report import Ecto.Query import Textbin.AccountsFixtures @@ -14,7 +19,7 @@ defmodule Textbin.Administration.ConcurrencyTest do setup do :ok = Sandbox.checkout(Repo, sandbox: false) - Repo.query!("TRUNCATE platform_audit_events") + Repo.query!("TRUNCATE reports, platform_audit_events") {:ok, cleanup} = Agent.start(fn -> [] end) on_exit(fn -> @@ -22,7 +27,8 @@ defmodule Textbin.Administration.ConcurrencyTest do :ok = Sandbox.checkout(Repo, sandbox: false) try do - Repo.query!("TRUNCATE platform_audit_events") + Repo.query!("TRUNCATE reports, platform_audit_events") + Repo.delete_all(from paste in Paste, where: paste.created_by_user_id in ^user_ids) Repo.delete_all(from user in User, where: user.id in ^user_ids) after Sandbox.checkin(Repo) @@ -125,6 +131,37 @@ defmodule Textbin.Administration.ConcurrencyTest do end end + test "concurrent report reviews produce one transition and audit event", %{cleanup: cleanup} do + admin_a = tracked_admin_fixture(cleanup) + admin_b = tracked_admin_fixture(cleanup) + owner = tracked_user_fixture(cleanup) + reporter = tracked_user_fixture(cleanup) + + assert {:ok, paste} = + Pastes.create_paste(Scope.for_user(owner), %{ + data: "concurrent report", + audience: "public" + }) + + assert {:ok, report} = + Reports.create_report(Scope.for_user(reporter), paste.id, %{"category" => "spam"}) + + results = + race([ + fn -> Administration.resolve_report(admin_scope(admin_a), report, "actioned") end, + fn -> Administration.dismiss_report(admin_scope(admin_b), report, "dismissed") end + ]) + + assert Enum.count(results, &match?({:ok, %{id: _, status: _}}, &1)) == 1 + assert Enum.count(results, &match?({:error, :not_found}, &1)) == 1 + assert Repo.get!(Report, report.id).status in ["actioned", "dismissed"] + + assert Repo.aggregate( + from(event in PlatformAuditEvent, where: event.target_id == ^report.id), + :count + ) == 1 + end + defp race(functions) do parent = self() diff --git a/test/textbin/administration_test.exs b/test/textbin/administration_test.exs index 9b32d5c..7b84ee3 100644 --- a/test/textbin/administration_test.exs +++ b/test/textbin/administration_test.exs @@ -470,14 +470,20 @@ defmodule Textbin.AdministrationTest do dismissed = report_fixture(context, "spam") resolved = report_fixture(context, "malware") - assert {:ok, %Report{status: "dismissed"}} = + assert {:ok, dismissed_result} = Administration.dismiss_report(stale_scope, dismissed, "not actionable", request_id: "dismiss-request" ) - assert {:ok, %Report{status: "actioned"}} = + assert dismissed_result == %{id: dismissed.id, status: "dismissed"} + refute Map.has_key?(dismissed_result, :reporter_user_id) + + assert {:ok, resolved_result} = Administration.resolve_report(stale_scope, resolved, "handled externally") + assert resolved_result == %{id: resolved.id, status: "actioned"} + refute Map.has_key?(resolved_result, :reporter_user_id) + assert {:ok, %{entries: []}} = Administration.list_reports(context.scope) events = @@ -518,12 +524,35 @@ defmodule Textbin.AdministrationTest do assert Repo.get!(Report, report.id).status == "open" refute Repo.exists?(from event in PlatformAuditEvent, where: event.target_id == ^report.id) - assert {:ok, %Report{status: "actioned"}} = + assert {:ok, %{id: report_id, status: "actioned"} = result} = Administration.resolve_report(context.scope, report, "reviewed") + assert report_id == report.id + refute Map.has_key?(result, :reporter_user_id) + assert {:error, :not_found} = Administration.dismiss_report(context.scope, report, "second review") end + + test "reloads revoked authority for report reads and review", context do + report = report_fixture(context, "spam") + replacement = admin_fixture() + + assert {:ok, %User{platform_role: nil}} = + Administration.revoke_platform_admin( + admin_scope(replacement), + context.admin, + "rotation complete" + ) + + assert {:error, :forbidden} = Administration.list_reports(context.scope) + + assert {:error, :forbidden} = + Administration.resolve_report(context.scope, report, "stale authority") + + assert Repo.get!(Report, report.id).status == "open" + refute Repo.exists?(from event in PlatformAuditEvent, where: event.target_id == ^report.id) + end end describe "administration reads" do @@ -543,6 +572,7 @@ defmodule Textbin.AdministrationTest do &Administration.lookup(&1, admin.email), &Administration.list_recent_public_pastes/1, &Administration.list_largest_pastes/1, + &Administration.list_reports/1, &Administration.list_platform_audit_events/1 ] do assert {:error, :forbidden} = operation.(ordinary_scope) diff --git a/test/textbin/reports_test.exs b/test/textbin/reports_test.exs index 3d05925..2b23144 100644 --- a/test/textbin/reports_test.exs +++ b/test/textbin/reports_test.exs @@ -5,6 +5,7 @@ defmodule Textbin.ReportsTest do alias Textbin.Accounts alias Textbin.Accounts.Scope + alias Textbin.Organizations alias Textbin.Pastes alias Textbin.Pastes.Paste alias Textbin.Reports @@ -85,6 +86,70 @@ defmodule Textbin.ReportsTest do Reports.create_report(context.reporter_scope, workspace_paste.id, %{ "category" => "spam" }) + + unconfirmed = unconfirmed_user_fixture() + + assert {:error, :forbidden} = + Reports.create_report(Scope.for_user(unconfirmed), workspace_paste.id, %{ + "category" => "spam" + }) + end + + test "rejects policy-disabled and deletion-in-progress targets", context do + organization = Organizations.get_personal_organization!(context.owner_scope.user) + workspace = hd(organization.workspaces) + + assert {:ok, policy_paste} = + Pastes.create_paste(context.owner_scope, %{data: "policy", audience: "public"}) + + workspace + |> Ecto.Changeset.change(external_sharing_policy: "disabled") + |> Repo.update!() + + assert {:error, :not_found} = + Reports.create_report(context.reporter_scope, policy_paste.id, %{ + "category" => "spam" + }) + + workspace + |> Ecto.Changeset.change( + external_sharing_policy: "public", + deletion_requested_at: Paste.utc_now_ms() + ) + |> Repo.update!() + + assert {:error, :not_found} = + Reports.create_report(context.reporter_scope, policy_paste.id, %{ + "category" => "spam" + }) + + workspace + |> Ecto.Changeset.change(deletion_requested_at: nil) + |> Repo.update!() + + organization + |> Ecto.Changeset.change(deletion_requested_at: Paste.utc_now_ms()) + |> Repo.update!() + + assert {:error, :not_found} = + Reports.create_report(context.reporter_scope, policy_paste.id, %{ + "category" => "spam" + }) + end + + test "retains reports after reporter and paste deletion", context do + assert {:ok, paste} = + Pastes.create_paste(context.owner_scope, %{data: "retained", audience: "public"}) + + assert {:ok, report} = + Reports.create_report(context.reporter_scope, paste.id, %{"category" => "other"}) + + assert {:ok, _reporter} = Accounts.delete_user(context.reporter_scope) + assert Repo.get!(Report, report.id).reporter_user_id == context.reporter_scope.user.id + + assert {:ok, _paste} = Pastes.delete_paste(context.owner_scope, paste) + refute Repo.get(Paste, paste.id) + assert Repo.get!(Report, report.id).paste_id == paste.id end test "validates category, notes, and one open report per reporter", context do From 0f1b413aca9bc0824db15713c394d2ba36a18232 Mon Sep 17 00:00:00 2001 From: Darwin D Wu Date: Wed, 26 Aug 2026 14:01:34 -0700 Subject: [PATCH 3/3] style: satisfy report submission lint --- lib/textbin/reports.ex | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/textbin/reports.ex b/lib/textbin/reports.ex index 16a4355..00ebf0f 100644 --- a/lib/textbin/reports.ex +++ b/lib/textbin/reports.ex @@ -18,10 +18,12 @@ defmodule Textbin.Reports do @doc "Submits an abuse report for an active public or unlisted paste." def create_report(%Scope{user: %User{id: reporter_id}}, paste_id, attrs) when is_map(attrs) do - with {:ok, paste_id} <- Ecto.UUID.cast(paste_id) do - Repo.transact(fn -> create_report_in_transaction(reporter_id, paste_id, attrs) end) - else - :error -> {:error, :not_found} + case Ecto.UUID.cast(paste_id) do + {:ok, paste_id} -> + Repo.transact(fn -> create_report_in_transaction(reporter_id, paste_id, attrs) end) + + :error -> + {:error, :not_found} end end