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/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(_))));
}