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

nordisk/holger

holger-estate-front/tests/the_people_are_holgers.rs

//! **The people are holger's, through the thin console** (holger-ui thin
//! layer, 2026-10-17).
//!
//! A REAL holger (its passkey plane, its read gate, its RBAC, its HTTP door and
//! gRPC plane on loopback) and the REAL console router (holger-ui-server, linked
//! in exactly as the front links it), driven the way a browser drives them, with
//! a soft authenticator for the person's key. The console holds nothing: the
//! ceremony is holger's, the session is holger's bearer, and every call —
//! gRPC-web and the passkey door — is passed through and decided by holger.

use std::io::Write as _;

use http::{Request, StatusCode};
use nornir_auth_webauthn::softkey::SoftKey;
use tower::ServiceExt as _;

use holger_estate_front::certify::proto as pb;

const RP: &str = "holger.test";
const ORIGIN: &str = "https://holger.test";
/// holger's static bearer (subject `console`, Admin): the bootstrap that
/// enrols the first passkey, and the console's proof behind a ceremony hint.
const CONSOLE: &str = "c0nsole-bearer-0123456789abcdefghij";
const GATED: &str = "monetize-backup";

fn free_port() -> u16 {
    std::net::TcpListener::bind("127.0.0.1:0")
        .unwrap()
        .local_addr()
        .unwrap()
        .port()
}

/// A real holger: the estate's auth block (the console's static bearer is
/// Admin, the three groups the kit emits) plus the passkey plane the installer
/// feeds, an open repository and the gated ledger-backup repository.
fn holger(data: &std::path::Path, token_file: &std::path::Path) -> (String, String) {
    let (grpc, http) = (free_port(), free_port());
    let ron = format!(
        r#"(
    repositories: [
        (ron_name: "bundles", ron_repo_type: "raw", ron_upstreams: [], ron_in: None,
         ron_out: Some((ron_storage_endpoint: "store", ron_exposed_endpoint: "doors")),
         ron_data_dir: Some("{d}/bundles")),
        (ron_name: "{GATED}", ron_repo_type: "raw", ron_upstreams: [], ron_in: None,
         ron_out: Some((ron_storage_endpoint: "store", ron_exposed_endpoint: "doors")),
         ron_data_dir: Some("{d}/{GATED}"), ron_require_auth: true, ron_require_role: Some(Admin)),
    ],
    exposed_endpoints: [ (ron_name: "doors", ron_url: "127.0.0.1:{grpc}", ron_http_url: Some("127.0.0.1:{http}")) ],
    storage_endpoints: [ (ron_name: "store", ron_storage_type: "znippy", ron_path: "{d}") ],
    audit: (dir: "{d}/audit", enabled: true),
    auth: Some((
        methods: [
            StaticToken(subject: "console", token_file: Some("{tf}")),
            Passkey(rp_id: "{RP}", rp_origin: "{ORIGIN}"),
        ],
        roles: {{"console": Admin}},
        group_roles: {{"holger-admins": Admin, "holger-writers": Writer, "holger-readers": Reader}},
        require_auth_for_reads: false,
    )),
)"#,
        d = data.display(),
        tf = token_file.display(),
    );
    let mut cfg = tempfile::NamedTempFile::new().unwrap();
    cfg.write_all(ron.as_bytes()).unwrap();
    let mut h = holger_server_lib::read_ron_config(cfg.path()).expect("the RON parses");
    h.instantiate_backends().unwrap();
    holger_server_lib::wire_holger(&mut h).unwrap();
    h.start().unwrap();
    std::mem::forget(cfg);
    (format!("127.0.0.1:{grpc}"), format!("127.0.0.1:{http}"))
}

/// The console, as the front builds it.
fn console(grpc: &str, door: &str) -> axum::Router {
    holger_ui_server::router(
        None,
        holger_ui_server::Upstreams::new(grpc, door, Some(CONSOLE.into())),
    )
}

struct Answer {
    status: StatusCode,
    body: serde_json::Value,
    text: String,
    /// The session cookie the console set, if it set one (A34-38).
    cookie: Option<String>,
}

/// A JSON call to holger's passkey door, through the console.
async fn call(
    app: &axum::Router,
    path: &str,
    bearer: Option<&str>,
    body: serde_json::Value,
) -> Answer {
    let mut req = Request::builder()
        .method("POST")
        .uri(path)
        .header(http::header::CONTENT_TYPE, "application/json");
    if let Some(b) = bearer {
        req = req.header(http::header::AUTHORIZATION, format!("Bearer {b}"));
    }
    let r = app
        .clone()
        .oneshot(req.body(axum::body::Body::from(body.to_string())).unwrap())
        .await
        .unwrap();
    let status = r.status();
    let cookie = r
        .headers()
        .get_all(http::header::SET_COOKIE)
        .iter()
        .filter_map(|v| v.to_str().ok())
        .find_map(|v| {
            v.strip_prefix("__Host-holger_session=")
                .and_then(|v| v.split(';').next())
                .filter(|v| !v.is_empty())
                .map(str::to_owned)
        });
    let bytes = axum::body::to_bytes(r.into_body(), 1 << 22).await.unwrap();
    let text = String::from_utf8_lossy(&bytes).into_owned();
    Answer {
        status,
        body: serde_json::from_str(&text).unwrap_or(serde_json::Value::Null),
        text,
        cookie,
    }
}

/// A JSON call carrying the console's session cookie instead of a bearer (what a browser sends
/// from the console's own page: its Origin is this site's).
async fn call_with_cookie(app: &axum::Router, path: &str, cookie: &str) -> Answer {
    call_with_cookie_from(app, path, cookie, ORIGIN).await
}

/// [`call_with_cookie`] from the page at `origin`.
async fn call_with_cookie_from(
    app: &axum::Router,
    path: &str,
    cookie: &str,
    origin: &str,
) -> Answer {
    let req = Request::builder()
        .method("POST")
        .uri(path)
        .header(http::header::HOST, RP)
        .header(http::header::ORIGIN, origin)
        .header(http::header::CONTENT_TYPE, "application/json")
        .header(
            http::header::COOKIE,
            format!("__Host-holger_session={cookie}"),
        );
    let r = app
        .clone()
        .oneshot(req.body(axum::body::Body::from("{}")).unwrap())
        .await
        .unwrap();
    let status = r.status();
    let bytes = axum::body::to_bytes(r.into_body(), 1 << 22).await.unwrap();
    let text = String::from_utf8_lossy(&bytes).into_owned();
    Answer {
        status,
        body: serde_json::from_str(&text).unwrap_or(serde_json::Value::Null),
        text,
        cookie: None,
    }
}

/// One gRPC-web call through the console's `/grpc/` door, as the browser's
/// `tonic-web-wasm-client` makes it: `Ok(reply)` or `Err((grpc-status, message))`.
async fn grpc_web<Q: prost::Message, A: prost::Message + Default>(
    app: &axum::Router,
    method: &str,
    bearer: &str,
    ask: &Q,
) -> Result<A, (u32, String)> {
    let msg = ask.encode_to_vec();
    let mut frame = vec![0u8];
    frame.extend_from_slice(&(msg.len() as u32).to_be_bytes());
    frame.extend_from_slice(&msg);
    let req = Request::builder()
        .method("POST")
        .uri(format!("/grpc/{method}"))
        .header(http::header::CONTENT_TYPE, "application/grpc-web+proto")
        .header("x-grpc-web", "1")
        .header(http::header::AUTHORIZATION, format!("Bearer {bearer}"))
        .body(axum::body::Body::from(frame))
        .unwrap();
    let r = app.clone().oneshot(req).await.unwrap();
    assert_eq!(
        r.status(),
        StatusCode::OK,
        "gRPC-web answers 200 and says the outcome in grpc-status"
    );
    let header_status = r
        .headers()
        .get("grpc-status")
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.parse::<u32>().ok());
    let header_message = r
        .headers()
        .get("grpc-message")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("")
        .to_owned();
    let bytes = axum::body::to_bytes(r.into_body(), 1 << 24).await.unwrap();
    let (mut reply, mut status, mut message) = (None, header_status, header_message);
    let mut at = 0;
    while at + 5 <= bytes.len() {
        let flag = bytes[at];
        let len = u32::from_be_bytes([bytes[at + 1], bytes[at + 2], bytes[at + 3], bytes[at + 4]])
            as usize;
        let body = &bytes[at + 5..at + 5 + len];
        if flag & 0x80 == 0 {
            reply = Some(A::decode(body).expect("holger's message decodes"));
        } else {
            for line in String::from_utf8_lossy(body).lines() {
                if let Some((k, v)) = line.split_once(':') {
                    match k.trim() {
                        "grpc-status" => status = v.trim().parse().ok(),
                        "grpc-message" => message = v.trim().to_owned(),
                        _ => {}
                    }
                }
            }
        }
        at += 5 + len;
    }
    match (status.unwrap_or(0), reply) {
        (0, Some(r)) => Ok(r),
        (0, None) => Ok(A::default()),
        (s, _) => Err((s, message)),
    }
}

/// A registration through the console: start → the soft key → finish.
async fn register(
    app: &axum::Router,
    key: &mut SoftKey,
    ask: serde_json::Value,
    bearer: Option<&str>,
) -> Answer {
    let start = call(app, "/-/auth/passkey/register/start", bearer, ask.clone()).await;
    if start.status != StatusCode::OK {
        return start;
    }
    let challenge = start.body["publicKey"]["challenge"]
        .as_str()
        .unwrap()
        .to_owned();
    let (cd, att) = key.register(RP, ORIGIN, &challenge, true);
    let mut body = serde_json::json!({ "ceremony_id": start.body["ceremony_id"], "id": key.credential_id_b64(), "response": { "clientDataJSON": cd, "attestationObject": att } });
    if let Some(i) = ask.get("invite") {
        body["invite"] = i.clone();
    }
    call(app, "/-/auth/passkey/register/finish", bearer, body).await
}

/// A login through the console, `uv` or touch only: holger's session, as the console's HttpOnly
/// cookie carries it (A34-38: the answer the page reads holds no token).
async fn login(app: &axum::Router, key: &mut SoftKey, uv: bool) -> String {
    let start = call(
        app,
        "/-/auth/passkey/login/start",
        None,
        if uv {
            serde_json::json!({ "uv": "required" })
        } else {
            serde_json::json!({})
        },
    )
    .await;
    assert_eq!(start.status, StatusCode::OK, "login/start: {}", start.text);
    let challenge = start.body["publicKey"]["challenge"]
        .as_str()
        .unwrap()
        .to_owned();
    let (cd, ad, sig) = key.authenticate(RP, ORIGIN, &challenge, uv);
    let body = serde_json::json!({ "ceremony_id": start.body["ceremony_id"], "id": key.credential_id_b64(), "response": { "clientDataJSON": cd, "authenticatorData": ad, "signature": sig } });
    let fin = call(app, "/-/auth/passkey/login/finish", None, body).await;
    assert_eq!(fin.status, StatusCode::OK, "login/finish: {}", fin.text);
    assert!(
        fin.body.get("token").is_none() && fin.body["signed_in"] == true,
        "the page never reads the token: {}",
        fin.text
    );
    fin.cookie
        .expect("holger's session, in the console's cookie")
}

fn coordinate(name: &str) -> Option<pb::ArtifactId> {
    Some(pb::ArtifactId {
        namespace: String::new(),
        name: name.into(),
        version: "1".into(),
    })
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn holger_owns_the_people_and_decides_every_call_the_console_passes_through() {
    let data = tempfile::tempdir().unwrap();
    let token_file = data.path().join("console-token");
    std::fs::write(&token_file, CONSOLE).unwrap();
    // A key file is 0600 (holger-key-1).
    std::fs::set_permissions(
        &token_file,
        <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
    )
    .unwrap();
    let (grpc, door) = holger(&data.path().join("holger"), &token_file);
    for _ in 0..200 {
        if reqwest_free_get(&format!("http://{door}/-/auth/passkey/config")).await == Some(200) {
            break;
        }
        tokio::time::sleep(std::time::Duration::from_millis(25)).await;
    }
    let app = console(&grpc, &door);

    // ── 1. The owner's first key, enrolled with holger's static bearer.
    let mut owner = SoftKey::new(0x42, b"owners-yubikey");
    let r = register(
        &app,
        &mut owner,
        serde_json::json!({ "display": "rickard", "role": "admin", "label": "a key" }),
        Some(CONSOLE),
    )
    .await;
    assert_eq!(
        r.status,
        StatusCode::OK,
        "holger enrolled the owner: {}",
        r.text
    );
    let owner_token = login(&app, &mut owner, true).await;
    let me = call(
        &app,
        "/-/auth/passkey/session",
        Some(&owner_token),
        serde_json::json!({}),
    )
    .await;
    let by_cookie = call_with_cookie(&app, "/-/auth/passkey/session", &owner_token).await;
    assert_eq!(
        (by_cookie.status, &by_cookie.body["subject"]),
        (StatusCode::OK, &me.body["subject"]),
        "the cookie is the person's bearer on every call: {}",
        by_cookie.text
    );
    // RA5-5: a page on another site (a sibling of the same domain sends the SameSite=Strict
    // cookie too) changes nothing with it: refused before the cookie is anybody's bearer.
    let forged = call_with_cookie_from(
        &app,
        "/-/auth/passkey/sessions/revoke-all",
        &owner_token,
        "https://test2.holger.test",
    )
    .await;
    assert_eq!(
        forged.status,
        StatusCode::FORBIDDEN,
        "a cross-site state change rides on no cookie: {}",
        forged.text
    );
    assert_eq!(
        call_with_cookie(&app, "/-/auth/passkey/session", &owner_token)
            .await
            .status,
        StatusCode::OK,
        "the session lives on"
    );
    assert_eq!(
        (me.body["display"].as_str(), me.body["role"].as_str()),
        (Some("rickard"), Some("admin")),
        "{}",
        me.text
    );
    // The bootstrap retires once an admin passkey exists.
    let mut late = SoftKey::new(0x43, b"a-second-key");
    let again = register(
        &app,
        &mut late,
        serde_json::json!({ "display": "eve", "role": "admin" }),
        Some(CONSOLE),
    )
    .await;
    assert_ne!(
        again.status,
        StatusCode::OK,
        "the bootstrap enrols no second admin: {}",
        again.text
    );

    // ── 2. Step-up and the invite are holger's.
    let touch = login(&app, &mut owner, false).await;
    let refused = call(
        &app,
        "/-/auth/passkey/invites",
        Some(&touch),
        serde_json::json!({ "role": "viewer" }),
    )
    .await;
    assert_eq!(refused.status, StatusCode::FORBIDDEN, "{}", refused.text);
    assert!(
        refused.body["error"]
            .as_str()
            .unwrap_or("")
            .starts_with("log in again"),
        "the pane's step-up cue, holger's words: {}",
        refused.text
    );
    let no_admin = call(
        &app,
        "/-/auth/passkey/invites",
        Some(&owner_token),
        serde_json::json!({ "role": "admin" }),
    )
    .await;
    assert_eq!(
        no_admin.status,
        StatusCode::FORBIDDEN,
        "an invite never grants admin: {}",
        no_admin.text
    );
    let invite = call(
        &app,
        "/-/auth/passkey/invites",
        Some(&owner_token),
        serde_json::json!({ "role": "viewer" }),
    )
    .await;
    assert_eq!(invite.status, StatusCode::OK, "{}", invite.text);
    let secret = invite.body["invite"].as_str().unwrap().to_owned();

    let mut vera = SoftKey::new(0x56, b"veras-phone");
    let machine = register(
        &app,
        &mut vera,
        serde_json::json!({ "invite": secret, "display": "console" }),
        None,
    )
    .await;
    assert_eq!(
        machine.status,
        StatusCode::BAD_REQUEST,
        "a machine's name: {}",
        machine.text
    );
    let r = register(
        &app,
        &mut vera,
        serde_json::json!({ "invite": secret, "display": "vera" }),
        None,
    )
    .await;
    assert_eq!(r.status, StatusCode::OK, "{}", r.text);
    let vera_token = login(&app, &mut vera, true).await;
    let v = call(
        &app,
        "/-/auth/passkey/session",
        Some(&vera_token),
        serde_json::json!({}),
    )
    .await;
    assert_eq!(v.body["role"].as_str(), Some("reader"), "{}", v.text);

    // ── 3. Every gRPC-web call is the person's, and holger decides.
    let listed: pb::ListRepositoriesResponse = grpc_web(
        &app,
        "holger.v1.AdminService/ListRepositories",
        &vera_token,
        &pb::ListRepositoriesRequest {},
    )
    .await
    .expect("vera may list");
    let names: Vec<_> = listed
        .repositories
        .iter()
        .map(|r| r.name.as_str())
        .collect();
    assert!(
        names.contains(&"bundles") && !names.contains(&GATED),
        "holger lists vera what she may read: {names:?}"
    );
    let put = |token: &str| {
        let req = pb::PutArtifactRequest {
            repository: "bundles".into(),
            id: coordinate("a.txt"),
            data: b"hello".to_vec(),
            async_job: false,
        };
        let app = app.clone();
        let token = token.to_owned();
        async move {
            grpc_web::<_, pb::PutArtifactResponse>(
                &app,
                "holger.v1.RepositoryService/PutArtifact",
                &token,
                &req,
            )
            .await
        }
    };
    let (code, message) = put(&vera_token)
        .await
        .expect_err("holger refuses a reader's write");
    assert_eq!(code, 7, "PERMISSION_DENIED: {message}");
    let admin_view = grpc_web::<_, pb::AdminSnapshotReply>(
        &app,
        "holger.v1.AdminService/AdminSnapshot",
        &vera_token,
        &pb::AdminSnapshotRequest { log_lines: 0 },
    )
    .await;
    assert!(admin_view.is_err(), "the admin view is not a viewer's");

    assert!(
        put(&owner_token)
            .await
            .expect("the owner writes as themselves")
            .success
    );
    let who: pb::WhoAmIResponse = grpc_web(
        &app,
        "holger.v1.AdminService/WhoAmI",
        &owner_token,
        &pb::WhoAmIRequest::default(),
    )
    .await
    .unwrap();
    assert!(
        who.method == "passkey"
            && who.subject.starts_with("u-")
            && who.global_role.eq_ignore_ascii_case("admin"),
        "whoami is the person's own: {who:?}"
    );

    // ── 4. Logout ends the session at holger.
    let out = call(
        &app,
        "/-/auth/passkey/logout",
        Some(&vera_token),
        serde_json::json!({}),
    )
    .await;
    assert_eq!(out.status, StatusCode::OK, "{}", out.text);
    let after = call(
        &app,
        "/-/auth/passkey/session",
        Some(&vera_token),
        serde_json::json!({}),
    )
    .await;
    assert_eq!(
        after.status,
        StatusCode::UNAUTHORIZED,
        "a logged-out session is no session: {}",
        after.text
    );
}

/// A plain GET's status, with no client library: the rig's readiness probe.
async fn reqwest_free_get(url: &str) -> Option<u16> {
    let (hp, path) = split(url);
    let mut s = tokio::net::TcpStream::connect(&hp).await.ok()?;
    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
    s.write_all(
        format!("GET {path} HTTP/1.1\r\nHost: {hp}\r\nConnection: close\r\n\r\n").as_bytes(),
    )
    .await
    .ok()?;
    let mut out = Vec::new();
    s.read_to_end(&mut out).await.ok()?;
    String::from_utf8_lossy(&out)
        .split_whitespace()
        .nth(1)?
        .parse()
        .ok()
}

/// A bearer POST to holger's own door: (status, body).
#[allow(dead_code)]
async fn reqwest_post_bearer(url: &str, bearer: &str) -> (u16, String) {
    let (hp, path) = split(url);
    let mut s = tokio::net::TcpStream::connect(&hp).await.unwrap();
    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
    s.write_all(format!("POST {path} HTTP/1.1\r\nHost: {hp}\r\nAuthorization: Bearer {bearer}\r\nContent-Length: 2\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{{}}").as_bytes()).await.unwrap();
    let mut out = Vec::new();
    s.read_to_end(&mut out).await.unwrap();
    let text = String::from_utf8_lossy(&out).into_owned();
    let status = text
        .split_whitespace()
        .nth(1)
        .and_then(|s| s.parse().ok())
        .unwrap_or(0);
    (status, text)
}

fn split(url: &str) -> (String, String) {
    let rest = url.trim_start_matches("http://");
    let (hp, path) = rest.split_once('/').unwrap_or((rest, ""));
    (hp.to_owned(), format!("/{path}"))
}