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

//! **ONE monetize, two SITES of one product, each its own Stripe and its own appliance**
//! (owner 2026-10-11, STINKY ST-3/ST-4): monetize.ignalina.dk sells gunnar for gunnar.rs
//! (`gunnar-rs`, `stripe/gunnar`) and test2.gunnar.rs (`test2-gunnar-rs`,
//! `stripe/gunnar-test2`). Each site's console calls with its OWN estate bearer and sends the
//! account's plain name; each site's main mints the account's actor ticket with its OWN key.
//!
//! What must hold, over the wire:
//!
//! * a ticket is checked under the CALLING estate's appliance key (`[estates.<e>]
//!   appliance_key`): test2's ticket passes from test2, gunnar.rs's ticket from gunnar.rs,
//!   and neither site's ticket passes from the other site;
//! * the plain tenant is the calling estate's account: booked as `@<estate>/<name>` away
//!   from the product's home, plain at home, and billed through THAT site's account;
//! * a tenant of another estate is refused from this one, never billed anywhere.

use std::sync::Arc;

use ed25519_dalek::{Signer as _, SigningKey};
use monetize::embed::signing::base64_encode;
use monetize::embed::ticket::{ticket_message, ActorTicket};
use monetize::embed::{PURPOSE_ESTATE, PURPOSE_ORDER};
use monetize::testing::{Answer, MockCloud, NOW};
use monetize_pay::{CallbackEvent, ChargeHandle, ChargeRequest, ChargeStatus, Market, Money, PayError, RawCallback, Settlement};
use monetize_proto::estate_order_client::EstateOrderClient;
use monetize_proto::purchase_client::PurchaseClient;
use monetize_proto as pb;
use monetize_server::config::EstateConfig;
use monetize_server::registry::Registry;
use monetize_server::state::AppState;
use monetize_server::testing::estate::FakeEstate;
use monetize_server::testing::products::TestProduct;
use monetize_server::testing::TestServer;
use tonic::metadata::MetadataValue;
use tonic::transport::Channel;
use tonic::{Code, Request};

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

/// A vendor instance that only says who it is (and opens a page).
struct Named(&'static str);

#[allow(dead_code)]
impl Named {
    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, _: &monetize_pay::signup::SignupOrder) -> Result<Option<monetize_pay::signup::SignupCheckout>, PayError> {
        Ok(None)
    }
}
#[allow(unused_variables)]
impl monetize_pay::payment::Payment for Named {
    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
    }
}

/// gunnar.rs's main's ticket key, and test2's: two appliances, two keys.
fn home_key() -> SigningKey {
    SigningKey::from_bytes(&[21u8; 32])
}
fn test2_key() -> SigningKey {
    SigningKey::from_bytes(&[22u8; 32])
}

fn hex(k: &SigningKey) -> String {
    k.verifying_key().to_bytes().iter().map(|b| format!("{b:02x}")).collect()
}

fn caps() -> std::collections::BTreeMap<String, u64> {
    [("pack_bytes".to_string(), 10u64 << 30)].into()
}

/// An `order` ticket the way a site's main mints it: for its account's PLAIN name.
fn order_ticket(by: &SigningKey, tenant: &str, nonce: &str) -> Vec<u8> {
    let mut t = ActorTicket::unsigned("gunnar", "gunnar", tenant, PURPOSE_ORDER, nonce, NOW as i64, 60_000).covering(caps());
    t.signature = base64_encode(&by.sign(&ticket_message(&t)).to_bytes());
    serde_json::to_vec(&t).unwrap()
}

/// An `estate` ticket (the account menu, private instances) for the account's plain name.
fn estate_ticket(by: &SigningKey, account: &str, nonce: &str) -> Vec<u8> {
    let mut t = ActorTicket::unsigned("gunnar", "gunnar", account, PURPOSE_ESTATE, nonce, NOW as i64, 60_000);
    t.signature = base64_encode(&by.sign(&ticket_message(&t)).to_bytes());
    serde_json::to_vec(&t).unwrap()
}

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

impl Rig {
    async fn up() -> Rig {
        let cloud = MockCloud::new();
        cloud.answer_all(Answer::Provisioned);
        let site = |vendor: &str, site: &str, key: &SigningKey| EstateConfig { product: "gunnar".into(), vendor: vendor.into(), owner: "ignalina".into(), site: site.into(), appliance_key: hex(key), ..Default::default() };
        let reg = Registry::new()
            .pulled_product(TestProduct::gunnar(), HOME)
            .vendor(Named("stripe/gunnar"))
            .vendor(Named("stripe/gunnar-test2"))
            .route("gunnar", "stripe/gunnar")
            .home("gunnar", HOME)
            .estate(HOME, site("stripe/gunnar", "gunnar.rs", &home_key()))
            .estate(TEST2, site("stripe/gunnar-test2", "test2.gunnar.rs", &test2_key()))
            .cloud(cloud);
        let mut state = AppState::in_memory(reg);
        *state.terms.write().unwrap() = monetize_server::state::TermsStore::fixture();
        state.clock = Box::new(|| NOW);
        state.fleet.know_bearer(&monetize_server::fleet::bearer_digest(HOME_BEARER), HOME);
        state.fleet.know_bearer(&monetize_server::fleet::bearer_digest(TEST2_BEARER), TEST2);
        // Each site's own main: room bought as iron, as the old double answered.
        for estate in [HOME, TEST2] {
            FakeEstate::declare(&state, "gunnar", estate).buys_iron(&["pack_bytes"]);
        }
        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 }
    }

    async fn start(&self, bearer: &str, tenant: &str, ticket: Vec<u8>) -> Result<pb::StartPurchaseResponse, tonic::Status> {
        let mut r = Request::new(pb::StartPurchaseRequest { product: "gunnar".into(), tenant: tenant.into(), caps: caps(), actor_ticket: ticket, ..Default::default() });
        r.metadata_mut().insert("authorization", MetadataValue::try_from(format!("Bearer {bearer}")).unwrap());
        PurchaseClient::new(self.channel.clone()).start(r).await.map(|r| r.into_inner())
    }

    async fn purchases(&self, bearer: &str, account: &str, ticket: Vec<u8>) -> Result<pb::EstateOrderPurchasesResponse, tonic::Status> {
        let mut r = Request::new(pb::EstateOrderPurchasesRequest { account: account.into(), actor_ticket: ticket });
        r.metadata_mut().insert("authorization", MetadataValue::try_from(format!("Bearer {bearer}")).unwrap());
        EstateOrderClient::new(self.channel.clone()).purchases(r).await.map(|r| r.into_inner())
    }
}

/// ★ ST-3 + ST-4: each site's account buys from its own console, under its own main's key,
/// booked on its own estate and billed through its own Stripe — never the other site's.
#[tokio::test]
async fn each_site_buys_under_its_own_key_on_its_own_estate_through_its_own_account() {
    let rig = Rig::up().await;

    let t2 = rig.start(TEST2_BEARER, "nina", order_ticket(&test2_key(), "nina", "n-t2-1")).await.expect("test2's account buys from test2 under test2's key");
    assert_eq!(t2.handle_vendor, "stripe/gunnar-test2", "test2 money goes to test2's own Stripe");
    assert!(t2.reference.contains("@test2-gunnar-rs/nina"), "booked on test2's estate, not the home's nina: {}", t2.reference);

    let home = rig.start(HOME_BEARER, "nina", order_ticket(&home_key(), "nina", "n-home-1")).await.expect("gunnar.rs's nina buys from gunnar.rs");
    assert_eq!(home.handle_vendor, "stripe/gunnar", "gunnar.rs money goes to gunnar.rs's Stripe");
    assert!(!home.reference.contains('@'), "the home's account stays plain: {}", home.reference);
    assert_ne!(t2.reference, home.reference, "two sites' nina are two accounts");
    assert!(rig.state.ledger.attempt(&t2.reference).unwrap().is_some());
}

/// ★ ST-3: a ticket is only good at the site whose main minted it.
#[tokio::test]
async fn a_ticket_from_one_sites_main_is_refused_from_the_other_site() {
    let rig = Rig::up().await;
    let e = rig.start(TEST2_BEARER, "nina", order_ticket(&home_key(), "nina", "x-1")).await.expect_err("gunnar.rs's key is not test2's");
    assert_eq!(e.code(), Code::PermissionDenied, "{e:?}");
    let e = rig.start(HOME_BEARER, "nina", order_ticket(&test2_key(), "nina", "x-2")).await.expect_err("test2's key is not gunnar.rs's");
    assert_eq!(e.code(), Code::PermissionDenied, "{e:?}");
    // Spent once, refused again (single use holds per estate key too).
    rig.start(TEST2_BEARER, "olle", order_ticket(&test2_key(), "olle", "once")).await.expect("first spend");
    let e = rig.start(TEST2_BEARER, "olle", order_ticket(&test2_key(), "olle", "once")).await.expect_err("a ticket is spent once");
    assert_eq!(e.code(), Code::PermissionDenied, "{e:?}");
}

/// ★ ST-4: a site cannot name another site's account.
#[tokio::test]
async fn a_tenant_of_another_estate_is_refused_and_billed_nowhere() {
    let rig = Rig::up().await;
    let e = rig.start(TEST2_BEARER, "@gunnar-rs/nina", order_ticket(&test2_key(), "nina", "o-1")).await.expect_err("not test2's account");
    assert_eq!(e.code(), Code::PermissionDenied, "{e:?}");
    let e = rig.start(HOME_BEARER, "@test2-gunnar-rs/nina", order_ticket(&home_key(), "nina", "o-2")).await.expect_err("not gunnar.rs's account");
    assert_eq!(e.code(), Code::PermissionDenied, "{e:?}");
    // Its own qualified name is its own account.
    let ok = rig.start(TEST2_BEARER, "@test2-gunnar-rs/pia", order_ticket(&test2_key(), "pia", "o-3")).await.expect("test2's own, spelt in full");
    assert_eq!(ok.handle_vendor, "stripe/gunnar-test2");
}

/// ★ ST-3: the account menu (`EstateOrder.Purchases`, private buy, renew, restore) proves the
/// account under the CALLING site's key — on the ONE box, with no `[products.gunnar]
/// appliance_key` and no `[estate_orders]` at all.
#[tokio::test]
async fn the_account_menu_proves_each_sites_account_under_that_sites_key() {
    let rig = Rig::up().await;
    rig.purchases(TEST2_BEARER, "nina", estate_ticket(&test2_key(), "nina", "m-1")).await.expect("test2's account, test2's key");
    rig.purchases(HOME_BEARER, "nina", estate_ticket(&home_key(), "nina", "m-2")).await.expect("gunnar.rs's account, gunnar.rs's key");
    let e = rig.purchases(TEST2_BEARER, "nina", estate_ticket(&home_key(), "nina", "m-3")).await.expect_err("the other site's key");
    assert_eq!(e.code(), Code::Unauthenticated, "{e:?}");
}