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

nordisk/holger-ui

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,
    }
}