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

nordisk/monetize

crates/monetize-server/tests/console_signup_contract.rs

//! **The console's signup calls against ONE monetize selling one product for two SITES, each
//! site its own vendor INSTANCE** (`stripe/gunnar` for gunnar.rs, `stripe/gunnar-test2` for
//! test2.gunnar.rs) — the wire the gunnar console speaks, replayed against the real server.
//!
//! The console (gunnar-ui `auth::signup`, and the shared relay monetize-site-buy it speaks
//! through) names NO vendor — [`CONSOLE_VENDOR`], `""` (A32-15: the payment core is no vendor's
//! shape) — on `Signup.Start`, `Signup.Status` and `Signup.Rename`. It never knows the site's
//! instance: which account takes the money is monetize's decision (`[estates.<e>] vendor`), the
//! calling estate's own instance answers, and the console only learns the session id in its
//! return URL. (Until A32-15 it named the KIND, `"stripe"`; the server still maps a kind.)
//!
//! i54a run30/31 (2026-10-12): the console's `Signup.Start` with `vendor: "stripe"` met exact
//! equality against the routed instance `stripe/gunnar-test2`, found no vendor and answered
//! UNIMPLEMENTED — "An account is not sold here right now." on test2's signup page. A unit test
//! of the registry was added with the fix (monetize 7f9446fa); THIS file holds the console's
//! side of the contract over the wire, including the poll the welcome page makes after the
//! card: the webhook keeps the paid signup under the INSTANCE (`monetize_pay::instance` renames
//! a `PaidSignup`'s vendor), and the console asks naming no vendor.
//!
//! gunnar-ui's `tests/console_signup_contract.rs` reads [`CONSOLE_VENDOR`] out of THIS file
//! (its strong half, with a monetize checkout beside it) and holds its own console to it.

use std::sync::Arc;

use monetize_pay::signup::{PaidSignup, SignupCheckout, SignupOrder};
use monetize_pay::{CallbackEvent, ChargeHandle, ChargeRequest, ChargeStatus, Currency, Market, Money, PayError, RawCallback, Settlement};
use monetize_proto as pb;
use monetize_proto::signup_client::SignupClient;
use monetize_proto::signup_status_response::State;
use monetize_server::config::{EstateConfig, SignupPlan};
use monetize_server::registry::Registry;
use monetize_server::state::AppState;
use monetize_server::testing::TestServer;
use tonic::metadata::MetadataValue;
use tonic::transport::Channel;
use tonic::{Code, Request};

/// **THE CONTRACT**: the vendor name the gunnar console sends on every `Signup.*` call: none
/// (A32-15), never an instance (no `/`). gunnar-ui's contract test reads this line.
pub const CONSOLE_VENDOR: &str = "";

const SERVICE: &str = "test-bearer-token-of-sufficient-length";
const HOME: &str = "gunnar-rs";
const TEST2: &str = "test2-gunnar-rs";
const HOME_BEARER: &str = "estate-bearer-of-gunnar-rs-0123456789";
const TEST2_BEARER: &str = "estate-bearer-of-test2-0123456789abcd";
const PLAN: &str = "gunnar/10gb-1m";

/// One site's Stripe account: a vendor INSTANCE that mints its own checkout pages.
struct Site(&'static str);

#[allow(dead_code)]
impl Site {
    pub fn vendor(&self) -> &'static str {
        self.0
    }
    pub fn markets(&self) -> &[Market] {
        &[Market::Se]
    }
    pub fn reserve(&self, r: &ChargeRequest) -> Result<ChargeHandle, PayError> {
        Ok(ChargeHandle { vendor: self.0.into(), id: format!("cs_{}", r.reference.len()) })
    }
    pub fn draw(&self, _: &ChargeHandle) -> Result<ChargeStatus, PayError> {
        Err(PayError::Unavailable("not here".into()))
    }
    pub fn unlock(&self, _: &ChargeHandle) -> Result<ChargeStatus, PayError> {
        Ok(ChargeStatus::Released)
    }
    pub fn charge_status(&self, _: &ChargeHandle) -> Result<ChargeStatus, PayError> {
        Ok(ChargeStatus::Reserved)
    }
    pub fn parse_callback(&self, _: RawCallback<'_>) -> Result<CallbackEvent, PayError> {
        Err(PayError::Ignored(format!("seen by {}", self.0)))
    }
    pub fn give_back(&self, _: &ChargeHandle, _: Money) -> Result<ChargeStatus, PayError> {
        Err(PayError::Unavailable("not here".into()))
    }
    pub fn settled(&self, _: &ChargeHandle) -> Option<Settlement> {
        None
    }
    pub fn redirect(&self, _: &ChargeHandle) -> Option<String> {
        None
    }
    pub fn start_signup(&self, o: &SignupOrder) -> Result<Option<SignupCheckout>, PayError> {
        let session = format!("cs_{}_{}", self.0.replace('/', "_"), o.username);
        Ok(Some(SignupCheckout { url: format!("https://checkout.example/{}/{session}", self.0), session }))
    }
}
#[allow(unused_variables)]
impl monetize_pay::payment::Payment for Site {
    fn manifest(&self) -> monetize_pay::payment::PayManifest {
        monetize_pay::payment::PayManifest { vendor: self.vendor().to_string(), markets: self.markets().to_vec(), settings: Vec::new(), holds: true, test_payer: None }
    }
    fn page(&self, charge: &monetize_pay::payment::Charge<'_>, back: &monetize_pay::payment::Return) -> Result<monetize_pay::payment::Page, monetize_pay::PayError> {
        Ok(match charge {
            monetize_pay::payment::Charge::Order(req) => monetize_pay::payment::Page::Order { handle: self.reserve(req)?, url: None },
            monetize_pay::payment::Charge::Signup(o) => monetize_pay::payment::Page::Signup(self.start_signup(o)?),
            monetize_pay::payment::Charge::CardCheck(o) => monetize_pay::payment::Page::CardCheck(None),
            monetize_pay::payment::Charge::TestSignup(o) => monetize_pay::payment::Page::TestSignup(None),
        })
    }
    fn status(&self, of: &monetize_pay::payment::Of<'_>) -> Result<monetize_pay::payment::Standing, monetize_pay::PayError> {
        Ok(match of {
            monetize_pay::payment::Of::Charge(h) => monetize_pay::payment::Standing::Charge(self.charge_status(h)?),
            monetize_pay::payment::Of::Page(h) => monetize_pay::payment::Standing::Page(self.redirect(h)),
            monetize_pay::payment::Of::Signup { session } => monetize_pay::payment::Standing::Signup(None),
            monetize_pay::payment::Of::CardCheck { session } => monetize_pay::payment::Standing::CardCheck(None),
        })
    }
    fn capture(&self, h: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
        self.draw(h)
    }
    fn release(&self, h: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
        self.unlock(h)
    }
    fn refund(&self, h: &monetize_pay::ChargeHandle, amount: monetize_pay::Money) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
        self.give_back(h, amount)
    }
    fn callback(&self, raw: monetize_pay::RawCallback<'_>) -> Result<monetize_pay::CallbackEvent, monetize_pay::PayError> {
        self.parse_callback(raw)
    }
    fn settlement(&self, h: &monetize_pay::ChargeHandle) -> Option<monetize_pay::Settlement> {
        self.settled(h)
    }
    fn paid_since(&self, since_unix_ms: i64) -> Result<Vec<monetize_pay::reconcile::PaidAtVendor>, monetize_pay::PayError> {
        Err(monetize_pay::reconcile::cannot_be_asked())
    }
    fn livemode(&self) -> Option<bool> {
        None
    }
}

struct Rig {
    state: Arc<AppState>,
    _server: TestServer,
    channel: Channel,
}

impl Rig {
    async fn up() -> Rig {
        let site = |vendor: &str, site: &str| EstateConfig { product: "gunnar".into(), vendor: vendor.into(), owner: "ignalina".into(), site: site.into(), ..Default::default() };
        let reg = Registry::new()
            .vendor(Site("stripe/gunnar"))
            .vendor(Site("stripe/gunnar-test2"))
            .route("gunnar", "stripe/gunnar")
            .home("gunnar", HOME)
            .estate(HOME, site("stripe/gunnar", "gunnar.rs"))
            .estate(TEST2, site("stripe/gunnar-test2", "test2.gunnar.rs"));
        let mut state = AppState::in_memory(reg);
        state.signup_plans = [(PLAN.to_string(), SignupPlan { amount_minor: 10_000, currency: Currency::Sek, cap_bytes: 10_737_418_240, period_days: 30 })].into();
        state.fleet.know_bearer(&monetize_server::fleet::bearer_digest(HOME_BEARER), HOME);
        state.fleet.know_bearer(&monetize_server::fleet::bearer_digest(TEST2_BEARER), TEST2);
        let state = Arc::new(state);
        let server = TestServer::spawn(state.clone(), SERVICE).await;
        let channel = Channel::from_shared(server.endpoint()).unwrap().connect().await.expect("connect");
        Rig { state, _server: server, channel }
    }

    fn console(&self, bearer: &'static str) -> SignupClient<tonic::service::interceptor::InterceptedService<Channel, impl tonic::service::Interceptor>> {
        let b: MetadataValue<_> = format!("Bearer {bearer}").parse().unwrap();
        SignupClient::with_interceptor(self.channel.clone(), move |mut r: Request<()>| {
            r.metadata_mut().insert("authorization", b.clone());
            Ok(r)
        })
    }

    /// `Signup.Start` exactly as gunnar-ui's `auth::signup::start` builds it.
    async fn start(&self, bearer: &'static str, vendor: &str, user: &str, site: &str) -> Result<pb::StartSignupResponse, tonic::Status> {
        self.console(bearer)
            .start(pb::StartSignupRequest {
                username: user.into(),
                product: "gunnar".into(),
                plan: PLAN.into(),
                vendor: vendor.into(),
                success_url: format!("https://{site}/welcome?session={{session}}"),
                cancel_url: format!("https://{site}/signup"),
                email: format!("{user}@example.com"),
                market: 0,
            })
            .await
            .map(tonic::Response::into_inner)
    }

    /// What the vendor's webhook leaves behind: the paid signup kept under the INSTANCE that
    /// took the money (`monetize_pay::instance` renames `stripe` to its instance id).
    fn paid_at(&self, instance: &str, session: &str, user: &str) {
        let paid = PaidSignup {
            vendor: instance.into(),
            session: session.into(),
            reference: format!("pi_{user}"),
            email: format!("{user}@example.com"),
            username: user.into(),
            product: "gunnar".into(),
            plan: PLAN.into(),
            amount: Money { minor: 10_000, currency: Currency::Sek },
            tax: Money { minor: 0, currency: Currency::Sek },
            paid_at_unix_ms: 1_791_700_000_000,
            customer_country: None,
        };
        self.state.signups.record_pending(&paid, 1_791_700_000_000).expect("the webhook's row is kept");
    }
}

#[test]
fn the_console_names_no_vendor_never_an_instance() {
    assert!(CONSOLE_VENDOR.is_empty(), "A32-15: a site names no vendor; the shop routes it: {CONSOLE_VENDOR:?}");
}

/// ★ i54a run30/31: each site's console asks with the KIND and gets ITS OWN site's account.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_consoles_start_is_minted_by_the_calling_sites_own_instance() {
    let rig = Rig::up().await;
    let t2 = rig.start(TEST2_BEARER, CONSOLE_VENDOR, "nina", "test2.gunnar.rs").await.expect("test2's console sells an account (run30: UNIMPLEMENTED 'not sold here')");
    assert!(t2.url.starts_with("https://checkout.example/stripe/gunnar-test2/"), "test2's money goes to test2's account: {}", t2.url);
    let home = rig.start(HOME_BEARER, CONSOLE_VENDOR, "olle", "gunnar.rs").await.expect("gunnar.rs's console sells an account");
    assert!(home.url.starts_with("https://checkout.example/stripe/gunnar/"), "gunnar.rs's money goes to gunnar.rs's account: {}", home.url);
}

/// Another site's instance, or another kind, is never substituted for the calling site's.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn another_sites_instance_or_another_kind_is_never_substituted() {
    let rig = Rig::up().await;
    let other = rig.start(TEST2_BEARER, "stripe/gunnar", "nina", "test2.gunnar.rs").await.expect_err("test2 naming gunnar.rs's account");
    assert_eq!(other.code(), Code::Unimplemented, "{other:?}");
    let kind = rig.start(TEST2_BEARER, "klarna", "nina", "test2.gunnar.rs").await.expect_err("another kind");
    assert_eq!(kind.code(), Code::Unimplemented, "{kind:?}");
}

/// ★ The welcome page after the card: the console polls `Signup.Status` with the KIND and the
/// session from its return URL; the webhook kept the row under the INSTANCE. The customer must
/// read the signup's state, never UNKNOWN for ever.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_welcome_pages_poll_by_kind_finds_the_signup_the_instance_took() {
    let rig = Rig::up().await;
    let t2 = rig.start(TEST2_BEARER, CONSOLE_VENDOR, "nina", "test2.gunnar.rs").await.expect("minted");
    let before = rig.console(TEST2_BEARER).status(pb::SignupStatusRequest { vendor: CONSOLE_VENDOR.into(), session: t2.session.clone() }).await.expect("polled").into_inner();
    assert_eq!(before.state, State::Unknown as i32, "before the webhook nothing is known: {before:?}");
    rig.paid_at("stripe/gunnar-test2", &t2.session, "nina");
    let after = rig.console(TEST2_BEARER).status(pb::SignupStatusRequest { vendor: CONSOLE_VENDOR.into(), session: t2.session.clone() }).await.expect("polled").into_inner();
    assert_ne!(after.state, State::Unknown as i32, "the welcome page polls by kind and must find test2's paid signup: {after:?}");
    // The other site's console asking for test2's session learns nothing.
    let foreign = rig.console(HOME_BEARER).status(pb::SignupStatusRequest { vendor: CONSOLE_VENDOR.into(), session: t2.session.clone() }).await.expect("polled").into_inner();
    assert_eq!(foreign.state, State::Unknown as i32, "gunnar.rs's console reads nothing of test2's signup: {foreign:?}");
}

/// ★ The rename after the card (a name taken in between) is asked by KIND too.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_consoles_rename_by_kind_reaches_the_signup_the_instance_took() {
    let rig = Rig::up().await;
    let t2 = rig.start(TEST2_BEARER, CONSOLE_VENDOR, "nina", "test2.gunnar.rs").await.expect("minted");
    rig.paid_at("stripe/gunnar-test2", &t2.session, "nina");
    let renamed = rig.console(TEST2_BEARER).rename(pb::RenameSignupRequest { vendor: CONSOLE_VENDOR.into(), session: t2.session.clone(), username: "nina2".into() }).await;
    if let Err(e) = &renamed {
        assert_ne!(e.code(), Code::NotFound, "the rename by kind must reach test2's signup, not 'no signup here': {e:?}");
    }
}