crates/monetize-server/tests/tenant_history.rs
//! **A customer reading their OWN purchase history, over the wire.**
//!
//! `Purchase.History` is the account pane's list: one line per ATTEMPT, folded
//! by the same `monetize::ledger::Sale::fold` that `Ledger.Sales` uses, gated
//! by the same actor ticket `Quote` and `Start` are gated by.
//!
//! What this file has to prove is not that the fold works — `monetize`'s own
//! `sale_tests` do that against rows the real transaction wrote. It is the four
//! things only the SERVICE can get wrong, and each of them is somebody's data:
//!
//! | what | why it is here |
//! |---|---|
//! | a tenant sees only their own rows | the whole point of the call |
//! | a nested namespace is a DIFFERENT tenant | `team/` is a prefix of `team/sub/`, and a prefix is not an equality |
//! | a genuine ticket for A reads nothing of B's | the attack `monetize_embed::ticket` exists to refuse, on the read path |
//! | the refusal says nothing about whether B EXISTS | otherwise the refusal is the enumeration the gate was built to prevent |
//!
//! …plus the two shape rules: **no `cost` on the wire** (asserted on the
//! encoded bytes, not the struct), and **outcomes, not steps** (a six-step sale
//! is ONE row).
use std::sync::atomic::{AtomicU64, Ordering};
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_READ, PURPOSE_RENEW};
use monetize::pay::{Currency, Money};
use monetize::testing::{purchase, Answer, MockCloud, MockPay, NOW};
use monetize::transaction::{Outcome, Purchase};
use monetize_proto::purchase_client::PurchaseClient;
use monetize_proto::{self as pb};
use monetize_server::registry::Registry;
use monetize_server::services::purchase::{clamp_limit, HISTORY_CEILING};
use monetize_server::state::AppState;
use monetize_server::testing::estate::FakeEstate;
use monetize_server::testing::products::TestProduct;
use monetize_server::testing::TestServer;
use prost::Message as _;
use tonic::metadata::MetadataValue;
use tonic::transport::Channel;
use tonic::{Code, Request};
const BEARER: &str = "test-bearer-token-of-sufficient-length";
/// **The provider's cost, in a number nothing else in this test could produce.**
/// Five bytes of varint, so finding it in an encoded message is finding THIS
/// field and never a coincidence in a cap or a timestamp.
const DISTINCTIVE_COST: u64 = 1_337_424_242;
/// The appliance's key. The host checks under it; the tests mint with it.
fn appliance() -> SigningKey {
SigningKey::from_bytes(&[11u8; 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()
}
fn mint(key: &SigningKey, ticket: ActorTicket) -> Vec<u8> {
let mut ticket = ticket;
ticket.signature = base64_encode(&key.sign(&ticket_message(&ticket)).to_bytes());
serde_json::to_vec(&ticket).expect("a ticket serialises")
}
/// A genuine, live READ ticket for `tenant`.
///
/// `PURPOSE_READ`, which names no caps because a read buys nothing. It was
/// `PURPOSE_RENEW` for one commit — strictly narrower than what a read needs,
/// so nothing could be done with it that could not have been done anyway, and
/// still wrong: a ticket is single-use, so listing a history burned the one the
/// customer would have renewed with.
fn ticket(tenant: &str, nonce: &str, at: u64) -> Vec<u8> {
mint(&appliance(), ActorTicket::unsigned("gunnar", "gunnar", tenant, PURPOSE_READ, nonce, at as i64, 60_000))
}
/// A genuine RENEWAL ticket — for the one test that proves it cannot read.
fn renewal_ticket(tenant: &str, nonce: &str, at: u64) -> Vec<u8> {
mint(&appliance(), ActorTicket::unsigned("gunnar", "gunnar", tenant, PURPOSE_RENEW, nonce, at as i64, 60_000))
}
struct Rig {
state: Arc<AppState>,
server: TestServer,
channel: Channel,
/// Movable, because a history is ORDERED and every attempt written at the
/// same instant would make "newest first" a coin toss.
clock: Arc<AtomicU64>,
}
impl Rig {
async fn up() -> Rig {
let cloud = MockCloud::new();
cloud.answer_all(Answer::Provisioned);
// A32-13: the HOST checks the ticket, on the server's clock, so a test mints each
// ticket at the rig's clock ([`Rig::now`]) however many minutes of ledger it wrote.
let mut state = AppState::in_memory(Registry::new().pulled_product(TestProduct::gunnar(), "gunnar.rs").ticket_key("gunnar", &appliance_hex()).sells("gunnar", MockPay::new()).cloud(cloud));
FakeEstate::declare(&state, "gunnar", "gunnar.rs").buys_iron(&["pack_bytes"]);
*state.terms.write().unwrap() = monetize_server::state::TermsStore::fixture();
let clock = Arc::new(AtomicU64::new(NOW));
let ticks = clock.clone();
state.clock = Box::new(move || ticks.load(Ordering::SeqCst));
let state = Arc::new(state);
let server = TestServer::spawn(state.clone(), BEARER).await;
let channel = Channel::from_shared(server.endpoint()).unwrap().connect().await.expect("connect");
Rig { state, server, channel, clock }
}
/// The rig's clock now: what the host judges a ticket's life on.
fn now(&self) -> u64 {
self.clock.load(Ordering::SeqCst)
}
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
}
/// **Sell `gib` to `tenant` through the real transaction**, a minute later
/// than whatever came before, and answer the reference.
///
/// The rows the fold reads are therefore the rows the engine writes — a
/// hand-built sequence could drift from the grammar and prove nothing.
fn sell(&self, tenant: &str, gib: u64) -> String {
self.clock.fetch_add(60_000, Ordering::SeqCst);
let mut p = purchase(tenant);
p.caps = [("pack_bytes".to_string(), gib << 30)].into();
p.delta = [("pack_bytes".to_string(), gib << 30)].into();
// Distinct from `price` and from every other number on the row, so the
// byte-level assertion about `cost` is about `cost`.
p.cost = Money { minor: DISTINCTIVE_COST, currency: Currency::Eur };
self.run(&p)
}
fn run(&self, p: &Purchase) -> String {
let reference = p.reference();
let out = self.state.transaction(self.state.registry.products().next().unwrap(), monetize_pay::Vendor::of(&MockPay::new())).run(p).expect("the fixture sale must go through");
assert!(matches!(out, Outcome::Paid { .. }), "{out:?}");
reference
}
async fn history(&self, tenant: &str, actor_ticket: &[u8], limit: u32) -> Result<pb::PurchaseHistoryResponse, tonic::Status> {
PurchaseClient::new(self.channel.clone())
.history(self.authed(pb::PurchaseHistoryRequest { product: "gunnar".into(), tenant: tenant.into(), actor_ticket: actor_ticket.to_vec(), limit }))
.await
.map(|r| r.into_inner())
}
fn steps(&self) -> u64 {
self.state.ledger.step_count().expect("the ledger counts its rows")
}
}
/// ★ **A TENANT SEES THEIR OWN PURCHASES AND NOBODY ELSE'S.** The verb this
/// call exists for, and the one assertion every other one in this file is a
/// harder version of.
#[tokio::test]
async fn a_tenant_sees_only_their_own_attempts() {
let rig = Rig::up().await;
let first = rig.sell("alice", 10);
let second = rig.sell("alice", 100);
let betas = rig.sell("beta", 50);
let out = rig.history("alice", &ticket("alice", "h-1", rig.now()), 0).await.expect("alice's own ticket reads alice's own history");
let refs: Vec<&str> = out.attempts.iter().map(|a| a.reference.as_str()).collect();
assert_eq!(refs, vec![second.as_str(), first.as_str()], "alice's two attempts, newest first: {refs:?}");
assert!(!refs.contains(&betas.as_str()), "beta's purchase reached alice: {refs:?}");
assert!(out.attempts.iter().all(|a| a.outcome == "granted"), "both sold: {:?}", out.attempts);
rig.server.stop().await;
}
/// ★ **A NAMESPACE IS NOT A PREFIX.**
///
/// A gunnar namespace may itself contain a slash (`team/sub`), so the ledger
/// reference `gunnar/team/sub/<date>+<caps>` begins with the string
/// `gunnar/team/`. A history built from the prefix scan alone would hand
/// `team` every purchase `team/sub` ever made — a genuine ticket, a genuine
/// tenant, and somebody else's money — which is why the fold is filtered on the
/// tenant EXACTLY afterwards.
#[tokio::test]
async fn a_nested_namespace_is_a_different_tenant() {
let rig = Rig::up().await;
let parent = rig.sell("team", 10);
let child = rig.sell("team/sub", 20);
let out = rig.history("team", &ticket("team", "h-2", rig.now()), 0).await.expect("team reads team");
let refs: Vec<&str> = out.attempts.iter().map(|a| a.reference.as_str()).collect();
assert_eq!(refs, vec![parent.as_str()], "team must see exactly its own attempt: {refs:?}");
assert!(!refs.contains(&child.as_str()), "the sub-namespace's purchase leaked to its parent: {refs:?}");
// …and the child reads its own, so this is a boundary and not an empty
// answer that would pass for the wrong reason.
let out = rig.history("team/sub", &ticket("team/sub", "h-3", rig.now()), 0).await.expect("the sub-namespace reads its own");
assert_eq!(out.attempts.iter().map(|a| a.reference.as_str()).collect::<Vec<_>>(), vec![child.as_str()]);
rig.server.stop().await;
}
/// ★ **THE ATTACK, ON THE READ PATH — AND THE SILENCE THAT GOES WITH IT.**
///
/// A perfectly genuine ticket, signed by the real appliance for the bearer's
/// OWN tenant, presented on somebody else's history. Nothing about the
/// signature is wrong. Two things must happen and the second is the one that is
/// easy to get wrong:
///
/// 1. nothing of beta's comes back, and
/// 2. **the refusal is the same whether beta exists or not.** A refusal that
/// read differently for a real tenant than for an invented one would answer,
/// for any name a stranger cares to type, whether that customer exists —
/// which is the enumeration this gate was built to prevent. So the two are
/// asserted EQUAL, byte for byte, and neither names a tenant at all.
#[tokio::test]
async fn a_genuine_ticket_for_one_tenant_reads_nothing_of_anothers() {
let rig = Rig::up().await;
rig.sell("alice", 10);
// beta is real, and has bought something, so the refusal below is about WHO
// is asking and not about a tenant with no history.
rig.sell("beta", 50);
let before = rig.steps();
let refused = rig.history("beta", &ticket("alice", "h-4", rig.now()), 0).await.expect_err("alice's ticket must not read beta's history");
assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
assert!(!refused.message().contains("alice"), "the refusal names the ticket's own tenant: {}", refused.message());
assert!(!refused.message().contains("beta"), "the refusal names the tenant that was asked about: {}", refused.message());
// The same ticket, on a tenant that has never existed.
let invented = rig.history("no-such-customer", &ticket("alice", "h-5", rig.now()), 0).await.expect_err("nor an invented one");
assert_eq!(invented.code(), refused.code(), "a real tenant and an invented one refuse with different codes");
assert_eq!(
invented.message(),
refused.message(),
"the refusal tells a stranger whether the tenant exists: {:?} for a real one, {:?} for an invented one",
refused.message(),
invented.message()
);
assert_eq!(rig.steps(), before, "a refused read wrote a row");
rig.server.stop().await;
}
/// ★ **THE PROVIDER'S COST DOES NOT REACH THE CUSTOMER — ON THE BYTES.**
///
/// `cost` is what the provider pays for the iron; beside `price` the pair is a
/// margin, and a margin is not the customer's business. gunnar-ui asserts the
/// same thing one hop further out (`the_offer_carries_no_cost`) and states why
/// it is done on the ENCODED BYTES and not on the struct: the first version of
/// that renderer passed the whole message through, and only the bytes caught
/// it.
///
/// So: encode the answer, look for the number, and — the half that makes the
/// search mean something — look for `price`'s number too and REQUIRE it, so a
/// test that found nothing because it was searching wrong would fail here
/// first.
#[tokio::test]
async fn no_cost_reaches_the_customer() {
let rig = Rig::up().await;
rig.sell("alice", 10);
let out = rig.history("alice", &ticket("alice", "h-6", rig.now()), 0).await.expect("alice reads her own history");
let sold = out.attempts.first().expect("one attempt").clone();
let price = sold.price.as_ref().expect("the customer's own price is on the row").minor;
assert!(price > 0, "the fixture list is not free, or this test proves nothing");
let bytes = out.encode_to_vec();
assert!(
!contains(&bytes, &varint(DISTINCTIVE_COST)),
"the provider's cost reached the customer: {DISTINCTIVE_COST} is in the {} encoded bytes of {out:?}",
bytes.len()
);
assert!(contains(&bytes, &varint(price)), "the customer's own price is NOT in the encoded bytes, so this search proves nothing about cost");
rig.server.stop().await;
}
/// **The limit is the server's to decide.** 0 means the ceiling, a number below
/// it is honoured, and a number above it — including the `u32::MAX` a client
/// sends to mean "everything" — is the ceiling. An unbounded history is a disk
/// read whoever holds a ticket gets to size.
#[tokio::test]
async fn the_limit_is_clamped() {
let rig = Rig::up().await;
let _oldest = rig.sell("alice", 10);
let _middle = rig.sell("alice", 20);
let newest = rig.sell("alice", 30);
let one = rig.history("alice", &ticket("alice", "h-7", rig.now()), 1).await.expect("one row");
assert_eq!(one.attempts.len(), 1, "a limit of 1 answered {} attempts", one.attempts.len());
assert_eq!(one.attempts[0].reference, newest, "the one row kept must be the NEWEST, not whichever came first off the scan");
let all = rig.history("alice", &ticket("alice", "h-8", rig.now()), u32::MAX).await.expect("everything, clamped");
assert!(all.attempts.len() <= HISTORY_CEILING as usize, "u32::MAX was honoured: {} attempts", all.attempts.len());
assert_eq!(all.attempts.len(), 3, "and the three that exist all fit under the ceiling");
// The arithmetic itself, which three fixture sales cannot exercise at the
// boundary: a ceiling proven only by a ledger small enough to fit under it
// is a ceiling nobody has tested.
assert_eq!(clamp_limit(0), HISTORY_CEILING as usize, "0 means the ceiling");
assert_eq!(clamp_limit(1), 1, "a small limit is the caller's");
assert_eq!(clamp_limit(HISTORY_CEILING), HISTORY_CEILING as usize);
assert_eq!(clamp_limit(HISTORY_CEILING + 1), HISTORY_CEILING as usize, "one over the ceiling is the ceiling");
assert_eq!(clamp_limit(u32::MAX), HISTORY_CEILING as usize, "and so is every number above it");
rig.server.stop().await;
}
/// ★ **OUTCOMES, NOT STEPS.** `UI.md` rule 6, and the reason `Ledger.Sales`
/// exists beside `Ledger.Query`: a sale is six ledger rows, and a history that
/// answered rows would fill a page with one purchase.
///
/// One sale, several rows, exactly ONE line — and the line says how many rows
/// are behind it, so the count is a fact the caller can show rather than
/// something this test asserts about itself.
#[tokio::test]
async fn rows_are_folded_outcomes_and_not_ledger_steps() {
let rig = Rig::up().await;
let reference = rig.sell("alice", 10);
let rows = rig.state.ledger.rows(&reference).expect("the sale's own rows");
assert!(rows.len() > 1, "the fixture sale writes one row, so there is nothing to fold: {rows:?}");
let out = rig.history("alice", &ticket("alice", "h-9", rig.now()), 0).await.expect("alice reads her own history");
assert_eq!(out.attempts.len(), 1, "a {}-row sale became {} rows on the wire", rows.len(), out.attempts.len());
let only = &out.attempts[0];
assert_eq!(only.reference, reference);
assert_eq!(only.steps as usize, rows.len(), "the badge must say how many steps are behind the line");
assert_eq!(only.outcome, "granted", "the fold's own word, not a re-mapped purchase state");
assert_eq!(only.delta.get("pack_bytes").copied(), Some(10u64 << 30), "what it was FOR: the delta that was sold");
assert!(only.started_unix_ms > 0 && only.at_unix_ms >= only.started_unix_ms, "{only:?}");
rig.server.stop().await;
}
/// **No ticket is the OPERATOR path, unchanged** — the same rule `Quote` and
/// `Start` live by. Absence is not a refusal, and the product is not asked at
/// all, so a deployment with no appliance key is not broken by this gate
/// existing.
#[tokio::test]
async fn history_with_no_ticket_is_the_operator_path() {
let rig = Rig::up().await;
let alices = rig.sell("alice", 10);
rig.sell("beta", 50);
let out = rig.history("alice", b"", 0).await.expect("an operator reads any tenant's history");
assert_eq!(out.attempts.iter().map(|a| a.reference.as_str()).collect::<Vec<_>>(), vec![alices.as_str()]);
assert_eq!(rig.state.registry.tickets_checked("gunnar"), 0, "an absent ticket is not a question: nothing must be checked at all");
// …and an operator asking for a tenant that bought nothing gets an empty
// list rather than an error: nothing is not a failure.
let none = rig.history("nobody", b"", 0).await.expect("a tenant with no purchases has an empty history");
assert!(none.attempts.is_empty(), "{none:?}");
rig.server.stop().await;
}
/// A ticket that does not verify reads nothing, whatever is in the ledger — the
/// regression guard on the order of the gate and the read.
#[tokio::test]
async fn a_ticket_that_does_not_verify_reads_nothing() {
let rig = Rig::up().await;
rig.sell("alice", 10);
for (what, bytes) in [
("rubbish", b"not a ticket at all".to_vec()),
("another appliance's key", mint(&SigningKey::from_bytes(&[99u8; 32]), ActorTicket::unsigned("gunnar", "gunnar", "alice", PURPOSE_READ, "h-10", NOW as i64, 60_000))),
// ★ A GENUINE ticket from the right appliance for the right tenant,
// minted for the wrong VERB. Before `PURPOSE_READ` this was the ticket
// a history read was gated by, and it worked; now it is refused, which
// is the whole point of giving a read its own word.
("a renewal ticket, which is not a read", renewal_ticket("alice", "h-11", rig.now())),
] {
let refused = rig.history("alice", &bytes, 0).await.expect_err("{what} must not read a history");
assert_eq!(refused.code(), Code::PermissionDenied, "{what}: {refused:?}");
}
rig.server.stop().await;
}
/// The protobuf varint encoding of `n` — what a `uint64` field looks like on the
/// wire, so a test can look for a NUMBER in encoded bytes rather than for a
/// field it has been told is absent.
fn varint(n: u64) -> Vec<u8> {
let mut out = Vec::new();
let mut n = n;
while n >= 0x80 {
out.push((n as u8) | 0x80);
n >>= 7;
}
out.push(n as u8);
out
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
/// ★ **The amount a customer reads comes from monetize, and it is the SAME
/// string a quote would carry.**
///
/// `gunnar-ui` may contain no price, no currency literal and no money
/// arithmetic, so it cannot render `Money { minor, currency }` at all — it
/// reached that conclusion twice, once for a quote and once for this history,
/// and both times the honest answer was that monetize must send words.
///
/// The assertion that matters is the LAST one: the history row and the quote
/// must be formatted by one writer, so a customer cannot be quoted `220.00 SEK`
/// and later shown `220,00 kr` for the same money. Comparing the two strings is
/// what makes "agree by construction" a thing a test can fail on.
#[tokio::test]
async fn the_price_a_customer_reads_is_monetizes_own_words() {
let rig = Rig::up().await;
rig.sell("alice", 10);
let answered = rig
.history("alice", &ticket("alice", "h-words-1", rig.now()), 0)
.await
.expect("alice reads her own history");
let row = answered.attempts.first().expect("one sale");
assert!(!row.price_words.is_empty(), "a customer was shown no amount at all");
// Exact, integer, and carrying its currency — never a float artefact and
// never a bare number a customer cannot act on.
assert!(
row.price_words.contains('.') && (row.price_words.ends_with("SEK") || row.price_words.ends_with("EUR")),
"the rendered price is not an amount with a currency: {:?}",
row.price_words
);
// **One writer.** The same `Money` rendered for a quote must read
// identically; two formatters staying in step is a thing that holds until
// somebody edits one of them.
let price = row.price.expect("the row carries the machine's number too");
let same = monetize_pay::Money {
minor: price.minor,
currency: if price.currency == monetize_proto::Currency::Sek as i32 {
monetize_pay::Currency::Sek
} else {
monetize_pay::Currency::Eur
},
};
assert_eq!(
row.price_words,
same.in_words(),
"the history row formatted an amount some other way than `Money::in_words`"
);
}