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:#?}");
}