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