diff --git a/FEDERATION.md b/FEDERATION.md index 00ecf47dc..8934c0d4b 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -47,9 +47,10 @@ Akkoma strongly encourages ActivityPub implementations to include a FEP-2c59-compliant WebFinger backlink in their actor documents. Without FEP-2c59 and if different domains are used for ActivityPub and the Webfinger subject, -Akkoma relies on the presence of an host-meta LRDD template on the ActivityPub domain -or a HTTP redirect from the ActivityPub domain’s `/.well-known/webfinger` to an equivalent endpoint -on the domain used in the `subject` to discover and validate the domain association. +Akkoma relies on either the presence of an host-meta LRDD template on the ActivityPub domain +or a working WebFinger endpoint on the ActivityPub domain. Additionally all WebFinger endpoints +related to the ActivityPub and canonical WebFinger domain SHOULD also respond to queries about +an alternative acct URI constructed with the WebFinger domain passed as the resource. Without FEP-2c59 Akkoma may not become aware of changes to the preferred WebFinger `subject` domain for already discovered users. diff --git a/lib/pleroma/web/web_finger/finger.ex b/lib/pleroma/web/web_finger/finger.ex index c58ba0bff..406d133aa 100644 --- a/lib/pleroma/web/web_finger/finger.ex +++ b/lib/pleroma/web/web_finger/finger.ex @@ -154,11 +154,9 @@ defmodule Pleroma.Web.WebFinger.Finger do query_uri = make_finger_uri(domain, resource) resp = HTTP.Backoff.get(query_uri, [{"accept", "application/xrd+xml,application/jrd+json"}]) - with {:ok, %{url: resolved_uri, status: status} = resp_data} when status in 200..299 <- resp, + with {:ok, %{status: status} = resp_data} when status in 200..299 <- resp, {_, {:ok, parsed_data}} <- {:parse, parse_finger_response(resp_data)} do - resolved_domain = URI.parse(resolved_uri).host - - {:ok, resolved_domain, parsed_data} + {:ok, parsed_data} else {:ok, %Tesla.Env{} = env} -> {:error, map_fetch_error_reason(env)} {:parse, {:error, _} = error} -> error @@ -180,6 +178,47 @@ defmodule Pleroma.Web.WebFinger.Finger do end end + # Parsed WebFinger response data with the subject acct URI (and thus parsed_subject and normalised_subject) + # being verified to be authorised by the domain in which authority it lies + # (i.e. make sure an "acct:user@domain.example" is acknowlledged by domain.example) + # Does NOT verify the actor pointed at in "ap_id" agrees to the handle! + defp finger_data_with_domainauth(domain, resource, allow_refetch \\ true) do + with {:ok, %{"subject" => finger_subject} = preparsed_data} <- + finger_unverified_data(domain, resource), + handle <- normalise_webfinger_handle(finger_subject), + {nick_user, nick_domain} <- parse_handle(handle), + {_, false} <- {:no_domain, nick_domain == nil}, + # We cannot reliably accepted redirects as auth for the final domain. + # Thus only accepted result if matching the _initial_ domain, else allow a single refetch + # (traversing longer redirect chains may risk getting stuck in loops) + {_, true, _} <- + {:domainauth, nick_domain == domain, {nick_domain, resource_from_mention(handle)}} do + parsed_data = + preparsed_data + |> Map.put("normalised_subject", handle) + |> Map.put("parsed_subject", {nick_user, nick_domain}) + + {:ok, parsed_data} + else + {:domainauth, _, {nick_domain, new_resource}} -> + if allow_refetch do + finger_data_with_domainauth(nick_domain, new_resource) + else + Logger.error( + "Spoofed WebFinger response: #{inspect(domain)} responded with subject from #{inspect(nick_domain)} when no alias was expected!" + ) + + {:error, :finger_domain_spoof} + end + + {:no_domain, _} -> + {:error, :no_domain} + + error -> + error + end + end + @doc """ Discovers and verifies the WebFinger handle of an ActivityPub actor for use as a nickname. If the actor or instance does not use WebFinger or just temporarily unavailable no value @@ -199,15 +238,10 @@ defmodule Pleroma.Web.WebFinger.Finger do with {_, false} <- {:no_domain, domain == nil || ap_domain == nil}, {_, false} <- {:matching_domain, domain == ap_domain}, - # We check for an exact match to the preferred handle which will ALWAYS - # belong to the initial query domain, thus we do not need to consider the final domain here. - # If the query domain delegates to another domain via host-meta or HTTP redirects on - # ./well-known/ paths (which ought to be directly controlled by the operator), - # this clearly indicates consent of the query domain to allow the final domain to manage this data - {_, {:ok, _, %{"ap_id" => fingered_ap_id, "subject" => finger_subject}}} <- - {:query, finger_unverified_data(domain, ap_id)}, + # Since we already query the preferred domain, no refetches ought to be necessary + {_, {:ok, %{"ap_id" => fingered_ap_id, "normalised_subject" => finger_handle}}} <- + {:query, finger_data_with_domainauth(domain, ap_id, false)}, {_, false} <- {:fingered_data_mismatch, ap_id != fingered_ap_id}, - finger_handle <- normalise_webfinger_handle(finger_subject), {_, false} <- {:fingered_data_mismatch, preferred_handle != finger_handle} do {:ok, preferred_handle} else @@ -226,18 +260,15 @@ defmodule Pleroma.Web.WebFinger.Finger do ap_domain = URI.parse(ap_id).host with {_, false} <- {:no_domain, ap_domain == nil}, - {_, {:ok, finger_domain, %{"ap_id" => fingered_ap_id, "subject" => finger_subject}}} <- - {:query, finger_unverified_data(ap_domain, ap_id)}, + {_, + {:ok, + %{ + "ap_id" => fingered_ap_id, + "normalised_subject" => handle, + "parsed_subject" => {nick_user, _} + }}} <- + {:query, finger_data_with_domainauth(ap_domain, ap_id)}, {_, false} <- {:fingered_data_mismatch, fingered_ap_id != ap_id}, - handle <- normalise_webfinger_handle(finger_subject), - {nick_user, nick_domain} <- parse_handle(handle), - # Mastodon in its infinite wisdom encourages setups for custom WebFinger domains, - # such that the actual WebFinger response is _never_ served directly from the domain used in handles. - # Unlike in domain authority checks for AP IDs, here only fixed /.well-known URLs are queried, - # thus a redirect on this endpoint can be considered an approval from the redirecting domain - # (but not the redirected-to domain!) and it should be safe to accept both domain authorities here. - {_, false} <- - {:finger_domain_spoof, nick_domain != finger_domain && nick_domain != ap_domain}, ap_name <- actor_data["preferredUsername"], {_, false} <- {:fingered_data_mismatch, ap_name != nil && ap_name != nick_user} do {:ok, handle} @@ -305,16 +336,17 @@ defmodule Pleroma.Web.WebFinger.Finger do resource = resource_from_mention(mention_handle) with {_, false} <- {:invalid_handle, qname == nil || qdomain == nil}, - {_, {:ok, finger_domain, %{"ap_id" => fingered_ap_id, "subject" => finger_subject}}} <- - {:query, finger_unverified_data(qdomain, resource)}, - handle <- normalise_webfinger_handle(finger_subject), - {nick_user, nick_domain} <- parse_handle(handle), - # see comment in finger_actor for why both domains can and need to be accepted - {_, false} <- - {:finger_domain_spoof, nick_domain != finger_domain && nick_domain != qdomain}, + {_, + {:ok, + %{ + "ap_id" => fingered_ap_id, + "normalised_subject" => handle, + "parsed_subject" => {nick_user, nick_domain} + }}} <- + {:query, finger_data_with_domainauth(qdomain, resource)}, {_, {:ok, data}} <- {:fetch, Fetcher.fetch_and_contain_remote_object_from_id(fingered_ap_id)} do - verify_ap_data_from_finger(data, handle, finger_domain, nick_user) + verify_ap_data_from_finger(data, handle, nick_domain, nick_user) else {:query, error} -> error {:fetch, error} -> error @@ -339,7 +371,7 @@ defmodule Pleroma.Web.WebFinger.Finger do end with {_, domain} when is_binary(domain) <- {:domain, domain}, - {:ok, _, data} <- finger_unverified_data(domain, resource) do + {:ok, data} <- finger_unverified_data(domain, resource) do {:ok, data} else {:domain, _} -> {:error, :no_domain} diff --git a/test/pleroma/web/web_finger/finger_test.exs b/test/pleroma/web/web_finger/finger_test.exs index bdcdab6d6..4b251fb7a 100644 --- a/test/pleroma/web/web_finger/finger_test.exs +++ b/test/pleroma/web/web_finger/finger_test.exs @@ -56,11 +56,17 @@ defmodule Pleroma.Web.WebFinger.FingerTest do end describe "finger_mention/1" do - test "accepts content in authority of final domain" do + test "follows subjects to canonical domain to verify" do # Not FEP-2c59, but otherwise one possible sane setup Tesla.Mock.mock(fn - %{url: "https://fedi.example.com/.well-known/webfinger?resource=" <> rsrc} - when rsrc in ["acct:user@fedi.example.com", "https://fedi.example.com/users/user"] -> + %{url: url} + when url in [ + "https://fedi.example.com/.well-known/webfinger?resource=" <> + "acct:user@fedi.example.com", + "https://fedi.example.com/.well-known/webfinger?resource=" <> + "https://fedi.example.com/users/user", + "https://example.com/.well-known/webfinger?resource=" <> "acct:user@example.com" + ] -> {:ok, %Tesla.Env{ status: 200, @@ -97,10 +103,110 @@ defmodule Pleroma.Web.WebFinger.FingerTest do {:ok, "user@example.com", _} = Finger.finger_mention("user@fedi.example.com") end + test "rejects spoof attempt via redirect to untrusted cross-domain path when handle does not exist on authorative domain" do + Tesla.Mock.mock(fn + %{ + url: + "https://stinkyplace.example/.well-known/webfinger?resource=acct:doppelgaenger@stinkyplace.example" + } -> + {:ok, + %Tesla.Env{ + status: 200, + body: + File.read!("test/fixtures/webfinger/pleroma-webfinger.json") + |> String.replace("{{domain}}", "shinyplace.example") + |> String.replace("{{nickname}}", "doppelgaenger") + |> String.replace("{{subdomain}}", "stinkyplace.example"), + headers: [{"content-type", "application/jrd+json"}], + url: "https://shinyplace.example/user-uploads/123/webfinger.json" + }} + + %{url: url} + when url in [ + "https://stinkyplace.example/.well-known/host-meta", + "https://shinyplace.example/.well-known/host-meta", + "https://shinyplace.example/.well-known/webfinger?resource=acct:doppelgaenger@shinyplace.example" + ] -> + {:ok, %Tesla.Env{status: 404, url: url}} + end) + + assert {:error, :not_found} = + Finger.finger_mention("@doppelgaenger@stinkyplace.example") + end + + test "only uses data from authorative domain when refetching" do + Tesla.Mock.mock(fn + %{ + url: + "https://stinkyplace.example/.well-known/webfinger?resource=acct:doppelgaenger@stinkyplace.example" + } -> + {:ok, + %Tesla.Env{ + status: 200, + body: + File.read!("test/fixtures/webfinger/pleroma-webfinger.json") + |> String.replace("{{domain}}", "shinyplace.example") + |> String.replace("{{nickname}}", "doppelgaenger") + |> String.replace("{{subdomain}}", "stinkyplace.example"), + headers: [{"content-type", "application/jrd+json"}], + url: "https://shinyplace.example/user-uploads/123/webfinger.json" + }} + + %{ + url: + "https://shinyplace.example/.well-known/webfinger?resource=acct:doppelgaenger@shinyplace.example" = + url + } -> + {:ok, + %Tesla.Env{ + status: 200, + body: + File.read!("test/fixtures/webfinger/pleroma-webfinger.json") + |> String.replace("{{domain}}", "shinyplace.example") + |> String.replace("{{nickname}}", "doppelgaenger") + |> String.replace("{{subdomain}}", "shinyplace.example"), + headers: [{"content-type", "application/jrd+json"}], + url: url + }} + + %{url: "https://shinyplace.example/users/doppelgaenger" = url} -> + {:ok, + %Tesla.Env{ + status: 200, + url: url, + headers: [{"content-type", "application/activity+json"}], + body: """ + { + "id": "#{url}", + "type": "Service", + "inbox": "#{url}/inbox", + "outbox": "#{url}/inbox" + } + """ + }} + + %{url: url} + when url in [ + "https://stinkyplace.example/.well-known/host-meta", + "https://shinyplace.example/.well-known/host-meta" + ] -> + {:ok, %Tesla.Env{status: 404, url: url}} + end) + + {:ok, "doppelgaenger@shinyplace.example", + %{"id" => "https://shinyplace.example/users/doppelgaenger"}} = + Finger.finger_mention("@doppelgaenger@stinkyplace.example") + end + test "accepts content in authority of query domain" do # Early 2026 Mastodon style Tesla.Mock.mock(fn - %{url: "https://example.com/.well-known/webfinger?resource=acct:user@example.com"} -> + %{url: url} + when url in [ + "https://example.com/.well-known/webfinger?resource=acct:user@example.com", + "https://fedi.example.com/.well-known/webfinger?resource=acct:user@example.com", + "https://fedi.example.com/.well-known/webfinger?resource=https://fedi.example.com/users/user" + ] -> {:ok, %Tesla.Env{ status: 200, @@ -114,11 +220,15 @@ defmodule Pleroma.Web.WebFinger.FingerTest do |> String.replace("{{subdomain}}", "fedi.example.com") }} - %{url: "https://fedi.example.com/.well-known/host-meta"} -> + %{url: url} + when url in [ + "https://example.com/.well-known/host-meta", + "https://fedi.example.com/.well-known/host-meta" + ] -> {:ok, %Tesla.Env{ status: 404, - url: "https://example.com/.well-known/host-meta" + url: url }} %{url: "https://fedi.example.com/users/user"} -> @@ -137,7 +247,7 @@ defmodule Pleroma.Web.WebFinger.FingerTest do {:ok, "user@example.com", _} = Finger.finger_mention("user@example.com") end - test "errors when being served content from unrelated third-party domain" do + test "errors when being served content from unrelated third-party domain which is not registered" do Tesla.Mock.mock(fn %{url: "https://example.com/.well-known/webfinger?resource=acct:user@example.com"} -> {:ok, @@ -152,15 +262,19 @@ defmodule Pleroma.Web.WebFinger.FingerTest do |> String.replace("{{subdomain}}", "fedi.example.org") }} - %{url: "https://example.com/.well-known/host-meta"} -> + %{url: url} + when url in [ + "https://example.com/.well-known/host-meta", + "https://shinyplace.example/.well-known/webfinger?resource=acct:user@shinyplace.example" + ] -> {:ok, %Tesla.Env{ status: 404, - url: "https://example.com/.well-known/host-meta" + url: url }} end) - {:error, :finger_domain_spoof} = Finger.finger_mention("user@example.com") + {:error, :not_found} = Finger.finger_mention("user@example.com") end test "should use the webfinger property to look up the webfinger data for an actor" do @@ -207,11 +321,13 @@ defmodule Pleroma.Web.WebFinger.FingerTest do {:ok, "user@example.com", _data} = Finger.finger_mention("@user@example.com") end - test "allows HTTP redirects to serve as webfinger domain delegation" do + test "works with HTTP redirects on .well-known webfinger path" do Tesla.Mock.mock(fn - %{ - url: "https://example.com/.well-known/webfinger?resource=acct:user@example.com" - } -> + %{url: url} + when url in [ + "https://example.com/.well-known/webfinger?resource=acct:user@example.com", + "https://somewhere-else.com/.well-known/webfinger?resource=acct:another-user@somewhere-else.com" + ] -> {:ok, %Tesla.Env{ status: 200, @@ -252,11 +368,13 @@ defmodule Pleroma.Web.WebFinger.FingerTest do {:ok, "another-user@somewhere-else.com", _data} = Finger.finger_mention("@user@example.com") end - test "should reject a cross-domain webfinger if the final actor has an incorrect webfinger property" do + test "should reject a cross-domain webfinger if the final actor has an incorrect webfinger property even if domain agrees to handle" do Tesla.Mock.mock(fn - %{ - url: "https://example.com/.well-known/webfinger?resource=acct:user@example.com" - } -> + %{url: url} + when url in [ + "https://example.com/.well-known/webfinger?resource=acct:user@example.com", + "https://somewhere-else.com/.well-known/webfinger?resource=acct:another-user@somewhere-else.com" + ] -> {:ok, %Tesla.Env{ status: 200, @@ -300,9 +418,11 @@ defmodule Pleroma.Web.WebFinger.FingerTest do test "should refetch the initial actor if no backlink exists on the final actor" do Tesla.Mock.mock(fn # first, the initial webfinger we fetch points to somewhere-else.com - %{ - url: "https://example.com/.well-known/webfinger?resource=acct:user@example.com" - } -> + %{url: url} + when url in [ + "https://example.com/.well-known/webfinger?resource=acct:user@example.com", + "https://somewhere-else.com/.well-known/webfinger?resource=acct:another-user@somewhere-else.com" + ] -> {:ok, %Tesla.Env{ status: 200, @@ -545,7 +665,8 @@ defmodule Pleroma.Web.WebFinger.FingerTest do |> String.replace("{{nickname}}", "user") |> String.replace("{{subdomain}}", "social.example.com"), headers: [{"content-type", "application/jrd+json"}], - url: "https://oops-this-was-a-redirect/somewhere" + url: + "https://oops-this-was-a-redirect-to-a-trusted-path/.well-known/my-static-webfinger" }} %{url: "https://example.com/.well-known/host-meta"} -> @@ -566,6 +687,43 @@ defmodule Pleroma.Web.WebFinger.FingerTest do }) end + test "refuses domain mismatches early with webfinger backlink" do + Tesla.Mock.mock(fn + # we should finger the webfinger property + %{ + url: + "https://example.com/.well-known/webfinger?resource=https://social.example.com/users/user" + } -> + {:ok, + %Tesla.Env{ + status: 200, + body: + File.read!("test/fixtures/webfinger/pleroma-webfinger.json") + |> String.replace("{{domain}}", "broken.example.com") + |> String.replace("{{nickname}}", "user") + |> String.replace("{{subdomain}}", "social.example.com"), + headers: [{"content-type", "application/jrd+json"}], + url: "https://broken.example.com/.well-known/webfinger" + }} + + %{url: "https://example.com/.well-known/host-meta"} -> + {:ok, + %Tesla.Env{ + status: 200, + url: "https://example.com/.well-known/host-meta", + body: + File.read!("test/fixtures/webfinger/masto-host-meta.xml") + |> String.replace("{{domain}}", "example.com") + }} + end) + + assert {:error, :finger_domain_spoof} = + Finger.finger_actor(%{ + "id" => "https://social.example.com/users/user", + "webfinger" => "user@example.com" + }) + end + test "can discover nick from WebFinger query alone if actor contains no hints" do Tesla.Mock.mock(fn # we should finger the ID directly @@ -644,6 +802,101 @@ defmodule Pleroma.Web.WebFinger.FingerTest do "id" => "https://example.com/users/user" }) end + + require Logger + + test "enusures final WebFinger response actually links back to inital actor (with FEP-2c59)" do + Tesla.Mock.mock(fn + %{ + url: + "https://shinyplace.example/.well-known/webfinger?resource=https://example.com/users/user" = + url + } -> + {:ok, + %Tesla.Env{ + status: 200, + body: + File.read!("test/fixtures/webfinger/masto-webfinger.json") + |> String.replace("{{domain}}", "shinyplace.example") + |> String.replace("{{nickname}}", "user") + |> String.replace("{{subdomain}}", "example.com") + |> String.replace("{{apid}}", "https://example.com/users/not-this-user"), + headers: [{"content-type", "application/jrd+json"}], + url: url + }} + + %{url: "https://shinyplace.example/.well-known/host-meta" = url} -> + {:ok, + %Tesla.Env{ + status: 200, + url: url, + body: + File.read!("test/fixtures/webfinger/masto-host-meta.xml") + |> String.replace("{{domain}}", "shinyplace.example") + }} + end) + + assert {:error, :fingered_data_mismatch} = + Finger.finger_actor(%{ + "id" => "https://example.com/users/user", + "webfinger" => "user@shinyplace.example" + }) + end + + test "enusures final WebFinger response actually links back to inital acotr (without FEP-2c59)" do + Tesla.Mock.mock(fn + %{ + url: + "https://example.com/.well-known/webfinger?resource=https://example.com/users/user" = + url + } -> + {:ok, + %Tesla.Env{ + status: 200, + body: + File.read!("test/fixtures/webfinger/masto-webfinger.json") + # Indicates different WebFinger domain is preferred + |> String.replace("{{domain}}", "shinyplace.example") + |> String.replace("{{nickname}}", "user") + |> String.replace("{{subdomain}}", "example.com") + # This still matches the initially fingered actor + |> String.replace("{{apid}}", "https://example.com/users/user"), + headers: [{"content-type", "application/jrd+json"}], + url: url + }} + + %{ + url: + "https://shinyplace.example/.well-known/webfinger?resource=acct:user@shinyplace.example" = + url + } -> + {:ok, + %Tesla.Env{ + status: 200, + body: + File.read!("test/fixtures/webfinger/masto-webfinger.json") + |> String.replace("{{domain}}", "shinyplace.example") + |> String.replace("{{nickname}}", "user") + |> String.replace("{{subdomain}}", "shinyplace.example") + # different AP id than initially fingered actor we’re trying to enrich here! + |> String.replace("{{apid}}", "https://shinyplace.com/users/user"), + headers: [{"content-type", "application/jrd+json"}], + url: url + }} + + %{url: url} + when url in [ + "https://example.com/.well-known/host-meta", + "https://shinyplace.example/.well-known/host-meta" + ] -> + {:ok, %Tesla.Env{status: 404, url: url}} + end) + + assert {:error, :fingered_data_mismatch} = + Finger.finger_actor(%{ + "id" => "https://example.com/users/user" + }) + end end describe "finger_raw_data/1" do @@ -803,10 +1056,27 @@ defmodule Pleroma.Web.WebFinger.FingerTest do Tesla.Mock.json(fake_webfinger, url: url) - %{url: "https://bad.com/.well-known/host-meta"} -> - {:ok, %Tesla.Env{status: 404}} + # the AP id from fake WebFInger response + %{url: "https://bad.com/webfingertest" = url} -> + {:ok, + %Tesla.Env{ + status: 200, + url: url, + headers: [{"content-type", "application/activity+json"}], + body: """ + { + "id": "#{url}", + "type": "Service", + "inbox": "#{url}/inbox", + "outbox": "#{url}/inbox" + } + """ + }} + + %{url: url} -> + {:ok, %Tesla.Env{status: 404, url: url}} end) - assert {:error, :finger_domain_spoof} = Finger.finger_mention("meanie@bad.com") + assert {:error, :not_found} = Finger.finger_mention("meanie@bad.com") end end diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index c4aa41d44..cdf8b5e94 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -1715,7 +1715,11 @@ defmodule HttpRequestMock do end Macros.mock_masto_webfinger( - "https://sub.mastodon.example/.well-known/webfinger?resource=acct:a@mastodon.example", + [ + "https://sub.mastodon.example/.well-known/webfinger?resource=https://sub.mastodon.example/users/a", + "https://sub.mastodon.example/.well-known/webfinger?resource=acct:a@mastodon.example", + "https://mastodon.example/.well-known/webfinger?resource=acct:a@mastodon.example" + ], "a", "mastodon.example", "sub.mastodon.example" @@ -1774,12 +1778,12 @@ defmodule HttpRequestMock do }} end - def get( - "https://sub.pleroma.example/.well-known/webfinger?resource=acct:a@pleroma.example" = url, - _, - _, - _ - ) do + def get(url, _, _, _) + when url in [ + "https://sub.pleroma.example/.well-known/webfinger?resource=https://sub.pleroma.example/users/a", + "https://sub.pleroma.example/.well-known/webfinger?resource=acct:a@pleroma.example", + "https://pleroma.example/.well-known/webfinger?resource=acct:a@pleroma.example" + ] do {:ok, %Tesla.Env{ status: 200, diff --git a/test/support/http_request_mock_macros.ex b/test/support/http_request_mock_macros.ex index b5091f5c4..e8946ddad 100644 --- a/test/support/http_request_mock_macros.ex +++ b/test/support/http_request_mock_macros.ex @@ -3,16 +3,20 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule HttpRequestMockMacros do - defmacro mock_masto_webfinger(url, nick, webfinger_domain, ap_domain \\ nil, ap_id \\ nil) do - quote do - def get(unquote(url) = url, _, _, _) do - webfinger_response_masto( - url, - unquote(nick), - unquote(webfinger_domain), - unquote(ap_domain), - unquote(ap_id) - ) + defmacro mock_masto_webfinger(urls, nick, webfinger_domain, ap_domain \\ nil, ap_id \\ nil) do + urls = if is_binary(urls), do: [urls], else: urls + + for url <- urls do + quote do + def get(unquote(url) = url, _, _, _) do + webfinger_response_masto( + url, + unquote(nick), + unquote(webfinger_domain), + unquote(ap_domain), + unquote(ap_id) + ) + end end end end