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

nordisk/monetize

crates/monetize-server/tests/guests_on_the_fly.rs

//! **Guests on the fly, through the running server's own watcher step** — no
//! restart anywhere. One `AppState`, built once; the operator edits files; the
//! watcher's `look` is what the server's thread runs every period.
//!
//! * a NEW product is added live (and its price list with it);
//! * it is REPLACED live, a call in flight finishing on the old guest;
//! * a replacement signed by a key the block does not trust is REFUSED, and
//!   the old guest keeps serving;
//! * a module the block names but nobody pinned or signed is refused, and so
//!   is an allow-list the module asks for but the operator did not write.
//!
//! Needs the real toy guest (`cargo xtask build-guests`); it fails naming the
//! command rather than skipping.

use std::path::{Path, PathBuf};

use guest_host::verify::sign;
use monetize_server::config::Config;
use monetize_server::guests::look;
use monetize_pay::factory::{VendorBuild, VendorFactory};
use monetize_pay::Payment;
use monetize_server::registry::{Linked, Registry};
use monetize_server::state::AppState;

fn built_toy() -> PathBuf {
    let target = std::env::var("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|_| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target"));
    let p = target.join("wasm32-unknown-unknown/release/monetize_guest_toy.wasm");
    assert!(p.is_file(), "{} has not been built. This test loads the REAL guest; it does not skip.\nBuild it with:\n    cargo xtask build-guests", p.display());
    p
}

fn scratch(name: &str) -> PathBuf {
    let p = std::env::temp_dir().join(format!("monetize-onthefly-{name}-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&p);
    std::fs::create_dir_all(&p).unwrap();
    p
}

/// The toy with a custom section appended: the same guest, other bytes.
fn toy_bytes(tag: &str) -> Vec<u8> {
    let mut bytes = std::fs::read(built_toy()).unwrap();
    if !tag.is_empty() {
        let mut content = vec![tag.len() as u8];
        content.extend_from_slice(tag.as_bytes());
        bytes.push(0);
        bytes.push(content.len() as u8);
        bytes.extend_from_slice(&content);
    }
    bytes
}

/// Write a file and move its mtime on, so a watcher on a coarse-mtime
/// filesystem cannot miss a same-second rewrite.
fn write(p: &Path, bytes: &[u8]) {
    let tmp = p.with_extension("tmp-write");
    std::fs::write(&tmp, bytes).unwrap();
    std::fs::rename(&tmp, p).unwrap();
    std::thread::sleep(std::time::Duration::from_millis(20));
}

/// The toy's vendor instance: priced products name theirs (`vendor = "mock/toy"`).
fn base_toml(dir: &Path) -> String {
    format!("data_dir = {:?}\n[vendors.mock.toy]\nenabled = true\n", dir.join("data").display().to_string())
}

fn build_mock(_: &VendorBuild<'_>) -> Result<Box<dyn Payment>, String> {
    Ok(Box::new(monetize::testing::MockPay::new()))
}

/// What this server links: a `mock` vendor kind, for `[vendors.mock.toy]`.
fn linked() -> Linked {
    let mut l = Linked::open();
    l.vendor(VendorFactory { name: "mock", config_envs: &[], build: build_mock });
    l
}

/// Sign `module` as a release does (every guest monetize runs is signed, owner 2026-10-17): a
/// CA of its own beside it (`<module>.root.der`), its `.p7s` beside it. Answers the root.
fn vouched(module: &Path) -> PathBuf {
    use guest_host::verify::sign::{self, dev};
    let (ca_key, ca_der) = dev::mint_ca("test-plugin-root").unwrap();
    std::fs::write(root_of(module), &ca_der).unwrap();
    sign::sign_module_file(module, &dev::new_ed25519_signer(&ca_key, &ca_der, "test-plugin-signing").unwrap()).unwrap();
    root_of(module)
}

fn root_of(module: &Path) -> PathBuf {
    let mut root = module.as_os_str().to_owned();
    root.push(".root.der");
    PathBuf::from(root)
}

fn toy_block(module: &Path, vouch: &str, grants: &str) -> String {
    format!(
        "[products.toy]\nvendor = \"mock/toy\"\ninstance = \"toy-estate\"\nguest = {:?}\n{vouch}\ngrants = {grants}\n\n[prices.toy.SE]\ndisk_gib_month = 100\n",
        module.display().to_string()
    )
}

fn digest(bytes: &[u8]) -> String {
    guest_host::digest_hex(bytes)
}

fn serving(state: &AppState) -> Option<String> {
    state.registry.guest("toy").map(|g| g.digest_hex().to_string())
}

#[test]
fn a_guest_is_added_replaced_and_refused_live_and_the_old_one_keeps_serving() {
    let dir = scratch("live");
    let config = dir.join("monetize.toml");
    let module = dir.join("toy.wasm");

    // THE SERVER STARTS with no product at all.
    write(&config, base_toml(&dir).as_bytes());
    let cfg = Config::load(&config).unwrap();
    let state = AppState::in_memory(Registry::from_config_with(&cfg, &linked()).unwrap());
    assert!(state.registry.find_product("toy").is_none());
    let mut last = (Vec::new(), Some(cfg));
    let _ = look(&state, &config, &mut last);

    // 1. ADD: the operator drops the module and writes its block, pinned.
    let v1 = toy_bytes("");
    write(&module, &v1);
    let root = vouched(&module);
    write(&config, format!("{}{}", base_toml(&dir), toy_block(&module, &format!("guest_digest = {:?}\nguest_trust_roots = [{:?}]", digest(&v1), root.display().to_string()), "[\"endpoint\"]")).as_bytes());
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.starts_with("guest ADDED [products.toy]") && l.contains(&digest(&v1))), "{lines:#?}");
    let toy = state.registry.find_product("toy").expect("the new product is served");
    assert!(toy.tenant_renewal() && toy.meters().iter().any(|m| m.name == "things"), "the new guest answers: its manifest and its facts");
    assert!(state.registry.product_ids().contains(&"toy"));
    assert!(state.terms.read().unwrap().prices.contains_key("toy"), "a new product is priced as the operator wrote, never free");
    // Nothing moved: nothing happens, and nothing is logged.
    assert!(look(&state, &config, &mut last).is_empty());

    // 2. REPLACE: a rebuilt module and its new pin. A call already in flight keeps its instance.
    let in_flight = state.registry.guest("toy").unwrap();
    let v2 = toy_bytes("v2");
    write(&module, &v2);
    let root = vouched(&module);
    write(&config, format!("{}{}", base_toml(&dir), toy_block(&module, &format!("guest_digest = {:?}\nguest_trust_roots = [{:?}]", digest(&v2), root.display().to_string()), "[\"endpoint\"]")).as_bytes());
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.starts_with("guest REPLACED [products.toy]") && l.contains(&digest(&v1)) && l.contains(&digest(&v2))), "{lines:#?}");
    assert_eq!(serving(&state), Some(digest(&v2)));
    assert_eq!(in_flight.digest_hex(), digest(&v1), "the call in flight is on the old guest");
    assert!(in_flight.lifecycle_lay_plan(&monetize_product_guest::wire::LayPlanReq { company: "t".into(), ..Default::default() }).is_ok(), "and it finishes");
    assert!(state.registry.find_product("toy").unwrap().tenant_renewal());
    assert_eq!(state.registry.products().filter(|p| p.id() == "toy").count(), 1, "a replacement is not a second product");

    // 3. BAD SIGNATURE: the block moves to trust roots; the new module is signed by a stranger.
    use sign::dev;
    let (ca_key, ca_der) = dev::mint_ca("release-door-ca").unwrap();
    let root = dir.join("root.der");
    std::fs::write(&root, &ca_der).unwrap();
    let trusted = dev::new_ed25519_signer(&ca_key, &ca_der, "release-door").unwrap();
    let (stranger_key, stranger_der) = dev::mint_ca("stranger-ca").unwrap();
    let stranger = dev::new_ed25519_signer(&stranger_key, &stranger_der, "stranger").unwrap();
    let v3 = toy_bytes("v3");
    write(&module, &v3);
    sign::sign_module_file(&module, &stranger).unwrap();
    write(&config, format!("{}{}", base_toml(&dir), toy_block(&module, &format!("guest_trust_roots = [{:?}]", root.display().to_string()), "[\"endpoint\"]")).as_bytes());
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.contains("guest REFUSED [guest-refused] [products.toy]") && l.contains("did not verify") && l.contains("still serves")), "{lines:#?}");
    assert_eq!(serving(&state), Some(digest(&v2)), "a refused replacement swapped something in");
    assert!(state.registry.guest("toy").unwrap().lifecycle_lay_plan(&monetize_product_guest::wire::LayPlanReq { company: "t".into(), ..Default::default() }).is_ok(), "the old guest stopped answering");

    // 4. The trusted signature arrives: taken in, live.
    sign::sign_module_file(&module, &trusted).unwrap();
    std::thread::sleep(std::time::Duration::from_millis(20));
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.starts_with("guest REPLACED [products.toy]") && l.contains("signed by release-door")), "{lines:#?}");
    assert_eq!(serving(&state), Some(digest(&v3)));

    // 5. A guest never grants itself anything: the block drops `endpoint`, and the module imports it.
    write(&config, format!("{}{}", base_toml(&dir), toy_block(&module, &format!("guest_trust_roots = [{:?}]", root.display().to_string()), "[]")).as_bytes());
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.contains("guest REFUSED [guest-refused] [products.toy]") && l.contains("endpoint")), "{lines:#?}");
    assert_eq!(serving(&state), Some(digest(&v3)));

    // 6. Nobody vouches: refused by name, the old guest serves.
    write(&config, format!("{}{}", base_toml(&dir), toy_block(&module, "", "[\"endpoint\"]")).as_bytes());
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.contains("[guest-refused]") && l.contains("nobody vouches")), "{lines:#?}");
    assert_eq!(serving(&state), Some(digest(&v3)));

    // 7. A config that does not parse changes nothing.
    write(&config, b"this is = = not toml");
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.contains("[config-unreadable]")), "{lines:#?}");
    assert_eq!(serving(&state), Some(digest(&v3)));
    let _ = std::fs::remove_dir_all(&dir);
}

/// **A new product whose price list does not parse is not served** — served
/// without it, it would be sold as free.
#[test]
fn a_new_guest_whose_price_list_is_refused_is_not_served_at_all() {
    let dir = scratch("unpriced");
    let config = dir.join("monetize.toml");
    let module = dir.join("toy.wasm");
    write(&config, base_toml(&dir).as_bytes());
    let cfg = Config::load(&config).unwrap();
    let state = AppState::in_memory(Registry::from_config_with(&cfg, &linked()).unwrap());
    let mut last = (Vec::new(), Some(cfg));
    let _ = look(&state, &config, &mut last);
    let v1 = toy_bytes("");
    write(&module, &v1);
    let root = vouched(&module);
    let block = toy_block(&module, &format!("guest_digest = {:?}\nguest_trust_roots = [{:?}]", digest(&v1), root.display().to_string()), "[\"endpoint\"]").replace("disk_gib_month = 100", "no_such_unit = 100");
    write(&config, format!("{}{}", base_toml(&dir), block).as_bytes());
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.contains("[guest-terms-refused] [products.toy]") && l.contains("no_such_unit")), "{lines:#?}");
    assert!(state.registry.find_product("toy").is_none(), "an unpriced product was served");
    let _ = std::fs::remove_dir_all(&dir);
}

/// **A guest refused at START is skipped by name, never fatal** (it used to
/// stop the whole server: the gunnar front's refused plugin, kept in its
/// config, stopped monetize at the next boot). The rest serves, and the
/// watcher takes the product in live once a good module arrives.
#[test]
fn a_guest_refused_at_start_is_skipped_by_name_and_taken_in_live_once_it_is_good() {
    let dir = scratch("start-refused");
    let config = dir.join("monetize.toml");
    let module = dir.join("toy.wasm");
    let good = toy_bytes("");
    // A module that is NOT the one its block pins.
    write(&module, &toy_bytes("forged"));
    let root = vouched(&module);
    write(&config, format!("{}{}", base_toml(&dir), toy_block(&module, &format!("guest_digest = {:?}\nguest_trust_roots = [{:?}]", digest(&good), root.display().to_string()), "[\"endpoint\"]")).as_bytes());
    let cfg = Config::load(&config).unwrap();
    let reg = Registry::from_config_with(&cfg, &linked()).expect("a refused guest does not stop the start");
    assert!(reg.find_product("toy").is_none());
    let said: Vec<String> = reg.start_refusals().iter().map(|r| r.to_string()).collect();
    assert!(said.iter().any(|l| l.starts_with("guest REFUSED [guest-refused-at-start] [products.toy]")), "{said:#?}");

    let state = AppState::in_memory(reg);
    let mut last = (Vec::new(), Some(cfg));
    let _ = look(&state, &config, &mut last);
    write(&module, &good);
    vouched(&module);
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.starts_with("guest ADDED [products.toy]")), "{lines:#?}");
    assert!(state.registry.find_product("toy").is_some());
}

/// **`require_prices`** (the paid front): a product with no `[prices.<id>]`
/// is REFUSED by name — at start and live — unless its block says
/// `free = true`. Without `require_prices` (the open deployment) nothing
/// changes: no list is free.
#[test]
fn where_prices_are_required_an_unpriced_product_is_refused_unless_it_says_free() {
    let dir = scratch("require-prices");
    let config = dir.join("monetize.toml");
    let module = dir.join("toy.wasm");
    let v1 = toy_bytes("");
    write(&module, &v1);
    let root = vouched(&module);
    let block = |extra: &str| {
        format!(
            "[products.toy]\nvendor = \"mock/toy\"\ninstance = \"toy-estate\"\nguest = {:?}\nguest_digest = {:?}\nguest_trust_roots = [{:?}]\ngrants = [\"endpoint\"]\n{extra}",
            module.display().to_string(),
            digest(&v1),
            root.display().to_string()
        )
    };
    let base = format!("require_prices = true\n{}", base_toml(&dir));

    // At start: refused by name, the server starts.
    write(&config, format!("{base}{}", block("")).as_bytes());
    let cfg = Config::load(&config).unwrap();
    let reg = Registry::from_config_with(&cfg, &linked()).unwrap();
    assert!(reg.find_product("toy").is_none());
    assert!(reg.start_refusals().iter().any(|r| r.to_string().starts_with("guest REFUSED [product-no-prices] [products.toy]")));

    // Live: still refused while it has no list.
    let state = AppState::in_memory(reg);
    let mut last = (Vec::new(), Some(cfg));
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.contains("[product-no-prices] [products.toy]")), "{lines:#?}");
    assert!(state.registry.find_product("toy").is_none(), "an unpriced product was served free");

    // `free = true`, said on purpose: taken in.
    write(&config, format!("{base}{}", block("free = true\n")).as_bytes());
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.starts_with("guest ADDED [products.toy]")), "{lines:#?}");

    // Priced: taken at start with no `free`.
    write(&config, format!("{base}{}[prices.toy.SE]\ndisk_gib_month = 100\n", block("")).as_bytes());
    let cfg = Config::load(&config).unwrap();
    assert!(Registry::from_config_with(&cfg, &linked()).unwrap().find_product("toy").is_some());

    // The open deployment: no `require_prices`, no list — free, as before.
    write(&config, format!("{}{}", base_toml(&dir), block("")).as_bytes());
    let cfg = Config::load(&config).unwrap();
    let reg = Registry::from_config_with(&cfg, &linked()).unwrap();
    assert!(reg.find_product("toy").is_some() && reg.start_refusals().is_empty());
}

/// ★ **The signature is mandatory, at start and at the hot swap** (owner 2026-10-17:
/// "monetize skall verifiera signaturen på wasmen"). A block that pins a digest and names no
/// `guest_trust_roots` is refused by name — `[guest-unsigned]` — at start (skipped, the rest
/// serves), and live: a SIGNED guest that serves keeps serving when its block is edited down
/// to a digest alone, and when its vendor or cloud block is.
#[test]
fn a_digest_alone_is_refused_by_name_at_start_and_at_the_hot_swap() {
    use sign::dev;
    let dir = scratch("unsigned");
    let config = dir.join("monetize.toml");
    let module = dir.join("toy.wasm");
    let v1 = toy_bytes("");
    write(&module, &v1);
    let pinned = format!("guest_digest = {:?}", digest(&v1));

    // START: refused by name, the server starts.
    write(&config, format!("{}{}", base_toml(&dir), toy_block(&module, &pinned, "[\"endpoint\"]")).as_bytes());
    let cfg = Config::load(&config).unwrap();
    let reg = Registry::from_config_with(&cfg, &linked()).expect("a refused guest does not stop the start");
    assert!(reg.find_product("toy").is_none(), "an unsigned guest was served at start");
    let said: Vec<String> = reg.start_refusals().iter().map(|r| r.to_string()).collect();
    assert!(said.iter().any(|l| l.contains("[products.toy]") && l.contains("[guest-unsigned]")), "{said:#?}");

    // LIVE: signed, it is taken in.
    let (ca_key, ca_der) = dev::mint_ca("release-door-ca").unwrap();
    let root = dir.join("root.der");
    std::fs::write(&root, &ca_der).unwrap();
    let signer = dev::new_ed25519_signer(&ca_key, &ca_der, "release-door").unwrap();
    sign::sign_module_file(&module, &signer).unwrap();
    let signed = format!("guest_trust_roots = [{:?}]\n{pinned}", root.display().to_string());
    let state = AppState::in_memory(reg);
    let mut last = (Vec::new(), Some(cfg));
    write(&config, format!("{}{}", base_toml(&dir), toy_block(&module, &signed, "[\"endpoint\"]")).as_bytes());
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.starts_with("guest ADDED [products.toy]") && l.contains("signed by release-door")), "{lines:#?}");

    // The block edited down to its digest: refused by name, the signed guest serves on.
    let v2 = toy_bytes("v2");
    write(&module, &v2);
    write(&config, format!("{}{}", base_toml(&dir), toy_block(&module, &format!("guest_digest = {:?}", digest(&v2)), "[\"endpoint\"]")).as_bytes());
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.contains("REFUSED") && l.contains("[products.toy]") && l.contains("[guest-unsigned]")), "{lines:#?}");
    assert_eq!(serving(&state), Some(digest(&v1)), "an unsigned replacement was swapped in");
    let _ = std::fs::remove_dir_all(&dir);
}

/// ★ **JOINED-REFUSE, the ONE box's update sequence** (prod 2026-10-26, 04:17:09 → 04:18:21):
/// the NEW app is up and its product guest is refused on the OLD feed; the guardian ticks in that
/// gap and finds the adopted site's standing record (`joined/<site>.json`, its ONE bearer); then
/// the NEW feed is taken and the guest loads. The record is never put aside: it waits, said once
/// as a WARN, and the first tick after the guest loads takes it — the site is declared on the
/// guardian and its bearer opens the estate-facing doors again (its shop answers, no 503).
#[test]
fn a_joined_site_waits_through_the_update_gap_and_is_taken_once_its_guest_loads() {
    let dir = scratch("joined-gap");
    let config = dir.join("monetize.toml");
    let module = dir.join("toy.wasm");
    let good = toy_bytes("");
    // The new app on the OLD feed: the module it finds is not the one its block pins.
    write(&module, &toy_bytes("old-feed"));
    let root = vouched(&module);
    write(&config, format!("{}{}", base_toml(&dir), toy_block(&module, &format!("guest_digest = {:?}\nguest_trust_roots = [{:?}]", digest(&good), root.display().to_string()), "[\"endpoint\"]")).as_bytes());
    let cfg = Config::load(&config).unwrap();
    let reg = Registry::from_config_with(&cfg, &linked()).expect("a refused guest does not stop the start");
    assert!(reg.find_product("toy").is_none(), "the gap: the product is not served");
    let state = AppState::in_memory(reg);

    // The adopted site's standing record, written by its enrolment long before the update.
    let joined = dir.join("data").join(monetize_server::fleet::JOINED_REL);
    std::fs::create_dir_all(&joined).unwrap();
    let now = state.now();
    let bearer = "toy-site-bearer-0123456789abcdef";
    let record = serde_json::json!({
        "estate": "toy-site", "product": "toy", "box_name": "main", "endpoint": "", "estate_ca_pem": "", "owner_key": "",
        "enrolled_unix_ms": now - 1_000, "bearer_sha256": monetize_server::fleet::bearer_digest(bearer), "bearer_until_unix_ms": now + 365 * 24 * 3_600_000,
    });
    std::fs::write(joined.join("toy-site.json"), record.to_string()).unwrap();
    // A second site's record an OLDER app put aside in an earlier gap (prod's gunnar-rs.json.refused).
    let old_bearer = "toy-old-bearer-0123456789abcdef";
    let mut old = record.clone();
    old["estate"] = "toy-old".into();
    old["bearer_sha256"] = monetize_server::fleet::bearer_digest(old_bearer).into();
    std::fs::write(joined.join("toy-old.json.refused"), old.to_string()).unwrap();
    assert!(state.fleet.joined_at(joined.clone()));

    // The guardian ticks in the gap: the record waits, said once, and opens nothing yet.
    let first = monetize_server::fleet::tick(&state);
    assert!(first.iter().any(|l| l.starts_with("WARN [joined] toy-site.json")), "{first:#?}");
    assert!(first.iter().all(|l| !l.contains("ALERT") && !l.contains("REFUSED")), "{first:#?}");
    let again = monetize_server::fleet::tick(&state);
    assert!(again.iter().all(|l| !l.contains("toy-site")), "said once, not on every tick: {again:#?}");
    assert!(joined.join("toy-site.json").exists() && !joined.join("toy-site.json.refused").exists(), "never put aside for a product not served yet");
    assert_eq!(state.fleet.estate_of_bearer(bearer), None);
    assert!(joined.join("toy-old.json.refused").exists(), "the older app's record stays aside while its product is not served");

    // The NEW feed is taken: the guest loads (the watcher's step, as the server's thread runs it).
    let mut last = (Vec::new(), Some(cfg));
    let _ = look(&state, &config, &mut last);
    write(&module, &good);
    vouched(&module);
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.starts_with("guest ADDED [products.toy]")), "{lines:#?}");

    // The next tick takes the site: declared, its bearer opens, and it says it was taken.
    let taken = monetize_server::fleet::tick(&state);
    assert!(taken.iter().any(|l| l.contains("toy-site.json is taken now")), "{taken:#?}");
    let g = state.fleet.guardian().unwrap();
    assert_eq!(g.store.product_of("toy-site").as_deref(), Some("toy"), "{taken:#?}");
    assert_eq!(state.fleet.estate_of_bearer(bearer).as_deref(), Some("toy-site"), "the site's estate-facing calls open again: its shop is ready");
    assert!(joined.join("toy-site.json").exists());
    // The older app's record is put back and taken in the same tick: that site heals by itself.
    assert!(taken.iter().any(|l| l.contains("toy-old.json.refused is put back")), "{taken:#?}");
    assert!(joined.join("toy-old.json").exists() && !joined.join("toy-old.json.refused").exists());
    assert_eq!(state.fleet.estate_of_bearer(old_bearer).as_deref(), Some("toy-old"), "{taken:#?}");
}