Gunnar

nordisk/gunnar-ui

uitest/src/firstlogin.rs

//! **The buyer's first login** (private ownership, 2026-10-07) — the browser
//! half of the UpCloud mockup purchase of a private Gunnar.
//!
//! The seller's "ready" mail carries
//! `https://<company>.gunnar.rs/login?invite=<secret>&user=<account>`. This
//! mode is the buyer: a headless Chrome with a virtual authenticator of its
//! own opens that link (or the same path on the buyer's own domain, which
//! the front sends to the relying party's host), registers a passkey, logs in
//! with it, and asks the console who it is. A second browser then opens the
//! same link: it must register nothing (the invite is single use).
//!
//! The estate is a mock's: its name is mapped to the address it answers at
//! (`--resolve <host>=<ip>:<port>`, repeatable), and ONLY its own certificate
//! is trusted (`--spki <base64 SHA-256 of its SubjectPublicKeyInfo>`), so the
//! page is a secure context and WebAuthn runs.
//!
//! The link is a secret: it is read from a file and never printed — a row
//! names the host and the account only.

use std::path::Path;

use crate::cdp::{Cdp, Chrome};
use crate::json::esc;
use crate::matrix::{go, want, Row};

/// What the register button's page says, or the login's verdict, has settled.
const MSG_SETTLED: &str = "!!document.getElementById('msg') && (document.getElementById('msg').classList.contains('ok') || document.getElementById('msg').classList.contains('err'))";

/// Logged in, or the page said why not.
const LOGIN_SETTLED: &str = "fetch('/auth/status').then(r => r.json()).then(j => \
     j.authenticated === true || !!document.getElementById('msg')?.classList.contains('err'))";

/// The link's parts: `(scheme://host, path?query, user)`. Pure.
pub fn parts(link: &str) -> Result<(String, String, String), String> {
    let link = link.trim();
    let rest = link.strip_prefix("https://").ok_or("the first-login link is not https://")?;
    let (host, path) = rest.split_once('/').ok_or("the first-login link has no path")?;
    let path = format!("/{path}");
    if !path.starts_with("/login?") || !path.contains("invite=") {
        return Err("the first-login link is not /login?invite=…".into());
    }
    let user = path.split("user=").nth(1).map(|u| u.split(['&', '#']).next().unwrap_or("").to_string()).unwrap_or_default();
    if user.is_empty() {
        return Err("the first-login link names no user".into());
    }
    Ok((format!("https://{host}"), path, user))
}

/// **Run the buyer's first login.** `link_file` holds the mail's link (or the
/// mail's whole text: the first `https://…/login?invite=` in it is taken).
/// `via`: the buyer's own domain, to open the link there instead.
pub fn run(chrome_bin: &str, link_file: &Path, via: Option<&str>, extra: Vec<String>) -> Vec<Row> {
    let mut rows: Vec<Row> = Vec::new();
    let text = match std::fs::read_to_string(link_file) {
        Ok(t) => t,
        Err(e) => {
            go(&mut rows, "the-ready-mails-link-is-read", || Err(format!("{}: {e}", link_file.display())));
            return rows;
        }
    };
    let link = text
        .split_whitespace()
        .find(|w| w.starts_with("https://") && w.contains("/login?invite="))
        .unwrap_or("")
        .trim_end_matches(['.', ',', ')', '>'])
        .to_string();
    let mut parsed = None;
    go(&mut rows, "the-ready-mails-link-is-read", || {
        let p = parts(&link)?;
        let said = format!("{}/login for {}", p.0, p.2);
        parsed = Some(p);
        Ok(said)
    });
    let Some((rp_origin, path, user)) = parsed else { return rows };
    let open_at = match via {
        Some(d) => format!("https://{}{path}", d.trim().trim_end_matches('/')),
        None => format!("{rp_origin}{path}"),
    };
    let _ = crate::cdp::EXTRA_ARGS.set(extra);

    let port = match std::net::TcpListener::bind("127.0.0.1:0").and_then(|l| l.local_addr()) {
        Ok(a) => a.port(),
        Err(e) => {
            go(&mut rows, "chrome-starts-headless", || Err(e.to_string()));
            return rows;
        }
    };
    let profile = std::env::temp_dir().join(format!("gunnar-uitest-first-login-{}", std::process::id()));
    let mut chrome = match Chrome::launch(chrome_bin, &profile, port) {
        Ok(c) => c,
        Err(e) => {
            go(&mut rows, "chrome-starts-headless", || Err(e));
            return rows;
        }
    };
    let mut c = match Cdp::attach(port) {
        Ok(c) => c,
        Err(e) => {
            go(&mut rows, "devtools-protocol-attaches", || Err(e));
            chrome.kill();
            return rows;
        }
    };

    let mut sess = String::new();
    let mut auth = String::new();
    let ok = go(&mut rows, "the-buyer-has-a-browser-and-an-empty-authenticator", || {
        sess = c.isolated_page()?;
        auth = c.add_authenticator(&sess)?;
        let held = c.credentials(&sess, &auth)?;
        want(held.is_empty(), || format!("{} credential(s) already", held.len()))?;
        Ok("a cookie jar and an authenticator of the buyer's own".into())
    });
    if ok {
        let rp_host = rp_origin.trim_start_matches("https://").to_string();
        go(&mut rows, "the-link-lands-on-the-relying-partys-host", || {
            c.navigate(&sess, &open_at)?;
            c.wait_for(&sess, "!!document.getElementById('register') || !!document.getElementById('msg')", 30_000)?;
            let at = c.eval_str(&sess, "location.host")?;
            want(at == rp_host, || format!("the page is at {at}, not the relying party {rp_host}"))?;
            // The secret is taken out of the address bar at once (login.html).
            let search = c.eval_str(&sess, "location.search")?;
            want(!search.contains("invite="), || "the invite stayed in the address bar".into())?;
            Ok(format!("{at}{}", if via.is_some() { " (sent there from the own domain)" } else { "" }))
        });
        let registered = go(&mut rows, "the-link-registers-the-buyers-first-passkey", || {
            c.eval(
                &sess,
                "(() => { const l = document.getElementById('label'); if (l) l.value = 'first-login';
                   document.getElementById('register').click(); return 'clicked'; })()",
            )?;
            c.wait_for(&sess, MSG_SETTLED, 30_000)?;
            let said = c.eval_str(&sess, "document.getElementById('msg').textContent")?;
            want(said.contains("registered"), || format!("the page said {said:?}"))?;
            let held = c.credentials(&sess, &auth)?;
            want(held.len() == 1, || format!("the authenticator holds {}", held.len()))?;
            Ok("one passkey, made by the buyer's authenticator".into())
        });
        if registered {
            go(&mut rows, "the-buyer-logs-in-as-the-orders-account-an-admin", || {
                c.navigate(&sess, &format!("{rp_origin}/login"))?;
                c.wait_for(&sess, "!!document.getElementById('login')", 30_000)?;
                c.eval(&sess, "document.getElementById('login').click()")?;
                c.wait_for(&sess, LOGIN_SETTLED, 30_000)?;
                let st = c.eval(&sess, "fetch('/auth/status').then(r => r.json())")?;
                want(st.path("authenticated").and_then(|v| v.as_bool()) == Some(true), || "not authenticated".into())?;
                want(st.text("user") == user, || format!("logged in as {:?}, the order's account is {user:?}", st.text("user")))?;
                want(st.text("role") == "admin", || format!("the buyer is {:?}, not the estate's admin", st.text("role")))?;
                Ok(format!("{} as {}", st.text("user"), st.text("role")))
            });
        }
        go(&mut rows, "the-link-is-spent-a-second-browser-registers-nothing", || {
            let other = c.isolated_page()?;
            let other_auth = c.add_authenticator(&other)?;
            c.navigate(&other, &format!("{rp_origin}{path}"))?;
            c.wait_for(&other, "!!document.getElementById('register')", 30_000)?;
            c.eval(&other, "document.getElementById('register').click()")?;
            c.wait_for(&other, MSG_SETTLED, 30_000)?;
            let ok = c.eval_str(&other, "String(document.getElementById('msg').classList.contains('ok'))")?;
            want(ok != "true", || "a second browser registered through the same link".into())?;
            let st = c.eval(&other, "fetch('/auth/status').then(r => r.json())")?;
            want(st.path("authenticated").and_then(|v| v.as_bool()) != Some(true), || "the second browser is logged in".into())?;
            // The key the second browser made is not on the estate: nobody
            // answers a login with it.
            let _ = other_auth;
            Ok(format!("refused: {}", esc(&c.eval_str(&other, "document.getElementById('msg').textContent").unwrap_or_default())))
        });
    }
    chrome.kill();
    let _ = std::fs::remove_dir_all(&profile);
    rows
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_links_parts_are_its_origin_its_path_and_its_user() {
        let (o, p, u) = parts("https://acme.gunnar.rs/login?invite=ab12&user=alice").unwrap();
        assert_eq!((o.as_str(), p.as_str(), u.as_str()), ("https://acme.gunnar.rs", "/login?invite=ab12&user=alice", "alice"));
        assert!(parts("http://acme.gunnar.rs/login?invite=a&user=b").is_err());
        assert!(parts("https://acme.gunnar.rs/login?user=b").is_err());
        assert!(parts("https://acme.gunnar.rs/login?invite=a").is_err());
    }
}