crates/monetize-server/tests/sandbox_cloud.rs
//! **The transaction against the rehearsal cloud**: reserve → ensure → capture
//! with `monetize_cloud::sandbox` behind `SpendCap`, the shape the TEST
//! deployment of monetize runs. Ledger rows land, the resource ids say
//! `sandbox:`, the spend total rises by the price — and the 110 GiB shape from
//! `monetize::transaction`'s own tests: an order buys the delta, never the
//! whole amount again.
//!
//! Measure, never assert: every claim has its red twin in the same file, and the
//! assertions are
//! on applied output — the sandbox's recorded iron, the cap's file, the ledger's
//! rows, the mock's request log — never on a round-trip.
#![cfg(feature = "sandbox")]
use std::collections::BTreeMap;
use std::path::PathBuf;
use monetize::testing::{purchase, terms, MockPay, MockProduct, NOW};
use monetize::{Ledger, Order, Outcome, Signer, Step, Transaction};
use monetize_product::TenantId;
use monetize_cloud::cap::{Ceiling, SpendCap};
use monetize_cloud::sandbox::{DeclaredPrices, Sandbox, SandboxConfig, LABEL_PROVENANCE};
use monetize_cloud::{Ensured, Resource, ResourceId, ResourceStatus, ServerId};
use monetize_pay::Currency;
const SRV: &str = "gunnar-se-sto1";
fn tmp(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("monetize-server-sandbox-cloud-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
fn disk(gib: u64) -> Resource {
Resource::Disk { server: ServerId(SRV.into()), gib }
}
/// 23 cents/GiB/month, declared. The measured twin below reads the real list.
fn declared_sandbox(dir: &std::path::Path) -> Sandbox {
let mut c = SandboxConfig::new();
c.state_path = Some(dir.join("sandbox-cloud.json"));
c.declared = Some(DeclaredPrices { disk_minor_per_gib_month: 23, plan_minor_per_month: BTreeMap::new(), currency: Currency::Eur });
Sandbox::new(c).unwrap()
}
struct Rig {
pay: MockPay,
sandbox: std::sync::Arc<Sandbox>,
cloud: SpendCap<std::sync::Arc<Sandbox>>,
product: MockProduct,
ledger: Ledger,
signer: Signer,
dir: PathBuf,
}
impl Rig {
fn new(name: &str, sandbox: Sandbox, ceiling_minor: u64) -> Rig {
let dir = tmp(name);
let sandbox = std::sync::Arc::new(sandbox);
let cloud = SpendCap::new(sandbox.clone(), Ceiling::total(ceiling_minor, Currency::Eur), dir.join("sandbox-spend.json")).unwrap();
Rig { pay: MockPay::new(), sandbox, cloud, product: MockProduct::gunnar(), ledger: Ledger::in_memory().unwrap(), signer: Signer::generate(), dir }
}
fn declared(name: &str, ceiling_minor: u64) -> Rig {
let dir = tmp(name);
let sb = declared_sandbox(&dir);
Rig::new(name, sb, ceiling_minor)
}
fn tx(&self) -> Transaction<'_> {
Transaction { pay: monetize_pay::Vendor::of(&self.pay), cloud: &self.cloud, product: self.product.served(), ledger: &self.ledger, signer: &self.signer, now_unix_ms: NOW }
}
fn steps(&self, reference: &str) -> Vec<&'static str> {
self.ledger
.rows(reference)
.unwrap()
.into_iter()
// `Step::name` is the one writer of these strings — the wire row and
// the console badge use it too. This kept a third copy, and being
// behind a feature is how the copy went stale without anyone seeing:
// `cargo test --workspace` does not compile it.
.map(|r| r.step.name())
.collect()
}
/// Every resource id the ledger says was provisioned, in row order.
fn provisioned_ids(&self) -> Vec<ResourceId> {
self.ledger
.export_rows()
.unwrap()
.into_iter()
.filter_map(|(_, r)| match r.step {
Step::Ensured { ensured: Ensured::Provisioned { id, .. }, .. } => Some(id),
_ => None,
})
.collect()
}
}
/// The order for more: 100 GiB where the tenant has 10.
fn order_100() -> Order {
Order { product: "gunnar".into(), tenant: TenantId("alice".into()), caps: [("pack_bytes".to_string(), 100u64 << 30)].into() }
}
// ── the full cycle ───────────────────────────────────────────────────────────
/// reserve → ensure → capture, on the rehearsal cloud. The rows land, the id is
/// `sandbox:…`, the cap's total rises by the price, the iron is on disk marked.
#[test]
fn a_purchase_lands_in_the_ledger_as_marked_sandbox_iron_and_moves_the_spend_total() {
let rig = Rig::declared("cycle", 100_000);
let small = purchase("alice");
let out = rig.tx().run(&small).expect("the purchase goes through");
assert!(matches!(out, Outcome::Paid { .. }), "{out:?}");
let reference = match &out {
Outcome::Paid { reference, .. } => reference.clone(),
other => panic!("{other:?}"),
};
assert_eq!(rig.steps(&reference), vec!["Reserved", "Ensured", "Captured", "EntitlementPushed"], "rule 1, in order");
let ids = rig.provisioned_ids();
assert_eq!(ids.len(), 1);
assert_eq!(ids[0].provider, "sandbox");
assert!(ids[0].id.starts_with("sandbox:disk:"), "{}", ids[0].id);
// The money-shaped numbers agree from three directions: the ledger's fold,
// the cap's durable total, and the iron's own cost.
assert_eq!(rig.ledger.total_provisioned_minor(Currency::Eur).unwrap(), 230, "10 GiB × 23");
assert_eq!(rig.cloud.spent_total(), 230);
let iron = rig.sandbox.resources();
assert_eq!(iron.len(), 1);
assert_eq!(iron[0].cost.minor_per_month, 230);
assert_eq!(iron[0].reference, reference, "rule 2: attached to the order");
assert_eq!(iron[0].labels[LABEL_PROVENANCE], "sandbox");
assert_eq!(rig.sandbox.status(&ids[0]).unwrap(), ResourceStatus::Active);
// And persisted, so a restart of the test deployment forgets nothing.
let on_disk = std::fs::read_to_string(rig.dir.join("sandbox-cloud.json")).unwrap();
assert!(on_disk.contains(&ids[0].id) && on_disk.contains(&reference), "{on_disk}");
assert!(std::fs::read_to_string(rig.dir.join("sandbox-spend.json")).unwrap().contains("230"));
// What we own out there, asked THROUGH the cap: the disk, marked, priced —
// and reconciled against the ledger it is neither orphan nor missing.
let inv = monetize_cloud::ask::account(&rig.cloud).unwrap();
assert_eq!(inv.owned.iter().map(|o| (&o.id, o.gib, o.reference.as_deref(), o.provenance.as_str())).collect::<Vec<_>>(), vec![(&ids[0], 10, Some(reference.as_str()), "sandbox")]);
assert_eq!(inv.foreign, 0);
let rec = monetize::reconcile(&inv.owned, &rig.ledger.all_holdings().unwrap());
assert_eq!(rec.matched.len(), 1);
assert!(rec.orphans.is_empty() && rec.missing.is_empty(), "{rec:?}");
// Give it back: gone from the inventory — and now the ledger, which still
// says alice holds it, reads it as MISSING until the teardown row lands.
monetize_cloud::ask::destroy_one(&rig.cloud, &ids[0]).unwrap();
assert!(monetize_cloud::ask::account(&rig.cloud).unwrap().owned.is_empty(), "destroyed iron is not owned");
let rec = monetize::reconcile(&monetize_cloud::ask::account(&rig.cloud).unwrap().owned, &rig.ledger.all_holdings().unwrap());
assert_eq!(rec.missing.iter().map(|h| h.reference.as_str()).collect::<Vec<_>>(), vec![reference.as_str()], "sold capacity that is not there");
}
// ── the order for more: the delta, never the whole amount again ─────────────
/// **RED: a tenant on 10 GiB who orders 100 must end up holding 100, not 110** —
/// `monetize::transaction`'s test, on the rehearsal cloud, read off the sandbox's
/// recorded iron instead of a mock's counter.
#[test]
fn an_order_ensures_only_the_delta_on_the_sandbox_cloud() {
let rig = Rig::declared("increase", 100_000);
rig.tx().run(&purchase("alice")).unwrap();
assert_eq!(rig.sandbox.live_gib(), 10);
let out = rig.tx().order(&order_100(), &terms(), NOW + 86_400_000, None).expect("the order goes through");
assert!(matches!(out, Outcome::Paid { .. }), "{out:?}");
// THE MONEY-SHAPED NUMBER: what the provider would invoice. 100, not 110.
assert_eq!(rig.sandbox.live_gib(), 100, "the tenant asked for 100 GiB and must be billed for 100 GiB");
let sizes: Vec<u64> = rig.sandbox.resources().iter().map(|r| r.size_gib).collect();
assert_eq!(sizes, vec![10, 90], "one call, for the 90 they lacked");
let fps: Vec<String> = rig.sandbox.calls().iter().filter_map(|c| match c {
monetize_cloud::sandbox::Call::Ensure { fingerprint, .. } => Some(fingerprint.clone()),
_ => None,
}).collect();
assert_eq!(fps, vec!["disk:gunnar-se-sto1:10".to_string(), "disk:gunnar-se-sto1:90".to_string()]);
assert_eq!(rig.ledger.total_provisioned_minor(Currency::Eur).unwrap(), 100 * 23, "billed 100, not 110");
assert_eq!(rig.cloud.spent_total(), 100 * 23);
// The tenant is entitled to the WHOLE 100 GiB though only 90 was bought.
let fact = rig.ledger.entitlement("gunnar", "alice").unwrap().unwrap();
assert_eq!(fact.caps["pack_bytes"], 100 << 30);
}
/// **REFUSE-TWIN.** The delta is only worth anything if the naive path really
/// would buy twice on this cloud — the sandbox is idempotent on reference +
/// resource, and the naive re-purchase is a new reference.
#[test]
fn refuse_twin_selling_the_whole_amount_outright_buys_the_ten_again() {
let rig = Rig::declared("naive", 100_000);
let small = purchase("alice");
rig.tx().run(&small).unwrap();
let mut naive = small.clone();
naive.caps.insert("pack_bytes".into(), 100 << 30);
naive.resources = vec![disk(100)];
naive.starts_unix_ms = NOW + 86_400_000;
rig.tx().run(&naive).unwrap();
assert_eq!(rig.sandbox.live_gib(), 110, "MEASURED: a plain re-purchase buys the 10 GiB the tenant already holds");
assert_eq!(rig.cloud.spent_total(), 110 * 23);
}
// ── the cap refuses before the sandbox is touched ────────────────────────────
/// The €10 lifetime ceiling, crossed: the transaction releases, and the
/// sandbox's call log is EMPTY — it was never asked, so nothing was minted.
#[test]
fn the_cap_refuses_before_the_sandbox_is_called_and_the_attempt_is_released() {
let rig = Rig::declared("cap", 1000);
let mut big = purchase("alice");
big.resources = vec![disk(50)]; // 1150 > 1000
let out = rig.tx().run(&big).expect("a refusal is an outcome, not a crash");
assert!(matches!(out, Outcome::Released { .. }), "{out:?}");
assert!(rig.sandbox.calls().is_empty(), "the sandbox must not have been called: {:?}", rig.sandbox.calls());
assert!(rig.sandbox.resources().is_empty());
assert_eq!(rig.cloud.spent_total(), 0);
assert_eq!(rig.ledger.total_provisioned_minor(Currency::Eur).unwrap(), 0);
let reference = match out {
Outcome::Released { reference, .. } => reference,
other => panic!("{other:?}"),
};
assert_eq!(rig.steps(&reference), vec!["Reserved", "Released"], "rule 1: a failed ensure releases");
// Twin: 40 GiB (920) fits, and then the next 10 GiB (230) crosses — the
// ceiling is LIFETIME, so the second attempt is refused with the first's
// total already committed.
let mut fits = purchase("alice");
fits.resources = vec![disk(40)];
assert!(matches!(rig.tx().run(&fits).unwrap(), Outcome::Paid { .. }));
assert_eq!(rig.cloud.spent_total(), 920);
let next = purchase("bob");
assert!(matches!(rig.tx().run(&next).unwrap(), Outcome::Released { .. }));
assert_eq!(rig.sandbox.calls().len(), 1, "only the purchase that fit reached the sandbox");
}
// ── restart ──────────────────────────────────────────────────────────────────
/// Persist, drop the in-memory instance, reopen: `status` is still `Active`,
/// `destroy` still works, and the cap's total is still what the ledger says.
#[test]
fn the_test_deployment_forgets_nothing_across_a_restart() {
let dir = tmp("restart");
let id = {
let rig = Rig::new("restart", declared_sandbox(&dir), 100_000);
rig.tx().run(&purchase("alice")).unwrap();
rig.provisioned_ids().remove(0)
// rig dropped: sandbox, cap and ledger instances are gone
};
let again = declared_sandbox(&dir);
assert_eq!(again.status(&id).unwrap(), ResourceStatus::Active, "the iron survived the process");
let cap = SpendCap::new(again, Ceiling::total(100_000, Currency::Eur), dir.join("sandbox-spend.json")).unwrap();
assert_eq!(cap.spent_total(), 230, "so did the spend");
monetize_cloud::ask::destroy_one(&cap, &id).expect("destroy after a restart");
assert_eq!(monetize_cloud::ask::status(&cap, &id).unwrap(), ResourceStatus::Gone);
// Twin: a sandbox that was never given a home DOES forget — the registry is
// what gives a deployment its state path, and this is why.
let ephemeral = {
let mut c = SandboxConfig::new();
c.declared = Some(DeclaredPrices { disk_minor_per_gib_month: 23, plan_minor_per_month: BTreeMap::new(), currency: Currency::Eur });
Sandbox::new(c).unwrap()
};
let ghost = match ephemeral.ensure("gunnar/x/2026-09", &disk(1)).unwrap() {
Ensured::Provisioned { id, .. } => id,
other => panic!("{other:?}"),
};
drop(ephemeral);
let mut c = SandboxConfig::new();
c.declared = Some(DeclaredPrices { disk_minor_per_gib_month: 23, plan_minor_per_month: BTreeMap::new(), currency: Currency::Eur });
assert!(matches!(Sandbox::new(c).unwrap().status(&ghost), Err(monetize_cloud::CloudError::UnknownResource(_))));
}