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

nordisk/monetize

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