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/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:?}");
}