1
0
Fork 0
mirror of https://git.pleroma.social/pleroma/pleroma.git synced 2026-02-15 17:16:57 +00:00

fedbox: extract federation test helpers

This commit is contained in:
Lain Soykaf 2026-02-03 10:32:43 +04:00
parent b0d84f376c
commit f268c38a52
3 changed files with 948 additions and 936 deletions

View file

@ -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"]

View file

@ -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/<meta\s+name=\"csrf-token\"\s+content=\"([^\"]+)\"/i, html) do
[_, token] -> token
_ -> raise("csrf token not found")
end
end
defp extract_oauth_code!(html) when is_binary(html) do
with [_, code] <-
Regex.run(
~r/<input[^>]*class=[\"'][^\"']*oauth-code[^\"']*[\"'][^>]*value=[\"']([^\"']+)[\"']/i,
html
) ||
Regex.run(
~r/<input[^>]*value=[\"']([^\"']+)[\"'][^>]*class=[\"'][^\"']*oauth-code[^\"']*[\"']/i,
html
) do
String.trim(code)
else
_ ->
case Regex.run(~r/Copy this code back into the client:.*?<div[^>]*>([^<]+)<\/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

View file

@ -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/<meta\s+name=\"csrf-token\"\s+content=\"([^\"]+)\"/i, html) do
[_, token] -> token
_ -> raise("csrf token not found")
end
end
defp extract_oauth_code!(html) when is_binary(html) do
with [_, code] <-
Regex.run(
~r/<input[^>]*class=[\"'][^\"']*oauth-code[^\"']*[\"'][^>]*value=[\"']([^\"']+)[\"']/i,
html
) ||
Regex.run(
~r/<input[^>]*value=[\"']([^\"']+)[\"'][^>]*class=[\"'][^\"']*oauth-code[^\"']*[\"']/i,
html
) do
String.trim(code)
else
_ ->
case Regex.run(~r/Copy this code back into the client:.*?<div[^>]*>([^<]+)<\/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