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

nordisk/gunnar-ui

crates/gunnar-ui-server/src/passkeys_at_gunnar/first_touch.rs

//! ★ **The first touch after paying, in a real browser** (A29-20; the owner, 2026-10-22:
//! "passkey request was cancelled … Payment received — your account x14 is ready").
//!
//! The console's own router (the welcome page, the login page as a metered build serves it,
//! `/paid/…`) on loopback over the fake gunnar and the fake monetize, in headless Chromium with
//! a virtual authenticator. Chromium is driven over the DevTools protocol on a pipe
//! (`--remote-debugging-pipe`), so the test needs nothing beyond the standard library and
//! `serde_json`. Needs `CHROME` (a Chromium binary, as the browser image `browser/Containerfile`
//! sets it). Without it each test says NOT RUN and returns, except under CI's `browser` step
//! (`cargo xtask browser`, which sets `GUNNAR_UI_NEEDS_BROWSER=1`), where it is RED (RA2-7).
//!
//! Two things are held:
//!
//! * **The first passkey is made inside the press, and the sign-in after it too.** Right after
//!   paying, gunnar is slow to answer the registration's start (held back 6 s, longer than
//!   Chromium keeps a press live). The ceremony watch ([`WATCH`]) writes down every ceremony
//!   and refuses one started without a live gesture, as Safari does. Exactly one registration
//!   and one sign-in, each inside the person's gesture, no two at once, no navigation while one
//!   runs; the person ends signed in.
//! * **Back from the vendor, signed in, with no touch.** The session cookie is
//!   `SameSite=Strict`, so the vendor's top-level hop back to `/paid/<reference>` carries none
//!   and the console answers it with `/login?next=`. The login page's own ask is same-site and
//!   carries the cookie, so a live session is sent straight on to the order's page
//!   (`alreadyIn`): no ceremony at all. (A32-10 named this hop as a second lead for A29-20;
//!   measured here, it starts no ceremony.)

use std::io::{BufRead as _, BufReader, Write as _};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};

use serde_json::{json, Value};

use super::{console_selling_at, Fake};
use crate::auth::signup::tests::{FakeMonetize, SESSION};

/// Longer than Chromium keeps a press live (5 s).
const SLOW: Duration = Duration::from_secs(6);

/// The ceremony watch, put before every document's own script: each ceremony's kind, whether
/// the person's gesture was live (`navigator.userActivation.isActive`), whether another one
/// was running, and whether the page navigated while one ran, kept in the tab's
/// `sessionStorage`. One started with no live gesture is refused as Safari refuses it.
const WATCH: &str = r#"(() => {
  if (!navigator.credentials || window.__ceremoniesWatched) return;
  window.__ceremoniesWatched = true;
  const KEY = '__ceremonies';
  const load = () => { try { return JSON.parse(sessionStorage.getItem(KEY) || '[]'); } catch (e) { return []; } };
  const keep = (all) => { try { sessionStorage.setItem(KEY, JSON.stringify(all)); } catch (e) {} };
  let running = 0;
  addEventListener('pagehide', () => {
    if (!running) return;
    const all = load();
    if (all.length) { all[all.length - 1].navigated = true; keep(all); }
  });
  for (const kind of ['create', 'get']) {
    const real = navigator.credentials[kind].bind(navigator.credentials);
    navigator.credentials[kind] = (options) => {
      if (!options || !options.publicKey) return real(options);
      const all = load();
      all.push({ kind, gesture: !!(navigator.userActivation && navigator.userActivation.isActive), overlap: running > 0, navigated: false, path: location.pathname, allow: (options.publicKey.allowCredentials || []).length, outcome: '' });
      keep(all);
      const at = all.length - 1;
      const settle = (outcome) => { const now = load(); if (now[at]) { now[at].outcome = outcome; keep(now); } };
      if (!all[at].gesture) {
        settle('NotAllowedError');
        return Promise.reject(new DOMException('The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.', 'NotAllowedError'));
      }
      running += 1;
      return real(options).then((c) => { running -= 1; settle('ok'); return c; }, (e) => { running -= 1; settle((e && e.name) || 'error'); throw e; });
    };
  }
})();"#;

/// **Chromium on a DevTools pipe**: fd 3 is what it reads, fd 4 what it writes, each message
/// one JSON object ended by a NUL. One page, attached flat (`sessionId`).
pub(crate) struct Chromium {
    child: Child,
    stdin: Mutex<ChildStdin>,
    answers: Arc<(Mutex<Vec<Value>>, Condvar)>,
    next: Mutex<u64>,
    session: String,
    /// The virtual authenticator's id (`WebAuthn.addCredential` plants a passkey on it).
    authenticator: String,
    _profile: tempfile::TempDir,
}

impl Chromium {
    pub(crate) fn launch(chrome: &str) -> Chromium {
        let profile = tempfile::tempdir().expect("a profile");
        // The shell moves the pipes to the descriptors the protocol names; nothing else.
        let mut child = Command::new("sh")
            .arg("-c")
            .arg("exec \"$0\" \"$@\" 3<&0 4>&1 </dev/null >/dev/null")
            .arg(chrome)
            .args(["--headless=new", "--remote-debugging-pipe", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", "--no-first-run", "--no-default-browser-check"])
            .arg(format!("--user-data-dir={}", profile.path().display()))
            .arg("about:blank")
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .spawn()
            .expect("chromium starts");
        let stdin = child.stdin.take().expect("its pipe in");
        let stdout = child.stdout.take().expect("its pipe out");
        let answers: Arc<(Mutex<Vec<Value>>, Condvar)> = Arc::default();
        let into = answers.clone();
        std::thread::spawn(move || {
            let mut read = BufReader::new(stdout);
            loop {
                let mut buf = Vec::new();
                match read.read_until(0, &mut buf) {
                    Ok(0) | Err(_) => return,
                    Ok(_) => {}
                }
                buf.pop();
                if let Ok(v) = serde_json::from_slice::<Value>(&buf) {
                    if v.get("id").is_some() {
                        into.0.lock().expect("answers").push(v);
                        into.1.notify_all();
                    }
                }
            }
        });
        let mut c = Chromium { child, stdin: Mutex::new(stdin), answers, next: Mutex::new(0), session: String::new(), authenticator: String::new(), _profile: profile };
        let targets = c.call("Target.getTargets", json!({}));
        let page = targets["targetInfos"]
            .as_array()
            .and_then(|t| t.iter().find(|t| t["type"] == "page"))
            .map(|t| t["targetId"].as_str().unwrap_or_default().to_owned());
        let page = match page {
            Some(p) => p,
            None => c.call("Target.createTarget", json!({ "url": "about:blank" }))["targetId"].as_str().unwrap_or_default().to_owned(),
        };
        let attached = c.call("Target.attachToTarget", json!({ "targetId": page, "flatten": true }));
        c.session = attached["sessionId"].as_str().expect("a session").to_owned();
        c.on_page("Page.enable", json!({}));
        c.on_page("Page.addScriptToEvaluateOnNewDocument", json!({ "source": WATCH }));
        c.on_page("WebAuthn.enable", json!({ "enableUI": false }));
        let added = c.on_page(
            "WebAuthn.addVirtualAuthenticator",
            json!({ "options": { "protocol": "ctap2", "transport": "internal", "hasResidentKey": true, "hasUserVerification": true, "isUserVerified": true, "automaticPresenceSimulation": true } }),
        );
        c.authenticator = added["authenticatorId"].as_str().expect("the authenticator's id").to_owned();
        c
    }

    fn send(&self, method: &str, params: Value, session: Option<&str>) -> Value {
        match self.try_send(method, params, session) {
            Ok(v) => v,
            Err(v) => panic!("{method}: {v}"),
        }
    }

    /// [`Self::send`], its error answer handed back.
    fn try_send(&self, method: &str, params: Value, session: Option<&str>) -> Result<Value, Value> {
        let id = {
            let mut n = self.next.lock().expect("ids");
            *n += 1;
            *n
        };
        let mut msg = json!({ "id": id, "method": method, "params": params });
        if let Some(s) = session {
            msg["sessionId"] = json!(s);
        }
        let mut bytes = serde_json::to_vec(&msg).expect("json");
        bytes.push(0);
        self.stdin.lock().expect("the pipe").write_all(&bytes).expect("chromium reads");
        let end = Instant::now() + Duration::from_secs(60);
        let (lock, ready) = &*self.answers;
        let mut got = lock.lock().expect("answers");
        loop {
            if let Some(at) = got.iter().position(|v| v["id"] == id) {
                let v = got.remove(at);
                if v.get("error").is_some() {
                    return Err(v);
                }
                return Ok(v["result"].clone());
            }
            let left = end.saturating_duration_since(Instant::now());
            assert!(!left.is_zero(), "{method}: no answer within 60 s");
            got = ready.wait_timeout(got, left).expect("answers").0;
        }
    }

    fn call(&self, method: &str, params: Value) -> Value {
        self.send(method, params, None)
    }

    pub(crate) fn on_page(&self, method: &str, params: Value) -> Value {
        self.send(method, params, Some(&self.session.clone()))
    }

    pub(crate) fn open(&self, url: &str) {
        self.on_page("Page.navigate", json!({ "url": url }));
    }

    /// `js` (an expression; a promise is awaited) as JSON; `Null` while a page is between documents.
    pub(crate) fn js(&self, js: &str) -> Value {
        match self.try_send("Runtime.evaluate", json!({ "expression": js, "returnByValue": true, "awaitPromise": true }), Some(&self.session.clone())) {
            Ok(r) => r["result"]["value"].clone(),
            // Asked while the page went to its next document: nothing to read yet.
            Err(e) if e.to_string().contains("navigated or closed") || e.to_string().contains("context was destroyed") => Value::Null,
            Err(e) => panic!("Runtime.evaluate: {e}"),
        }
    }

    pub(crate) fn until(&self, what: &str, js: &str) {
        let end = Instant::now() + Duration::from_secs(30);
        while Instant::now() < end {
            if self.js(js) == Value::Bool(true) {
                return;
            }
            std::thread::sleep(Duration::from_millis(100));
        }
        panic!("{what}: not seen within 30 s at {}\n--- the page says ---\n{}", self.js("location.href"), self.text());
    }

    pub(crate) fn holds(&self, js: &str, within: Duration) -> bool {
        let end = Instant::now() + within;
        while Instant::now() < end {
            if self.js(js) == Value::Bool(true) {
                return true;
            }
            std::thread::sleep(Duration::from_millis(100));
        }
        false
    }

    pub(crate) fn text(&self) -> String {
        self.js("document.body ? document.body.innerText : ''").as_str().unwrap_or_default().to_owned()
    }

    /// **Press** the shown, enabled `selector` as a person does: a real mouse press and release at
    /// its middle, so the page holds the person's gesture.
    pub(crate) fn press(&self, selector: &str) {
        let sel = serde_json::to_string(selector).expect("json");
        let find = format!("[...document.querySelectorAll({sel})].find(x => x.offsetParent !== null && !x.disabled)");
        self.until(&format!("{selector} shown and enabled"), &format!("!!({find})"));
        let at = self.js(&format!("(() => {{ const e = {find}; e.scrollIntoView({{block: 'center'}}); const r = e.getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; }})()"));
        let (x, y) = (at[0].as_f64().expect("x"), at[1].as_f64().expect("y"));
        for kind in ["mousePressed", "mouseReleased"] {
            self.on_page("Input.dispatchMouseEvent", json!({ "type": kind, "x": x, "y": y, "button": "left", "clickCount": 1 }));
        }
    }

    /// **Move the mouse onto** the shown `selector` (a person reaching for the button).
    fn hover(&self, selector: &str) {
        let sel = serde_json::to_string(selector).expect("json");
        let find = format!("[...document.querySelectorAll({sel})].find(x => x.offsetParent !== null)");
        self.until(&format!("{selector} shown"), &format!("!!({find})"));
        let at = self.js(&format!("(() => {{ const e = {find}; e.scrollIntoView({{block: 'center'}}); const r = e.getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; }})()"));
        let (x, y) = (at[0].as_f64().expect("x"), at[1].as_f64().expect("y"));
        self.on_page("Input.dispatchMouseEvent", json!({ "type": "mouseMoved", "x": x, "y": y }));
    }

    fn ceremonies(&self) -> Vec<Value> {
        self.js("JSON.parse(sessionStorage.getItem('__ceremonies') || '[]')").as_array().cloned().unwrap_or_default()
    }
}

impl Drop for Chromium {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

/// One Chromium at a time: three at once on a loaded box let a 5 s press lapse on its own.
pub(crate) static ONE_BROWSER: Mutex<()> = Mutex::new(());

/// Set by CI's `browser` step (`cargo xtask browser`): the browser MUST be here.
const NEEDS_BROWSER: &str = "GUNNAR_UI_NEEDS_BROWSER";

pub(crate) fn chrome() -> Option<String> {
    browser_for(std::env::var("CHROME").ok(), std::env::var(NEEDS_BROWSER).is_ok_and(|v| v == "1"))
}

/// The Chromium to drive, or `None` (NOT RUN) when there is none.
fn browser_for(chrome: Option<String>, required: bool) -> Option<String> {
    let c = chrome.filter(|c| !c.is_empty());
    if c.is_none() && required {
        panic!("{NEEDS_BROWSER}=1 but CHROME names no Chromium: the browser step must run the first touch, never pass it as NOT RUN");
    }
    if c.is_none() {
        eprintln!("NOT RUN: set CHROME to a Chromium binary for the first touch after paying");
    }
    c
}

/// RA2-7: where the CI's browser step runs these tests, a missing browser is RED, never a
/// green NOT RUN; elsewhere (a plain `cargo test`) it stays a named NOT RUN.
#[test]
#[should_panic(expected = "GUNNAR_UI_NEEDS_BROWSER")]
fn no_browser_is_red_where_the_browser_step_requires_one() {
    browser_for(None, true);
}

#[test]
fn no_browser_is_a_named_not_run_elsewhere_and_a_browser_is_used() {
    assert_eq!(browser_for(None, false), None);
    assert_eq!(browser_for(Some(String::new()), false), None);
    assert_eq!(browser_for(Some("/usr/bin/chromium".into()), true).as_deref(), Some("/usr/bin/chromium"));
}

/// The console on `http://localhost:<port>` (a secure context), its relying party `localhost`,
/// over the fake gunnar (bob holds no passkey yet) and the fake monetize (bob's paid sign-up).
async fn console_on_loopback() -> (String, u16, Fake) {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("a loopback port");
    let port = listener.local_addr().expect("its address").port();
    let base = format!("http://localhost:{port}");
    let fake = Fake::new();
    fake.person("bob", None);
    *fake.origin.lock().expect("the origin") = Some(base.clone());
    let rig = console_selling_at(fake.clone(), Some(FakeMonetize::created("bob")), "localhost", &base).await;
    let app = rig.router.clone();
    tokio::spawn(async move { axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>()).await });
    // The rig's own handles stay alive with the test (its store, its gunnar).
    std::mem::forget(rig);
    (base, port, fake)
}

/// Back from paying (the welcome page), the passkey step, and the first touch: one registration
/// and one sign-in, each inside the person's press.
fn first_touch(c: &Chromium, base: &str) {
    c.open(&format!("{base}/welcome?session={SESSION}"));
    c.until("the passkey step", "location.pathname === '/login' && !!document.getElementById('register') && document.getElementById('register').textContent === 'Touch your security key'");
    assert!(c.text().contains("Payment received — your account bob is ready. Touch your security key to finish."), "{}", c.text());
    // The person reads the line and presses once the button is offered.
    std::thread::sleep(SLOW + Duration::from_secs(1));
    c.press("#register");
    // Signed in, the login page goes on to `next` (`/`: the console, which this rig does not
    // carry); the record is read back on a page of this origin that stays put.
    let went_on = c.holds("location.pathname === '/'", Duration::from_secs(30));
    let said = c.text();
    c.open(&format!("{base}/signup"));
    c.until("a page of the console", "document.readyState === 'complete' && location.pathname === '/signup'");
    let signed_in = went_on && c.holds("fetch('/auth/status').then(r => r.json()).then(s => s.authenticated === true)", Duration::from_secs(10));
    let ceremonies = c.ceremonies();
    assert!(signed_in, "the first touch after paying did not sign bob in.\nceremonies: {ceremonies:?}\n--- the page said ---\n{said}");
    assert_eq!(ceremonies.iter().filter(|c| c["kind"] == "create").count(), 1, "one registration: {ceremonies:?}");
    assert_eq!(ceremonies.iter().filter(|c| c["kind"] == "get").count(), 1, "one sign-in, the passkey just made: {ceremonies:?}");
    // The sign-in names the passkey just made and no other, so the device offers no choice.
    assert!(ceremonies.iter().filter(|c| c["kind"] == "get").all(|c| c["allow"] == 1), "the sign-in names the passkey just made: {ceremonies:?}");
    for one in &ceremonies {
        assert_eq!(one["gesture"], true, "a ceremony started without the person's press: {one} (all: {ceremonies:?})");
        assert_eq!(one["overlap"], false, "two ceremonies at once: {one}");
        assert_eq!(one["navigated"], false, "the page navigated while a ceremony ran: {one}");
        assert_eq!(one["outcome"], "ok", "{one}");
    }
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_first_touch_after_paying_registers_and_signs_in_inside_the_press() {
    let Some(chrome) = chrome() else { return };
    let (base, _, fake) = console_on_loopback().await;
    // Right after paying gunnar is slow to open the registration (its first challenge).
    fake.challenge_delays.lock().expect("the delays").push_back(SLOW);
    tokio::task::spawn_blocking(move || {
        let _one = ONE_BROWSER.lock().unwrap_or_else(|p| p.into_inner());
        let c = Chromium::launch(&chrome);
        first_touch(&c, &base);
    })
    .await
    .expect("the browser half");
    assert_eq!(fake.registrations.lock().expect("registrations").len(), 1, "gunnar registered one passkey");
    assert_eq!(fake.registrations.lock().expect("registrations")[0].principal_id, "bob");
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_first_touch_back_from_the_vendor_finds_the_buyer_signed_in() {
    let Some(chrome) = chrome() else { return };
    let (base, port, fake) = console_on_loopback().await;
    fake.challenge_delays.lock().expect("the delays").push_back(SLOW);
    tokio::task::spawn_blocking(move || {
        let _one = ONE_BROWSER.lock().unwrap_or_else(|p| p.into_inner());
        let c = Chromium::launch(&chrome);
        first_touch(&c, &base);
        // ── signed in, bob buys more room; the vendor sends him back, from ITS site ──
        // `127.0.0.1` is another site than `localhost`: a link pressed there is the vendor's
        // top-level hop, and a SameSite=Strict cookie does not ride it.
        c.open(&format!("http://127.0.0.1:{port}/login"));
        c.until("the vendor's page", "document.readyState === 'complete'");
        let back = format!("{base}/paid/ord-bob-1");
        c.js(&format!("(() => {{ const a = document.createElement('a'); a.id = 'back'; a.href = {}; a.textContent = 'Back to the shop'; document.body.prepend(a); return true; }})()", serde_json::to_string(&back).unwrap()));
        c.press("#back");
        c.until("the way back lands", "location.host.startsWith('localhost') && document.readyState === 'complete'");
        let at = c.js("location.pathname").as_str().unwrap_or_default().to_owned();
        assert_eq!(at, "/paid/ord-bob-1", "back from the vendor the order's page opens, no fresh login: {}\n{}", c.js("location.href"), c.text());
        assert!(
            c.holds("fetch('/auth/status').then(r => r.json()).then(s => s.authenticated === true)", Duration::from_secs(10)),
            "the order page's own asks carry bob's session"
        );
        assert_eq!(c.ceremonies().iter().filter(|c| c["kind"] == "get").count(), 1, "no second sign-in ceremony: {:?}", c.ceremonies());
    })
    .await
    .expect("the browser half");
}

/// ★ UI-ENC: the repository encryption switch's info box, in the same browser (its driver).
mod encryption_info;

/// ★ SHADOW-SEL: a selected README shows its words once, in the same browser (its driver).
mod readme_selection;

/// ★ LOGIN-1: `/login` is the login, in the same browser (its driver).
mod login_is_login;

/// ★ RA2-17: the sign-in right after the first passkey, and the moved passkey's one more touch,
/// in the same browser (its driver).
mod sign_in_after;

/// ★ RA2-6: **the plain Log in starts its passkey inside the press.** A person who already holds
/// a passkey comes back signed out and presses Log in while gunnar is slow to hand out the
/// start (6 s, longer than a press stays live). The start is asked when the person reaches for
/// the button (hover or focus), never on every anonymous view; a press that finds it still on
/// its way waits and, with the gesture gone, asks for one more press instead of starting a
/// ceremony nobody pressed for. The next press signs in at once.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_plain_log_in_starts_its_passkey_inside_the_press() {
    let Some(chrome) = chrome() else { return };
    let (base, _, fake) = console_on_loopback().await;
    let slow = fake.challenge_delays.clone();
    tokio::task::spawn_blocking(move || {
        let _one = ONE_BROWSER.lock().unwrap_or_else(|p| p.into_inner());
        let c = Chromium::launch(&chrome);
        first_touch(&c, &base);
        let before = c.ceremonies().len();
        c.on_page("Network.clearBrowserCookies", json!({}));
        c.open(&format!("{base}/login"));
        c.until("the login page, signed out", "document.readyState === 'complete' && location.pathname === '/login' && !!document.getElementById('login') && document.getElementById('login').offsetParent !== null");
        std::thread::sleep(Duration::from_millis(500));
        slow.lock().expect("the delays").push_back(SLOW);
        c.hover("#login");
        std::thread::sleep(Duration::from_millis(200));
        c.press("#login");
        let again = c.holds("document.body.innerText.includes('Ready. Touch your security key again.')", SLOW + Duration::from_secs(8));
        let said = c.text();
        let now: Vec<Value> = c.ceremonies().into_iter().skip(before).collect();
        assert!(now.iter().all(|one| one["gesture"] == true), "a sign-in ceremony started without the person's press: {now:?}\n--- the page said ---\n{said}");
        assert!(again, "the slow start did not ask for one more press.\nceremonies: {now:?}\n--- the page said ---\n{said}");
        c.press("#login");
        let went_on = c.holds("location.pathname === '/'", Duration::from_secs(20));
        // The record is read back on a page of this origin that stays put (`/` is the console,
        // which this rig does not carry).
        c.open(&format!("{base}/signup"));
        c.until("a page of the console", "document.readyState === 'complete' && location.pathname === '/signup'");
        let now: Vec<Value> = c.ceremonies().into_iter().skip(before).collect();
        assert!(went_on, "the second press did not sign in: {now:?}\n{}", c.text());
        assert_eq!(now.iter().filter(|c| c["kind"] == "get").count(), 1, "one sign-in ceremony: {now:?}");
        for one in &now {
            assert_eq!((one["gesture"].clone(), one["outcome"].clone()), (json!(true), json!("ok")), "{one}");
        }
    })
    .await
    .expect("the browser half");
}