Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-forge/tests/list.rs

//! **The listing, per forge, against a loopback double — no network.**
//!
//! Every test here runs the real `ureq` client against a real TCP socket
//! serving the real JSON shape out of the forge's own documentation. What is
//! faked is the *server*, not the HTTP: a trait seam with a fake client would
//! only prove this crate agrees with our beliefs about ureq, and the beliefs
//! are the part most likely to be wrong.
//!
//! The matrix each forge is put through is the same seven cases, because the
//! interesting differences between the three are all in these seven:
//!
//! | case | what it would catch |
//! |---|---|
//! | three pages | the pagination signal read wrong → a silently truncated list |
//! | an archived repository | a picker that cannot grey the read-only ones |
//! | a private repository | GitLab's `internal` shown as world-readable |
//! | an empty account | a "no next page" signal that loops or errors |
//! | 401 | a bad token reported as retryable, and a token echoed in the message |
//! | 403 + `Retry-After` | a rate limit reported as a dead token |
//! | a self-hosted base URL | a constant host baked into the driver |

mod common;

use std::time::Duration;

use common::{gitea_page, github_page, gitlab_page, link_next, Row};
use gunnar_forge::testing::MockServer;
use gunnar_forge::{Forge, ForgeError, ForgeKind};

/// A token that is trivially greppable, used by every test here so that
/// `token_never_leaks.rs` can assert on the same string.
const TOKEN: &str = "ghp_TOTALLYSECRETVALUE0000000000000000";

const GH_PATH: &str = "/user/repos";
const CB_PATH: &str = "/api/v1/user/repos";
const GL_PATH: &str = "/api/v4/projects";

// ── GitHub ───────────────────────────────────────────────────────────────────

#[test]
fn github_walks_three_pages_via_the_link_header() {
    let s = MockServer::start();
    let base = s.url();
    // Pages 1 and 2 carry `rel="next"`; page 3 carries only `first`/`prev`,
    // which is the ONLY way GitHub says a list is finished.
    s.on(
        "GET",
        GH_PATH,
        200,
        &[("Link", &link_next(&base, 2))],
        &github_page(&[Row::public("a"), Row::public("b")]),
    );
    s.on(
        "GET",
        GH_PATH,
        200,
        &[("Link", &link_next(&base, 3))],
        &github_page(&[Row::public("c")]),
    );
    s.on(
        "GET",
        GH_PATH,
        200,
        &[("Link", &format!("<{base}/user/repos?page=1>; rel=\"prev\""))],
        &github_page(&[Row::public("d")]),
    );

    let repos = Forge::github_enterprise(&base).list_repos(TOKEN).unwrap();

    let names: Vec<&str> = repos.iter().map(|r| r.full_name.as_str()).collect();
    assert_eq!(
        names,
        ["octocat/a", "octocat/b", "octocat/c", "octocat/d"],
        "all three pages, in order"
    );
    assert_eq!(s.count("GET", GH_PATH), 3, "exactly three requests");

    let seen = s.recorded();
    let asked: Vec<Option<&str>> = seen.iter().map(|r| r.param("page")).collect();
    assert_eq!(
        asked,
        [Some("1"), Some("2"), Some("3")],
        "the page number comes from the Link header, not from a counter"
    );
    assert!(repos.iter().all(|r| r.forge == ForgeKind::GitHub));
}

#[test]
fn github_carries_archived_private_and_the_two_clone_urls() {
    let s = MockServer::start();
    s.on_body(
        "GET",
        GH_PATH,
        200,
        &github_page(&[
            Row::public("open"),
            Row::archived("frozen"),
            Row::private("secret"),
        ]),
    );

    let repos = Forge::github_enterprise(s.url()).list_repos(TOKEN).unwrap();
    assert_eq!(repos.len(), 3);

    assert!(!repos[0].archived && !repos[0].private);
    assert!(repos[1].archived, "an archived repository must say so");
    assert!(repos[2].private, "a private repository must say so");

    // Both URLs, because the mirror picks one AFTER the list: a deploy key
    // needs the ssh form, a token needs the https one.
    assert_eq!(
        repos[0].clone_url_https,
        "https://github.com/octocat/open.git"
    );
    assert_eq!(repos[0].clone_url_ssh, "git@github.com:octocat/open.git");
    assert_eq!(repos[0].default_branch, "master");
    assert_eq!(repos[0].size_kib, Some(108), "GitHub reports KiB directly");
    assert_eq!(
        repos[0].description.as_deref(),
        Some("This your first repo!")
    );
}

#[test]
fn github_empty_account_is_an_empty_list_and_one_request() {
    let s = MockServer::start();
    s.on_body("GET", GH_PATH, 200, "[]");
    let repos = Forge::github_enterprise(s.url()).list_repos(TOKEN).unwrap();
    assert!(
        repos.is_empty(),
        "an account with no repositories is Ok(vec![])"
    );
    assert_eq!(s.count("GET", GH_PATH), 1, "and costs exactly one request");
}

#[test]
fn github_401_names_the_forge_and_is_not_retryable() {
    let s = MockServer::start();
    s.on_body(
        "GET",
        GH_PATH,
        401,
        r#"{"message":"Bad credentials","documentation_url":"https://docs.github.com/rest"}"#,
    );
    let err = Forge::github_enterprise(s.url())
        .list_repos(TOKEN)
        .unwrap_err();

    assert!(matches!(err, ForgeError::Unauthorized { forge } if forge == ForgeKind::GitHub));
    assert_eq!(err.forge(), ForgeKind::GitHub);
    assert_eq!(err.status(), Some(401));
    assert!(
        !err.is_retryable(),
        "retrying a rejected token forever is how an account gets locked"
    );
    assert!(err.to_string().contains("github"));
}

#[test]
fn github_403_with_retry_after_is_a_rate_limit_and_not_a_dead_token() {
    let s = MockServer::start();
    // GitHub's SECONDARY rate limit: 403, not 429, with a Retry-After in
    // seconds. Reporting this as Unauthorized would send a caller off to
    // re-issue a token that was never the problem.
    s.on(
        "GET",
        GH_PATH,
        403,
        &[
            ("Retry-After", "60"),
            ("x-ratelimit-remaining", "0"),
            ("x-ratelimit-reset", "1700000000"),
        ],
        r#"{"message":"You have exceeded a secondary rate limit."}"#,
    );
    let err = Forge::github_enterprise(s.url())
        .list_repos(TOKEN)
        .unwrap_err();

    match &err {
        ForgeError::RateLimited {
            forge,
            status,
            retry_after,
        } => {
            assert_eq!(*forge, ForgeKind::GitHub);
            assert_eq!(*status, 403);
            assert_eq!(*retry_after, Some(Duration::from_secs(60)));
        }
        other => panic!("expected a rate limit, got {other:?}"),
    }
    assert!(err.is_retryable());
    assert!(err.to_string().contains("60s"), "{err}");
}

#[test]
fn github_403_without_a_rate_limit_signal_stays_a_plain_http_error() {
    let s = MockServer::start();
    // The OTHER 403: a token whose scopes do not cover the endpoint. No
    // Retry-After, and `x-ratelimit-remaining` is NOT zero. Calling this
    // retryable would make a caller wait out a permission problem forever.
    s.on(
        "GET",
        GH_PATH,
        403,
        &[("x-ratelimit-remaining", "4999")],
        r#"{"message":"Resource not accessible by personal access token"}"#,
    );
    let err = Forge::github_enterprise(s.url())
        .list_repos(TOKEN)
        .unwrap_err();
    assert!(
        matches!(err, ForgeError::Http { status: 403, .. }),
        "got {err:?}"
    );
    assert!(!err.is_retryable());
}

#[test]
fn github_sends_a_bearer_token_and_pins_the_api_version() {
    let s = MockServer::start();
    s.on_body("GET", GH_PATH, 200, "[]");
    Forge::github_enterprise(s.url()).list_repos(TOKEN).unwrap();

    let r = &s.recorded()[0];
    assert_eq!(r.header("authorization"), Some(&*format!("Bearer {TOKEN}")));
    assert_eq!(r.header("x-github-api-version"), Some("2022-11-28"));
    assert_eq!(r.header("accept"), Some("application/vnd.github+json"));
    // `api.github.com` answers 403 to a request with no User-Agent, which would
    // surface as a rate limit against a limit that was never hit.
    assert!(
        r.header("user-agent")
            .is_some_and(|ua| ua.starts_with("gunnar-forge/")),
        "a User-Agent is required by GitHub, not optional"
    );
}

// ── Codeberg / Forgejo ───────────────────────────────────────────────────────

#[test]
fn codeberg_walks_three_pages_via_x_total_count() {
    let s = MockServer::start();
    for page in 1..=3 {
        s.on(
            "GET",
            CB_PATH,
            200,
            &[("X-Total-Count", "6")],
            &gitea_page(
                "codeberg.org",
                &[
                    Row::public(["a", "c", "e"][page - 1]),
                    Row::public(["b", "d", "f"][page - 1]),
                ],
            ),
        );
    }
    let repos = Forge::forgejo(s.url()).list_repos(TOKEN).unwrap();

    assert_eq!(repos.len(), 6);
    assert_eq!(s.count("GET", CB_PATH), 3);
    let seen = s.recorded();
    assert_eq!(
        seen.iter().map(|r| r.param("page")).collect::<Vec<_>>(),
        [Some("1"), Some("2"), Some("3")]
    );
    // Gitea's page-size parameter is `limit`, not `per_page`. Getting this
    // wrong is invisible — Gitea ignores unknown parameters and serves its
    // default of 30, so the list is still correct and three times slower.
    assert_eq!(s.recorded()[0].param("limit"), Some("100"));
    assert!(repos.iter().all(|r| r.forge == ForgeKind::Codeberg));
}

#[test]
fn codeberg_without_the_count_header_pages_until_an_empty_page() {
    let s = MockServer::start();
    // A reverse proxy that strips `X-` headers, or an old Gitea. The fallback
    // must NOT be "stop when a page is shorter than requested": Gitea clamps
    // `limit` to its own maximum without saying so, so a short page is the
    // normal case and stopping there truncates the account silently.
    s.on_body(
        "GET",
        CB_PATH,
        200,
        &gitea_page("codeberg.org", &[Row::public("a")]),
    );
    s.on_body(
        "GET",
        CB_PATH,
        200,
        &gitea_page("codeberg.org", &[Row::public("b")]),
    );
    s.on_body("GET", CB_PATH, 200, "[]");

    let repos = Forge::forgejo(s.url()).list_repos(TOKEN).unwrap();
    assert_eq!(repos.len(), 2, "both short pages, not just the first");
    assert_eq!(s.count("GET", CB_PATH), 3, "one extra request is the price");
}

#[test]
fn codeberg_carries_archived_private_size_and_both_urls() {
    let s = MockServer::start();
    s.on(
        "GET",
        CB_PATH,
        200,
        &[("X-Total-Count", "3")],
        &gitea_page(
            "codeberg.org",
            &[
                Row::public("open"),
                Row::archived("frozen"),
                Row::private("secret"),
            ],
        ),
    );
    let repos = Forge::forgejo(s.url()).list_repos(TOKEN).unwrap();
    assert!(repos[1].archived);
    assert!(repos[2].private);
    assert_eq!(repos[0].default_branch, "main");
    assert_eq!(
        repos[0].size_kib,
        Some(4096),
        "Gitea reports KiB, like GitHub"
    );
    assert_eq!(
        repos[0].clone_url_https,
        "https://codeberg.org/alice/open.git"
    );
    assert_eq!(repos[0].clone_url_ssh, "git@codeberg.org:alice/open.git");
}

#[test]
fn codeberg_empty_account_is_one_request() {
    let s = MockServer::start();
    s.on("GET", CB_PATH, 200, &[("X-Total-Count", "0")], "[]");
    let repos = Forge::forgejo(s.url()).list_repos(TOKEN).unwrap();
    assert!(repos.is_empty());
    assert_eq!(s.count("GET", CB_PATH), 1);
}

#[test]
fn codeberg_401_names_codeberg_and_uses_the_token_scheme() {
    let s = MockServer::start();
    s.on_body(
        "GET",
        CB_PATH,
        401,
        r#"{"message":"token does not exist","url":"https://codeberg.org/api/swagger"}"#,
    );
    let err = Forge::forgejo(s.url()).list_repos(TOKEN).unwrap_err();
    assert!(matches!(err, ForgeError::Unauthorized { forge } if forge == ForgeKind::Codeberg));
    assert!(err.to_string().contains("codeberg"));
    // Gitea's own scheme. `Bearer` also works on current Forgejo and does not
    // on the older instances a self-hosted base URL most often points at.
    assert_eq!(
        s.recorded()[0].header("authorization"),
        Some(&*format!("token {TOKEN}"))
    );
}

#[test]
fn codeberg_429_is_a_rate_limit() {
    let s = MockServer::start();
    // Forgejo answers 429, not GitHub's 403.
    s.on(
        "GET",
        CB_PATH,
        429,
        &[("Retry-After", "30")],
        r#"{"message":"too many requests"}"#,
    );
    let err = Forge::forgejo(s.url()).list_repos(TOKEN).unwrap_err();
    assert!(matches!(
        err,
        ForgeError::RateLimited {
            status: 429,
            retry_after: Some(d),
            ..
        } if d == Duration::from_secs(30)
    ));
    assert!(err.is_retryable());
}

#[test]
fn a_self_hosted_forgejo_keeps_its_own_host_in_the_clone_urls() {
    let s = MockServer::start();
    // The base URL is the double; the JSON says `git.example.com`, which is
    // what a real self-hosted instance behind a proxy looks like. The clone
    // URLs must come from the FORGE, not be reconstructed from our base — a
    // driver that rebuilt them would hand the mirror an unreachable host.
    s.on(
        "GET",
        CB_PATH,
        200,
        &[("X-Total-Count", "1")],
        &gitea_page("git.example.com", &[Row::public("internal-tool")]),
    );
    let repos = Forge::forgejo(s.url()).list_repos(TOKEN).unwrap();

    assert_eq!(repos[0].full_name, "alice/internal-tool");
    assert_eq!(
        repos[0].clone_url_https,
        "https://git.example.com/alice/internal-tool.git"
    );
    assert_eq!(
        repos[0].clone_url_ssh,
        "git@git.example.com:alice/internal-tool.git"
    );
    assert_eq!(repos[0].forge, ForgeKind::Codeberg);
    // And the request went to the base we were given, with the /api/v1 prefix
    // the driver adds — the caller passes a site root, not an API root.
    assert_eq!(s.recorded()[0].path, CB_PATH);
}

// ── GitLab ───────────────────────────────────────────────────────────────────

#[test]
fn gitlab_walks_three_pages_via_x_next_page() {
    let s = MockServer::start();
    s.on(
        "GET",
        GL_PATH,
        200,
        &[("X-Next-Page", "2"), ("X-Total-Pages", "3")],
        &gitlab_page(&[Row::public("a")]),
    );
    s.on(
        "GET",
        GL_PATH,
        200,
        &[("X-Next-Page", "3"), ("X-Total-Pages", "3")],
        &gitlab_page(&[Row::public("b")]),
    );
    // The LAST page sends the header with an EMPTY value, not no header at
    // all. A client testing for presence would loop here forever.
    s.on(
        "GET",
        GL_PATH,
        200,
        &[("X-Next-Page", ""), ("X-Total-Pages", "3")],
        &gitlab_page(&[Row::public("c")]),
    );

    let repos = Forge::gitlab_at(s.url()).list_repos(TOKEN).unwrap();
    assert_eq!(repos.len(), 3);
    assert_eq!(s.count("GET", GL_PATH), 3);
    assert!(repos.iter().all(|r| r.forge == ForgeKind::GitLab));
    // `membership=true` is the whole reason this is not a listing of every
    // public project on gitlab.com.
    assert_eq!(s.recorded()[0].param("membership"), Some("true"));
}

#[test]
fn gitlab_internal_visibility_counts_as_private() {
    let s = MockServer::start();
    s.on(
        "GET",
        GL_PATH,
        200,
        &[("X-Next-Page", "")],
        &gitlab_page(&[
            Row::public("open"),
            Row::private("internal-only"),
            Row::archived("frozen"),
        ]),
    );
    let repos = Forge::gitlab_at(s.url()).list_repos(TOKEN).unwrap();

    assert!(!repos[0].private, "visibility=public is public");
    assert!(
        repos[1].private,
        "visibility=internal is NOT world-readable and must not be shown as public"
    );
    assert!(repos[2].archived);
    assert_eq!(
        repos[0].full_name, "diaspora/open",
        "path_with_namespace, kept whole"
    );
    assert_eq!(
        repos[0].clone_url_https,
        "https://gitlab.example.com/diaspora/open.git"
    );
    assert_eq!(
        repos[0].clone_url_ssh,
        "git@gitlab.example.com:diaspora/open.git"
    );
    // GitLab only reports a size to an instance administrator asking for
    // `statistics=true`, so this is honestly absent rather than a zero.
    assert_eq!(repos[0].size_kib, None);
}

#[test]
fn gitlab_empty_account_is_one_request() {
    let s = MockServer::start();
    s.on("GET", GL_PATH, 200, &[("X-Next-Page", "")], "[]");
    let repos = Forge::gitlab_at(s.url()).list_repos(TOKEN).unwrap();
    assert!(repos.is_empty());
    assert_eq!(s.count("GET", GL_PATH), 1);
}

#[test]
fn gitlab_401_names_gitlab_and_sends_a_bearer_token() {
    let s = MockServer::start();
    s.on_body("GET", GL_PATH, 401, r#"{"message":"401 Unauthorized"}"#);
    let err = Forge::gitlab_at(s.url()).list_repos(TOKEN).unwrap_err();
    assert!(matches!(err, ForgeError::Unauthorized { forge } if forge == ForgeKind::GitLab));
    assert!(err.to_string().contains("gitlab"));
    // Bearer and not PRIVATE-TOKEN: PRIVATE-TOKEN rejects an OAuth access
    // token, which is what the UI half will hand us.
    assert_eq!(
        s.recorded()[0].header("authorization"),
        Some(&*format!("Bearer {TOKEN}"))
    );
    assert!(
        s.recorded()[0].header("private-token").is_none(),
        "the token must be in ONE header, not two"
    );
}

#[test]
fn gitlab_429_carries_retry_after() {
    let s = MockServer::start();
    s.on(
        "GET",
        GL_PATH,
        429,
        &[("Retry-After", "120"), ("RateLimit-Remaining", "0")],
        r#"{"message":"Retry later"}"#,
    );
    let err = Forge::gitlab_at(s.url()).list_repos(TOKEN).unwrap_err();
    assert!(matches!(
        err,
        ForgeError::RateLimited { retry_after: Some(d), .. } if d == Duration::from_secs(120)
    ));
}

#[test]
fn a_self_managed_gitlab_gets_the_api_v4_prefix_added_to_the_site_root() {
    let s = MockServer::start();
    s.on(
        "GET",
        GL_PATH,
        200,
        &[("X-Next-Page", "")],
        &gitlab_page(&[Row::public("x")]),
    );
    let repos = Forge::gitlab_at(s.url()).list_repos(TOKEN).unwrap();
    assert_eq!(repos.len(), 1);
    assert_eq!(s.recorded()[0].path, GL_PATH);
}

#[test]
fn codeberg_and_a_self_hosted_forgejo_are_one_driver_behind_two_constructors() {
    // The ONLY difference between them is the base URL. If these ever became
    // two implementations, the day one of them learned about a renamed Gitea
    // field the other would not.
    assert_eq!(Forge::codeberg().kind(), ForgeKind::Codeberg);
    assert_eq!(
        Forge::forgejo("https://git.example.com").kind(),
        ForgeKind::Codeberg
    );
    assert_eq!(Forge::github().kind(), ForgeKind::GitHub);
    assert_eq!(
        Forge::github_enterprise("https://gh.example.com/api/v3").kind(),
        ForgeKind::GitHub
    );
    assert_eq!(Forge::gitlab().kind(), ForgeKind::GitLab);
    assert_eq!(
        Forge::gitlab_at("https://gl.example.com").kind(),
        ForgeKind::GitLab
    );
}

// ── the loop's own guard rails ───────────────────────────────────────────────

#[test]
fn a_forge_that_repeats_the_same_next_page_stops_instead_of_spinning() {
    let s = MockServer::start();
    // One canned answer, so every page says "next is page 2" forever — which is
    // what a proxy that ignores the `page` parameter produces. The engine's
    // rule is STRICTLY GREATER, so this costs one wasted request and stops.
    //
    // Written first as a `max_pages` test and seen red, which is how the two
    // guard rails turned out to be two different properties:
    //
    //   called `Result::unwrap_err()` on an `Ok` value: [Repo { … }, Repo { … }]
    //
    // Two repos, not four: the monotonicity check had already ended the loop
    // before the cap was anywhere near. Both are asserted now, separately.
    s.on(
        "GET",
        GL_PATH,
        200,
        &[("X-Next-Page", "2")],
        &gitlab_page(&[Row::public("a")]),
    );
    let repos = Forge::gitlab_at(s.url()).list_repos(TOKEN).unwrap();
    assert_eq!(repos.len(), 2, "page 1, page 2, and then it noticed");
    assert_eq!(s.count("GET", GL_PATH), 2);
}

#[test]
fn a_forge_that_paginates_forever_is_refused_at_the_cap() {
    let s = MockServer::start();
    // Now the header really does advance: 2, 3, 4, 5, 6, … A forge behaving
    // like this is unbounded work while holding a live credential in memory,
    // so the cap — not the monotonicity check — has to be the thing that stops
    // it.
    for next in 2..=6 {
        s.on(
            "GET",
            GL_PATH,
            200,
            &[("X-Next-Page", &next.to_string())],
            &gitlab_page(&[Row::public("a")]),
        );
    }
    let forge = Forge::gitlab_at(s.url()).with_options(gunnar_forge::ListOptions {
        max_pages: 4,
        ..Default::default()
    });
    let err = forge.list_repos(TOKEN).unwrap_err();
    assert!(
        matches!(err, ForgeError::TooManyPages { pages: 4, .. }),
        "got {err:?}"
    );
    assert_eq!(s.count("GET", GL_PATH), 4, "and it really did stop at four");
}

#[test]
fn a_forge_that_answers_2xx_with_rubbish_is_malformed_and_names_no_value() {
    let s = MockServer::start();
    s.on_body("GET", GH_PATH, 200, r#"{"message":"not an array"}"#);
    let err = Forge::github_enterprise(s.url())
        .list_repos(TOKEN)
        .unwrap_err();
    assert!(matches!(err, ForgeError::Malformed { .. }), "got {err:?}");
    assert!(err.to_string().contains("array of repositories"));
    assert!(!err.is_retryable());
}

#[test]
fn a_row_missing_its_clone_urls_is_skipped_rather_than_failing_the_page() {
    let s = MockServer::start();
    // One broken row in an account of many must not empty the picker.
    s.on_body(
        "GET",
        GH_PATH,
        200,
        r#"[{"full_name":"a/b"},{"full_name":"c/d","clone_url":"https://h/c/d.git","ssh_url":"git@h:c/d.git","default_branch":"main"}]"#,
    );
    let repos = Forge::github_enterprise(s.url()).list_repos(TOKEN).unwrap();
    assert_eq!(repos.len(), 1);
    assert_eq!(repos[0].full_name, "c/d");
    // The optional fields degrade instead of failing: this is the whole reason
    // the parser reads a `Value` rather than deriving a struct.
    assert_eq!(repos[0].size_kib, None);
    assert_eq!(repos[0].description, None);
    assert!(!repos[0].archived);
}

#[test]
fn a_repository_with_no_commits_has_an_empty_default_branch_and_is_still_listed() {
    let s = MockServer::start();
    s.on_body(
        "GET",
        CB_PATH,
        200,
        r#"[{"full_name":"r/new","clone_url":"https://h/r/new.git","ssh_url":"git@h:r/new.git","default_branch":"","empty":true,"description":null}]"#,
    );
    s.on_body("GET", CB_PATH, 200, "[]");
    let repos = Forge::forgejo(s.url()).list_repos(TOKEN).unwrap();
    assert_eq!(repos.len(), 1);
    assert_eq!(repos[0].default_branch, "");
    assert_eq!(repos[0].description, None, "null and \"\" both mean absent");
}

#[test]
fn an_unreachable_forge_is_a_transport_error_naming_no_url() {
    // Port 1 on loopback: nothing listens, and the connect fails immediately
    // rather than after a timeout, so this test is fast and has no network.
    let err = Forge::github_enterprise("http://127.0.0.1:1")
        .list_repos(TOKEN)
        .unwrap_err();
    assert!(matches!(err, ForgeError::Transport { .. }), "got {err:?}");
    assert!(err.is_retryable());
    assert_eq!(err.status(), None);
    assert!(
        !err.to_string().contains("127.0.0.1"),
        "an error must not carry the request: {err}"
    );
}