crates/monetize-site-buy/tests/operator_doors.rs
//! ★ **An operator's orders for a tenant are a door group of the shared router** (G-REDS 4a,
//! A28 H19's gap): Gunnar's `/api/namespaces/{name}/increase*`, moved from gunnar-ui-server's
//! `api::increase` as it was. An administrator places an order on a tenant's behalf: the tenant
//! is the one in the PATH, the order carries NO actor ticket (nobody here claims to hold the
//! tenant, and monetize treats an empty ticket as the unchanged operator path), the payer is
//! the site's (the operator's receipts address), and the site says who may do it
//! ([`BuySite::operator`]). The group is mounted only where a site names it
//! ([`Doors::operator`], the two that start an order with [`Doors::operator_orders`]);
//! [`router`](monetize_site_buy::router), Holger's mount, has none of it. Addresses are
//! `*.mock.invalid`.
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use axum::http::{HeaderMap, StatusCode};
use monetize_proto as pb;
use monetize_site_buy::{
Account, BuySite, Buyer, Door, Doors, Operator, Refusal, Relay, ShopLink, Who,
};
use tonic::{Request, Response, Status};
const CHECKOUT: &str = "https://checkout.mock.invalid/c/pay/cs_test_a1b2c3d4e5";
/// What the shop was asked, call by call.
#[derive(Clone, Debug, PartialEq, Eq)]
enum Asked {
List,
Quote {
tenant: String,
caps: BTreeMap<String, u64>,
renew: bool,
bundle: String,
ticket: usize,
},
Bundles {
tenant: String,
market: i32,
ticket: usize,
},
Start {
tenant: String,
caps: BTreeMap<String, u64>,
bundle: String,
payer: String,
market: i32,
ticket: usize,
renew: bool,
},
Status {
reference: String,
tenant: String,
ticket: usize,
},
}
#[derive(Clone, Default)]
struct Shop {
asked: Arc<Mutex<Vec<Asked>>>,
/// `Purchase.Start` answers this checkout link.
link: Arc<Mutex<String>>,
/// `Purchase.Quote` refuses with this, when set.
refuse_quote: Arc<Mutex<Option<Status>>>,
/// `Products.List` refuses.
products_down: bool,
}
impl Shop {
fn asked(&self) -> Vec<Asked> {
self.asked.lock().unwrap().clone()
}
fn ask(&self, a: Asked) {
self.asked.lock().unwrap().push(a);
}
}
fn caps_of<'a>(m: impl IntoIterator<Item = (&'a String, &'a u64)>) -> BTreeMap<String, u64> {
m.into_iter().map(|(k, v)| (k.clone(), *v)).collect()
}
#[tonic::async_trait]
impl pb::products_server::Products for Shop {
async fn list(
&self,
_: Request<pb::ListProductsRequest>,
) -> Result<Response<pb::ListProductsResponse>, Status> {
self.ask(Asked::List);
if self.products_down {
return Err(Status::unavailable("the catalogue is resting"));
}
let meter = |name: &str, enforced: bool, unit: &str| pb::MeterInfo {
name: name.into(),
meaning: format!("{name}, as monetize says it"),
enforcement: if enforced {
pb::Enforcement::Enforced as i32
} else {
pb::Enforcement::Unspecified as i32
},
unit: unit.into(),
..Default::default()
};
Ok(Response::new(pb::ListProductsResponse {
products: vec![
pb::ProductInfo {
id: "gunnar".into(),
meters: vec![
meter("pack_bytes", true, "disk_gib_month"),
meter("repos", true, ""),
meter("seats", false, "seat_month"),
],
..Default::default()
},
pb::ProductInfo {
id: "holger".into(),
meters: vec![meter("artifacts", true, "gib_month")],
..Default::default()
},
],
..Default::default()
}))
}
}
#[tonic::async_trait]
impl pb::purchase_server::Purchase for Shop {
async fn bundles(
&self,
r: Request<pb::ListBundlesRequest>,
) -> Result<Response<pb::ListBundlesResponse>, Status> {
let r = r.into_inner();
self.ask(Asked::Bundles {
tenant: r.tenant,
market: r.market,
ticket: r.actor_ticket.len(),
});
Ok(Response::new(pb::ListBundlesResponse {
bundles: vec![pb::Bundle {
id: "gunnar/10gb-1m".into(),
label: "10 GiB more".into(),
can_serve: true,
..Default::default()
}],
..Default::default()
}))
}
async fn start(
&self,
r: Request<pb::StartPurchaseRequest>,
) -> Result<Response<pb::StartPurchaseResponse>, Status> {
let r = r.into_inner();
self.ask(Asked::Start {
tenant: r.tenant.clone(),
caps: caps_of(&r.caps),
bundle: r.bundle,
payer: r.payer,
market: r.market,
ticket: r.actor_ticket.len(),
renew: r.renew,
});
Ok(Response::new(pb::StartPurchaseResponse {
reference: format!("gunnar/{}/2026-10-25", r.tenant),
state: pb::PurchaseState::Pending as i32,
redirect_url: self.link.lock().unwrap().clone(),
..Default::default()
}))
}
async fn status(
&self,
r: Request<pb::PurchaseStatusRequest>,
) -> Result<Response<pb::PurchaseStatusResponse>, Status> {
let r = r.into_inner();
self.ask(Asked::Status {
reference: r.reference.clone(),
tenant: r.tenant,
ticket: r.actor_ticket.len(),
});
Ok(Response::new(pb::PurchaseStatusResponse {
reference: r.reference,
state: pb::PurchaseState::Paid as i32,
..Default::default()
}))
}
async fn quote(
&self,
r: Request<pb::QuoteRequest>,
) -> Result<Response<pb::QuoteResponse>, Status> {
let r = r.into_inner();
self.ask(Asked::Quote {
tenant: r.tenant.clone(),
caps: caps_of(&r.caps),
renew: r.renew,
bundle: r.bundle,
ticket: r.actor_ticket.len(),
});
if let Some(s) = self.refuse_quote.lock().unwrap().clone() {
return Err(s);
}
Ok(Response::new(pb::QuoteResponse {
reference: format!("gunnar/{}/2026-10-25", r.tenant),
can_serve: true,
period: "month".into(),
current: [("pack_bytes".to_owned(), 10u64)].into_iter().collect(),
caps: r.caps.clone(),
delta: [("pack_bytes".to_owned(), 90u64)].into_iter().collect(),
cost: Some(pb::Money {
minor: 133_700,
..Default::default()
}),
price: Some(pb::Money {
minor: 1_000,
..Default::default()
}),
..Default::default()
}))
}
async fn abandon(
&self,
_: Request<pb::AbandonPurchaseRequest>,
) -> Result<Response<pb::AbandonPurchaseResponse>, Status> {
Err(Status::unimplemented("Abandon"))
}
async fn cancel(
&self,
_: Request<pb::CancelPurchaseRequest>,
) -> Result<Response<pb::CancelPurchaseResponse>, Status> {
Err(Status::unimplemented("Cancel"))
}
async fn history(
&self,
_: Request<pb::PurchaseHistoryRequest>,
) -> Result<Response<pb::PurchaseHistoryResponse>, Status> {
Err(Status::unimplemented("History"))
}
}
/// A Gunnar-shaped console: `x-role: admin` is an administrator (the operator), anything else
/// is refused as Gunnar refuses it; the operator's receipts address pays.
struct Console;
impl BuySite for Console {
fn origin(&self) -> String {
"https://gunnar.mock.invalid".into()
}
fn product_word(&self) -> String {
"Gunnar".into()
}
async fn operator(&self, headers: &HeaderMap) -> Result<Operator, Refusal> {
match headers.get("x-role").and_then(|v| v.to_str().ok()) {
Some("admin") => Ok(Operator {
who: "root".into(),
payer: "receipts@mock.invalid".into(),
}),
_ => Err(Refusal::new(
StatusCode::FORBIDDEN,
"a namespace's orders, offers and bundles are an administrator's",
)),
}
}
async fn who(&self, _: &HeaderMap) -> Result<Who, Refusal> {
Ok(Who::Machine)
}
async fn ticket(
&self,
_: &HeaderMap,
_: &Buyer,
_: &str,
_: &BTreeMap<String, u64>,
) -> Result<Vec<u8>, Refusal> {
panic!("an operator's door asks for no ticket")
}
async fn held_caps(&self, _: &HeaderMap, _: &Buyer) -> Result<BTreeMap<String, u64>, Refusal> {
panic!("an operator's door reads no held caps")
}
async fn signup_account(&self, _: &str) -> Result<Account, Refusal> {
Ok(Account::NoPasskey)
}
}
/// Holger-shaped: implements no operator.
struct NoOperator;
impl BuySite for NoOperator {
fn origin(&self) -> String {
"https://holger.mock.invalid".into()
}
fn product_word(&self) -> String {
"Holger".into()
}
async fn who(&self, _: &HeaderMap) -> Result<Who, Refusal> {
Ok(Who::Visitor)
}
async fn ticket(
&self,
_: &HeaderMap,
_: &Buyer,
_: &str,
_: &BTreeMap<String, u64>,
) -> Result<Vec<u8>, Refusal> {
Ok(Vec::new())
}
async fn held_caps(&self, _: &HeaderMap, _: &Buyer) -> Result<BTreeMap<String, u64>, Refusal> {
Ok(BTreeMap::new())
}
async fn signup_account(&self, _: &str) -> Result<Account, Refusal> {
Ok(Account::NoPasskey)
}
}
async fn shop_at(shop: Shop) -> ShopLink {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = listener.local_addr().unwrap();
tokio::spawn(
tonic::transport::Server::builder()
.add_service(pb::products_server::ProductsServer::new(shop.clone()))
.add_service(pb::purchase_server::PurchaseServer::new(shop))
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)),
);
ShopLink::connect_lazy(
&Door {
url: format!("http://{at}"),
..Door::default()
},
"site-bearer-for-tests",
"gunnar",
)
.unwrap()
}
async fn console(shop: &Shop, doors: Doors) -> axum::Router {
monetize_site_buy::router_of(
Arc::new(Relay::new(Console, shop_at(shop.clone()).await)),
doors,
)
}
const OPERATOR: Doors = Doors {
operator: true,
operator_orders: true,
..Doors::NONE
};
async fn call(
router: &axum::Router,
method: &str,
uri: &str,
role: &str,
body: &str,
) -> (StatusCode, serde_json::Value) {
use tower::ServiceExt;
let req = axum::http::Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json")
.header("x-role", role)
.header("accept-language", "da-DK, en;q=0.5")
.body(axum::body::Body::from(body.to_owned()))
.unwrap();
let res = router.clone().oneshot(req).await.unwrap();
let status = res.status();
let bytes = axum::body::to_bytes(res.into_body(), 1 << 20)
.await
.unwrap();
(
status,
serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null),
)
}
/// Every door of the group, as `(method, path, body)`; the last two start an order.
const DOORS: &[(&str, &str, &str)] = &[
("GET", "/api/namespaces/acme/increase", ""),
(
"GET",
"/api/namespaces/acme/increase/offer?meter=pack_bytes&cap=100",
"",
),
(
"GET",
"/api/namespaces/acme/increase/status?reference=gunnar/acme/2026-10-25",
"",
),
("GET", "/api/namespaces/acme/increase/bundles", ""),
(
"POST",
"/api/namespaces/acme/increase",
"{\"meter\":\"pack_bytes\",\"cap\":100}",
),
(
"POST",
"/api/namespaces/acme/increase/bundle",
"{\"bundle\":\"gunnar/10gb-1m\"}",
),
];
/// ★ The group is there only where a site names it, the two that start an order only with
/// `operator_orders`, and `router` (Holger's mount) has none of it.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_operator_doors_are_mounted_only_where_a_site_names_them() {
let shop = Shop::default();
let none = console(&shop, Doors::ALL).await;
for (m, uri, body) in DOORS {
assert_eq!(
call(&none, m, uri, "admin", body).await,
(StatusCode::NOT_FOUND, serde_json::Value::Null),
"{m} {uri}: not in Doors::ALL"
);
}
let holger = monetize_site_buy::router(Arc::new(Relay::new(
NoOperator,
shop_at(shop.clone()).await,
)));
for (m, uri, body) in DOORS {
assert_eq!(
call(&holger, m, uri, "admin", body).await.0,
StatusCode::NOT_FOUND,
"{m} {uri}: not in `router`"
);
}
let reads = console(
&shop,
Doors {
operator: true,
..Doors::NONE
},
)
.await;
for (m, uri, body) in &DOORS[..4] {
assert_eq!(
call(&reads, m, uri, "admin", body).await.0,
StatusCode::OK,
"{m} {uri} is mounted"
);
}
assert_eq!(
call(&reads, "POST", DOORS[4].1, "admin", DOORS[4].2)
.await
.0,
StatusCode::METHOD_NOT_ALLOWED,
"the typed order starts one: not mounted"
);
assert_eq!(
call(&reads, "POST", DOORS[5].1, "admin", DOORS[5].2)
.await
.0,
StatusCode::NOT_FOUND,
"the bundle order starts one: not mounted"
);
assert!(
!shop
.asked()
.iter()
.any(|a| matches!(a, Asked::Start { .. })),
"nothing was started: {:?}",
shop.asked()
);
}
/// ★ Whom the site does not let order is refused before the shop is dialled, on every door
/// that names a tenant; a site with no operator (the default) refuses everybody.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn who_may_not_order_is_refused_before_the_shop_is_asked() {
let shop = Shop::default();
let router = console(&shop, OPERATOR).await;
for (m, uri, body) in &DOORS[1..] {
let (st, answer) = call(&router, m, uri, "viewer", body).await;
assert_eq!(st, StatusCode::FORBIDDEN, "{m} {uri}: {answer}");
assert_eq!(
(answer["code"].as_str(), answer["message"].as_str()),
(
Some("permissiondenied"),
Some("a namespace's orders, offers and bundles are an administrator's")
),
"{m} {uri}"
);
}
let holger = monetize_site_buy::router_of(
Arc::new(Relay::new(NoOperator, shop_at(shop.clone()).await)),
OPERATOR,
);
assert_eq!(
call(&holger, "POST", DOORS[5].1, "admin", DOORS[5].2)
.await
.0,
StatusCode::FORBIDDEN,
"no operator by default"
);
assert!(
shop.asked().is_empty(),
"the shop was asked: {:?}",
shop.asked()
);
}
/// ★ An operator's typed order: the path's tenant, the caps it names as the TARGET, the
/// operator's payer, the market the browser speaks, no ticket and no bundle; the vendor's own
/// link back, byte for byte.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn an_operators_typed_order_names_the_tenant_the_caps_and_the_payer_and_no_ticket() {
let shop = Shop::default();
*shop.link.lock().unwrap() = CHECKOUT.into();
let router = console(&shop, OPERATOR).await;
let (st, attempt) = call(
&router,
"POST",
"/api/namespaces/acme/increase",
"admin",
"{\"meter\":\"pack_bytes\",\"cap\":100}",
)
.await;
assert_eq!(st, StatusCode::OK, "{attempt}");
assert_eq!(
attempt,
serde_json::json!({"reference": "gunnar/acme/2026-10-25", "state": "pending", "url": CHECKOUT, "reason": ""})
);
assert_eq!(
shop.asked(),
[Asked::Start {
tenant: "acme".into(),
caps: [("pack_bytes".to_owned(), 100)].into(),
bundle: String::new(),
payer: "receipts@mock.invalid".into(),
market: pb::Market::Dk as i32,
ticket: 0,
renew: false
}]
);
}
/// ★ An order from the window: one opaque id, no caps; a market the page names wins over the
/// browser's, and an unknown one is refused by name before the shop is asked.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn an_operators_bundle_order_names_the_bundle_and_no_caps() {
let shop = Shop::default();
let router = console(&shop, OPERATOR).await;
let (st, answer) = call(
&router,
"POST",
"/api/namespaces/acme/increase/bundle",
"admin",
"{\"bundle\":\"gunnar/10gb-1m\",\"market\":\"xx\"}",
)
.await;
assert_eq!(
(st, answer["code"].as_str()),
(StatusCode::BAD_REQUEST, Some("invalidargument")),
"{answer}"
);
assert!(shop.asked().is_empty());
let (st, attempt) = call(
&router,
"POST",
"/api/namespaces/acme/increase/bundle",
"admin",
"{\"bundle\":\"gunnar/10gb-1m\",\"market\":\"SE\"}",
)
.await;
assert_eq!(
(st, attempt["state"].as_str(), attempt["url"].as_str()),
(StatusCode::OK, Some("pending"), Some("")),
"{attempt}"
);
assert_eq!(
shop.asked(),
[Asked::Start {
tenant: "acme".into(),
caps: BTreeMap::new(),
bundle: "gunnar/10gb-1m".into(),
payer: "receipts@mock.invalid".into(),
market: pb::Market::Se as i32,
ticket: 0,
renew: false
}]
);
}
/// The offer: a quote for the typed target, never a renewal, no ticket, and neither the
/// provider's cost nor the price reaches the browser.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_offer_quotes_the_target_and_carries_no_cost() {
let shop = Shop::default();
let router = console(&shop, OPERATOR).await;
let (st, offer) = call(
&router,
"GET",
"/api/namespaces/acme/increase/offer?meter=pack_bytes&cap=100",
"admin",
"",
)
.await;
assert_eq!(st, StatusCode::OK, "{offer}");
assert_eq!(
offer,
serde_json::json!({"reference": "gunnar/acme/2026-10-25", "can_serve": true, "reason": "", "words": "", "period": "month", "current": {"pack_bytes": 10}, "caps": {"pack_bytes": 100}, "delta": {"pack_bytes": 90}})
);
assert!(
!offer.to_string().contains("133700") && !offer.to_string().contains("1000"),
"no amount: {offer}"
);
assert_eq!(
shop.asked(),
[Asked::Quote {
tenant: "acme".into(),
caps: [("pack_bytes".to_owned(), 100)].into(),
renew: false,
bundle: String::new(),
ticket: 0
}]
);
}
/// A refusal is monetize's own sentence, verbatim (the operator reads the shop's words), in the
/// console's `{code, message}`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_refusal_is_the_shops_sentence_in_the_consoles_shape() {
let shop = Shop::default();
*shop.refuse_quote.lock().unwrap() =
Some(Status::failed_precondition("acme holds no plan to raise"));
let router = console(&shop, OPERATOR).await;
let (st, answer) = call(
&router,
"GET",
"/api/namespaces/acme/increase/offer?meter=pack_bytes&cap=100",
"admin",
"",
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST, "{answer}");
assert_eq!(
(
answer["code"].as_str(),
answer["message"].as_str(),
answer["error"].as_str()
),
(
Some("failedprecondition"),
Some("acme holds no plan to raise"),
Some("acme holds no plan to raise")
)
);
}
/// The window and the follow: the path's tenant, no ticket; the window names its market.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_window_and_the_follow_are_scoped_to_the_paths_tenant() {
let shop = Shop::default();
let router = console(&shop, OPERATOR).await;
let (st, window) = call(
&router,
"GET",
"/api/namespaces/acme/increase/bundles",
"admin",
"",
)
.await;
assert_eq!(
(
st,
window["market"].as_str(),
window["offers"][0]["id"].as_str()
),
(StatusCode::OK, Some("DK"), Some("gunnar/10gb-1m")),
"{window}"
);
let (st, follow) = call(
&router,
"GET",
"/api/namespaces/acme/increase/status?reference=gunnar/acme/2026-10-25",
"admin",
"",
)
.await;
assert_eq!(
follow,
serde_json::json!({"reference": "gunnar/acme/2026-10-25", "state": "paid", "url": "", "reason": ""}),
"{st}"
);
assert_eq!(
shop.asked(),
[
Asked::Bundles {
tenant: "acme".into(),
market: pb::Market::Dk as i32,
ticket: 0
},
Asked::Status {
reference: "gunnar/acme/2026-10-25".into(),
tenant: "acme".into(),
ticket: 0
}
]
);
}
/// A checkout link that is not https never reaches the browser.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_checkout_link_that_is_not_https_is_refused() {
let shop = Shop::default();
*shop.link.lock().unwrap() = "http://checkout.mock.invalid/pay".into();
let router = console(&shop, OPERATOR).await;
let (st, answer) = call(
&router,
"POST",
"/api/namespaces/acme/increase/bundle",
"admin",
"{\"bundle\":\"gunnar/10gb-1m\"}",
)
.await;
assert_eq!(
(st, answer["code"].as_str()),
(StatusCode::BAD_GATEWAY, Some("badpayurl")),
"{answer}"
);
assert!(!answer.to_string().contains("http://checkout"), "{answer}");
}
/// What is for sale: this product's enforced, priced meters; a shop that cannot be asked is a
/// sentence, not a failure. Asked of whoever reaches the door (the site's floors are in front).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn what_is_for_sale_is_this_products_enforced_priced_meters() {
let shop = Shop::default();
let router = console(&shop, OPERATOR).await;
let (st, sale) = call(
&router,
"GET",
"/api/namespaces/acme/increase",
"viewer",
"",
)
.await;
assert_eq!(
(st, sale),
(
StatusCode::OK,
serde_json::json!({"meters": [{"meter": "pack_bytes", "meaning": "pack_bytes, as monetize says it"}]})
)
);
let down = Shop {
products_down: true,
..Shop::default()
};
let router = console(&down, OPERATOR).await;
let (st, sale) = call(&router, "GET", "/api/namespaces/acme/increase", "admin", "").await;
assert_eq!(
(st, sale["meters"].as_array().map(Vec::len)),
(StatusCode::OK, Some(0)),
"{sale}"
);
assert!(
sale["unavailable"]
.as_str()
.is_some_and(|s| s.contains("could not reach the billing service")
&& s.contains("the catalogue is resting")),
"{sale}"
);
}