Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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}"
    );
}