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