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

nordisk/gunnar

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