crates/monetize-site-buy/tests/add_users_door.rs
//! ★ **"Add users" is a door of the shared buy flow** (A32-25): Gunnar's account admin adds
//! users through `POST /api/account/users`, the relay's [`add_users`], not through the
//! console's own `Purchase.Start`. The order is monetize's `Purchase.Start{users}` for the
//! account in the body, under an ORDER ticket the product mints for exactly the users and their
//! room (`users`, `user_room_bytes`: monetize's `UsersAsk::ticket_caps`), so the product decides
//! who may (Gunnar mints it only for the account's admin). The invite is redeemed at the site's
//! own origin. The group is mounted only where a site names it ([`Doors::users`]): Holger's
//! `router` 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::relay::{add_users, users_caps, UsersOrder};
use monetize_site_buy::{Account, Ask, BuySite, Buyer, Door, Doors, Refusal, Relay, ShopLink, Who};
use tonic::{Request, Response, Status};
const CHECKOUT: &str = "https://checkout.mock.invalid/c/pay/cs_test_users";
/// What `Purchase.Start` was asked: the tenant, the users order and the ticket's bytes.
#[derive(Clone, Debug, PartialEq)]
struct Started {
tenant: String,
users: Option<pb::UsersOrder>,
ticket: Vec<u8>,
bundle: String,
caps: BTreeMap<String, u64>,
}
#[derive(Clone, Default)]
struct Shop {
started: Arc<Mutex<Vec<Started>>>,
/// The answer `Purchase.Start` gives (`None`: a checkout for the rest).
answer: Arc<Mutex<Option<Result<pb::StartPurchaseResponse, Status>>>>,
}
#[tonic::async_trait]
impl pb::purchase_server::Purchase for Shop {
async fn bundles(
&self,
_: Request<pb::ListBundlesRequest>,
) -> Result<Response<pb::ListBundlesResponse>, Status> {
Err(Status::unimplemented("Bundles"))
}
async fn start(
&self,
r: Request<pb::StartPurchaseRequest>,
) -> Result<Response<pb::StartPurchaseResponse>, Status> {
let r = r.into_inner();
self.started.lock().unwrap().push(Started {
tenant: r.tenant.clone(),
users: r.users.clone(),
ticket: r.actor_ticket.clone(),
bundle: r.bundle,
caps: r.caps.into_iter().collect(),
});
match self.answer.lock().unwrap().clone() {
Some(a) => a.map(Response::new),
None => Ok(Response::new(pb::StartPurchaseResponse {
reference: format!("gunnar/{}/users-1", r.tenant),
state: pb::PurchaseState::Pending as i32,
redirect_url: CHECKOUT.into(),
words: "two more users: one checkout".into(),
users: Some(pb::UsersGrant {
granted: false,
seats: 2,
seats_open: 0,
room_bytes: 2 << 30,
..Default::default()
}),
..Default::default()
})),
}
}
async fn status(
&self,
_: Request<pb::PurchaseStatusRequest>,
) -> Result<Response<pb::PurchaseStatusResponse>, Status> {
Err(Status::unimplemented("Status"))
}
async fn quote(
&self,
_: Request<pb::QuoteRequest>,
) -> Result<Response<pb::QuoteResponse>, Status> {
Err(Status::unimplemented("Quote"))
}
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-user` names the person signed in (`member` a member of an
/// account, which may still be its admin); every ticket it mints is kept.
#[derive(Clone, Default)]
struct Console {
#[allow(clippy::type_complexity)]
minted: Arc<Mutex<Vec<(String, String, String, BTreeMap<String, u64>)>>>,
/// The product refuses the ticket (the person is not the account's admin).
refuses: bool,
}
impl BuySite for Console {
fn origin(&self) -> String {
"https://gunnar.mock.invalid".into()
}
fn product_word(&self) -> String {
"Gunnar".into()
}
async fn who(&self, headers: &HeaderMap) -> Result<Who, Refusal> {
Ok(match headers.get("x-user").and_then(|v| v.to_str().ok()) {
None => Who::Visitor,
Some("member") => Who::Person(Buyer {
account: "kim".into(),
member: Some(monetize_site_buy::Member {
account: "acme".into(),
admin: "kim".into(),
line: "You administer acme.".into(),
}),
..Buyer::default()
}),
Some(user) => Who::Person(Buyer {
account: user.into(),
tenant: user.into(),
..Buyer::default()
}),
})
}
async fn ticket(
&self,
_: &HeaderMap,
buyer: &Buyer,
purpose: &str,
caps: &BTreeMap<String, u64>,
) -> Result<Vec<u8>, Refusal> {
if self.refuses {
return Err(Refusal::new(
StatusCode::FORBIDDEN,
"only the account's admin adds its users",
));
}
self.minted.lock().unwrap().push((
purpose.to_owned(),
buyer.account.clone(),
buyer.tenant().to_owned(),
caps.clone(),
));
Ok(b"order-ticket".to_vec())
}
async fn held_caps(&self, _: &HeaderMap, _: &Buyer) -> Result<BTreeMap<String, u64>, Refusal> {
panic!("adding users reads no held caps: monetize asks the product whether they fit")
}
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::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()
}
fn ask(user: Option<&str>) -> Ask {
let mut h = HeaderMap::new();
if let Some(u) = user {
h.insert("x-user", u.parse().unwrap());
}
Ask::new(h, "203.0.113.9")
}
fn order(account: &str, count: u32, room_gib: u64, name: &str) -> UsersOrder {
UsersOrder {
account: account.into(),
count,
room_gib,
name: name.into(),
email: "eve@mock.invalid".into(),
}
}
/// ★ The order: `Purchase.Start{users}` for the BODY's account, the users and their room, the
/// one named user's address, the invite redeemed at the site's own origin; the ticket an ORDER
/// ticket for exactly `users_caps`, minted for that account; no bundle and no caps beside it.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn add_users_starts_monetizes_users_order_under_an_order_ticket_for_exactly_those_users() {
let shop = Shop::default();
let site = Console::default();
let relay = Relay::new(site.clone(), shop_at(shop.clone()).await);
let a = add_users(&relay, &ask(Some("member")), &order("acme", 2, 1, "")).await;
assert_eq!(a.status, StatusCode::OK, "{a:?}");
let caps = users_caps(2, 1 << 30);
assert_eq!(
caps,
BTreeMap::from([
("users".to_string(), 2),
("user_room_bytes".to_string(), 1 << 30)
]),
"monetize's UsersAsk::ticket_caps"
);
assert_eq!(
*site.minted.lock().unwrap(),
vec![(
"order".to_string(),
"kim".to_string(),
"acme".to_string(),
caps.clone()
)],
"an order ticket for acme, minted as kim"
);
let started = shop.started.lock().unwrap().clone();
assert_eq!(started.len(), 1);
let s = &started[0];
assert_eq!(
(
s.tenant.as_str(),
s.ticket.as_slice(),
s.bundle.as_str(),
s.caps.is_empty()
),
("acme", &b"order-ticket"[..], "", true)
);
assert_eq!(
s.users,
Some(pb::UsersOrder {
count: 2,
room_bytes: 1 << 30,
name: String::new(),
email: "eve@mock.invalid".into(),
invite_origin: "https://gunnar.mock.invalid".into()
})
);
assert_eq!(a.body["state"], "pending");
assert_eq!(a.body["checkout_url"], CHECKOUT);
assert_eq!(a.body["reference"], "gunnar/acme/users-1");
assert_eq!(
(a.body["seats"].as_u64(), a.body["room_bytes"].as_u64()),
(Some(2), Some(2 << 30))
);
}
/// ★ Users that fit are granted at once: the answer says `added`, names the principal and the
/// named user's invite link, and carries no checkout.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn users_that_fit_are_added_at_once_with_the_named_users_invite() {
let shop = Shop::default();
*shop.answer.lock().unwrap() = Some(Ok(pb::StartPurchaseResponse {
reference: "gunnar/acme/users-2".into(),
state: pb::PurchaseState::Paid as i32,
users: Some(pb::UsersGrant {
granted: true,
principal: "eve".into(),
invite_url: "https://gunnar.mock.invalid/login?invite=s3cret&user=eve".into(),
invited: "eve@mock.invalid".into(),
seats: 1,
..Default::default()
}),
..Default::default()
}));
let relay = Relay::new(Console::default(), shop_at(shop.clone()).await);
let a = add_users(&relay, &ask(Some("kim")), &order("acme", 1, 0, "eve")).await;
assert_eq!(a.status, StatusCode::OK, "{a:?}");
assert_eq!(
(a.body["state"].as_str(), a.body["checkout_url"].as_str()),
(Some("added"), Some(""))
);
assert_eq!(
a.body["principal"],
serde_json::json!({"id": "eve", "name": "eve"})
);
assert_eq!(
a.body["url"],
"https://gunnar.mock.invalid/login?invite=s3cret&user=eve"
);
assert_eq!(a.body["mailed"], "eve@mock.invalid");
}
/// ★ Who may not is refused before the shop is asked: nobody signed in, or a person the
/// product mints no ticket for; a refusal of the shop's is its code and words; a checkout link
/// that is not https never reaches the browser.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn who_may_not_is_refused_and_the_shops_refusal_and_a_bad_link_are_said() {
let shop = Shop::default();
let relay = Relay::new(Console::default(), shop_at(shop.clone()).await);
let a = add_users(&relay, &ask(None), &order("acme", 1, 0, "eve")).await;
assert_eq!(a.status, StatusCode::UNAUTHORIZED, "{a:?}");
let a = add_users(&relay, &ask(Some("kim")), &order(" ", 1, 0, "eve")).await;
assert_eq!(a.status, StatusCode::BAD_REQUEST, "no account: {a:?}");
let refusing = Relay::new(
Console {
refuses: true,
..Console::default()
},
shop_at(shop.clone()).await,
);
let a = add_users(&refusing, &ask(Some("kim")), &order("acme", 1, 0, "eve")).await;
assert_eq!(
(a.status, a.body["message"].as_str()),
(
StatusCode::FORBIDDEN,
Some("only the account's admin adds its users")
),
"{a:?}"
);
assert!(
shop.started.lock().unwrap().is_empty(),
"the shop was asked"
);
*shop.answer.lock().unwrap() = Some(Err(Status::failed_precondition(
"acme has no room for 3 users of 5 GiB",
)));
let a = add_users(&relay, &ask(Some("kim")), &order("acme", 3, 5, "")).await;
assert_eq!(
(
a.status,
a.body["code"].as_str(),
a.body["message"].as_str()
),
(
StatusCode::BAD_REQUEST,
Some("failedprecondition"),
Some("acme has no room for 3 users of 5 GiB")
)
);
*shop.answer.lock().unwrap() = Some(Ok(pb::StartPurchaseResponse {
reference: "r".into(),
redirect_url: "http://checkout.mock.invalid/x".into(),
..Default::default()
}));
let a = add_users(&relay, &ask(Some("kim")), &order("acme", 1, 0, "")).await;
assert_eq!(
(a.status, a.body["code"].as_str()),
(StatusCode::BAD_GATEWAY, Some("badpayurl")),
"{a:?}"
);
}
/// ★ The door `POST /api/account/users` is mounted only where a site names it.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_users_door_is_mounted_only_where_a_site_names_it() {
use tower::ServiceExt;
let shop = Shop::default();
let call = |router: axum::Router| async move {
let req = axum::http::Request::builder().method("POST").uri("/api/account/users").header("content-type", "application/json").header("x-user", "kim").body(axum::body::Body::from(r#"{"account":"acme","count":1,"room_gib":0,"name":"eve","email":"eve@mock.invalid"}"#)).unwrap();
router.oneshot(req).await.unwrap().status()
};
let all = monetize_site_buy::router_of::<_, ()>(
Arc::new(Relay::new(Console::default(), shop_at(shop.clone()).await)),
Doors::ALL,
);
assert_eq!(
call(all).await,
StatusCode::NOT_FOUND,
"not in Doors::ALL (Holger's mount)"
);
let users = monetize_site_buy::router_of::<_, ()>(
Arc::new(Relay::new(Console::default(), shop_at(shop.clone()).await)),
Doors {
users: true,
..Doors::NONE
},
);
assert_eq!(call(users).await, StatusCode::OK);
assert_eq!(shop.started.lock().unwrap().len(), 1);
}