browser-tests/tests/console.rs
//! ★ **The console in a real browser** (lane HOLGER-UI-TESTS): the owner's first passkey through
//! the login's break-glass door; an invited writer uploads a wheel in the console; the intake
//! chain holds it (its scan run by the console's bearer, as the estate's console runs it); the
//! Flow page shows holger's own estate board (the server's rows, drawn here); the Security page
//! shows the gateway (the chain, the wheel waiting for its approvals) and not the board; two
//! people approve it in the console, one is not enough, two make it APPROVED. Each person is
//! one tab with one virtual authenticator. No link or request goes to the ONE box's site.
use holger_ui_browser_tests::browser::{Browser, Tab, NEVER};
use holger_ui_browser_tests::rig::{self, Rig, CONSOLE, WHEEL};
use monetize_site_buy::testkit::STRIPE;
/// The login's register panel, as the page's own controls drive it; answers once the tab holds
/// holger's session (a registration that answers none is followed by "Log in").
async fn register(tab: &Tab, user: &str) {
tab.until("the register button", "!!document.getElementById('register')").await;
tab.js("document.getElementById('reg').open = true").await.unwrap();
tab.fill("#user", user).await;
tab.click("#register").await;
tab.until("registered", holger_ui_browser_tests::browser::SIGNED_IN_OR_REGISTERED).await;
if !tab.signed_in().await {
// A real press: the sign-in starts its passkey only inside the person's gesture (RA2-6).
tab.press("#login").await;
tab.until("holger's session in this tab", holger_ui_browser_tests::browser::SIGNED_IN).await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_console_uploads_holds_shows_the_board_and_takes_two_approvals() {
let rig = Rig::start().await;
let browser = Browser::launch().await.expect("chromium starts");
// ── the owner: the first admin passkey, enrolled with the break-glass token ──
let owner = browser.tab().await.unwrap();
owner.open(&rig.at("/login")).await.unwrap();
owner.until("the bootstrap door", "!document.getElementById('reg').hidden").await;
owner.fill("#token", CONSOLE).await;
owner.js("document.getElementById('role').value = 'admin'").await.unwrap();
register(&owner, "owner").await;
owner.assert_no_link_to(NEVER).await.unwrap();
// ── an invited writer uploads the wheel into `incoming` ──
let writer = browser.person().await.unwrap();
let invite = rig.invite("wanda", "holger-writers");
writer.open(&rig.at(&format!("/login?invite={invite}"))).await.unwrap();
register(&writer, "wanda").await;
let files = tempfile::tempdir().unwrap();
let wheel = rig::write_wheel(files.path());
writer.open(&rig.at("/repo/incoming")).await.unwrap();
writer.until("the upload panel", "!!document.querySelector('details.upload')").await;
writer.js("document.querySelector('details.upload').open = true").await.unwrap();
writer.pick_file("details.upload input[type=file]", &wheel).await.unwrap();
writer.until("the name from the file", &format!("[...document.querySelectorAll('details.upload input[type=text]')].some(i => i.value === {})", serde_json::to_string(WHEEL).unwrap())).await;
writer.click_text("Upload").await;
writer.until("uploaded", "!!document.querySelector('details.upload .ok') || !!document.querySelector('details.upload .err')").await;
assert!(writer.text().await.contains(&format!("uploaded {WHEEL}")), "{}", writer.text().await);
writer.assert_no_link_to(NEVER).await.unwrap();
// ── the chain holds it; its required scan, then the policy check ──
let (_, state) = rig.console_get(&format!("/-/intake/state?name={WHEEL}"));
assert!(state.contains("INGESTED"), "an upload is held at the door: {state}");
for want in ["SCANNED", "POLICY_CHECKED"] {
let (status, body) = rig.console_post(&format!("/-/intake/advance?name={WHEEL}&verb=advance"), None);
assert!(status == 200 && body.contains(want), "→ {want}: {status} {body}");
}
// ── the Flow page: the server's board, drawn here (the console's only picture, HOL-UI-FIX) ──
owner.open(&rig.at("/flow?open=")).await.unwrap();
owner.until("the estate board", "!!document.querySelector('img.board-img')").await;
let src = owner.js("document.querySelector('img.board-img').src").await.unwrap();
let src = src.as_str().unwrap();
assert!(src.starts_with("data:image/svg+xml"), "the board is drawn as an image: {}", &src[..src.len().min(80)]);
let drawn = owner.js("(() => { const s = document.querySelector('img.board-img').src; return decodeURIComponent(s.slice(s.indexOf(',') + 1)); })()").await.unwrap();
let drawn = drawn.as_str().unwrap();
assert!(drawn.contains("<svg"), "{}", &drawn[..drawn.len().min(200)]);
// HOL-SVG-RON: the image is holger's flow drawing of its RON (`AdminService.FlowSvg`), as
// sent: every node of holger's model `/-/flow.json` is in it.
let (status, served) = rig.console_get("/-/flow.json");
assert_eq!(status, 200, "holger's own flow door: {served}");
let model: serde_json::Value = serde_json::from_str(&served).unwrap_or_else(|e| panic!("holger's flow model is JSON ({e}): {served}"));
for n in model["nodes"].as_array().expect("the nodes") {
let id = n["id"].as_str().unwrap();
assert!(drawn.contains(&format!(r#"data-node="{id}""#)), "the console shows holger's node {id}");
}
let summary = owner.until("the board's summary", "(document.querySelector('.board-summary') || {}).textContent || ''").await;
// holger's own line (BOARD-LINE, holger 0fea2068): "1 box: main · no upstream (uploads only) · …".
assert!(summary.as_str().unwrap().contains("1 box: main"), "the server's counts in words: {summary}");
// ── HOL-FLOW-SMART: nothing open is level 1, a click on main unfolds it ──
assert!(drawn.contains(r#"data-level="1""#), "the Flow with nothing open shows the machines only");
owner.until("main's link over the image", "!!document.querySelector('[data-board=estate] a.board-hit[href=\"flow?open=main\"]')").await;
owner.click("[data-board=estate] a.board-hit[href=\"flow?open=main\"]").await;
owner.until("the Flow, main unfolded", "location.pathname === '/flow' && location.search === '?open=main'").await;
owner.until("level 2", "(() => { const i = document.querySelector('[data-board=estate] img.board-img'); return !!i && decodeURIComponent(i.src).includes('data-level=\"2\"'); })()").await;
let (_, unfolded) = rig.console_get("/-/flow.json?open=main");
let unfolded: serde_json::Value = serde_json::from_str(&unfolded).unwrap();
let flow = owner.js("(() => { const s = document.querySelector('[data-board=estate] img.board-img').src; return decodeURIComponent(s.slice(s.indexOf(',') + 1)); })()").await.unwrap();
for n in unfolded["nodes"].as_array().expect("the nodes") {
let id = n["id"].as_str().unwrap();
assert!(flow.as_str().unwrap().contains(&format!(r#"data-node="{id}""#)), "the Flow shows holger's node {id} with main unfolded");
}
// The console's home is the Overview (HOL-UI-FIX): the estate in cards, no drawing. On a
// box `/` is holger's front page, so the console's home is where its own header link leads.
owner.click("header a.brand").await;
owner.until("the console's home", "location.pathname === '/' && /up \\d/.test(document.body.innerText)").await;
let board = owner.js("!!document.querySelector('[data-board=estate]')").await.unwrap();
assert_eq!(board, serde_json::Value::Bool(false), "the console's home draws the board");
// ── HENRIK-TASKS 1 (Henrik Torp): Admin opens on its switches, its tabs wrap on a phone ──
owner.click("header nav.nav a[href=\"/admin\"]").await;
owner.until("Admin, on its switches", "(document.querySelector('nav.tabs button[aria-pressed=true]') || {}).textContent === 'Switches'").await;
// A phone: the tabs wrap onto more rows, and no two of them overlap.
owner.set_width(375).await;
let tabs = owner.js("[...document.querySelectorAll('nav.tabs button')].map(b => { const r = b.getBoundingClientRect(); return [r.left, r.top, r.right]; })").await.unwrap();
owner.set_width(0).await;
let tabs: Vec<(f64, f64, f64)> = tabs.as_array().unwrap().iter().map(|t| (t[0].as_f64().unwrap(), t[1].as_f64().unwrap(), t[2].as_f64().unwrap())).collect();
let rows: std::collections::BTreeSet<i64> = tabs.iter().map(|t| t.1.round() as i64).collect();
assert!(rows.len() > 1, "on a phone the tabs wrap: {tabs:?}");
for pair in tabs.windows(2) {
if (pair[0].1 - pair[1].1).abs() < 1.0 {
assert!(pair[0].2 <= pair[1].0, "two tabs overlap on one row: {pair:?}");
}
assert!(pair[0].2 <= 375.5, "a tab runs off a phone's screen: {pair:?}");
}
// ── the security page (HOL-SEC, owner 2026-10-27): security, not the machinery ──
owner.open(&rig.at("/security")).await.unwrap();
owner.until("the security summary", "!!document.querySelector('[data-security=headline]')").await;
let headline = owner.js("document.querySelector('[data-security=headline]').textContent").await.unwrap();
assert!(headline.as_str().unwrap().contains("1 package waits for approval"), "the server's headline: {headline}");
let waiting = owner.until("the wheel waits for approval", &format!("(() => {{ const s = document.querySelector('[data-section=approvals]'); return s && s.innerText.includes({}) ? s.innerText : ''; }})()", serde_json::to_string(WHEEL).unwrap())).await;
assert!(waiting.as_str().unwrap().contains("0 of 2"), "its votes, as the server counts them: {waiting}");
let chain = owner.js("document.querySelector('[data-section=chain]').innerText").await.unwrap();
for step in ["Intake chain", "Scanned", "Policy checked", "Approved", "Sealed", "Served", "2 different people approve it"] {
// innerText follows the page's CSS (a section title is set in capitals).
assert!(chain.as_str().unwrap().to_lowercase().contains(&step.to_lowercase()), "the chain says {step:?}: {chain}");
}
let board = owner.js("!!document.querySelector('[data-board=estate]') || !!document.querySelector('img.board-img')").await.unwrap();
assert_eq!(board, serde_json::Value::Bool(false), "the Security page draws the machinery board: {}", owner.text().await);
owner.until("the intake tiles", "document.querySelectorAll('nav.tiles a').length >= 3").await;
// ── one approval is not enough ──
let view = rig.at(&format!("/security/artifact?ns=&name={WHEEL}&version="));
owner.open(&view).await.unwrap();
owner.until("POLICY_CHECKED", "document.body.innerText.includes('POLICY_CHECKED')").await;
owner.until("0 of 2", "document.body.innerText.includes('0 of 2 approvals')").await;
owner.click_text("Approve").await;
owner.until("1 of 2", "document.body.innerText.includes('1 of 2 approvals')").await;
assert!(!owner.text().await.contains("APPROVED"), "one person approves nothing alone: {}", owner.text().await);
// ── a second person, the approver, makes it APPROVED ──
// HOL-NCSC (holger-auth-6): an approver's invite is minted by a person's stepped-up passkey
// session, never by the console's machine token: the owner, on its own session.
let anna = browser.person().await.unwrap();
let body = serde_json::json!({ "display": "anna", "groups": [holger_recipe::estate_ron::APPROVER_GROUP] });
let minted = owner.js(&OWNER_MINTS.replace("BODY", &body.to_string())).await.unwrap();
let invite = minted.as_str().filter(|m| !m.starts_with("refused")).unwrap_or_else(|| panic!("the owner mints anna's approver invite: {minted}")).to_owned();
anna.open(&rig.at(&format!("/login?invite={invite}"))).await.unwrap();
register(&anna, "anna").await;
anna.open(&view).await.unwrap();
anna.until("1 of 2 for anna", "document.body.innerText.includes('1 of 2 approvals')").await;
anna.click_text("Approve").await;
anna.until("APPROVED", "document.body.innerText.includes('APPROVED')").await;
let (_, state) = rig.console_get(&format!("/-/intake/state?name={WHEEL}"));
assert!(state.contains("APPROVED"), "holger holds it APPROVED: {state}");
anna.assert_no_link_to(NEVER).await.unwrap();
browser.assert_nothing_went_to(NEVER, &rig.base, STRIPE);
}
/// ★ **A stale touch is answered in place** (A28 M9; owner 2026-10-19 "log in again … idiotic").
/// The admin's session comes from a plain touch, so holger refuses an invite with its
/// machine-readable `"code":"stepup"`. The console asks the key for a fresh, user-verified
/// touch right on the Invites pane (`/-/auth/passkey/step-up/*`) and sends the mint once more:
/// the link appears, the tab never leaves `/admin`, and no login page is shown.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_stale_touch_is_answered_in_place_on_the_invites_pane() {
let rig = Rig::start().await;
let browser = Browser::launch().await.expect("chromium starts");
let owner = browser.tab().await.unwrap();
owner.open(&rig.at("/login")).await.unwrap();
owner.until("the bootstrap door", "!document.getElementById('reg').hidden").await;
owner.fill("#token", CONSOLE).await;
owner.js("document.getElementById('role').value = 'admin'").await.unwrap();
register(&owner, "owner").await;
// ── a plain touch: the session holger mints is not stepped up ──
// A key with no PIN answers only a login that names it (Chrome finds no discoverable
// credential on it), so this login names the owner's passkey; the rest is the login page's.
let id = owner.passkey_id().await.unwrap();
owner.user_verified(false).await.unwrap();
owner.open(&rig.at("/login")).await.unwrap();
owner.js(holger_ui_browser_tests::browser::SIGN_OUT_TAB).await.unwrap();
let said = owner.js(&TOUCH_ONLY_LOGIN.replace("PASSKEY_ID", &id)).await.unwrap();
assert_eq!(said, serde_json::json!("ok"), "a touch-only login");
let stale = owner.session_cookie().await;
let stepped_up = "fetch('/-/auth/passkey/session', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }).then(r => r.json()).then(j => j.stepped_up)";
assert_eq!(owner.js(stepped_up).await.unwrap(), serde_json::json!(false), "a plain touch is not a step-up");
// ── the key verifies again; the admin mints an invite on the console ──
owner.user_verified(true).await.unwrap();
owner.open(&rig.at("/admin")).await.unwrap();
owner.click_text("Invites").await;
owner.click("form.toolbar button[type=submit]").await;
owner.until("the minted link, after a touch in place", "!!document.querySelector('.minted') || !!document.querySelector('p.err')").await;
assert!(owner.shown(".minted").await, "the invite was minted after the touch: {}", owner.text().await);
assert!(owner.url().await.starts_with(&rig.at("/admin")), "the tab stayed on the pane: {}", owner.url().await);
// IDENTITY-HOST (holger d47e5b15): the identity core steps the session up IN PLACE, so the
// tab keeps its cookie, and that session is now the stepped-up one.
let fresh = owner.session_cookie().await;
assert_eq!(fresh, stale, "the core steps the tab's own session up, in place");
assert_eq!(owner.js(stepped_up).await.unwrap(), serde_json::json!(true));
let asked = browser.requests();
let after: Vec<&String> = asked.iter().skip_while(|u| !u.ends_with("/-/auth/passkey/step-up/start")).collect();
assert!(after.iter().any(|u| u.ends_with("/-/auth/passkey/step-up/finish")), "the touch went to holger's step-up door: {asked:?}");
assert!(!after.iter().any(|u| u.contains("/login")), "no login page after the refusal: {after:?}");
browser.assert_nothing_went_to(NEVER, &rig.base, STRIPE);
}
/// The owner's tab mints an invite on its own session (the Invites pane's `POST`, with a body
/// the pane does not offer: an approver's): holger's secret, or `refused <status> <words>`.
const OWNER_MINTS: &str = r#"fetch('/-/auth/passkey/invites', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(BODY) }).then(async r => r.ok ? (await r.json()).invite : 'refused ' + r.status + ' ' + await r.text())"#;
/// A login by a key that names its passkey (`PASSKEY_ID`): holger's own `login/start` and
/// `login/finish`, the session in the console's HttpOnly cookie, where the login page's goes.
const TOUCH_ONLY_LOGIN: &str = r#"(async () => { try {
const d = s => { s = s.replace(/-/g, '+').replace(/_/g, '/'); while (s.length % 4) s += '='; return Uint8Array.from(atob(s), c => c.charCodeAt(0)); };
const e = b => btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const st = await (await fetch('/-/auth/passkey/login/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })).json();
const pk = st.publicKey; pk.challenge = d(pk.challenge); pk.allowCredentials = [{ type: 'public-key', id: d('PASSKEY_ID') }];
const c = await navigator.credentials.get({ publicKey: pk });
const body = { ceremony_id: st.ceremony_id, id: c.id, response: { clientDataJSON: e(c.response.clientDataJSON), authenticatorData: e(c.response.authenticatorData), signature: e(c.response.signature) } };
const fin = await fetch('/-/auth/passkey/login/finish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!fin.ok) return 'refused ' + fin.status + ' ' + await fin.text();
if ((await fin.json()).token) return 'the page read the token';
return 'ok';
} catch (e) { return 'ERR ' + e.name + ': ' + e.message; } })()"#;