crates/monetize-site-buy/tests/p4b_doors.rs
//! ★ **A site mounts the doors it sells, and a site may hold the grow doors to checked tickets**
//! (A28 H19, lane P4b: Gunnar's console onto [`monetize_site_buy::router_of`]).
//!
//! Gunnar registers its doors in groups: the sign-up and private-instance doors only where it
//! sells, the account's reads wherever it has a monetize, and the doors that START an order
//! only where its buyers buy (never on a sold Gunnar). So the router mounts exactly the groups a
//! site names ([`Doors`]); [`router`](monetize_site_buy::router) is every group, as before.
//!
//! And Gunnar keeps its window, its grow, its status poll and its cancel behind the same
//! question as Prolong (`Products.List` `tenant_renewal`): on a shop that does not verify tenant
//! tickets, a proto3 shop drops the ticket and the order would run with no tenant check. A site
//! Every site is held to that since A32-9 (no knob: `checked_tickets_on_the_shop.rs`).
//! 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, Refusal, Relay, ShopLink, Who};
use tonic::{Request, Response, Status};
#[derive(Clone, Default)]
struct Shop {
renewal: bool,
asked: Arc<Mutex<Vec<&'static str>>>,
}
impl Shop {
fn asked(&self) -> Vec<&'static str> {
self.asked.lock().unwrap().clone()
}
fn ask(&self, what: &'static str) {
self.asked.lock().unwrap().push(what);
}
}
#[tonic::async_trait]
impl pb::products_server::Products for Shop {
async fn list(
&self,
_: Request<pb::ListProductsRequest>,
) -> Result<Response<pb::ListProductsResponse>, Status> {
Ok(Response::new(pb::ListProductsResponse {
products: vec![pb::ProductInfo {
id: "gunnar".into(),
tenant_renewal: self.renewal,
..Default::default()
}],
..Default::default()
}))
}
}
#[tonic::async_trait]
impl pb::purchase_server::Purchase for Shop {
async fn bundles(
&self,
_: Request<pb::ListBundlesRequest>,
) -> Result<Response<pb::ListBundlesResponse>, Status> {
self.ask("Purchase.Bundles");
Ok(Response::new(pb::ListBundlesResponse::default()))
}
async fn start(
&self,
_: Request<pb::StartPurchaseRequest>,
) -> Result<Response<pb::StartPurchaseResponse>, Status> {
self.ask("Purchase.Start");
Err(Status::failed_precondition("nothing to start"))
}
async fn status(
&self,
_: Request<pb::PurchaseStatusRequest>,
) -> Result<Response<pb::PurchaseStatusResponse>, Status> {
self.ask("Purchase.Status");
Ok(Response::new(pb::PurchaseStatusResponse::default()))
}
async fn quote(
&self,
_: Request<pb::QuoteRequest>,
) -> Result<Response<pb::QuoteResponse>, Status> {
self.ask("Purchase.Quote");
Ok(Response::new(pb::QuoteResponse::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> {
self.ask("Purchase.Cancel");
Ok(Response::new(pb::CancelPurchaseResponse::default()))
}
async fn history(
&self,
_: Request<pb::PurchaseHistoryRequest>,
) -> Result<Response<pb::PurchaseHistoryResponse>, Status> {
self.ask("Purchase.History");
Ok(Response::new(pb::PurchaseHistoryResponse::default()))
}
}
/// A Gunnar-shaped site: a person signed in.
struct Site;
impl BuySite for Site {
fn origin(&self) -> String {
"https://gunnar.mock.invalid".into()
}
fn product_word(&self) -> String {
"Gunnar".into()
}
async fn who(&self, _: &HeaderMap) -> Result<Who, Refusal> {
Ok(Who::Person(Buyer {
account: "alice".into(),
..Default::default()
}))
}
async fn ticket(
&self,
_: &HeaderMap,
_: &Buyer,
purpose: &str,
_: &BTreeMap<String, u64>,
) -> Result<Vec<u8>, Refusal> {
Ok(purpose.as_bytes().to_vec())
}
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 relay(renewal: bool) -> (Arc<Relay<Site>>, Shop) {
let shop = Shop {
renewal,
..Shop::default()
};
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.clone()))
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)),
);
let link = ShopLink::connect_lazy(
&Door {
url: format!("http://{at}"),
..Door::default()
},
"site-bearer-for-tests",
"gunnar",
)
.unwrap();
(Arc::new(Relay::new(Site, link)), shop)
}
async fn call(
router: axum::Router,
method: &str,
uri: &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")
.body(axum::body::Body::from(body.to_owned()))
.unwrap();
let res = router.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 a group, as `(method, path, body)`.
const SIGNUP: &[(&str, &str, &str)] = &[
("GET", "/auth/signup/ladder", ""),
("GET", "/auth/signup/name?u=x14", ""),
("POST", "/auth/signup/start", "{\"username\":\"x14\"}"),
(
"GET",
"/auth/signup/status?session=cs_test_abcdefghijkl",
"",
),
(
"POST",
"/auth/signup/rename",
"{\"session\":\"cs_test_abcdefghijkl\",\"username\":\"x15\"}",
),
("POST", "/auth/signup/sign-in-again", "{}"),
];
const ACCOUNT: &[(&str, &str, &str)] = &[
("GET", "/api/account/subscription", ""),
(
"GET",
"/api/account/subscription/status?reference=gunnar/alice/1",
"",
),
(
"POST",
"/api/account/subscription/cancel",
"{\"reference\":\"gunnar/alice/1\"}",
),
("GET", "/api/account/subscription/bundles", ""),
("GET", "/api/account/subscription/history", ""),
("GET", "/api/account/purchases", ""),
];
const ORDERS: &[(&str, &str, &str)] = &[
(
"POST",
"/api/account/subscription/buy-bundle",
"{\"bundle\":\"b1\"}",
),
("POST", "/api/account/subscription/renew", "{}"),
];
/// ★ A site that names only the private-instance doors answers nothing else: the sign-up, the
/// account's own and the order-starting paths are not there (axum's 404 with no body), while
/// the private doors are (the way back from the vendor, a 303, asks no shop).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_site_mounts_only_the_doors_it_names() {
let (r, shop) = relay(true).await;
let only_private = Doors {
private: true,
..Doors::NONE
};
let router = monetize_site_buy::router_of(r.clone(), only_private);
assert_eq!(
call(router.clone(), "GET", "/paid/estate/acme", "").await.0,
StatusCode::SEE_OTHER
);
for (m, uri, body) in
SIGNUP
.iter()
.chain(ACCOUNT)
.chain(ORDERS)
.chain(&[("GET", "/auth/buy/offer", "")])
{
let (st, answer) = call(router.clone(), m, uri, body).await;
assert_eq!(
(st, answer),
(StatusCode::NOT_FOUND, serde_json::Value::Null),
"{m} {uri} is not this site's door"
);
}
assert!(shop.asked().is_empty(), "no shop call: {:?}", shop.asked());
// The account's reads without the orders: a sold site (it sells nothing to start).
let reads = Doors {
account: true,
..Doors::NONE
};
let router = monetize_site_buy::router_of(r.clone(), reads);
for (m, uri, body) in ACCOUNT {
assert_ne!(
call(router.clone(), m, uri, body).await.0,
StatusCode::NOT_FOUND,
"{m} {uri} is mounted"
);
}
for (m, uri, body) in ORDERS {
assert_eq!(
call(router.clone(), m, uri, body).await.0,
StatusCode::NOT_FOUND,
"{m} {uri} starts an order: not mounted"
);
}
assert_eq!(
call(router.clone(), "GET", "/paid/estate/acme", "").await.0,
StatusCode::NOT_FOUND
);
// `router` is every group, as it always was.
let all = monetize_site_buy::router(r);
assert_eq!(
call(all.clone(), "GET", "/paid/estate/acme", "").await.0,
StatusCode::SEE_OTHER
);
for (m, uri, body) in ACCOUNT.iter().chain(ORDERS) {
assert_ne!(
call(all.clone(), m, uri, body).await.0,
StatusCode::NOT_FOUND,
"{m} {uri}"
);
}
}
/// ★ Every site holds its grow doors to checked tickets (A32-9): on a shop that does not
/// verify tenant tickets, the window, the grow, the status poll and the cancel answer 501 with
/// the renewal's sentence, and the purchase is never asked.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_grow_doors_wait_for_checked_tickets_where_the_site_asks() {
let (r, shop) = relay(false).await;
let router = monetize_site_buy::router(r);
for (m, uri, body) in [
("GET", "/api/account/subscription/bundles", ""),
(
"POST",
"/api/account/subscription/buy-bundle",
"{\"bundle\":\"b1\"}",
),
(
"GET",
"/api/account/subscription/status?reference=gunnar/alice/1",
"",
),
(
"POST",
"/api/account/subscription/cancel",
"{\"reference\":\"gunnar/alice/1\"}",
),
] {
let (st, answer) = call(router.clone(), m, uri, body).await;
assert_eq!(st, StatusCode::NOT_IMPLEMENTED, "{uri}: {answer}");
assert_eq!(
answer["error"],
monetize_site_buy::relay::RENEWAL_NOT_VERIFIED,
"{uri}"
);
}
assert!(
shop.asked().is_empty(),
"no purchase call: {:?}",
shop.asked()
);
}
/// A shop that verifies them: the same doors go on to the shop.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_shop_that_checks_tickets_is_asked() {
let (r, shop) = relay(true).await;
let router = monetize_site_buy::router(r);
assert_eq!(
call(
router.clone(),
"GET",
"/api/account/subscription/bundles",
""
)
.await
.0,
StatusCode::OK
);
assert_eq!(
call(
router.clone(),
"GET",
"/api/account/subscription/status?reference=gunnar/alice/1",
""
)
.await
.0,
StatusCode::OK
);
assert_eq!(
call(
router,
"POST",
"/api/account/subscription/cancel",
"{\"reference\":\"gunnar/alice/1\"}"
)
.await
.0,
StatusCode::OK
);
assert_eq!(
shop.asked(),
["Purchase.Bundles", "Purchase.Status", "Purchase.Cancel"]
);
}
/// ★ **A32-9: every site's grow doors wait for checked tickets** — there is no knob: a site that
/// says nothing (the old default `false`, Holger before decision 1) is held to them as well, so
/// on a shop that does not verify tenant tickets the window answers 501 and asks no purchase.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn every_site_s_grow_doors_wait_for_checked_tickets() {
let (r, shop) = relay(false).await;
let router = monetize_site_buy::router(r);
let (st, answer) = call(router, "GET", "/api/account/subscription/bundles", "").await;
assert_eq!(st, StatusCode::NOT_IMPLEMENTED, "{answer}");
assert_eq!(
answer["error"],
monetize_site_buy::relay::RENEWAL_NOT_VERIFIED
);
assert!(
shop.asked().is_empty(),
"no purchase call: {:?}",
shop.asked()
);
}
/// A site's own router state (Gunnar's `AppState`).
#[derive(Clone)]
struct SiteState(&'static str);
/// ★ **The doors merge into a router that holds its own state**, before its layers and its
/// `with_state` (Gunnar's console): the site's own routes and its middleware stay its own, and
/// the doors answer beside them.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_doors_merge_into_a_site_router_with_a_state_of_its_own() {
use axum::extract::State;
let (r, _) = relay(true).await;
let site: axum::Router<SiteState> = axum::Router::new().route(
"/private",
axum::routing::get(|State(s): State<SiteState>| async move { s.0 }),
);
let merged = site
.merge(monetize_site_buy::router_of::<_, SiteState>(
r,
Doors {
private: true,
..Doors::NONE
},
))
.with_state(SiteState("the site's own page"));
let (st, _) = call(merged.clone(), "GET", "/paid/estate/acme", "").await;
assert_eq!(st, StatusCode::SEE_OTHER, "the door answers");
use tower::ServiceExt;
let res = merged
.oneshot(
axum::http::Request::builder()
.uri("/private")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(res.into_body(), 1 << 10)
.await
.unwrap();
assert_eq!(&bytes[..], b"the site's own page");
}
/// A member of an account (Gunnar's console's employee): buys and renews nothing.
struct MemberSite;
impl BuySite for MemberSite {
fn origin(&self) -> String {
"https://gunnar.mock.invalid".into()
}
fn product_word(&self) -> String {
"Gunnar".into()
}
async fn who(&self, _: &HeaderMap) -> Result<Who, Refusal> {
Ok(Who::Person(Buyer {
account: "bob".into(),
member: Some(monetize_site_buy::Member {
account: "acme".into(),
admin: "alice".into(),
line: "You are a member of acme.".into(),
}),
..Default::default()
}))
}
async fn ticket(
&self,
_: &HeaderMap,
_: &Buyer,
purpose: &str,
_: &BTreeMap<String, u64>,
) -> Result<Vec<u8>, Refusal> {
Ok(purpose.as_bytes().to_vec())
}
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)
}
}
/// ★ **Who asks is told first** (Gunnar's `OwnSubscription`, which runs before a handler): a
/// member is refused as a member on every account door — the quote, Prolong, the history, the
/// window, its grow, the status poll and the cancel — before the shop is asked whether it
/// verifies tickets. And a shop with no `Products` service at all does not verify them: 501
/// with the renewal's sentence, never "the shop did not answer".
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_member_is_told_first_and_a_shop_without_products_verifies_nothing() {
// A shop with Purchase only (no Products service).
let shop = Shop::default();
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::purchase_server::PurchaseServer::new(shop.clone()))
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)),
);
let link = |product: &str| {
ShopLink::connect_lazy(
&Door {
url: format!("http://{at}"),
..Door::default()
},
"site-bearer-for-tests",
product,
)
.unwrap()
};
let doors = [
("GET", "/api/account/subscription", ""),
("POST", "/api/account/subscription/renew", "{}"),
("GET", "/api/account/subscription/history", ""),
("GET", "/api/account/subscription/bundles", ""),
(
"POST",
"/api/account/subscription/buy-bundle",
"{\"bundle\":\"b1\"}",
),
(
"GET",
"/api/account/subscription/status?reference=gunnar/acme/1",
"",
),
(
"POST",
"/api/account/subscription/cancel",
"{\"reference\":\"gunnar/acme/1\"}",
),
];
let members = monetize_site_buy::router(Arc::new(Relay::new(MemberSite, link("gunnar"))));
for (m, uri, body) in doors {
let (st, answer) = call(members.clone(), m, uri, body).await;
assert_eq!(st, StatusCode::FORBIDDEN, "{m} {uri}: {answer}");
assert!(
answer["error"]
.as_str()
.is_some_and(|w| w.starts_with("You are a member of acme.")),
"{m} {uri}: {answer}"
);
}
let persons = monetize_site_buy::router(Arc::new(Relay::new(Site, link("gunnar"))));
for (m, uri, body) in doors {
let (st, answer) = call(persons.clone(), m, uri, body).await;
assert_eq!(st, StatusCode::NOT_IMPLEMENTED, "{m} {uri}: {answer}");
assert_eq!(
answer["error"],
monetize_site_buy::relay::RENEWAL_NOT_VERIFIED,
"{m} {uri}"
);
}
assert!(
shop.asked().is_empty(),
"no purchase call: {:?}",
shop.asked()
);
}