browser-tests/src/browser.rs
//! **The browser**: a headless Chromium over the DevTools protocol (chromiumoxide).
//!
//! * Name resolution is closed (`--host-resolver-rules`): only `localhost` resolves, so the
//! page can reach nothing but the rig, and a step to the vendor's checkout ends on its URL
//! (the fake vendor is the test's: it pays and follows the way back).
//! * Every request every tab makes is written down ([`Browser::requests`]), from the DevTools
//! `Network.requestWillBeSent` event, and [`Browser::assert_nothing_went_to`] reads them.
//! * Each tab has its own virtual authenticator (WebAuthn domain, CTAP2, internal, resident
//! keys, user verification passes): one tab is one person with one passkey device.
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use anyhow::{anyhow, bail, Result};
use chromiumoxide::browser::BrowserConfig;
use chromiumoxide::cdp::browser_protocol::dom::SetFileInputFilesParams;
use chromiumoxide::cdp::browser_protocol::fetch::{EnableParams as FetchEnable, EventRequestPaused, FulfillRequestParams, HeaderEntry, RequestPattern};
use chromiumoxide::cdp::browser_protocol::network::{EnableParams as NetworkEnable, EventRequestWillBeSent};
use chromiumoxide::cdp::browser_protocol::page::AddScriptToEvaluateOnNewDocumentParams;
use chromiumoxide::cdp::browser_protocol::web_authn::{AddCredentialParams, AddVirtualAuthenticatorParams, AuthenticatorId, AuthenticatorProtocol, AuthenticatorTransport, EnableParams as WebAuthnEnable, GetCredentialsParams, RemoveVirtualAuthenticatorParams, VirtualAuthenticatorOptions};
use chromiumoxide::Page;
use futures::StreamExt as _;
/// The host the browser must never reach, by link or request (owner: the buyer never sees
/// monetize).
pub const NEVER: &str = "monetize.ignalina.dk";
/// The console's session cookie (holger-ui-server `session::SESSION_COOKIE`).
pub const SESSION_COOKIE: &str = "__Host-holger_session";
/// **Signed in?**, as a page asks holger (an expression; the promise is awaited).
pub const SIGNED_IN: &str = "(async () => { try { return (await fetch('/-/auth/passkey/session', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).ok; } catch (e) { return false; } })()";
/// **Signed in, or the page says registered** (a registration that answers no session).
pub const SIGNED_IN_OR_REGISTERED: &str = "(async () => { try { if ((await fetch('/-/auth/passkey/session', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).ok) return true; } catch (e) {} const m = document.getElementById('msg'); return !!m && /registered/.test(m.textContent); })()";
/// **This tab forgets its session** (the console server clears its cookie; holger's session lives on).
pub const SIGN_OUT_TAB: &str = "fetch('/-/console/token', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{\"token\":\"\"}' }).then(r => r.ok)";
/// ★ **The ceremony watch** (A29-20, "passkey request was cancelled" right after "Payment
/// received"): put before every document's own script, it wraps `navigator.credentials.create`
/// and `.get` and writes each ceremony down in `window.__ceremonies` (and the tab's
/// `sessionStorage`, so a navigation keeps the record): its kind, whether the person's gesture
/// was still live when it started (`navigator.userActivation.isActive`; Chromium keeps a click
/// live for 5 s), whether another ceremony was still running, and whether the page navigated
/// away while one ran. A ceremony started without a live gesture is refused the way Safari
/// refuses it (`NotAllowedError`), so a page that waits on the network between the press and
/// the ceremony shows here what a buyer saw.
pub const CEREMONY_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;
window.__ceremonies = load;
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();
const row = { kind, gesture: !!(navigator.userActivation && navigator.userActivation.isActive), overlap: running > 0, navigated: false, path: location.pathname, outcome: '' };
all.push(row);
keep(all);
const at = all.length - 1;
const settle = (outcome) => { const now = load(); if (now[at]) { now[at].outcome = outcome; keep(now); } };
if (!row.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; });
};
}
})();"#;
/// The fake vendor's checkout page.
const VENDOR_PAGE: &str = "<!doctype html><title>Checkout (fake vendor)</title><h1>Pay at the vendor</h1>";
pub struct Browser {
inner: chromiumoxide::Browser,
requests: Arc<Mutex<Vec<String>>>,
_profile: tempfile::TempDir,
}
/// **One tab**: one person, one passkey device.
pub struct Tab {
pub page: Page,
/// The tab's virtual authenticator (its PIN or fingerprint: [`Tab::user_verified`]).
authenticator: Mutex<AuthenticatorId>,
}
impl Browser {
/// **Launch Chromium** (`$CHROME`, else `chromium`).
pub async fn launch() -> Result<Browser> {
let chrome = std::env::var("CHROME").unwrap_or_else(|_| "chromium".into());
let profile = tempfile::tempdir()?;
let config = BrowserConfig::builder()
.chrome_executable(chrome)
.new_headless_mode()
.no_sandbox()
.user_data_dir(profile.path())
.window_size(1280, 900)
.arg("--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost")
.arg("--disable-gpu")
.arg("--disable-dev-shm-usage")
.launch_timeout(Duration::from_secs(60))
.request_timeout(Duration::from_secs(60))
.build()
.map_err(|e| anyhow!("the browser's config: {e}"))?;
let (inner, mut handler) = chromiumoxide::Browser::launch(config).await?;
tokio::spawn(async move { while handler.next().await.is_some() {} });
Ok(Browser { inner, requests: Arc::default(), _profile: profile })
}
/// **A new tab** with its own virtual authenticator, every request written down. Tabs of one
/// browser share its cookies, so one person's session (A34-38: an HttpOnly cookie, as
/// Gunnar's); another person is [`Browser::person`].
pub async fn tab(&self) -> Result<Tab> {
self.tab_in(None).await
}
/// **Another person**: a tab in a browser context of its own (its own cookies, so its own
/// session), on the same browser, every request written down with the rest.
pub async fn person(&self) -> Result<Tab> {
let context = self.inner.create_browser_context(chromiumoxide::cdp::browser_protocol::target::CreateBrowserContextParams::default()).await?;
self.tab_in(Some(context)).await
}
async fn tab_in(&self, context: Option<chromiumoxide::cdp::browser_protocol::browser::BrowserContextId>) -> Result<Tab> {
let mut target = chromiumoxide::cdp::browser_protocol::target::CreateTargetParams::new("about:blank");
target.browser_context_id = context;
let page = self.inner.new_page(target).await?;
page.execute(NetworkEnable::default()).await?;
let mut sent = page.event_listener::<EventRequestWillBeSent>().await?;
let log = self.requests.clone();
tokio::spawn(async move {
while let Some(e) = sent.next().await {
log.lock().unwrap().push(e.request.url.clone());
}
});
// ★ **The fake vendor's page**: the checkout's address answers a page of its own, so the
// tab is AT the vendor (its address the checkout's) until the test pays and follows the
// way back. Nothing of it reaches a network.
let vendor = format!("{}/*", monetize_site_buy::testkit::STRIPE);
page.execute(FetchEnable::builder().pattern(RequestPattern::builder().url_pattern(vendor).build()).build()).await?;
let mut paused = page.event_listener::<EventRequestPaused>().await?;
let answer = page.clone();
tokio::spawn(async move {
while let Some(e) = paused.next().await {
let mut f = FulfillRequestParams::new(e.request_id.clone(), 200);
f.response_headers = Some(vec![HeaderEntry { name: "content-type".into(), value: "text/html; charset=utf-8".into() }]);
f.body = Some(base64::Engine::encode(&base64::engine::general_purpose::STANDARD, VENDOR_PAGE).into());
let _ = answer.execute(f).await;
}
});
page.execute(WebAuthnEnable::default()).await?;
let authenticator = page.execute(AddVirtualAuthenticatorParams::new(authenticator(true))).await?.result.authenticator_id.clone();
Ok(Tab { page, authenticator: Mutex::new(authenticator) })
}
/// Every URL every tab asked for, in order.
pub fn requests(&self) -> Vec<String> {
self.requests.lock().unwrap().clone()
}
/// **No request went to `host`**, and every request stayed on the rig (`base`), was the
/// vendor's checkout (`vendor`), or was the page's own data.
pub fn assert_nothing_went_to(&self, host: &str, base: &str, vendor: &str) {
let all = self.requests();
assert!(!all.is_empty(), "the browser made requests");
let bad: Vec<&String> = all.iter().filter(|u| u.contains(host)).collect();
assert!(bad.is_empty(), "a request went to {host}: {bad:?}");
let off: Vec<&String> = all
.iter()
.filter(|u| !(u.starts_with(&format!("{base}/")) || *u == base || u.starts_with(vendor) || u.starts_with("data:") || u.starts_with("blob:") || u.starts_with("about:") || u.starts_with("chrome-error:")))
.collect();
assert!(off.is_empty(), "requests left the site: {off:?}");
}
}
impl Tab {
/// **Watch this tab's passkey ceremonies** ([`CEREMONY_WATCH`]) on every document it opens
/// from now on.
pub async fn watch_ceremonies(&self) -> Result<()> {
self.page.execute(AddScriptToEvaluateOnNewDocumentParams::new(CEREMONY_WATCH)).await?;
Ok(())
}
/// Every ceremony this tab started since [`Tab::watch_ceremonies`], in order (`kind`,
/// `gesture`, `overlap`, `navigated`, `path`, `outcome`).
pub async fn ceremonies(&self) -> Vec<serde_json::Value> {
let v = self.js("JSON.parse(sessionStorage.getItem('__ceremonies') || '[]')").await.unwrap_or_default();
v.as_array().cloned().unwrap_or_default()
}
/// **Press** the first shown, enabled element matching `selector` the way a person does: a
/// real mouse press and release at its middle (DevTools `Input`), so the page gets the
/// person's gesture (`navigator.userActivation`). [`Tab::click`] calls `click()` from a
/// script, which carries no gesture.
pub async fn press(&self, selector: &str) {
let sel = serde_json::to_string(selector).unwrap();
let find = format!("[...document.querySelectorAll({sel})].find(x => x.offsetParent !== null && !x.disabled)");
self.until(&format!("{selector} shown and enabled"), &format!("!!({find})")).await;
self.js(&format!("(() => {{ const e = {find}; e.setAttribute('data-press', '1'); return true; }})()")).await.expect("the mark");
let e = self.page.find_element("[data-press='1']").await.expect("the element to press");
let _ = self.js("document.querySelector(\"[data-press='1']\").removeAttribute('data-press')").await;
e.click().await.expect("the press");
}
/// **Move the mouse onto** the first shown element matching `selector` (a person reaching
/// for it): DevTools mouse movement, so the page sees `pointerenter`.
pub async fn hover(&self, selector: &str) {
let sel = serde_json::to_string(selector).unwrap();
let find = format!("[...document.querySelectorAll({sel})].find(x => x.offsetParent !== null)");
self.until(&format!("{selector} shown"), &format!("!!({find})")).await;
self.js(&format!("(() => {{ const e = {find}; e.setAttribute('data-hover', '1'); return true; }})()")).await.expect("the mark");
let e = self.page.find_element("[data-hover='1']").await.expect("the element to hover");
let _ = self.js("document.querySelector(\"[data-hover='1']\").removeAttribute('data-hover')").await;
e.hover().await.expect("the hover");
}
/// [`Tab::press`] the shown button or link whose text is `words` exactly.
pub async fn press_text(&self, words: &str) {
let w = serde_json::to_string(words).unwrap();
let find = format!("[...document.querySelectorAll('button, a')].find(x => x.offsetParent !== null && !x.disabled && x.textContent.trim() === {w})");
self.until(&format!("a button {words:?}"), &format!("!!({find})")).await;
self.js(&format!("(() => {{ const e = {find}; e.setAttribute('data-press', '1'); return true; }})()")).await.expect("the mark");
let e = self.page.find_element("[data-press='1']").await.expect("the element to press");
let _ = self.js("document.querySelector(\"[data-press='1']\").removeAttribute('data-press')").await;
e.click().await.expect("the press");
}
/// The id of the tab's first passkey, base64url (as WebAuthn's `allowCredentials` names it).
pub async fn passkey_id(&self) -> Result<String> {
let id = self.authenticator.lock().unwrap().clone();
let held = self.page.execute(GetCredentialsParams::new(id)).await?.result.credentials.clone();
let first = held.first().ok_or_else(|| anyhow!("the tab's device holds no passkey"))?;
let std: &str = first.credential_id.as_ref();
Ok(std.replace('+', "-").replace('/', "_").trim_end_matches('=').to_owned())
}
/// **Whether the key verifies the person** (its PIN or fingerprint) on its next answers;
/// `false`: a plain touch, as a key with no PIN gives (A28 M9).
///
/// Chrome's virtual authenticator refuses a login outright when it can verify but is told
/// not to, so the tab's device is swapped instead: its passkeys (signature counts
/// included) move to an authenticator that can, or cannot, verify the person.
pub async fn user_verified(&self, yes: bool) -> Result<()> {
let old = self.authenticator.lock().unwrap().clone();
let held = self.page.execute(GetCredentialsParams::new(old.clone())).await?.result.credentials.clone();
anyhow::ensure!(!held.is_empty(), "the tab's device holds no passkey to move");
self.page.execute(RemoveVirtualAuthenticatorParams::new(old)).await?;
let new = self.page.execute(AddVirtualAuthenticatorParams::new(authenticator(yes))).await?.result.authenticator_id.clone();
for c in held {
self.page.execute(AddCredentialParams::new(new.clone(), c)).await?;
}
*self.authenticator.lock().unwrap() = new;
Ok(())
}
/// Open `url` and wait for its load.
pub async fn open(&self, url: &str) -> Result<()> {
self.page.goto(url).await?;
Ok(())
}
/// The tab's address now.
pub async fn url(&self) -> String {
self.page.url().await.ok().flatten().unwrap_or_default()
}
/// Evaluate `js` (an expression; a promise is awaited) as JSON.
pub async fn js(&self, js: &str) -> Result<serde_json::Value> {
let r = self.page.evaluate(js).await?;
Ok(r.value().cloned().unwrap_or(serde_json::Value::Null))
}
/// **Wait until `js` is truthy** (polled), and answer its value; `what` names it in the
/// failure, with the page's text and address.
pub async fn until(&self, what: &str, js: &str) -> serde_json::Value {
let end = Instant::now() + Duration::from_secs(30);
let mut last;
loop {
match self.js(js).await {
Ok(v) if truthy(&v) => return v,
Ok(v) => last = v.to_string(),
Err(e) => last = e.to_string(),
}
if Instant::now() > end {
let text = self.js("document.body ? document.body.innerText : ''").await.unwrap_or_default();
panic!("{what}: not seen within 30 s at {} (last: {last})\n--- the page says ---\n{}", self.url().await, text.as_str().unwrap_or_default());
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
/// Wait until the address starts with `prefix`; answer it.
pub async fn until_url(&self, what: &str, prefix: &str) -> String {
let end = Instant::now() + Duration::from_secs(30);
loop {
let u = self.url().await;
if u.starts_with(prefix) {
return u;
}
if Instant::now() > end {
let text = self.js("document.body ? document.body.innerText : ''").await.unwrap_or_default();
panic!("{what}: the tab never went to {prefix}… (it is at {u})\n--- the page says ---\n{}", text.as_str().unwrap_or_default());
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
/// Click the first element matching `selector` that is shown (waits for it).
pub async fn click(&self, selector: &str) {
let sel = serde_json::to_string(selector).unwrap();
self.until(&format!("{selector} shown"), &format!("(() => {{ const e = [...document.querySelectorAll({sel})].find(x => x.offsetParent !== null && !x.disabled); return !!e; }})()")).await;
self.js(&format!("(() => {{ const e = [...document.querySelectorAll({sel})].find(x => x.offsetParent !== null && !x.disabled); e.click(); return true; }})()")).await.expect("the click");
}
/// Click the shown button or link whose text is `words` exactly (waits for it).
pub async fn click_text(&self, words: &str) {
let w = serde_json::to_string(words).unwrap();
let find = format!("[...document.querySelectorAll('button, a')].find(x => x.offsetParent !== null && !x.disabled && x.textContent.trim() === {w})");
self.until(&format!("a button {words:?}"), &format!("!!({find})")).await;
self.js(&format!("(() => {{ const e = {find}; e.click(); return true; }})()")).await.expect("the click");
}
/// Type `text` into the input `selector` (its value set, then `input` and `change`).
pub async fn fill(&self, selector: &str, text: &str) {
let (sel, t) = (serde_json::to_string(selector).unwrap(), serde_json::to_string(text).unwrap());
self.until(&format!("{selector} present"), &format!("!!document.querySelector({sel})")).await;
self.js(&format!("(() => {{ const e = document.querySelector({sel}); e.value = {t}; e.dispatchEvent(new Event('input', {{bubbles: true}})); e.dispatchEvent(new Event('change', {{bubbles: true}})); return true; }})()")).await.expect("the typing");
}
/// Pick `file` in the file input `selector`.
pub async fn pick_file(&self, selector: &str, file: &std::path::Path) -> Result<()> {
let e = self.page.find_element(selector).await?;
let mut p = SetFileInputFilesParams::new(vec![file.display().to_string()]);
p.backend_node_id = Some(e.backend_node_id);
self.page.execute(p).await?;
Ok(())
}
/// The page's text.
pub async fn text(&self) -> String {
self.js("document.body ? document.body.innerText : ''").await.ok().and_then(|v| v.as_str().map(str::to_owned)).unwrap_or_default()
}
/// Whether the element `selector` is shown.
pub async fn shown(&self, selector: &str) -> bool {
let sel = serde_json::to_string(selector).unwrap();
truthy(&self.js(&format!("(() => {{ const e = document.querySelector({sel}); return !!e && !e.hidden && e.offsetParent !== null; }})()")).await.unwrap_or_default())
}
/// **Is this tab signed in?** holger's session door, asked as the page asks it: the session is
/// the console's HttpOnly cookie (A34-38), which no script can read.
///
/// ★ A page that is navigating (a registration that signed in moves on to the console) has
/// no context to ask in, or its ask is cut off: the answer is asked again once the page has
/// landed, so "not signed in" is never a navigation's answer.
pub async fn signed_in(&self) -> bool {
for _ in 0..3 {
if let Ok(v) = self.js(SIGNED_IN).await {
if truthy(&v) {
return true;
}
}
tokio::time::sleep(Duration::from_millis(700)).await;
}
false
}
/// The session cookie's value, as the browser holds it (read over DevTools: no script can).
pub async fn session_cookie(&self) -> String {
self.page.get_cookies().await.ok().into_iter().flatten().find(|c| c.name == SESSION_COOKIE).map(|c| c.value).unwrap_or_default()
}
/// **No link, image, form or script on this page names `host`**, and no text does.
pub async fn assert_no_link_to(&self, host: &str) -> Result<()> {
let h = serde_json::to_string(host).unwrap();
let found = self
.js(&format!("[...document.querySelectorAll('[href],[src],[action]')].map(e => e.href || e.src || e.action || '').filter(u => String(u).includes({h})).concat(document.documentElement.outerHTML.includes({h}) ? ['(the page source)'] : [])"))
.await?;
if found.as_array().is_some_and(|a| !a.is_empty()) {
bail!("{} names {host}: {found}", self.url().await);
}
Ok(())
}
}
fn truthy(v: &serde_json::Value) -> bool {
match v {
serde_json::Value::Null => false,
serde_json::Value::Bool(b) => *b,
serde_json::Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
serde_json::Value::String(s) => !s.is_empty(),
_ => true,
}
}
/// A tab's passkey device: CTAP2, resident keys; `uv`: it verifies the person (a PIN or a
/// fingerprint, the platform's own), else it only answers a touch.
fn authenticator(uv: bool) -> VirtualAuthenticatorOptions {
VirtualAuthenticatorOptions {
protocol: AuthenticatorProtocol::Ctap2,
ctap2_version: None,
// A key that only answers a touch is a security key with no PIN set: USB.
transport: if uv { AuthenticatorTransport::Internal } else { AuthenticatorTransport::Usb },
has_resident_key: Some(true),
has_user_verification: Some(uv),
has_large_blob: None,
has_cred_blob: None,
has_min_pin_length: None,
has_prf: None,
automatic_presence_simulation: Some(true),
is_user_verified: Some(uv),
default_backup_eligibility: None,
default_backup_state: None,
}
}