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

nordisk/monetize

crates/monetize-cloud-wasm/tests/the_toy_cloud_guest.rs

//! **The cloud guest host against the REAL built toy cloud** — the ABI end to
//! end: vouching, the kind and name gates, the start-path proof, every op
//! keeping its answer's shape and its error's kind, the pool ledger durable in
//! the host's state across calls AND across a rebuild of the host (a restart),
//! and the spend ceiling the host wraps it in refusing over the limit.
//!
//! The module is required, never skipped: build it with
//! `cargo xtask build-guests` (it is in `GUEST_CRATES`).

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

use monetize_cloud::cap::{Ceiling, SpendCap};
use monetize_cloud::{CloudError, Ensured, ResourceId, ResourceStatus, Room, ServerId, Zone};
use monetize_cloud_wasm::{guest_settings, GuestCloud};

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

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 copy(dir: &Path, stem: &str, as_name: &str) -> PathBuf {
    let dst = dir.join(as_name);
    std::fs::copy(built(stem), &dst).unwrap();
    dst
}

/// 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();
    let mut root = module.as_os_str().to_owned();
    root.push(".root.der");
    let root = PathBuf::from(root);
    std::fs::write(&root, &ca_der).unwrap();
    sign::sign_module_file(
        module,
        &dev::new_ed25519_signer(&ca_key, &ca_der, "test-plugin-signing").unwrap(),
    )
    .unwrap();
    root
}

fn block(module: &Path, extra: serde_json::Value) -> serde_json::Value {
    let mut b = serde_json::json!({
        "guest": module,
        "guest_digest": guest_host::digest_hex(&std::fs::read(module).unwrap()),
        "guest_trust_roots": [vouched(module)],
        "grants": ["state"],
        "disk_minor_per_gib_month": 23,
        "tag": "v1",
    });
    for (k, v) in extra.as_object().unwrap() {
        b[k] = v.clone();
    }
    b
}

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

#[test]
fn the_toy_cloud_answers_every_op_and_its_ledger_survives_a_restart() {
    let dir = scratch("ops");
    let module = copy(&dir, "monetize_guest_toy_cloud", "toycloud.wasm");
    let b = block(&module, serde_json::json!({}));
    let c = GuestCloud::build("toycloud", &dir, &b).expect("the toy cloud loads");
    let m = monetize_cloud::Cloud::manifest(&c);
    assert_eq!(m.provider, "toycloud");
    assert_eq!(m.zones, vec![Zone("toy-1".into())]);
    assert_eq!(
        monetize_cloud::ask::ceilings(&c).get("cpu_millicores"),
        Some(&4_000)
    );

    // The settings reached the guest; the host's keys did not.
    let s = guest_settings(&b);
    assert!(
        s.get("guest").is_none() && s.get("guest_digest").is_none() && s.get("grants").is_none(),
        "{s}"
    );
    assert_eq!(s["tag"], "v1");

    assert_eq!(
        monetize_cloud::Cloud::price(&c, &monetize_cloud::cloud::Priced::Resource(&disk(10)))
            .unwrap()
            .minor_per_month,
        230
    );
    let first = match monetize_cloud::ask::ensure(&c, "ref-a", &disk(10), Some(Room::Buy)).unwrap()
    {
        Ensured::Provisioned { id, cost } => {
            assert_eq!(cost.minor_per_month, 230);
            assert!(id.id.starts_with("v1:"), "{}", id.id);
            id
        }
        other => panic!("{other:?}"),
    };
    // Idempotent through the host's memory: the guest is a fresh instance per
    // call, so only the host state can make the retry find the first purchase.
    match monetize_cloud::ask::ensure(&c, "ref-a", &disk(10), Some(Room::Buy)).unwrap() {
        Ensured::Provisioned { id, .. } => assert_eq!(id, first, "a retried ensure bought twice"),
        other => panic!("{other:?}"),
    }
    assert!(
        c.state_file().is_file(),
        "the state is durable beside the ledger"
    );
    assert_eq!(
        monetize_cloud::ask::status(&c, &first).unwrap(),
        ResourceStatus::Active
    );
    assert_eq!(monetize_cloud::ask::account(&c).unwrap().owned.len(), 1);
    assert!(
        matches!(monetize_cloud::ask::ensure(&c, "unavailable", &disk(1), None), Err(CloudError::Unavailable(m)) if m.contains("down")),
        "the kind survives the wire"
    );
    drop(c);

    // A RESTART: a new host over the same data dir remembers the grant.
    let again = GuestCloud::build("toycloud", &dir, &b).expect("reloads");
    match monetize_cloud::ask::ensure(&again, "ref-a", &disk(10), Some(Room::Buy)).unwrap() {
        Ensured::Provisioned { id, .. } => assert_eq!(
            id, first,
            "a restart forgot a purchase and would buy it again"
        ),
        other => panic!("{other:?}"),
    }
    monetize_cloud::ask::destroy_one(&again, &first).unwrap();
    assert_eq!(
        monetize_cloud::ask::status(&again, &first).unwrap(),
        ResourceStatus::Gone
    );
    let unknown = ResourceId {
        provider: "toycloud".into(),
        id: "nothing".into(),
    };
    assert!(
        matches!(monetize_cloud::ask::destroy_one(&again, &unknown), Err(CloudError::UnknownResource(id)) if id == unknown)
    );
    // The journaled growth CROSSES (2026-09-26): the toy cloud has no growth path,
    // and it is the GUEST's own refusal that comes back — naming the toy cloud —
    // with nothing written to the host's journal.
    let journal = monetize_cloud::grow::Journal::open(dir.join("j.json")).unwrap();
    let set = monetize_cloud::grow::DataSet {
        product: "p".into(),
        server: ServerId("box-1".into()),
        members: vec![],
        target_gib: 1,
    };
    let grown = monetize_cloud::ask::grow(&again, &set, false, &journal);
    assert!(
        matches!(&grown, Err(CloudError::Refused(m)) if m.contains("toycloud cloud cannot grow")),
        "{grown:?}"
    );
    assert!(
        journal.records().is_empty(),
        "a refused growth journals nothing"
    );
    // A journal with no file cannot be written through to: refused by the host.
    let sinkless = monetize_cloud::grow::Journal::with_sink(
        vec![],
        Box::new(|_: &monetize_cloud::grow::GrowRecord| Ok(())),
    );
    assert!(
        matches!(monetize_cloud::ask::grow(&again, &set, false, &sinkless), Err(CloudError::Refused(m)) if m.contains("grow_set"))
    );
    let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn the_host_refuses_what_is_not_a_cloud_or_not_this_cloud_or_not_vouched_for_or_cannot_prove_itself(
) {
    let dir = scratch("refusals");
    let module = copy(&dir, "monetize_guest_toy_cloud", "toycloud.wasm");
    // Nobody vouches.
    let mut b = block(&module, serde_json::json!({}));
    b.as_object_mut().unwrap().remove("guest_digest");
    b.as_object_mut().unwrap().remove("guest_trust_roots");
    let e = GuestCloud::build("toycloud", &dir, &b).unwrap_err();
    assert!(
        e.contains("[clouds.toycloud]") && e.contains("nobody vouches"),
        "{e}"
    );
    // Under another name: its purchases would land under another ceiling.
    let e = GuestCloud::build("upcloud", &dir, &block(&module, serde_json::json!({}))).unwrap_err();
    assert!(
        e.contains("[clouds.upcloud]") && e.contains("`toycloud`"),
        "{e}"
    );
    // A PRODUCT module under [clouds.*]: the kind gate. The Lake's guest, a product that opens
    // no door, so the refusal is the kind's alone.
    let product = copy(&dir, "monetize_guest_lake", "lake.wasm");
    let mut pb = block(&product, serde_json::json!({}));
    pb["grants"] = serde_json::json!([]);
    let e = GuestCloud::build("lake", &dir, &pb).unwrap_err();
    assert!(
        e.contains("`product` guest") && e.contains("`cloud` guest"),
        "{e}"
    );
    // The toy, a TEST guest, under [clouds.*]: only a products block loads a test guest.
    let toy = copy(&dir, "monetize_guest_toy", "toy.wasm");
    let mut tb = block(&toy, serde_json::json!({}));
    tb["grants"] = serde_json::json!(monetize_product_guest::grants::TEST);
    let e = GuestCloud::build("toy", &dir, &tb).unwrap_err();
    assert!(
        e.contains("`test` guest") && e.contains("`cloud` guest"),
        "{e}"
    );
    // The start-path proof fails: refused at load, by name.
    let e = GuestCloud::build(
        "toycloud",
        &dir,
        &block(&module, serde_json::json!({"refuse_verify": true})),
    )
    .unwrap_err();
    assert!(
        e.contains("could not prove") && e.contains("credential is dead"),
        "{e}"
    );
    // An ungranted door: the module imports the state door and the block does not grant it.
    let mut nb = block(&module, serde_json::json!({}));
    nb["grants"] = serde_json::json!([]);
    let e = GuestCloud::build("toycloud", &dir, &nb).unwrap_err();
    assert!(e.contains("`state`"), "{e}");
    let _ = std::fs::remove_dir_all(&dir);
}

/// **The spend ceiling stays on the host**: the same wrapper a linked cloud
/// gets, and it refuses over the limit before the guest is ever asked to buy.
#[test]
fn the_host_ceiling_refuses_an_over_limit_purchase_before_the_guest_buys() {
    let dir = scratch("cap");
    let module = copy(&dir, "monetize_guest_toy_cloud", "toycloud.wasm");
    let c = GuestCloud::build("toycloud", &dir, &block(&module, serde_json::json!({}))).unwrap();
    let capped = SpendCap::new(
        c,
        Ceiling::total(300, monetize_pay::Currency::Eur),
        dir.join("toycloud-spend.json"),
    )
    .unwrap();
    assert!(
        matches!(
            monetize_cloud::ask::ensure(&capped, "r1", &disk(10), Some(Room::Buy)),
            Ok(Ensured::Provisioned { .. })
        ),
        "230 of 300"
    );
    let over = monetize_cloud::ask::ensure(&capped, "r2", &disk(10), Some(Room::Buy));
    assert!(
        matches!(&over, Err(CloudError::OverCap(m)) if m.contains("3.00 EUR")),
        "{over:?}"
    );
    assert_eq!(
        monetize_cloud::ask::account(&capped).unwrap().owned.len(),
        1,
        "the refused purchase never reached the guest"
    );
    let _ = std::fs::remove_dir_all(&dir);
}

/// The kit's three machines, one 10 GiB system disk each, for `company`.
fn estate(company: &str) -> monetize_cloud::lay::EstatePlan {
    use monetize_cloud::lay::{
        default_interfaces, reference_of, DiskPlan, EstatePlan, MachinePlan,
    };
    let m = |role: &str| MachinePlan {
        role: role.into(),
        hostname: format!("gunnar-{role}-{company}"),
        title: format!("gunnar {role}-{company}"),
        plan: "TOY-1xCPU-2GB-toy-1".into(),
        boot_order: "disk,cdrom".into(),
        labels: Default::default(),
        interfaces: default_interfaces(),
        medium: String::new(),
        medium_object: None,
        template: String::new(),
        user_data: Default::default(),
        disks: vec![DiskPlan {
            title: format!("gunnar-{role}-{company} system"),
            gib: 10,
            tier: "standard".into(),
            labels: Default::default(),
        }],
    };
    EstatePlan {
        reference: reference_of(company),
        company: company.into(),
        zone: Zone("toy-1".into()),
        machines: vec![m("front"), m("main"), m("twin")],
        narrate_from: String::new(),
        shape: None,
    }
}

/// ★ **A guest cloud lays a sold estate** (2026-10-08, test2's shop: "the
/// upcloud cloud cannot lay an estate"): the plan list and the estate's
/// price come from the GUEST, the lay crosses journaled — every record the
/// guest writes is in the job's journal FILE when the call returns — and the
/// host's ceiling admits the whole estate before the guest is asked, so an
/// estate over it is refused with nothing laid and nothing journaled.
#[test]
fn a_guest_cloud_lays_an_estate_journaled_to_the_jobs_file_under_the_hosts_ceiling() {
    let dir = scratch("lay");
    let module = copy(&dir, "monetize_guest_toy_cloud", "toycloud.wasm");
    let c = GuestCloud::build(
        "toycloud",
        &dir,
        &block(&module, serde_json::json!({"tag": "v7"})),
    )
    .unwrap();

    let plans = monetize_cloud::ask::plan_catalog(&c, &Zone("toy-1".into()))
        .expect("the guest's plan list, not the trait's refusal");
    assert_eq!(plans[0].name, "TOY-1xCPU-2GB-toy-1");
    assert_eq!(
        monetize_cloud::Cloud::price(&c, &monetize_cloud::cloud::Priced::Plan(&estate("acme")))
            .unwrap()
            .minor_per_month,
        3 * (1_000 + 230),
        "three servers and three 10 GiB disks at the toy's prices"
    );

    // Over the ceiling: refused before the guest lays, nothing journaled.
    let tight = SpendCap::new(
        GuestCloud::build(
            "toycloud",
            &dir,
            &block(&module, serde_json::json!({"tag": "v7"})),
        )
        .unwrap(),
        Ceiling::total(1_000, monetize_pay::Currency::Eur),
        dir.join("tight-spend.json"),
    )
    .unwrap();
    let journal = monetize_cloud::grow::Journal::open(dir.join("tight-journal")).unwrap();
    let over = monetize_cloud::Cloud::lay(&tight, &estate("acme"), &journal);
    assert!(
        matches!(&over, Err(CloudError::OverCap(m)) if m.contains("1000") || m.contains("10.00")),
        "{over:?}"
    );
    assert!(
        journal.records().is_empty(),
        "nothing was asked of the guest"
    );

    // Under it: laid, and the job's journal FILE holds every record.
    let capped = SpendCap::new(
        c,
        Ceiling::total(10_000, monetize_pay::Currency::Eur),
        dir.join("toycloud-spend.json"),
    )
    .unwrap();
    let journal = monetize_cloud::grow::Journal::open(dir.join("journal")).unwrap();
    let laid = monetize_cloud::Cloud::lay(&capped, &estate("acme"), &journal)
        .expect("laid through the guest");
    assert_eq!(
        laid.machines
            .iter()
            .map(|m| m.role.as_str())
            .collect::<Vec<_>>(),
        vec!["front", "main", "twin"]
    );
    assert_eq!(
        laid.machine("main").unwrap().server,
        "v7:estate/acme:main",
        "the block's settings reached the guest"
    );
    assert_eq!(
        journal.records().len(),
        6,
        "an intent and a confirmation per server, read back by the caller's journal"
    );
    let file = monetize_cloud::grow::Journal::open(dir.join("journal")).unwrap();
    assert_eq!(
        file.records().len(),
        6,
        "written THROUGH to the file, not kept in the guest"
    );
    assert!(file
        .records()
        .iter()
        .all(|r| r.step == monetize_cloud::lay::step::SERVER));

    // A journal with no file: refused by the host, by name, nothing laid.
    let sinkless = monetize_cloud::grow::Journal::with_sink(
        vec![],
        Box::new(|_: &monetize_cloud::grow::GrowRecord| Ok(())),
    );
    assert!(
        matches!(monetize_cloud::Cloud::lay(&capped, &estate("bolt"), &sinkless), Err(CloudError::Refused(m)) if m.contains("lay_estate"))
    );
    // A verb the toy has not: the GUEST's own refusal, naming it.
    let keep = monetize_cloud::lay::KeepBoot {
        reference: laid.reference.clone(),
        zone: laid.zone.clone(),
        machine: laid.machine("front").unwrap().clone(),
        medium: String::new(),
        medium_object: None,
    };
    let kept = monetize_cloud::ask::keep_boot(&capped, &keep, &journal);
    assert!(
        matches!(&kept, Err(CloudError::Refused(m)) if m.contains("toycloud cloud has no `keep_boot`")),
        "{kept:?}"
    );
    let _ = std::fs::remove_dir_all(&dir);
}

/// ★ **A32 (wiring trace, 2026-10-08): a lay never holds the shop's room
/// purchases back.** A sold estate's lay waits minutes on the provider
/// (UpCloud's servers); on the guest's one lane it held every purchase of the
/// shop behind it for up to 15 minutes. The lay runs on the estate's own
/// lane: a room purchase made while it waits is served at once, durable in
/// the host's state, and the lay finishes as it would have.
#[test]
fn a_room_purchase_is_served_while_a_lay_is_in_progress() {
    use std::time::{Duration, Instant};
    let dir = scratch("lane");
    let module = copy(&dir, "monetize_guest_toy_cloud", "toycloud.wasm");
    let c = std::sync::Arc::new(
        GuestCloud::build(
            "toycloud",
            &dir,
            &block(
                &module,
                serde_json::json!({"tag": "v9", "lay_takes_ms": 4_000}),
            ),
        )
        .unwrap(),
    );
    let journal = monetize_cloud::grow::Journal::open(dir.join("journal")).unwrap();
    let lay = {
        let c = std::sync::Arc::clone(&c);
        std::thread::spawn(move || {
            let t = Instant::now();
            let laid = monetize_cloud::Cloud::lay(&c, &estate("acme"), &journal);
            (laid, t.elapsed())
        })
    };
    std::thread::sleep(Duration::from_millis(500));
    let t = Instant::now();
    let bought = monetize_cloud::ask::ensure(&c, "room-a", &disk(10), Some(Room::Buy))
        .expect("a room purchase while the lay waits");
    let took = t.elapsed();
    let Ensured::Provisioned { id, .. } = bought else {
        panic!("{bought:?}")
    };
    assert!(
        took < Duration::from_secs(2),
        "served beside the lay, not behind it: {took:?}"
    );
    let (laid, lay_took) = lay.join().unwrap();
    assert_eq!(
        laid.expect("the lay finishes as it would have")
            .machine("main")
            .unwrap()
            .server,
        "v9:estate/acme:main"
    );
    assert!(lay_took >= Duration::from_secs(4), "{lay_took:?}");
    assert!(
        took + Duration::from_millis(500) < lay_took,
        "the purchase was done while the lay still waited: {took:?} against {lay_took:?}"
    );
    // The purchase is in the host's durable state: a restarted host answers it again.
    let again = GuestCloud::build(
        "toycloud",
        &dir,
        &block(&module, serde_json::json!({"tag": "v9"})),
    )
    .unwrap();
    assert!(
        matches!(monetize_cloud::ask::ensure(&again, "room-a", &disk(10), Some(Room::Buy)).unwrap(), Ensured::Provisioned { id: same, .. } if same == id),
        "the ledger kept it: a retry answers the same purchase"
    );
    let _ = std::fs::remove_dir_all(&dir);
}

/// ★ **A sold estate's growth runs on the estate's lane** (wiring trace,
/// 2026-10-08): `SoldEstate.GrowSet` grows a SOLD estate's disks at the shop
/// (detach, resize, attach — minutes at UpCloud). On the everyday lane it
/// waited behind, and held back, the shop's own purchases; `grow_estate_set`
/// runs beside them. Here a slow purchase holds the everyday lane, and the
/// estate's growth is answered at once (the toy grows nothing: its own
/// refusal, by name), while an everyday `grow_set` waits its turn.
#[test]
fn a_sold_estates_growth_is_not_held_behind_the_shops_purchases() {
    use std::time::{Duration, Instant};
    let dir = scratch("growlane");
    let module = copy(&dir, "monetize_guest_toy_cloud", "toycloud.wasm");
    let c = std::sync::Arc::new(
        GuestCloud::build(
            "toycloud",
            &dir,
            &block(
                &module,
                serde_json::json!({"tag": "v10", "ensure_takes_ms": 3_000}),
            ),
        )
        .unwrap(),
    );
    let set = monetize_cloud::grow::DataSet {
        product: "gunnar".into(),
        server: ServerId("estate-main".into()),
        members: vec![],
        target_gib: 20,
    };
    let journal = monetize_cloud::grow::Journal::open(dir.join("grow-journal")).unwrap();
    let buying = {
        let c = std::sync::Arc::clone(&c);
        std::thread::spawn(move || {
            monetize_cloud::ask::ensure(&c, "room-b", &disk(10), Some(Room::Buy))
        })
    };
    std::thread::sleep(Duration::from_millis(500));
    let t = Instant::now();
    let grown = monetize_cloud::ask::grow(&c, &set, true, &journal);
    let took = t.elapsed();
    assert!(
        matches!(&grown, Err(CloudError::Refused(m)) if m.contains("toycloud")),
        "the toy's own answer: {grown:?}"
    );
    assert!(
        took < Duration::from_secs(1),
        "answered beside the purchase, not behind it: {took:?}"
    );
    let t = Instant::now();
    let _ = monetize_cloud::ask::grow(&c, &set, false, &journal);
    assert!(
        t.elapsed() >= Duration::from_millis(1_500),
        "an everyday growth still waits its turn on the everyday lane"
    );
    assert!(buying.join().unwrap().is_ok());
    let _ = std::fs::remove_dir_all(&dir);
}

/// ★ **A lay on the estate's lane never holds the shop's growths back** (code-smell
/// review 2026-10-10, row 17): every JOURNALED call shared one write-through slot and
/// one lock, so a 15-minute lay held an everyday `grow_set` behind it, against the lane
/// rule above. Each call has its own slot now: the growth is answered beside the lay,
/// and the lay still writes its journal through to its own file.
#[test]
fn a_journaled_growth_is_not_held_behind_a_lay_on_the_estates_lane() {
    use std::time::{Duration, Instant};
    let dir = scratch("journal-lanes");
    let module = copy(&dir, "monetize_guest_toy_cloud", "toycloud.wasm");
    let c = std::sync::Arc::new(
        GuestCloud::build(
            "toycloud",
            &dir,
            &block(
                &module,
                serde_json::json!({"tag": "v11", "lay_takes_ms": 4_000}),
            ),
        )
        .unwrap(),
    );
    let lay_journal = monetize_cloud::grow::Journal::open(dir.join("lay-journal")).unwrap();
    let lay = {
        let c = std::sync::Arc::clone(&c);
        std::thread::spawn(move || {
            let laid = monetize_cloud::Cloud::lay(&c, &estate("acme"), &lay_journal);
            (laid, lay_journal.records().len())
        })
    };
    std::thread::sleep(Duration::from_millis(500));
    let set = monetize_cloud::grow::DataSet {
        product: "gunnar".into(),
        server: ServerId("main".into()),
        members: vec![],
        target_gib: 20,
    };
    let grow_journal = monetize_cloud::grow::Journal::open(dir.join("grow-journal")).unwrap();
    let t = Instant::now();
    let grown = monetize_cloud::ask::grow(&c, &set, false, &grow_journal);
    let took = t.elapsed();
    assert!(
        matches!(&grown, Err(CloudError::Refused(m)) if m.contains("toycloud")),
        "the toy's own answer: {grown:?}"
    );
    assert!(
        took < Duration::from_secs(2),
        "answered beside the lay, not behind it: {took:?}"
    );
    let (laid, records) = lay.join().unwrap();
    laid.expect("the lay finishes as it would have");
    assert!(
        records > 0,
        "the lay's journal was still written through to its own file"
    );
    assert!(
        grow_journal.records().is_empty(),
        "nothing of the lay's journal reached the growth's file"
    );
    let _ = std::fs::remove_dir_all(&dir);
}

/// ★ **A purchase the guest made is never answered as nothing bought** (code-smell review
/// 2026-10-10, finding 4): when the host cannot write its state file after the call, the
/// guest's answer — the server it bought — still comes back; the write is tried again on
/// the next call, and a restart remembers the purchase.
#[test]
fn a_state_file_that_cannot_be_written_never_hides_a_purchase_the_guest_made() {
    let dir = scratch("unwritten");
    let module = copy(&dir, "monetize_guest_toy_cloud", "toycloud.wasm");
    let b = block(&module, serde_json::json!({}));
    let c = GuestCloud::build("toycloud", &dir, &b).expect("the toy cloud loads");
    // The state file's place is taken by a directory that is not empty: no rename lands there.
    let state = c.state_file().to_path_buf();
    let _ = std::fs::remove_file(&state);
    std::fs::create_dir_all(state.join("in-the-way")).unwrap();
    let bought = monetize_cloud::ask::ensure(&c, "ref-p", &disk(10), Some(Room::Buy));
    let id = match bought {
        Ok(Ensured::Provisioned { id, .. }) => id,
        other => panic!("the guest bought the disk; the host must say so, whatever its state file did: {other:?}"),
    };
    assert!(
        c.state_unwritten().is_some(),
        "the unwritten state is marked, not lost in a log line"
    );
    std::fs::remove_dir_all(&state).unwrap();
    assert_eq!(
        monetize_cloud::ask::status(&c, &id).unwrap(),
        ResourceStatus::Active
    );
    assert!(
        state.is_file(),
        "the next call writes the state that was not written"
    );
    assert_eq!(
        c.state_unwritten(),
        None,
        "a write that lands clears the mark"
    );
    drop(c);
    let again = GuestCloud::build("toycloud", &dir, &b).expect("reloads");
    match monetize_cloud::ask::ensure(&again, "ref-p", &disk(10), Some(Room::Buy)).unwrap() {
        Ensured::Provisioned { id: same, .. } => {
            assert_eq!(same, id, "a restart forgot the purchase")
        }
        other => panic!("{other:?}"),
    }
    let _ = std::fs::remove_dir_all(&dir);
}

/// ★ **A linked provider's pool ledger that cannot be read is never taken as no ledger**
/// (code-smell review 2026-10-10, row 10): the first start of a cloud as a guest seeds its
/// state from `<name>-pool.json`. Only a ledger that does not EXIST means "start empty"; one
/// that is there and cannot be read (here a directory; in life a permission, an IO error)
/// refuses the start — an empty ledger would sell again what was sold.
#[test]
fn a_pool_ledger_that_cannot_be_read_refuses_the_start_and_only_a_missing_one_starts_empty() {
    let dir = scratch("pool-unreadable");
    let module = copy(&dir, "monetize_guest_toy_cloud", "toycloud.wasm");
    let b = block(&module, serde_json::json!({}));
    std::fs::create_dir_all(dir.join("toycloud-pool.json").join("in-the-way")).unwrap();
    let refused = GuestCloud::build("toycloud", &dir, &b)
        .expect_err("an unreadable pool ledger refuses the start");
    assert!(
        refused.contains("toycloud-pool.json"),
        "the refusal names the ledger: {refused}"
    );
    std::fs::remove_dir_all(dir.join("toycloud-pool.json")).unwrap();
    GuestCloud::build("toycloud", &dir, &b).expect("no ledger at all: an empty start");
    let _ = std::fs::remove_dir_all(&dir);
}

/// ★ **A destroy is not cut off at the everyday call deadline** (F4, the ONE box 2026-10-20:
/// test3-holger-rs's terminate). UpCloud refuses to delete a STARTED server, so the guest stops
/// it hard, waits for `stopped` and only then deletes it with its disks: tens of seconds. The
/// host ran `destroy` under the block's everyday 30 s deadline, the call was cut off at
/// 28.6 s with the main stopped and not deleted ("op 213 (destroy): … deadline"), and the
/// give-back parked. A destroy runs under the cloud kind's whole deadline, as a stop does.
#[test]
fn a_destroy_that_outlasts_the_everyday_deadline_still_gives_the_resource_back() {
    use std::time::{Duration, Instant};
    let dir = scratch("slow-destroy");
    let module = copy(&dir, "monetize_guest_toy_cloud", "toycloud.wasm");
    let c = GuestCloud::build("toycloud", &dir, &block(&module, serde_json::json!({"tag": "v12", "guest_call_timeout_ms": 1_000, "destroy_takes_ms": 2_500}))).unwrap();
    let Ensured::Provisioned { id, .. } =
        monetize_cloud::ask::ensure(&c, "slow-destroy", &disk(10), Some(Room::Buy)).unwrap()
    else {
        panic!("bought")
    };
    let t = Instant::now();
    let gone = monetize_cloud::ask::destroy_one(&c, &id);
    assert!(
        gone.is_ok(),
        "a destroy that waits past the everyday deadline is not cut off: {gone:?}"
    );
    assert!(
        t.elapsed() >= Duration::from_millis(2_500),
        "it waited as the provider did: {:?}",
        t.elapsed()
    );
    assert_eq!(
        monetize_cloud::ask::status(&c, &id).unwrap(),
        ResourceStatus::Gone,
        "and the resource is gone"
    );
    // The everyday deadline still holds for the everyday calls. The purchase blocks on the host
    // and never answers by itself (`ensure_blocks`), so only the deadline can end it: a wait
    // of a fixed length raced the epoch ticker, and on a loaded box the call answered Ok.
    let slow = GuestCloud::build("toycloud", &dir, &block(&module, serde_json::json!({"tag": "v12", "guest_call_timeout_ms": 1_000, "ensure_blocks": true}))).unwrap();
    let bounded = monetize_cloud::ask::ensure(&slow, "slow-ensure", &disk(10), Some(Room::Buy));
    assert!(
        matches!(&bounded, Err(CloudError::Unavailable(e)) if e.contains("deadline")),
        "a purchase is still bounded by the block's deadline: {bounded:?}"
    );
    let _ = std::fs::remove_dir_all(&dir);
}