From c2b40659e7843a84e1b05f81522d8366430321e5 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Mon, 22 Dec 2025 23:33:00 +0100 Subject: [PATCH 1/9] MastoAPI: Fix misattribution when fetching status by Activity FlakeID --- lib/pleroma/web/mastodon_api/views/status_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index c1a996f62..09af60865 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -240,7 +240,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do object = Object.normalize(activity, fetch: false) - user = CommonAPI.get_user(activity.data["actor"]) + user = CommonAPI.get_user(object.data["actor"]) user_follower_address = user.follower_address like_count = object.data["like_count"] || 0 From 01ffaba3d2f9c5c94234e558f98f6795a6919b6c Mon Sep 17 00:00:00 2001 From: Phantasm Date: Mon, 22 Dec 2025 23:55:38 +0100 Subject: [PATCH 2/9] MastoAPI: Fix unauth visibility checks when fetching by Activity FlakeID - Adds another Pleroma.ActivityPub.Visibility.visible_for_user?/2 func - Modifies existing tests to include a local Activity referencing a remote Object - Changes Announce Activity test factory to reference Objects instead of Activities and use a different Actor for the Announce - Changes ap_id of remote user in Announce test factory to match Objects - Adds `object_local` option to Note factories that explicitly changes the domain in the URL to not match the endpoint URL in the test env to properly work with the new visibility func, since we don't store locality of Object unlike Activities --- lib/pleroma/web/activity_pub/visibility.ex | 19 +++ .../controllers/status_controller_test.exs | 159 +++++++++++++++--- test/support/factory.ex | 35 +++- 3 files changed, 182 insertions(+), 31 deletions(-) diff --git a/lib/pleroma/web/activity_pub/visibility.ex b/lib/pleroma/web/activity_pub/visibility.ex index 97fc7fa1b..b393947fe 100644 --- a/lib/pleroma/web/activity_pub/visibility.ex +++ b/lib/pleroma/web/activity_pub/visibility.ex @@ -73,6 +73,25 @@ defmodule Pleroma.Web.ActivityPub.Visibility do |> Pleroma.List.member?(user) end + def visible_for_user?(%Activity{data: _, object: %Object{data: _} = object} = activity, nil) do + activity_visibility? = restrict_unauthenticated_access?(activity) + activity_public? = public?(activity) and not local_public?(activity) + object_visibility? = restrict_unauthenticated_access?(object) + object_public? = public?(object) and not local_public?(object) + + # Activity could be local, but object might not (Announce/Like) + cond do + activity_visibility? == true and object_visibility? == true -> + false + + activity_visibility? or object_visibility? -> + false + + true -> + activity_public? and object_public? + end + end + def visible_for_user?(%{__struct__: module} = message, nil) when module in [Activity, Object] do if restrict_unauthenticated_access?(message), diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs index 5c24df864..ef6bcc9b1 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -759,9 +759,17 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do end defp local_and_remote_activities do + remote_user = insert(:user, local: false, domain: "example.com") + announce_user = insert(:user) local = insert(:note_activity) - remote = insert(:note_activity, local: false) - {:ok, local: local, remote: remote} + remote = insert(:note_activity, local: false, object_local: false, user: remote_user) + remote_note = insert(:note_activity, local: false, object_local: false) + + local_activity_remote_object = + insert(:announce_activity, note_activity: remote_note, user: announce_user) + + {:ok, + local: local, remote: remote, local_activity_remote_object: local_activity_remote_object} end defp local_and_remote_context_activities do @@ -814,7 +822,12 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do setup do: clear_config([:restrict_unauthenticated, :activities, :remote], true) - test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do + test "if user is unauthenticated", %{ + conn: conn, + local: local, + remote: remote, + local_activity_remote_object: local_activity_remote_object + } do res_conn = get(conn, "/api/v1/statuses/#{local.id}") assert json_response_and_validate_schema(res_conn, :not_found) == %{ @@ -823,18 +836,31 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do res_conn = get(conn, "/api/v1/statuses/#{remote.id}") + assert json_response_and_validate_schema(res_conn, :not_found) == %{ + "error" => "Record not found" + } + + res_conn = get(conn, "/api/v1/statuses/#{local_activity_remote_object.id}") + assert json_response_and_validate_schema(res_conn, :not_found) == %{ "error" => "Record not found" } end - test "if user is authenticated", %{local: local, remote: remote} do + test "if user is authenticated", %{ + local: local, + remote: remote, + local_activity_remote_object: local_activity_remote_object + } do %{conn: conn} = oauth_access(["read"]) res_conn = get(conn, "/api/v1/statuses/#{local.id}") assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) res_conn = get(conn, "/api/v1/statuses/#{remote.id}") assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) + + res_conn = get(conn, "/api/v1/statuses/#{local_activity_remote_object.id}") + assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) end end @@ -843,9 +869,20 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do setup do: clear_config([:restrict_unauthenticated, :activities, :local], true) - test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do + test "if user is unauthenticated", %{ + conn: conn, + local: local, + remote: remote, + local_activity_remote_object: local_activity_remote_object + } do res_conn = get(conn, "/api/v1/statuses/#{local.id}") + assert json_response_and_validate_schema(res_conn, :not_found) == %{ + "error" => "Record not found" + } + + res_conn = get(conn, "/api/v1/statuses/#{local_activity_remote_object.id}") + assert json_response_and_validate_schema(res_conn, :not_found) == %{ "error" => "Record not found" } @@ -854,13 +891,20 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) end - test "if user is authenticated", %{local: local, remote: remote} do + test "if user is authenticated", %{ + local: local, + remote: remote, + local_activity_remote_object: local_activity_remote_object + } do %{conn: conn} = oauth_access(["read"]) res_conn = get(conn, "/api/v1/statuses/#{local.id}") assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) res_conn = get(conn, "/api/v1/statuses/#{remote.id}") assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) + + res_conn = get(conn, "/api/v1/statuses/#{local_activity_remote_object.id}") + assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) end end @@ -869,24 +913,42 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do setup do: clear_config([:restrict_unauthenticated, :activities, :remote], true) - test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do + test "if user is unauthenticated", %{ + conn: conn, + local: local, + remote: remote, + local_activity_remote_object: local_activity_remote_object + } do res_conn = get(conn, "/api/v1/statuses/#{local.id}") assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) res_conn = get(conn, "/api/v1/statuses/#{remote.id}") + assert json_response_and_validate_schema(res_conn, :not_found) == %{ + "error" => "Record not found" + } + + res_conn = get(conn, "/api/v1/statuses/#{local_activity_remote_object.id}") + assert json_response_and_validate_schema(res_conn, :not_found) == %{ "error" => "Record not found" } end - test "if user is authenticated", %{local: local, remote: remote} do + test "if user is authenticated", %{ + local: local, + remote: remote, + local_activity_remote_object: local_activity_remote_object + } do %{conn: conn} = oauth_access(["read"]) res_conn = get(conn, "/api/v1/statuses/#{local.id}") assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) res_conn = get(conn, "/api/v1/statuses/#{remote.id}") assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) + + res_conn = get(conn, "/api/v1/statuses/#{local_activity_remote_object.id}") + assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) end end @@ -946,18 +1008,35 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do setup do: clear_config([:restrict_unauthenticated, :activities, :remote], true) - test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do - res_conn = get(conn, "/api/v1/statuses?id[]=#{local.id}&id[]=#{remote.id}") + test "if user is unauthenticated", %{ + conn: conn, + local: local, + remote: remote, + local_activity_remote_object: local_activity_remote_object + } do + res_conn = + get( + conn, + "/api/v1/statuses?id[]=#{local.id}&id[]=#{remote.id}&id[]=#{local_activity_remote_object.id}" + ) assert json_response_and_validate_schema(res_conn, 200) == [] end - test "if user is authenticated", %{local: local, remote: remote} do + test "if user is authenticated", %{ + local: local, + remote: remote, + local_activity_remote_object: local_activity_remote_object + } do %{conn: conn} = oauth_access(["read"]) - res_conn = get(conn, "/api/v1/statuses?id[]=#{local.id}&id[]=#{remote.id}") + res_conn = + get( + conn, + "/api/v1/statuses?id[]=#{local.id}&id[]=#{remote.id}&id[]=#{local_activity_remote_object.id}" + ) - assert length(json_response_and_validate_schema(res_conn, 200)) == 2 + assert length(json_response_and_validate_schema(res_conn, 200)) == 3 end end @@ -966,19 +1045,36 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do setup do: clear_config([:restrict_unauthenticated, :activities, :local], true) - test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do - res_conn = get(conn, "/api/v1/statuses?id[]=#{local.id}&id[]=#{remote.id}") + test "if user is unauthenticated", %{ + conn: conn, + local: local, + remote: remote, + local_activity_remote_object: local_activity_remote_object + } do + res_conn = + get( + conn, + "/api/v1/statuses?id[]=#{local.id}&id[]=#{remote.id}&id[]=#{local_activity_remote_object.id}" + ) remote_id = remote.id assert [%{"id" => ^remote_id}] = json_response_and_validate_schema(res_conn, 200) end - test "if user is authenticated", %{local: local, remote: remote} do + test "if user is authenticated", %{ + local: local, + remote: remote, + local_activity_remote_object: local_activity_remote_object + } do %{conn: conn} = oauth_access(["read"]) - res_conn = get(conn, "/api/v1/statuses?id[]=#{local.id}&id[]=#{remote.id}") + res_conn = + get( + conn, + "/api/v1/statuses?id[]=#{local.id}&id[]=#{remote.id}&id[]=#{local_activity_remote_object.id}" + ) - assert length(json_response_and_validate_schema(res_conn, 200)) == 2 + assert length(json_response_and_validate_schema(res_conn, 200)) == 3 end end @@ -987,19 +1083,36 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do setup do: clear_config([:restrict_unauthenticated, :activities, :remote], true) - test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do - res_conn = get(conn, "/api/v1/statuses?id[]=#{local.id}&id[]=#{remote.id}") + test "if user is unauthenticated", %{ + conn: conn, + local: local, + remote: remote, + local_activity_remote_object: local_activity_remote_object + } do + res_conn = + get( + conn, + "/api/v1/statuses?id[]=#{local.id}&id[]=#{remote.id}&id[]=#{local_activity_remote_object.id}" + ) local_id = local.id assert [%{"id" => ^local_id}] = json_response_and_validate_schema(res_conn, 200) end - test "if user is authenticated", %{local: local, remote: remote} do + test "if user is authenticated", %{ + local: local, + remote: remote, + local_activity_remote_object: local_activity_remote_object + } do %{conn: conn} = oauth_access(["read"]) - res_conn = get(conn, "/api/v1/statuses?id[]=#{local.id}&id[]=#{remote.id}") + res_conn = + get( + conn, + "/api/v1/statuses?id[]=#{local.id}&id[]=#{remote.id}&id[]=#{local_activity_remote_object.id}" + ) - assert length(json_response_and_validate_schema(res_conn, 200)) == 2 + assert length(json_response_and_validate_schema(res_conn, 200)) == 3 end end diff --git a/test/support/factory.ex b/test/support/factory.ex index 88c4ed8e5..d57090be6 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -102,11 +102,18 @@ defmodule Pleroma.Factory do user = attrs[:user] || insert(:user) + object_id = if attrs[:object_local] == false do + # Must not match our Endpoint URL in the test env + "https://example.com/objects/#{Ecto.UUID.generate()}" + else + Pleroma.Web.ActivityPub.Utils.generate_object_id() + end + data = %{ "type" => "Note", "content" => text, "source" => text, - "id" => Pleroma.Web.ActivityPub.Utils.generate_object_id(), + "id" => object_id, "actor" => user.ap_id, "to" => ["https://www.w3.org/ns/activitystreams#Public"], "published" => DateTime.utc_now() |> DateTime.to_iso8601(), @@ -361,14 +368,24 @@ defmodule Pleroma.Factory do def note_activity_factory(attrs \\ %{}) do user = attrs[:user] || insert(:user) - note = attrs[:note] || insert(:note, user: user) + object_local = if attrs[:object_local] == false, do: false, else: true + note = attrs[:note] || insert(:note, user: user, object_local: object_local) + + activity_id = if attrs[:local] == false do + # Same domain as in note Object factory, it doesn't make sense + # to create mismatched Create Activities with an ID coming from + # a different domain than the Object + "https://example.com/activities/#{Ecto.UUID.generate()}" + else + Pleroma.Web.ActivityPub.Utils.generate_activity_id() + end data_attrs = attrs[:data_attrs] || %{} - attrs = Map.drop(attrs, [:user, :note, :data_attrs]) + attrs = Map.drop(attrs, [:user, :note, :data_attrs, :object_local]) data = %{ - "id" => Pleroma.Web.ActivityPub.Utils.generate_activity_id(), + "id" => activity_id, "type" => "Create", "actor" => note.data["actor"], "to" => note.data["to"], @@ -408,15 +425,17 @@ defmodule Pleroma.Factory do def announce_activity_factory(attrs \\ %{}) do note_activity = attrs[:note_activity] || insert(:note_activity) + object = Object.normalize(note_activity, fetch: false) user = attrs[:user] || insert(:user) data = %{ + "id" => Pleroma.Web.ActivityPub.Utils.generate_activity_id(), "type" => "Announce", - "actor" => note_activity.actor, - "object" => note_activity.data["id"], - "to" => [user.follower_address, note_activity.data["actor"]], + "actor" => user.ap_id, + "object" => object.data["id"], + "to" => [user.follower_address, object.data["actor"]], "cc" => ["https://www.w3.org/ns/activitystreams#Public"], - "context" => note_activity.data["context"] + "context" => object.data["context"] } %Pleroma.Activity{ From b9601ae11af022cdfbc624b1a50a0ccffb8e9a9d Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 23 Dec 2025 00:09:11 +0100 Subject: [PATCH 3/9] MastoAPI: Add Announce and EmojiReact attribution tests Introduces a new EmojiReact Activity factory --- .../controllers/status_controller_test.exs | 66 +++++++++++++++++++ test/support/factory.ex | 22 +++++++ 2 files changed, 88 insertions(+) diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs index ef6bcc9b1..f44058610 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -815,6 +815,28 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do %{locals: [id1, id2], remote: remote_activity.id, context: context} end + defp local_interactions_to_remote do + interacted_user = insert(:user, local: false, domain: "example.com") + interacting_user = insert(:user) + + announced_post = + insert(:note_activity, local: false, object_local: false, user: interacted_user) + + emoji_reacted_post = + insert(:note_activity, local: false, object_local: false, user: interacted_user) + + announce = insert(:announce_activity, note_activity: announced_post, user: interacting_user) + + emoji_react = + insert(:emoji_react_activity, note_activity: emoji_reacted_post, user: interacting_user) + + {:ok, + announce: announce, + emoji_react: emoji_react, + interacted: interacted_user, + interacter: interacting_user} + end + describe "status with restrict unauthenticated activities for local and remote" do setup do: local_and_remote_activities() @@ -952,6 +974,50 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do end end + # Rest of Activities like Create,Like,Flag,Add,Remove,... return 404. + describe "status has correct attribution when fetching as" do + setup do: local_interactions_to_remote() + + test "Announce activity", %{ + conn: conn, + announce: activity, + interacter: interacter, + interacted: user + } do + announce_id = activity.id + announced_activity = Pleroma.Activity.get_create_by_object_ap_id(activity.data["object"]) + announced_id = announced_activity.id + interacter_ap_id = interacter.id + user_ap_id = user.id + + result = + conn + |> get("/api/v1/statuses/#{activity.id}") + |> json_response_and_validate_schema(200) + + assert match?( + %{ + "id" => ^announce_id, + "account" => %{"id" => ^interacter_ap_id}, + "reblog" => %{"account" => %{"id" => ^user_ap_id}, "id" => ^announced_id} + }, + result + ) + end + + test "EmojiReact activity", %{conn: conn, emoji_react: activity, interacted: user} do + emoji_react_id = activity.id + user_ap_id = user.id + + result = + conn + |> get("/api/v1/statuses/#{emoji_react_id}") + |> json_response_and_validate_schema(200) + + assert match?(%{"id" => ^emoji_react_id, "account" => %{"id" => ^user_ap_id}}, result) + end + end + test "getting a status that doesn't exist returns 404" do %{conn: conn} = oauth_access(["read:statuses"]) activity = insert(:note_activity) diff --git a/test/support/factory.ex b/test/support/factory.ex index d57090be6..e60756644 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -445,6 +445,28 @@ defmodule Pleroma.Factory do } end + def emoji_react_activity_factory(attrs \\ %{}) do + note_activity = attrs[:note_activity] || insert(:note_activity) + object = Object.normalize(note_activity, fetch: false) + user = attrs[:user] || insert(:user) + + data = %{ + "id" => Pleroma.Web.ActivityPub.Utils.generate_activity_id(), + "actor" => user.ap_id, + "type" => "EmojiReact", + "object" => object.data["id"], + "to" => [user.follower_address, object.data["actor"]], + "cc" => ["https://www.w3.org/ns/activitystreams#Public"], + "published_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "context" => object.data["context"], + "content" => "😀" + } + + %Pleroma.Activity{ + data: data + } + end + def like_activity_factory(attrs \\ %{}) do note_activity = attrs[:note_activity] || insert(:note_activity) object = Object.normalize(note_activity, fetch: false) From ba8235ef502b4c1cce2dad48fcab0c64e3d52950 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 23 Dec 2025 16:51:59 +0100 Subject: [PATCH 4/9] lint --- test/support/factory.ex | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/test/support/factory.ex b/test/support/factory.ex index e60756644..1c245ae92 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -102,12 +102,13 @@ defmodule Pleroma.Factory do user = attrs[:user] || insert(:user) - object_id = if attrs[:object_local] == false do - # Must not match our Endpoint URL in the test env - "https://example.com/objects/#{Ecto.UUID.generate()}" - else - Pleroma.Web.ActivityPub.Utils.generate_object_id() - end + object_id = + if attrs[:object_local] == false do + # Must not match our Endpoint URL in the test env + "https://example.com/objects/#{Ecto.UUID.generate()}" + else + Pleroma.Web.ActivityPub.Utils.generate_object_id() + end data = %{ "type" => "Note", @@ -371,14 +372,15 @@ defmodule Pleroma.Factory do object_local = if attrs[:object_local] == false, do: false, else: true note = attrs[:note] || insert(:note, user: user, object_local: object_local) - activity_id = if attrs[:local] == false do - # Same domain as in note Object factory, it doesn't make sense - # to create mismatched Create Activities with an ID coming from - # a different domain than the Object - "https://example.com/activities/#{Ecto.UUID.generate()}" - else - Pleroma.Web.ActivityPub.Utils.generate_activity_id() - end + activity_id = + if attrs[:local] == false do + # Same domain as in note Object factory, it doesn't make sense + # to create mismatched Create Activities with an ID coming from + # a different domain than the Object + "https://example.com/activities/#{Ecto.UUID.generate()}" + else + Pleroma.Web.ActivityPub.Utils.generate_activity_id() + end data_attrs = attrs[:data_attrs] || %{} attrs = Map.drop(attrs, [:user, :note, :data_attrs, :object_local]) From 7c93cd351bf72a30e2f499872c503efd07f81528 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 23 Dec 2025 16:52:45 +0100 Subject: [PATCH 5/9] MastoAPI StatusController: add tests for fetching context via Activity --- .../controllers/status_controller_test.exs | 266 +++++++++++++++++- 1 file changed, 265 insertions(+), 1 deletion(-) diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs index f44058610..2d39786d6 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -812,7 +812,34 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do {:ok, job} = Pleroma.Web.Federator.incoming_ap_doc(params) {:ok, remote_activity} = ObanHelpers.perform(job) - %{locals: [id1, id2], remote: remote_activity.id, context: context} + {:ok, %{id: local_to_local_react_id}} = CommonAPI.react_with_emoji(id1, local_user_2, "🦊") + + {:ok, %{id: local_to_remote_react_id}} = + CommonAPI.react_with_emoji(remote_activity.id, local_user_1, "🦊") + + {:ok, %{id: remote_to_local_react_id}} = CommonAPI.react_with_emoji(id1, remote_user, "🦊") + + {:ok, %{id: remote_to_remote_react_id}} = + CommonAPI.react_with_emoji(remote_activity.id, remote_user, "🦊") + + %{ + locals: [id1, id2], + remote: remote_activity.id, + local_interactions: [local_to_local_react_id, local_to_remote_react_id], + remote_interactions: [remote_to_local_react_id, remote_to_remote_react_id], + context: context + } + end + + defp extract_activity_ids_from_response(list) when is_list(list) do + list + |> Enum.map(& &1["id"]) + end + + defp all_ids_included?(checked, authority) when is_list(checked) and is_list(authority) do + set1 = MapSet.new(checked) + set2 = MapSet.new(authority) + MapSet.equal?(set1, set2) end defp local_interactions_to_remote do @@ -1182,6 +1209,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do end end + # Note: Context route on EmojiReact/Announce activities puts everything into the ancestors field describe "getting status contexts restricted unauthenticated for local and remote" do setup do: local_and_remote_context_activities() @@ -1207,6 +1235,40 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do } end + test "if user is unauthenticated Activity interactions", %{ + conn: conn, + local_interactions: [local_to_local, local_to_remote], + remote_interactions: [remote_to_local, remote_to_remote] + } do + res_conn = get(conn, "/api/v1/statuses/#{local_to_local}/context") + + assert json_response_and_validate_schema(res_conn, 200) == %{ + "ancestors" => [], + "descendants" => [] + } + + res_conn = get(conn, "/api/v1/statuses/#{local_to_remote}/context") + + assert json_response_and_validate_schema(res_conn, 200) == %{ + "ancestors" => [], + "descendants" => [] + } + + res_conn = get(conn, "/api/v1/statuses/#{remote_to_local}/context") + + assert json_response_and_validate_schema(res_conn, 200) == %{ + "ancestors" => [], + "descendants" => [] + } + + res_conn = get(conn, "/api/v1/statuses/#{remote_to_remote}/context") + + assert json_response_and_validate_schema(res_conn, 200) == %{ + "ancestors" => [], + "descendants" => [] + } + end + test "if user is authenticated", %{locals: [post_id, reply_id], remote: remote_reply_id} do %{conn: conn} = oauth_access(["read"]) res_conn = get(conn, "/api/v1/statuses/#{post_id}/context") @@ -1240,6 +1302,47 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do assert post_id in ancestor_ids assert remote_reply_id in descendant_ids end + + test "if user is authenticated Activity interactions", %{ + locals: [post_id, reply_id], + remote: remote_reply_id, + local_interactions: [local_to_local, local_to_remote], + remote_interactions: [remote_to_local, remote_to_remote] + } do + %{conn: conn} = oauth_access(["read"]) + all_ids = [post_id, reply_id, remote_reply_id] + res_conn = get(conn, "/api/v1/statuses/#{local_to_local}/context") + + %{"ancestors" => ancestors1, "descendants" => []} = + json_response_and_validate_schema(res_conn, 200) + + ancestor_ids1 = extract_activity_ids_from_response(ancestors1) + assert all_ids_included?(ancestor_ids1, all_ids) + + res_conn = get(conn, "/api/v1/statuses/#{local_to_remote}/context") + + %{"ancestors" => ancestors2, "descendants" => []} = + json_response_and_validate_schema(res_conn, 200) + + ancestor_ids2 = extract_activity_ids_from_response(ancestors2) + assert all_ids_included?(ancestor_ids2, all_ids) + + res_conn = get(conn, "/api/v1/statuses/#{remote_to_local}/context") + + %{"ancestors" => ancestors3, "descendants" => []} = + json_response_and_validate_schema(res_conn, 200) + + ancestor_ids3 = extract_activity_ids_from_response(ancestors3) + assert all_ids_included?(ancestor_ids3, all_ids) + + res_conn = get(conn, "/api/v1/statuses/#{remote_to_remote}/context") + + %{"ancestors" => ancestors4, "descendants" => []} = + json_response_and_validate_schema(res_conn, 200) + + ancestor_ids4 = extract_activity_ids_from_response(ancestors4) + assert all_ids_included?(ancestor_ids4, all_ids) + end end describe "getting status contexts restricted unauthenticated for local" do @@ -1289,6 +1392,45 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do assert remote_reply_id in descendant_ids end + test "if user is unauthenticated Activity interactions", %{ + conn: conn, + remote: remote_reply_id, + local_interactions: [local_to_local, local_to_remote], + remote_interactions: [remote_to_local, remote_to_remote] + } do + res_conn = get(conn, "/api/v1/statuses/#{local_to_local}/context") + + %{"ancestors" => ancestors1, "descendants" => []} = + json_response_and_validate_schema(res_conn, 200) + + ancestor_ids1 = extract_activity_ids_from_response(ancestors1) + assert all_ids_included?(ancestor_ids1, [remote_reply_id]) + + res_conn = get(conn, "/api/v1/statuses/#{local_to_remote}/context") + + %{"ancestors" => ancestors2, "descendants" => []} = + json_response_and_validate_schema(res_conn, 200) + + ancestor_ids2 = extract_activity_ids_from_response(ancestors2) + assert all_ids_included?(ancestor_ids2, [remote_reply_id]) + + res_conn = get(conn, "/api/v1/statuses/#{remote_to_local}/context") + + %{"ancestors" => ancestors3, "descendants" => []} = + json_response_and_validate_schema(res_conn, 200) + + ancestor_ids3 = extract_activity_ids_from_response(ancestors3) + assert all_ids_included?(ancestor_ids3, [remote_reply_id]) + + res_conn = get(conn, "/api/v1/statuses/#{remote_to_remote}/context") + + %{"ancestors" => ancestors4, "descendants" => []} = + json_response_and_validate_schema(res_conn, 200) + + ancestor_ids4 = extract_activity_ids_from_response(ancestors4) + assert all_ids_included?(ancestor_ids4, [remote_reply_id]) + end + test "if user is authenticated", %{locals: [post_id, reply_id], remote: remote_reply_id} do %{conn: conn} = oauth_access(["read"]) res_conn = get(conn, "/api/v1/statuses/#{post_id}/context") @@ -1322,6 +1464,47 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do assert post_id in ancestor_ids assert remote_reply_id in descendant_ids end + + test "if user is authenticated Activity interactions", %{ + locals: [post_id, reply_id], + remote: remote_reply_id, + local_interactions: [local_to_local, local_to_remote], + remote_interactions: [remote_to_local, remote_to_remote] + } do + %{conn: conn} = oauth_access(["read"]) + all_ids = [post_id, reply_id, remote_reply_id] + res_conn = get(conn, "/api/v1/statuses/#{local_to_local}/context") + + %{"ancestors" => ancestors1, "descendants" => []} = + json_response_and_validate_schema(res_conn, 200) + + ancestor_ids1 = extract_activity_ids_from_response(ancestors1) + assert all_ids_included?(ancestor_ids1, all_ids) + + res_conn = get(conn, "/api/v1/statuses/#{local_to_remote}/context") + + %{"ancestors" => ancestors2, "descendants" => []} = + json_response_and_validate_schema(res_conn, 200) + + ancestor_ids2 = extract_activity_ids_from_response(ancestors2) + assert all_ids_included?(ancestor_ids2, all_ids) + + res_conn = get(conn, "/api/v1/statuses/#{remote_to_local}/context") + + %{"ancestors" => ancestors3, "descendants" => []} = + json_response_and_validate_schema(res_conn, 200) + + ancestor_ids3 = extract_activity_ids_from_response(ancestors3) + assert all_ids_included?(ancestor_ids3, all_ids) + + res_conn = get(conn, "/api/v1/statuses/#{remote_to_remote}/context") + + %{"ancestors" => ancestors4, "descendants" => []} = + json_response_and_validate_schema(res_conn, 200) + + ancestor_ids4 = extract_activity_ids_from_response(ancestors4) + assert all_ids_included?(ancestor_ids4, all_ids) + end end describe "getting status contexts restricted unauthenticated for remote" do @@ -1371,6 +1554,46 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do assert remote_reply_id not in descendant_ids end + test "if user is unauthenticated Activity interactions", %{ + conn: conn, + locals: [post_id, reply_id], + local_interactions: [local_to_local, local_to_remote], + remote_interactions: [remote_to_local, remote_to_remote] + } do + res_conn = get(conn, "/api/v1/statuses/#{local_to_local}/context") + all_ids = [post_id, reply_id] + + %{"ancestors" => ancestors1, "descendants" => []} = + json_response_and_validate_schema(res_conn, 200) + + ancestor_ids1 = extract_activity_ids_from_response(ancestors1) + assert all_ids_included?(ancestor_ids1, all_ids) + + res_conn = get(conn, "/api/v1/statuses/#{local_to_remote}/context") + + %{"ancestors" => ancestors2, "descendants" => []} = + json_response_and_validate_schema(res_conn, 200) + + ancestor_ids2 = extract_activity_ids_from_response(ancestors2) + assert all_ids_included?(ancestor_ids2, all_ids) + + res_conn = get(conn, "/api/v1/statuses/#{remote_to_local}/context") + + %{"ancestors" => ancestors3, "descendants" => []} = + json_response_and_validate_schema(res_conn, 200) + + ancestor_ids3 = extract_activity_ids_from_response(ancestors3) + assert all_ids_included?(ancestor_ids3, all_ids) + + res_conn = get(conn, "/api/v1/statuses/#{remote_to_remote}/context") + + %{"ancestors" => ancestors4, "descendants" => []} = + json_response_and_validate_schema(res_conn, 200) + + ancestor_ids4 = extract_activity_ids_from_response(ancestors4) + assert all_ids_included?(ancestor_ids4, all_ids) + end + test "if user is authenticated", %{locals: [post_id, reply_id], remote: remote_reply_id} do %{conn: conn} = oauth_access(["read"]) res_conn = get(conn, "/api/v1/statuses/#{post_id}/context") @@ -1404,6 +1627,47 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do assert post_id in ancestor_ids assert remote_reply_id in descendant_ids end + + test "if user is authenticated Activity interactions", %{ + locals: [post_id, reply_id], + remote: remote_reply_id, + local_interactions: [local_to_local, local_to_remote], + remote_interactions: [remote_to_local, remote_to_remote] + } do + %{conn: conn} = oauth_access(["read"]) + all_ids = [post_id, reply_id, remote_reply_id] + res_conn = get(conn, "/api/v1/statuses/#{local_to_local}/context") + + %{"ancestors" => ancestors1, "descendants" => []} = + json_response_and_validate_schema(res_conn, 200) + + ancestor_ids1 = extract_activity_ids_from_response(ancestors1) + assert all_ids_included?(ancestor_ids1, all_ids) + + res_conn = get(conn, "/api/v1/statuses/#{local_to_remote}/context") + + %{"ancestors" => ancestors2, "descendants" => []} = + json_response_and_validate_schema(res_conn, 200) + + ancestor_ids2 = extract_activity_ids_from_response(ancestors2) + assert all_ids_included?(ancestor_ids2, all_ids) + + res_conn = get(conn, "/api/v1/statuses/#{remote_to_local}/context") + + %{"ancestors" => ancestors3, "descendants" => []} = + json_response_and_validate_schema(res_conn, 200) + + ancestor_ids3 = extract_activity_ids_from_response(ancestors3) + assert all_ids_included?(ancestor_ids3, all_ids) + + res_conn = get(conn, "/api/v1/statuses/#{remote_to_remote}/context") + + %{"ancestors" => ancestors4, "descendants" => []} = + json_response_and_validate_schema(res_conn, 200) + + ancestor_ids4 = extract_activity_ids_from_response(ancestors4) + assert all_ids_included?(ancestor_ids4, all_ids) + end end describe "deleting a status" do From df375662d661f2d8b311255f0eb8f209655e0393 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 23 Dec 2025 17:04:08 +0100 Subject: [PATCH 6/9] AP: simplify visible_for_user? conditions. `true or true` returns `true` --- lib/pleroma/web/activity_pub/visibility.ex | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/pleroma/web/activity_pub/visibility.ex b/lib/pleroma/web/activity_pub/visibility.ex index b393947fe..903804d0d 100644 --- a/lib/pleroma/web/activity_pub/visibility.ex +++ b/lib/pleroma/web/activity_pub/visibility.ex @@ -73,7 +73,7 @@ defmodule Pleroma.Web.ActivityPub.Visibility do |> Pleroma.List.member?(user) end - def visible_for_user?(%Activity{data: _, object: %Object{data: _} = object} = activity, nil) do + def visible_for_user?(%Activity{object: %Object{} = object} = activity, nil) do activity_visibility? = restrict_unauthenticated_access?(activity) activity_public? = public?(activity) and not local_public?(activity) object_visibility? = restrict_unauthenticated_access?(object) @@ -81,9 +81,6 @@ defmodule Pleroma.Web.ActivityPub.Visibility do # Activity could be local, but object might not (Announce/Like) cond do - activity_visibility? == true and object_visibility? == true -> - false - activity_visibility? or object_visibility? -> false From 07849927dad0572e2f2dda706c164d48ae676d78 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Tue, 23 Dec 2025 18:37:30 +0100 Subject: [PATCH 7/9] add changelogs --- changelog.d/mastoapi-misatrribution.fix | 1 + changelog.d/restrict-unauthenticated-bypass.fix | 1 + 2 files changed, 2 insertions(+) create mode 100644 changelog.d/mastoapi-misatrribution.fix create mode 100644 changelog.d/restrict-unauthenticated-bypass.fix diff --git a/changelog.d/mastoapi-misatrribution.fix b/changelog.d/mastoapi-misatrribution.fix new file mode 100644 index 000000000..ba744c62b --- /dev/null +++ b/changelog.d/mastoapi-misatrribution.fix @@ -0,0 +1 @@ +MastodonAPI: Fix misattribution of statuses when fetched via non-Announce Activity ID diff --git a/changelog.d/restrict-unauthenticated-bypass.fix b/changelog.d/restrict-unauthenticated-bypass.fix new file mode 100644 index 000000000..974fa6df9 --- /dev/null +++ b/changelog.d/restrict-unauthenticated-bypass.fix @@ -0,0 +1 @@ +Fix bypass of the restrict unauthenticated setting by requesting local Activities From 96de44b3d8af8acb0c63fc1b405a9d73f3d8a758 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 25 Dec 2025 19:16:26 +0100 Subject: [PATCH 8/9] Tests AP Factory: fix featured collection factories Internally it created Objects, tests passed Activities --- test/pleroma/activity_test.exs | 16 ++++++++++------ test/support/factory.ex | 10 +++++----- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/test/pleroma/activity_test.exs b/test/pleroma/activity_test.exs index 62d07b1ee..4212462da 100644 --- a/test/pleroma/activity_test.exs +++ b/test/pleroma/activity_test.exs @@ -261,23 +261,27 @@ defmodule Pleroma.ActivityTest do test "add_by_params_query/3" do user = insert(:user) - note = insert(:note_activity, user: user) + note_activity = insert(:note_activity, user: user) - insert(:add_activity, user: user, note: note) - insert(:add_activity, user: user, note: note) + insert(:add_activity, user: user, note_activity: note_activity) + insert(:add_activity, user: user, note_activity: note_activity) insert(:add_activity, user: user) - assert Repo.aggregate(Activity, :count, :id) == 4 + assert Repo.aggregate(Activity, :count, :id) == 5 add_query = - Activity.add_by_params_query(note.data["object"], user.ap_id, user.featured_address) + Activity.add_by_params_query( + note_activity.data["object"], + user.ap_id, + user.featured_address + ) assert Repo.aggregate(add_query, :count, :id) == 2 Repo.delete_all(add_query) assert Repo.aggregate(add_query, :count, :id) == 0 - assert Repo.aggregate(Activity, :count, :id) == 2 + assert Repo.aggregate(Activity, :count, :id) == 3 end describe "associated_object_id() sql function" do diff --git a/test/support/factory.ex b/test/support/factory.ex index 1c245ae92..b0d77ee84 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -305,27 +305,27 @@ defmodule Pleroma.Factory do featured_collection_activity(attrs, "Add") end - def remove_activity_factor(attrs \\ %{}) do + def remove_activity_factory(attrs \\ %{}) do featured_collection_activity(attrs, "Remove") end defp featured_collection_activity(attrs, type) do user = attrs[:user] || insert(:user) - note = attrs[:note] || insert(:note, user: user) + note_activity = attrs[:note_activity] || insert(:note_activity, user: user) data_attrs = attrs |> Map.get(:data_attrs, %{}) |> Map.put(:type, type) - attrs = Map.drop(attrs, [:user, :note, :data_attrs]) + attrs = Map.drop(attrs, [:user, :note_activity, :data_attrs]) data = %{ "id" => Pleroma.Web.ActivityPub.Utils.generate_activity_id(), "target" => user.featured_address, - "object" => note.data["object"], - "actor" => note.data["actor"], + "object" => note_activity.data["object"], + "actor" => note_activity.data["actor"], "type" => "Add", "to" => [Pleroma.Constants.as_public()], "cc" => [user.follower_address] From 38b3bff4e85563d1b576ca9c88bd1cd8180f3dd5 Mon Sep 17 00:00:00 2001 From: Phantasm Date: Thu, 25 Dec 2025 19:23:56 +0100 Subject: [PATCH 9/9] MastoAPI: Add more post attribution tests when fetched by Activity ID Types returning 404: - Accept - Reject - Delete - Flag - Follow - Undo Types returning posts: - Create - Update - Like - Announce - EmojiReact - Add/Remove --- .../controllers/status_controller_test.exs | 119 ++++++++++++++++-- 1 file changed, 109 insertions(+), 10 deletions(-) diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs index 2d39786d6..d9ec236a3 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -852,16 +852,24 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do emoji_reacted_post = insert(:note_activity, local: false, object_local: false, user: interacted_user) + favorited_post = + insert(:note_activity, local: false, object_local: false, user: interacted_user) + announce = insert(:announce_activity, note_activity: announced_post, user: interacting_user) emoji_react = insert(:emoji_react_activity, note_activity: emoji_reacted_post, user: interacting_user) - {:ok, - announce: announce, - emoji_react: emoji_react, - interacted: interacted_user, - interacter: interacting_user} + {:ok, favorite} = CommonAPI.favorite(favorited_post.id, interacting_user) + + { + :ok, + announce: announce, + emoji_react: emoji_react, + favorite: favorite, + interacted: interacted_user, + interacter: interacting_user + } end describe "status with restrict unauthenticated activities for local and remote" do @@ -1001,10 +1009,34 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do end end - # Rest of Activities like Create,Like,Flag,Add,Remove,... return 404. + # Note: Activities of type Flag,Follow,Delete,Accept,Reject,Undo return 404 describe "status has correct attribution when fetching as" do setup do: local_interactions_to_remote() + test "Add/Remove activity", %{conn: conn, interacter: user} do + add = insert(:add_activity, user: user) + add_id = add.id + remove = insert(:remove_activity, user: user) + remove_id = remove.id + user_id = user.id + + # Schema validation fails: + # null value where integer expected at /pleroma/conversation_id + result1 = + conn + |> get("/api/v1/statuses/#{add_id}") + |> json_response(200) + + assert match?(%{"id" => ^add_id, "account" => %{"id" => ^user_id}}, result1) + + result2 = + conn + |> get("/api/v1/statuses/#{remove_id}") + |> json_response(200) + + assert match?(%{"id" => ^remove_id, "account" => %{"id" => ^user_id}}, result2) + end + test "Announce activity", %{ conn: conn, announce: activity, @@ -1015,7 +1047,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do announced_activity = Pleroma.Activity.get_create_by_object_ap_id(activity.data["object"]) announced_id = announced_activity.id interacter_ap_id = interacter.id - user_ap_id = user.id + user_id = user.id result = conn @@ -1026,22 +1058,89 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do %{ "id" => ^announce_id, "account" => %{"id" => ^interacter_ap_id}, - "reblog" => %{"account" => %{"id" => ^user_ap_id}, "id" => ^announced_id} + "reblog" => %{"account" => %{"id" => ^user_id}, "id" => ^announced_id} }, result ) end + test "Create activity", %{conn: conn, interacted: user} do + note_activity = insert(:note_activity, local: false, object_local: false, user: user) + note_activity_id = note_activity.id + user_id = user.id + + result = + conn + |> get("/api/v1/statuses/#{note_activity_id}") + |> json_response_and_validate_schema(200) + + assert match?(%{"id" => ^note_activity_id, "account" => %{"id" => ^user_id}}, result) + end + test "EmojiReact activity", %{conn: conn, emoji_react: activity, interacted: user} do emoji_react_id = activity.id - user_ap_id = user.id + user_id = user.id result = conn |> get("/api/v1/statuses/#{emoji_react_id}") |> json_response_and_validate_schema(200) - assert match?(%{"id" => ^emoji_react_id, "account" => %{"id" => ^user_ap_id}}, result) + assert match?(%{"id" => ^emoji_react_id, "account" => %{"id" => ^user_id}}, result) + end + + test "Like activity", %{conn: conn, favorite: activity, interacted: user} do + like_id = activity.id + user_id = user.id + + result = + conn + |> get("/api/v1/statuses/#{like_id}") + |> json_response_and_validate_schema(200) + + assert match?(%{"id" => ^like_id, "account" => %{"id" => ^user_id}}, result) + end + + test "Update activity" do + %{conn: conn, user: user} = oauth_access(["write:statuses"]) + user_id = user.id + + {:ok, activity} = CommonAPI.post(user, %{status: "This will be edited"}) + {:ok, updated_activity} = CommonAPI.update(activity, user, %{status: "edited"}) + + activity_id = activity.id + updated_activity_id = updated_activity.id + + result1 = + conn + |> get("/api/v1/statuses/#{activity_id}") + |> json_response_and_validate_schema(200) + + # Even though we ask for the original Create activity, updated post is served + assert match?( + %{ + "id" => ^activity_id, + "account" => %{"id" => ^user_id}, + "content" => "edited" + }, + result1 + ) + + # Schema validation fails: + # null value where integer expected at /pleroma/conversation_id + result2 = + conn + |> get("/api/v1/statuses/#{updated_activity_id}") + |> json_response(200) + + assert match?( + %{ + "id" => ^updated_activity_id, + "account" => %{"id" => ^user_id}, + "content" => "edited" + }, + result2 + ) end end