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

nordisk/monetize

crates/monetize-admin/tests/solo.rs

//! ★ **G6 — the solo monetize box** (owner 2026-10-10 08:54): ONE machine runs
//! monetize-server and the console for EVERY product, as two separate
//! processes of this one binary, behind the box's own :443.
//!
//! * the roles: `monetize-admin` alone is PID 1; `monetize-admin server …` is
//!   monetize-server, `monetize-admin ui …` the console;
//! * each process listens on its own explicit loopback address
//!   (`127.0.0.1:50061` the server, `127.0.0.1:8091` the console);
//! * the edge carries `/callback/<kind>/<instance>` and the estate-facing gRPC
//!   services to the server, everything else to the console — and never the
//!   operator's gRPC (Ledger, Accounts, …) from outside;
//! * a child that dies is started again; a changed console setting restarts
//!   the console.

use std::collections::BTreeMap;
use std::net::{SocketAddr, SocketAddrV4};
use std::time::{Duration, Instant};

use monetize_admin::solo::{self, UiSettings};
use monetize_admin::Role;

fn words(w: &[&str]) -> Vec<String> {
    w.iter().map(|s| s.to_string()).collect()
}

#[test]
fn the_binary_is_pid1_the_server_or_the_console_by_its_first_word() {
    assert_eq!(
        monetize_admin::role(words(&["/sbin/monetize-admin"])),
        Role::Pid1
    );
    assert_eq!(
        monetize_admin::role(words(&[
            "monetize-admin",
            "server",
            "--config",
            "/data/monetize/monetize.toml"
        ])),
        Role::Server(words(&["--config", "/data/monetize/monetize.toml"]))
    );
    assert_eq!(
        monetize_admin::role(words(&["monetize-admin", "ui", "--bind", "127.0.0.1:8091"])),
        Role::Ui(words(&["--bind", "127.0.0.1:8091"]))
    );
    // The roles are words only the box itself says: anything else is PID 1's.
    assert_eq!(
        monetize_admin::role(words(&["monetize-admin", "--help"])),
        Role::Pid1
    );
    // ST-15: the ledger merge is gone; `ledger merge …` is no role of the box.
    assert_eq!(
        monetize_admin::role(words(&[
            "monetize-admin",
            "ledger",
            "merge",
            "--from",
            "/b/gunnar"
        ])),
        Role::Pid1
    );
    assert_eq!(
        monetize_admin::role(words(&["monetize-admin", "ledger"])),
        Role::Pid1
    );
}

#[test]
fn each_process_has_its_own_explicit_loopback_address() {
    assert_eq!(
        solo::SERVER,
        "127.0.0.1:50061".parse::<SocketAddrV4>().unwrap()
    );
    assert_eq!(solo::UI, "127.0.0.1:8091".parse::<SocketAddrV4>().unwrap());
    let root = std::path::Path::new("/data");
    assert_eq!(
        solo::server_args(root),
        words(&["server", "--config", "/data/monetize/monetize.toml"])
    );
    assert_eq!(
        solo::ui_args(root),
        words(&[
            "ui",
            "--bind",
            "127.0.0.1:8091",
            "--server",
            "127.0.0.1:50061",
            "--root",
            "/data"
        ])
    );
}

/// The children are this binary started again: the words PID 1 starts them
/// with must name their role. Without the role word a child is PID 1 again and
/// starts its own children — a fork bomb (MEASURED on the bench 2026-10-10:
/// thousands of `exe` processes, the box out of memory, the installer link
/// dead).
#[test]
fn the_children_are_started_with_their_role_word_never_as_pid1() {
    let root = std::path::Path::new("/data");
    let argv = |args: Vec<String>| {
        std::iter::once("/proc/self/exe".to_string())
            .chain(args)
            .collect::<Vec<_>>()
    };
    assert_eq!(
        monetize_admin::role(argv(solo::server_args(root))),
        Role::Server(words(&["--config", "/data/monetize/monetize.toml"]))
    );
    assert_eq!(
        monetize_admin::role(argv(solo::ui_args(root))),
        Role::Ui(words(&[
            "--bind",
            "127.0.0.1:8091",
            "--server",
            "127.0.0.1:50061",
            "--root",
            "/data"
        ]))
    );
}

#[test]
fn the_consoles_settings_are_the_feeds_and_kept_owner_only() {
    let good = "Zq3v8pL0xN6wT2yR9kB4mH7sJ1cF5dGa";
    let feed: BTreeMap<String, String> = [
        ("web_hosts", "monetize.ignalina.dk"),
        (monetize_admin::FEED_ADMIN_TOKEN, good),
    ]
    .iter()
    .map(|(k, v)| (k.to_string(), v.to_string()))
    .collect();
    let (s, refused) = UiSettings::of(&feed, "monetize");
    assert!(refused.is_empty(), "{refused:?}");
    assert_eq!(
        s,
        UiSettings {
            rp_id: "monetize.ignalina.dk".into(),
            admin_token: Some(good.into())
        }
    );
    let dir = tempfile::tempdir().unwrap();
    assert!(s.write(dir.path()).unwrap(), "the first write is a change");
    assert!(
        !s.write(dir.path()).unwrap(),
        "the same settings again: no change, no restart"
    );
    assert_eq!(UiSettings::read(dir.path()).unwrap(), s);
    use std::os::unix::fs::PermissionsExt as _;
    assert_eq!(
        std::fs::metadata(dir.path().join(solo::UI_SETTINGS_REL))
            .unwrap()
            .permissions()
            .mode()
            & 0o777,
        0o600
    );
    let weak: BTreeMap<String, String> =
        [(monetize_admin::FEED_ADMIN_TOKEN.to_string(), "a".repeat(40))].into();
    let (w, refused) = UiSettings::of(&weak, "monetize");
    assert!(
        w.admin_token.is_none()
            && refused
                .iter()
                .any(|l| l.contains("[console-admin-token-weak]")),
        "{refused:?}"
    );
}

/// The door's feed lies over the installer's, key by key.
#[test]
fn what_the_operator_put_on_the_door_wins_over_the_installers_feed() {
    let dir = tempfile::tempdir().unwrap();
    let fed: BTreeMap<String, String> = [
        ("plugin.gunnar.prices", "[SE]\ndisk_gib_month = 1\n"),
        ("web_hosts", "monetize.ignalina.dk"),
    ]
    .iter()
    .map(|(k, v)| (k.to_string(), v.to_string()))
    .collect();
    assert_eq!(
        solo::feed_now(dir.path(), &fed),
        fed,
        "nothing on the door: the installer's feed"
    );
    let put: BTreeMap<String, String> = [(
        "plugin.gunnar.prices".to_string(),
        "[SE]\ndisk_gib_month = 2\n".to_string(),
    )]
    .into();
    solo::write_door_feed(dir.path(), &put).unwrap();
    let now = solo::feed_now(dir.path(), &fed);
    assert_eq!(now["plugin.gunnar.prices"], "[SE]\ndisk_gib_month = 2\n");
    assert_eq!(now["web_hosts"], "monetize.ignalina.dk");
}

/// A feed that makes no config monetize starts on is refused WHOLE, by name,
/// before anything is written: a priced product that names no vendor.
#[test]
fn a_feed_monetize_would_not_start_on_is_judged_before_it_is_taken() {
    use base64::Engine as _;
    let wasm = base64::engine::general_purpose::STANDARD.encode(b"\0asm\x01\0\0\0");
    let dir = tempfile::tempdir().unwrap();
    let mut feed: BTreeMap<String, String> = BTreeMap::from([
        ("plugin.gunnar.wasm".to_string(), wasm.clone()),
        (
            "plugin.gunnar.toml".to_string(),
            "instance = \"gunnar-rs\"\nguest_digest = \"ab\"\ngrants = []\n".to_string(),
        ),
        (
            "plugin.gunnar.prices".to_string(),
            "[SE]\ndisk_gib_month = 2200\n".to_string(),
        ),
    ]);
    let e = solo::judge(dir.path(), &feed).unwrap_err();
    assert!(e.contains("vendor"), "{e}");
    feed.insert("plugin.gunnar.toml".into(), "vendor = \"stripe/gunnar\"\ninstance = \"gunnar-rs\"\nguest_digest = \"ab\"\ngrants = []\n".into());
    feed.insert("vendor.stripe.gunnar.wasm".into(), wasm);
    feed.insert(
        "vendor.stripe.gunnar.toml".into(),
        "enabled = true\nguest_digest = \"cd\"\n".into(),
    );
    assert_eq!(
        solo::judge(dir.path(), &feed).unwrap(),
        words(&["gunnar"]),
        "the products it serves"
    );
    assert!(
        !dir.path().join("monetize/monetize.toml").exists(),
        "judging writes nothing where monetize reads"
    );
}

// ── the edge ───────────────────────────────────────────────────────────────

/// A stand-in for one process: answers every request with `who`, the path,
/// the Host it was asked under and the X-Forwarded-For.
async fn stand_in(who: &'static str) -> SocketAddrV4 {
    use axum::extract::Request;
    let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let SocketAddr::V4(at) = l.local_addr().unwrap() else {
        unreachable!()
    };
    let app = axum::Router::new().fallback(move |req: Request| async move {
        let h = |n: &str| {
            req.headers()
                .get(n)
                .and_then(|v| v.to_str().ok())
                .unwrap_or("-")
                .to_string()
        };
        format!(
            "{who} {} {:?} host={} xff={} proto={}",
            req.uri().path(),
            req.version(),
            h("host"),
            h("x-forwarded-for"),
            h("x-forwarded-proto")
        )
    });
    tokio::spawn(async move {
        axum::serve(l, app.into_make_service_with_connect_info::<SocketAddr>())
            .await
            .unwrap()
    });
    at
}

async fn edge() -> SocketAddr {
    let (server, ui) = (stand_in("server").await, stand_in("ui").await);
    let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let at = l.local_addr().unwrap();
    let app = monetize_admin::edge::router(
        server,
        ui,
        std::env::temp_dir().join(format!("monetize-admin-solo-edge-{}", std::process::id())),
    );
    tokio::spawn(async move {
        axum::serve(l, app.into_make_service_with_connect_info::<SocketAddr>())
            .await
            .unwrap()
    });
    at
}

/// One request through the edge: HTTP/1.1, or h2c prior knowledge for gRPC.
async fn ask(at: SocketAddr, method: &str, path: &str, grpc: bool) -> (u16, String) {
    use http_body_util::BodyExt as _;
    let mut b = hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new());
    if grpc {
        b.http2_only(true);
    }
    let client = b.build_http::<axum::body::Body>();
    let mut req = http::Request::builder()
        .method(method)
        .uri(format!("http://{at}{path}"))
        .header("host", "monetize.ignalina.dk");
    if grpc {
        req = req
            .header("content-type", "application/grpc")
            .header("te", "trailers");
    }
    let resp = client
        .request(req.body(axum::body::Body::empty()).unwrap())
        .await
        .unwrap();
    let status = resp.status().as_u16();
    let body = resp.into_body().collect().await.unwrap().to_bytes();
    (status, String::from_utf8_lossy(&body).to_string())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_edge_carries_webhooks_and_estate_grpc_to_the_server_and_the_rest_to_the_console() {
    let at = edge().await;
    let (st, said) = ask(at, "POST", "/callback/stripe/holger", false).await;
    assert_eq!(st, 200);
    assert!(said.starts_with("server /callback/stripe/holger HTTP/1.1 host=monetize.ignalina.dk xff=127.0.0.1 proto=https"), "{said}");
    for svc in monetize_admin::edge::ESTATE_FACING {
        let (st, said) = ask(at, "POST", &format!("/{svc}/Anything"), true).await;
        assert_eq!(st, 200, "{svc}");
        assert!(
            said.starts_with(&format!("server /{svc}/Anything HTTP/2.0")),
            "{svc}: {said}"
        );
    }
    let (st, said) = ask(at, "GET", "/api/accounts", false).await;
    assert_eq!(st, 200);
    assert!(
        said.starts_with("ui /api/accounts HTTP/1.1 host=monetize.ignalina.dk xff=127.0.0.1"),
        "{said}"
    );
    let (_, said) = ask(at, "GET", "/", false).await;
    assert!(said.starts_with("ui / "), "{said}");
}

/// The operator's gRPC (the ledger, the accounts, the refunds) is the
/// console's, on loopback — never carried from outside, and never to the
/// console either.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_operators_grpc_is_never_carried_from_outside() {
    let at = edge().await;
    for path in [
        "/monetize.v1.Ledger/Export",
        "/monetize.v1.Accounts/List",
        "/monetize.v1.SignupAdmin/Refund",
    ] {
        let (st, said) = ask(at, "POST", path, true).await;
        assert_eq!(st, 404, "{path}: {said}");
        assert!(
            !said.contains("server ") && !said.contains("ui "),
            "{path} reached a process: {said}"
        );
    }
}

// ── the children ───────────────────────────────────────────────────────────

fn starts(file: &std::path::Path) -> usize {
    std::fs::read_to_string(file)
        .map(|t| t.lines().count())
        .unwrap_or(0)
}

async fn until(what: &str, f: impl Fn() -> bool) {
    let began = Instant::now();
    while !f() {
        assert!(began.elapsed() < Duration::from_secs(20), "{what}");
        tokio::time::sleep(Duration::from_millis(20)).await;
    }
}

/// A child that dies is started again; asked to restart, the running one is
/// stopped and started again.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_child_that_dies_is_started_again_and_a_restart_replaces_it() {
    let dir = tempfile::tempdir().unwrap();
    let log = dir.path().join("starts");
    // Dies at once: started again (the backoff starts at a second).
    let s = monetize_admin::supervise::Supervisor::spawn(
        "dies",
        "/bin/sh".into(),
        words(&["-c", &format!("echo up >> {}", log.display())]),
    );
    until("started twice", || starts(&log) >= 2).await;
    assert!(s.starts() >= 2);
    drop(s);
    // Runs for ever: a restart replaces it.
    let log2 = dir.path().join("starts2");
    let s = monetize_admin::supervise::Supervisor::spawn(
        "lives",
        "/bin/sh".into(),
        words(&[
            "-c",
            &format!("echo up >> {}; exec sleep 600", log2.display()),
        ]),
    );
    until("started once", || starts(&log2) == 1).await;
    s.restart();
    until("restarted", || starts(&log2) == 2).await;
    assert_eq!(s.starts(), 2);
}

// ── the real roles, as processes ───────────────────────────────────────────

fn free_port() -> u16 {
    std::net::TcpListener::bind("127.0.0.1:0")
        .unwrap()
        .local_addr()
        .unwrap()
        .port()
}

async fn post(at: SocketAddr, path: &str) -> u16 {
    let client = hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
        .build_http::<axum::body::Body>();
    let req = http::Request::post(format!("http://{at}{path}"))
        .body(axum::body::Body::empty())
        .unwrap();
    match client.request(req).await {
        Ok(r) => r.status().as_u16(),
        Err(_) => 0,
    }
}

struct Kill(std::process::Child);
impl Drop for Kill {
    fn drop(&mut self) {
        let _ = self.0.kill();
        let _ = self.0.wait();
    }
}

/// `monetize-admin server --config …` is monetize-server on the config's own
/// address; `monetize-admin ui …` is the console on its own, talking to it.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_server_and_the_console_run_as_two_processes_on_their_own_addresses() {
    let dir = tempfile::tempdir().unwrap();
    let (sp, up) = (free_port(), free_port());
    let cfg = dir.path().join("monetize.toml");
    std::fs::write(
        &cfg,
        format!(
            "grpc_addr = \"127.0.0.1:{sp}\"\ndata_dir = \"{}\"\ntoken_file = \"{}\"\n",
            dir.path().join("data").display(),
            dir.path().join("token").display()
        ),
    )
    .unwrap();
    std::fs::write(
        dir.path().join("token"),
        "t0ken-for-the-console-on-loopback",
    )
    .unwrap();
    let exe = env!("CARGO_BIN_EXE_monetize-admin");
    let _server = Kill(
        std::process::Command::new(exe)
            .args(["server", "--config", &cfg.display().to_string()])
            .spawn()
            .unwrap(),
    );
    let server: SocketAddr = format!("127.0.0.1:{sp}").parse().unwrap();
    let began = Instant::now();
    // The server answers its webhook door: an instance nobody configured is 404.
    loop {
        match post(server, "/callback/stripe/nobody").await {
            0 => assert!(
                began.elapsed() < Duration::from_secs(60),
                "the server never listened on {server}"
            ),
            st => {
                assert_eq!(st, 404);
                break;
            }
        }
        tokio::time::sleep(Duration::from_millis(100)).await;
    }
    UiSettings {
        rp_id: "monetize.ignalina.dk".into(),
        admin_token: Some("Zq3v8pL0xN6wT2yR9kB4mH7sJ1cF5dGa".into()),
    }
    .write(dir.path())
    .unwrap();
    std::fs::create_dir_all(dir.path().join("monetize")).unwrap();
    std::fs::copy(dir.path().join("token"), dir.path().join("monetize/token")).unwrap();
    let ui: SocketAddr = format!("127.0.0.1:{up}").parse().unwrap();
    let _ui = Kill(
        std::process::Command::new(exe)
            .args([
                "ui",
                "--bind",
                &ui.to_string(),
                "--server",
                &server.to_string(),
                "--root",
                &dir.path().display().to_string(),
            ])
            .spawn()
            .unwrap(),
    );
    let began = Instant::now();
    loop {
        match post(ui, "/api/auth/logout").await {
            0 => assert!(
                began.elapsed() < Duration::from_secs(60),
                "the console never listened on {ui}"
            ),
            _ => break,
        }
        tokio::time::sleep(Duration::from_millis(100)).await;
    }
}