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"));
}
}
}