crates/monetize-pay/src/sandbox/tests.rs
//! Red-first for the sandbox vendor. The interesting assertions are the ones
//! about what it REFUSES: a vendor that always says yes is worthless as a
//! rehearsal, because the paths that matter in production are the refusals.
use super::*;
use crate::{Currency, Payment};
fn req(reference: &str, minor: u64) -> ChargeRequest {
ChargeRequest {
reference: reference.into(),
amount: Money { minor, currency: Currency::Sek },
market: Market::Se,
description: "a year of 10 GiB".into(),
payer: None,
}
}
fn provider(secret: &str) -> SandboxProvider {
SandboxProvider::new(SandboxConfig { callback_secret: secret.into(), state_path: None }).with_clock(|| 1_788_000_000_000)
}
#[test]
fn the_two_phase_shape_holds_and_capture_is_idempotent() {
let p = provider("s3cret");
let h = p.reserve(&req("gunnar/alice/2027", 22000)).unwrap();
assert_eq!(h.vendor, VENDOR);
assert_eq!(p.charge_status(&h).unwrap(), ChargeStatus::Reserved, "reserve draws nothing");
let captured = p.capture(&h).unwrap();
match &captured {
ChargeStatus::Captured { at_unix_ms, vendor_ref } => {
assert_eq!(*at_unix_ms, 1_788_000_000_000, "the clock is the injected one");
assert!(vendor_ref.contains(&h.id));
}
other => panic!("{other:?}"),
}
// Idempotent on the handle: the ledger may replay a capture after a crash.
assert_eq!(p.capture(&h).unwrap(), captured);
assert_eq!(p.charge_status(&h).unwrap(), captured);
}
#[test]
fn reserve_is_idempotent_on_the_reference_so_a_retry_mints_no_second_charge() {
let p = provider("s3cret");
let a = p.reserve(&req("gunnar/alice/2027", 22000)).unwrap();
let b = p.reserve(&req("gunnar/alice/2027", 22000)).unwrap();
assert_eq!(a, b, "the reference IS the idempotency key");
let c = p.reserve(&req("gunnar/bob/2027", 22000)).unwrap();
assert_ne!(a, c, "a different reference is a different charge");
}
#[test]
fn refuse_twin_the_things_a_yes_machine_must_still_say_no_to() {
let p = provider("s3cret");
assert!(matches!(p.reserve(&req("", 22000)), Err(PayError::Declined(_))), "no reference to key a ledger row on");
assert!(matches!(p.reserve(&req("r", 0)), Err(PayError::Declined(_))), "a charge of zero is not a charge");
let mut wrong = req("r-eur", 2000);
wrong.amount.currency = Currency::Eur;
assert!(matches!(p.reserve(&wrong), Err(PayError::Declined(_))), "SEK market, EUR money");
let alien = ChargeHandle { vendor: "stripe".into(), id: "pi_123".into() };
assert!(matches!(p.capture(&alien), Err(PayError::UnknownHandle(_))), "another vendor's handle");
assert!(matches!(p.charge_status(&alien), Err(PayError::UnknownHandle(_))));
let ghost = ChargeHandle { vendor: VENDOR.into(), id: "sbx_never".into() };
assert!(matches!(p.capture(&ghost), Err(PayError::UnknownHandle(_))), "a handle it never minted");
// Release-then-capture and capture-then-release must BOTH be refused: a
// vendor that allows both is one no ledger can reconcile against.
let r = p.reserve(&req("r-rel", 100)).unwrap();
assert_eq!(p.release(&r).unwrap(), ChargeStatus::Released);
assert_eq!(p.release(&r).unwrap(), ChargeStatus::Released, "release is idempotent");
assert!(matches!(p.capture(&r), Err(PayError::Declined(_))), "capture after release");
let c = p.reserve(&req("r-cap", 100)).unwrap();
p.capture(&c).unwrap();
assert!(matches!(p.release(&c), Err(PayError::Declined(_))), "a drawn charge is refunded, not released");
assert!(matches!(p.refund(&r, Money { minor: 1, currency: Currency::Sek }), Err(PayError::Declined(_))), "refund before capture");
assert!(p.refund(&c, Money { minor: 100, currency: Currency::Sek }).is_ok());
assert!(matches!(p.refund(&c, Money { minor: 101, currency: Currency::Sek }), Err(PayError::Declined(_))), "over-refund");
assert!(matches!(p.refund(&c, Money { minor: 1, currency: Currency::Eur }), Err(PayError::Declined(_))), "wrong currency");
}
#[test]
fn refuse_twin_a_forged_or_unsigned_callback_is_never_believed() {
let p = provider("s3cret");
let h = p.reserve(&req("gunnar/alice/2027", 22000)).unwrap();
let body = format!(r#"{{"reference":"gunnar/alice/2027","handle":"{}"}}"#, h.id);
let good = SandboxProvider::signature("s3cret", "gunnar/alice/2027", &h.id);
// The happy path FIRST, so the refusals below cannot be vacuous.
let ok = p
.parse_callback(RawCallback { headers: &[(SIGNATURE_HEADER.into(), good.clone())], body: body.as_bytes() })
.expect("a correctly signed callback verifies");
assert_eq!(ok.reference, "gunnar/alice/2027");
assert_eq!(ok.handle, h);
assert!(matches!(p.parse_callback(RawCallback { headers: &[], body: body.as_bytes() }), Err(PayError::Unverified(_))), "unsigned");
let forged = SandboxProvider::signature("guess", "gunnar/alice/2027", &h.id);
assert!(matches!(
p.parse_callback(RawCallback { headers: &[(SIGNATURE_HEADER.into(), forged)], body: body.as_bytes() }),
Err(PayError::Unverified(_))
), "the wrong secret");
let other = SandboxProvider::signature("s3cret", "gunnar/bob/2027", &h.id);
assert!(matches!(
p.parse_callback(RawCallback { headers: &[(SIGNATURE_HEADER.into(), other)], body: body.as_bytes() }),
Err(PayError::Unverified(_))
), "a signature minted for another reference, replayed onto this one");
for bad in [&b"not json"[..], br#"{"handle":"x"}"#, br#"{"reference":"r"}"#] {
assert!(matches!(
p.parse_callback(RawCallback { headers: &[(SIGNATURE_HEADER.into(), good.clone())], body: bad }),
Err(PayError::Unverified(_))
), "{}", String::from_utf8_lossy(bad));
}
// NO SECRET ⇒ every callback refused, including a correctly signed one.
// Fail-closed, the same choice klarna's impl makes.
let closed = provider("");
let h2 = closed.reserve(&req("r2", 100)).unwrap();
let b2 = format!(r#"{{"reference":"r2","handle":"{}"}}"#, h2.id);
let s2 = SandboxProvider::signature("", "r2", &h2.id);
assert!(matches!(
closed.parse_callback(RawCallback { headers: &[(SIGNATURE_HEADER.into(), s2)], body: b2.as_bytes() }),
Err(PayError::Unverified(_))
), "an unconfigured secret must not become an open door");
}
#[test]
fn the_signature_depends_on_every_input() {
let a = SandboxProvider::signature("s", "ref", "h");
assert_ne!(a, SandboxProvider::signature("t", "ref", "h"), "secret");
assert_ne!(a, SandboxProvider::signature("s", "other", "h"), "reference");
assert_ne!(a, SandboxProvider::signature("s", "ref", "g"), "handle");
assert_eq!(a, SandboxProvider::signature("s", "ref", "h"), "deterministic");
}
/// It must work through the trait object the registry hands out, not only the
/// concrete type — being deployable is the whole point of it.
#[test]
fn it_serves_as_a_boxed_payment() {
let p: Box<dyn Payment> = Box::new(provider("s"));
let m = p.manifest();
assert_eq!(m.vendor, "sandbox");
assert!(m.markets.contains(&Market::Se) && m.markets.contains(&Market::Fi));
let v = crate::Vendor::new(&m.vendor, &m.markets, &*p);
let h = v.reserve(&req("r", 500), &crate::payment::Return::vendors_own()).unwrap();
assert!(matches!(p.capture(&h).unwrap(), ChargeStatus::Captured { .. }));
}
/// **A vendor that forgets on restart is not a vendor.**
///
/// MEASURED on gunnar.rs 2026-09-04: a purchase reserved and captured through
/// the sandbox, the service was restarted, and the refund came back
/// `unknown handle sandbox:sbx_…` — the attempt could be neither finished nor
/// abandoned. Worse than the inconvenience: monetize's whole ledger design is
/// about resuming an attempt after a crash, and a vendor that forgets across a
/// restart cannot rehearse the one property most worth rehearsing.
#[test]
fn charges_survive_the_process_that_minted_them() {
let dir = std::env::temp_dir().join(format!("monetize-sandbox-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("sandbox.json");
let cfg = || SandboxConfig { callback_secret: "s3cret".into(), state_path: Some(path.clone()) };
// One process reserves and captures.
let handle = {
let p = SandboxProvider::new(cfg()).with_clock(|| 1_788_000_000_000);
let h = p.reserve(&req("gunnar/alice/2027", 22000)).unwrap();
assert!(matches!(p.capture(&h).unwrap(), ChargeStatus::Captured { .. }));
h
};
// A DIFFERENT process — the restart — must still know the charge.
let p = SandboxProvider::new(cfg());
assert!(matches!(p.charge_status(&handle).unwrap(), ChargeStatus::Captured { .. }), "the capture must survive");
// And the refund that failed on the live box must now work.
assert!(p.refund(&handle, Money { minor: 22000, currency: Currency::Sek }).is_ok(), "a captured charge is refundable after a restart");
// The reference index survives too, so a retried Purchase.Start is still
// idempotent across a restart rather than minting a second charge.
assert_eq!(p.reserve(&req("gunnar/alice/2027", 22000)).unwrap(), handle, "idempotence must outlive the process too");
// NO PATH means in memory, which is right for a test and wrong for a
// deployment — asserted so the default cannot quietly become persistence
// nobody asked for.
let mem = SandboxProvider::new(SandboxConfig { callback_secret: "s".into(), state_path: None });
let h = mem.reserve(&req("r", 100)).unwrap();
let mem2 = SandboxProvider::new(SandboxConfig { callback_secret: "s".into(), state_path: None });
assert!(matches!(mem2.charge_status(&h), Err(PayError::UnknownHandle(_))), "an in-memory vendor forgets, by design");
let _ = std::fs::remove_dir_all(&dir);
}
/// A signup order in the shape `Signup.Start` builds one: a name that was
/// checked, the plan's NET price, and a return URL with the token the vendor
/// substitutes.
fn an_order(username: &str) -> SignupOrder {
SignupOrder {
username: username.into(),
email: "buyer@example.test".into(),
product: "gunnar".into(),
plan: "gunnar/10gb-1m".into(),
amount: Money { minor: 10_000, currency: Currency::Sek },
market: Market::Se,
description: "gunnar/10gb-1m".into(),
success_url: "https://gunnar.rs/welcome?session={session}".into(),
cancel_url: "https://gunnar.rs/signup".into(),
}
}
/// ★ **The rehearsal checkout lands on the return page, and pays nothing yet.**
///
/// The open build has to be able to drive the whole path — pick a name, "pay",
/// come back — with no merchant agreement. What it must NOT do is create the
/// customer: a sandbox that settled on `start_signup` would rehearse a flow
/// where paying is optional.
#[test]
fn a_signup_checkout_lands_on_the_return_page_and_settles_nothing() {
let p = provider("s3cret");
let checkout = p.start_signup(&an_order("bob")).unwrap().expect("the rehearsal vendor sells signups");
assert!(checkout.url.starts_with("https://gunnar.rs/welcome?session="), "{}", checkout.url);
assert!(!checkout.session.is_empty(), "the return page has nothing to poll with");
assert!(checkout.url.ends_with(&checkout.session), "the URL and the session disagree: {}", checkout.url);
assert!(!checkout.url.contains("{session}"), "the token was left unsubstituted: {}", checkout.url);
// Nothing has been paid: no callback has arrived, so nothing is claimable.
let raw_body = serde_json::to_vec(&serde_json::json!({ "signup": checkout.session })).unwrap();
let unsigned = p.parse_callback(RawCallback { headers: &[], body: &raw_body }).unwrap_err();
assert!(matches!(unsigned, PayError::Unverified(_)), "{unsigned:?}");
}
/// **And when the signed callback arrives, it is a PAID SIGNUP carrying the
/// name that was picked before the money moved.**
#[test]
fn a_signed_signup_callback_carries_the_name_that_was_picked_first() {
let p = provider("s3cret");
let checkout = p.start_signup(&an_order("bob")).unwrap().unwrap();
let body = serde_json::to_vec(&serde_json::json!({ "signup": checkout.session })).unwrap();
let sig = SandboxProvider::signup_signature("s3cret", &checkout.session);
let out = p.parse_callback(RawCallback { headers: &[(SIGNATURE_HEADER.into(), sig)], body: &body }).unwrap_err();
let PayError::Signup(s) = out else { panic!("{out:?}") };
assert_eq!(s.username, "bob", "the vendor lost the name");
assert_eq!(s.session, checkout.session);
assert_eq!(s.product, "gunnar");
assert_eq!(s.plan, "gunnar/10gb-1m");
assert_eq!(s.amount, Money { minor: 10_000, currency: Currency::Sek });
assert_eq!(s.tax, Money { minor: 0, currency: Currency::Sek }, "no tax registration is rehearsed here");
}
/// **A signature minted for a CHARGE cannot be replayed as one for a signup**,
/// and a session this vendor never minted is a delivery about nothing we hold.
#[test]
fn a_signup_callback_refuses_a_charges_signature_and_ignores_a_session_it_never_minted() {
let p = provider("s3cret");
let checkout = p.start_signup(&an_order("bob")).unwrap().unwrap();
let body = serde_json::to_vec(&serde_json::json!({ "signup": checkout.session })).unwrap();
// The charge-shaped signature over the same id.
let wrong = SandboxProvider::signature("s3cret", &checkout.session, &checkout.session);
let out = p.parse_callback(RawCallback { headers: &[(SIGNATURE_HEADER.into(), wrong)], body: &body }).unwrap_err();
assert!(matches!(out, PayError::Unverified(_)), "{out:?}");
// Verified, and about nothing here: 200 and nothing done, never a 401.
let other = serde_json::to_vec(&serde_json::json!({ "signup": "sbxcs_nobody" })).unwrap();
let sig = SandboxProvider::signup_signature("s3cret", "sbxcs_nobody");
let out = p.parse_callback(RawCallback { headers: &[(SIGNATURE_HEADER.into(), sig)], body: &other }).unwrap_err();
assert!(matches!(out, PayError::Ignored(_)), "{out:?}");
}
/// The signup checkouts survive a restart, like every other row this vendor
/// keeps — a webhook arriving after a deploy must still find its name.
#[test]
fn signup_checkouts_outlive_the_process() {
let dir = std::env::temp_dir().join(format!("monetize-sandbox-signup-{}", crate::now_unix_ms()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("sandbox.json");
let cfg = || SandboxConfig { callback_secret: "s3cret".into(), state_path: Some(path.clone()) };
let session = SandboxProvider::new(cfg()).start_signup(&an_order("bob")).unwrap().unwrap().session;
let after = SandboxProvider::new(cfg());
let body = serde_json::to_vec(&serde_json::json!({ "signup": session })).unwrap();
let sig = SandboxProvider::signup_signature("s3cret", &session);
let out = after.parse_callback(RawCallback { headers: &[(SIGNATURE_HEADER.into(), sig)], body: &body }).unwrap_err();
assert!(matches!(out, PayError::Signup(_)), "a restart forgot which name was bought: {out:?}");
let _ = std::fs::remove_dir_all(&dir);
}
/// R5a: the sandbox vendor is the open default's money on the ONE box. A panic
/// that once held its rows, its references or its signups must not make every
/// later charge panic.
#[test]
fn a_poisoned_sandbox_vendor_still_charges() {
let p = provider("s3cret");
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _a = p.rows.lock();
let _b = p.by_reference.lock();
let _c = p.signups.lock();
panic!("a panic while the books are held");
}));
assert!(p.rows.is_poisoned() && p.by_reference.is_poisoned() && p.signups.is_poisoned());
let h = p.reserve(&req("gunnar/alice/2027", 22000)).expect("a reserve after the panic");
assert!(matches!(p.capture(&h).expect("captured"), ChargeStatus::Captured { .. }));
assert!(p.charge_status(&h).is_ok());
assert!(p.refund(&h, Money { minor: 100, currency: Currency::Sek }).is_ok());
let h2 = p.reserve(&req("gunnar/bob/2027", 100)).expect("a second reserve");
assert_eq!(p.release(&h2).expect("released"), ChargeStatus::Released);
}