crates/monetize-ui-server/tests/screens_rig/mod.rs
//! The in-process rig the `screens_*` tests share: the BFF's router, driven
//! with `tower::ServiceExt::oneshot`, over the SAME mock monetize and the
//! SAME fixture the browser matrix runs against (`uitest/src/{fixture,mock}.rs`,
//! `#[path]`-included by each test file — one writer for every number).
//!
//! It is `tests/api.rs`'s `Rig`, with the mock lifted out. Each `screens_*.rs`
//! file declares the two shared modules at its crate root and then `mod
//! screens_rig;` — the mock reads `crate::fixture`, so the include has to be
//! at the root.
#![allow(dead_code)]
use std::path::PathBuf;
use std::sync::Arc;
use axum::body::Body;
use axum::Router;
use http::{Method, Request, StatusCode};
use http_body_util::BodyExt;
use monetize_ui_server::app::App;
use monetize_ui_server::auth::{RelyingParty, Webauthn};
use monetize_ui_server::grpc::Monetize;
use monetize_ui_server::role::Role;
use monetize_ui_server::serve;
use monetize_ui_server::sse::EventHub;
use monetize_ui_server::state::StateStore;
use serde_json::Value;
use tower::ServiceExt;
use crate::mock::Mock;
pub const BEARER: &str = "the-screens-bearer";
pub const ADMIN_TOKEN: &str = "break-glass";
pub const ORIGIN: &str = "https://console.example";
pub struct Rig {
pub router: Router,
pub mock: Arc<Mock>,
pub webauthn: Arc<Webauthn>,
pub ui_dir: Option<PathBuf>,
_dir: tempfile::TempDir,
}
pub enum Auth<'a> {
None,
Token,
Cookie(&'a str),
}
impl Rig {
pub async fn up() -> Rig {
Rig::up_with(Mock::new(), None).await
}
/// `ui_dir` = a directory holding an `index.html`, for the CSP tests; the
/// API-only shape otherwise.
pub async fn up_with(mock: Arc<Mock>, ui_dir: Option<PathBuf>) -> Rig {
let control = crate::mock::serve(mock.clone(), "127.0.0.1:0", BEARER).await.expect("the mock binds a port").to_string();
let dir = tempfile::tempdir().unwrap();
let store = StateStore::open(dir.path().join("state.redb")).unwrap();
let rp = RelyingParty { rp_id: "console.example".into(), origin: ORIGIN.into(), rp_name: "monetize".into() };
let webauthn = Arc::new(Webauthn::new(rp, store).unwrap());
let monetize = Monetize::connect_lazy(&control, BEARER).unwrap();
let app = Arc::new(App {
webauthn: Some(webauthn.clone()),
admin_token: Some(ADMIN_TOKEN.into()),
events: EventHub::spawn(monetize.clone(), std::time::Duration::from_secs(3600)),
monetize,
operator: None,
ui_dir: ui_dir.clone(),
});
Rig { router: serve::router(app), mock, webauthn, ui_dir, _dir: dir }
}
pub fn cookie(&self, role: Role) -> String {
self.webauthn.mint_session("bob", role, true)
}
pub async fn call(&self, method: Method, path: &str, auth: Auth<'_>, body: Option<Value>) -> (StatusCode, Value, http::HeaderMap) {
let (status, headers, bytes) = self.raw(method, path, auth, body, &[]).await;
if headers.get("content-type").is_some_and(|v| v.to_str().unwrap_or("").starts_with("text/event-stream")) {
return (status, Value::Null, headers);
}
let value = serde_json::from_slice(&bytes).unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).into_owned()));
(status, value, headers)
}
/// The whole response, bytes and all, with extra request headers.
pub async fn raw(&self, method: Method, path: &str, auth: Auth<'_>, body: Option<Value>, extra: &[(&str, &str)]) -> (StatusCode, http::HeaderMap, Vec<u8>) {
let mut req = Request::builder().method(method).uri(path).header("origin", ORIGIN);
for (k, v) in extra {
req = req.header(*k, *v);
}
match auth {
Auth::None => {}
Auth::Token => req = req.header("authorization", format!("Bearer {ADMIN_TOKEN}")),
Auth::Cookie(c) => req = req.header("cookie", format!("monetize_ui_session={c}")),
}
let req = match body {
Some(b) => req.header("content-type", "application/json").body(Body::from(b.to_string())).unwrap(),
None => req.body(Body::empty()).unwrap(),
};
let res = self.router.clone().oneshot(req).await.unwrap();
let status = res.status();
let headers = res.headers().clone();
// An SSE body never ends; `sse_until` reads those. Collecting one
// here hangs the test forever (measured 2026-09-05: the floors file
// sat on a Viewer's `GET /api/events` until killed).
if headers.get("content-type").is_some_and(|v| v.to_str().unwrap_or("").starts_with("text/event-stream")) {
return (status, headers, Vec::new());
}
let bytes = res.into_body().collect().await.unwrap().to_bytes().to_vec();
(status, headers, bytes)
}
/// A read as a Viewer — what every screen does.
pub async fn get(&self, path: &str) -> Value {
let viewer = self.cookie(Role::Viewer);
let (status, body, _) = self.call(Method::GET, path, Auth::Cookie(&viewer), None).await;
assert_eq!(status, StatusCode::OK, "GET {path}: {body}");
body
}
}
/// Open `/api/events` and return the `data:` payloads until `want` matches one,
/// or panic with everything seen. Reads REAL SSE bytes.
pub async fn sse_until(router: &Router, cookie: &str, deadline: tokio::time::Instant, want: impl Fn(&Value) -> bool) -> (Value, Vec<Value>) {
let req = Request::builder().method(Method::GET).uri("/api/events").header("origin", ORIGIN).header("cookie", format!("monetize_ui_session={cookie}")).body(Body::empty()).unwrap();
let res = router.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK, "the feed must open for a session");
assert!(res.headers().get("content-type").is_some_and(|v| v.to_str().unwrap_or("").starts_with("text/event-stream")));
let mut body = res.into_body();
let mut seen = Vec::new();
let mut buf = String::new();
loop {
let frame = tokio::time::timeout_at(deadline, body.frame()).await.unwrap_or_else(|_| panic!("the feed went quiet; frames so far: {seen:?}"));
let Some(Ok(frame)) = frame else { panic!("the feed ended; frames so far: {seen:?}") };
let Ok(data) = frame.into_data() else { continue };
buf.push_str(&String::from_utf8_lossy(&data));
while let Some(end) = buf.find("\n\n") {
let record: String = buf.drain(..end + 2).collect();
for line in record.lines() {
let Some(rest) = line.strip_prefix("data: ") else { continue };
let Ok(v) = serde_json::from_str::<Value>(rest) else { continue };
if want(&v) {
return (v, seen);
}
seen.push(v);
}
}
}
}
/// Bytes as a JSON number, for `assert_eq!(v["used"], gib(4))`-style lines.
pub fn n(v: u64) -> Value {
Value::from(v)
}