Gunnar

nordisk/gunnar-ui

uitest/src/kitenrol.rs

//! **A machine asks; the admin approves with a touch of HER key; gunnar
//! decides** (2026-10-05) — the ops kit's console credential without the
//! owner's copied session cookie.
//!
//! The kit is a browser context of its own with its own virtual authenticator:
//! it opens the creation ceremony anonymously, asks gunnar for an ADMIN
//! credential under the machine name `kit-uitest`, and gets a code. bob — the
//! admin, in his own session — sees "Pending: … requests admin — code …" on
//! Administration → Identities and presses Approve: HIS virtual authenticator
//! answers the user-verified step-up (the soft key), the console relays it,
//! gunnar verifies it and makes the machine. The row is gone, and the kit logs
//! in with ITS key and reads as `kit-uitest`, admin. A refused request, and an
//! approval that names another code, enrol nothing. (A touch steps the admin's
//! identity-core session up for gunnar's window, 10 minutes: an approval inside it
//! needs no second touch, so "no touch" is not a state the rig's admin can be in.)
//!
//! Every assertion is the server's word (`/auth/machine/enrol/<id>`,
//! `/auth/status`) or the authenticator's, never the page's claim.

use crate::cdp::Cdp;
use crate::http;
use crate::json::{esc, Json};
use crate::matrix::{go, want, Row};
use crate::rig::Rig;

const MAIN_TEXT: &str = "(document.querySelector('main') || document.body).innerText";

/// The kit's request, as `estate console enrol --request` sends it — a real
/// creation ceremony by the kit context's own authenticator.
fn ask(c: &mut Cdp, kit: &str, machine: &str, role: &str) -> Result<Json, String> {
    c.eval(
        kit,
        &format!(
            "(async () => {{
               const b2u = s => {{ s = s.replace(/-/g,'+').replace(/_/g,'/'); while (s.length % 4) s += '=';
                                   return Uint8Array.from(atob(s), c => c.charCodeAt(0)).buffer; }};
               const u2b = b => btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
               const st = await fetch('/auth/machine/enrol/start?machine=' + encodeURIComponent({m}));
               if (!st.ok) return {{ status: st.status, text: await st.text() }};
               const start = await st.json();
               const pk = start.publicKey;
               pk.challenge = b2u(pk.challenge); pk.user.id = b2u(pk.user.id); pk.excludeCredentials = [];
               const cred = await navigator.credentials.create({{ publicKey: pk }});
               const r = await fetch('/auth/machine/enrol', {{ method: 'POST', headers: {{ 'Content-Type': 'application/json' }},
                 body: JSON.stringify({{ ceremony_id: start.ceremony_id, machine: {m}, label: {l}, role: {r},
                   response: {{ clientDataJSON: u2b(cred.response.clientDataJSON), attestationObject: u2b(cred.response.attestationObject) }} }}) }});
               const text = await r.text();
               let body = null; try {{ body = JSON.parse(text); }} catch (e) {{}}
               return {{ status: r.status, text, body }};
             }})()",
            m = esc(machine),
            l = esc(&format!("installer kit on {}", machine.trim_start_matches("kit-"))),
            r = esc(role),
        ),
    )
}

/// The request's state, as gunnar answers the kit's poll.
fn state_of(c: &mut Cdp, kit: &str, id: &str) -> Result<String, String> {
    let j = c.eval(kit, &format!("fetch('/auth/machine/enrol/' + {}).then(r => r.json())", esc(id)))?;
    Ok(j.text("state"))
}

pub fn rows(r: &Rig, rows: &mut Vec<Row>, c: &mut Cdp, admin: &str) {
    let mut kit = String::new();
    let mut kit_auth = String::new();
    let mut asked = (String::new(), String::new());
    go(rows, "a-kit-asks-for-an-admin-credential-and-waits-with-a-code", || {
        kit = c.isolated_page()?;
        kit_auth = c.add_authenticator(&kit)?;
        c.navigate(&kit, &format!("{}/login", r.origin()))?;
        let j = ask(c, &kit, "kit-uitest", "admin")?;
        want(j.path("status").and_then(|s| s.as_f64()) == Some(200.0), || format!("the kit's request: {}", http::cut(&j.dump(), 300)))?;
        let (id, code) = (j.text("body.id"), j.text("body.code"));
        want(j.text("body.state") == "pending", || format!("not pending: {}", http::cut(&j.dump(), 300)))?;
        let b = code.as_bytes();
        want(b.len() == 7 && b[3] == b'-' && b[..3].iter().all(u8::is_ascii_uppercase) && b[4..].iter().all(u8::is_ascii_digit), || format!("a code is ABC-123: {code:?}"))?;
        let creds = c.credentials(&kit, &kit_auth)?;
        want(creds.len() == 1, || format!("the kit's authenticator holds {} credentials", creds.len()))?;
        // Pending is nobody: gunnar does not offer the kit's key at a login —
        // not even at one named for the machine (the security-key path).
        let offered = c.eval(&kit, "fetch('/auth/passkey/login/start?user=kit-uitest').then(r => r.json()).then(j => (j.publicKey.allowCredentials || []).map(c => c.id))")?;
        let kit_id = creds[0].text("credentialId");
        want(!offered.dump().contains(&kit_id.replace('+', "-").replace('/', "_").trim_end_matches('=').to_owned()), || format!("a PENDING credential is offered at login: {}", http::cut(&offered.dump(), 300)))?;
        want(state_of(c, &kit, &id)? == "pending", || "the request is not pending".to_owned())?;
        asked = (id, code.clone());
        Ok(format!("kit-uitest asks admin — code {code}; gunnar offers its key to no login yet"))
    });

    let (id, code) = asked.clone();
    let row = format!("document.querySelector('.machine-request[data-request={}]')", esc(&id));
    go(rows, "the-admin-sees-pending-label-role-and-code-on-identities", || {
        want(!id.is_empty(), || "no request (the row before failed)".to_owned())?;
        c.navigate(admin, &format!("{}/access", r.origin()))?;
        c.wait_for(admin, &format!("!!{row}"), 30_000)
            .map_err(|e| format!("no pending row on Identities ({e}): {}", http::cut(&c.eval_str(admin, MAIN_TEXT).unwrap_or_default(), 400)))?;
        let line = c.eval_str(admin, &format!("{row}.querySelector('.machine-request-line').textContent"))?;
        let want_line = format!("Pending: installer kit on uitest requests admin — code {code}");
        want(line == want_line, || format!("the row reads {line:?}, not {want_line:?}"))?;
        Ok(line)
    });

    go(rows, "approve-is-the-admins-own-touch-and-gunnar-makes-the-machine", || {
        want(!id.is_empty(), || "no request (the rows before failed)".to_owned())?;
        c.eval(admin, &format!("(() => {{ {row}.querySelector('button.approve').click(); return 1; }})()"))?;
        c.wait_for(admin, &format!("!{row}"), 30_000).map_err(|e| {
            format!("the row is still there ({e}): {}", http::cut(&c.eval_str(admin, MAIN_TEXT).unwrap_or_default(), 400))
        })?;
        let state = state_of(c, &kit, &id)?;
        want(state == "approved", || format!("gunnar says the request is {state:?}"))?;
        let j = c.eval(&kit, &format!("fetch('/auth/machine/enrol/' + {}).then(r => r.json())", esc(&id)))?;
        want(j.text("decided_by") == "bob", || format!("approved by {:?}, not bob: {}", j.text("decided_by"), http::cut(&j.dump(), 200)))?;
        Ok("the row is gone; gunnar: approved by bob".into())
    });

    go(rows, "the-kit-logs-in-with-its-own-key-and-whoami-is-kit-uitest-admin", || {
        c.navigate(&kit, &format!("{}/login", r.origin()))?;
        // The ONE button, naming nobody (owner 2026-10-07, (g)): the kit's key
        // was registered discoverable (`residentKey: required`), so the
        // authenticator answers the anonymous ceremony with it.
        c.eval(&kit, "document.getElementById('login').click()")?;
        c.wait_for(&kit, "fetch('/auth/status').then(r => r.json()).then(j => j.authenticated === true || !!document.getElementById('msg')?.classList.contains('err'))", 30_000)?;
        let st = c.eval(&kit, "fetch('/auth/status').then(r => r.json())")?;
        want(st.path("authenticated").and_then(|v| v.as_bool()) == Some(true), || format!("the kit is not logged in: {}", http::cut(&st.dump(), 200)))?;
        want(st.text("user") == "kit-uitest" && st.text("role") == "admin", || format!("the kit reads as {:?} {:?}", st.text("user"), st.text("role")))?;
        // gunnar lists the kit's credential on its machine, never on bob.
        let list = c.eval(admin, "fetch('/auth/passkey/credentials').then(r => r.json())")?;
        let owners: Vec<String> = list.path("credentials").map(|a| a.arr().iter().map(|x| x.text("user")).collect()).unwrap_or_default();
        want(owners.iter().any(|o| o == "kit-uitest"), || format!("no credential on kit-uitest in {}", http::cut(&list.dump(), 300)))?;
        Ok(format!("{} as {}; the credential is the machine's", st.text("user"), st.text("role")))
    });

    go(rows, "a-refused-request-and-an-approval-with-the-wrong-code-enrol-nothing", || {
        let other = c.isolated_page()?;
        c.add_authenticator(&other)?;
        c.navigate(&other, &format!("{}/login", r.origin()))?;
        let j = ask(c, &other, "kit-refused", "operator")?;
        let id2 = j.text("body.id");
        want(!id2.is_empty(), || format!("no second request: {}", http::cut(&j.dump(), 300)))?;
        want(!code.is_empty() && code != j.text("body.code"), || "no other code to name (the rows before failed)".to_owned())?;
        // The code is the machine's proof that the admin approves THIS request: another
        // request's code (the kit's, decided already) is refused by gunnar, and nothing moves.
        let bare = c.eval(
            admin,
            &format!(
                "fetch('/auth/machine/requests/' + {} + '/approve', {{ method: 'POST', headers: {{ 'Content-Type': 'application/json' }}, body: JSON.stringify({{ code: {} }}) }}).then(r => r.status)",
                esc(&id2),
                esc(&code)
            ),
        )?;
        want(bare.as_f64().is_some_and(|s| s >= 400.0), || format!("an approval with the wrong code answered {}", bare.dump()))?;
        want(state_of(c, &other, &id2)? == "pending", || "an approval with the wrong code moved the request".to_owned())?;
        let row2 = format!("document.querySelector('.machine-request[data-request={}]')", esc(&id2));
        c.navigate(admin, &format!("{}/access", r.origin()))?;
        c.wait_for(admin, &format!("!!{row2}"), 30_000)?;
        c.eval(admin, &format!("(() => {{ {row2}.querySelector('button.refuse').click(); return 1; }})()"))?;
        c.wait_for(admin, &format!("!{row2}"), 30_000)?;
        let state = state_of(c, &other, &id2)?;
        want(state == "refused", || format!("gunnar says {state:?}"))?;
        let users = r.control(&["user", "list"])?;
        want(!users.contains("kit-refused"), || format!("a refused request made a principal: {}", http::cut(&users, 200)))?;
        Ok(format!("the wrong code: {} and still pending; refused: no principal", bare.dump()))
    });

    // The admin lists the kit's credential and revokes it — and it leaves the
    // box with one admin passkey again, as the rows after these expect.
    go(rows, "the-admin-revokes-the-kits-credential-and-the-kit-is-out", || {
        want(!kit.is_empty(), || "no kit (the rows before failed)".to_owned())?;
        let list = c.eval(admin, "fetch('/auth/passkey/credentials').then(r => r.json())")?;
        let id = list
            .path("credentials")
            .and_then(|a| a.arr().iter().find(|x| x.text("user") == "kit-uitest").map(|x| x.text("id")))
            .ok_or_else(|| format!("gunnar lists no credential on kit-uitest: {}", http::cut(&list.dump(), 300)))?;
        let st = c.eval(
            admin,
            &format!(
                "fetch('/auth/passkey/credentials/revoke', {{ method: 'POST', headers: {{ 'Content-Type': 'application/json' }}, body: JSON.stringify({{ id: {} }}) }}).then(async r => ({{ status: r.status, text: await r.text() }}))",
                esc(&id)
            ),
        )?;
        want(st.path("status").and_then(|s| s.as_f64()) == Some(200.0), || format!("revoking the kit's credential: {}", http::cut(&st.dump(), 300)))?;
        let after = c.eval(&kit, "fetch('/auth/status').then(r => r.json())")?;
        want(after.path("authenticated").and_then(|v| v.as_bool()) != Some(true), || format!("the kit is still logged in after its credential was revoked: {}", http::cut(&after.dump(), 200)))?;
        let list = c.eval(admin, "fetch('/auth/passkey/credentials').then(r => r.json())")?;
        let left = list.path("credentials").map(|a| a.arr().iter().any(|x| x.text("user") == "kit-uitest")).unwrap_or(true);
        want(!left, || format!("gunnar still lists the kit's credential: {}", http::cut(&list.dump(), 300)))?;
        Ok("revoked by bob at gunnar; the kit's session ended".into())
    });
}