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/providers_on_the_fly.rs

//! **Vendors and clouds on the fly, through the running server's own watcher
//! step** — no restart anywhere. One `AppState`, built once; the operator
//! edits the config; `look` is what the server's watcher thread runs.
//!
//! Clouds, with the REAL toy cloud guest (`cargo xtask build-guests`):
//! * a cloud guest is ADDED live, wrapped in its spend ceiling;
//! * it is REPLACED live, and the ceiling is reconciled against the LEDGER
//!   before the new one is published — a lost cap file does not hand the
//!   deployment its budget again, and an over-limit purchase is refused;
//! * a module nobody vouches for is REFUSED and the old one keeps buying;
//! * `enabled = false` RETIRES it: nothing new goes through it.
//!
//! Vendors, with a registered factory: ADDED, REPLACED (its `checkout_url`
//! followed live), RETIRED when its block is removed.

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

use monetize_cloud::{CloudError, Ensured, Room, ServerId};
use monetize_pay::factory::{VendorBuild, VendorFactory};
use monetize_pay::Payment;
use monetize_server::config::Config;
use monetize_server::guests::look;
use monetize_server::registry::{Linked, Registry};
use monetize_server::state::AppState;

fn built(stem: &str) -> 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(format!("wasm32-unknown-unknown/release/{stem}.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-providers-{name}-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&p);
    std::fs::create_dir_all(&p).unwrap();
    p
}

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

fn base(dir: &Path) -> String {
    format!("data_dir = {:?}\n", dir.join("data").display().to_string())
}

/// 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 cloud_block(module: &Path, digest: &str, total_minor: u64, tag: &str, enabled: bool) -> String {
    format!(
        "[clouds.toycloud]\nenabled = {enabled}\ncap = {{ total_minor = {total_minor}, currency = \"Eur\" }}\nguest = {:?}\nguest_digest = {digest:?}\nguest_trust_roots = [{:?}]\ngrants = [\"state\"]\ntag = {tag:?}\n",
        module.display().to_string(),
        root_of(module).display().to_string()
    )
}

fn disk() -> monetize_cloud::Resource {
    monetize_cloud::Resource::Disk { server: ServerId("box-1".into()), gib: 10 }
}

#[test]
fn a_cloud_guest_is_added_replaced_reconciled_refused_and_retired_live() {
    let dir = scratch("cloud");
    let config = dir.join("monetize.toml");
    let module = dir.join("toycloud.wasm");
    std::fs::copy(built("monetize_guest_toy_cloud"), &module).unwrap();
    vouched(&module);
    let digest = guest_host::digest_hex(&std::fs::read(&module).unwrap());

    // THE SERVER STARTS with no cloud at all.
    write(&config, base(&dir).as_bytes());
    let cfg = Config::load(&config).unwrap();
    let state = AppState::in_memory(Registry::from_config(&cfg).unwrap());
    assert_eq!(monetize_cloud::Cloud::manifest(state.registry.cloud_for_transactions()).provider, "none");
    let mut last = (Vec::new(), Some(cfg));
    assert!(look(&state, &config, &mut last).is_empty(), "a quiet start stays quiet");

    // 1. ADD — a signed-or-pinned cloud guest, under a ceiling of 3.00 EUR.
    write(&config, format!("{}{}", base(&dir), cloud_block(&module, &digest, 300, "v1", true)).as_bytes());
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.starts_with("cloud ADDED [clouds.toycloud]") && l.contains(&digest) && l.contains("ceiling total 3.00 EUR")), "{lines:#?}");
    let cloud = state.registry.transaction_slot().expect("a cloud a sale spends at");
    assert_eq!(cloud.name(), "toycloud", "the only cloud is where a sale provisions");
    assert_eq!(cloud.spend_ceiling().map(|c| c.total_minor), Some(Some(300)), "wrapped in the ceiling on the host");
    let first = match monetize_cloud::ask::ensure(&cloud, "ref-1", &disk(), Some(Room::Buy)).unwrap() {
        Ensured::Provisioned { id, cost } => {
            assert_eq!(cost.minor_per_month, 230);
            id
        }
        other => panic!("{other:?}"),
    };
    // What the transaction would have written after that `ensure`.
    monetize_server::testing::provisioned_rows(&state.ledger, &[("toycloud", 230)]);
    assert!(look(&state, &config, &mut last).is_empty(), "nothing moved, nothing logged");

    // 2. REPLACE — and the cap file is lost on the way. The replacement is
    //    reconciled against the LEDGER before it is published, so it knows the
    //    2.30 already committed and refuses the next 2.30.
    let _ = std::fs::remove_file(dir.join("data/toycloud-spend.json"));
    write(&config, format!("{}{}", base(&dir), cloud_block(&module, &digest, 300, "v2", true)).as_bytes());
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.starts_with("cloud REPLACED [clouds.toycloud]") && l.contains("2.30 EUR committed per the ledger")), "{lines:#?}");
    let cloud = state.registry.find_cloud("toycloud").unwrap();
    let over = monetize_cloud::ask::ensure(&cloud, "ref-2", &disk(), Some(Room::Buy));
    assert!(matches!(&over, Err(CloudError::OverCap(m)) if m.contains("3.00") || m.contains("300")), "the ceiling refuses over the limit, live: {over:?}");
    // The guest's own memory survived the swap: the first purchase is found, not bought again.
    match monetize_cloud::ask::ensure(&cloud, "ref-1", &disk(), Some(Room::Buy)) {
        Ok(Ensured::Provisioned { id, .. }) => assert_eq!(id, first),
        // A cap may refuse the idempotent re-ask before the guest is reached;
        // either way nothing was bought twice.
        Err(e) if e.is_refusal() => {}
        other => panic!("{other:?}"),
    }
    assert_eq!(monetize_cloud::ask::account(&cloud).unwrap().owned.len(), 1, "exactly one purchase at the provider");

    // 3. REFUSED — a module nobody pinned: the old guest keeps buying.
    write(&config, format!("{}{}", base(&dir), cloud_block(&module, &"0".repeat(64), 1_000, "v3", true)).as_bytes());
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.starts_with("cloud REFUSED [cloud-refused] [clouds.toycloud]") && l.contains("still serves")), "{lines:#?}");
    assert_eq!(state.registry.find_cloud("toycloud").unwrap().spend_ceiling().and_then(|c| c.total_minor), Some(300), "what served still serves");

    // A ceiling raised live admits the purchase the old one refused.
    write(&config, format!("{}{}", base(&dir), cloud_block(&module, &digest, 1_000, "v3", true)).as_bytes());
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.starts_with("cloud REPLACED [clouds.toycloud]") && l.contains("ceiling total 10.00 EUR (7.70 left)")), "{lines:#?}");
    assert!(matches!(monetize_cloud::ask::ensure(&state.registry.find_cloud("toycloud").unwrap(), "ref-2", &disk(), Some(Room::Buy)), Ok(Ensured::Provisioned { .. })));

    // 4. RETIRE — nothing new goes through it, and it is not listed.
    write(&config, format!("{}{}", base(&dir), cloud_block(&module, &digest, 1_000, "v3", false)).as_bytes());
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.starts_with("cloud RETIRED [clouds.toycloud]")), "{lines:#?}");
    assert!(state.registry.find_cloud("toycloud").is_none());
    assert_eq!(monetize_cloud::Cloud::manifest(state.registry.cloud_for_transactions()).provider, "none");
    assert!(state.config.read().unwrap().clouds.get("toycloud").is_some_and(|c| !c.enabled), "the picture's config followed");
    let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn an_enabled_cloud_with_no_ceiling_is_refused_live_as_at_start() {
    let dir = scratch("nocap");
    let config = dir.join("monetize.toml");
    let module = dir.join("toycloud.wasm");
    std::fs::copy(built("monetize_guest_toy_cloud"), &module).unwrap();
    vouched(&module);
    let digest = guest_host::digest_hex(&std::fs::read(&module).unwrap());
    write(&config, base(&dir).as_bytes());
    let cfg = Config::load(&config).unwrap();
    let state = AppState::in_memory(Registry::from_config(&cfg).unwrap());
    let mut last = (Vec::new(), Some(cfg));
    let _ = look(&state, &config, &mut last);
    let block = format!("[clouds.toycloud]\nenabled = true\nguest = {:?}\nguest_digest = {digest:?}\nguest_trust_roots = [{:?}]\ngrants = [\"state\"]\n", module.display().to_string(), root_of(&module).display().to_string());
    write(&config, format!("{}{block}", base(&dir)).as_bytes());
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.contains("REFUSED") && l.contains("[clouds.toycloud]") && l.contains("no `cap`")), "{lines:#?}");
    assert!(state.registry.find_cloud("toycloud").is_none());
    let _ = std::fs::remove_dir_all(&dir);
}

/// A vendor that answers to the block's name.
struct Acme(monetize::testing::MockPay);
#[allow(dead_code)]
impl Acme {
    pub fn vendor(&self) -> &'static str {
        "acme"
    }
    pub fn markets(&self) -> &[monetize_pay::Market] {
        self.0.markets()
    }
    pub fn reserve(&self, r: &monetize_pay::ChargeRequest) -> Result<monetize_pay::ChargeHandle, monetize_pay::PayError> {
        self.0.reserve(r)
    }
    pub fn draw(&self, h: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
        self.0.capture(h)
    }
    pub fn unlock(&self, h: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
        self.0.release(h)
    }
    pub fn charge_status(&self, h: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
        self.0.charge_status(h)
    }
    pub fn parse_callback(&self, raw: monetize_pay::RawCallback<'_>) -> Result<monetize_pay::CallbackEvent, monetize_pay::PayError> {
        self.0.callback(raw)
    }
    pub fn give_back(&self, h: &monetize_pay::ChargeHandle, m: monetize_pay::Money) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
        self.0.refund(h, m)
    }
    pub fn settled(&self, h: &monetize_pay::ChargeHandle) -> Option<monetize_pay::Settlement> {
        self.0.settlement(h)
    }
    pub fn redirect(&self, h: &monetize_pay::ChargeHandle) -> Option<String> {
        self.0.redirect(h)
    }
    pub fn start_signup(&self, o: &monetize_pay::signup::SignupOrder) -> Result<Option<monetize_pay::signup::SignupCheckout>, monetize_pay::PayError> {
        self.0.start_signup(o)
    }
}
#[allow(unused_variables)]
impl monetize_pay::payment::Payment for Acme {
    fn manifest(&self) -> monetize_pay::payment::PayManifest {
        monetize_pay::payment::PayManifest { vendor: self.vendor().to_string(), markets: self.markets().to_vec(), settings: Vec::new(), holds: true, test_payer: None }
    }
    fn page(&self, charge: &monetize_pay::payment::Charge<'_>, back: &monetize_pay::payment::Return) -> Result<monetize_pay::payment::Page, monetize_pay::PayError> {
        Ok(match charge {
            monetize_pay::payment::Charge::Order(req) => monetize_pay::payment::Page::Order { handle: self.reserve(req)?, url: None },
            monetize_pay::payment::Charge::Signup(o) => monetize_pay::payment::Page::Signup(self.start_signup(o)?),
            monetize_pay::payment::Charge::CardCheck(o) => monetize_pay::payment::Page::CardCheck(None),
            monetize_pay::payment::Charge::TestSignup(o) => monetize_pay::payment::Page::TestSignup(None),
        })
    }
    fn status(&self, of: &monetize_pay::payment::Of<'_>) -> Result<monetize_pay::payment::Standing, monetize_pay::PayError> {
        Ok(match of {
            monetize_pay::payment::Of::Charge(h) => monetize_pay::payment::Standing::Charge(self.charge_status(h)?),
            monetize_pay::payment::Of::Page(h) => monetize_pay::payment::Standing::Page(self.redirect(h)),
            monetize_pay::payment::Of::Signup { session } => monetize_pay::payment::Standing::Signup(None),
            monetize_pay::payment::Of::CardCheck { session } => monetize_pay::payment::Standing::CardCheck(None),
        })
    }
    fn capture(&self, h: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
        self.draw(h)
    }
    fn release(&self, h: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
        self.unlock(h)
    }
    fn refund(&self, h: &monetize_pay::ChargeHandle, amount: monetize_pay::Money) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
        self.give_back(h, amount)
    }
    fn callback(&self, raw: monetize_pay::RawCallback<'_>) -> Result<monetize_pay::CallbackEvent, monetize_pay::PayError> {
        self.parse_callback(raw)
    }
    fn settlement(&self, h: &monetize_pay::ChargeHandle) -> Option<monetize_pay::Settlement> {
        self.settled(h)
    }
    fn paid_since(&self, since_unix_ms: i64) -> Result<Vec<monetize_pay::reconcile::PaidAtVendor>, monetize_pay::PayError> {
        Err(monetize_pay::reconcile::cannot_be_asked())
    }
    fn livemode(&self) -> Option<bool> {
        None
    }
}

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

#[test]
fn a_vendor_is_added_replaced_and_retired_live_and_its_checkout_url_follows() {
    let dir = scratch("vendor");
    let config = dir.join("monetize.toml");
    let mut linked = Linked::open();
    linked.vendor(VendorFactory { name: "acme", config_envs: &[], build: build_acme });
    write(&config, base(&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);
    assert!(state.registry.find_vendor("acme/shop").is_none());

    write(&config, format!("{}[vendors.acme.shop]\nenabled = true\ncheckout_url = \"https://pay.example/a/{{handle}}\"\ncountry = \"IE\"\n", base(&dir)).as_bytes());
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.starts_with("vendor ADDED [vendors.acme.shop] linked")), "{lines:#?}");
    assert!(state.registry.find_vendor("acme/shop").is_some());
    assert_eq!(state.checkout_urls.read().unwrap().get("acme/shop").map(String::as_str), Some("https://pay.example/a/{handle}"));
    assert_eq!(state.books.read().unwrap().supplier_countries.get("acme/shop").map(String::as_str), Some("IE"));

    write(&config, format!("{}[vendors.acme.shop]\nenabled = true\ncheckout_url = \"https://pay.example/b/{{handle}}\"\n", base(&dir)).as_bytes());
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.starts_with("vendor REPLACED [vendors.acme.shop]")), "{lines:#?}");
    assert_eq!(state.checkout_urls.read().unwrap().get("acme/shop").map(String::as_str), Some("https://pay.example/b/{handle}"));
    assert_eq!(state.registry.vendors().filter(|v| v.name == "acme/shop").count(), 1, "a replacement is not a second vendor");

    write(&config, base(&dir).as_bytes());
    let lines = look(&state, &config, &mut last);
    assert!(lines.iter().any(|l| l.starts_with("vendor RETIRED [vendors.acme.shop]")), "{lines:#?}");
    assert!(state.registry.find_vendor("acme/shop").is_none());
    assert!(state.checkout_urls.read().unwrap().get("acme/shop").is_none());
    let _ = std::fs::remove_dir_all(&dir);
}