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}"
);
}