crates/gunnar-front/src/web/tests.rs
//! **Gunnar's half of the front's :443** (A32-11): estate-front's own tests drive the
//! certificate road end to end (its fake ACME directory, the DNS check, the status); these hold
//! what Gunnar adds on top — the ACME switch, the login names, git's paths, the access log, the
//! post-quantum TLS, the CLI hole's names — over the shared front.
use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
use axum::routing::get;
use axum::Router;
use super::*;
async fn listeners() -> (
tokio::net::TcpListener,
tokio::net::TcpListener,
SocketAddr,
SocketAddr,
) {
let a = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let b = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let (x, y) = (a.local_addr().unwrap(), b.local_addr().unwrap());
(a, b, x, y)
}
async fn until<T>(what: &str, mut f: impl FnMut() -> Option<T>) -> T {
for _ in 0..200 {
if let Some(t) = f() {
return t;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
panic!("timed out waiting for {what}");
}
#[tokio::test(flavor = "multi_thread")]
async fn git_and_lfs_paths_are_answered_on_the_same_host_with_the_ssh_url() {
use tower::ServiceExt as _;
let face = Router::new()
.fallback(|| async { (StatusCode::UNAUTHORIZED, "log in to use the console") });
let rule = serve::CallbackOnly {
hosts: vec!["git.gunnar.rs".to_string()],
to: "gunnar.rs".to_string(),
};
let router = serve::git_paths_here(serve::narrowed(face, Some(rule)));
let send = |host: &str, method: &str, path: &str| {
let req = axum::http::Request::builder()
.method(method)
.uri(path)
.header(header::HOST, host)
.body(axum::body::Body::empty())
.unwrap();
router.clone().oneshot(req)
};
for host in ["git.gunnar.rs", "gunnar.rs"] {
let lfs = send(host, "POST", "/nordisk/holger.git/info/lfs/objects/batch")
.await
.unwrap();
assert_ne!(
lfs.status(),
StatusCode::PERMANENT_REDIRECT,
"{host}: no cross-host redirect for LFS"
);
assert_eq!(lfs.status(), StatusCode::NOT_IMPLEMENTED, "{host}");
assert_eq!(
lfs.headers()[header::CONTENT_TYPE],
"application/vnd.git-lfs+json",
"{host}"
);
let body: serde_json::Value = serde_json::from_slice(
&axum::body::to_bytes(lfs.into_body(), 1 << 20)
.await
.unwrap(),
)
.unwrap();
let message = body["message"].as_str().expect("git-lfs prints `message`");
assert!(message.contains("pure SSH"), "{host}: {message}");
assert!(
message.contains(&format!("ssh://git@{host}:2222/nordisk/holger.git")),
"{host}: {message}"
);
let refs = send(
host,
"GET",
"/nordisk/holger.git/info/refs?service=git-upload-pack",
)
.await
.unwrap();
assert_eq!(
refs.status(),
StatusCode::NOT_IMPLEMENTED,
"{host}: smart HTTP is answered here too"
);
let text = String::from_utf8(
axum::body::to_bytes(refs.into_body(), 1 << 20)
.await
.unwrap()
.to_vec(),
)
.unwrap();
assert!(
text.contains(&format!("ssh://git@{host}:2222/nordisk/holger.git")),
"{host}: {text}"
);
}
// Everything else is untouched: the callback-only host still sends a page to the apex.
let page = send("git.gunnar.rs", "GET", "/repo/nordisk/holger")
.await
.unwrap();
assert_eq!(page.status(), StatusCode::PERMANENT_REDIRECT);
let console = send("gunnar.rs", "GET", "/api/repos").await.unwrap();
assert_eq!(
console.status(),
StatusCode::UNAUTHORIZED,
"the console still answers its own paths"
);
}
/// ★ **Own domain: the login lands on the canonical host** (private ownership, 2026-10-07;
/// WWW-CANON, owner 2026-10-30: the site is `www.<site>`). A sold estate serves `acme.gunnar.rs`
/// FIRST — its passkeys' relying party, a name whose DNS and certificate the seller controls —
/// and the buyer's own domain after it. The console's origin is the canonical name
/// (`www.acme.gunnar.rs`, `WebConfig::canonical`), so a login page on every other served name
/// (the bare first host, `git.`, the own domain) is a temporary redirect to the same path and
/// query on it: the ready mail's first-login link and a "Sign in" click both reach the one
/// origin the console's passkeys are asked at. Every other path there is served as before; the
/// canonical host and a callback-only name are never sent anywhere.
#[tokio::test(flavor = "multi_thread")]
async fn own_domain_login_lands_on_the_canonical_www_host() {
use tower::ServiceExt as _;
let feed = BTreeMap::from([
(FEED_WEB_HOSTS.to_string(), "acme.gunnar.rs,www.acme.gunnar.rs,git.acme.gunnar.rs,monetize.acme.gunnar.rs,pay.example.dk,git.acme.example".to_string()),
(FEED_WEB_CALLBACK_ONLY.to_string(), "pay.example.dk".to_string()),
]);
let cfg = WebConfig::from("acme", &feed);
assert_eq!(
cfg.name(),
"acme.gunnar.rs",
"the relying party is the first host"
);
assert_eq!(
login_elsewhere(&cfg),
vec![
"acme.gunnar.rs",
"git.acme.gunnar.rs",
"monetize.acme.gunnar.rs",
"git.acme.example"
],
"every served name but the canonical www and a callback-only one"
);
// The wiring: every look sets the login names and their target off the config.
let layers = Layers::default();
(layers.on_look())(&cfg);
let rule = layers.login_on().expect("a login rule");
assert_eq!(
rule.to, "www.acme.gunnar.rs",
"the login pages go to the canonical www host"
);
let app = Web::new(tempfile::tempdir().unwrap().path()).app;
app.set(Router::new().fallback(|| async { "the console" }));
let router = serve::login_on_first(app.current(), Some(rule));
let send = |host: &str, path: &str| {
let req = axum::http::Request::get(path)
.header(header::HOST, host)
.body(axum::body::Body::empty())
.unwrap();
router.clone().oneshot(req)
};
// The ready mail's link, opened on the buyer's own domain.
let link = send("git.acme.example:443", "/login?invite=abc&user=acmebuyer")
.await
.unwrap();
assert_eq!(
link.status(),
StatusCode::TEMPORARY_REDIRECT,
"temporary: a browser must not keep it if the canonical host ever moves"
);
assert_eq!(
link.headers()[header::LOCATION],
"https://www.acme.gunnar.rs/login?invite=abc&user=acmebuyer"
);
for page in [
"/login",
"/welcome",
"/signup?plan=private",
"/private",
"/login/sso/acme",
] {
let r = send("acme.gunnar.rs", page).await.unwrap();
assert_eq!(r.status(), StatusCode::TEMPORARY_REDIRECT, "{page}");
assert_eq!(
r.headers()[header::LOCATION],
format!("https://www.acme.gunnar.rs{page}"),
"{page}"
);
}
// The rest of the own domain is served as before: the anonymous web, its JSON, git.
for path in [
"/",
"/acme/repo",
"/login/repos",
"/auth/status",
"/api/health",
] {
let r = send("git.acme.example", path).await.unwrap();
assert_eq!(r.status(), StatusCode::OK, "{path}");
}
// The canonical host is never sent anywhere.
let www = send("www.acme.gunnar.rs", "/login?invite=abc")
.await
.unwrap();
assert_eq!(
axum::body::to_bytes(www.into_body(), 1 << 20)
.await
.unwrap(),
"the console"
);
// A name that is not served is not ours to send.
assert_eq!(
send("elsewhere.example", "/login").await.unwrap().status(),
StatusCode::OK
);
// No www served: the login pages go to the first host, as before.
let no_www = WebConfig::from(
"acme",
&BTreeMap::from([(
FEED_WEB_HOSTS.to_string(),
"acme.gunnar.rs,git.acme.example".to_string(),
)]),
);
assert_eq!(login_elsewhere(&no_www), vec!["git.acme.example"]);
let layers = Layers::default();
(layers.on_look())(&no_www);
assert_eq!(
layers.login_on().map(|r| r.to),
Some("acme.gunnar.rs".to_string())
);
// Nothing fed beyond the first host: nothing is sent anywhere.
let one = WebConfig::from(
"acme",
&BTreeMap::from([(FEED_WEB_HOSTS.to_string(), "acme.gunnar.rs".to_string())]),
);
assert!(login_elsewhere(&one).is_empty());
app.set_login_on(&login_elsewhere(&one), one.name());
assert!(app.login_on().is_none());
}
/// **RUNTIME LOGS (2026-09-30): one access line per request on :443**, into
/// the log door (tracing → the boot log): method, path (no query string, a
/// token-like segment withheld), status, principal (the console's
/// [`gunnar_ui_server::auth::middleware::AccessPrincipal`] on the answer, else
/// `anonymous`) and milliseconds. Never a header, never a query value.
#[tokio::test]
async fn every_request_on_443_is_one_redacted_access_line() {
use tower::ServiceExt as _;
let seen: Arc<Mutex<Vec<serve::Access>>> = Arc::new(Mutex::new(Vec::new()));
let sink = seen.clone();
let face = Router::new()
.route("/api/repos", get(|| async { "[]" }))
.route(
"/who",
get(|| async {
let mut r = "me".into_response();
#[cfg(feature = "console")]
r.extensions_mut()
.insert(gunnar_ui_server::auth::middleware::AccessPrincipal(
"alice".into(),
));
r
}),
);
let app = serve::access_logged_to(
face,
Arc::new(move |a: &serve::Access| sink.lock().unwrap().push(a.clone())),
);
let req = |uri: &str| {
axum::http::Request::builder()
.uri(uri)
.header("authorization", "Bearer s3cr3t-bearer")
.body(axum::body::Body::empty())
.unwrap()
};
let r = app
.clone()
.oneshot(req("/api/repos?token=abc123secret"))
.await
.unwrap();
assert_eq!(r.status(), StatusCode::OK);
let _ = app
.clone()
.oneshot(req("/invite/Zm9vYmFyYmF6cXV4cXV1eHF1dXhxdXV4cXV1eA"))
.await
.unwrap();
let _ = app.clone().oneshot(req("/who")).await.unwrap();
let got = seen.lock().unwrap().clone();
assert_eq!(got.len(), 3, "one line per request");
assert_eq!(
(
got[0].method.as_str(),
got[0].path.as_str(),
got[0].status,
got[0].principal.as_str()
),
("GET", "/api/repos?…", 200, "anonymous")
);
assert_eq!(got[1].path, "/invite/<withheld>");
assert_eq!(got[1].status, 404);
#[cfg(feature = "console")]
assert_eq!(got[2].principal, "alice");
let line = serve::access_line(&got[0]);
assert!(
line.contains("GET /api/repos?… 200 anonymous") && line.contains(" ms"),
"{line}"
);
assert!(
!format!("{got:?}").contains("s3cr3t") && !format!("{got:?}").contains("abc123secret"),
"no credential, no query value"
);
// The path redaction, pure.
assert_eq!(
serve::redacted_path("/repo/acme/widgets/tree/main"),
"/repo/acme/widgets/tree/main"
);
assert_eq!(
serve::redacted_path("/reset/3f2a9c1be07d4a5c8e6f1b2d3c4e5f6a"),
"/reset/<withheld>"
);
assert_eq!(serve::redacted_path("/callback/stripe"), "/callback/stripe");
}
/// **The console's HTTPS is post-quantum** (2026-10-06, the PQ transport
/// lane): the front's rustls offers X25519MLKEM768 FIRST — the hybrid group
/// Chrome, Firefox, curl and OpenSSL 3.5 offer by default — and keeps the
/// classical groups after it, so an older browser still connects.
#[test]
fn the_fronts_tls_offers_x25519mlkem768_first_and_keeps_the_classical_groups() {
let cfg = estate_front::web::serve::tls_config_with(
Arc::new(certs::Resolver::default()),
Some(tls()),
)
.unwrap();
let names: Vec<rustls::NamedGroup> = cfg
.crypto_provider()
.kx_groups
.iter()
.map(|g| g.name())
.collect();
assert_eq!(
names,
vec![
rustls::NamedGroup::X25519MLKEM768,
rustls::NamedGroup::X25519,
rustls::NamedGroup::secp256r1,
rustls::NamedGroup::secp384r1
]
);
}
/// The group a TLS 1.3 client offering exactly `groups` negotiates with
/// the front on `addr`, and the page it read.
async fn negotiated(
addr: SocketAddr,
name: &str,
ca_pem: &str,
groups: Vec<&'static dyn rustls::crypto::SupportedKxGroup>,
) -> Result<(rustls::NamedGroup, String), String> {
use rustls::pki_types::pem::PemObject as _;
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let mut roots = rustls::RootCertStore::empty();
for c in rustls::pki_types::CertificateDer::pem_slice_iter(ca_pem.as_bytes()) {
roots.add(c.unwrap()).unwrap();
}
let provider = rustls::crypto::CryptoProvider {
kx_groups: groups,
..rustls::crypto::ring::default_provider()
};
let cfg = rustls::ClientConfig::builder_with_provider(Arc::new(provider))
.with_safe_default_protocol_versions()
.unwrap()
.with_root_certificates(roots)
.with_no_client_auth();
let tcp = tokio::net::TcpStream::connect(addr)
.await
.map_err(|e| e.to_string())?;
let sni = rustls::pki_types::ServerName::try_from(name.to_string()).unwrap();
let mut tls = tokio_rustls::TlsConnector::from(Arc::new(cfg))
.connect(sni, tcp)
.await
.map_err(|e| e.to_string())?;
let group = tls
.get_ref()
.1
.negotiated_key_exchange_group()
.map(|g| g.name())
.ok_or("no group")?;
tls.write_all(
format!("GET /health HTTP/1.1\r\nHost: {name}\r\nConnection: close\r\n\r\n").as_bytes(),
)
.await
.map_err(|e| e.to_string())?;
let mut out = String::new();
let _ = tls.read_to_string(&mut out).await;
Ok((group, out))
}
/// **Interop on the real :443** — the front serving a fed certificate, as on
/// vm1. A client offering ONLY X25519MLKEM768 gets the page; OpenSSL ≥ 3.5
/// (its own ML-KEM) and curl over it say the group they negotiated; a client
/// offering only the classical groups still gets the page, on X25519.
/// OpenSSL and curl are skipped when absent, unless `PQ_INTEROP_REQUIRED=1`.
#[tokio::test(flavor = "multi_thread")]
async fn the_console_on_443_negotiates_x25519mlkem768_with_openssl_curl_and_rustls_and_classical_clients_still_connect(
) {
let dir = tempfile::tempdir().unwrap();
let (l80, l443, _a80, a443) = listeners().await;
let (chain, key) = certs::self_signed(&["gunnar.test"], 80);
let feed = BTreeMap::from([
(FEED_WEB_HOSTS.to_string(), "gunnar.test".to_string()),
(certs::fed_cert_key("gunnar.test"), chain.clone()),
(certs::fed_key_key("gunnar.test"), key),
]);
let web = start_on(
dir.path(),
"x".into(),
Arc::new(move || feed.clone()),
l80,
l443,
);
until("the fed certificate", || web.resolver.any().then_some(())).await;
let classical = rustls::crypto::ring::default_provider();
let hybrid = *nordisk_pairing::pqtls::provider()
.kx_groups
.first()
.unwrap();
let (group, page) = negotiated(a443, "gunnar.test", &chain, vec![hybrid])
.await
.unwrap();
assert_eq!(group, rustls::NamedGroup::X25519MLKEM768);
assert!(
page.starts_with("HTTP/1.1 200") && page.ends_with("ok\n"),
"{page}"
);
let (group, page) = negotiated(a443, "gunnar.test", &chain, classical.kx_groups.clone())
.await
.unwrap();
assert_eq!(
group,
rustls::NamedGroup::X25519,
"a classical client: prefer, never require"
);
assert!(page.starts_with("HTTP/1.1 200"), "{page}");
let required = std::env::var("PQ_INTEROP_REQUIRED").as_deref() == Ok("1");
let has_group = std::process::Command::new("openssl")
.args(["list", "-tls-groups"])
.output()
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.split(':')
.any(|g| g.trim() == "X25519MLKEM768")
})
.unwrap_or(false);
if !has_group {
assert!(
!required,
"PQ_INTEROP_REQUIRED=1 and no openssl ≥ 3.5 with X25519MLKEM768 on PATH"
);
eprintln!("SKIP openssl/curl: no X25519MLKEM768 on this box");
return;
}
let ca = dir.path().join("ca.pem");
std::fs::write(&ca, &chain).unwrap();
let port = a443.port();
let out = tokio::task::spawn_blocking(move || {
std::process::Command::new("openssl")
.args([
"s_client",
"-connect",
&format!("127.0.0.1:{port}"),
"-servername",
"gunnar.test",
"-groups",
"X25519MLKEM768",
"-brief",
])
.stdin(std::process::Stdio::null())
.output()
.unwrap()
})
.await
.unwrap();
let said =
String::from_utf8_lossy(&out.stdout).to_string() + &String::from_utf8_lossy(&out.stderr);
assert!(
said.contains("Negotiated TLS1.3 group: X25519MLKEM768"),
"openssl s_client said:\n{said}"
);
let ca2 = ca.clone();
let out = tokio::task::spawn_blocking(move || {
std::process::Command::new("curl")
.args([
"-sS",
"--curves",
"X25519MLKEM768",
"--resolve",
&format!("gunnar.test:{port}:127.0.0.1"),
"--cacert",
])
.arg(&ca2)
.arg(format!("https://gunnar.test:{port}/health"))
.output()
})
.await
.unwrap();
match out {
Ok(o) => assert_eq!(
(
o.status.success(),
String::from_utf8_lossy(&o.stdout).to_string()
),
(true, "ok\n".to_string()),
"curl offering only X25519MLKEM768: {}",
String::from_utf8_lossy(&o.stderr)
),
Err(e) => assert!(!required, "PQ_INTEROP_REQUIRED=1 and no curl: {e}"),
}
}
/// ★ **AIRGAP B1: the switch is on only in a build with `acme` and only when the medium says
/// `on`**; estate-front's own test holds that a front with it off asks no ACME directory.
#[test]
fn the_acme_switch_needs_the_feature_and_the_word_on() {
assert!(!acme_switch(None) && !acme_switch(Some("off")) && !acme_switch(Some("")));
assert_eq!(acme_switch(Some(" on ")), cfg!(feature = "acme"));
assert_eq!(acme_switch(Some("ON")), cfg!(feature = "acme"));
}
/// ★ A32-11: **Gunnar's outer layer wraps everything the shared front serves**, per connection:
/// the login names, git's paths, the CLI hole, and the access log outermost. And Gunnar never
/// sets estate-front's callback target, so a stale `monetize_control_host` key carries nothing
/// to any monetize: that host is answered by the face like every other name.
#[tokio::test]
async fn gunnars_layers_wrap_the_shared_front_and_no_monetize_is_carried() {
use tower::ServiceExt as _;
let web = Web::new(tempfile::tempdir().unwrap().path());
web.app
.set(Router::new().fallback(|| async { "the console" }));
web.app
.set_login_on(&["www.acme.gunnar.rs".to_string()], "acme.gunnar.rs");
(*web).app.set_control_host(Some("monetize.acme.gunnar.rs"));
let send = |host: &str, path: &str| {
web.app.router_now().oneshot(
axum::http::Request::get(path)
.header(header::HOST, host)
.body(axum::body::Body::empty())
.unwrap(),
)
};
let login = send("www.acme.gunnar.rs", "/login").await.unwrap();
assert_eq!(
(
login.status(),
login.headers()[header::LOCATION].to_str().unwrap()
),
(
StatusCode::TEMPORARY_REDIRECT,
"https://acme.gunnar.rs/login"
),
"the login names"
);
assert_eq!(
send("acme.gunnar.rs", "/nordisk/x.git/info/refs")
.await
.unwrap()
.status(),
StatusCode::NOT_IMPLEMENTED,
"git's paths"
);
let m = send("monetize.acme.gunnar.rs", "/monetize.v1.Fleet/Report")
.await
.unwrap();
assert_eq!(
axum::body::to_bytes(m.into_body(), 1 << 20).await.unwrap(),
"the console",
"no monetize behind a front that names no callback target"
);
let mod_rs = include_str!("mod.rs");
let outer = &mod_rs[mod_rs.find("fn outer(&self)").unwrap()..];
let outer = &outer[..outer.find("\n }\n").unwrap()];
assert!(
outer.contains("serve::access_logged(router)")
&& outer.contains("hole::with_hole(")
&& outer.contains("serve::git_paths_here(")
&& outer.contains("serve::login_on_first("),
"{outer}"
);
// RA4-22: the outer layer is installed ONE way, through estate-front's Hooks (passed to
// start_with, or Web::with_hooks for a web half that is not started), never set again.
assert!(
!mod_rs.contains(&["set_", "outer("].concat()),
"Gunnar installs its outer layer through Hooks only"
);
assert!(
mod_rs.contains("outer: Some(self.outer())") && mod_rs.contains("Web::with_hooks("),
"the hooks carry it, a web half that is not started too"
);
}
/// The login names and the hole's names follow the config the certificate loop read: every
/// served name but the canonical one (WWW-CANON: www) and a callback-only one; the hole: every
/// name but a callback-only one (a CLI may dial the bare site; its gRPC is never redirected).
#[test]
fn the_login_names_and_the_holes_names_follow_the_feed() {
let feed = BTreeMap::from([
(
FEED_WEB_HOSTS.to_string(),
"acme.gunnar.rs,www.acme.gunnar.rs,pay.example.dk".to_string(),
),
(
FEED_WEB_CALLBACK_ONLY.to_string(),
"pay.example.dk".to_string(),
),
]);
let cfg = WebConfig::from("acme", &feed);
assert_eq!(login_elsewhere(&cfg), vec!["acme.gunnar.rs"]);
assert_eq!(
console_names(&cfg),
vec!["acme.gunnar.rs", "www.acme.gunnar.rs"]
);
}