crates/monetize-site-buy/src/testkit.rs
//! **A fake ONE box and a fake Stripe, for the buy flow's tests** (feature `testkit`).
//!
//! [`FakeOneBox`] serves the monetize services a site's relay calls (`Signup`, `EstateOrder`,
//! `Purchase`, `Products`, and `Entitlement` for a site that reads its held caps there) on a loopback port,
//! over monetize-proto, and keeps a [`Book`] of what it heard and decided. Its Stripe is a
//! ledger of checkouts: each minted checkout is one Stripe page, and [`FakeOneBox::pay`] is
//! the vendor's paid event (the account is made, the instance is queued, the room is granted).
//!
//! It stands in for the ONE box in a product's own tests too (gunnar-ui-server, holger's
//! front): a site mounts its real provider over the relay and points it here. Nothing in it
//! is a real price or a real address: every host is `*.mock.invalid`.
use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex, MutexGuard};
use monetize_proto as pb;
use tonic::{Code, Request, Response, Status};
use crate::link::{Door, ShopLink};
/// What the box answers a ticket it honoured before (monetize-server `site_ticket::SPENT`).
pub const SPENT: &str = "this ticket has already been spent; ask the appliance for another";
/// The fake vendor's checkout host.
pub const STRIPE: &str = "https://stripe.mock.invalid";
/// The apex the fake lays instances under.
pub const APEX: &str = "estates.mock.invalid";
/// The free account's plan row, and the paid one's, under the product (`<product>/<row>`).
pub const FREE_ROW: &str = "account";
pub const PAID_ROW: &str = "10gb-1m@11000SEK";
/// The paid plan's amount, minor units, and its words.
pub const PAID_MINOR: u64 = 11_000;
pub const PAID_WORDS: &str = "110.00 SEK a month";
/// The ladder: (name, main plan, data GiB, monthly words, minor per month).
pub const LADDER: &[(&str, &str, u64, &str, u64)] = &[("Mini", "m-mini", 40, "96.20 EUR a month", 9_620), ("Small", "m-small", 120, "132.00 EUR a month", 13_200)];
/// The term a private instance is paid for, months.
pub const TERM_MONTHS: u64 = 4;
/// The account's grow: one bundle.
pub const BUNDLE: &str = "more10";
pub const BUNDLE_BYTES: u64 = 10 << 30;
pub const BUNDLE_MINOR: u64 = 22_000;
pub const BUNDLE_WORDS: &str = "220.00 SEK";
/// What the free account and the paid plan hold.
pub const FREE_BYTES: u64 = 1 << 30;
pub const PAID_BYTES: u64 = 10 << 30;
/// ★ **What the ONE box says while a paid account settles** (PAID-WORDS, 2026-10-26): the
/// account is queued for the product's main and made on its next call, a few seconds. These are
/// monetize-server's own words for that state (`signup::customer_words`); its test
/// `the_fake_box_says_what_the_box_says_while_an_account_settles` holds the two to one sentence.
pub const SETTLING_WORDS: &str = "Payment received. Setting up your account; this takes a few seconds.";
/// ★ RA4-19: **the sentence the box sends AHEAD with a settling answer**, for its silence: what
/// the page shows once the box's settle bound has passed with no answer (monetize-server
/// `signup::words::TAKING_LONGER_MAILED`, the box mailing the sign-in link), due
/// [`SILENT_AFTER_MS`] after the answer.
pub const LONGER_WORDS: &str =
"Payment received. This is taking longer than usual; your payment is safe, and we will e-mail you the sign-in link as soon as your account is ready — you do not need to pay again.";
/// When [`LONGER_WORDS`] is due after a settling answer: the box's settle bound (monetize-server
/// `signup::SETTLE_BOUND_MS`), from the payment; the fake's payment is "just now".
pub const SILENT_AFTER_MS: i64 = 2 * 60 * 1000;
/// What the box says while the PRODUCT has told it, by name, that it is in maintenance
/// (monetize-server `signup::words::MAINTENANCE`).
pub const MAINTENANCE_WORDS: &str =
"Payment received. The shop is temporarily down for maintenance; your account will be ready as soon as it is back — you do not need to pay again.";
/// What a checkout is for.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum What {
/// A new account (the free account's 0 kr card check, or a paid plan).
Signup { username: String, plan: String, email: String },
/// A private instance; `new_account` when a visitor pays for the account with it.
Estate { company: String, account: String, new_account: bool, email: String, step: String },
/// A change of an instance's plan.
Change { company: String, step: String },
/// The account's grow.
Bundle { tenant: String, bundle: String },
}
/// **One Stripe page.**
#[derive(Clone, Debug)]
pub struct Checkout {
/// `cs_test_…`: the vendor's session id.
pub id: String,
pub what: What,
pub amount_minor: u64,
/// Stripe's 0 kr card check (mode `setup`), not a payment.
pub setup: bool,
/// Where the vendor sends the buyer when paid (`{session}` unreplaced), and on Cancel.
pub success_url: String,
pub cancel_url: String,
/// The shop's reference (a purchase's `p-<n>`, an order's `estate/<co>`).
pub reference: String,
pub paid: bool,
}
impl Checkout {
/// The page the buyer is sent to.
pub fn url(&self) -> String {
format!("{STRIPE}/c/pay/{}", self.id)
}
/// Where the vendor sends the buyer back after paying.
pub fn return_url(&self) -> String {
self.success_url.replace("{session}", &self.id)
}
}
/// **A mail the shop sent.**
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Mail {
pub to: String,
/// `sign-in` (a new account's link), `sign-in-again`.
pub kind: String,
pub link: String,
}
/// An account the shop made.
#[derive(Clone, Debug, Default)]
pub struct Acct {
pub email: String,
pub plan: String,
pub caps: BTreeMap<String, u64>,
}
/// A private instance the shop sold.
#[derive(Clone, Debug, Default)]
pub struct Estate {
pub account: String,
pub step: String,
/// `waiting_for_payment` → `queued` → `laying` → `ready`.
pub state: String,
/// A change asked and its state (`ordered`, `done`).
pub change: Option<(String, String)>,
}
/// **What the fake heard and decided.**
#[derive(Default)]
pub struct Book {
/// Every call, `Service.Method`, in order.
pub calls: Vec<String>,
/// The `authorization` every call came with.
pub bearers: Vec<String>,
pub checkouts: Vec<Checkout>,
pub mails: Vec<Mail>,
pub accounts: BTreeMap<String, Acct>,
pub estates: BTreeMap<String, Estate>,
/// Every e-mail address a call carried: `(call, address)`.
pub emails: Vec<(String, String)>,
/// Every way back a call carried: `(call, url)`.
pub urls: Vec<(String, String)>,
/// Every actor ticket a call carried: `(call, ticket)`.
pub tickets: Vec<(String, Vec<u8>)>,
/// Every call answers this (an ONE box that errs).
pub fail: Option<(Code, String)>,
/// The plans, the ladder and the sells word answer only after this (a slow or stuck ONE box).
pub stall: Option<std::time::Duration>,
/// The customer's sentence riding beside [`Self::fail`] (`monetize-customer-words-bin`).
pub fail_words: Option<String>,
/// Purchases by reference: (checkout index).
purchases: BTreeMap<String, usize>,
/// The purchases their buyer cancelled before paying ("Cancel order"), by reference.
pub cancelled: std::collections::BTreeSet<String>,
/// Whether this box spends each tenant ticket once ([`FakeOneBox::verify_tickets`]).
pub verifies_tickets: bool,
/// ★ A32-9: this box says it checks NO tenant ticket ([`FakeOneBox::checks_no_tickets`]): its
/// `Products.List` answers `tenant_renewal = false`, a shop older than the ticket.
pub checks_no_tickets: bool,
/// The tickets a `Purchase` call honoured, once each (a box that verifies them).
pub spent: std::collections::BTreeSet<Vec<u8>>,
/// ★ How many `Signup.Status` polls a paid sign-up stays PENDING after paying, settling
/// ([`FakeOneBox::settle_after`]); 0: made at once.
pub settle_polls: u32,
/// The settle's polls (1-based, counted after paying) the box does not answer
/// ([`FakeOneBox::status_fails_at`]).
pub status_fails: std::collections::BTreeSet<u32>,
/// The product has told the box it is in maintenance ([`FakeOneBox::in_maintenance`]).
pub maintenance: bool,
/// The `Signup.Status` polls each paid checkout has had since paying, by checkout id.
pub status_polls: BTreeMap<String, u32>,
/// The account's grow window rows, as a test sets them ([`FakeOneBox::sell_bundles`]);
/// `None`: the default two ([`BUNDLE`] and an unservable one).
pub bundles: Option<Vec<pb::Bundle>>,
}
impl Book {
/// The checkouts minted for a call kind (`signup`, `estate`, `change`, `bundle`).
pub fn checkouts_of(&self, kind: &str) -> Vec<&Checkout> {
self.checkouts
.iter()
.filter(|c| matches!((&c.what, kind), (What::Signup { .. }, "signup") | (What::Estate { .. }, "estate") | (What::Change { .. }, "change") | (What::Bundle { .. }, "bundle")))
.collect()
}
pub fn checkout(&self, id_or_url: &str) -> Option<&Checkout> {
let id = id_or_url.rsplit('/').next().unwrap_or(id_or_url);
self.checkouts.iter().find(|c| c.id == id)
}
pub fn count(&self, call: &str) -> usize {
self.calls.iter().filter(|c| *c == call).count()
}
}
/// **The fake ONE box.** Clone it freely: every clone is the same box.
#[derive(Clone)]
pub struct FakeOneBox {
book: Arc<Mutex<Book>>,
pub product: String,
pub bearer: String,
pub addr: SocketAddr,
}
fn minor_words(minor: u64, cur: &str) -> String {
format!("{}.{:02} {cur}", minor / 100, minor % 100)
}
impl FakeOneBox {
/// **A fake ONE box for `product` on a loopback port**, insisting on the site's `bearer`.
pub async fn spawn(product: &str, bearer: &str) -> FakeOneBox {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("a loopback port");
let addr = listener.local_addr().expect("its address");
let fake = FakeOneBox { book: Arc::default(), product: product.to_owned(), bearer: bearer.to_owned(), addr };
let serve = tonic::transport::Server::builder()
.add_service(pb::signup_server::SignupServer::new(fake.clone()))
.add_service(pb::estate_order_server::EstateOrderServer::new(fake.clone()))
.add_service(pb::purchase_server::PurchaseServer::new(fake.clone()))
.add_service(pb::products_server::ProductsServer::new(fake.clone()))
.add_service(pb::entitlement_server::EntitlementServer::new(fake.clone()))
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener));
tokio::spawn(serve);
fake
}
/// `http://127.0.0.1:<port>`.
pub fn url(&self) -> String {
format!("http://{}", self.addr)
}
/// The site's link to this box, with the site's bearer.
pub fn link(&self) -> ShopLink {
ShopLink::connect_lazy(&Door { url: self.url(), ..Door::default() }, &self.bearer, &self.product).expect("a link to the fake")
}
/// What the box heard and decided.
pub fn book(&self) -> MutexGuard<'_, Book> {
self.book.lock().unwrap_or_else(|p| p.into_inner())
}
/// From now on every call answers `code` (`None`: answer again).
/// From now on the plans, the ladder and the sells word answer only after `d` (`None`: at once).
pub fn stall(&self, d: Option<std::time::Duration>) {
self.book().stall = d;
}
async fn stalled(&self) {
let d = self.book().stall;
if let Some(d) = d {
tokio::time::sleep(d).await;
}
}
pub fn fail(&self, code: Option<(Code, &str)>) {
let mut b = self.book();
b.fail = code.map(|(c, m)| (c, m.to_owned()));
b.fail_words = None;
}
/// From now on every call answers `code` with the operator's `message`, and the customer's
/// `words` beside it, as monetize's quote refusals do.
pub fn fail_saying(&self, code: Code, message: &str, words: &str) {
let mut b = self.book();
b.fail = Some((code, message.to_owned()));
b.fail_words = Some(words.to_owned());
}
/// **From now on this box verifies the product's tenant tickets**, as a configured ONE box
/// does (`Products.List` answers `tenant_renewal`; monetize-server `site_ticket`): every
/// `Purchase` call honours a ticket ONCE, and the same ticket again is refused as spent
/// ([`SPENT`]). A box left as spawned SAYS it checks them (`tenant_renewal`, as a configured
/// ONE box), but spends none, so a test's plain tickets may be presented again.
pub fn verify_tickets(&self) {
self.book().verifies_tickets = true;
}
/// ★ **A paid account settles over `polls` status polls** (PAID-WORDS): after the vendor's
/// paid event the box answers `Signup.Status` PENDING, in [`SETTLING_WORDS`], for the first
/// `polls` polls (the account waits for the product's main, as a pulled product's does), and
/// CREATED after.
pub fn settle_after(&self, polls: u32) {
self.book().settle_polls = polls;
}
/// The settle's polls `at` (1-based, after paying) answer UNAVAILABLE: a blip of the box
/// while the account is being made.
pub fn status_fails_at(&self, at: &[u32]) {
self.book().status_fails = at.iter().copied().collect();
}
/// The product says, by name, that it is in maintenance: a settling account reads
/// [`MAINTENANCE_WORDS`].
pub fn in_maintenance(&self, on: bool) {
self.book().maintenance = on;
}
/// ★ **A32-9: from now on this box says it checks no tenant ticket** (`Products.List`
/// `tenant_renewal = false`: a shop older than the ticket). Every site's grow doors refuse
/// there with the buyer's words; no `Purchase` call is made.
pub fn checks_no_tickets(&self) {
self.book().checks_no_tickets = true;
}
/// An account the shop knows already (a signed-in buyer's), holding `caps`.
pub fn add_account(&self, name: &str, email: &str, caps: &[(&str, u64)]) {
self.book().accounts.insert(name.to_owned(), Acct { email: email.to_owned(), plan: String::new(), caps: caps.iter().map(|(k, v)| ((*k).to_owned(), *v)).collect() });
}
/// **The grow window this box answers** (`Purchase.Bundles`), in the order given: a test
/// that plays the ONE box's real list (monetize-server answers it cheapest first).
pub fn sell_bundles(&self, rows: Vec<pb::Bundle>) {
self.book().bundles = Some(rows);
}
/// An instance the account bought already, ready.
pub fn add_estate(&self, company: &str, account: &str, step: &str) {
self.book().estates.insert(company.to_owned(), Estate { account: account.to_owned(), step: step.to_owned(), state: "ready".into(), change: None });
}
/// **The vendor's paid event** for the checkout `id_or_url`: the shop settles it once (a
/// second event changes nothing). `false`: no such checkout.
pub fn pay(&self, id_or_url: &str) -> bool {
let mut b = self.book();
let id = id_or_url.rsplit('/').next().unwrap_or(id_or_url).to_owned();
let Some(i) = b.checkouts.iter().position(|c| c.id == id) else { return false };
if b.cancelled.contains(&b.checkouts[i].reference) {
return false;
}
if b.checkouts[i].paid {
return true;
}
b.checkouts[i].paid = true;
let c = b.checkouts[i].clone();
let back = c.return_url();
match c.what {
What::Signup { username, plan, email } => {
let bytes = if c.setup { FREE_BYTES } else { PAID_BYTES };
b.accounts.insert(username.clone(), Acct { email: email.clone(), plan, caps: BTreeMap::from([("disk_bytes".to_owned(), bytes)]) });
b.mails.push(Mail { to: email, kind: "sign-in".into(), link: back.clone() });
}
What::Estate { company, account, new_account, email, step } => {
if new_account {
b.accounts.insert(account.clone(), Acct { email: email.clone(), plan: String::new(), caps: BTreeMap::new() });
b.mails.push(Mail { to: email, kind: "sign-in".into(), link: back.clone() });
}
b.estates.insert(company, Estate { account, step, state: "queued".into(), change: None });
}
What::Change { company, step } => {
if let Some(e) = b.estates.get_mut(&company) {
e.step = step.clone();
e.change = Some((step, "done".into()));
}
}
What::Bundle { tenant, .. } => {
if let Some(a) = b.accounts.get_mut(&tenant) {
let held = a.caps.entry("disk_bytes".into()).or_insert(0);
*held = held.saturating_add(BUNDLE_BYTES);
}
}
}
true
}
/// The box's verdict on a call: its bearer, its failure, and the call written down.
fn hear<T>(&self, call: &str, r: &Request<T>) -> Result<(), Status> {
let bearer = r.metadata().get("authorization").and_then(|v| v.to_str().ok()).unwrap_or("").to_owned();
let mut b = self.book();
b.calls.push(call.to_owned());
b.bearers.push(bearer.clone());
if let Some((code, message)) = &b.fail {
let mut status = Status::new(*code, message.clone());
if let Some(words) = &b.fail_words {
status.metadata_mut().insert_bin(crate::rules::CUSTOMER_WORDS_KEY, tonic::metadata::MetadataValue::from_bytes(words.as_bytes()));
}
return Err(status);
}
if bearer != format!("Bearer {}", self.bearer) {
return Err(Status::unauthenticated("this site is not known here"));
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn mint(&self, b: &mut Book, what: What, amount_minor: u64, setup: bool, success_url: &str, cancel_url: &str, reference: String) -> Checkout {
let n = b.checkouts.len() + 1;
let c = Checkout { id: format!("cs_test_fake{n:010}"), what, amount_minor, setup, success_url: success_url.to_owned(), cancel_url: cancel_url.to_owned(), reference, paid: false };
b.checkouts.push(c.clone());
c
}
fn step_of(sizes: &Option<pb::EstateSizes>) -> Option<&'static (&'static str, &'static str, u64, &'static str, u64)> {
let main = sizes.as_ref().map(|s| s.main_plan.as_str()).unwrap_or("");
LADDER.iter().find(|s| s.1 == main || (main.is_empty() && s.0 == "Mini"))
}
fn ticket_of(&self, call: &str, ticket: &[u8]) -> Result<(), Status> {
let mut b = self.book();
b.tickets.push((call.to_owned(), ticket.to_vec()));
if ticket.is_empty() {
return Err(Status::unauthenticated("no actor ticket: the product vouches for its buyer"));
}
if b.verifies_tickets && call.starts_with("Purchase.") && !b.spent.insert(ticket.to_vec()) {
return Err(Status::permission_denied(SPENT));
}
Ok(())
}
}
type R<T> = Result<Response<T>, Status>;
#[tonic::async_trait]
impl pb::signup_server::Signup for FakeOneBox {
async fn status(&self, r: Request<pb::SignupStatusRequest>) -> R<pb::SignupStatusResponse> {
self.hear("Signup.Status", &r)?;
let r = r.into_inner();
let mut b = self.book();
let Some(c) = b.checkout(&r.session) else { return Err(Status::not_found("no such sign-up")) };
let user = match &c.what {
What::Signup { username, .. } => username.clone(),
What::Estate { account, new_account: true, .. } => account.clone(),
_ => return Err(Status::not_found("no such sign-up")),
};
if !c.paid {
return Ok(Response::new(pb::SignupStatusResponse { state: pb::signup_status_response::State::Pending as i32, words: "Waiting for your payment to be confirmed. Nothing more is needed from you.".into(), reason: "the vendor's event has not landed".into(), ..Default::default() }));
}
// ★ Paid, and settling: the account is made on the product's main's next call.
let id = c.id.clone();
let polled = {
let n = b.status_polls.entry(id).or_insert(0);
*n += 1;
*n
};
if b.status_fails.contains(&polled) {
return Err(Status::unavailable("the box did not answer this poll"));
}
if polled <= b.settle_polls {
let words = if b.maintenance { MAINTENANCE_WORDS } else { SETTLING_WORDS };
let ahead = if b.maintenance { MAINTENANCE_WORDS } else { LONGER_WORDS };
return Ok(Response::new(pb::SignupStatusResponse { state: pb::signup_status_response::State::Pending as i32, words: words.into(), words_if_silent: ahead.into(), silent_after_ms: SILENT_AFTER_MS, ..Default::default() }));
}
Ok(Response::new(pb::SignupStatusResponse { state: pb::signup_status_response::State::Created as i32, username: user, product: self.product.clone(), ..Default::default() }))
}
async fn check_name(&self, r: Request<pb::CheckNameRequest>) -> R<pb::CheckNameResponse> {
self.hear("Signup.CheckName", &r)?;
let name = r.into_inner().username;
let free = name != "taken" && !self.book().accounts.contains_key(&name);
Ok(Response::new(pb::CheckNameResponse { free, reason: if free { String::new() } else { format!("username {name:?} is taken; choose another") } }))
}
async fn start(&self, r: Request<pb::StartSignupRequest>) -> R<pb::StartSignupResponse> {
self.hear("Signup.Start", &r)?;
let r = r.into_inner();
let mut b = self.book();
b.emails.push(("Signup.Start".into(), r.email.clone()));
b.urls.push(("Signup.Start".into(), r.success_url.clone()));
b.urls.push(("Signup.Start".into(), r.cancel_url.clone()));
if r.product != self.product {
return Err(Status::invalid_argument("that product is not sold to this site"));
}
if r.username == "taken" || b.accounts.contains_key(&r.username) {
return Err(Status::failed_precondition(format!("username {:?} is taken; choose another", r.username)));
}
let free = r.plan.is_empty() || r.plan == format!("{}/{FREE_ROW}", self.product);
let paid = r.plan == format!("{}/{PAID_ROW}", self.product);
if !free && !paid {
return Err(Status::invalid_argument("that plan is not sold here"));
}
let what = What::Signup { username: r.username.clone(), plan: r.plan.clone(), email: r.email.clone() };
let c = self.mint(&mut b, what, if free { 0 } else { PAID_MINOR }, free, &r.success_url, &r.cancel_url, format!("signup/{}", r.username));
Ok(Response::new(pb::StartSignupResponse { url: c.url(), session: c.id }))
}
async fn rename(&self, r: Request<pb::RenameSignupRequest>) -> R<pb::RenameSignupResponse> {
self.hear("Signup.Rename", &r)?;
Err(Status::not_found("no such sign-up"))
}
async fn plans(&self, r: Request<pb::SignupPlansRequest>) -> R<pb::SignupPlansResponse> {
self.hear("Signup.Plans", &r)?;
self.stalled().await;
let market = r.into_inner().market;
Ok(Response::new(pb::SignupPlansResponse {
plans: vec![
pb::SignupPlanOffer { id: format!("{}/{FREE_ROW}", self.product), name: "Free account".into(), resources_words: "1 GB of room".into(), price_words: "Free".into(), cap_bytes: FREE_BYTES, period_days: 0 },
pb::SignupPlanOffer { id: format!("{}/{PAID_ROW}", self.product), name: "10 GiB · 1 month".into(), resources_words: "10 GB of room".into(), price_words: PAID_WORDS.into(), cap_bytes: PAID_BYTES, period_days: 30 },
],
market,
}))
}
async fn send_sign_in_again(&self, r: Request<pb::SignupSignInAgainRequest>) -> R<pb::SignupSignInAgainResponse> {
self.hear("Signup.SendSignInAgain", &r)?;
let r = r.into_inner();
let mut b = self.book();
b.urls.push(("Signup.SendSignInAgain".into(), r.welcome_url.clone()));
let Some(c) = b.checkout(&r.session).cloned() else { return Err(Status::not_found("no such sign-up")) };
let user = match &c.what {
What::Signup { username, .. } => username.clone(),
What::Estate { account, .. } => account.clone(),
_ => return Err(Status::not_found("no such sign-up")),
};
let to = b.accounts.get(&user).map(|a| a.email.clone()).ok_or_else(|| Status::failed_precondition("this account is not made yet"))?;
b.mails.push(Mail { to, kind: "sign-in-again".into(), link: r.welcome_url.replace("{session}", &c.id) });
Ok(Response::new(pb::SignupSignInAgainResponse { words: "We’ve sent it again to the address the account signed up with.".into() }))
}
}
#[tonic::async_trait]
impl pb::estate_order_server::EstateOrder for FakeOneBox {
async fn product_order(&self, _: tonic::Request<pb::EstateProductOrderRequest>) -> Result<tonic::Response<pb::EstateProductOrderResponse>, tonic::Status> { Err(tonic::Status::unimplemented("product_order")) }
async fn product_order_answer(&self, _: tonic::Request<pb::EstateProductOrderAnswerRequest>) -> Result<tonic::Response<pb::EstateProductOrderAnswerResponse>, tonic::Status> { Err(tonic::Status::unimplemented("product_order_answer")) }
async fn product_order_kinds(&self, _: tonic::Request<pb::EstateProductOrderKindsRequest>) -> Result<tonic::Response<pb::EstateProductOrderKindsResponse>, tonic::Status> { Err(tonic::Status::unimplemented("product_order_kinds")) }
async fn start(&self, r: Request<pb::EstateOrderStartRequest>) -> R<pb::EstateOrderStartResponse> {
self.hear("EstateOrder.Start", &r)?;
let r = r.into_inner();
{
let mut b = self.book();
b.emails.push(("EstateOrder.Start".into(), r.email.clone()));
for u in [&r.welcome_url, &r.cancel_url] {
if !u.is_empty() {
b.urls.push(("EstateOrder.Start".into(), u.clone()));
}
}
}
if !r.new_account {
self.ticket_of("EstateOrder.Start", &r.actor_ticket)?;
}
let mut b = self.book();
if r.company == "taken" || b.estates.contains_key(&r.company) {
return Err(Status::already_exists(format!("{}.{APEX} is taken", r.company)));
}
if r.new_account && (r.account == "taken" || b.accounts.contains_key(&r.account)) {
return Err(Status::already_exists(format!("username {:?} is taken; choose another", r.account)));
}
let Some(step) = Self::step_of(&r.sizes) else { return Err(Status::invalid_argument("which size? that plan is not one sold here")) };
let amount = step.4 * TERM_MONTHS;
let success = if r.new_account { r.welcome_url.clone() } else { String::new() };
let what = What::Estate { company: r.company.clone(), account: r.account.clone(), new_account: r.new_account, email: r.email.clone(), step: step.0.to_owned() };
let c = self.mint(&mut b, what, amount, false, &success, &r.cancel_url, format!("estate/{}", r.company));
Ok(Response::new(pb::EstateOrderStartResponse { reference: c.reference.clone(), checkout_url: c.url(), state: "waiting_for_payment".into(), words: "Waiting for your payment on the payment page.".into(), price_words: minor_words(amount, "EUR") }))
}
async fn status(&self, r: Request<pb::EstateOrderStatusRequest>) -> R<pb::EstateOrderStatusResponse> {
self.hear("EstateOrder.Status", &r)?;
let r = r.into_inner();
let mut b = self.book();
let paid = b.checkouts.iter().any(|c| c.paid && matches!(&c.what, What::Estate { company, .. } if *company == r.company));
let held = b.checkouts.iter().any(|c| matches!(&c.what, What::Estate { company, .. } if *company == r.company));
let Some(e) = b.estates.get_mut(&r.company) else {
if held && !paid {
return Ok(Response::new(pb::EstateOrderStatusResponse { company: r.company, state: "waiting_for_payment".into(), words: "Waiting for your payment on the payment page.".into(), ..Default::default() }));
}
return Err(Status::not_found("no such order"));
};
// Each poll lays it one step further: queued → laying → ready.
e.state = match e.state.as_str() {
"queued" => "laying",
"laying" | "ready" => "ready",
other => other,
}
.to_owned();
let state = e.state.clone();
let (change_status, change_words) = e.change.clone().map(|(step, st)| (st, format!("{} is {step} now.", r.company))).unwrap_or_default();
let ready = state == "ready";
let own = !r.actor_ticket.is_empty();
let words = match state.as_str() {
"ready" => format!("Your instance is ready at https://{}.{APEX}", r.company),
"laying" => "Your machines and disks are being set up.".to_owned(),
_ => "Your instance is paid and being prepared.".to_owned(),
};
Ok(Response::new(pb::EstateOrderStatusResponse {
company: r.company.clone(),
state,
words,
url: if ready && own { format!("https://{}.{APEX}", r.company) } else { String::new() },
change_status,
change_words,
..Default::default()
}))
}
async fn send_sign_in_again(&self, r: Request<pb::EstateOrderSignInAgainRequest>) -> R<pb::EstateOrderSignInAgainResponse> {
self.hear("EstateOrder.SendSignInAgain", &r)?;
Ok(Response::new(pb::EstateOrderSignInAgainResponse { words: "sent".into() }))
}
async fn change_plan(&self, r: Request<pb::EstateOrderChangePlanRequest>) -> R<pb::EstateOrderChangePlanResponse> {
self.hear("EstateOrder.ChangePlan", &r)?;
let r = r.into_inner();
self.ticket_of("EstateOrder.ChangePlan", &r.actor_ticket)?;
let mut b = self.book();
let Some(e) = b.estates.get(&r.company).cloned() else { return Err(Status::not_found("no such instance")) };
let Some(step) = Self::step_of(&r.sizes) else { return Err(Status::invalid_argument("which size? that plan is not one sold here")) };
if step.0 == e.step {
return Err(Status::failed_precondition(format!("{} is {} already", r.company, e.step)));
}
let n = b.checkouts_of("change").len() + 1;
let what = What::Change { company: r.company.clone(), step: step.0.to_owned() };
let c = self.mint(&mut b, what, step.4, false, "", "", format!("estate/{}/change-{n}", r.company));
if let Some(e) = b.estates.get_mut(&r.company) {
e.change = Some((step.0.to_owned(), "ordered".into()));
}
Ok(Response::new(pb::EstateOrderChangePlanResponse { reference: c.reference.clone(), checkout_url: c.url(), status: "ordered".into(), words: "Pay the difference to grow it; the machines restart.".into(), price_words: minor_words(step.4, "EUR"), twin_plan: String::new() }))
}
async fn ladder(&self, r: Request<pb::EstateOrderLadderRequest>) -> R<pb::EstateOrderLadderResponse> {
self.hear("EstateOrder.Ladder", &r)?;
self.stalled().await;
let r = r.into_inner();
let current = if r.company.is_empty() {
0
} else {
self.ticket_of("EstateOrder.Ladder", &r.actor_ticket)?;
let b = self.book();
let e = b.estates.get(&r.company).ok_or_else(|| Status::not_found("no such instance"))?;
LADDER.iter().position(|s| s.0 == e.step).unwrap_or(0) as u32
};
Ok(Response::new(pb::EstateOrderLadderResponse {
steps: LADDER
.iter()
.map(|s| pb::EstateLadderStep {
name: s.0.into(),
sizes: Some(pb::EstateSizes { front_plan: "f-mini".into(), main_plan: s.1.into(), main_data_gib: s.2, ..Default::default() }),
monthly_words: s.3.into(),
total_words: minor_words(s.4 * TERM_MONTHS, "EUR"),
// The real ladder's `pay_words` is the AMOUNT (`services/estate_order.rs`: `q.total.in_words()`);
// the page adds "Pay " (`buy.js` BUTTONS). A fake that said "Pay " read "Pay Pay …".
pay_words: minor_words(s.4 * TERM_MONTHS, "EUR"),
available: true,
..Default::default()
})
.collect(),
current,
laid: !r.company.is_empty(),
apex: APEX.into(),
term_months: TERM_MONTHS as u32,
..Default::default()
}))
}
async fn sells(&self, r: Request<pb::EstateOrderSellsRequest>) -> R<pb::EstateOrderSellsResponse> {
self.hear("EstateOrder.Sells", &r)?;
self.stalled().await;
Ok(Response::new(pb::EstateOrderSellsResponse { estates: true }))
}
async fn purchases(&self, r: Request<pb::EstateOrderPurchasesRequest>) -> R<pb::EstateOrderPurchasesResponse> {
self.hear("EstateOrder.Purchases", &r)?;
let r = r.into_inner();
self.ticket_of("EstateOrder.Purchases", &r.actor_ticket)?;
let b = self.book();
let estates = b
.estates
.iter()
.filter(|(_, e)| e.account == r.account)
.map(|(co, e)| {
let step = LADDER.iter().find(|s| s.0 == e.step);
pb::EstatePurchase { company: co.clone(), host: format!("{co}.{APEX}"), url: if e.state == "ready" { format!("https://{co}.{APEX}") } else { String::new() }, state: e.state.clone(), step: e.step.clone(), monthly_words: step.map(|s| s.3.to_owned()).unwrap_or_default(), ..Default::default() }
})
.collect();
let acct = b.accounts.get(&r.account);
let rooms = acct.filter(|a| !a.plan.is_empty()).map(|a| pb::RoomPurchase { state: "paid".into(), cap_bytes: a.caps.get("disk_bytes").copied().unwrap_or(0), ..Default::default() }).into_iter().collect();
Ok(Response::new(pb::EstateOrderPurchasesResponse { estates, rooms, account_here: acct.is_some() }))
}
async fn renew(&self, r: Request<pb::EstateOrderRenewRequest>) -> R<pb::EstateOrderRenewResponse> {
self.hear("EstateOrder.Renew", &r)?;
Err(Status::unimplemented("Renew"))
}
async fn notice(&self, _: Request<pb::EstateNoticeRequest>) -> R<pb::EstateNoticeResponse> {
Err(Status::unimplemented("Notice"))
}
async fn cancel(&self, _: Request<pb::EstateOrderCancelRequest>) -> R<pb::EstateOrderCancelResponse> {
Err(Status::unimplemented("Cancel"))
}
async fn settle(&self, _: Request<pb::EstateOrderSettleRequest>) -> R<pb::EstateOrderSettleResponse> {
Err(Status::unimplemented("Settle"))
}
async fn restore_front(&self, _: Request<pb::EstateOrderRestoreFrontRequest>) -> R<pb::EstateOrderRestoreFrontResponse> {
Err(Status::unimplemented("RestoreFront"))
}
async fn terminate(&self, _: Request<pb::EstateOrderTerminateRequest>) -> R<pb::EstateOrderTerminateResponse> {
Err(Status::unimplemented("Terminate"))
}
async fn first_link(&self, _: Request<pb::EstateOrderFirstLinkRequest>) -> R<pb::EstateOrderFirstLinkResponse> {
Err(Status::unimplemented("FirstLink"))
}
async fn refeed(&self, _: Request<pb::EstateOrderRefeedRequest>) -> R<pb::EstateOrderRefeedResponse> {
Err(Status::unimplemented("Refeed"))
}
async fn console(&self, _: Request<pb::EstateOrderConsoleRequest>) -> R<pb::EstateOrderConsoleResponse> {
Err(Status::unimplemented("Console"))
}
async fn rebuild_main(&self, _: Request<pb::EstateRebuildMainRequest>) -> R<pb::EstateRebuildMainResponse> {
Err(Status::unimplemented("RebuildMain"))
}
async fn order_site(&self, _: Request<pb::EstateOrderSiteRequest>) -> R<pb::EstateOrderSiteResponse> {
Err(Status::unimplemented("OrderSite"))
}
async fn list_sites(&self, _: Request<pb::EstateSitesRequest>) -> R<pb::EstateSitesResponse> {
Err(Status::unimplemented("ListSites"))
}
async fn update_estate(&self, _: Request<pb::EstateUpdateRequest>) -> R<pb::EstateUpdateResponse> {
Err(Status::unimplemented("UpdateEstate"))
}
async fn update_status(&self, _: Request<pb::EstateUpdateStatusRequest>) -> R<pb::EstateUpdateResponse> {
Err(Status::unimplemented("UpdateStatus"))
}
async fn cancel_update(&self, _: Request<pb::EstateUpdateStatusRequest>) -> R<pb::EstateUpdateResponse> { Err(Status::unimplemented("cancel_update")) }
async fn set_live_update(&self, _: Request<pb::EstateSetLiveUpdateRequest>) -> R<pb::EstateUpdateResponse> {
Err(Status::unimplemented("SetLiveUpdate"))
}
async fn adopt_estate(&self, _: Request<pb::EstateAdoptRequest>) -> R<pb::EstateAdoptResponse> {
Err(Status::unimplemented("AdoptEstate"))
}
async fn list_code_red(&self, _: Request<pb::CodeRedListRequest>) -> R<pb::CodeRedList> {
Err(Status::unimplemented("ListCodeRed"))
}
async fn ack_code_red(&self, _: Request<pb::CodeRedAckRequest>) -> R<pb::CodeRedList> {
Err(Status::unimplemented("AckCodeRed"))
}
async fn raise_code_red(&self, _: Request<pb::CodeRed>) -> R<pb::CodeRedList> {
Err(Status::unimplemented("RaiseCodeRed"))
}
async fn clear_code_red(&self, _: Request<pb::CodeRedClearRequest>) -> R<pb::CodeRedList> {
Err(Status::unimplemented("ClearCodeRed"))
}
async fn narrate_log(&self, _: Request<pb::EstateNarrateLogRequest>) -> R<pb::EstateNarrateLogResponse> {
Err(Status::unimplemented("NarrateLog"))
}
async fn install_log(&self, _: Request<pb::EstateInstallLogRequest>) -> R<pb::EstateInstallLogResponse> {
Err(Status::unimplemented("InstallLog"))
}
}
#[tonic::async_trait]
impl pb::purchase_server::Purchase for FakeOneBox {
async fn bundles(&self, r: Request<pb::ListBundlesRequest>) -> R<pb::ListBundlesResponse> {
self.hear("Purchase.Bundles", &r)?;
let r = r.into_inner();
self.ticket_of("Purchase.Bundles", &r.actor_ticket)?;
let b = self.book();
let plan = b.accounts.get(&r.tenant).map(|a| if a.plan.is_empty() { "none" } else { "paid" }).unwrap_or("none");
Ok(Response::new(pb::ListBundlesResponse {
bundles: b.bundles.clone().unwrap_or_else(|| {
vec![
pb::Bundle { id: BUNDLE.into(), label: "10.0 GiB more room".into(), price: BUNDLE_WORDS.into(), period: "month".into(), delta: [("disk_bytes".to_string(), BUNDLE_BYTES)].into_iter().collect(), can_serve: true, ..Default::default() },
pb::Bundle { id: "huge".into(), label: "1 TiB more room".into(), can_serve: false, reason: "no room".into(), ..Default::default() },
]
}),
reading: false,
plan: Some(pb::PlanStatus { state: plan.into(), ..Default::default() }),
}))
}
async fn start(&self, r: Request<pb::StartPurchaseRequest>) -> R<pb::StartPurchaseResponse> {
self.hear("Purchase.Start", &r)?;
let r = r.into_inner();
self.ticket_of("Purchase.Start", &r.actor_ticket)?;
let mut b = self.book();
if r.bundle != BUNDLE {
return Err(Status::invalid_argument("that is not something this account can order right now"));
}
if !r.caps.is_empty() {
return Err(Status::invalid_argument("the caps ride in the ticket, never beside a bundle"));
}
let n = b.purchases.len() + 1;
let reference = format!("p-{n}");
let what = What::Bundle { tenant: r.tenant.clone(), bundle: r.bundle.clone() };
let c = self.mint(&mut b, what, BUNDLE_MINOR, false, "", "", reference.clone());
let i = b.checkouts.len() - 1;
b.purchases.insert(reference.clone(), i);
Ok(Response::new(pb::StartPurchaseResponse { reference, state: pb::PurchaseState::Pending as i32, redirect_url: c.url(), ..Default::default() }))
}
async fn status(&self, r: Request<pb::PurchaseStatusRequest>) -> R<pb::PurchaseStatusResponse> {
self.hear("Purchase.Status", &r)?;
let r = r.into_inner();
self.ticket_of("Purchase.Status", &r.actor_ticket)?;
let b = self.book();
let Some(&i) = b.purchases.get(&r.reference) else { return Err(Status::not_found("no such purchase")) };
let c = &b.checkouts[i];
if !matches!(&c.what, What::Bundle { tenant, .. } if *tenant == r.tenant) {
return Err(Status::not_found("no such purchase"));
}
if b.cancelled.contains(&r.reference) {
return Ok(Response::new(pb::PurchaseStatusResponse { reference: r.reference, state: pb::PurchaseState::Released as i32, phase: pb::PurchasePhase::Cancelled as i32, ..Default::default() }));
}
if !c.paid {
return Ok(Response::new(pb::PurchaseStatusResponse { reference: r.reference, state: pb::PurchaseState::Pending as i32, phase: pb::PurchasePhase::WaitingForPayment as i32, redirect_url: c.url(), cancellable: true, ..Default::default() }));
}
let caps = b.accounts.get(&r.tenant).map(|a| a.caps.clone()).unwrap_or_default();
Ok(Response::new(pb::PurchaseStatusResponse {
reference: r.reference,
state: pb::PurchaseState::Paid as i32,
phase: pb::PurchasePhase::Done as i32,
fact: Some(pb::EntitlementFact { tenant: r.tenant, caps: caps.into_iter().collect(), ..Default::default() }),
..Default::default()
}))
}
async fn quote(&self, _: Request<pb::QuoteRequest>) -> R<pb::QuoteResponse> {
Err(Status::unimplemented("Quote"))
}
async fn abandon(&self, _: Request<pb::AbandonPurchaseRequest>) -> R<pb::AbandonPurchaseResponse> {
Err(Status::unimplemented("Abandon"))
}
/// "Cancel order": the buyer closes their own unpaid purchase; asked again it is already
/// closed; a paid one is not cancelled here.
async fn cancel(&self, r: Request<pb::CancelPurchaseRequest>) -> R<pb::CancelPurchaseResponse> {
self.hear("Purchase.Cancel", &r)?;
let r = r.into_inner();
self.ticket_of("Purchase.Cancel", &r.actor_ticket)?;
let mut b = self.book();
let Some(&i) = b.purchases.get(&r.reference) else { return Err(Status::not_found("no such purchase")) };
let c = b.checkouts[i].clone();
if !matches!(&c.what, What::Bundle { tenant, .. } if *tenant == r.tenant) {
return Err(Status::not_found("no such purchase"));
}
if c.paid {
return Err(Status::failed_precondition("this purchase is paid: it is not cancelled here"));
}
let already_closed = !b.cancelled.insert(r.reference.clone());
Ok(Response::new(pb::CancelPurchaseResponse { reference: r.reference, already_closed, state: pb::PurchaseState::Released as i32, phase: pb::PurchasePhase::Cancelled as i32, reason: r.reason, ..Default::default() }))
}
async fn history(&self, _: Request<pb::PurchaseHistoryRequest>) -> R<pb::PurchaseHistoryResponse> {
Err(Status::unimplemented("History"))
}
}
/// `Products.List`: the one product this box sells, and whether it verifies its tenant tickets.
#[tonic::async_trait]
impl pb::products_server::Products for FakeOneBox {
async fn list(&self, r: Request<pb::ListProductsRequest>) -> R<pb::ListProductsResponse> {
self.hear("Products.List", &r)?;
let tenant_renewal = !self.book().checks_no_tickets;
Ok(Response::new(pb::ListProductsResponse { products: vec![pb::ProductInfo { id: self.product.clone(), tenant_renewal, ..Default::default() }], ..Default::default() }))
}
}
#[tonic::async_trait]
impl pb::entitlement_server::Entitlement for FakeOneBox {
async fn get(&self, r: Request<pb::GetEntitlementRequest>) -> R<pb::GetEntitlementResponse> {
self.hear("Entitlement.Get", &r)?;
let r = r.into_inner();
let b = self.book();
let fact = b.accounts.get(&r.tenant).map(|a| pb::EntitlementFact { tenant: r.tenant.clone(), caps: a.caps.clone().into_iter().collect(), ..Default::default() });
Ok(Response::new(pb::GetEntitlementResponse { fact, pending_push: None }))
}
async fn list(&self, _: Request<pb::ListEntitlementsRequest>) -> R<pb::ListEntitlementsResponse> {
Err(Status::unimplemented("List"))
}
async fn set(&self, _: Request<pb::SetEntitlementRequest>) -> R<pb::SetEntitlementResponse> {
Err(Status::unimplemented("Set"))
}
async fn public_key(&self, _: Request<pb::PublicKeyRequest>) -> R<pb::PublicKeyResponse> {
Err(Status::unimplemented("PublicKey"))
}
async fn held(&self, _: Request<pb::HeldRequest>) -> R<pb::HeldResponse> {
Err(Status::unimplemented("Held"))
}
}
/// **The caps `tenant` holds at the fake**, asked over the wire (`Entitlement.Get`), the way a
/// site that reads its held caps at the ONE box asks.
pub async fn held_caps_at(url: &str, bearer: &str, product: &str, tenant: &str) -> Result<BTreeMap<String, u64>, Status> {
let channel = tonic::transport::Channel::from_shared(url.to_owned()).map_err(|e| Status::internal(e.to_string()))?.connect().await.map_err(|e| Status::unavailable(e.to_string()))?;
let bearer: tonic::metadata::MetadataValue<tonic::metadata::Ascii> = format!("Bearer {bearer}").parse().map_err(|_| Status::internal("bearer"))?;
let mut client = pb::entitlement_client::EntitlementClient::with_interceptor(channel, move |mut r: Request<()>| {
r.metadata_mut().insert("authorization", bearer.clone());
Ok(r)
});
let fact = client.get(pb::GetEntitlementRequest { product: product.to_owned(), tenant: tenant.to_owned() }).await?.into_inner().fact;
Ok(fact.map(|f| f.caps.into_iter().collect()).unwrap_or_default())
}
// ── two product sites, for a relay over any ONE box (this fake, or a real monetize) ──────
/// **The shape of a product site**: Gunnar's console (its session cookie, its pages) or
/// Holger's front (the person's bearer, its pages). Only the provider differs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Shape {
Gunnar,
Holger,
}
impl Shape {
pub fn product(self) -> &'static str {
match self {
Shape::Gunnar => "gunnar",
Shape::Holger => "holger",
}
}
pub fn word(self) -> &'static str {
match self {
Shape::Gunnar => "Gunnar",
Shape::Holger => "Holger",
}
}
pub fn origin(self) -> &'static str {
match self {
Shape::Gunnar => "https://gunnar.mock.invalid",
Shape::Holger => "https://holger.mock.invalid",
}
}
pub fn host(self) -> &'static str {
self.origin().trim_start_matches("https://")
}
/// The site's pages: gunnar-ui-server `auth::routes::SIGNUP_ACCOUNT` / `SIGNUP_PRIVATE`,
/// holger-estate-front `HolgerSite::pages`.
pub fn pages(self) -> crate::Pages {
match self {
Shape::Gunnar => crate::Pages { account: "/signup?plan=10gb".into(), private: "/signup?plan=private".into(), manage: "/signup?plan=private&renew=".into() },
Shape::Holger => crate::Pages { manage: "/private?renew=".into(), ..crate::Pages::default() },
}
}
/// The session header a signed-in request carries.
pub fn session(self, user: &str) -> (&'static str, String) {
match self {
Shape::Gunnar => ("cookie", format!("gunnar_session={user}")),
Shape::Holger => ("authorization", format!("Bearer {user}")),
}
}
}
/// How a test site vouches for its buyer: `(buyer, purpose, caps) → ticket`.
pub type Minter = Arc<dyn Fn(&crate::Buyer, &str, &BTreeMap<String, u64>) -> Vec<u8> + Send + Sync>;
/// Whether the product holds the account `user` yet.
pub type Holds = Arc<dyn Fn(&str) -> bool + Send + Sync>;
/// The caps the product says `tenant` holds (instead of asking `Entitlement.Get`).
pub type Held = Arc<dyn Fn(&str) -> BTreeMap<String, u64> + Send + Sync>;
/// **A test product site** of a [`Shape`]: who asks is the session header's name
/// (`member:<co>` a member of `co`, `machine` no person, `down` the product unreachable); its
/// tickets are [`TestSite::minted`]'s; its held caps are the ONE box's `Entitlement.Get`.
#[derive(Clone)]
pub struct TestSite {
pub shape: Shape,
/// Where `Entitlement.Get` is asked, with which bearer, for which product.
pub shop: (String, String, String),
/// Asks per door and client address (`None`: no budget).
pub budget: Option<u32>,
spent: Arc<Mutex<std::collections::HashMap<String, u32>>>,
/// Accounts that hold a passkey here already.
pub passkeys: Arc<Mutex<Vec<String>>>,
/// Every ticket the site minted: (purpose, account, caps).
#[allow(clippy::type_complexity)]
pub minted: Arc<Mutex<Vec<(String, String, BTreeMap<String, u64>)>>>,
mint: Option<Minter>,
holds: Holds,
held: Option<Held>,
}
impl TestSite {
/// The site over this fake ONE box (its tickets plain words, its accounts the fake's).
pub fn over(shape: Shape, fake: &FakeOneBox) -> TestSite {
let f = fake.clone();
TestSite::at(shape, &fake.url(), &fake.bearer, Arc::new(move |u: &str| f.book().accounts.contains_key(u)))
}
/// The site over the ONE box at `url` with `bearer`; `holds` answers whether the product
/// holds an account.
pub fn at(shape: Shape, url: &str, bearer: &str, holds: Holds) -> TestSite {
TestSite { shape, shop: (url.to_owned(), bearer.to_owned(), shape.product().to_owned()), budget: None, spent: Arc::default(), passkeys: Arc::default(), minted: Arc::default(), mint: None, holds, held: None }
}
pub fn with_budget(mut self, budget: Option<u32>) -> TestSite {
self.budget = budget;
self
}
/// Tickets minted by `mint` (a real ONE box verifies them).
pub fn with_minter(mut self, mint: Minter) -> TestSite {
self.mint = Some(mint);
self
}
/// Held caps from `held` (the product's own word) rather than the ONE box's.
pub fn with_held(mut self, held: Held) -> TestSite {
self.held = Some(held);
self
}
fn name_of(&self, headers: &axum::http::HeaderMap) -> Option<String> {
match self.shape {
Shape::Gunnar => headers.get("cookie").and_then(|v| v.to_str().ok()).and_then(|c| c.split(';').find_map(|p| p.trim().strip_prefix("gunnar_session=").map(str::to_owned))),
Shape::Holger => headers.get("authorization").and_then(|v| v.to_str().ok()).and_then(|a| a.strip_prefix("Bearer ").map(str::to_owned)),
}
}
}
impl crate::BuySite for TestSite {
fn origin(&self) -> String {
self.shape.origin().to_owned()
}
fn product_word(&self) -> String {
self.shape.word().to_owned()
}
fn pages(&self) -> crate::Pages {
self.shape.pages()
}
fn allowed(&self, door: &str, ip: &str, _key: &str) -> bool {
let Some(budget) = self.budget else { return true };
let mut spent = self.spent.lock().unwrap_or_else(|p| p.into_inner());
let n = spent.entry(format!("{door}|{ip}")).or_insert(0);
*n += 1;
*n <= budget
}
async fn who(&self, headers: &axum::http::HeaderMap) -> Result<crate::Who, crate::Refusal> {
use crate::{Buyer, Member, Who};
Ok(match self.name_of(headers).as_deref() {
None => Who::Visitor,
Some("machine") => Who::Machine,
Some("down") => return Err(crate::Refusal::new(axum::http::StatusCode::SERVICE_UNAVAILABLE, format!("{} cannot be reached right now: try again in a minute.", self.shape.word()))),
Some(m) if m.starts_with("member:") => {
let co = &m["member:".len()..];
Who::Person(Buyer { account: "ann".into(), member: Some(Member { account: co.into(), admin: "boss".into(), line: format!("You are a member of {co}.") }), ..Buyer::default() })
}
Some(a) => Who::Person(Buyer { account: a.into(), ..Buyer::default() }),
})
}
async fn ticket(&self, _: &axum::http::HeaderMap, buyer: &crate::Buyer, purpose: &str, caps: &BTreeMap<String, u64>) -> Result<Vec<u8>, crate::Refusal> {
self.minted.lock().unwrap_or_else(|p| p.into_inner()).push((purpose.to_owned(), buyer.account.clone(), caps.clone()));
Ok(match &self.mint {
Some(mint) => mint(buyer, purpose, caps),
None => format!("{}:{purpose}:{}:{}", self.shape.product(), buyer.account, serde_json::to_string(caps).unwrap_or_default()).into_bytes(),
})
}
async fn held_caps(&self, _: &axum::http::HeaderMap, buyer: &crate::Buyer) -> Result<BTreeMap<String, u64>, crate::Refusal> {
if let Some(held) = &self.held {
return Ok(held(buyer.tenant()));
}
let (url, bearer, product) = &self.shop;
held_caps_at(url, bearer, product, buyer.tenant()).await.map_err(|_| crate::Refusal::new(axum::http::StatusCode::SERVICE_UNAVAILABLE, "the room held cannot be read right now"))
}
async fn signup_account(&self, user: &str) -> Result<crate::Account, crate::Refusal> {
if !(self.holds)(user) {
return Err(crate::Refusal::new(axum::http::StatusCode::CONFLICT, "not here yet"));
}
Ok(if self.passkeys.lock().unwrap_or_else(|p| p.into_inner()).iter().any(|p| p == user) { crate::Account::HasPasskey } else { crate::Account::NoPasskey })
}
}