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

nordisk/monetize

uitest/src/rig.rs

//! The rig: a mock monetize and a REAL `monetize-ui-server`, both on loopback,
//! both this process's children, both gone when it exits.
//!
//! gunnar-ui's `rig.rs`, with gunnar swapped for the mock (which is this very
//! binary in `--mock-serve` mode, so there is one thing to build).
//!
//! Two postures, because the screens and the login are different questions:
//!
//! * [`Posture::Open`] — no passkey relying party, no admin token, bound to
//!   loopback: the console runs every request as admin (`trunk serve`'s
//!   posture, confined by refusal). The FOUR SCREENS are walked here, so a
//!   hiccup in the WebAuthn ceremony cannot paint every screen row red.
//! * [`Posture::Passkey`] — a relying party on `localhost` and a break-glass
//!   token: the ceremony itself, and the proof that the token retires.

use std::io::{BufRead, BufReader};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};

use crate::http;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Posture {
    Open,
    Passkey,
}

pub struct Rig {
    pub work: PathBuf,
    pub ui: u16,
    pub mock_addr: String,
    /// The break-glass admin token (passkey posture only).
    pub admin_token: String,
    pub posture: Posture,
    mock: Option<Child>,
    console: Option<Child>,
    keep: bool,
}

fn free_port() -> Result<u16, String> {
    let l = TcpListener::bind("127.0.0.1:0").map_err(|e| format!("bind 0: {e}"))?;
    Ok(l.local_addr()
        .map_err(|e| format!("local_addr: {e}"))?
        .port())
}

/// `http://localhost:NNNN` or `http://127.0.0.1:NNNN` — the only origins the
/// harness will drive. Anything else is somebody's deployment.
pub fn is_loopback_origin(origin: &str) -> bool {
    let rest = origin
        .strip_prefix("http://")
        .or_else(|| origin.strip_prefix("https://"))
        .unwrap_or("");
    let host = rest
        .split('/')
        .next()
        .unwrap_or("")
        .rsplit_once(':')
        .map(|(h, _)| h)
        .unwrap_or(rest);
    host == "localhost" || host == "127.0.0.1" || host == "[::1]"
}

impl Rig {
    pub fn up(
        ui_bin: &str,
        dist: &Path,
        work: &Path,
        keep: bool,
        posture: Posture,
    ) -> Result<Rig, String> {
        // Every run starts from nothing: the passkey posture is only true once
        // per state store (the token retires the first time an admin passkey
        // exists). The marker is the safety catch: this deletes a directory,
        // so it refuses one it did not make.
        let marker = work.join(".monetize-uitest");
        if work.exists() {
            if !marker.exists() {
                return Err(format!("{} already exists and was not made by monetize-uitest; point --work somewhere else rather than have this delete it", work.display()));
            }
            std::fs::remove_dir_all(work)
                .map_err(|e| format!("clearing {}: {e}", work.display()))?;
        }
        std::fs::create_dir_all(work.join("data")).map_err(|e| format!("work dir: {e}"))?;
        std::fs::write(&marker, "monetize-uitest scratch\n").map_err(|e| format!("marker: {e}"))?;

        let token = format!("uitest-{}", std::process::id());
        let admin_token = format!("break-glass-{}", std::process::id());
        let me = std::env::current_exe().map_err(|e| format!("current_exe: {e}"))?;

        // The mock: this binary, serving the fixture. It announces its port on
        // stdout, and that line is the readiness signal.
        let mut mock = Command::new(&me)
            .current_dir(work)
            .args(["--mock-serve", "127.0.0.1:0", "--token", &token])
            .stdout(Stdio::piped())
            .stderr(Stdio::from(
                std::fs::File::create(work.join("mock.err")).map_err(|e| e.to_string())?,
            ))
            .spawn()
            .map_err(|e| format!("spawn the mock ({}): {e}", me.display()))?;
        let mock_addr = {
            let out = mock.stdout.take().ok_or("the mock has no stdout")?;
            let mut line = String::new();
            BufReader::new(out)
                .read_line(&mut line)
                .map_err(|e| format!("reading the mock's port: {e}"))?;
            match line.trim().strip_prefix("listening ") {
                Some(a) => a.to_owned(),
                None => {
                    let _ = mock.kill();
                    return Err(format!(
                        "the mock said {line:?} instead of its port; see {}",
                        work.join("mock.err").display()
                    ));
                }
            }
        };

        let ui = free_port()?;
        let bind = format!("127.0.0.1:{ui}");
        let mut console = Command::new(ui_bin);
        console
            .current_dir(work)
            .args([
                "--bind",
                &bind,
                "--control",
                &mock_addr,
                "--token",
                &token,
                "--data",
            ])
            .arg(work.join("data"))
            .arg("--ui-dir")
            .arg(dist)
            .args(["--poll-secs", "1"])
            .env_remove("MONETIZE_SERVER_TOKEN")
            .env_remove("MONETIZE_UI_DATA")
            .env_remove("MONETIZE_UI_ADMIN_TOKEN")
            .env_remove("MONETIZE_UI_WEBAUTHN_RP_ID")
            .env_remove("MONETIZE_UI_WEBAUTHN_ORIGIN")
            .env_remove("MONETIZE_UI_WEBAUTHN_RP_NAME")
            .stdout(Stdio::from(
                std::fs::File::create(work.join("ui.log")).map_err(|e| e.to_string())?,
            ))
            .stderr(Stdio::from(
                std::fs::File::create(work.join("ui.err")).map_err(|e| e.to_string())?,
            ));
        if posture == Posture::Passkey {
            // `localhost` is a secure context in Chrome, so WebAuthn works over
            // plain http here. It must be a NAME: the console refuses a
            // dotted-quad RP id — which is why the rig binds 127.0.0.1 but the
            // browser is sent to localhost.
            console
                .env("MONETIZE_UI_ADMIN_TOKEN", &admin_token)
                .env("MONETIZE_UI_WEBAUTHN_RP_ID", "localhost")
                .env(
                    "MONETIZE_UI_WEBAUTHN_ORIGIN",
                    format!("http://localhost:{ui}"),
                )
                .env("MONETIZE_UI_WEBAUTHN_RP_NAME", "monetize uitest");
        }
        let console = console
            .spawn()
            .map_err(|e| format!("spawn {ui_bin}: {e}"))?;

        let rig = Rig {
            work: work.to_path_buf(),
            ui,
            mock_addr,
            admin_token,
            posture,
            mock: Some(mock),
            console: Some(console),
            keep,
        };

        let deadline = Instant::now() + Duration::from_secs(30);
        loop {
            if let Ok(r) = http::req(&bind, "GET", "/health", &[], None) {
                if r.status == 200 && r.body.trim() == "ok" {
                    break;
                }
            }
            if Instant::now() > deadline {
                return Err(format!(
                    "the console did not serve /health on {bind} within 30 s: {}",
                    http::cut(
                        &std::fs::read_to_string(work.join("ui.err")).unwrap_or_default(),
                        400
                    )
                ));
            }
            std::thread::sleep(Duration::from_millis(150));
        }
        Ok(rig)
    }

    pub fn ui_addr(&self) -> String {
        format!("127.0.0.1:{}", self.ui)
    }

    /// What the BROWSER must use. A passkey is scoped to the origin, so a page
    /// opened on 127.0.0.1 could never assert a credential registered on
    /// localhost.
    pub fn origin(&self) -> String {
        format!("http://localhost:{}", self.ui)
    }
}

impl Drop for Rig {
    fn drop(&mut self) {
        // The console first (it holds a stream on the mock), then the mock.
        for c in [self.console.as_mut(), self.mock.as_mut()]
            .into_iter()
            .flatten()
        {
            let _ = c.kill();
            let _ = c.wait();
        }
        if !self.keep {
            let _ = std::fs::remove_dir_all(self.work.join("data"));
        }
    }
}