Gunnar

nordisk/holger-egui

tests/robot_login_pane.rs

//! holger-server-ui **Sign-in pane** robot cell — proves the mounted shared
//! `facett_auth_ui::LoginPane` is driven by the REAL
//! [`holger_server_ui::login_seam::HolgerLoginSeam`] over the shared `nornir-auth-client`
//! device-code grant, end to end, WITHOUT a real IdP and WITHOUT ever leaking a
//! secret value into the observable `state_json`.
//!
//! Gated behind `gui` (the pane + seam only compile there). Run:
//! `cargo test -p holger-server-ui --features gui --test robot_login_pane`.
//!
//! LAW #5 (one shared pane, no per-app twin) + the pane's RED-when-broken security
//! invariant: the seam exposes no token/secret VALUE, so a sentinel access token can
//! never reach the pane or its state — this test asserts it never does.
#![cfg(feature = "gui")]

use holger_server_ui::facett_auth_ui::{Facet, LoginPane};
use holger_server_ui::login_seam::{subject_of, HolgerLoginSeam};
use nornir_auth_client::{FileTokenStore, LoginConfig};

/// A JWT with the given JSON payload (unsigned `header.payload.sig` — display-only,
/// the same shape the IdP would mint; the server verifies the signature on presentation).
fn jwt(payload: serde_json::Value) -> String {
    use base64::Engine;
    let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
    format!("{}.{}.sig", b64(br#"{"alg":"none"}"#), b64(payload.to_string().as_bytes()))
}

/// **The REAL `HolgerLoginSeam` drives the shared `LoginPane` SignedOut → AwaitingUser
/// → SignedIn over a LOCAL stub IdP (no real network), and NO secret value ever appears
/// in the pane's `state_json`.** A tiny loopback HTTP server plays the RFC 8628
/// device-authorization + token endpoints; the seam runs the real device-code grant
/// against it through `nornir-auth-client`, and the presentational pane renders only the
/// states the seam surfaces. Drives deterministically with `auto_poll(false)`.
#[test]
fn holger_seam_drives_the_shared_login_pane_no_leak() {
    // The IdP mints THIS access token — a sentinel we then prove never leaks.
    let access_token = jwt(serde_json::json!({ "preferred_username": "robot@vetra.se" }));
    // Sanity: the seam's display decoder recovers the identity from it.
    assert_eq!(subject_of(&access_token).as_deref(), Some("robot@vetra.se"));

    // A multi-thread runtime serves the stub IdP on its background workers while the
    // main thread drives the (sync) seam — whose own `block_on` runs the real grant.
    // The server is IO (a mock HTTP endpoint), NOT CPU parallelism (ROOT LAW #0).
    let server_rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap();
    let addr = server_rt.block_on(stub_idp::spawn(access_token.clone()));

    let dir = tempfile::tempdir().unwrap();
    let store = FileTokenStore::at(dir.path().join("auth-token.json"));
    let cfg = LoginConfig {
        device_auth_url: format!("http://{addr}/device"),
        token_url: format!("http://{addr}/token"),
        client_id: "holger-server-ui".into(),
        scope: "openid offline_access".into(),
    };
    let seam = HolgerLoginSeam::with_config_and_store(cfg, store);
    let mut pane = LoginPane::new(Box::new(seam)).with_auto_poll(false);

    // SignedOut — a fresh seam over an empty store opens signed-out.
    assert_eq!(pane.state_json()["state"], "signed_out");

    // Begin → AwaitingUser: the device-code prompt (user-code + verification URL) shows.
    pane.update_json(r#"{"begin":true}"#);
    let s = pane.state_json();
    assert_eq!(s["state"], "awaiting_user", "begin → the device-code prompt: {s}");
    assert_eq!(s["user_code"], "WDJB-MJHT");
    assert!(s["verification_uri"].as_str().unwrap().contains("/device"));

    // Poll → SignedIn: the subject shows; credential NAMES only (no values).
    pane.update_json(r#"{"poll":true}"#);
    let s = pane.state_json();
    assert_eq!(s["state"], "signed_in", "poll → signed in: {s}");
    assert_eq!(s["subject"], "robot@vetra.se");
    let names = s["credential_names"].as_array().unwrap();
    assert!(names.iter().any(|n| n == "access_token"), "credential NAMES surface: {s}");
    assert!(names.iter().any(|n| n == "refresh_token"), "offline_access ⇒ refresh_token name");

    // NO secret value ANYWHERE — not the sentinel token, not an access_token/value/token key.
    let whole = serde_json::to_string(&s).unwrap();
    assert!(!whole.contains(&access_token), "the sentinel access token never reaches state: {whole}");
    assert!(!whole.contains("eyJ"), "no JWT bytes leak into the state: {whole}");
    assert!(s.as_object().unwrap().get("value").is_none());
    assert!(s.as_object().unwrap().get("token").is_none());

    // Sign out → back to signed-out (the store is cleared; nothing lingers).
    pane.update_json(r#"{"sign_out":true}"#);
    assert_eq!(pane.state_json()["state"], "signed_out");
}

/// **RED-when-broken: the pane is a REAL egui surface (non-blank render).** Drive the
/// mounted `LoginPane`'s body through the headless kittest harness in the signed-out
/// state and assert its *Sign in* button is in the AccessKit tree — the LAW #4 render
/// proof that the shared pane actually paints when holger mounts it (not just state).
#[test]
fn login_pane_renders_a_signin_button() {
    use egui_kittest::kittest::Queryable;
    use egui_kittest::Harness;

    // A fresh seam over an empty temp store ⇒ signed-out ⇒ the pane shows *Sign in*.
    let dir = tempfile::tempdir().unwrap();
    let store = FileTokenStore::at(dir.path().join("auth-token.json"));
    let seam = HolgerLoginSeam::with_config_and_store(
        LoginConfig {
            device_auth_url: "http://127.0.0.1:1/device".into(),
            token_url: "http://127.0.0.1:1/token".into(),
            client_id: "holger-server-ui".into(),
            scope: "openid".into(),
        },
        store,
    );
    // Distinct heading so the render query matches only the *Sign in* BUTTON (the
    // default title would also be "Sign in", colliding with the button label).
    let pane = std::rc::Rc::new(std::cell::RefCell::new(
        LoginPane::new(Box::new(seam)).with_auto_poll(false).with_title("Sign in to holger"),
    ));
    let p = pane.clone();
    let mut h = Harness::builder()
        .with_size(egui::vec2(420.0, 320.0))
        .build_ui(move |ui| p.borrow_mut().ui(ui));
    h.run();
    assert!(h.query_by_label("Sign in").is_some(), "the signed-out pane paints a Sign in button");
    assert_eq!(pane.borrow().state_json()["state"], "signed_out");
}

/// A minimal localhost stub of the RFC 8628 IdP endpoints for the sign-in test — a raw
/// `tokio` TCP HTTP/1.1 server answering `/device` (device-authorization) and `/token`
/// (immediate approval). No real network; bound to `127.0.0.1:0`. Mirrors korp's
/// `src/login.rs` stub (the shared reuse pattern). The `tokio::spawn`s here run IO (a mock
/// HTTP server), NOT CPU work — ROOT LAW #0 targets compute parallelism, not IO servers.
mod stub_idp {
    use tokio::io::{AsyncReadExt, AsyncWriteExt};
    use tokio::net::TcpListener;

    /// Bind on an ephemeral loopback port, serve the two endpoints on a background task,
    /// and return the bound `addr`.
    pub async fn spawn(access_token: String) -> std::net::SocketAddr {
        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
        let addr = listener.local_addr().unwrap();
        let device_body = format!(
            r#"{{"device_code":"dc-123","user_code":"WDJB-MJHT","verification_uri":"http://{addr}/device","interval":1,"expires_in":600}}"#
        );
        let token_body = format!(
            r#"{{"access_token":"{access_token}","refresh_token":"rt-456","expires_in":3600,"token_type":"Bearer"}}"#
        );
        tokio::spawn(async move {
            loop {
                let Ok((mut sock, _)) = listener.accept().await else { break };
                let device_body = device_body.clone();
                let token_body = token_body.clone();
                tokio::spawn(async move {
                    let mut buf = vec![0u8; 4096];
                    let n = sock.read(&mut buf).await.unwrap_or(0);
                    let req = String::from_utf8_lossy(&buf[..n]);
                    let path = req.split_whitespace().nth(1).unwrap_or("/");
                    let body = if path.starts_with("/token") { &token_body } else { &device_body };
                    let resp = format!(
                        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
                        body.len(),
                        body
                    );
                    let _ = sock.write_all(resp.as_bytes()).await;
                    let _ = sock.shutdown().await;
                });
            }
        });
        addr
    }
}