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

nordisk/gunnar-ui

crates/gunnar-ui-server/src/anonymous/throttled.rs

//! **Every request passes the one throttle** (GUN-GUEST, owner 2026-10-30): the console's own
//! router, as `serve::router` builds it, in whatever build this test binary is.

use std::net::SocketAddr;

use tower::ServiceExt;

use super::*;
use crate::throttle::tests::{next_router, wide};
use crate::throttle::{Budget, Limits};

thread_local! {
    /// The front routes the next fixture console on this thread is served around.
    pub(super) static FRONT: std::cell::RefCell<Option<axum::Router>> = const { std::cell::RefCell::new(None) };
}

/// One request to the console from `peer`, with a session cookie when given one.
async fn ask(app: &axum::Router, method: http::Method, peer: &str, uri: &str, cookie: Option<&str>) -> http::StatusCode {
    let mut b = http::Request::builder().method(method).uri(uri);
    if let Some(c) = cookie {
        b = b.header(http::header::COOKIE, format!("{}={c}", crate::auth::SESSION_COOKIE));
    }
    let mut r = b.body(axum::body::Body::empty()).expect("a request");
    let addr: SocketAddr = peer.parse().expect("an address");
    r.extensions_mut().insert(axum::extract::ConnectInfo(addr));
    app.clone().oneshot(r).await.expect("an answer").status()
}

/// Every kind of address a guest asks for: the shell's pages, a static asset, the repository
/// reads (README, raw, log, browse…), the site's own files, the login page.
fn guest_routes() -> Vec<String> {
    let mut v = vec!["/".to_owned(), "/repos".into(), format!("/repo/{NS}/{PUBLIC}"), format!("/{NS}"), "/console-abc.js".into(), "/gunnar-ui-web-abc_bg.wasm".into(), "/login".into(), "/login/repos".into(), "/llms.txt".into(), "/sitemap.xml".into(), "/robots.txt".into(), "/api/repos".into()];
    v.extend(read_routes(NS, PUBLIC));
    v
}

/// ★ A guest past its budget is answered 429 on EVERY kind of route, and another address is not.
#[tokio::test]
async fn a_guest_past_its_budget_gets_429_on_every_guest_route() {
    let mut l = wide();
    l.guest.budget.burst = 2;
    next_router(l);
    let (app, _passkeys, _feed) = console(FakeGunnar::new(), true).await;
    let peer = "198.51.100.10:5000";
    for _ in 0..2 {
        assert_ne!(ask(&app, http::Method::GET, peer, "/repos", None).await, http::StatusCode::TOO_MANY_REQUESTS);
    }
    for route in guest_routes() {
        assert_eq!(ask(&app, http::Method::GET, peer, &route, None).await, http::StatusCode::TOO_MANY_REQUESTS, "{route} was not throttled");
    }
    // Two addresses are two budgets.
    assert_ne!(ask(&app, http::Method::GET, "198.51.100.11:5000", "/repos", None).await, http::StatusCode::TOO_MANY_REQUESTS);
}

/// ★ A signed-in person is throttled too: per account, from any address, with a higher budget
/// than a guest's.
#[tokio::test]
async fn a_signed_in_person_past_their_budget_gets_429_whatever_address_they_ask_from() {
    let mut l = wide();
    l.guest.budget.burst = 1;
    l.account.budget = Budget { burst: 4, per_second: 0.0 };
    next_router(l);
    let (app, passkeys, _feed) = console(FakeGunnar::new(), true).await;
    let cookie = passkeys.make_session("vera", crate::role::Role::Viewer, true);
    for i in 0..4 {
        let s = ask(&app, http::Method::GET, &format!("198.51.100.{}:1", 20 + i), "/api/repos", Some(&cookie)).await;
        assert_eq!(s, http::StatusCode::OK, "request {i} of a signed-in person, past a guest's budget of 1");
    }
    assert_eq!(ask(&app, http::Method::GET, "198.51.100.99:1", "/api/repos", Some(&cookie)).await, http::StatusCode::TOO_MANY_REQUESTS, "the account's budget, from a fifth address");
    let other = passkeys.make_session("olle", crate::role::Role::Viewer, true);
    assert_eq!(ask(&app, http::Method::GET, "198.51.100.99:1", "/api/repos", Some(&other)).await, http::StatusCode::OK, "another account");
}

/// The route literals this crate registers, read off its own source (`.route("…"`), each
/// `{param}` given a value: the list the walk below asks.
fn registered_routes() -> Vec<String> {
    fn walk(dir: &std::path::Path, out: &mut Vec<String>) {
        for e in std::fs::read_dir(dir).expect("the source dir").flatten() {
            let p = e.path();
            if p.is_dir() {
                walk(&p, out);
            } else if p.extension().is_some_and(|x| x == "rs") {
                let src = std::fs::read_to_string(&p).expect("a source file");
                let mut rest = src.as_str();
                while let Some(at) = rest.find(".route(") {
                    rest = &rest[at + ".route(".len()..];
                    let t = rest.trim_start();
                    if let Some(lit) = t.strip_prefix('"').and_then(|t| t.split_once('"')).map(|(l, _)| l) {
                        if lit.starts_with('/') {
                            out.push(lit.to_owned());
                        }
                    }
                }
            }
        }
    }
    let mut out = Vec::new();
    walk(&std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), &mut out);
    let mut out: Vec<String> = out
        .into_iter()
        .map(|r| r.split('/').map(|seg| if seg.starts_with('{') { "x" } else { seg }).collect::<Vec<_>>().join("/"))
        .collect();
    // And what no literal names: the fallback, a namespace, the assets.
    out.extend(["/no/such/page".into(), "/nordisk".into(), "/app.js".into(), "/".into()]);
    out.sort();
    out.dedup();
    out
}

/// ★ **No route skips the limiter**: with every budget at zero, each route this crate
/// registers (and the fallback) is answered 429 for GET and POST, guest or signed in.
#[tokio::test]
async fn no_route_skips_the_limiter() {
    let zero = |mut t: crate::throttle::Tier| {
        t.budget.burst = 0;
        t
    };
    let w = wide();
    next_router(Limits { guest: zero(w.guest), credentialed: zero(w.credentialed), account: zero(w.account), token: zero(w.token), global: zero(w.global), ..w });
    let (app, passkeys, _feed) = console(FakeGunnar::new(), true).await;
    let cookie = passkeys.make_session("vera", crate::role::Role::Admin, true);
    let routes = registered_routes();
    assert!(routes.len() > 100, "the walk found only {} routes", routes.len());
    let mut skipped = Vec::new();
    for route in &routes {
        for method in [http::Method::GET, http::Method::POST] {
            for c in [None, Some(cookie.as_str())] {
                if ask(&app, method.clone(), "198.51.100.30:1", route, c).await != http::StatusCode::TOO_MANY_REQUESTS {
                    skipped.push(format!("{method} {route} (signed in: {})", c.is_some()));
                }
            }
        }
    }
    assert!(skipped.is_empty(), "these skip the limiter: {skipped:?}");
}

/// The counters are an administrator's read.
#[tokio::test]
async fn the_throttles_counters_are_an_administrators_read() {
    let mut l = wide();
    l.guest.budget.burst = 1;
    next_router(l);
    let (app, passkeys, _feed) = console(FakeGunnar::new(), true).await;
    let _ = ask(&app, http::Method::GET, "198.51.100.40:1", "/repos", None).await;
    assert_eq!(ask(&app, http::Method::GET, "198.51.100.40:1", "/repos", None).await, http::StatusCode::TOO_MANY_REQUESTS);
    let (status, body) = admin(&app, "/api/throttle").await;
    assert_eq!(status, http::StatusCode::OK, "{body}");
    let v: serde_json::Value = serde_json::from_str(&body).expect("json");
    assert!(v["throttled"].as_u64().is_some_and(|n| n >= 1), "{v}");
    let viewer = passkeys.make_session("vera", crate::role::Role::Viewer, true);
    assert_eq!(ask(&app, http::Method::GET, "198.51.100.41:1", "/api/throttle", Some(&viewer)).await, http::StatusCode::FORBIDDEN);
}

/// ★ A product's own routes in front of the console (gunnar-front's `/download/…`) pass the
/// same throttle, once.
#[tokio::test]
async fn a_fronts_own_routes_pass_the_same_throttle_once() {
    let mut l = wide();
    l.guest.budget.burst = 3;
    next_router(l);
    FRONT.with(|f| *f.borrow_mut() = Some(axum::Router::new().route("/download/x", axum::routing::get(|| async { "a file" }))));
    let (app, _passkeys, _feed) = console(FakeGunnar::new(), true).await;
    let peer = "198.51.100.50:1";
    assert_eq!(ask(&app, http::Method::GET, peer, "/download/x", None).await, http::StatusCode::OK);
    // One request to the console behind it is counted once, not twice.
    let _ = ask(&app, http::Method::GET, peer, "/repos", None).await;
    assert_ne!(ask(&app, http::Method::GET, peer, "/download/x", None).await, http::StatusCode::TOO_MANY_REQUESTS, "the third request is the third, not the fifth");
    assert_eq!(ask(&app, http::Method::GET, peer, "/download/x", None).await, http::StatusCode::TOO_MANY_REQUESTS, "the front's own route is throttled");
}