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

//! **A tenant renewing their own subscription, over the wire.**
//!
//! `monetize_embed::ticket` proves the ticket itself — what verifies, what is
//! refused, and why a refusal does not name a tenant back. This file proves the
//! thing no unit test on either side can: that `Purchase.Quote` and
//! `Purchase.Start` actually CONSULT the product about a ticket, on the renew
//! path and only there, **before** anything is priced, reserved, written or
//! dialled.
//!
//! The four shapes, and each one is a hole somebody would otherwise dig:
//!
//! | ticket | `renew` | what must happen |
//! |---|---|---|
//! | none | false | an order, exactly as before this path existed |
//! | none | true | the OPERATOR renewal, exactly as before |
//! | renew | true | the product decides, and a no is `permission_denied` |
//! | order | false | the product decides, **and the CAPS must match** |
//! | renew | false | `permission_denied` — a ticket authorises ONE verb |
//! | order | true | `permission_denied` — the other direction of the same rule |
//!
//! **The row that matters most is the one that is not in the table**: a genuine
//! ORDER ticket for the caps the customer agreed to, spent on a BIGGER order.
//! Nothing about that ticket is wrong — the appliance signed it, for the right
//! tenant, product and verb — and it must still be refused, or the appliance is
//! vouching for "this human may buy" and whatever holds the ticket picks the
//! amount. See [`an_order_ticket_cannot_place_a_bigger_order`].
//!
//! Driven over gRPC against [`MockProduct`], which verifies for real (it holds
//! an appliance key and `monetize_embed::verify_ticket`). The PLUGIN's own
//! verifier has its own tests in `products/gunnar/tests/actor_ticket.rs`; what
//! is under test here is the server's use of the seam.

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_ORDER, PURPOSE_RENEW};
use monetize::testing::{purchase, Answer, MockCloud, MockPay, MockProduct, NOW};
use monetize::transaction::Outcome;
use monetize_proto::products_client::ProductsClient;
use monetize_proto::purchase_client::PurchaseClient;
use monetize_proto::{self as pb, PurchaseState};
use monetize_server::registry::Registry;
use monetize_server::state::AppState;
use monetize_server::testing::TestServer;
use tonic::metadata::MetadataValue;
use tonic::{Code, Request};
use tonic::transport::Channel;

const BEARER: &str = "test-bearer-token-of-sufficient-length";

/// The appliance's key. The product trusts it; the tests mint with it.
fn appliance() -> SigningKey {
    SigningKey::from_bytes(&[11u8; 32])
}

/// Mint the way a real appliance does: build unsigned, sign the canonical
/// message, fill in the signature, serialise.
fn mint(key: &SigningKey, ticket: ActorTicket) -> Vec<u8> {
    let mut ticket = ticket;
    ticket.signature = base64_encode(&key.sign(&ticket_message(&ticket)).to_bytes());
    serde_json::to_vec(&ticket).expect("a ticket serialises")
}

/// A genuine, live `renew` ticket for `tenant`, from the appliance the product
/// trusts. `nonce` is the test's, so a test about replay can repeat one.
fn renew_ticket(tenant: &str, nonce: &str) -> Vec<u8> {
    mint(&appliance(), ActorTicket::unsigned("gunnar", "gunnar", tenant, PURPOSE_RENEW, nonce, NOW as i64, 60_000))
}

/// The caps an order NAMES: the full target, per meter, which is what the wire
/// carries and what the ticket must cover. `alice` holds 10 GiB from the rig's
/// first sale, so an order for 100 sells the 90 she lacks.
fn caps(gib: u64) -> std::collections::BTreeMap<String, u64> {
    [("pack_bytes".to_string(), gib << 30)].into()
}

/// A genuine, live `order` ticket for `tenant`, **covering exactly `for_caps`**
/// — the target the appliance was asked to vouch for.
fn order_ticket(tenant: &str, for_caps: std::collections::BTreeMap<String, u64>, nonce: &str) -> Vec<u8> {
    mint(
        &appliance(),
        ActorTicket::unsigned("gunnar", "gunnar", tenant, PURPOSE_ORDER, nonce, NOW as i64, 60_000).covering(for_caps),
    )
}

fn cloud_provisioned() -> MockCloud {
    let cloud = MockCloud::new();
    cloud.answer_all(Answer::Provisioned);
    cloud
}

struct Rig {
    state: Arc<AppState>,
    product: Arc<MockProduct>,
    server: TestServer,
    channel: Channel,
    /// What alice's first period runs out at, so a test can say the ledger did
    /// not move.
    paid_until: u64,
}

impl Rig {
    /// alice bought a period at [`NOW`] and the product trusts the appliance —
    /// a shop customer with a console that can fetch tickets.
    async fn up() -> Rig {
        Rig::up_with_key(Some(appliance().verifying_key())).await
    }

    /// The same, with the product's appliance key set or not. `None` is the
    /// deployment nobody configured: it must refuse every ticket, and it must
    /// not tell a console the route exists.
    async fn up_with_key(key: Option<monetize::embed::VerifyingKey>) -> Rig {
        let product = Arc::new(MockProduct::gunnar());
        // A32-13: the product's main mints tickets, the HOST checks them under the key its
        // registry holds, on the rig's fake clock (one clock: a ticket minted at NOW is live).
        // The host reads a product's facts ONCE, when it is registered (A32-13 step 3), so the
        // product says it mints tickets BEFORE the registry takes it; said after, the host
        // would never hear it and would refuse every ticket as one this product cannot verify.
        if key.is_some() {
            product.mints_tickets();
        }
        let mut reg = Registry::new().shared_product(product.clone()).sells("gunnar", MockPay::new()).cloud(cloud_provisioned());
        if let Some(k) = key {
            reg = reg.ticket_key("gunnar", &k.to_bytes().iter().map(|b| format!("{b:02x}")).collect::<String>());
        }
        let mut state = AppState::in_memory(reg);
        *state.terms.write().unwrap() = monetize_server::state::TermsStore::fixture();
        state.clock = Box::new(|| NOW);
        let state = Arc::new(state);
        // The first period, sold directly: this file is about the RENEWAL, and
        // an order over the wire to set it up would only add a second thing
        // that could fail.
        let pay = MockPay::new();
        let out = state.transaction(state.registry.products().next().unwrap(), monetize_pay::Vendor::of(&pay)).run(&purchase("alice")).expect("the first period must sell");
        assert!(matches!(out, Outcome::Paid { .. }), "{out:?}");
        let paid_until = state.ledger.entitlement("gunnar", "alice").unwrap().unwrap().paid_until_unix_ms.expect("a paid period");
        let server = TestServer::spawn(state.clone(), BEARER).await;
        let channel = Channel::from_shared(server.endpoint()).unwrap().connect().await.expect("connect");
        Rig { state, product, server, channel, paid_until }
    }

    fn authed<T>(&self, req: T) -> Request<T> {
        let mut r = Request::new(req);
        r.metadata_mut().insert("authorization", MetadataValue::try_from(format!("Bearer {BEARER}")).unwrap());
        r
    }

    /// `Purchase.Start` for a renewal of `tenant`, carrying `ticket` (empty =
    /// the operator path).
    async fn start_renewal(&self, tenant: &str, ticket: &[u8]) -> Result<pb::StartPurchaseResponse, tonic::Status> {
        PurchaseClient::new(self.channel.clone())
            .start(self.authed(pb::StartPurchaseRequest {
                product: "gunnar".into(),
                tenant: tenant.into(),
                caps: Default::default(),
                vendor: "mock".into(),
                payer: String::new(),
                starts_unix_ms: 0,
                market: 0,
                renew: true,
                actor_ticket: ticket.to_vec(),
                bundle: String::new(),
                users: None,
            }))
            .await
            .map(|r| r.into_inner())
    }

    /// An ORDER — new caps, new money — for the caps it NAMES, carrying
    /// `ticket` (empty = the operator path).
    async fn start_order(&self, tenant: &str, order_caps: std::collections::BTreeMap<String, u64>, ticket: &[u8]) -> Result<pb::StartPurchaseResponse, tonic::Status> {
        PurchaseClient::new(self.channel.clone())
            .start(self.authed(pb::StartPurchaseRequest {
                product: "gunnar".into(),
                tenant: tenant.into(),
                caps: order_caps,
                vendor: "mock".into(),
                payer: String::new(),
                starts_unix_ms: NOW,
                market: 0,
                renew: false,
                actor_ticket: ticket.to_vec(),
                bundle: String::new(),
                users: None,
            }))
            .await
            .map(|r| r.into_inner())
    }

    async fn quote_renewal(&self, tenant: &str, ticket: &[u8]) -> Result<pb::QuoteResponse, tonic::Status> {
        PurchaseClient::new(self.channel.clone())
            .quote(self.authed(pb::QuoteRequest {
                product: "gunnar".into(),
                tenant: tenant.into(),
                caps: Default::default(),
                market: 0,
                starts_unix_ms: 0,
                renew: true,
                actor_ticket: ticket.to_vec(),
                bundle: String::new(),
            }))
            .await
            .map(|r| r.into_inner())
    }

    async fn quote_order(&self, tenant: &str, order_caps: std::collections::BTreeMap<String, u64>, ticket: &[u8]) -> Result<pb::QuoteResponse, tonic::Status> {
        PurchaseClient::new(self.channel.clone())
            .quote(self.authed(pb::QuoteRequest {
                product: "gunnar".into(),
                tenant: tenant.into(),
                caps: order_caps,
                market: 0,
                starts_unix_ms: NOW,
                renew: false,
                actor_ticket: ticket.to_vec(),
                bundle: String::new(),
            }))
            .await
            .map(|r| r.into_inner())
    }

    fn steps(&self) -> u64 {
        self.state.ledger.step_count().expect("the ledger counts its rows")
    }

    fn paid_until_now(&self) -> Option<u64> {
        self.state.ledger.entitlement("gunnar", "alice").unwrap().unwrap().paid_until_unix_ms
    }

    /// **What the tenant is ALLOWED right now**, off the stored fact. This is the
    /// number a blank cheque would move, so every refusal test reads it before
    /// and after.
    fn caps_now(&self, tenant: &str) -> std::collections::BTreeMap<String, u64> {
        self.state.ledger.entitlement("gunnar", tenant).unwrap().map(|f| f.caps).unwrap_or_default()
    }
}

/// ★ **A TENANT RENEWS THEMSELVES.** The verb this whole path exists for: a
/// genuine ticket, for the tenant the order names, and the renewal goes through
/// exactly as an operator's would.
#[tokio::test]
async fn a_genuine_ticket_renews_its_own_tenant() {
    let rig = Rig::up().await;

    let out = rig.start_renewal("alice", &renew_ticket("alice", "n-1")).await.expect("alice's own ticket must renew alice");

    assert_eq!(out.state, PurchaseState::Paid as i32, "{}", out.reason);
    assert!(rig.paid_until_now().unwrap() > rig.paid_until, "the period moved");
    assert_eq!(rig.state.registry.tickets_checked("gunnar"), 1, "the ticket was checked exactly once");
    rig.server.stop().await;
}

/// ★ **THE ATTACK, END TO END.** A perfectly genuine ticket — the real
/// appliance signed it, for the bearer's own tenant — spent on somebody else's
/// renewal. Nothing about the signature is wrong. What refuses it is that the
/// server hands the product BOTH the ticket and the tenant from the order, so
/// the comparison cannot be forgotten by any call site.
#[tokio::test]
async fn a_genuine_ticket_for_one_tenant_cannot_renew_another() {
    let rig = Rig::up().await;
    // beta has a period of their own, so the refusal is about WHO is asking and
    // not about a tenant with nothing to renew.
    let out = rig.state.transaction(rig.state.registry.products().next().unwrap(), monetize_pay::Vendor::of(&MockPay::new())).run(&purchase("beta")).expect("beta's first period");
    assert!(matches!(out, Outcome::Paid { .. }));
    let before = rig.steps();

    let refused = rig.start_renewal("beta", &renew_ticket("alice", "n-2")).await.expect_err("alice's ticket must not renew beta");

    assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
    assert!(!refused.message().contains("alice"), "the refusal names the ticket's own tenant to whoever asked about another: {}", refused.message());
    assert_eq!(rig.steps(), before, "a refused ticket writes nothing");
    rig.server.stop().await;
}

/// ★ **NOTHING IS PRICED, RESERVED OR WRITTEN BEHIND A REFUSED TICKET.**
///
/// The refusal has to come before the transaction, not after it: a ticket
/// checked at the end would mean a stranger's request had already reserved
/// money at a vendor and appended rows that then had to be unwound.
#[tokio::test]
async fn a_refused_ticket_costs_nothing_and_leaves_the_ledger_alone() {
    let rig = Rig::up().await;
    let before = rig.steps();
    let paid_until = rig.paid_until_now();
    // Counted from HERE: the rig's own first sale pushed a fact and asked about
    // capacity, and a test that compared against zero would be asserting that
    // the setup never happened.
    let pushes = rig.product.push_attempts().len();
    let absorbs = rig.product.absorb_asked().len();

    // Signed by an appliance this product holds no key for: genuine-looking
    // bytes, a real signature, the wrong signer.
    let forged = mint(&SigningKey::from_bytes(&[99u8; 32]), ActorTicket::unsigned("gunnar", "gunnar", "alice", PURPOSE_RENEW, "n-3", NOW as i64, 60_000));
    let refused = rig.start_renewal("alice", &forged).await.expect_err("a ticket from another key must not renew");

    assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
    assert_eq!(rig.steps(), before, "not one ledger row");
    assert_eq!(rig.paid_until_now(), paid_until, "the period did not move");
    assert_eq!(rig.product.push_attempts().len(), pushes, "no fact was pushed to the product");
    assert_eq!(rig.product.absorb_asked().len(), absorbs, "the product was never asked whether it could serve anything");
    rig.server.stop().await;
}

/// ★ **THE OPERATOR PATH IS BYTE FOR BYTE WHAT IT WAS.** The regression guard
/// on everything this change did not mean to touch: no ticket, no question
/// asked of the product, and the renewal sells.
#[tokio::test]
async fn a_renewal_with_no_ticket_is_the_operator_path_unchanged() {
    let rig = Rig::up().await;

    let out = rig.start_renewal("alice", b"").await.expect("the operator renewal must still sell");

    assert_eq!(out.state, PurchaseState::Paid as i32, "{}", out.reason);
    assert!(rig.paid_until_now().unwrap() > rig.paid_until, "the period moved");
    assert_eq!(rig.state.registry.tickets_checked("gunnar"), 0, "an absent ticket is not a question: nothing must be checked at all");
    rig.server.stop().await;
}

/// ★ **A TENANT BUYS THEIR OWN ROOM.** The order half of the verb this path
/// exists for: a genuine ORDER ticket, for the tenant the order names and for
/// **exactly the caps it names**, and the order goes through as an operator's
/// would.
#[tokio::test]
async fn a_genuine_order_ticket_places_exactly_that_order() {
    let rig = Rig::up().await;
    assert_eq!(rig.caps_now("alice"), caps(10), "the rig's first sale is the 10 GiB this order raises");

    let out = rig
        .start_order("alice", caps(100), &order_ticket("alice", caps(100), "o-1"))
        .await
        .expect("alice's own order ticket, for the caps she asked for, must sell");

    assert_eq!(out.state, PurchaseState::Paid as i32, "{}", out.reason);
    assert_eq!(rig.caps_now("alice"), caps(100), "the order she paid for is the order she got");
    assert_eq!(rig.state.registry.tickets_checked("gunnar"), 1, "the ticket was checked exactly once");
    rig.server.stop().await;
}

/// ★ **THE BLANK CHEQUE, END TO END — the most important refusal on this path.**
///
/// The ticket is GENUINE: the real appliance signed it, for alice's own tenant,
/// for the right product, for the right verb, and it is live. Only the AMOUNT
/// differs — the request asks for ten times what the appliance vouched for.
///
/// Without the caps in the signed message and compared against the ORDER, the
/// appliance would be vouching for "this human may buy" and whatever holds the
/// ticket would choose how much. So: refused, nothing written, the caps not
/// moved, and the product never asked whether it could serve the bigger order.
///
/// **That last assertion is also how this test proves no vendor was dialled.**
/// The vendor is looked up in `Purchase.Start` only AFTER the quote, and the
/// quote is what asks the product `can_absorb`; a product that was never asked
/// is a quote that never ran, and a quote that never ran is a vendor that was
/// never found, let alone called.
#[tokio::test]
async fn an_order_ticket_cannot_place_a_bigger_order() {
    let rig = Rig::up().await;
    let before = rig.steps();
    let held = rig.caps_now("alice");
    let absorbs = rig.product.absorb_asked().len();
    let pushes = rig.product.push_attempts().len();

    // Signed for 100 GiB. Spent on 1000.
    let ticket = order_ticket("alice", caps(100), "o-2");
    let refused = rig.start_order("alice", caps(1000), &ticket).await.expect_err("a ticket for 100 must not buy 1000");

    assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
    assert_eq!(rig.steps(), before, "a refused ticket writes nothing");
    assert_eq!(rig.caps_now("alice"), held, "the caps did not move");
    assert_eq!(rig.product.absorb_asked().len(), absorbs, "the product was never asked whether it could serve the bigger order");
    assert_eq!(rig.product.push_attempts().len(), pushes, "no fact was pushed");

    // …and the quote is gated identically, so a console cannot even price the
    // bigger order it could not buy.
    let refused = rig
        .start_order("alice", caps(1000), &order_ticket("alice", caps(100), "o-3"))
        .await
        .expect_err("nor does a fresh ticket for 100 buy 1000");
    assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
    rig.server.stop().await;
}

/// ★ **ONE TICKET, ONE VERB — both directions, through the service.**
///
/// A renewal ticket may not place an order, and an order ticket may not renew.
/// They fail for different reasons inside `verify_ticket` (the purpose compare,
/// and then the caps compare), so a server that got one right could still wave
/// the other through. Both are `permission_denied`: the request is well formed
/// and the TICKET is what is refused.
#[tokio::test]
async fn a_renewal_ticket_cannot_order_and_an_order_ticket_cannot_renew() {
    let rig = Rig::up().await;
    let before = rig.steps();

    let refused = rig.start_order("alice", caps(100), &renew_ticket("alice", "n-4")).await.expect_err("a renewal ticket bought room");
    assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");

    let refused = rig.quote_order("alice", caps(100), &renew_ticket("alice", "n-4b")).await.expect_err("nor may it price room");
    assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");

    let refused = rig.start_renewal("alice", &order_ticket("alice", caps(100), "o-4")).await.expect_err("an order ticket renewed a period");
    assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");

    // A renewal names no caps on the wire, so the order ticket's caps are
    // compared against an empty map and refused there — which is the same rule,
    // reached by the other road.
    let refused = rig.quote_renewal("alice", &order_ticket("alice", caps(100), "o-5")).await.expect_err("nor may it price one");
    assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");

    assert_eq!(rig.steps(), before, "and not one of the four wrote a row");
    assert_eq!(rig.paid_until_now(), Some(rig.paid_until), "the period did not move");
    rig.server.stop().await;
}

/// **An order with no ticket is untouched** — the regression guard on the
/// operator path, which is what every caller that predates this field is. The
/// refusals above are about the TICKET and never about orders.
#[tokio::test]
async fn an_order_without_a_ticket_still_goes_through() {
    let rig = Rig::up().await;

    let out = rig.start_order("alice", caps(100), b"").await.expect("an ordinary order must still sell");

    assert_eq!(out.state, PurchaseState::Paid as i32, "{}", out.reason);
    assert_eq!(rig.caps_now("alice"), caps(100), "the operator's order landed");
    assert_eq!(rig.state.registry.tickets_checked("gunnar"), 0, "an absent ticket is not a question: nothing must be checked at all");
    rig.server.stop().await;
}

/// **A refused ORDER ticket leaves the ledger exactly as it found it**, the
/// same guarantee the renewal path has: the gate is before the idempotency
/// read, the quote, the vendor lookup and the transaction, so a refusal has
/// nothing to unwind.
#[tokio::test]
async fn a_refused_order_ticket_leaves_the_ledger_untouched() {
    let rig = Rig::up().await;
    let before = rig.steps();
    let held = rig.caps_now("alice");
    let absorbs = rig.product.absorb_asked().len();

    // The real appliance's ticket, for the right caps — spent on somebody else.
    let refused = rig
        .start_order("beta", caps(100), &order_ticket("alice", caps(100), "o-6"))
        .await
        .expect_err("alice's order ticket must not buy beta's room");

    assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
    assert!(!refused.message().contains("alice"), "the refusal names the ticket's own tenant to whoever asked about another: {}", refused.message());
    assert_eq!(rig.steps(), before, "not one ledger row");
    assert_eq!(rig.caps_now("alice"), held, "alice's caps did not move");
    assert!(rig.caps_now("beta").is_empty(), "and beta got nothing");
    assert_eq!(rig.product.absorb_asked().len(), absorbs, "the product was never asked anything");
    rig.server.stop().await;
}

/// ★ **A TICKET IS SPENT ONCE, WHICHEVER CALL SPENDS IT.**
///
/// The replay ring lives in the plugin and cannot tell a `Quote` from a
/// `Start`, so a ticket used to price a renewal is gone. That is deliberate and
/// it is what a console must be built for: one ticket per call.
#[tokio::test]
async fn the_same_ticket_cannot_be_spent_twice() {
    let rig = Rig::up().await;
    let ticket = renew_ticket("alice", "n-5");

    let quote = rig.quote_renewal("alice", &ticket).await.expect("the first spend prices the renewal");
    assert!(quote.price.expect("a price").minor > 0, "the fixture list is not free, or this test proves nothing");

    let refused = rig.start_renewal("alice", &ticket).await.expect_err("the same ticket must not buy as well as quote");
    assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");

    let refused = rig.quote_renewal("alice", &ticket).await.expect_err("nor quote twice");
    assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
    rig.server.stop().await;
}

/// **The quote is gated too, and for two reasons neither of which is money.** A
/// quote names a tenant's standing caps and their price, so an ungated one is a
/// read-only enumeration of the customer list; and a console that could quote
/// what it cannot buy shows a customer a price and then a wall.
#[tokio::test]
async fn a_quote_for_a_renewal_is_gated_by_the_same_ticket() {
    let rig = Rig::up().await;

    let refused = rig.quote_renewal("alice", b"not a ticket at all").await.expect_err("rubbish must not price a renewal");
    assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");

    let priced = rig.quote_renewal("alice", &renew_ticket("alice", "n-6")).await.expect("a genuine ticket prices it");
    assert!(priced.can_serve, "a renewal asks the product no capacity question");
    rig.server.stop().await;
}

/// ★ **A PRODUCT WITH NO APPLIANCE KEY REFUSES EVERY TICKET — AND SAYS SO
/// BEFORE A CONSOLE DRAWS THE ROUTE.**
///
/// `ProductInfo.tenant_renewal` is how gunnar-ui decides whether self-service
/// exists at all. False here is not a policy, it is the truth: nothing in this
/// deployment could tell a customer from a stranger.
#[tokio::test]
async fn a_product_with_no_appliance_key_offers_nothing_and_refuses_everything() {
    let rig = Rig::up_with_key(None).await;

    let listed = ProductsClient::new(rig.channel.clone()).list(rig.authed(pb::ListProductsRequest {})).await.expect("list").into_inner();
    let gunnar = listed.products.iter().find(|p| p.id == "gunnar").expect("the product is linked in");
    assert!(!gunnar.tenant_renewal, "a product that holds no key must not advertise self-service renewal");

    let refused = rig.start_renewal("alice", &renew_ticket("alice", "n-7")).await.expect_err("a ticket nothing can check is not a ticket");
    assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");

    // …and the operator still gets through, which is the point of the whole
    // "empty changes nothing" rule.
    let out = rig.start_renewal("alice", b"").await.expect("the operator path does not need a key");
    assert_eq!(out.state, PurchaseState::Paid as i32, "{}", out.reason);
    rig.server.stop().await;
}

/// The capability reaches the wire when the key IS configured — the other half
/// of the test above, so neither answer can be the only one this list can give.
#[tokio::test]
async fn a_product_that_holds_the_key_advertises_tenant_renewal() {
    let rig = Rig::up().await;

    let listed = ProductsClient::new(rig.channel.clone()).list(rig.authed(pb::ListProductsRequest {})).await.expect("list").into_inner();
    let gunnar = listed.products.iter().find(|p| p.id == "gunnar").expect("the product is linked in");

    assert!(gunnar.tenant_renewal, "a console must be told it may offer the route");
    rig.server.stop().await;
}