//! **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());
}
}