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/p4b_doors.rs

//! ★ **A site mounts the doors it sells, and a site may hold the grow doors to checked tickets**
//! (A28 H19, lane P4b: Gunnar's console onto [`monetize_site_buy::router_of`]).
//!
//! Gunnar registers its doors in groups: the sign-up and private-instance doors only where it
//! sells, the account's reads wherever it has a monetize, and the doors that START an order
//! only where its buyers buy (never on a sold Gunnar). So the router mounts exactly the groups a
//! site names ([`Doors`]); [`router`](monetize_site_buy::router) is every group, as before.
//!
//! And Gunnar keeps its window, its grow, its status poll and its cancel behind the same
//! question as Prolong (`Products.List` `tenant_renewal`): on a shop that does not verify tenant
//! tickets, a proto3 shop drops the ticket and the order would run with no tenant check. A site
//! Every site is held to that since A32-9 (no knob: `checked_tickets_on_the_shop.rs`).
//! Addresses are `*.mock.invalid`.

use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};

use axum::http::{HeaderMap, StatusCode};
use monetize_proto as pb;
use monetize_site_buy::{Account, BuySite, Buyer, Door, Doors, Refusal, Relay, ShopLink, Who};
use tonic::{Request, Response, Status};

#[derive(Clone, Default)]
struct Shop {
    renewal: bool,
    asked: Arc<Mutex<Vec<&'static str>>>,
}

impl Shop {
    fn asked(&self) -> Vec<&'static str> {
        self.asked.lock().unwrap().clone()
    }
    fn ask(&self, what: &'static str) {
        self.asked.lock().unwrap().push(what);
    }
}

#[tonic::async_trait]
impl pb::products_server::Products for Shop {
    async fn list(
        &self,
        _: Request<pb::ListProductsRequest>,
    ) -> Result<Response<pb::ListProductsResponse>, Status> {
        Ok(Response::new(pb::ListProductsResponse {
            products: vec![pb::ProductInfo {
                id: "gunnar".into(),
                tenant_renewal: self.renewal,
                ..Default::default()
            }],
            ..Default::default()
        }))
    }
}

#[tonic::async_trait]
impl pb::purchase_server::Purchase for Shop {
    async fn bundles(
        &self,
        _: Request<pb::ListBundlesRequest>,
    ) -> Result<Response<pb::ListBundlesResponse>, Status> {
        self.ask("Purchase.Bundles");
        Ok(Response::new(pb::ListBundlesResponse::default()))
    }
    async fn start(
        &self,
        _: Request<pb::StartPurchaseRequest>,
    ) -> Result<Response<pb::StartPurchaseResponse>, Status> {
        self.ask("Purchase.Start");
        Err(Status::failed_precondition("nothing to start"))
    }
    async fn status(
        &self,
        _: Request<pb::PurchaseStatusRequest>,
    ) -> Result<Response<pb::PurchaseStatusResponse>, Status> {
        self.ask("Purchase.Status");
        Ok(Response::new(pb::PurchaseStatusResponse::default()))
    }
    async fn quote(
        &self,
        _: Request<pb::QuoteRequest>,
    ) -> Result<Response<pb::QuoteResponse>, Status> {
        self.ask("Purchase.Quote");
        Ok(Response::new(pb::QuoteResponse::default()))
    }
    async fn abandon(
        &self,
        _: Request<pb::AbandonPurchaseRequest>,
    ) -> Result<Response<pb::AbandonPurchaseResponse>, Status> {
        Err(Status::unimplemented("Abandon"))
    }
    async fn cancel(
        &self,
        _: Request<pb::CancelPurchaseRequest>,
    ) -> Result<Response<pb::CancelPurchaseResponse>, Status> {
        self.ask("Purchase.Cancel");
        Ok(Response::new(pb::CancelPurchaseResponse::default()))
    }
    async fn history(
        &self,
        _: Request<pb::PurchaseHistoryRequest>,
    ) -> Result<Response<pb::PurchaseHistoryResponse>, Status> {
        self.ask("Purchase.History");
        Ok(Response::new(pb::PurchaseHistoryResponse::default()))
    }
}

/// A Gunnar-shaped site: a person signed in.
struct Site;

impl BuySite for Site {
    fn origin(&self) -> String {
        "https://gunnar.mock.invalid".into()
    }
    fn product_word(&self) -> String {
        "Gunnar".into()
    }
    async fn who(&self, _: &HeaderMap) -> Result<Who, Refusal> {
        Ok(Who::Person(Buyer {
            account: "alice".into(),
            ..Default::default()
        }))
    }
    async fn ticket(
        &self,
        _: &HeaderMap,
        _: &Buyer,
        purpose: &str,
        _: &BTreeMap<String, u64>,
    ) -> Result<Vec<u8>, Refusal> {
        Ok(purpose.as_bytes().to_vec())
    }
    async fn held_caps(&self, _: &HeaderMap, _: &Buyer) -> Result<BTreeMap<String, u64>, Refusal> {
        Ok(BTreeMap::new())
    }
    async fn signup_account(&self, _: &str) -> Result<Account, Refusal> {
        Ok(Account::NoPasskey)
    }
}

async fn relay(renewal: bool) -> (Arc<Relay<Site>>, Shop) {
    let shop = Shop {
        renewal,
        ..Shop::default()
    };
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let at = listener.local_addr().unwrap();
    tokio::spawn(
        tonic::transport::Server::builder()
            .add_service(pb::products_server::ProductsServer::new(shop.clone()))
            .add_service(pb::purchase_server::PurchaseServer::new(shop.clone()))
            .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)),
    );
    let link = ShopLink::connect_lazy(
        &Door {
            url: format!("http://{at}"),
            ..Door::default()
        },
        "site-bearer-for-tests",
        "gunnar",
    )
    .unwrap();
    (Arc::new(Relay::new(Site, link)), shop)
}

async fn call(
    router: axum::Router,
    method: &str,
    uri: &str,
    body: &str,
) -> (StatusCode, serde_json::Value) {
    use tower::ServiceExt;
    let req = axum::http::Request::builder()
        .method(method)
        .uri(uri)
        .header("content-type", "application/json")
        .body(axum::body::Body::from(body.to_owned()))
        .unwrap();
    let res = router.oneshot(req).await.unwrap();
    let status = res.status();
    let bytes = axum::body::to_bytes(res.into_body(), 1 << 20)
        .await
        .unwrap();
    (
        status,
        serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null),
    )
}

/// Every door of a group, as `(method, path, body)`.
const SIGNUP: &[(&str, &str, &str)] = &[
    ("GET", "/auth/signup/ladder", ""),
    ("GET", "/auth/signup/name?u=x14", ""),
    ("POST", "/auth/signup/start", "{\"username\":\"x14\"}"),
    (
        "GET",
        "/auth/signup/status?session=cs_test_abcdefghijkl",
        "",
    ),
    (
        "POST",
        "/auth/signup/rename",
        "{\"session\":\"cs_test_abcdefghijkl\",\"username\":\"x15\"}",
    ),
    ("POST", "/auth/signup/sign-in-again", "{}"),
];
const ACCOUNT: &[(&str, &str, &str)] = &[
    ("GET", "/api/account/subscription", ""),
    (
        "GET",
        "/api/account/subscription/status?reference=gunnar/alice/1",
        "",
    ),
    (
        "POST",
        "/api/account/subscription/cancel",
        "{\"reference\":\"gunnar/alice/1\"}",
    ),
    ("GET", "/api/account/subscription/bundles", ""),
    ("GET", "/api/account/subscription/history", ""),
    ("GET", "/api/account/purchases", ""),
];
const ORDERS: &[(&str, &str, &str)] = &[
    (
        "POST",
        "/api/account/subscription/buy-bundle",
        "{\"bundle\":\"b1\"}",
    ),
    ("POST", "/api/account/subscription/renew", "{}"),
];

/// ★ A site that names only the private-instance doors answers nothing else: the sign-up, the
/// account's own and the order-starting paths are not there (axum's 404 with no body), while
/// the private doors are (the way back from the vendor, a 303, asks no shop).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_site_mounts_only_the_doors_it_names() {
    let (r, shop) = relay(true).await;
    let only_private = Doors {
        private: true,
        ..Doors::NONE
    };
    let router = monetize_site_buy::router_of(r.clone(), only_private);
    assert_eq!(
        call(router.clone(), "GET", "/paid/estate/acme", "").await.0,
        StatusCode::SEE_OTHER
    );
    for (m, uri, body) in
        SIGNUP
            .iter()
            .chain(ACCOUNT)
            .chain(ORDERS)
            .chain(&[("GET", "/auth/buy/offer", "")])
    {
        let (st, answer) = call(router.clone(), m, uri, body).await;
        assert_eq!(
            (st, answer),
            (StatusCode::NOT_FOUND, serde_json::Value::Null),
            "{m} {uri} is not this site's door"
        );
    }
    assert!(shop.asked().is_empty(), "no shop call: {:?}", shop.asked());

    // The account's reads without the orders: a sold site (it sells nothing to start).
    let reads = Doors {
        account: true,
        ..Doors::NONE
    };
    let router = monetize_site_buy::router_of(r.clone(), reads);
    for (m, uri, body) in ACCOUNT {
        assert_ne!(
            call(router.clone(), m, uri, body).await.0,
            StatusCode::NOT_FOUND,
            "{m} {uri} is mounted"
        );
    }
    for (m, uri, body) in ORDERS {
        assert_eq!(
            call(router.clone(), m, uri, body).await.0,
            StatusCode::NOT_FOUND,
            "{m} {uri} starts an order: not mounted"
        );
    }
    assert_eq!(
        call(router.clone(), "GET", "/paid/estate/acme", "").await.0,
        StatusCode::NOT_FOUND
    );

    // `router` is every group, as it always was.
    let all = monetize_site_buy::router(r);
    assert_eq!(
        call(all.clone(), "GET", "/paid/estate/acme", "").await.0,
        StatusCode::SEE_OTHER
    );
    for (m, uri, body) in ACCOUNT.iter().chain(ORDERS) {
        assert_ne!(
            call(all.clone(), m, uri, body).await.0,
            StatusCode::NOT_FOUND,
            "{m} {uri}"
        );
    }
}

/// ★ Every site holds its grow doors to checked tickets (A32-9): on a shop that does not
/// verify tenant tickets, the window, the grow, the status poll and the cancel answer 501 with
/// the renewal's sentence, and the purchase is never asked.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_grow_doors_wait_for_checked_tickets_where_the_site_asks() {
    let (r, shop) = relay(false).await;
    let router = monetize_site_buy::router(r);
    for (m, uri, body) in [
        ("GET", "/api/account/subscription/bundles", ""),
        (
            "POST",
            "/api/account/subscription/buy-bundle",
            "{\"bundle\":\"b1\"}",
        ),
        (
            "GET",
            "/api/account/subscription/status?reference=gunnar/alice/1",
            "",
        ),
        (
            "POST",
            "/api/account/subscription/cancel",
            "{\"reference\":\"gunnar/alice/1\"}",
        ),
    ] {
        let (st, answer) = call(router.clone(), m, uri, body).await;
        assert_eq!(st, StatusCode::NOT_IMPLEMENTED, "{uri}: {answer}");
        assert_eq!(
            answer["error"],
            monetize_site_buy::relay::RENEWAL_NOT_VERIFIED,
            "{uri}"
        );
    }
    assert!(
        shop.asked().is_empty(),
        "no purchase call: {:?}",
        shop.asked()
    );
}

/// A shop that verifies them: the same doors go on to the shop.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_shop_that_checks_tickets_is_asked() {
    let (r, shop) = relay(true).await;
    let router = monetize_site_buy::router(r);
    assert_eq!(
        call(
            router.clone(),
            "GET",
            "/api/account/subscription/bundles",
            ""
        )
        .await
        .0,
        StatusCode::OK
    );
    assert_eq!(
        call(
            router.clone(),
            "GET",
            "/api/account/subscription/status?reference=gunnar/alice/1",
            ""
        )
        .await
        .0,
        StatusCode::OK
    );
    assert_eq!(
        call(
            router,
            "POST",
            "/api/account/subscription/cancel",
            "{\"reference\":\"gunnar/alice/1\"}"
        )
        .await
        .0,
        StatusCode::OK
    );
    assert_eq!(
        shop.asked(),
        ["Purchase.Bundles", "Purchase.Status", "Purchase.Cancel"]
    );
}

/// ★ **A32-9: every site's grow doors wait for checked tickets** — there is no knob: a site that
/// says nothing (the old default `false`, Holger before decision 1) is held to them as well, so
/// on a shop that does not verify tenant tickets the window answers 501 and asks no purchase.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn every_site_s_grow_doors_wait_for_checked_tickets() {
    let (r, shop) = relay(false).await;
    let router = monetize_site_buy::router(r);
    let (st, answer) = call(router, "GET", "/api/account/subscription/bundles", "").await;
    assert_eq!(st, StatusCode::NOT_IMPLEMENTED, "{answer}");
    assert_eq!(
        answer["error"],
        monetize_site_buy::relay::RENEWAL_NOT_VERIFIED
    );
    assert!(
        shop.asked().is_empty(),
        "no purchase call: {:?}",
        shop.asked()
    );
}

/// A site's own router state (Gunnar's `AppState`).
#[derive(Clone)]
struct SiteState(&'static str);

/// ★ **The doors merge into a router that holds its own state**, before its layers and its
/// `with_state` (Gunnar's console): the site's own routes and its middleware stay its own, and
/// the doors answer beside them.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_doors_merge_into_a_site_router_with_a_state_of_its_own() {
    use axum::extract::State;
    let (r, _) = relay(true).await;
    let site: axum::Router<SiteState> = axum::Router::new().route(
        "/private",
        axum::routing::get(|State(s): State<SiteState>| async move { s.0 }),
    );
    let merged = site
        .merge(monetize_site_buy::router_of::<_, SiteState>(
            r,
            Doors {
                private: true,
                ..Doors::NONE
            },
        ))
        .with_state(SiteState("the site's own page"));
    let (st, _) = call(merged.clone(), "GET", "/paid/estate/acme", "").await;
    assert_eq!(st, StatusCode::SEE_OTHER, "the door answers");
    use tower::ServiceExt;
    let res = merged
        .oneshot(
            axum::http::Request::builder()
                .uri("/private")
                .body(axum::body::Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(res.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(res.into_body(), 1 << 10)
        .await
        .unwrap();
    assert_eq!(&bytes[..], b"the site's own page");
}

/// A member of an account (Gunnar's console's employee): buys and renews nothing.
struct MemberSite;

impl BuySite for MemberSite {
    fn origin(&self) -> String {
        "https://gunnar.mock.invalid".into()
    }
    fn product_word(&self) -> String {
        "Gunnar".into()
    }
    async fn who(&self, _: &HeaderMap) -> Result<Who, Refusal> {
        Ok(Who::Person(Buyer {
            account: "bob".into(),
            member: Some(monetize_site_buy::Member {
                account: "acme".into(),
                admin: "alice".into(),
                line: "You are a member of acme.".into(),
            }),
            ..Default::default()
        }))
    }
    async fn ticket(
        &self,
        _: &HeaderMap,
        _: &Buyer,
        purpose: &str,
        _: &BTreeMap<String, u64>,
    ) -> Result<Vec<u8>, Refusal> {
        Ok(purpose.as_bytes().to_vec())
    }
    async fn held_caps(&self, _: &HeaderMap, _: &Buyer) -> Result<BTreeMap<String, u64>, Refusal> {
        Ok(BTreeMap::new())
    }
    async fn signup_account(&self, _: &str) -> Result<Account, Refusal> {
        Ok(Account::NoPasskey)
    }
}

/// ★ **Who asks is told first** (Gunnar's `OwnSubscription`, which runs before a handler): a
/// member is refused as a member on every account door — the quote, Prolong, the history, the
/// window, its grow, the status poll and the cancel — before the shop is asked whether it
/// verifies tickets. And a shop with no `Products` service at all does not verify them: 501
/// with the renewal's sentence, never "the shop did not answer".
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_member_is_told_first_and_a_shop_without_products_verifies_nothing() {
    // A shop with Purchase only (no Products service).
    let shop = Shop::default();
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let at = listener.local_addr().unwrap();
    tokio::spawn(
        tonic::transport::Server::builder()
            .add_service(pb::purchase_server::PurchaseServer::new(shop.clone()))
            .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)),
    );
    let link = |product: &str| {
        ShopLink::connect_lazy(
            &Door {
                url: format!("http://{at}"),
                ..Door::default()
            },
            "site-bearer-for-tests",
            product,
        )
        .unwrap()
    };
    let doors = [
        ("GET", "/api/account/subscription", ""),
        ("POST", "/api/account/subscription/renew", "{}"),
        ("GET", "/api/account/subscription/history", ""),
        ("GET", "/api/account/subscription/bundles", ""),
        (
            "POST",
            "/api/account/subscription/buy-bundle",
            "{\"bundle\":\"b1\"}",
        ),
        (
            "GET",
            "/api/account/subscription/status?reference=gunnar/acme/1",
            "",
        ),
        (
            "POST",
            "/api/account/subscription/cancel",
            "{\"reference\":\"gunnar/acme/1\"}",
        ),
    ];

    let members = monetize_site_buy::router(Arc::new(Relay::new(MemberSite, link("gunnar"))));
    for (m, uri, body) in doors {
        let (st, answer) = call(members.clone(), m, uri, body).await;
        assert_eq!(st, StatusCode::FORBIDDEN, "{m} {uri}: {answer}");
        assert!(
            answer["error"]
                .as_str()
                .is_some_and(|w| w.starts_with("You are a member of acme.")),
            "{m} {uri}: {answer}"
        );
    }

    let persons = monetize_site_buy::router(Arc::new(Relay::new(Site, link("gunnar"))));
    for (m, uri, body) in doors {
        let (st, answer) = call(persons.clone(), m, uri, body).await;
        assert_eq!(st, StatusCode::NOT_IMPLEMENTED, "{m} {uri}: {answer}");
        assert_eq!(
            answer["error"],
            monetize_site_buy::relay::RENEWAL_NOT_VERIFIED,
            "{m} {uri}"
        );
    }
    assert!(
        shop.asked().is_empty(),
        "no purchase call: {:?}",
        shop.asked()
    );
}