crates/monetize-server/tests/order_scoping.rs
//! **An order's status, its refund and its cancellation are scoped to its
//! tenant — by monetize, over the wire** (2026-10-04, the console as a thin
//! layer).
//!
//! `Purchase.Status` and `Purchase.Abandon` took a reference and nothing else,
//! so the product's console kept an in-memory list of the references it had
//! minted and a `gunnar/<ns>/` prefix check as the ONLY gate in front of them.
//! And a customer's "cancel" on an order they had not paid for had nowhere
//! honest to go: the console called `Abandon`, which refuses exactly the
//! nothing-captured case, so the button could never succeed.
//!
//! What this file holds monetize to:
//!
//! | what | why |
//! |---|---|
//! | a tenant's ticket reads its own order and not another's | the whole point |
//! | another tenant's reference and a reference that does not exist get the SAME refusal | otherwise the refusal is the enumeration |
//! | a ticket for another tenant, or for another verb, is refused | the ticket's own rules, on these verbs |
//! | `team` does not reach `team/sub`'s order | a prefix is not an equality |
//! | `Cancel` closes an order nobody paid for, and twice is not an error | the customer's button |
//! | `Cancel` refuses an order the vendor says was PAID meanwhile, and writes the capture down | a payment always wins over a cancel |
//! | `Abandon` is scoped the same way | a refund names a tenant's money |
//! | `phase`, `cancellable`, `abandonable` come from monetize | the browser re-derived them |
use std::sync::Arc;
use ed25519_dalek::{Signer as _, SigningKey};
use monetize::embed::signing::base64_encode;
use monetize::embed::ticket::{ticket_message, ActorTicket};
use monetize::embed::{PURPOSE_CANCEL, PURPOSE_ORDER, PURPOSE_READ};
use monetize::pay::{ChargeHandle, Payment as _};
use monetize::testing::{Answer, MockCloud, MockProduct, NOW};
use monetize_proto::ledger_client::LedgerClient;
use monetize_proto::purchase_client::PurchaseClient;
use monetize_proto::query_ledger_request::By;
use monetize_proto::{self as pb, PurchasePhase, PurchaseState};
use monetize_server::registry::Registry;
use monetize_server::state::AppState;
use monetize_server::testing::{http_post, CallbackPay, TestServer};
use tonic::metadata::MetadataValue;
use tonic::transport::Channel;
use tonic::{Code, Request};
const BEARER: &str = "test-bearer-token-of-sufficient-length";
const SECRET: &str = "mockpay-shared-secret";
const GIB: u64 = 1 << 30;
/// The appliance's key. The host checks under it; the tests mint with it.
fn appliance() -> SigningKey {
SigningKey::from_bytes(&[29u8; 32])
}
/// Its public half, as `[products.gunnar] appliance_key` names it.
fn appliance_hex() -> String {
appliance().verifying_key().to_bytes().iter().map(|b| format!("{b:02x}")).collect()
}
/// A genuine ticket for `tenant`, for `purpose`. Every call spends its nonce,
/// so every call gets its own.
fn ticket(tenant: &str, purpose: &str) -> Vec<u8> {
static NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
let nonce = format!("n-{}", NONCE.fetch_add(1, std::sync::atomic::Ordering::SeqCst));
// Minted now: the host judges its life on the server's clock, the wall clock here (A32-13).
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as i64;
let mut t = ActorTicket::unsigned("gunnar", "gunnar", tenant, purpose, &nonce, now, 60_000);
t.signature = base64_encode(&appliance().sign(&ticket_message(&t)).to_bytes());
serde_json::to_vec(&t).expect("a ticket serialises")
}
struct Rig {
pay: CallbackPay,
product: Arc<MockProduct>,
_state: Arc<AppState>,
server: TestServer,
channel: Channel,
}
impl Rig {
async fn up() -> Rig {
let pay = CallbackPay::new(SECRET);
let product = Arc::new(MockProduct::gunnar());
product.can_serve(300 * GIB, 400 * GIB);
product.mints_tickets();
let cloud = MockCloud::new();
cloud.answer_all(Answer::OnHand);
let product_dyn: Arc<dyn monetize_product::Product> = product.clone();
let mut state = AppState::in_memory(Registry::new().shared_product(product_dyn).ticket_key("gunnar", &appliance_hex()).sells("gunnar", pay.clone()).cloud(cloud));
state.checkout_urls.get_mut().unwrap().insert("mockpay".into(), "https://pay.example/{handle}".into());
let state = Arc::new(state);
let server = TestServer::spawn(state.clone(), BEARER).await;
let channel = Channel::from_shared(server.endpoint()).unwrap().connect().await.unwrap();
Rig { pay, product, _state: state, server, channel }
}
fn authed<T>(&self, req: T) -> Request<T> {
let mut r = Request::new(req);
r.metadata_mut().insert("authorization", MetadataValue::try_from(format!("Bearer {BEARER}")).unwrap());
r
}
fn client(&self) -> PurchaseClient<Channel> {
PurchaseClient::new(self.channel.clone())
}
/// An order for `gib`, on the operator path. The customer has NOT paid, so
/// it comes back PENDING.
async fn start(&self, tenant: &str, gib: u64) -> pb::StartPurchaseResponse {
let started = self
.client()
.start(self.authed(pb::StartPurchaseRequest {
product: "gunnar".into(),
tenant: tenant.into(),
caps: [("pack_bytes".to_string(), gib * GIB)].into(),
vendor: "mockpay".into(),
starts_unix_ms: NOW,
market: pb::Market::Se as i32,
..Default::default()
}))
.await
.expect("the order goes through")
.into_inner();
assert_eq!(started.state(), PurchaseState::Pending, "{started:?}");
started
}
async fn status(&self, tenant: &str, reference: &str, actor_ticket: Vec<u8>) -> Result<pb::PurchaseStatusResponse, tonic::Status> {
self.client()
.status(self.authed(pb::PurchaseStatusRequest {
reference: reference.into(),
product: if tenant.is_empty() { String::new() } else { "gunnar".into() },
tenant: tenant.into(),
actor_ticket,
}))
.await
.map(|r| r.into_inner())
}
async fn cancel(&self, tenant: &str, reference: &str, actor_ticket: Vec<u8>) -> Result<pb::CancelPurchaseResponse, tonic::Status> {
self.client()
.cancel(self.authed(pb::CancelPurchaseRequest {
product: "gunnar".into(),
tenant: tenant.into(),
actor_ticket,
reference: reference.into(),
reason: "the customer changed their mind".into(),
}))
.await
.map(|r| r.into_inner())
}
async fn abandon(&self, tenant: &str, reference: &str, actor_ticket: Vec<u8>) -> Result<pb::AbandonPurchaseResponse, tonic::Status> {
self.client()
.abandon(self.authed(pb::AbandonPurchaseRequest {
reference: reference.into(),
reason: "the room never arrived".into(),
product: "gunnar".into(),
tenant: tenant.into(),
actor_ticket,
}))
.await
.map(|r| r.into_inner())
}
async fn ledger_kinds(&self, reference: &str) -> Vec<String> {
LedgerClient::new(self.channel.clone())
.query(self.authed(pb::QueryLedgerRequest { by: Some(By::Reference(reference.into())) }))
.await
.unwrap()
.into_inner()
.rows
.into_iter()
.map(|r| r.kind)
.collect()
}
/// One webhook delivery, as the vendor would POST it.
fn deliver(&self, reference: &str, handle: &str, event: &str) {
let body = CallbackPay::callback_body(reference, handle, event);
let (code, reply) = http_post(self.server.addr, "/callback/mockpay", &self.pay.signed_headers(), &body).expect("the webhook answers");
assert!((200..300).contains(&code), "{code} {reply}");
}
fn handle(id: &str) -> ChargeHandle {
ChargeHandle { vendor: "mockpay".into(), id: id.into() }
}
}
fn refused(r: Result<impl std::fmt::Debug, tonic::Status>, code: Code, what: &str) -> tonic::Status {
match r {
Ok(v) => panic!("{what}: answered {v:?}, expected {code:?}"),
Err(s) => {
assert_eq!(s.code(), code, "{what}: {s:?}");
s
}
}
}
/// ★ **A tenant's ticket reads its own order; another tenant's ticket reads
/// NOTHING of it, in the same sentence a reference that never existed gets.**
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn status_is_scoped_to_its_tenant_and_a_stranger_learns_nothing() {
let rig = Rig::up().await;
let alice = rig.start("alice", 10).await;
let own = rig.status("alice", &alice.reference, ticket("alice", PURPOSE_READ)).await.expect("alice reads her own order");
assert_eq!(own.state(), PurchaseState::Pending, "{own:?}");
assert_eq!(own.phase(), PurchasePhase::WaitingForPayment, "{own:?}");
assert!(own.cancellable, "a waiting order is cancellable: {own:?}");
assert!(!own.abandonable, "nothing was captured, so there is nothing to refund: {own:?}");
let theirs = refused(rig.status("beta", &alice.reference, ticket("beta", PURPOSE_READ)).await, Code::NotFound, "beta reads alice's order");
let nothing = refused(
rig.status("beta", "gunnar/beta/2026-09-01+deadbeef", ticket("beta", PURPOSE_READ)).await,
Code::NotFound,
"beta reads an order that never existed",
);
assert_eq!(theirs.message(), nothing.message(), "the refusal tells 'not yours' from 'does not exist'");
assert!(!theirs.message().contains("alice"), "the refusal names the owner: {theirs:?}");
// The ticket's own rules, on this verb: another tenant's ticket, and a
// ticket for another verb.
refused(rig.status("alice", &alice.reference, ticket("beta", PURPOSE_READ)).await, Code::PermissionDenied, "a ticket for beta on alice");
refused(rig.status("alice", &alice.reference, ticket("alice", PURPOSE_CANCEL)).await, Code::PermissionDenied, "a cancel ticket on a read");
// The operator path, scoped by tenant: the bearer and a tenant name.
let scoped = rig.status("alice", &alice.reference, Vec::new()).await.expect("the operator path, scoped to alice");
assert_eq!(scoped.reference, alice.reference);
refused(rig.status("beta", &alice.reference, Vec::new()).await, Code::NotFound, "the operator path scoped to beta, on alice's order");
// The unscoped operator path, byte for byte as before.
let unscoped = rig.status("", &alice.reference, Vec::new()).await.expect("the unscoped operator path");
assert_eq!(unscoped.state(), PurchaseState::Pending);
// …and a ticket with no tenant to compare it with is a client error.
refused(rig.status("", &alice.reference, ticket("alice", PURPOSE_READ)).await, Code::InvalidArgument, "a ticket with no tenant");
}
/// ★ **`team` does not reach `team/sub`'s order.** A reference prefix is not a
/// tenant equality when a namespace may itself contain a slash.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_nested_namespace_is_a_different_tenant() {
let rig = Rig::up().await;
let sub = rig.start("team/sub", 10).await;
refused(rig.status("team", &sub.reference, Vec::new()).await, Code::NotFound, "team on team/sub's order");
refused(rig.cancel("team", &sub.reference, Vec::new()).await, Code::NotFound, "team cancels team/sub's order");
rig.status("team/sub", &sub.reference, Vec::new()).await.expect("team/sub reads its own");
}
/// ★ **Cancel closes an order nobody has paid for — and a second press is not
/// an error.** Another tenant cannot cancel it, and neither can a read ticket.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn cancel_closes_an_unpaid_order_and_twice_is_not_an_error() {
let rig = Rig::up().await;
let alice = rig.start("alice", 10).await;
refused(rig.cancel("beta", &alice.reference, ticket("beta", PURPOSE_CANCEL)).await, Code::NotFound, "beta cancels alice's order");
refused(rig.cancel("alice", &alice.reference, ticket("alice", PURPOSE_READ)).await, Code::PermissionDenied, "a read ticket cancels");
refused(rig.cancel("alice", &alice.reference, ticket("alice", PURPOSE_ORDER)).await, Code::PermissionDenied, "an order ticket cancels");
assert!(!rig.ledger_kinds(&alice.reference).await.contains(&"Released".to_owned()), "a refused cancel closed the order");
let done = rig.cancel("alice", &alice.reference, ticket("alice", PURPOSE_CANCEL)).await.expect("alice cancels her unpaid order");
assert!(!done.already_closed, "{done:?}");
assert_eq!(done.phase(), PurchasePhase::Cancelled, "{done:?}");
assert!(
matches!(done.state(), PurchaseState::Released | PurchaseState::ReleasedWithOrphans),
"{done:?}"
);
assert!(done.reason.contains("the customer changed their mind"), "the reason is recorded: {done:?}");
assert_eq!(rig.pay.charge_status(&Rig::handle(&alice.handle_id)).unwrap(), monetize::pay::ChargeStatus::Released, "the vendor's hold was let go");
assert_eq!(rig.ledger_kinds(&alice.reference).await.last().map(String::as_str), Some("Released"));
let after = rig.status("alice", &alice.reference, ticket("alice", PURPOSE_READ)).await.unwrap();
assert_eq!(after.phase(), PurchasePhase::Cancelled, "{after:?}");
assert!(!after.cancellable && !after.abandonable, "{after:?}");
let again = rig.cancel("alice", &alice.reference, ticket("alice", PURPOSE_CANCEL)).await.expect("a second press is not an error");
assert!(again.already_closed, "{again:?}");
assert_eq!(
rig.ledger_kinds(&alice.reference).await.iter().filter(|k| *k == "Released").count(),
1,
"the second press wrote a second close"
);
}
/// ★ **A payment always wins over a cancel.** The customer paid at the vendor
/// and the webhook has not arrived: `Cancel` asks the vendor first, writes the
/// capture down, refuses, and the order then finishes as PAID.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn cancel_refuses_an_order_the_vendor_says_was_paid() {
let rig = Rig::up().await;
let alice = rig.start("alice", 10).await;
rig.pay.authorize(&alice.handle_id);
rig.pay.capture(&Rig::handle(&alice.handle_id)).expect("the vendor captures");
let no = refused(rig.cancel("alice", &alice.reference, ticket("alice", PURPOSE_CANCEL)).await, Code::FailedPrecondition, "cancel a paid order");
assert!(no.message().to_lowercase().contains("paid") || no.message().contains("captured"), "{no:?}");
let kinds = rig.ledger_kinds(&alice.reference).await;
assert!(kinds.contains(&"Captured".to_owned()), "the capture was not written down: {kinds:?}");
assert!(!kinds.contains(&"Released".to_owned()), "a paid order was closed as released: {kinds:?}");
// The order finishes as paid (the webhook, late).
rig.deliver(&alice.reference, &alice.handle_id, "captured");
let after = rig.status("alice", &alice.reference, ticket("alice", PURPOSE_READ)).await.unwrap();
assert_eq!(after.state(), PurchaseState::Paid, "{after:?}");
assert_eq!(after.phase(), PurchasePhase::Done, "{after:?}");
assert!(!after.cancellable && !after.abandonable, "{after:?}");
refused(rig.cancel("alice", &alice.reference, ticket("alice", PURPOSE_CANCEL)).await, Code::FailedPrecondition, "cancel a granted order");
}
/// ★ **Abandon (the refund) is scoped like Status**, and `abandonable` says
/// when it applies: money captured, the room never granted.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn abandon_is_scoped_to_its_tenant_and_refunds_only_its_own() {
let rig = Rig::up().await;
*rig.product.refuse_push.lock().unwrap() = true;
let alice = rig.start("alice", 10).await;
rig.pay.authorize(&alice.handle_id);
// The vendor's doorbell: the server captures, and the product refuses the
// fact — captured money, no room.
rig.deliver(&alice.reference, &alice.handle_id, "authorized");
let stuck = rig.status("alice", &alice.reference, ticket("alice", PURPOSE_READ)).await.unwrap();
assert_eq!(stuck.phase(), PurchasePhase::PaidAdding, "{stuck:?}");
assert!(stuck.abandonable && !stuck.cancellable, "{stuck:?}");
refused(rig.abandon("beta", &alice.reference, ticket("beta", PURPOSE_CANCEL)).await, Code::NotFound, "beta abandons alice's order");
refused(rig.abandon("alice", &alice.reference, ticket("alice", PURPOSE_READ)).await, Code::PermissionDenied, "a read ticket refunds");
assert!(rig.pay.refund_calls().is_empty(), "a refused abandon reached the vendor: {:?}", rig.pay.refund_calls());
rig.abandon("alice", &alice.reference, ticket("alice", PURPOSE_CANCEL)).await.expect("alice abandons her own stuck order");
assert_eq!(rig.pay.refund_calls().len(), 1, "{:?}", rig.pay.refund_calls());
let after = rig.status("alice", &alice.reference, Vec::new()).await.unwrap();
assert_eq!(after.phase(), PurchasePhase::Refunded, "{after:?}");
assert!(!after.abandonable && !after.cancellable, "{after:?}");
}