diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index c85a8b09f..41587c116 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -1,219 +1,257 @@ # Pleroma: A lightweight social networking server # Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Object.Fetcher do alias Pleroma.HTTP alias Pleroma.Instances alias Pleroma.Maps alias Pleroma.Object alias Pleroma.Object.Containment alias Pleroma.Signature alias Pleroma.Web.ActivityPub.InternalFetchActor alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.Federator require Logger require Pleroma.Constants + @mix_env Mix.env() + @spec reinject_object(struct(), map()) :: {:ok, Object.t()} | {:error, any()} defp reinject_object(%Object{data: %{}} = object, new_data) do Logger.debug("Reinjecting object #{new_data["id"]}") with {:ok, new_data, _} <- ObjectValidator.validate(new_data, %{}), {:ok, new_data} <- MRF.filter(new_data), {:ok, new_object, _} <- Object.Updater.do_update_and_invalidate_cache( object, new_data, _touch_changeset? = true ) do {:ok, new_object} else e -> Logger.error("Error while processing object: #{inspect(e)}") {:error, e} end end defp reinject_object(_, new_data) do with {:ok, object, _} <- Pipeline.common_pipeline(new_data, local: false) do {:ok, object} else e -> e end end def refetch_object(%Object{data: %{"id" => id}} = object) do with {:local, false} <- {:local, Object.local?(object)}, {:ok, new_data} <- fetch_and_contain_remote_object_from_id(id), {:ok, object} <- reinject_object(object, new_data) do {:ok, object} else {:local, true} -> {:ok, object} e -> {:error, e} end end @typep fetcher_errors :: :error | :reject | :allowed_depth | :fetch | :containment | :transmogrifier # Note: will create a Create activity, which we need internally at the moment. @spec fetch_object_from_id(String.t(), list()) :: {:ok, Object.t()} | {fetcher_errors(), any()} | Pipeline.errors() def fetch_object_from_id(id, options \\ []) do with {_, nil} <- {:fetch_object, Object.get_cached_by_ap_id(id)}, {_, true} <- {:allowed_depth, Federator.allowed_thread_distance?(options[:depth])}, {_, {:ok, data}} <- {:fetch, fetch_and_contain_remote_object_from_id(id)}, {_, nil} <- {:normalize, Object.normalize(data, fetch: false)}, params <- prepare_activity_params(data), {_, :ok} <- {:containment, Containment.contain_origin(id, params)}, {_, {:ok, activity}} <- {:transmogrifier, Transmogrifier.handle_incoming(params, options)}, {_, _data, %Object{} = object} <- {:object, data, Object.normalize(activity, fetch: false)} do {:ok, object} else {:normalize, object = %Object{}} -> {:ok, object} {:fetch_object, %Object{} = object} -> {:ok, object} {:object, data, nil} -> reinject_object(%Object{}, data) e -> Logger.metadata(object: id) Logger.error("Object rejected while fetching #{id} #{inspect(e)}") e end end defp prepare_activity_params(data) do %{ "type" => "Create", # Should we seriously keep this attributedTo thing? "actor" => data["actor"] || data["attributedTo"], "object" => data } |> Maps.put_if_present("to", data["to"]) |> Maps.put_if_present("cc", data["cc"]) |> Maps.put_if_present("bto", data["bto"]) |> Maps.put_if_present("bcc", data["bcc"]) end defp make_signature(id, date) do uri = URI.parse(id) signature = InternalFetchActor.get_actor() |> Signature.sign(%{ "(request-target)": "get #{uri.path}", host: uri.host, date: date }) {"signature", signature} end defp sign_fetch(headers, id, date) do if Pleroma.Config.get([:activitypub, :sign_object_fetches]) do [make_signature(id, date) | headers] else headers end end defp maybe_date_fetch(headers, date) do if Pleroma.Config.get([:activitypub, :sign_object_fetches]) do [{"date", date} | headers] else headers end end def fetch_and_contain_remote_object_from_id(id) def fetch_and_contain_remote_object_from_id(%{"id" => id}), do: fetch_and_contain_remote_object_from_id(id) def fetch_and_contain_remote_object_from_id(id) when is_binary(id) do Logger.debug("Fetching object #{id} via AP") with {:scheme, true} <- {:scheme, String.starts_with?(id, "http")}, {_, true} <- {:mrf, MRF.id_filter(id)}, {:ok, body} <- get_object(id), {:ok, data} <- safe_json_decode(body), :ok <- Containment.contain_origin_from_id(id, data) do if not Instances.reachable?(id) do Instances.set_reachable(id) end {:ok, data} else {:scheme, _} -> {:error, "Unsupported URI scheme"} {:error, e} -> {:error, e} {:mrf, false} -> {:error, {:reject, "Filtered by id"}} e -> {:error, e} end end def fetch_and_contain_remote_object_from_id(_id), do: {:error, "id must be a string"} + defp check_crossdomain_redirect(final_host, original_url) + + # Handle the common case in tests where responses don't include URLs + if @mix_env == :test do + defp check_crossdomain_redirect(nil, _) do + {:cross_domain_redirect, false} + end + end + + defp check_crossdomain_redirect(final_host, original_url) do + {:cross_domain_redirect, final_host != URI.parse(original_url).host} + end + defp get_object(id) do date = Pleroma.Signature.signed_date() headers = [{"accept", "application/activity+json"}] |> maybe_date_fetch(date) |> sign_fetch(id, date) case HTTP.get(id, headers) do + {:ok, %{body: body, status: code, headers: headers, url: final_url}} + when code in 200..299 -> + remote_host = if final_url, do: URI.parse(final_url).host, else: nil + + with {:cross_domain_redirect, false} <- check_crossdomain_redirect(remote_host, id), + {_, content_type} <- List.keyfind(headers, "content-type", 0), + {:ok, _media_type} <- verify_content_type(content_type) do + {:ok, body} + else + {:cross_domain_redirect, true} -> + {:error, {:cross_domain_redirect, true}} + + error -> + error + end + + # Handle the case where URL is not in the response (older HTTP library versions) {:ok, %{body: body, status: code, headers: headers}} when code in 200..299 -> case List.keyfind(headers, "content-type", 0) do {_, content_type} -> - case Plug.Conn.Utils.media_type(content_type) do - {:ok, "application", "activity+json", _} -> - {:ok, body} - - {:ok, "application", "ld+json", - %{"profile" => "https://www.w3.org/ns/activitystreams"}} -> - {:ok, body} - - _ -> - {:error, {:content_type, content_type}} + case verify_content_type(content_type) do + {:ok, _} -> {:ok, body} + error -> error end _ -> {:error, {:content_type, nil}} end {:ok, %{status: code}} when code in [401, 403] -> {:error, :forbidden} {:ok, %{status: code}} when code in [404, 410] -> {:error, :not_found} {:error, e} -> {:error, e} e -> {:error, e} end end defp safe_json_decode(nil), do: {:ok, nil} defp safe_json_decode(json), do: Jason.decode(json) + + defp verify_content_type(content_type) do + case Plug.Conn.Utils.media_type(content_type) do + {:ok, "application", "activity+json", _} -> + {:ok, :activity_json} + + {:ok, "application", "ld+json", %{"profile" => "https://www.w3.org/ns/activitystreams"}} -> + {:ok, :ld_json} + + _ -> + {:error, {:content_type, content_type}} + end + end end diff --git a/test/pleroma/object/fetcher_test.exs b/test/pleroma/object/fetcher_test.exs index 215fca570..4dabc283a 100644 --- a/test/pleroma/object/fetcher_test.exs +++ b/test/pleroma/object/fetcher_test.exs @@ -1,616 +1,720 @@ # Pleroma: A lightweight social networking server # Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Object.FetcherTest do use Pleroma.DataCase alias Pleroma.Activity alias Pleroma.Instances alias Pleroma.Object alias Pleroma.Object.Fetcher alias Pleroma.Web.ActivityPub.ObjectValidator require Pleroma.Constants import Mock import Pleroma.Factory import Tesla.Mock setup do mock(fn %{method: :get, url: "https://mastodon.example.org/users/userisgone"} -> %Tesla.Env{status: 410} %{method: :get, url: "https://mastodon.example.org/users/userisgone404"} -> %Tesla.Env{status: 404} %{ method: :get, url: "https://patch.cx/media/03ca3c8b4ac3ddd08bf0f84be7885f2f88de0f709112131a22d83650819e36c2.json" } -> %Tesla.Env{ status: 200, headers: [{"content-type", "application/json"}], body: File.read!("test/fixtures/spoofed-object.json") } env -> apply(HttpRequestMock, :request, [env]) end) :ok end describe "error cases" do setup do mock(fn %{method: :get, url: "https://social.sakamoto.gq/notice/9wTkLEnuq47B25EehM"} -> %Tesla.Env{ status: 200, body: File.read!("test/fixtures/fetch_mocks/9wTkLEnuq47B25EehM.json"), headers: HttpRequestMock.activitypub_object_headers() } %{method: :get, url: "https://social.sakamoto.gq/users/eal"} -> %Tesla.Env{ status: 200, body: File.read!("test/fixtures/fetch_mocks/eal.json"), headers: HttpRequestMock.activitypub_object_headers() } %{method: :get, url: "https://busshi.moe/users/tuxcrafting/statuses/104410921027210069"} -> %Tesla.Env{ status: 200, body: File.read!("test/fixtures/fetch_mocks/104410921027210069.json"), headers: HttpRequestMock.activitypub_object_headers() } %{method: :get, url: "https://busshi.moe/users/tuxcrafting"} -> %Tesla.Env{ status: 500 } %{ method: :get, url: "https://stereophonic.space/objects/02997b83-3ea7-4b63-94af-ef3aa2d4ed17" } -> %Tesla.Env{ status: 500 } end) :ok end test "it works when fetching the OP actor errors out" do # Here we simulate a case where the author of the OP can't be read assert {:ok, _} = Fetcher.fetch_object_from_id( "https://social.sakamoto.gq/notice/9wTkLEnuq47B25EehM" ) end end describe "max thread distance restriction" do @ap_id "http://mastodon.example.org/@admin/99541947525187367" setup do: clear_config([:instance, :federation_incoming_replies_max_depth]) test "it returns thread depth exceeded error if thread depth is exceeded" do clear_config([:instance, :federation_incoming_replies_max_depth], 0) assert {:allowed_depth, false} = Fetcher.fetch_object_from_id(@ap_id, depth: 1) end test "it fetches object if max thread depth is restricted to 0 and depth is not specified" do clear_config([:instance, :federation_incoming_replies_max_depth], 0) assert {:ok, _} = Fetcher.fetch_object_from_id(@ap_id) end test "it fetches object if requested depth does not exceed max thread depth" do clear_config([:instance, :federation_incoming_replies_max_depth], 10) assert {:ok, _} = Fetcher.fetch_object_from_id(@ap_id, depth: 10) end end describe "actor origin containment" do test "it rejects objects with a bogus origin" do {:containment, :error} = Fetcher.fetch_object_from_id("https://info.pleroma.site/activity.json") end test "it rejects objects when attributedTo is wrong (variant 1)" do {:containment, :error} = Fetcher.fetch_object_from_id("https://info.pleroma.site/activity2.json") end test "it rejects objects when attributedTo is wrong (variant 2)" do {:containment, :error} = Fetcher.fetch_object_from_id("https://info.pleroma.site/activity3.json") end end describe "fetching an object" do test "it fetches an object" do {:ok, object} = Fetcher.fetch_object_from_id("http://mastodon.example.org/@admin/99541947525187367") assert _activity = Activity.get_create_by_object_ap_id(object.data["id"]) {:ok, object_again} = Fetcher.fetch_object_from_id("http://mastodon.example.org/@admin/99541947525187367") assert [attachment] = object.data["attachment"] assert is_list(attachment["url"]) assert object == object_again end test "Return MRF reason when fetched status is rejected by one" do clear_config([:mrf_keyword, :reject], ["yeah"]) clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.KeywordPolicy]) assert {:transmogrifier, {:reject, "[KeywordPolicy] Matches with rejected keyword"}} == Fetcher.fetch_object_from_id( "http://mastodon.example.org/@admin/99541947525187367" ) end test "it does not fetch a spoofed object uploaded on an instance as an attachment" do assert {:fetch, {:error, {:content_type, "application/json"}}} = Fetcher.fetch_object_from_id( "https://patch.cx/media/03ca3c8b4ac3ddd08bf0f84be7885f2f88de0f709112131a22d83650819e36c2.json" ) end test "it resets instance reachability on successful fetch" do id = "http://mastodon.example.org/@admin/99541947525187367" Instances.set_consistently_unreachable(id) refute Instances.reachable?(id) {:ok, _object} = Fetcher.fetch_object_from_id("http://mastodon.example.org/@admin/99541947525187367") assert Instances.reachable?(id) end end describe "implementation quirks" do test "it can fetch plume articles" do {:ok, object} = Fetcher.fetch_object_from_id( "https://baptiste.gelez.xyz/~/PlumeDevelopment/this-month-in-plume-june-2018/" ) assert object end test "it can fetch peertube videos" do {:ok, object} = Fetcher.fetch_object_from_id( "https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3" ) assert object end test "it can fetch Mobilizon events" do {:ok, object} = Fetcher.fetch_object_from_id( "https://mobilizon.org/events/252d5816-00a3-4a89-a66f-15bf65c33e39" ) assert object end test "it can fetch wedistribute articles" do {:ok, object} = Fetcher.fetch_object_from_id("https://wedistribute.org/wp-json/pterotype/v1/object/85810") assert object end test "all objects with fake directions are rejected by the object fetcher" do assert {:error, _} = Fetcher.fetch_and_contain_remote_object_from_id( "https://info.pleroma.site/activity4.json" ) end test "handle HTTP 410 Gone response" do assert {:error, :not_found} == Fetcher.fetch_and_contain_remote_object_from_id( "https://mastodon.example.org/users/userisgone" ) end test "handle HTTP 404 response" do assert {:error, :not_found} == Fetcher.fetch_and_contain_remote_object_from_id( "https://mastodon.example.org/users/userisgone404" ) end test "it can fetch pleroma polls with attachments" do {:ok, object} = Fetcher.fetch_object_from_id("https://patch.cx/objects/tesla_mock/poll_attachment") assert object end end describe "pruning" do test "it can refetch pruned objects" do object_id = "http://mastodon.example.org/@admin/99541947525187367" {:ok, object} = Fetcher.fetch_object_from_id(object_id) assert object {:ok, _object} = Object.prune(object) refute Object.get_by_ap_id(object_id) {:ok, %Object{} = object_two} = Fetcher.fetch_object_from_id(object_id) assert object.data["id"] == object_two.data["id"] assert object.id != object_two.id end end describe "signed fetches" do setup do: clear_config([:activitypub, :sign_object_fetches]) test_with_mock "it signs fetches when configured to do so", Pleroma.Signature, [:passthrough], [] do clear_config([:activitypub, :sign_object_fetches], true) Fetcher.fetch_object_from_id("http://mastodon.example.org/@admin/99541947525187367") assert called(Pleroma.Signature.sign(:_, :_)) end test_with_mock "it doesn't sign fetches when not configured to do so", Pleroma.Signature, [:passthrough], [] do clear_config([:activitypub, :sign_object_fetches], false) Fetcher.fetch_object_from_id("http://mastodon.example.org/@admin/99541947525187367") refute called(Pleroma.Signature.sign(:_, :_)) end end describe "refetching" do setup do insert(:user, ap_id: "https://mastodon.social/users/emelie") object1 = %{ "id" => "https://mastodon.social/1", "actor" => "https://mastodon.social/users/emelie", "attributedTo" => "https://mastodon.social/users/emelie", "type" => "Note", "content" => "test 1", "bcc" => [], "bto" => [], "cc" => [], "to" => [Pleroma.Constants.as_public()], "summary" => "", "published" => "2023-05-08 23:43:20Z", "updated" => "2023-05-09 23:43:20Z" } {:ok, local_object1, _} = ObjectValidator.validate(object1, []) object2 = %{ "id" => "https://mastodon.social/2", "actor" => "https://mastodon.social/users/emelie", "attributedTo" => "https://mastodon.social/users/emelie", "type" => "Note", "content" => "test 2", "bcc" => [], "bto" => [], "cc" => [], "to" => [Pleroma.Constants.as_public()], "summary" => "", "published" => "2023-05-08 23:43:20Z", "updated" => "2023-05-09 23:43:25Z", "formerRepresentations" => %{ "type" => "OrderedCollection", "orderedItems" => [ %{ "type" => "Note", "content" => "orig 2", "actor" => "https://mastodon.social/users/emelie", "attributedTo" => "https://mastodon.social/users/emelie", "bcc" => [], "bto" => [], "cc" => [], "to" => [Pleroma.Constants.as_public()], "summary" => "", "published" => "2023-05-08 23:43:20Z", "updated" => "2023-05-09 23:43:21Z" } ], "totalItems" => 1 } } {:ok, local_object2, _} = ObjectValidator.validate(object2, []) mock(fn %{ method: :get, url: "https://mastodon.social/1" } -> %Tesla.Env{ status: 200, headers: [{"content-type", "application/activity+json"}], body: Jason.encode!(object1 |> Map.put("updated", "2023-05-09 23:44:20Z")) } %{ method: :get, url: "https://mastodon.social/2" } -> %Tesla.Env{ status: 200, headers: [{"content-type", "application/activity+json"}], body: Jason.encode!(object2 |> Map.put("updated", "2023-05-09 23:44:20Z")) } %{ method: :get, url: "https://mastodon.social/users/emelie/collections/featured" } -> %Tesla.Env{ status: 200, headers: [{"content-type", "application/activity+json"}], body: Jason.encode!(%{ "id" => "https://mastodon.social/users/emelie/collections/featured", "type" => "OrderedCollection", "actor" => "https://mastodon.social/users/emelie", "attributedTo" => "https://mastodon.social/users/emelie", "orderedItems" => [], "totalItems" => 0 }) } env -> apply(HttpRequestMock, :request, [env]) end) %{object1: local_object1, object2: local_object2} end test "it keeps formerRepresentations if remote does not have this attr", %{object1: object1} do full_object1 = object1 |> Map.merge(%{ "formerRepresentations" => %{ "type" => "OrderedCollection", "orderedItems" => [ %{ "type" => "Note", "content" => "orig 2", "actor" => "https://mastodon.social/users/emelie", "attributedTo" => "https://mastodon.social/users/emelie", "bcc" => [], "bto" => [], "cc" => [], "to" => [Pleroma.Constants.as_public()], "summary" => "", "published" => "2023-05-08 23:43:20Z" } ], "totalItems" => 1 } }) {:ok, o} = Object.create(full_object1) assert {:ok, refetched} = Fetcher.refetch_object(o) assert %{"formerRepresentations" => %{"orderedItems" => [%{"content" => "orig 2"}]}} = refetched.data end test "it uses formerRepresentations from remote if possible", %{object2: object2} do {:ok, o} = Object.create(object2) assert {:ok, refetched} = Fetcher.refetch_object(o) assert %{"formerRepresentations" => %{"orderedItems" => [%{"content" => "orig 2"}]}} = refetched.data end test "it replaces formerRepresentations with the one from remote", %{object2: object2} do full_object2 = object2 |> Map.merge(%{ "content" => "mew mew #def", "formerRepresentations" => %{ "type" => "OrderedCollection", "orderedItems" => [ %{"type" => "Note", "content" => "mew mew 2"} ], "totalItems" => 1 } }) {:ok, o} = Object.create(full_object2) assert {:ok, refetched} = Fetcher.refetch_object(o) assert %{ "content" => "test 2", "formerRepresentations" => %{"orderedItems" => [%{"content" => "orig 2"}]} } = refetched.data end test "it adds to formerRepresentations if the remote does not have one and the object has changed", %{object1: object1} do full_object1 = object1 |> Map.merge(%{ "content" => "mew mew #def", "formerRepresentations" => %{ "type" => "OrderedCollection", "orderedItems" => [ %{"type" => "Note", "content" => "mew mew 1"} ], "totalItems" => 1 } }) {:ok, o} = Object.create(full_object1) assert {:ok, refetched} = Fetcher.refetch_object(o) assert %{ "content" => "test 1", "formerRepresentations" => %{ "orderedItems" => [ %{"content" => "mew mew #def"}, %{"content" => "mew mew 1"} ], "totalItems" => 2 } } = refetched.data end test "it keeps the history intact if only updated time has changed", %{object1: object1} do full_object1 = object1 |> Map.merge(%{ "updated" => "2023-05-08 23:43:47Z", "formerRepresentations" => %{ "type" => "OrderedCollection", "orderedItems" => [ %{"type" => "Note", "content" => "mew mew 1"} ], "totalItems" => 1 } }) {:ok, o} = Object.create(full_object1) assert {:ok, refetched} = Fetcher.refetch_object(o) assert %{ "content" => "test 1", "formerRepresentations" => %{ "orderedItems" => [ %{"content" => "mew mew 1"} ], "totalItems" => 1 } } = refetched.data end test "it goes through ObjectValidator and MRF", %{object2: object2} do with_mock Pleroma.Web.ActivityPub.MRF, [:passthrough], filter: fn %{"type" => "Note"} = object -> {:ok, Map.put(object, "content", "MRFd content")} arg -> passthrough([arg]) end do {:ok, o} = Object.create(object2) assert {:ok, refetched} = Fetcher.refetch_object(o) assert %{"content" => "MRFd content"} = refetched.data end end end + describe "cross-domain redirect handling" do + setup do + mock(fn + # Cross-domain redirect with original domain in id + %{method: :get, url: "https://original.test/objects/123"} -> + %Tesla.Env{ + status: 200, + url: "https://media.test/objects/123", + headers: [{"content-type", "application/activity+json"}], + body: + Jason.encode!(%{ + "id" => "https://original.test/objects/123", + "type" => "Note", + "content" => "This is redirected content", + "actor" => "https://original.test/users/actor", + "attributedTo" => "https://original.test/users/actor" + }) + } + + # Cross-domain redirect with final domain in id + %{method: :get, url: "https://original.test/objects/final-domain-id"} -> + %Tesla.Env{ + status: 200, + url: "https://media.test/objects/final-domain-id", + headers: [{"content-type", "application/activity+json"}], + body: + Jason.encode!(%{ + "id" => "https://media.test/objects/final-domain-id", + "type" => "Note", + "content" => "This has final domain in id", + "actor" => "https://original.test/users/actor", + "attributedTo" => "https://original.test/users/actor" + }) + } + + # No redirect - same domain + %{method: :get, url: "https://original.test/objects/same-domain-redirect"} -> + %Tesla.Env{ + status: 200, + url: "https://original.test/objects/different-path", + headers: [{"content-type", "application/activity+json"}], + body: + Jason.encode!(%{ + "id" => "https://original.test/objects/same-domain-redirect", + "type" => "Note", + "content" => "This has a same-domain redirect", + "actor" => "https://original.test/users/actor", + "attributedTo" => "https://original.test/users/actor" + }) + } + + # Test case with missing url field in response (common in tests) + %{method: :get, url: "https://original.test/objects/missing-url"} -> + %Tesla.Env{ + status: 200, + # No url field + headers: [{"content-type", "application/activity+json"}], + body: + Jason.encode!(%{ + "id" => "https://original.test/objects/missing-url", + "type" => "Note", + "content" => "This has no URL field in response", + "actor" => "https://original.test/users/actor", + "attributedTo" => "https://original.test/users/actor" + }) + } + end) + + :ok + end + + test "it rejects objects from cross-domain redirects with original domain in id" do + assert {:error, {:cross_domain_redirect, true}} = + Fetcher.fetch_and_contain_remote_object_from_id( + "https://original.test/objects/123" + ) + end + + test "it rejects objects from cross-domain redirects with final domain in id" do + assert {:error, {:cross_domain_redirect, true}} = + Fetcher.fetch_and_contain_remote_object_from_id( + "https://original.test/objects/final-domain-id" + ) + end + + test "it accepts objects with same-domain redirects" do + assert {:ok, data} = + Fetcher.fetch_and_contain_remote_object_from_id( + "https://original.test/objects/same-domain-redirect" + ) + + assert data["content"] == "This has a same-domain redirect" + end + + test "it handles responses without URL field (common in tests)" do + assert {:ok, data} = + Fetcher.fetch_and_contain_remote_object_from_id( + "https://original.test/objects/missing-url" + ) + + assert data["content"] == "This has no URL field in response" + end + end + describe "fetch with history" do setup do object2 = %{ "id" => "https://mastodon.social/2", "actor" => "https://mastodon.social/users/emelie", "attributedTo" => "https://mastodon.social/users/emelie", "type" => "Note", "content" => "test 2", "bcc" => [], "bto" => [], "cc" => ["https://mastodon.social/users/emelie/followers"], "to" => [], "summary" => "", "formerRepresentations" => %{ "type" => "OrderedCollection", "orderedItems" => [ %{ "type" => "Note", "content" => "orig 2", "actor" => "https://mastodon.social/users/emelie", "attributedTo" => "https://mastodon.social/users/emelie", "bcc" => [], "bto" => [], "cc" => ["https://mastodon.social/users/emelie/followers"], "to" => [], "summary" => "" } ], "totalItems" => 1 } } mock(fn %{ method: :get, url: "https://mastodon.social/2" } -> %Tesla.Env{ status: 200, headers: [{"content-type", "application/activity+json"}], body: Jason.encode!(object2) } %{ method: :get, url: "https://mastodon.social/users/emelie/collections/featured" } -> %Tesla.Env{ status: 200, headers: [{"content-type", "application/activity+json"}], body: Jason.encode!(%{ "id" => "https://mastodon.social/users/emelie/collections/featured", "type" => "OrderedCollection", "actor" => "https://mastodon.social/users/emelie", "attributedTo" => "https://mastodon.social/users/emelie", "orderedItems" => [], "totalItems" => 0 }) } env -> apply(HttpRequestMock, :request, [env]) end) %{object2: object2} end test "it gets history", %{object2: object2} do {:ok, object} = Fetcher.fetch_object_from_id(object2["id"]) assert %{ "formerRepresentations" => %{ "type" => "OrderedCollection", "orderedItems" => [%{}] } } = object.data end end end