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