From f268c38a523e8d574fae2c53ffa429eca3731ec0 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 3 Feb 2026 10:32:43 +0400 Subject: [PATCH] fedbox: extract federation test helpers --- docker/federation/test_runner/Dockerfile | 2 + .../test_runner/lib/federation_box.ex | 943 ++++++++++++++++++ .../test_runner/test/federation_box_test.exs | 939 +---------------- 3 files changed, 948 insertions(+), 936 deletions(-) create mode 100644 docker/federation/test_runner/lib/federation_box.ex diff --git a/docker/federation/test_runner/Dockerfile b/docker/federation/test_runner/Dockerfile index 5295acf73d..b284345934 100644 --- a/docker/federation/test_runner/Dockerfile +++ b/docker/federation/test_runner/Dockerfile @@ -15,6 +15,8 @@ COPY mix.exs mix.lock ./ RUN mix deps.get --only test RUN mix deps.compile +COPY lib lib + COPY test test CMD ["mix", "test", "--color", "--trace"] diff --git a/docker/federation/test_runner/lib/federation_box.ex b/docker/federation/test_runner/lib/federation_box.ex new file mode 100644 index 0000000000..8e5753edbd --- /dev/null +++ b/docker/federation/test_runner/lib/federation_box.ex @@ -0,0 +1,943 @@ +defmodule FederationBox do + @moduledoc false + + @poll_interval_ms 1_000 + + def setup! do + scheme = fedtest_scheme() + password = System.get_env("FEDTEST_PASSWORD", "password") + + alice_handle = System.get_env("FEDTEST_PLEROMA1_HANDLE", "@alice@pleroma1.test") + bob_handle = System.get_env("FEDTEST_PLEROMA2_HANDLE", "@bob@pleroma2.test") + carol_handle = System.get_env("FEDTEST_MASTODON_HANDLE", "@carol@mastodon.test") + + %{username: alice_username, domain: alice_domain} = parse_handle!(alice_handle) + %{username: bob_username, domain: bob_domain} = parse_handle!(bob_handle) + %{username: carol_username, domain: carol_domain} = parse_handle!(carol_handle) + + pleroma1_base_url = "#{scheme}://#{alice_domain}" + pleroma2_base_url = "#{scheme}://#{bob_domain}" + mastodon_base_url = "#{scheme}://#{carol_domain}" + + scopes = "read write follow" + redirect_uri = "urn:ietf:wg:oauth:2.0:oob" + + alice_actor_id = + wait_until!( + fn -> webfinger_self_href(alice_username, alice_domain) end, + "webfinger ready #{alice_handle}" + ) + + bob_actor_id = + wait_until!( + fn -> webfinger_self_href(bob_username, bob_domain) end, + "webfinger ready #{bob_handle}" + ) + + carol_actor_id = + wait_until!( + fn -> webfinger_self_href(carol_username, carol_domain) end, + "webfinger ready #{carol_handle}" + ) + + alice_actor_id_variants = actor_id_variants(alice_actor_id) + bob_actor_id_variants = actor_id_variants(bob_actor_id) + carol_actor_id_variants = actor_id_variants(carol_actor_id) + + %{client_id: alice_client_id, client_secret: alice_client_secret} = + create_oauth_app!(pleroma1_base_url, scopes) + + alice_access_token = + password_grant_token!( + pleroma1_base_url, + alice_client_id, + alice_client_secret, + [alice_username, "#{alice_username}@#{alice_domain}"], + password, + scopes + ) + + %{client_id: bob_client_id, client_secret: bob_client_secret} = + create_oauth_app!(pleroma2_base_url, scopes) + + bob_access_token = + password_grant_token!( + pleroma2_base_url, + bob_client_id, + bob_client_secret, + [bob_username, "#{bob_username}@#{bob_domain}"], + password, + scopes + ) + + %{client_id: carol_client_id, client_secret: carol_client_secret} = + create_oauth_app!(mastodon_base_url, scopes) + + carol_session = mastodon_sign_in!(mastodon_base_url, "#{carol_username}@#{carol_domain}", password) + + carol_auth_code = + authorize_rails_oauth_app!( + mastodon_base_url, + carol_session, + carol_client_id, + redirect_uri, + scopes + ) + + carol_access_token = + exchange_auth_code!( + mastodon_base_url, + carol_client_id, + carol_client_secret, + redirect_uri, + carol_auth_code + ) + + + %{ + pleroma1_base_url: pleroma1_base_url, + pleroma2_base_url: pleroma2_base_url, + mastodon_base_url: mastodon_base_url, + alice_handle: alice_handle, + bob_handle: bob_handle, + carol_handle: carol_handle, + alice_access_token: alice_access_token, + bob_access_token: bob_access_token, + carol_access_token: carol_access_token, + alice_actor_id: alice_actor_id, + bob_actor_id: bob_actor_id, + alice_actor_id_variants: alice_actor_id_variants, + bob_actor_id_variants: bob_actor_id_variants, + carol_actor_id_variants: carol_actor_id_variants + } + end + + def follow_and_assert_remote_accept!(base_url, access_token, handle, local_actor_id_variants) + when is_binary(base_url) and is_binary(access_token) and is_binary(handle) and + is_list(local_actor_id_variants) do + _ = follow_remote!(base_url, access_token, handle) + + %{username: username, domain: domain} = parse_handle!(handle) + + remote_actor_id = + wait_until!( + fn -> webfinger_self_href(username, domain) end, + "actor #{handle}" + ) + + wait_until!( + fn -> + followers = fetch_follower_ids(remote_actor_id) + Enum.any?(followers, &(&1 in local_actor_id_variants)) + end, + "follow accepted local -> #{handle}" + ) + + :ok + end + + def follow_and_assert_local_accept!( + remote_base_url, + remote_access_token, + local_handle, + local_actor_id, + remote_actor_id_variants + ) + when is_binary(remote_base_url) and is_binary(remote_access_token) and + is_binary(local_handle) and + is_binary(local_actor_id) and is_list(remote_actor_id_variants) do + _ = follow_remote!(remote_base_url, remote_access_token, local_handle) + + wait_until!( + fn -> + followers = fetch_follower_ids(local_actor_id) + Enum.any?(followers, &(&1 in remote_actor_id_variants)) + end, + "follow accepted remote -> #{local_handle}" + ) + + :ok + end + + def unique_token do + System.unique_integer([:positive]) + |> Integer.to_string() + end + + def create_status!(base_url, access_token, text) + when is_binary(base_url) and is_binary(access_token) and is_binary(text) do + resp = + req_post!( + base_url <> "/api/v1/statuses", + headers: [{"authorization", "Bearer " <> access_token}], + form: [{"status", text}, {"visibility", "public"}] + ) + + ensure_json!(resp.body) + end + + def fetch_status!(base_url, access_token, status_id) + when is_binary(base_url) and is_binary(access_token) and is_binary(status_id) do + resp = + req_get!( + base_url <> "/api/v1/statuses/" <> status_id, + headers: [{"authorization", "Bearer " <> access_token}] + ) + + ensure_json!(resp.body) + end + + defp fetch_home_timeline!(base_url, access_token, opts) + when is_binary(base_url) and is_binary(access_token) and is_list(opts) do + limit = Keyword.get(opts, :limit, 20) + + resp = + req_get!( + base_url <> "/api/v1/timelines/home", + headers: [{"authorization", "Bearer " <> access_token}], + params: [{"limit", limit}] + ) + + ensure_json!(resp.body) + end + + def home_timeline_contains?(base_url, access_token, needle) + when is_binary(base_url) and is_binary(access_token) and is_binary(needle) do + statuses = fetch_home_timeline!(base_url, access_token, limit: 40) + + Enum.any?(statuses, fn status -> + content = Map.get(status, "content", "") + is_binary(content) and String.contains?(content, needle) + end) + end + + def favourite_status!(base_url, access_token, status_id) + when is_binary(base_url) and is_binary(access_token) and is_binary(status_id) do + _resp = + req_post!( + base_url <> "/api/v1/statuses/" <> status_id <> "/favourite", + headers: [{"authorization", "Bearer " <> access_token}] + ) + + :ok + end + + def reblog_status!(base_url, access_token, status_id) + when is_binary(base_url) and is_binary(access_token) and is_binary(status_id) do + resp = + req_post!( + base_url <> "/api/v1/statuses/" <> status_id <> "/reblog", + headers: [{"authorization", "Bearer " <> access_token}] + ) + + ensure_json!(resp.body) + end + + def delete_status!(base_url, access_token, status_id) + when is_binary(base_url) and is_binary(access_token) and is_binary(status_id) do + resp = + req_delete!( + base_url <> "/api/v1/statuses/" <> status_id, + headers: [{"authorization", "Bearer " <> access_token}] + ) + + if resp.status in 200..299 do + :ok + else + raise("unexpected status when deleting #{status_id}: #{resp.status}") + end + end + + def status_gone?(base_url, access_token, status_id) + when is_binary(base_url) and is_binary(access_token) and is_binary(status_id) do + resp = + req_get!( + base_url <> "/api/v1/statuses/" <> status_id, + headers: [{"authorization", "Bearer " <> access_token}] + ) + + resp.status in [404, 410] + end + + def find_status_by_uri_variant(base_url, access_token, uri_variants) + when is_binary(base_url) and is_binary(access_token) and is_list(uri_variants) do + statuses = fetch_home_timeline!(base_url, access_token, limit: 40) + + status = + Enum.find(statuses, fn status -> + status + |> status_uri() + |> case do + uri when is_binary(uri) -> uri in uri_variants + _ -> false + end + end) + + cond do + is_map(status) -> + {:ok, status} + + true -> + find_status_by_uri_variant_via_search(base_url, access_token, uri_variants) + end + end + + defp find_status_by_uri_variant_via_search(base_url, access_token, uri_variants) + when is_binary(base_url) and is_binary(access_token) and is_list(uri_variants) do + find_status_by_uri_variant_via_search_endpoint(base_url, access_token, uri_variants, :v2) || + find_status_by_uri_variant_via_search_endpoint(base_url, access_token, uri_variants, :v1) + end + + defp find_status_by_uri_variant_via_search_endpoint( + base_url, + access_token, + uri_variants, + version + ) + when is_binary(base_url) and is_binary(access_token) and is_list(uri_variants) and + version in [:v1, :v2] do + endpoint = + case version do + :v2 -> "/api/v2/search" + :v1 -> "/api/v1/search" + end + + Enum.find_value(uri_variants, fn query -> + params = + case version do + :v2 -> + [{"q", query}, {"type", "statuses"}, {"resolve", "true"}, {"limit", 10}] + + :v1 -> + [{"q", query}, {"resolve", "true"}, {"limit", 10}] + end + + resp = + req_get!( + base_url <> endpoint, + headers: [{"authorization", "Bearer " <> access_token}], + params: params + ) + + cond do + resp.status in 200..299 -> + body = ensure_json!(resp.body) + statuses = Map.get(body, "statuses", []) + + Enum.find_value(statuses, fn status -> + case status_uri(status) do + uri when is_binary(uri) -> + if Enum.member?(uri_variants, uri) do + {:ok, status} + else + nil + end + + _ -> + nil + end + end) + + true -> + nil + end + end) + end + + def status_uri(%{} = status) do + Map.get(status, "uri") || Map.get(status, "url") + end + + defp create_oauth_app!(base_url, scopes) when is_binary(base_url) and is_binary(scopes) do + resp = + req_post!( + base_url <> "/api/v1/apps", + form: [ + {"client_name", "fedbox"}, + {"redirect_uris", "urn:ietf:wg:oauth:2.0:oob"}, + {"scopes", scopes}, + {"website", ""} + ] + ) + + body = ensure_json!(resp.body) + + %{ + client_id: Map.fetch!(body, "client_id"), + client_secret: Map.fetch!(body, "client_secret") + } + end + + defp authorize_rails_oauth_app!(base_url, %{cookie_jar: jar}, client_id, redirect_uri, scopes) + when is_binary(base_url) and is_binary(client_id) and is_binary(redirect_uri) and + is_binary(scopes) do + query = + URI.encode_query(%{ + "client_id" => client_id, + "redirect_uri" => redirect_uri, + "response_type" => "code", + "scope" => scopes, + "state" => "" + }) + + {html, jar} = get_html!(base_url, "/oauth/authorize?" <> query, jar) + csrf_token = extract_csrf_token!(html) + + resp = + req_post!( + base_url <> "/oauth/authorize", + headers: cookie_headers(jar), + form: [ + {"authenticity_token", csrf_token}, + {"client_id", client_id}, + {"redirect_uri", redirect_uri}, + {"code_challenge", ""}, + {"code_challenge_method", ""}, + {"response_type", "code"}, + {"scope", scopes}, + {"state", ""} + ] + ) + + _jar = update_cookie_jar(jar, resp) + + cond do + resp.status in 300..399 -> + resp + |> Req.Response.get_header("location") + |> List.first() + |> case do + location when is_binary(location) and location != "" -> + location + |> URI.parse() + |> Map.get(:query, "") + |> URI.decode_query() + |> Map.get("code") + + _ -> + raise("oauth code not found") + end + + resp.status in 200..299 -> + extract_oauth_code!(to_string(resp.body)) + + true -> + raise("unexpected oauth authorize response: #{resp.status}") + end + end + + defp exchange_auth_code!(base_url, client_id, client_secret, redirect_uri, code) + when is_binary(base_url) and is_binary(client_id) and is_binary(client_secret) and + is_binary(redirect_uri) and is_binary(code) do + resp = + req_post!( + base_url <> "/oauth/token", + form: [ + {"grant_type", "authorization_code"}, + {"code", code}, + {"client_id", client_id}, + {"client_secret", client_secret}, + {"redirect_uri", redirect_uri} + ] + ) + + body = ensure_json!(resp.body) + Map.fetch!(body, "access_token") + end + + defp password_grant_token!( + base_url, + client_id, + client_secret, + usernames, + password, + scopes + ) + when is_binary(base_url) and is_binary(client_id) and is_binary(client_secret) and + is_list(usernames) and is_binary(password) and is_binary(scopes) do + usernames + |> Enum.find_value(fn username -> + resp = + req_post!( + base_url <> "/oauth/token", + form: [ + {"grant_type", "password"}, + {"username", username}, + {"password", password}, + {"client_id", client_id}, + {"client_secret", client_secret}, + {"scope", scopes} + ] + ) + + if resp.status in 200..299 do + body = ensure_json!(resp.body) + Map.fetch!(body, "access_token") + else + nil + end + end) + |> case do + token when is_binary(token) and token != "" -> + token + + _ -> + raise("failed to get password-grant token from #{base_url}") + end + end + + defp mastodon_sign_in!(base_url, email, password) + when is_binary(base_url) and is_binary(email) and is_binary(password) do + {html, jar} = get_html!(base_url, "/auth/sign_in", %{}) + csrf_token = extract_csrf_token!(html) + + {_, jar} = + post_rails_form!(base_url, "/auth/sign_in", jar, csrf_token, [ + {"user[email]", email}, + {"user[password]", password} + ]) + + %{cookie_jar: jar} + end + + defp webfinger_self_href(username, domain) when is_binary(username) and is_binary(domain) do + base_url = "#{fedtest_scheme()}://#{domain}" + + resp = + req_get!( + base_url <> "/.well-known/webfinger", + headers: [{"accept", "application/jrd+json"}], + params: [{"resource", "acct:#{username}@#{domain}"}] + ) + + cond do + resp.status in 200..299 -> + with {:ok, body} <- decode_json(resp.body), + links when is_list(links) <- Map.get(body, "links"), + %{} = link <- Enum.find(links, &(&1["rel"] == "self")), + href when is_binary(href) and href != "" <- link["href"] do + {:ok, href} + else + _ -> nil + end + + resp.status in 300..399 -> + case Req.Response.get_header(resp, "location") do + [location | _] -> + resp = + req_get!( + location, + headers: [{"accept", "application/jrd+json"}] + ) + + if resp.status in 200..299 do + with {:ok, body} <- decode_json(resp.body), + links when is_list(links) <- Map.get(body, "links"), + %{} = link <- Enum.find(links, &(&1["rel"] == "self")), + href when is_binary(href) and href != "" <- link["href"] do + {:ok, href} + else + _ -> nil + end + else + nil + end + + _ -> + nil + end + + true -> + nil + end + end + + defp fetch_follower_ids(remote_actor_id) when is_binary(remote_actor_id) do + remote_actor_id + |> fetch_ap_json!() + |> Map.get("followers") + |> case do + followers_url when is_binary(followers_url) and followers_url != "" -> + followers_url + |> fetch_collection_page_items!() + |> Enum.flat_map(&extract_id/1) + + _ -> + [] + end + end + + defp fetch_collection_page_items!(collection_url) when is_binary(collection_url) do + collection = fetch_ap_json!(collection_url) + + cond do + is_list(collection["orderedItems"]) -> + collection["orderedItems"] + + is_list(collection["items"]) -> + collection["items"] + + is_map(collection["first"]) -> + first = collection["first"] + Map.get(first, "orderedItems") || Map.get(first, "items") || [] + + is_binary(collection["first"]) -> + fetch_collection_page_items!(collection["first"]) + + true -> + [] + end + end + + defp fetch_ap_json!(url) when is_binary(url) do + resp = + req_get!( + url, + headers: [{"accept", "application/activity+json"}] + ) + + ensure_json!(resp.body) + end + + defp extract_id(id) when is_binary(id), do: [id] + defp extract_id(%{"id" => id}) when is_binary(id), do: [id] + defp extract_id(_), do: [] + + defp parse_handle!(handle) when is_binary(handle) do + handle + |> String.trim_leading("@") + |> String.split("@", parts: 2) + |> case do + [username, domain] when username != "" and domain != "" -> + %{username: username, domain: domain} + + _ -> + raise("invalid handle: #{inspect(handle)}") + end + end + + def actor_id_variants(actor_id) when is_binary(actor_id) do + actor_id = String.trim(actor_id) + + https_variant = + actor_id + |> URI.parse() + |> then(fn uri -> + case uri do + %URI{scheme: "http"} = uri -> URI.to_string(%URI{uri | scheme: "https", port: nil}) + _ -> actor_id + end + end) + + http_variant = + actor_id + |> URI.parse() + |> then(fn uri -> + case uri do + %URI{scheme: "https"} = uri -> URI.to_string(%URI{uri | scheme: "http", port: nil}) + _ -> actor_id + end + end) + + [actor_id, https_variant, http_variant] + |> Enum.uniq() + |> Enum.reject(&(&1 == "")) + end + + defp follow_remote!(base_url, access_token, handle) + when is_binary(base_url) and is_binary(access_token) and is_binary(handle) do + resp = + req_post!( + base_url <> "/api/v1/follows", + headers: [{"authorization", "Bearer " <> access_token}], + form: [{"uri", handle}] + ) + + cond do + resp.status in 200..299 -> + :ok + + resp.status in [404, 405] -> + follow_remote_via_lookup!(base_url, access_token, handle) + + true -> + raise("follow failed (POST /api/v1/follows): #{resp.status}") + end + end + + defp follow_remote_via_lookup!(base_url, access_token, handle) + when is_binary(base_url) and is_binary(access_token) and is_binary(handle) do + account_id = resolve_account_id!(base_url, access_token, handle) + + resp = + req_post!( + base_url <> "/api/v1/accounts/" <> account_id <> "/follow", + headers: [{"authorization", "Bearer " <> access_token}] + ) + + if resp.status in 200..299 do + :ok + else + raise("follow failed (POST /api/v1/accounts/:id/follow): #{resp.status}") + end + end + + defp resolve_account_id!(base_url, access_token, handle) + when is_binary(base_url) and is_binary(access_token) and is_binary(handle) do + acct = String.trim_leading(handle, "@") + + resp = + req_get!( + base_url <> "/api/v1/accounts/lookup", + headers: [{"authorization", "Bearer " <> access_token}], + params: [{"acct", acct}] + ) + + cond do + resp.status in 200..299 -> + resp.body + |> ensure_json!() + |> Map.fetch!("id") + + true -> + resolve_account_id_via_search!(base_url, access_token, handle) + end + end + + defp resolve_account_id_via_search!(base_url, access_token, handle) + when is_binary(base_url) and is_binary(access_token) and is_binary(handle) do + acct = String.trim_leading(handle, "@") + + resolve = + fn accounts -> + accounts + |> Enum.find(fn + %{"acct" => ^acct} -> true + _ -> false + end) + |> case do + %{"id" => id} when is_binary(id) and id != "" -> + {:ok, id} + + _ -> + accounts + |> List.first() + |> case do + %{"id" => id} when is_binary(id) and id != "" -> {:ok, id} + _ -> :error + end + end + end + + resp = + req_get!( + base_url <> "/api/v2/search", + headers: [{"authorization", "Bearer " <> access_token}], + params: [{"q", handle}, {"type", "accounts"}, {"resolve", "true"}, {"limit", 5}] + ) + + with status when status in 200..299 <- resp.status, + %{} = body <- ensure_json!(resp.body), + accounts when is_list(accounts) <- Map.get(body, "accounts"), + {:ok, account_id} <- resolve.(accounts) do + account_id + else + _ -> + resp = + req_get!( + base_url <> "/api/v1/accounts/search", + headers: [{"authorization", "Bearer " <> access_token}], + params: [{"q", handle}, {"resolve", "true"}, {"limit", 5}] + ) + + with status when status in 200..299 <- resp.status, + accounts when is_list(accounts) <- ensure_json!(resp.body), + {:ok, account_id} <- resolve.(accounts) do + account_id + else + _ -> + raise("failed to resolve remote account id for #{handle}") + end + end + end + + defp get_html!(base_url, path, jar) + when is_binary(base_url) and is_binary(path) and is_map(jar) do + resp = + req_get!( + base_url <> path, + headers: cookie_headers(jar) ++ [{"accept", "text/html"}] + ) + + jar = update_cookie_jar(jar, resp) + {to_string(resp.body), jar} + end + + defp post_rails_form!(base_url, path, jar, csrf_token, fields) + when is_binary(base_url) and is_binary(path) and is_map(jar) and is_binary(csrf_token) and + is_list(fields) do + resp = + req_post!( + base_url <> path, + headers: cookie_headers(jar), + form: [{"authenticity_token", csrf_token} | fields] + ) + + jar = update_cookie_jar(jar, resp) + {to_string(resp.body), jar} + end + + defp cookie_headers(jar) when is_map(jar) do + case jar do + jar when map_size(jar) == 0 -> + [] + + jar -> + cookie = + jar + |> Enum.map_join("; ", fn {name, value} -> "#{name}=#{value}" end) + + [{"cookie", cookie}] + end + end + + defp update_cookie_jar(jar, %Req.Response{} = resp) when is_map(jar) do + resp + |> Req.Response.get_header("set-cookie") + |> Enum.reduce(jar, fn set_cookie, jar -> + set_cookie + |> String.split(";", parts: 2) + |> List.first() + |> String.split("=", parts: 2) + |> case do + [name, value] when name != "" -> Map.put(jar, name, value) + _ -> jar + end + end) + end + + defp extract_csrf_token!(html) when is_binary(html) do + case Regex.run(~r/ token + _ -> raise("csrf token not found") + end + end + + defp extract_oauth_code!(html) when is_binary(html) do + with [_, code] <- + Regex.run( + ~r/]*class=[\"'][^\"']*oauth-code[^\"']*[\"'][^>]*value=[\"']([^\"']+)[\"']/i, + html + ) || + Regex.run( + ~r/]*value=[\"']([^\"']+)[\"'][^>]*class=[\"'][^\"']*oauth-code[^\"']*[\"']/i, + html + ) do + String.trim(code) + else + _ -> + case Regex.run(~r/Copy this code back into the client:.*?]*>([^<]+)<\/div>/s, html) do + [_, code] -> String.trim(code) + _ -> raise("oauth code not found") + end + end + end + + defp ensure_json!(%{} = body), do: body + defp ensure_json!(body) when is_list(body), do: body + defp ensure_json!(body) when is_binary(body), do: Jason.decode!(body) + + defp ensure_json!(body) do + body + |> to_string() + |> Jason.decode!() + end + + defp decode_json(%{} = body), do: {:ok, body} + defp decode_json(body) when is_binary(body), do: Jason.decode(body) + + defp decode_json(body) do + body + |> to_string() + |> Jason.decode() + end + + defp req_connect_options do + case System.get_env("FEDTEST_CACERTFILE", "") |> String.trim() do + "" -> + [] + + cacertfile -> + [ + connect_options: [ + transport_opts: [ + verify: :verify_peer, + cacertfile: cacertfile, + depth: 20, + reuse_sessions: false, + log_level: :warning, + customize_hostname_check: [ + match_fun: :public_key.pkix_verify_hostname_match_fun(:https) + ] + ] + ] + ] + end + end + + defp fedtest_scheme do + System.get_env("FEDTEST_SCHEME", "https") + |> to_string() + |> String.trim() + |> String.downcase() + |> case do + "http" -> "http" + "https" -> "https" + _ -> "https" + end + end + + defp req_default_opts(url) when is_binary(url) do + case URI.parse(url) do + %URI{scheme: "https"} -> req_connect_options() ++ [redirect: false] + _ -> [redirect: false] + end + end + + defp req_get!(url, opts) when is_binary(url) and is_list(opts) do + Req.get!(url, req_default_opts(url) ++ opts) + end + + defp req_post!(url, opts) when is_binary(url) and is_list(opts) do + Req.post!(url, req_default_opts(url) ++ opts) + end + + defp req_delete!(url, opts) when is_binary(url) and is_list(opts) do + Req.delete!(url, req_default_opts(url) ++ opts) + end + + def wait_until!(check_fun, label, timeout_ms \\ 120_000) + when is_function(check_fun, 0) and is_binary(label) and is_integer(timeout_ms) do + deadline = System.monotonic_time(:millisecond) + timeout_ms + do_wait_until(check_fun, label, deadline) + end + + defp do_wait_until(check_fun, label, deadline_ms) + when is_function(check_fun, 0) and is_binary(label) and is_integer(deadline_ms) do + case check_fun.() do + true -> + :ok + + {:ok, value} -> + value + + _ -> + if System.monotonic_time(:millisecond) > deadline_ms do + raise("timeout waiting for #{label}") + else + Process.sleep(@poll_interval_ms) + do_wait_until(check_fun, label, deadline_ms) + end + end + end +end diff --git a/docker/federation/test_runner/test/federation_box_test.exs b/docker/federation/test_runner/test/federation_box_test.exs index 5987b73033..d99392f93f 100644 --- a/docker/federation/test_runner/test/federation_box_test.exs +++ b/docker/federation/test_runner/test/federation_box_test.exs @@ -1,117 +1,12 @@ defmodule FederationBoxTest do use ExUnit.Case, async: false + import FederationBox + @moduletag timeout: 240_000 - @poll_interval_ms 1_000 - setup_all do - scheme = fedtest_scheme() - password = System.get_env("FEDTEST_PASSWORD", "password") - - alice_handle = System.get_env("FEDTEST_PLEROMA1_HANDLE", "@alice@pleroma1.test") - bob_handle = System.get_env("FEDTEST_PLEROMA2_HANDLE", "@bob@pleroma2.test") - carol_handle = System.get_env("FEDTEST_MASTODON_HANDLE", "@carol@mastodon.test") - - %{username: alice_username, domain: alice_domain} = parse_handle!(alice_handle) - %{username: bob_username, domain: bob_domain} = parse_handle!(bob_handle) - %{username: carol_username, domain: carol_domain} = parse_handle!(carol_handle) - - pleroma1_base_url = "#{scheme}://#{alice_domain}" - pleroma2_base_url = "#{scheme}://#{bob_domain}" - mastodon_base_url = "#{scheme}://#{carol_domain}" - - scopes = "read write follow" - redirect_uri = "urn:ietf:wg:oauth:2.0:oob" - - alice_actor_id = - wait_until!( - fn -> webfinger_self_href(alice_username, alice_domain) end, - "webfinger ready #{alice_handle}" - ) - - bob_actor_id = - wait_until!( - fn -> webfinger_self_href(bob_username, bob_domain) end, - "webfinger ready #{bob_handle}" - ) - - carol_actor_id = - wait_until!( - fn -> webfinger_self_href(carol_username, carol_domain) end, - "webfinger ready #{carol_handle}" - ) - - alice_actor_id_variants = actor_id_variants(alice_actor_id) - bob_actor_id_variants = actor_id_variants(bob_actor_id) - carol_actor_id_variants = actor_id_variants(carol_actor_id) - - %{client_id: alice_client_id, client_secret: alice_client_secret} = - create_oauth_app!(pleroma1_base_url, scopes) - - alice_access_token = - password_grant_token!( - pleroma1_base_url, - alice_client_id, - alice_client_secret, - [alice_username, "#{alice_username}@#{alice_domain}"], - password, - scopes - ) - - %{client_id: bob_client_id, client_secret: bob_client_secret} = - create_oauth_app!(pleroma2_base_url, scopes) - - bob_access_token = - password_grant_token!( - pleroma2_base_url, - bob_client_id, - bob_client_secret, - [bob_username, "#{bob_username}@#{bob_domain}"], - password, - scopes - ) - - %{client_id: carol_client_id, client_secret: carol_client_secret} = - create_oauth_app!(mastodon_base_url, scopes) - - carol_session = mastodon_sign_in!(mastodon_base_url, "#{carol_username}@#{carol_domain}", password) - - carol_auth_code = - authorize_rails_oauth_app!( - mastodon_base_url, - carol_session, - carol_client_id, - redirect_uri, - scopes - ) - - carol_access_token = - exchange_auth_code!( - mastodon_base_url, - carol_client_id, - carol_client_secret, - redirect_uri, - carol_auth_code - ) - - {:ok, - %{ - pleroma1_base_url: pleroma1_base_url, - pleroma2_base_url: pleroma2_base_url, - mastodon_base_url: mastodon_base_url, - alice_handle: alice_handle, - bob_handle: bob_handle, - carol_handle: carol_handle, - alice_access_token: alice_access_token, - bob_access_token: bob_access_token, - carol_access_token: carol_access_token, - alice_actor_id: alice_actor_id, - bob_actor_id: bob_actor_id, - alice_actor_id_variants: alice_actor_id_variants, - bob_actor_id_variants: bob_actor_id_variants, - carol_actor_id_variants: carol_actor_id_variants - }} + {:ok, FederationBox.setup!()} end test "outgoing follows are accepted (pleroma + mastodon)", ctx do @@ -680,832 +575,4 @@ defmodule FederationBoxTest do ) end - defp follow_and_assert_remote_accept!(base_url, access_token, handle, local_actor_id_variants) - when is_binary(base_url) and is_binary(access_token) and is_binary(handle) and - is_list(local_actor_id_variants) do - _ = follow_remote!(base_url, access_token, handle) - - %{username: username, domain: domain} = parse_handle!(handle) - - remote_actor_id = - wait_until!( - fn -> webfinger_self_href(username, domain) end, - "actor #{handle}" - ) - - wait_until!( - fn -> - followers = fetch_follower_ids(remote_actor_id) - Enum.any?(followers, &(&1 in local_actor_id_variants)) - end, - "follow accepted local -> #{handle}" - ) - - :ok - end - - defp follow_and_assert_local_accept!( - remote_base_url, - remote_access_token, - local_handle, - local_actor_id, - remote_actor_id_variants - ) - when is_binary(remote_base_url) and is_binary(remote_access_token) and - is_binary(local_handle) and - is_binary(local_actor_id) and is_list(remote_actor_id_variants) do - _ = follow_remote!(remote_base_url, remote_access_token, local_handle) - - wait_until!( - fn -> - followers = fetch_follower_ids(local_actor_id) - Enum.any?(followers, &(&1 in remote_actor_id_variants)) - end, - "follow accepted remote -> #{local_handle}" - ) - - :ok - end - - defp unique_token do - System.unique_integer([:positive]) - |> Integer.to_string() - end - - defp create_status!(base_url, access_token, text) - when is_binary(base_url) and is_binary(access_token) and is_binary(text) do - resp = - req_post!( - base_url <> "/api/v1/statuses", - headers: [{"authorization", "Bearer " <> access_token}], - form: [{"status", text}, {"visibility", "public"}] - ) - - ensure_json!(resp.body) - end - - defp fetch_status!(base_url, access_token, status_id) - when is_binary(base_url) and is_binary(access_token) and is_binary(status_id) do - resp = - req_get!( - base_url <> "/api/v1/statuses/" <> status_id, - headers: [{"authorization", "Bearer " <> access_token}] - ) - - ensure_json!(resp.body) - end - - defp fetch_home_timeline!(base_url, access_token, opts) - when is_binary(base_url) and is_binary(access_token) and is_list(opts) do - limit = Keyword.get(opts, :limit, 20) - - resp = - req_get!( - base_url <> "/api/v1/timelines/home", - headers: [{"authorization", "Bearer " <> access_token}], - params: [{"limit", limit}] - ) - - ensure_json!(resp.body) - end - - defp home_timeline_contains?(base_url, access_token, needle) - when is_binary(base_url) and is_binary(access_token) and is_binary(needle) do - statuses = fetch_home_timeline!(base_url, access_token, limit: 40) - - Enum.any?(statuses, fn status -> - content = Map.get(status, "content", "") - is_binary(content) and String.contains?(content, needle) - end) - end - - defp favourite_status!(base_url, access_token, status_id) - when is_binary(base_url) and is_binary(access_token) and is_binary(status_id) do - _resp = - req_post!( - base_url <> "/api/v1/statuses/" <> status_id <> "/favourite", - headers: [{"authorization", "Bearer " <> access_token}] - ) - - :ok - end - - defp reblog_status!(base_url, access_token, status_id) - when is_binary(base_url) and is_binary(access_token) and is_binary(status_id) do - resp = - req_post!( - base_url <> "/api/v1/statuses/" <> status_id <> "/reblog", - headers: [{"authorization", "Bearer " <> access_token}] - ) - - ensure_json!(resp.body) - end - - defp delete_status!(base_url, access_token, status_id) - when is_binary(base_url) and is_binary(access_token) and is_binary(status_id) do - resp = - req_delete!( - base_url <> "/api/v1/statuses/" <> status_id, - headers: [{"authorization", "Bearer " <> access_token}] - ) - - if resp.status in 200..299 do - :ok - else - raise("unexpected status when deleting #{status_id}: #{resp.status}") - end - end - - defp status_gone?(base_url, access_token, status_id) - when is_binary(base_url) and is_binary(access_token) and is_binary(status_id) do - resp = - req_get!( - base_url <> "/api/v1/statuses/" <> status_id, - headers: [{"authorization", "Bearer " <> access_token}] - ) - - resp.status in [404, 410] - end - - defp find_status_by_uri_variant(base_url, access_token, uri_variants) - when is_binary(base_url) and is_binary(access_token) and is_list(uri_variants) do - statuses = fetch_home_timeline!(base_url, access_token, limit: 40) - - status = - Enum.find(statuses, fn status -> - status - |> status_uri() - |> case do - uri when is_binary(uri) -> uri in uri_variants - _ -> false - end - end) - - cond do - is_map(status) -> - {:ok, status} - - true -> - find_status_by_uri_variant_via_search(base_url, access_token, uri_variants) - end - end - - defp find_status_by_uri_variant_via_search(base_url, access_token, uri_variants) - when is_binary(base_url) and is_binary(access_token) and is_list(uri_variants) do - find_status_by_uri_variant_via_search_endpoint(base_url, access_token, uri_variants, :v2) || - find_status_by_uri_variant_via_search_endpoint(base_url, access_token, uri_variants, :v1) - end - - defp find_status_by_uri_variant_via_search_endpoint( - base_url, - access_token, - uri_variants, - version - ) - when is_binary(base_url) and is_binary(access_token) and is_list(uri_variants) and - version in [:v1, :v2] do - endpoint = - case version do - :v2 -> "/api/v2/search" - :v1 -> "/api/v1/search" - end - - Enum.find_value(uri_variants, fn query -> - params = - case version do - :v2 -> - [{"q", query}, {"type", "statuses"}, {"resolve", "true"}, {"limit", 10}] - - :v1 -> - [{"q", query}, {"resolve", "true"}, {"limit", 10}] - end - - resp = - req_get!( - base_url <> endpoint, - headers: [{"authorization", "Bearer " <> access_token}], - params: params - ) - - cond do - resp.status in 200..299 -> - body = ensure_json!(resp.body) - statuses = Map.get(body, "statuses", []) - - Enum.find_value(statuses, fn status -> - case status_uri(status) do - uri when is_binary(uri) -> - if Enum.member?(uri_variants, uri) do - {:ok, status} - else - nil - end - - _ -> - nil - end - end) - - true -> - nil - end - end) - end - - defp status_uri(%{} = status) do - Map.get(status, "uri") || Map.get(status, "url") - end - - defp create_oauth_app!(base_url, scopes) when is_binary(base_url) and is_binary(scopes) do - resp = - req_post!( - base_url <> "/api/v1/apps", - form: [ - {"client_name", "fedbox"}, - {"redirect_uris", "urn:ietf:wg:oauth:2.0:oob"}, - {"scopes", scopes}, - {"website", ""} - ] - ) - - body = ensure_json!(resp.body) - - %{ - client_id: Map.fetch!(body, "client_id"), - client_secret: Map.fetch!(body, "client_secret") - } - end - - defp authorize_rails_oauth_app!(base_url, %{cookie_jar: jar}, client_id, redirect_uri, scopes) - when is_binary(base_url) and is_binary(client_id) and is_binary(redirect_uri) and - is_binary(scopes) do - query = - URI.encode_query(%{ - "client_id" => client_id, - "redirect_uri" => redirect_uri, - "response_type" => "code", - "scope" => scopes, - "state" => "" - }) - - {html, jar} = get_html!(base_url, "/oauth/authorize?" <> query, jar) - csrf_token = extract_csrf_token!(html) - - resp = - req_post!( - base_url <> "/oauth/authorize", - headers: cookie_headers(jar), - form: [ - {"authenticity_token", csrf_token}, - {"client_id", client_id}, - {"redirect_uri", redirect_uri}, - {"code_challenge", ""}, - {"code_challenge_method", ""}, - {"response_type", "code"}, - {"scope", scopes}, - {"state", ""} - ] - ) - - _jar = update_cookie_jar(jar, resp) - - cond do - resp.status in 300..399 -> - resp - |> Req.Response.get_header("location") - |> List.first() - |> case do - location when is_binary(location) and location != "" -> - location - |> URI.parse() - |> Map.get(:query, "") - |> URI.decode_query() - |> Map.get("code") - - _ -> - raise("oauth code not found") - end - - resp.status in 200..299 -> - extract_oauth_code!(to_string(resp.body)) - - true -> - raise("unexpected oauth authorize response: #{resp.status}") - end - end - - defp exchange_auth_code!(base_url, client_id, client_secret, redirect_uri, code) - when is_binary(base_url) and is_binary(client_id) and is_binary(client_secret) and - is_binary(redirect_uri) and is_binary(code) do - resp = - req_post!( - base_url <> "/oauth/token", - form: [ - {"grant_type", "authorization_code"}, - {"code", code}, - {"client_id", client_id}, - {"client_secret", client_secret}, - {"redirect_uri", redirect_uri} - ] - ) - - body = ensure_json!(resp.body) - Map.fetch!(body, "access_token") - end - - defp password_grant_token!( - base_url, - client_id, - client_secret, - usernames, - password, - scopes - ) - when is_binary(base_url) and is_binary(client_id) and is_binary(client_secret) and - is_list(usernames) and is_binary(password) and is_binary(scopes) do - usernames - |> Enum.find_value(fn username -> - resp = - req_post!( - base_url <> "/oauth/token", - form: [ - {"grant_type", "password"}, - {"username", username}, - {"password", password}, - {"client_id", client_id}, - {"client_secret", client_secret}, - {"scope", scopes} - ] - ) - - if resp.status in 200..299 do - body = ensure_json!(resp.body) - Map.fetch!(body, "access_token") - else - nil - end - end) - |> case do - token when is_binary(token) and token != "" -> - token - - _ -> - raise("failed to get password-grant token from #{base_url}") - end - end - - defp mastodon_sign_in!(base_url, email, password) - when is_binary(base_url) and is_binary(email) and is_binary(password) do - {html, jar} = get_html!(base_url, "/auth/sign_in", %{}) - csrf_token = extract_csrf_token!(html) - - {_, jar} = - post_rails_form!(base_url, "/auth/sign_in", jar, csrf_token, [ - {"user[email]", email}, - {"user[password]", password} - ]) - - %{cookie_jar: jar} - end - - defp webfinger_self_href(username, domain) when is_binary(username) and is_binary(domain) do - base_url = "#{fedtest_scheme()}://#{domain}" - - resp = - req_get!( - base_url <> "/.well-known/webfinger", - headers: [{"accept", "application/jrd+json"}], - params: [{"resource", "acct:#{username}@#{domain}"}] - ) - - cond do - resp.status in 200..299 -> - with {:ok, body} <- decode_json(resp.body), - links when is_list(links) <- Map.get(body, "links"), - %{} = link <- Enum.find(links, &(&1["rel"] == "self")), - href when is_binary(href) and href != "" <- link["href"] do - {:ok, href} - else - _ -> nil - end - - resp.status in 300..399 -> - case Req.Response.get_header(resp, "location") do - [location | _] -> - resp = - req_get!( - location, - headers: [{"accept", "application/jrd+json"}] - ) - - if resp.status in 200..299 do - with {:ok, body} <- decode_json(resp.body), - links when is_list(links) <- Map.get(body, "links"), - %{} = link <- Enum.find(links, &(&1["rel"] == "self")), - href when is_binary(href) and href != "" <- link["href"] do - {:ok, href} - else - _ -> nil - end - else - nil - end - - _ -> - nil - end - - true -> - nil - end - end - - defp fetch_follower_ids(remote_actor_id) when is_binary(remote_actor_id) do - remote_actor_id - |> fetch_ap_json!() - |> Map.get("followers") - |> case do - followers_url when is_binary(followers_url) and followers_url != "" -> - followers_url - |> fetch_collection_page_items!() - |> Enum.flat_map(&extract_id/1) - - _ -> - [] - end - end - - defp fetch_collection_page_items!(collection_url) when is_binary(collection_url) do - collection = fetch_ap_json!(collection_url) - - cond do - is_list(collection["orderedItems"]) -> - collection["orderedItems"] - - is_list(collection["items"]) -> - collection["items"] - - is_map(collection["first"]) -> - first = collection["first"] - Map.get(first, "orderedItems") || Map.get(first, "items") || [] - - is_binary(collection["first"]) -> - fetch_collection_page_items!(collection["first"]) - - true -> - [] - end - end - - defp fetch_ap_json!(url) when is_binary(url) do - resp = - req_get!( - url, - headers: [{"accept", "application/activity+json"}] - ) - - ensure_json!(resp.body) - end - - defp extract_id(id) when is_binary(id), do: [id] - defp extract_id(%{"id" => id}) when is_binary(id), do: [id] - defp extract_id(_), do: [] - - defp parse_handle!(handle) when is_binary(handle) do - handle - |> String.trim_leading("@") - |> String.split("@", parts: 2) - |> case do - [username, domain] when username != "" and domain != "" -> - %{username: username, domain: domain} - - _ -> - raise("invalid handle: #{inspect(handle)}") - end - end - - defp actor_id_variants(actor_id) when is_binary(actor_id) do - actor_id = String.trim(actor_id) - - https_variant = - actor_id - |> URI.parse() - |> then(fn uri -> - case uri do - %URI{scheme: "http"} = uri -> URI.to_string(%URI{uri | scheme: "https", port: nil}) - _ -> actor_id - end - end) - - http_variant = - actor_id - |> URI.parse() - |> then(fn uri -> - case uri do - %URI{scheme: "https"} = uri -> URI.to_string(%URI{uri | scheme: "http", port: nil}) - _ -> actor_id - end - end) - - [actor_id, https_variant, http_variant] - |> Enum.uniq() - |> Enum.reject(&(&1 == "")) - end - - defp follow_remote!(base_url, access_token, handle) - when is_binary(base_url) and is_binary(access_token) and is_binary(handle) do - resp = - req_post!( - base_url <> "/api/v1/follows", - headers: [{"authorization", "Bearer " <> access_token}], - form: [{"uri", handle}] - ) - - cond do - resp.status in 200..299 -> - :ok - - resp.status in [404, 405] -> - follow_remote_via_lookup!(base_url, access_token, handle) - - true -> - raise("follow failed (POST /api/v1/follows): #{resp.status}") - end - end - - defp follow_remote_via_lookup!(base_url, access_token, handle) - when is_binary(base_url) and is_binary(access_token) and is_binary(handle) do - account_id = resolve_account_id!(base_url, access_token, handle) - - resp = - req_post!( - base_url <> "/api/v1/accounts/" <> account_id <> "/follow", - headers: [{"authorization", "Bearer " <> access_token}] - ) - - if resp.status in 200..299 do - :ok - else - raise("follow failed (POST /api/v1/accounts/:id/follow): #{resp.status}") - end - end - - defp resolve_account_id!(base_url, access_token, handle) - when is_binary(base_url) and is_binary(access_token) and is_binary(handle) do - acct = String.trim_leading(handle, "@") - - resp = - req_get!( - base_url <> "/api/v1/accounts/lookup", - headers: [{"authorization", "Bearer " <> access_token}], - params: [{"acct", acct}] - ) - - cond do - resp.status in 200..299 -> - resp.body - |> ensure_json!() - |> Map.fetch!("id") - - true -> - resolve_account_id_via_search!(base_url, access_token, handle) - end - end - - defp resolve_account_id_via_search!(base_url, access_token, handle) - when is_binary(base_url) and is_binary(access_token) and is_binary(handle) do - acct = String.trim_leading(handle, "@") - - resolve = - fn accounts -> - accounts - |> Enum.find(fn - %{"acct" => ^acct} -> true - _ -> false - end) - |> case do - %{"id" => id} when is_binary(id) and id != "" -> - {:ok, id} - - _ -> - accounts - |> List.first() - |> case do - %{"id" => id} when is_binary(id) and id != "" -> {:ok, id} - _ -> :error - end - end - end - - resp = - req_get!( - base_url <> "/api/v2/search", - headers: [{"authorization", "Bearer " <> access_token}], - params: [{"q", handle}, {"type", "accounts"}, {"resolve", "true"}, {"limit", 5}] - ) - - with status when status in 200..299 <- resp.status, - %{} = body <- ensure_json!(resp.body), - accounts when is_list(accounts) <- Map.get(body, "accounts"), - {:ok, account_id} <- resolve.(accounts) do - account_id - else - _ -> - resp = - req_get!( - base_url <> "/api/v1/accounts/search", - headers: [{"authorization", "Bearer " <> access_token}], - params: [{"q", handle}, {"resolve", "true"}, {"limit", 5}] - ) - - with status when status in 200..299 <- resp.status, - accounts when is_list(accounts) <- ensure_json!(resp.body), - {:ok, account_id} <- resolve.(accounts) do - account_id - else - _ -> - raise("failed to resolve remote account id for #{handle}") - end - end - end - - defp get_html!(base_url, path, jar) - when is_binary(base_url) and is_binary(path) and is_map(jar) do - resp = - req_get!( - base_url <> path, - headers: cookie_headers(jar) ++ [{"accept", "text/html"}] - ) - - jar = update_cookie_jar(jar, resp) - {to_string(resp.body), jar} - end - - defp post_rails_form!(base_url, path, jar, csrf_token, fields) - when is_binary(base_url) and is_binary(path) and is_map(jar) and is_binary(csrf_token) and - is_list(fields) do - resp = - req_post!( - base_url <> path, - headers: cookie_headers(jar), - form: [{"authenticity_token", csrf_token} | fields] - ) - - jar = update_cookie_jar(jar, resp) - {to_string(resp.body), jar} - end - - defp cookie_headers(jar) when is_map(jar) do - case jar do - jar when map_size(jar) == 0 -> - [] - - jar -> - cookie = - jar - |> Enum.map_join("; ", fn {name, value} -> "#{name}=#{value}" end) - - [{"cookie", cookie}] - end - end - - defp update_cookie_jar(jar, %Req.Response{} = resp) when is_map(jar) do - resp - |> Req.Response.get_header("set-cookie") - |> Enum.reduce(jar, fn set_cookie, jar -> - set_cookie - |> String.split(";", parts: 2) - |> List.first() - |> String.split("=", parts: 2) - |> case do - [name, value] when name != "" -> Map.put(jar, name, value) - _ -> jar - end - end) - end - - defp extract_csrf_token!(html) when is_binary(html) do - case Regex.run(~r/ token - _ -> raise("csrf token not found") - end - end - - defp extract_oauth_code!(html) when is_binary(html) do - with [_, code] <- - Regex.run( - ~r/]*class=[\"'][^\"']*oauth-code[^\"']*[\"'][^>]*value=[\"']([^\"']+)[\"']/i, - html - ) || - Regex.run( - ~r/]*value=[\"']([^\"']+)[\"'][^>]*class=[\"'][^\"']*oauth-code[^\"']*[\"']/i, - html - ) do - String.trim(code) - else - _ -> - case Regex.run(~r/Copy this code back into the client:.*?]*>([^<]+)<\/div>/s, html) do - [_, code] -> String.trim(code) - _ -> raise("oauth code not found") - end - end - end - - defp ensure_json!(%{} = body), do: body - defp ensure_json!(body) when is_list(body), do: body - defp ensure_json!(body) when is_binary(body), do: Jason.decode!(body) - - defp ensure_json!(body) do - body - |> to_string() - |> Jason.decode!() - end - - defp decode_json(%{} = body), do: {:ok, body} - defp decode_json(body) when is_binary(body), do: Jason.decode(body) - - defp decode_json(body) do - body - |> to_string() - |> Jason.decode() - end - - defp req_connect_options do - case System.get_env("FEDTEST_CACERTFILE", "") |> String.trim() do - "" -> - [] - - cacertfile -> - [ - connect_options: [ - transport_opts: [ - verify: :verify_peer, - cacertfile: cacertfile, - depth: 20, - reuse_sessions: false, - log_level: :warning, - customize_hostname_check: [ - match_fun: :public_key.pkix_verify_hostname_match_fun(:https) - ] - ] - ] - ] - end - end - - defp fedtest_scheme do - System.get_env("FEDTEST_SCHEME", "https") - |> to_string() - |> String.trim() - |> String.downcase() - |> case do - "http" -> "http" - "https" -> "https" - _ -> "https" - end - end - - defp req_default_opts(url) when is_binary(url) do - case URI.parse(url) do - %URI{scheme: "https"} -> req_connect_options() ++ [redirect: false] - _ -> [redirect: false] - end - end - - defp req_get!(url, opts) when is_binary(url) and is_list(opts) do - Req.get!(url, req_default_opts(url) ++ opts) - end - - defp req_post!(url, opts) when is_binary(url) and is_list(opts) do - Req.post!(url, req_default_opts(url) ++ opts) - end - - defp req_delete!(url, opts) when is_binary(url) and is_list(opts) do - Req.delete!(url, req_default_opts(url) ++ opts) - end - - defp wait_until!(check_fun, label, timeout_ms \\ 120_000) - when is_function(check_fun, 0) and is_binary(label) and is_integer(timeout_ms) do - deadline = System.monotonic_time(:millisecond) + timeout_ms - do_wait_until(check_fun, label, deadline) - end - - defp do_wait_until(check_fun, label, deadline_ms) - when is_function(check_fun, 0) and is_binary(label) and is_integer(deadline_ms) do - case check_fun.() do - true -> - :ok - - {:ok, value} -> - value - - _ -> - if System.monotonic_time(:millisecond) > deadline_ms do - raise("timeout waiting for #{label}") - else - Process.sleep(@poll_interval_ms) - do_wait_until(check_fun, label, deadline_ms) - end - end - end end