Files
keysat/licensing-service/tests/api.rs
T
Grant 519fa1a8e6 v0.2.0:14 — Entitlements catalog read fix + drag-and-drop tier ordering
Bug fix:
  Product entitlements catalog reads were silently dropping. Every
  SELECT against the products table was missing entitlements_catalog_json
  from the column list, so the PATCH handler wrote the catalog correctly
  but every subsequent read returned null. Admin UI edits appeared to
  vanish on save. Fix: added the column to all four product SELECTs
  in repo.rs (list_products, get_product_by_slug, get_product_by_id —
  one column list, replace_all). Added regression test
  product_entitlements_catalog_round_trips_through_list_endpoint that
  exercises the full PATCH → list round-trip the admin UI hits.

UX:
  Drag-and-drop reordering on the tier-card grid. Operator drags any
  tier card to a new position; on drop, parallel PATCH requests set
  tier_rank 1..N based on the new visual order. Archived tiers are
  excluded (their position in the ladder is moot). Edit-policy modal
  retains the tier_rank number field for the two cases drag-and-drop
  can't express (precise override + blank-to-remove-from-ladder).
  Cursor signals grab/grabbing on hover/drag; dragging card lifts +
  fades for visual feedback.

Copy:
  Policies-tab section headers now show just the product name
  ("Keysat") instead of redundant "Keysat — keysat". Entitlements-
  catalog row editor description placeholder shortened from
  "Description (shown on buy page tooltip)" to "Description (buyer
  tooltip)" so it fits the column; full hover hint kept on the
  input's title attribute.

Test count: 87.
2026-05-11 11:14:20 -05:00

3168 lines
105 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! API endpoint integration tests.
//!
//! Drives real HTTP requests through the daemon's `axum::Router` against
//! a real SQLite database (per-test tempfile, identical pool options to
//! `src/db/mod.rs::init`). Companion to `tests/migrations.rs`: that file
//! tested schema correctness; this one tests endpoint correctness.
//!
//! These tests bypass `main.rs`'s env-var bootstrap and skip background
//! workers (reconcile, webhook delivery, session reaper). They construct
//! `AppState` programmatically with deterministic values so the same
//! pool, signing key, and admin token are reachable from inside the test
//! body.
use anyhow::Result;
use axum::body::{to_bytes, Body};
use axum::http::{HeaderMap, Request, StatusCode};
use axum::response::Response;
use chrono::Utc;
use keysat::api::{self, AppState};
use keysat::config::Config;
use keysat::crypto::{self, LicensePayload};
use keysat::db::repo;
use keysat::license_self::Tier;
use keysat::payment::{
CreateInvoiceParams, CreatedInvoiceHandle, PaymentProvider, ProviderInvoiceStatus,
ProviderKind, ProviderWebhookEvent,
};
use serde_json::{json, Value};
use sqlx::sqlite::{
SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteSynchronous,
};
use std::any::Any;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tempfile::NamedTempFile;
use tokio::sync::RwLock;
use tower::ServiceExt;
use uuid::Uuid;
/// Deterministic admin token used by every test that exercises an admin
/// endpoint. ≥32 chars to satisfy `Config::from_env`'s validation rule
/// (we don't go through that path here, but matching the constraint
/// keeps fixtures realistic).
const TEST_ADMIN_KEY: &str = "test_admin_api_key_with_at_least_32_chars_present";
// ---------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------
/// Open a fresh pool against a throwaway tempfile, mirroring
/// `src/db/mod.rs::init` exactly. `NamedTempFile` is returned so the
/// caller keeps it alive for the test's lifetime — when it drops, the
/// OS reclaims the file.
async fn make_pool() -> (SqlitePool, NamedTempFile) {
let tmp = NamedTempFile::new().expect("create tempfile");
let url = format!("sqlite://{}", tmp.path().display());
let opts = SqliteConnectOptions::from_str(&url)
.expect("parse sqlite url")
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal)
.foreign_keys(true)
.busy_timeout(Duration::from_secs(5));
let pool = SqlitePoolOptions::new()
.max_connections(2)
.connect_with(opts)
.await
.expect("connect to sqlite");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("run migrations");
(pool, tmp)
}
/// Build a fully-populated `AppState` ready to serve requests. Skips
/// `main.rs`'s env-var bootstrap and never spawns background workers —
/// these tests only exercise the request/response handler chain.
///
/// - `payment` is `None`. Endpoints that require a payment provider
/// (e.g. `POST /v1/purchase`) will return 503; tests below don't drive
/// those paths.
/// - `self_tier = Tier::Unlicensed` inherits Creator-tier caps (5
/// products, 5 codes, etc.). Plenty for the small fixtures here.
async fn make_test_state() -> (AppState, NamedTempFile) {
let (pool, tmp) = make_pool().await;
let keypair = crypto::keys::load_or_generate(&pool)
.await
.expect("load_or_generate keypair");
let cfg = Config {
bind: "127.0.0.1:0".parse().unwrap(),
db_path: PathBuf::from(":memory:"),
admin_api_key: TEST_ADMIN_KEY.to_string(),
btcpay_url: "http://btcpay.test:23000".to_string(),
btcpay_browser_url: None,
btcpay_public_url: None,
btcpay_api_key: None,
btcpay_store_id: None,
btcpay_webhook_secret: None,
public_base_url: "http://keysat.test".to_string(),
operator_name: Some("Test Operator".into()),
};
let state = AppState {
db: pool,
keypair: Arc::new(keypair),
payment: Arc::new(RwLock::new(None)),
config: Arc::new(cfg),
self_tier: Arc::new(RwLock::new(Tier::Unlicensed {
reason: "test fixture".into(),
})),
rates: keysat::rates::RateCache::new(),
};
(state, tmp)
}
/// Issue one request through the router. Clones state per call (cheap;
/// the DB pool, Arc'd config and keypair are all `Clone`) so multiple
/// requests in a single test share the same backend.
async fn send(state: &AppState, req: Request<Body>) -> Response {
api::router(state.clone())
.oneshot(req)
.await
.expect("router::oneshot")
}
async fn body_json(resp: Response) -> Value {
let bytes = to_bytes(resp.into_body(), 1024 * 1024)
.await
.expect("read body");
serde_json::from_slice(&bytes).expect("response body should be JSON")
}
fn build_request(
method: &str,
uri: &str,
headers: &[(&str, &str)],
body: Option<Value>,
) -> Request<Body> {
let mut b = Request::builder().method(method).uri(uri);
for (k, v) in headers {
b = b.header(*k, *v);
}
let body = match body {
Some(v) => {
b = b.header("content-type", "application/json");
Body::from(serde_json::to_vec(&v).expect("serialize JSON body"))
}
None => Body::empty(),
};
b.body(body).expect("build request")
}
// ---------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------
/// Smoke test for the framework. If this passes, we know the
/// state-construction + router-dispatch + response-parsing pipeline
/// works; tests below can focus on real assertions.
#[tokio::test]
async fn health_endpoint_returns_200() {
let (state, _tmp) = make_test_state().await;
let req = build_request("GET", "/healthz", &[], None);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
/// Admin endpoints reject calls that lack a valid admin token. The
/// distinction between 401 (no/malformed header) and 403 (header present
/// but token doesn't match) matters — the SPA renders different UI for
/// each ("you're not logged in" vs "you don't have permission").
#[tokio::test]
async fn admin_endpoint_rejects_missing_or_wrong_auth() {
let (state, _tmp) = make_test_state().await;
let body = json!({"slug": "x", "name": "X", "price_sats": 100});
// No Authorization header → 401 unauthorized.
let req = build_request("POST", "/v1/admin/products", &[], Some(body.clone()));
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"missing auth header should be 401"
);
// Wrong token → 403 forbidden. (The constant-time compare in
// require_admin returns Forbidden, not Unauthorized, when a token
// is present but doesn't match.)
let req = build_request(
"POST",
"/v1/admin/products",
&[(
"authorization",
"Bearer wrong_token_xxxxxxxxxxxxxxxxxxxxxxxx",
)],
Some(body),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"wrong token should be 403"
);
}
/// The full happy path for an admin write: auth → handler → DB insert
/// → audit log → response. If a refactor ever breaks one of those
/// links, this fails loudly.
#[tokio::test]
async fn admin_creates_product_with_correct_token() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
let req = build_request(
"POST",
"/v1/admin/products",
&[("authorization", &auth)],
Some(json!({
"slug": "test-product",
"name": "Test Product",
"description": "for tests",
"price_sats": 10_000
})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::OK,
"expected 200; got {}",
resp.status()
);
let body = body_json(resp).await;
assert_eq!(body["slug"], "test-product");
assert_eq!(body["name"], "Test Product");
assert_eq!(body["price_sats"], 10_000);
let id = body["id"]
.as_str()
.expect("response body should contain product id")
.to_string();
// Row landed in DB.
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM products WHERE id = ?")
.bind(&id)
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(count, 1, "exactly one product row should exist");
// Audit row was written for the create.
let audit_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM audit_log WHERE action = 'product.create' AND target_id = ?",
)
.bind(&id)
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(audit_count, 1, "audit log should record one create");
}
/// `/v1/validate` always returns HTTP 200 (per the documented contract);
/// failures are surfaced via `ok: false` + a machine-readable `reason`.
/// Bogus input returns `bad_format` — the parser couldn't even decode
/// the base32 envelope. This exercises the rate-limit pre-check and
/// the early parse-fail path.
#[tokio::test]
async fn validate_rejects_unsigned_garbage() {
let (state, _tmp) = make_test_state().await;
let req = build_request(
"POST",
"/v1/validate",
&[],
Some(json!({"key": "not-a-real-license"})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["ok"], false);
assert_eq!(body["reason"], "bad_format");
}
/// End-to-end license validation:
/// - seed a product
/// - issue a license tied to it
/// - sign a matching `LicensePayload` with the daemon's actual key
/// - encode to the base32 wire format
/// - POST /v1/validate
/// - assert `ok: true` plus the populated metadata fields
///
/// This is the most complex of the first round — it ties together DB
/// writes, the crypto module, and the validate handler. If anything in
/// any of those layers regresses, this fails.
#[tokio::test]
async fn validate_accepts_well_formed_license() {
let (state, _tmp) = make_test_state().await;
// Seed a product directly via the repo (skip the admin endpoint —
// this test is about /v1/validate, not product creation).
let product = repo::create_product(
&state.db,
"validate-test",
"Validate Test",
"",
100,
&json!({}),
)
.await
.expect("create_product");
// Issue a license tied to that product. Perpetual, single-machine,
// no entitlements — the simplest valid license shape.
let license_id = Uuid::new_v4();
let issued_at = Utc::now();
repo::create_license(
&state.db,
&license_id.to_string(),
&product.id,
None, // invoice_id (manual issuance — no invoice)
&issued_at.to_rfc3339(),
&json!({}), // metadata
None, // policy_id
None, // expires_at — perpetual
0, // grace_seconds
1, // max_machines
&[], // entitlements
false, // is_trial
None, // buyer_email
None, // nostr_npub
)
.await
.expect("create_license");
// Build the matching signed payload. Must use the same product_id
// and license_id as the DB row, because validate() looks the row up
// by license_id and verifies product_id matches.
let product_uuid = Uuid::parse_str(&product.id).expect("product id is a uuid");
let payload = LicensePayload {
version: 2,
flags: 0,
product_id: product_uuid,
license_id,
issued_at: issued_at.timestamp(),
expires_at: 0,
fingerprint_hash: [0; 32],
entitlements: vec![],
};
let signature = crypto::sign_payload(&state.keypair.signing, &payload);
let key_string = crypto::encode_key(&payload, &signature);
let req = build_request(
"POST",
"/v1/validate",
&[],
Some(json!({"key": key_string})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(
body["ok"], true,
"validation rejected a known-good license: {body:?}"
);
assert_eq!(body["license_id"], license_id.to_string());
assert_eq!(body["product_id"], product.id);
assert_eq!(body["status"], "active");
}
// ---------------------------------------------------------------------
// MockPaymentProvider — exercises the purchase + webhook code paths
// without talking to a real BTCPay. Reports kind=Btcpay so the daemon's
// BTCPay-specific compat accessors keep working; produces deterministic
// invoice ids so tests can assert on them; bypasses HMAC verification
// in `validate_webhook` and instead parses the test-supplied JSON body.
// ---------------------------------------------------------------------
struct MockPaymentProvider {
next_invoice_id: AtomicU64,
}
impl MockPaymentProvider {
fn new() -> Self {
Self {
next_invoice_id: AtomicU64::new(1),
}
}
}
#[async_trait::async_trait]
impl PaymentProvider for MockPaymentProvider {
fn kind(&self) -> ProviderKind {
ProviderKind::Btcpay
}
async fn create_invoice(
&self,
_params: CreateInvoiceParams<'_>,
) -> Result<CreatedInvoiceHandle> {
let n = self.next_invoice_id.fetch_add(1, Ordering::SeqCst);
Ok(CreatedInvoiceHandle {
provider_invoice_id: format!("mock-inv-{n}"),
checkout_url: format!("http://mock-checkout.test/i/{n}"),
})
}
async fn get_invoice_status(
&self,
_provider_invoice_id: &str,
) -> Result<ProviderInvoiceStatus> {
// Reconcile loop isn't exercised by these tests; return a sane
// default in case it gets called transitively.
Ok(ProviderInvoiceStatus::Settled)
}
/// Test-friendly webhook validator. Production providers would
/// HMAC-verify the body; we instead parse the body as JSON of
/// shape `{"kind": "settled"|"expired"|"invalid"|"refunded"|<other>,
/// "provider_invoice_id": "..."}`. Tests construct their own
/// payloads with no signature ceremony.
fn validate_webhook(
&self,
_headers: &HeaderMap,
body: &[u8],
) -> Result<ProviderWebhookEvent> {
let v: Value = serde_json::from_slice(body)?;
let kind = v["kind"].as_str().unwrap_or("");
let id = v["provider_invoice_id"].as_str().unwrap_or("").to_string();
Ok(match kind {
"settled" => ProviderWebhookEvent::InvoiceSettled {
provider_invoice_id: id,
},
"expired" => ProviderWebhookEvent::InvoiceExpired {
provider_invoice_id: id,
},
"invalid" => ProviderWebhookEvent::InvoiceInvalid {
provider_invoice_id: id,
},
other => ProviderWebhookEvent::Other {
kind: other.to_string(),
provider_invoice_id: Some(id).filter(|s| !s.is_empty()),
},
})
}
fn as_any(&self) -> &dyn Any {
self
}
}
/// Build a state with a MockPaymentProvider already installed. Mirror of
/// `make_test_state` for tests that drive the purchase / webhook paths.
async fn make_test_state_with_mock_provider() -> (AppState, NamedTempFile) {
let (state, tmp) = make_test_state().await;
state
.set_payment_provider(Arc::new(MockPaymentProvider::new()))
.await;
(state, tmp)
}
// ---------------------------------------------------------------------
// Purchase + webhook tests
// ---------------------------------------------------------------------
/// The free-tier shortcut: when post-discount, post-policy-override
/// price is 0 sats, the daemon synthesizes a settled invoice locally,
/// issues a license inline, and returns the signed key in the response.
/// No payment provider involved — `payment` stays `None`. This test
/// verifies that fast path end-to-end.
#[tokio::test]
async fn free_purchase_issues_license_inline() {
let (state, _tmp) = make_test_state().await;
let now = Utc::now().to_rfc3339();
// Seed a product (price > 0) plus a "free" policy that overrides
// the price to 0 sats. This is the common shape: paid product with
// an optional free tier on the buy page.
let product = repo::create_product(
&state.db,
"free-test",
"Free Test",
"",
10_000,
&json!({}),
)
.await
.expect("create_product");
sqlx::query(
"INSERT INTO policies(id, product_id, name, slug, price_sats_override, \
max_machines, public, created_at, updated_at) \
VALUES('pol-free', ?, 'Free', 'free', 0, 1, 1, ?, ?)",
)
.bind(&product.id)
.bind(&now)
.bind(&now)
.execute(&state.db)
.await
.expect("insert free policy");
let req = build_request(
"POST",
"/v1/purchase",
&[],
Some(json!({
"product": "free-test",
"policy_slug": "free"
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(
body["amount_sats"], 0,
"free policy should produce zero-sat invoice"
);
assert!(
body["license_key"].is_string(),
"free purchase should return license inline: {body:?}"
);
assert_eq!(body["checkout_url"], "");
// License row exists in DB.
let licenses: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses")
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(licenses, 1, "exactly one license should be issued");
// The inline license_key validates round-trip via /v1/validate.
let key = body["license_key"].as_str().unwrap().to_string();
let req = build_request("POST", "/v1/validate", &[], Some(json!({"key": key})));
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let validation = body_json(resp).await;
assert_eq!(
validation["ok"], true,
"the inlined license_key must validate cleanly: {validation:?}"
);
}
/// Paid purchase end-to-end through the trait. v0.1.0:43 migrated
/// `purchase::start` off the legacy `state.btcpay_client()` compat
/// accessor onto the abstract `state.payment_provider()` trait
/// surface, which means a `MockPaymentProvider` can drive the path
/// without a real BTCPay roundtrip.
///
/// Verifies:
/// - the daemon delegates invoice creation to the provider
/// - the returned `provider_invoice_id` is stamped onto the local
/// invoice row's `btcpay_invoice_id` column
/// - the buyer-facing `checkout_url` is whatever the provider
/// returned (mock returns a deterministic stub URL; production
/// BtcpayProvider rewrites the host inside its impl)
/// - no license is issued at this stage (that's the webhook's job)
#[tokio::test]
async fn paid_purchase_creates_invoice_via_provider() {
let (state, _tmp) = make_test_state_with_mock_provider().await;
repo::create_product(
&state.db,
"paid-test",
"Paid Test",
"",
10_000,
&json!({}),
)
.await
.expect("create_product");
let req = build_request(
"POST",
"/v1/purchase",
&[],
Some(json!({"product": "paid-test"})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::OK,
"paid purchase should succeed against the mock provider"
);
let body = body_json(resp).await;
assert_eq!(body["amount_sats"], 10_000);
assert_eq!(body["btcpay_invoice_id"], "mock-inv-1");
assert!(
body["checkout_url"]
.as_str()
.map_or(false, |s| s.starts_with("http://mock-checkout.test/")),
"checkout_url should pass through from the provider: {body:?}"
);
assert!(
body["license_key"].is_null(),
"no license should be issued before the settle webhook fires"
);
// Pending invoice row exists with the provider's id stamped on it.
let invoice_status: String = sqlx::query_scalar(
"SELECT status FROM invoices WHERE btcpay_invoice_id = 'mock-inv-1'",
)
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(invoice_status, "pending");
// No license yet.
let licenses: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses")
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(licenses, 0);
}
/// The settle webhook: provider POSTs an InvoiceSettled event, daemon
/// flips the invoice status and issues a license. Re-POSTing the same
/// webhook (which providers DO retry, sometimes aggressively) must not
/// duplicate the license — idempotency is critical because a flaky
/// network or provider retries can deliver the same event multiple
/// times. This is the production-correctness invariant we most need to
/// hold.
#[tokio::test]
async fn webhook_settles_invoice_and_issues_license_idempotently() {
let (state, _tmp) = make_test_state_with_mock_provider().await;
// Seed a product + a pending invoice directly via the repo (the
// HTTP purchase endpoint still uses BTCPay-specific compat code —
// see the comment block above). The webhook handler itself is on
// the abstract `PaymentProvider` trait, which the mock satisfies,
// so we can drive it through the router.
let product = repo::create_product(
&state.db,
"webhook-test",
"Webhook Test",
"",
5_000,
&json!({}),
)
.await
.expect("create_product");
let internal_invoice_id = Uuid::new_v4().to_string();
let provider_invoice_id = "mock-inv-fixture".to_string();
repo::create_invoice(
&state.db,
&internal_invoice_id,
&provider_invoice_id,
&product.id,
5_000,
"http://mock-checkout.test/i/1",
None, // buyer_email
None, // buyer_note
None, // policy_id
)
.await
.expect("create_invoice");
// First webhook delivery: daemon flips invoice → settled, issues
// license.
let webhook_body = json!({
"kind": "settled",
"provider_invoice_id": provider_invoice_id,
});
let req = build_request(
"POST",
"/v1/btcpay/webhook",
&[("content-type", "application/json")],
Some(webhook_body.clone()),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::OK,
"settle webhook should ack 200"
);
// Verify state changes.
let status_after_first: String = sqlx::query_scalar(
"SELECT status FROM invoices WHERE btcpay_invoice_id = ?",
)
.bind(&provider_invoice_id)
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(status_after_first, "settled");
let licenses_after_first: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses")
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(
licenses_after_first, 1,
"first settle webhook should issue exactly one license"
);
// Re-deliver the same webhook. Daemon must NOT issue a second
// license — provider retries are routine and a duplicated license
// means duplicated revenue or duplicated revocation surface area.
let req = build_request(
"POST",
"/v1/btcpay/webhook",
&[("content-type", "application/json")],
Some(webhook_body),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::OK,
"redelivered webhook should also ack 200"
);
let licenses_after_second: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses")
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(
licenses_after_second, 1,
"redelivered settle webhook MUST NOT duplicate the license"
);
}
/// Tier caps: an Unlicensed (or Creator-tier) operator may create up
/// to `CREATOR_PRODUCT_CAP` products. The Nth+1 attempt returns 402
/// with `upgrade_url` populated so the admin SPA can render the
/// "Upgrade to Pro" CTA inline.
///
/// Then we swap the daemon's `self_tier` to a Licensed tier with the
/// `unlimited_products` entitlement (the same entitlement the master
/// Keysat issues to paying operators) and verify the same N+1 attempt
/// now succeeds. This is the dynamic-swap behavior that lets operators
/// activate a new license via the admin API and keep working without a
/// daemon restart.
#[tokio::test]
async fn tier_caps_block_at_creator_limit_and_unlock_after_upgrade() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
// Reach the cap. CREATOR_PRODUCT_CAP is 5; create exactly five.
for i in 0..5 {
let req = build_request(
"POST",
"/v1/admin/products",
&[("authorization", &auth)],
Some(json!({
"slug": format!("p{i}"),
"name": format!("Product {i}"),
"price_sats": 1_000,
})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::OK,
"product {i} should succeed (under cap)"
);
}
// Sixth product → 402 with upgrade_url.
let req = build_request(
"POST",
"/v1/admin/products",
&[("authorization", &auth)],
Some(json!({
"slug": "p-over-cap",
"name": "Over The Cap",
"price_sats": 1_000,
})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::PAYMENT_REQUIRED,
"6th product should be blocked by the Creator-tier cap"
);
let body = body_json(resp).await;
assert!(
body["upgrade_url"]
.as_str()
.map_or(false, |u| u.contains("/buy/keysat")),
"402 response should carry an upgrade_url pointing at the master Keysat: {body:?}"
);
// DB should still reflect exactly 5 products — the 6th must not
// have leaked through.
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM products")
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(count, 5);
// Swap self_tier to a Licensed tier with `unlimited_products`.
// Mirrors what `Activate Keysat license` does in the admin UI: the
// operator pastes their Keysat-licenses-Keysat key, the daemon
// verifies it against the master pubkey, and writes the parsed
// entitlements into self_tier under a write lock — no restart.
*state.self_tier.write().await = Tier::Licensed {
license_id: Uuid::new_v4(),
product_id: Uuid::new_v4(),
expires_at: 0,
entitlements: vec!["self_host".into(), "unlimited_products".into()],
};
// Now the same 6th product attempt succeeds.
let req = build_request(
"POST",
"/v1/admin/products",
&[("authorization", &auth)],
Some(json!({
"slug": "p-after-upgrade",
"name": "Pro Tier Now",
"price_sats": 1_000,
})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::OK,
"after the tier swap, the cap should no longer fire"
);
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM products")
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(count, 6, "the previously-blocked product should now exist");
}
/// Webhook DLQ (dead-letter queue) — list + retry round trip.
///
/// The delivery worker retries failed deliveries with exponential
/// backoff up to 10 attempts, then sets `next_attempt_at = NULL` and
/// walks away. Pre-this-feature, those rows were invisible to the
/// operator. Now `GET /v1/admin/webhook-deliveries?status=failed`
/// surfaces them and `POST /v1/admin/webhook-deliveries/:id/retry`
/// puts them back in the queue.
///
/// We seed a "dead-lettered" row directly via SQL — the worker isn't
/// spawned in tests, so we don't need to drive 10 real failures to
/// reach the dead state. This tests the admin surface, not the
/// worker.
#[tokio::test]
async fn webhook_dlq_lists_failed_and_retry_requeues() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
let now = Utc::now().to_rfc3339();
// Configure a webhook endpoint to own the deliveries.
let endpoint_id = "ep1";
sqlx::query(
"INSERT INTO webhook_endpoints(id, url, secret, event_types, active, \
description, created_at, updated_at) \
VALUES(?, 'https://operator.example/keysat-hook', \
'0123456789abcdef0123456789abcdef', '[\"*\"]', 1, '', ?, ?)",
)
.bind(endpoint_id)
.bind(&now)
.bind(&now)
.execute(&state.db)
.await
.unwrap();
// One delivery in each state: delivered (success), pending
// (in-queue), and failed (DLQ — what we mostly care about).
let mk = |id: &str, attempts: i64, next: Option<&str>, delivered: Option<&str>| {
let id = id.to_string();
let attempts = attempts;
let next = next.map(|s| s.to_string());
let delivered = delivered.map(|s| s.to_string());
let pool = state.db.clone();
let now = now.clone();
async move {
sqlx::query(
"INSERT INTO webhook_deliveries(id, endpoint_id, event_type, \
payload_json, attempt_count, next_attempt_at, delivered_at, created_at) \
VALUES(?, ?, 'license.issued', '{}', ?, ?, ?, ?)",
)
.bind(&id)
.bind(endpoint_id)
.bind(attempts)
.bind(next.as_deref())
.bind(delivered.as_deref())
.bind(&now)
.execute(&pool)
.await
.unwrap();
}
};
mk("d-delivered", 1, None, Some(&now)).await;
mk("d-pending", 2, Some(&now), None).await;
// The dead-lettered case: 10 attempts, next_attempt_at NULL, never delivered.
mk("d-failed", 10, None, None).await;
// List with status=failed should return ONLY the dead-lettered row.
let req = build_request(
"GET",
"/v1/admin/webhook-deliveries?status=failed",
&[("authorization", &auth)],
None,
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
let deliveries = body["deliveries"].as_array().expect("deliveries array");
assert_eq!(
deliveries.len(),
1,
"status=failed should return the one DLQ row, got {deliveries:?}"
);
assert_eq!(deliveries[0]["id"], "d-failed");
assert_eq!(deliveries[0]["attempt_count"], 10);
// Retry the dead-lettered delivery.
let req = build_request(
"POST",
"/v1/admin/webhook-deliveries/d-failed/retry",
&[("authorization", &auth)],
None,
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK, "retry should succeed");
let body = body_json(resp).await;
assert_eq!(
body["attempt_count"], 0,
"retry should reset attempt_count to 0"
);
assert!(
body["next_attempt_at"].is_string(),
"retry should set next_attempt_at: {body:?}"
);
// After retry: status=failed should be empty (the row left the
// DLQ); status=pending should now contain it.
let req = build_request(
"GET",
"/v1/admin/webhook-deliveries?status=failed",
&[("authorization", &auth)],
None,
);
let resp = send(&state, req).await;
let body = body_json(resp).await;
assert_eq!(
body["deliveries"].as_array().unwrap().len(),
0,
"after retry, the row should no longer be 'failed'"
);
let req = build_request(
"GET",
"/v1/admin/webhook-deliveries?status=pending",
&[("authorization", &auth)],
None,
);
let resp = send(&state, req).await;
let body = body_json(resp).await;
let pending = body["deliveries"].as_array().unwrap();
assert!(
pending.iter().any(|d| d["id"] == "d-failed"),
"after retry, the previously-failed row should appear in 'pending'"
);
// Audit log captured the retry.
let audit_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM audit_log WHERE action = 'webhook_delivery.retry' AND target_id = 'd-failed'",
)
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(audit_count, 1, "retry must write an audit log entry");
// Retry on a non-existent id is 404.
let req = build_request(
"POST",
"/v1/admin/webhook-deliveries/never-existed/retry",
&[("authorization", &auth)],
None,
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
// Bad status filter is 400 (a typo'd query string shouldn't
// silently succeed; that's a UI footgun).
let req = build_request(
"GET",
"/v1/admin/webhook-deliveries?status=garbage",
&[("authorization", &auth)],
None,
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
/// Buyer self-service recovery: re-derive a lost license key from
/// (invoice_id, buyer_email). The most-common buyer support ticket
/// turned into a self-service flow.
///
/// Verifies:
/// - matching pair → 200 with a license_key that validates
/// - wrong email → 404 with the generic error message (does not
/// leak whether the invoice id existed)
/// - missing invoice → 404
/// - unsettled invoice → 404 (no license to recover)
/// - audit log row written on success
#[tokio::test]
async fn recover_returns_license_key_for_matching_pair() {
let (state, _tmp) = make_test_state().await;
// Seed a product, a settled invoice, and an active license.
let product = repo::create_product(
&state.db,
"rec-test",
"Recover Test",
"",
5_000,
&json!({}),
)
.await
.expect("create_product");
let invoice_id = Uuid::new_v4().to_string();
repo::create_invoice(
&state.db,
&invoice_id,
"btcpay-rec-1",
&product.id,
5_000,
"http://x/",
Some("Buyer@Example.COM"), // mixed case to verify lowercasing
None,
None,
)
.await
.expect("create_invoice");
sqlx::query("UPDATE invoices SET status = 'settled' WHERE id = ?")
.bind(&invoice_id)
.execute(&state.db)
.await
.unwrap();
let license_id = Uuid::new_v4();
let now = Utc::now().to_rfc3339();
repo::create_license(
&state.db,
&license_id.to_string(),
&product.id,
Some(&invoice_id),
&now,
&json!({}),
None,
None,
0,
1,
&[],
false,
Some("buyer@example.com"),
None,
)
.await
.expect("create_license");
// Wrong email → 404 with generic error (does not reveal the
// invoice id exists).
let req = build_request(
"POST",
"/v1/recover",
&[("content-type", "application/json")],
Some(json!({
"invoice_id": invoice_id,
"email": "wrong@example.com",
})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"wrong email should 404"
);
// Bogus invoice id → same generic 404.
let req = build_request(
"POST",
"/v1/recover",
&[("content-type", "application/json")],
Some(json!({
"invoice_id": Uuid::new_v4().to_string(),
"email": "buyer@example.com",
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
// Matching pair (case-insensitive email) → 200 with a real
// license key.
let req = build_request(
"POST",
"/v1/recover",
&[("content-type", "application/json")],
Some(json!({
"invoice_id": invoice_id,
"email": "Buyer@Example.com", // different casing on purpose
})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::OK,
"matching pair should succeed"
);
let body = body_json(resp).await;
let license_key = body["license_key"]
.as_str()
.expect("license_key should be present in response")
.to_string();
assert_eq!(body["license_id"], license_id.to_string());
// The recovered key validates round-trip via /v1/validate.
let req = build_request(
"POST",
"/v1/validate",
&[("content-type", "application/json")],
Some(json!({"key": license_key})),
);
let resp = send(&state, req).await;
let validation = body_json(resp).await;
assert_eq!(
validation["ok"], true,
"recovered key must validate cleanly: {validation:?}"
);
// Audit log captured the recovery.
let audit_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM audit_log WHERE action = 'license.recovered'",
)
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(audit_count, 1, "recovery must write an audit row");
}
/// USD-priced paid purchase records the listed currency, value, and
/// exchange rate on the invoice row. Uses a manual rate pin so the
/// test is network-free and the conversion is exactly verifiable.
#[tokio::test]
async fn paid_purchase_in_usd_records_listed_currency_and_rate() {
let (state, _tmp) = make_test_state_with_mock_provider().await;
// Pin USD at $50,000 / BTC. $49.00 (4900 cents) → 9800 sats:
// sats = 4900 * 1_000_000 / 50000 = 98000... wait
// 4900 * 1_000_000 = 4_900_000_000
// 4_900_000_000 / 50_000 = 98_000
sqlx::query("INSERT INTO settings(key, value, updated_at) VALUES('manual_rate_pin_USD', '50000', ?)")
.bind(Utc::now().to_rfc3339())
.execute(&state.db)
.await
.unwrap();
// USD-priced product via the typed admin endpoint.
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
let req = build_request(
"POST",
"/v1/admin/products",
&[("authorization", &auth)],
Some(json!({
"slug": "usd-app",
"name": "USD App",
"price_currency": "USD",
"price_value": 4900,
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
// Initiate purchase. Should call create_invoice with the rate
// recorded.
let req = build_request(
"POST",
"/v1/purchase",
&[("content-type", "application/json")],
Some(json!({"product": "usd-app"})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(
body["amount_sats"], 98_000,
"$49.00 at $50k/BTC = 98,000 sats — got {body:?}"
);
// The invoice row carries the audit trail.
let row: (Option<String>, Option<i64>, Option<i64>, Option<String>, i64) = sqlx::query_as(
"SELECT listed_currency, listed_value, exchange_rate_centibps, \
exchange_rate_source, amount_sats FROM invoices WHERE btcpay_invoice_id = 'mock-inv-1'"
)
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(row.0.as_deref(), Some("USD"));
assert_eq!(row.1, Some(4900));
assert_eq!(row.2, Some(500_000_000), "rate × 10000: 50000 × 10000");
assert_eq!(row.3.as_deref(), Some("manual_pin"));
assert_eq!(row.4, 98_000);
}
/// Active-provider preference round-trip. Pins the contract that
/// `Activate <provider>` flips both the in-memory provider AND the
/// persisted preference so the next daemon boot picks the same one.
///
/// Simulates the operator's lifecycle:
/// 1. Configure both BTCPay and Zaprite (both rows in DB)
/// 2. Activate Zaprite → preference flag = "zaprite"
/// 3. Activate BTCPay → preference flag = "btcpay"
/// 4. Disconnect BTCPay → preference flag cleared (because it
/// pointed at the wiped config)
/// 5. Disconnect Zaprite while preference was already "btcpay"
/// → preference NOT cleared (stays at "btcpay" because it
/// was pointing at a different provider)
#[tokio::test]
async fn payment_provider_preference_round_trip() {
use keysat::payment::{self, ProviderKind};
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
// Zaprite activation requires the `zaprite_payments` entitlement
// (Pro tier and above). Pin the daemon's self-tier to a Licensed
// tier carrying that entitlement so the activate path doesn't
// 402. BTCPay is unconditional and works at every tier.
{
let mut guard = state.self_tier.write().await;
*guard = keysat::license_self::Tier::Licensed {
license_id: uuid::Uuid::new_v4(),
product_id: uuid::Uuid::new_v4(),
expires_at: 0,
entitlements: vec![
"unlimited_products".to_string(),
"unlimited_policies".to_string(),
"unlimited_codes".to_string(),
"recurring_billing".to_string(),
"zaprite_payments".to_string(),
],
};
}
// Pre-seed both configs as if the operator had run Connect on
// each at some point. We bypass the actual Connect endpoints
// because they call out to BTCPay / Zaprite to validate the
// credentials, which we don't want to do in unit tests.
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO btcpay_config(id, base_url, api_key, store_id, webhook_id, \
webhook_secret, connected_at) \
VALUES(1, 'http://btcpay.test', 'btcpay-key', 'store-1', 'wh-1', \
'0123456789abcdef', ?)",
)
.bind(&now)
.execute(&state.db)
.await
.unwrap();
sqlx::query(
"INSERT INTO zaprite_config(id, api_key, base_url, webhook_id, connected_at, updated_at) \
VALUES(1, 'zaprite-key', 'https://api.zaprite.test', NULL, ?, ?)",
)
.bind(&now)
.bind(&now)
.execute(&state.db)
.await
.unwrap();
// Step 1: no preference recorded yet.
let pref = payment::read_active_provider_preference(&state.db).await;
assert_eq!(pref, None);
// Step 2: GET status surfaces both as configured, no active yet.
let req = build_request(
"GET",
"/v1/admin/payment-provider/status",
&[("authorization", &auth)],
None,
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["btcpay_configured"], true);
assert_eq!(body["zaprite_configured"], true);
assert!(body["preferred"].is_null());
// Step 3: Activate Zaprite. The endpoint reads the saved
// zaprite_config to build the provider — the saved key
// 'zaprite-key' won't talk to a real API but the activate
// path doesn't ping; that's only on Connect.
let req = build_request(
"POST",
"/v1/admin/payment-provider/activate",
&[("authorization", &auth)],
Some(json!({"provider": "zaprite"})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::OK,
"activate zaprite should succeed when zaprite_config is present"
);
let pref = payment::read_active_provider_preference(&state.db).await;
assert_eq!(pref, Some(ProviderKind::Zaprite));
// Step 4: Activate BTCPay. Preference flips.
let req = build_request(
"POST",
"/v1/admin/payment-provider/activate",
&[("authorization", &auth)],
Some(json!({"provider": "btcpay"})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let pref = payment::read_active_provider_preference(&state.db).await;
assert_eq!(pref, Some(ProviderKind::Btcpay));
// Step 5: Activate something that's not configured. Should 400.
sqlx::query("DELETE FROM zaprite_config WHERE id = 1")
.execute(&state.db)
.await
.unwrap();
let req = build_request(
"POST",
"/v1/admin/payment-provider/activate",
&[("authorization", &auth)],
Some(json!({"provider": "zaprite"})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"activating an unconfigured provider must 400 with 'run Connect first'"
);
// Step 6: Bad provider name → 400.
let req = build_request(
"POST",
"/v1/admin/payment-provider/activate",
&[("authorization", &auth)],
Some(json!({"provider": "stripe"})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
// Step 7: write_active_provider_preference invariant —
// explicit setting survives a re-read (durability across the
// simulated restart that the boot-time loader cares about).
payment::write_active_provider_preference(&state.db, ProviderKind::Btcpay)
.await
.unwrap();
let pref = payment::read_active_provider_preference(&state.db).await;
assert_eq!(pref, Some(ProviderKind::Btcpay));
payment::write_active_provider_preference(&state.db, ProviderKind::Zaprite)
.await
.unwrap();
let pref = payment::read_active_provider_preference(&state.db).await;
assert_eq!(pref, Some(ProviderKind::Zaprite));
}
/// Zaprite webhook authentication contract.
///
/// Zaprite doesn't sign webhooks (verified May 2026 — no HMAC,
/// no JWT, no header-based signature). The defense Keysat uses is
/// the externalUniqId round-trip: we set our local invoice UUID
/// as the order's externalUniqId at creation, and the webhook
/// handler trusts the body only insofar as we can match the
/// Zaprite order id back to a local invoice we created.
///
/// This test pins the validate_webhook impl's parsing contract:
/// - extracts the order id from `data.id` (Zaprite's payload shape)
/// - maps event types to ProviderWebhookEvent variants
/// - rejects payloads missing an order id
#[tokio::test]
async fn zaprite_webhook_event_parsing() {
use keysat::payment::{
zaprite::{ZapriteClient, ZapriteProvider},
PaymentProvider, ProviderWebhookEvent,
};
// We don't talk to Zaprite for this test — just exercise the
// pure-parsing branch of validate_webhook. Construct a client
// with bogus credentials; never used here.
let provider = ZapriteProvider::new(ZapriteClient::new(
"https://api.zaprite.test",
"test-key-not-used",
));
let headers = axum::http::HeaderMap::new();
// order.paid → InvoiceSettled
let body = br#"{"event":"order.paid","data":{"id":"zap-order-1"}}"#;
let event = provider.validate_webhook(&headers, body).expect("parse");
match event {
ProviderWebhookEvent::InvoiceSettled { provider_invoice_id } => {
assert_eq!(provider_invoice_id, "zap-order-1");
}
other => panic!("expected InvoiceSettled, got {other:?}"),
}
// order.complete + order.overpaid → also Settled (operator gets paid)
for kind in &["order.complete", "order.overpaid"] {
let body = format!(r#"{{"event":"{kind}","data":{{"id":"x"}}}}"#);
let event = provider
.validate_webhook(&headers, body.as_bytes())
.expect("parse");
assert!(
matches!(event, ProviderWebhookEvent::InvoiceSettled { .. }),
"{kind} should map to Settled"
);
}
// order.expired → InvoiceExpired
let body = br#"{"event":"order.expired","data":{"id":"zap-order-2"}}"#;
let event = provider.validate_webhook(&headers, body).expect("parse");
assert!(matches!(
event,
ProviderWebhookEvent::InvoiceExpired { .. }
));
// order.refunded → InvoiceRefunded
let body = br#"{"event":"order.refunded","data":{"id":"zap-order-3"}}"#;
let event = provider.validate_webhook(&headers, body).expect("parse");
assert!(matches!(
event,
ProviderWebhookEvent::InvoiceRefunded { .. }
));
// Unknown event type → Other (forward-compat for new event
// kinds Zaprite ships in the future)
let body = br#"{"event":"order.partially_refunded","data":{"id":"zap-order-4"}}"#;
let event = provider.validate_webhook(&headers, body).expect("parse");
match event {
ProviderWebhookEvent::Other { kind, provider_invoice_id } => {
assert_eq!(kind, "order.partially_refunded");
assert_eq!(provider_invoice_id.as_deref(), Some("zap-order-4"));
}
other => panic!("expected Other, got {other:?}"),
}
// Missing order id → reject. An attacker can't trigger any
// local state change without telling us which order to act on.
let body = br#"{"event":"order.paid","data":{}}"#;
let result = provider.validate_webhook(&headers, body);
assert!(
result.is_err(),
"payload without order id must be rejected"
);
// Malformed JSON → reject.
let body = b"not json at all";
let result = provider.validate_webhook(&headers, body);
assert!(result.is_err());
}
/// Zaprite provider self-identifies as `ProviderKind::Zaprite`.
/// Trivial but pins the kind() return for the call sites that
/// switch on provider identity (e.g., audit log strings).
#[tokio::test]
async fn zaprite_provider_kind() {
use keysat::payment::{
zaprite::{ZapriteClient, ZapriteProvider},
PaymentProvider, ProviderKind,
};
let p = ZapriteProvider::new(ZapriteClient::new(
"https://api.zaprite.test",
"test-key",
));
assert_eq!(p.kind(), ProviderKind::Zaprite);
assert_eq!(p.kind().as_str(), "zaprite");
}
/// Rate fetcher: manual pin in settings table overrides the source
/// chain. Locks in the test-mode + maintenance-window contract that
/// other phases (invoice rate recording, buy-page rendering) rely on.
#[tokio::test]
async fn rate_cache_honors_manual_pin_from_settings() {
let (state, _tmp) = make_test_state().await;
// Pin USD at $65,000 / BTC. The fetcher MUST return this value
// without hitting any external API.
sqlx::query("INSERT INTO settings(key, value, updated_at) VALUES('manual_rate_pin_USD', '65000', ?)")
.bind(Utc::now().to_rfc3339())
.execute(&state.db)
.await
.unwrap();
let rate = keysat::rates::get_rate(&state, "USD")
.await
.expect("manual pin should resolve without network");
assert_eq!(rate.units_per_btc, 65000.0);
assert_eq!(rate.source, "manual_pin");
// Convert $49.00 (4900 cents) to sats. At $65k/BTC:
// sats = 4900 * 1_000_000 / 65000 = 75,384.6 → 75,385.
let conv = keysat::rates::convert_to_sats(&state, "USD", 4900)
.await
.expect("convert");
assert_eq!(conv.sats, 75_385, "rounding tie-break: 75384.615 rounds to 75385");
assert_eq!(
conv.rate_centibps,
Some(650_000_000),
"rate stored as units×10000: 65000 × 10000"
);
// SAT-currency conversions are identity (no rate involved).
let sat_conv = keysat::rates::convert_to_sats(&state, "SAT", 50_000)
.await
.unwrap();
assert_eq!(sat_conv.sats, 50_000);
assert!(sat_conv.rate_centibps.is_none());
}
/// Admin endpoint visibility: GET /v1/admin/rates returns whatever
/// is currently cached, including manual pins. Operators can verify
/// the daemon's current quote against external sources before
/// trusting fiat-priced invoice flows.
#[tokio::test]
async fn admin_rates_endpoint_reflects_manual_pin() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
sqlx::query("INSERT INTO settings(key, value, updated_at) VALUES('manual_rate_pin_USD', '60000', ?)")
.bind(Utc::now().to_rfc3339())
.execute(&state.db)
.await
.unwrap();
// Trigger a rate read so the cache populates.
let _ = keysat::rates::get_rate(&state, "USD").await.unwrap();
let req = build_request(
"GET",
"/v1/admin/rates",
&[("authorization", &auth)],
None,
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
let rates = body["rates"].as_array().expect("rates array");
let usd = rates
.iter()
.find(|r| r["currency"] == "USD")
.expect("USD entry should be present");
assert_eq!(usd["units_per_btc"], 60_000.0);
assert_eq!(usd["source"], "manual_pin");
}
/// Multi-currency product creation. The admin endpoint accepts both
/// the legacy SAT-only form (`price_sats: N`) and the new typed form
/// (`price_currency + price_value`). Verifies:
/// - legacy form still works, produces a SAT-currency row
/// - typed SAT form works, dual-writes price_sats correctly
/// - typed USD form works, leaves price_sats=0 (filled at invoice time)
/// - unknown currency code → 400
/// - inconsistent legacy + typed values → 400 (catches half-migrated clients)
/// - typed without value → 400; value without currency → 400
#[tokio::test]
async fn admin_create_product_accepts_legacy_and_typed_currency_forms() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
// Legacy SAT form.
let req = build_request(
"POST",
"/v1/admin/products",
&[("authorization", &auth)],
Some(json!({"slug": "legacy", "name": "Legacy", "price_sats": 50000})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["price_sats"], 50_000);
assert_eq!(body["price_currency"], "SAT");
assert_eq!(body["price_value"], 50_000);
// Typed SAT form.
let req = build_request(
"POST",
"/v1/admin/products",
&[("authorization", &auth)],
Some(json!({
"slug": "typed-sat",
"name": "Typed SAT",
"price_currency": "SAT",
"price_value": 75000,
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["price_sats"], 75_000);
assert_eq!(body["price_currency"], "SAT");
assert_eq!(body["price_value"], 75_000);
// Typed USD form: $49.00 = 4900 cents. price_sats stays 0 until
// the first invoice triggers a rate lookup.
let req = build_request(
"POST",
"/v1/admin/products",
&[("authorization", &auth)],
Some(json!({
"slug": "typed-usd",
"name": "Typed USD",
"price_currency": "USD",
"price_value": 4900,
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["price_currency"], "USD");
assert_eq!(body["price_value"], 4900);
assert_eq!(
body["price_sats"], 0,
"USD products should have price_sats=0 until first invoice rate-converts them"
);
// Bad currency.
let req = build_request(
"POST",
"/v1/admin/products",
&[("authorization", &auth)],
Some(json!({
"slug": "bad-currency",
"name": "Bad",
"price_currency": "GBP",
"price_value": 100,
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
// Inconsistent legacy + typed (catches half-migrated clients).
let req = build_request(
"POST",
"/v1/admin/products",
&[("authorization", &auth)],
Some(json!({
"slug": "inconsistent",
"name": "Inconsistent",
"price_sats": 50000,
"price_currency": "USD",
"price_value": 4900,
})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"mismatched legacy + typed pricing should 400"
);
// Half-form: currency without value.
let req = build_request(
"POST",
"/v1/admin/products",
&[("authorization", &auth)],
Some(json!({
"slug": "half-1",
"name": "Half 1",
"price_currency": "USD",
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
// Half-form: value without currency.
let req = build_request(
"POST",
"/v1/admin/products",
&[("authorization", &auth)],
Some(json!({
"slug": "half-2",
"name": "Half 2",
"price_value": 4900,
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
/// Community analytics: opt-in toggle + privacy contract.
///
/// Locks in two invariants:
/// - Default state is OFF; no install_uuid generated.
/// - Enabling generates a fresh install_uuid; the heartbeat
/// preview's counts are floored to the nearest 5 (anti-
/// fingerprinting); no operator-identifying fields are present.
/// - Bad collector URL → 400 (must start with http:// or https://).
#[tokio::test]
async fn community_analytics_opt_in_and_privacy_contract() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
// Default state: disabled, no install_uuid yet.
let req = build_request(
"GET",
"/v1/admin/community-analytics",
&[("authorization", &auth)],
None,
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["enabled"], false, "must default to off");
assert!(
body["install_uuid"].is_null(),
"no UUID should exist before opt-in"
);
assert!(
body["collector_url"].is_null(),
"no URL should exist before opt-in"
);
// Bad URL → 400.
let req = build_request(
"POST",
"/v1/admin/community-analytics",
&[("authorization", &auth)],
Some(json!({"enabled": true, "collector_url": "ftp://wrong"})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
// Enabling without a URL is allowed (armed but silent).
let req = build_request(
"POST",
"/v1/admin/community-analytics",
&[("authorization", &auth)],
Some(json!({"enabled": true, "collector_url": null})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
// Now an install_uuid exists.
let req = build_request(
"GET",
"/v1/admin/community-analytics",
&[("authorization", &auth)],
None,
);
let resp = send(&state, req).await;
let body = body_json(resp).await;
assert_eq!(body["enabled"], true);
let uuid = body["install_uuid"]
.as_str()
.expect("install_uuid should be present after opt-in");
assert_eq!(uuid.len(), 36, "install_uuid should be a UUIDv4 string");
// Privacy contract: the preview heartbeat MUST contain only
// anonymized fields. Specifically, no operator_name, no
// public_url, no store_id, no api keys, no buyer info.
let preview = &body["preview_heartbeat"];
let preview_str =
serde_json::to_string(preview).expect("preview should serialize");
for forbidden in &[
"operator_name",
"public_url",
"store_id",
"api_key",
"buyer_email",
"btcpay_url",
] {
assert!(
!preview_str.contains(forbidden),
"preview heartbeat must not contain '{forbidden}': {preview_str}"
);
}
// Counts must be floored to the nearest 5. Seed 23 active
// licenses → counts.active_licenses must be 20.
let product = repo::create_product(
&state.db,
"ana-prod",
"Analytics Test",
"",
100,
&json!({}),
)
.await
.unwrap();
for _ in 0..23 {
let lid = Uuid::new_v4().to_string();
repo::create_license(
&state.db,
&lid,
&product.id,
None,
&Utc::now().to_rfc3339(),
&json!({}),
None,
None,
0,
1,
&[],
false,
None,
None,
)
.await
.unwrap();
}
let req = build_request(
"GET",
"/v1/admin/community-analytics",
&[("authorization", &auth)],
None,
);
let resp = send(&state, req).await;
let body = body_json(resp).await;
let preview = &body["preview_heartbeat"];
assert_eq!(
preview["counts"]["active_licenses"], 20,
"23 licenses must floor to 20 (anti-fingerprinting): {preview:?}"
);
// Reset wipes the UUID.
let req = build_request(
"POST",
"/v1/admin/community-analytics/reset",
&[("authorization", &auth)],
None,
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let req = build_request(
"GET",
"/v1/admin/community-analytics",
&[("authorization", &auth)],
None,
);
let body = body_json(send(&state, req).await).await;
assert!(
body["install_uuid"].is_null(),
"install_uuid must be wiped after reset: {body:?}"
);
}
// ---------------------------------------------------------------------
// Recurring-subscription policy admin (Phase 4 of recurring subs)
//
// The renewal worker (src/subscriptions.rs + tests/subscriptions.rs)
// has its own coverage. This block is about the ADMIN surface — can an
// operator create a recurring policy through the API, can they edit
// it, and does the public buy-page endpoint surface the right cadence
// fields for the front-end to render?
// ---------------------------------------------------------------------
/// Helper: swap `state.self_tier` to a Pro-equivalent licensed tier
/// (carries `unlimited_products`, `unlimited_policies`, AND
/// `recurring_billing`). Mirrors what `Activate Keysat license` does
/// in production.
async fn upgrade_to_pro(state: &AppState) {
*state.self_tier.write().await = Tier::Licensed {
license_id: Uuid::new_v4(),
product_id: Uuid::new_v4(),
expires_at: 0,
entitlements: vec![
"self_host".into(),
"unlimited_products".into(),
"unlimited_policies".into(),
"unlimited_codes".into(),
"recurring_billing".into(),
"card_payments".into(),
],
};
}
/// Operator on Creator tier (no `recurring_billing` entitlement)
/// cannot create a recurring policy. The 402 should mention upgrade.
#[tokio::test]
async fn recurring_policy_blocked_on_creator_tier() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
// Seed a product so `product_slug` lookup succeeds and the test
// exercises the recurring-feature gate, not the not-found path.
let _ = repo::create_product(
&state.db,
"rec-blocked",
"Blocked",
"",
100_000,
&json!({}),
)
.await
.expect("create_product");
let req = build_request(
"POST",
"/v1/admin/policies",
&[("authorization", &auth)],
Some(json!({
"product_slug": "rec-blocked",
"name": "Monthly",
"slug": "monthly",
"is_recurring": true,
"renewal_period_days": 30
})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::PAYMENT_REQUIRED,
"Creator tier must see 402 for recurring=true; got {}",
resp.status()
);
let body = body_json(resp).await;
assert!(
body["upgrade_url"].as_str().unwrap_or("").contains("buy/keysat"),
"402 should carry an upgrade_url to the master Keysat: {body:?}"
);
}
/// Operator on Pro tier can create a monthly subscription policy. The
/// stored row carries the recurring fields, the public list endpoint
/// echoes them, and the policies admin list shows is_recurring=true.
#[tokio::test]
async fn pro_tier_creates_monthly_recurring_policy() {
let (state, _tmp) = make_test_state().await;
upgrade_to_pro(&state).await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
let _ = repo::create_product(
&state.db,
"rec-product",
"Recurring Product",
"",
25_000,
&json!({}),
)
.await
.expect("create_product");
// Create a recurring monthly policy with a 14-day trial.
let req = build_request(
"POST",
"/v1/admin/policies",
&[("authorization", &auth)],
Some(json!({
"product_slug": "rec-product",
"name": "Monthly",
"slug": "monthly",
"duration_seconds": 30 * 86_400,
"max_machines": 1,
"is_recurring": true,
"renewal_period_days": 30,
"grace_period_days": 7,
"trial_days": 14
})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::OK,
"create with Pro tier should succeed; got {}",
resp.status()
);
let body = body_json(resp).await;
assert_eq!(body["is_recurring"], true);
assert_eq!(body["renewal_period_days"], 30);
assert_eq!(body["grace_period_days"], 7);
assert_eq!(body["trial_days"], 14);
// Public buy-page API surfaces the same shape so the JS price
// renderer can reach for it.
let req = build_request("GET", "/v1/products/rec-product/policies", &[], None);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
let policies = body["policies"].as_array().expect("policies array");
let monthly = policies
.iter()
.find(|p| p["slug"] == "monthly")
.expect("monthly policy in public list");
assert_eq!(monthly["is_recurring"], true);
assert_eq!(monthly["renewal_period_days"], 30);
assert_eq!(monthly["trial_days"], 14);
}
/// Validation: recurring=true with renewal_period_days=0 must be rejected.
/// Catches a foot-gun where the operator forgets to fill in the cadence.
#[tokio::test]
async fn recurring_requires_positive_period() {
let (state, _tmp) = make_test_state().await;
upgrade_to_pro(&state).await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
let _ = repo::create_product(&state.db, "rec-bad", "Bad", "", 100, &json!({}))
.await
.expect("create_product");
let req = build_request(
"POST",
"/v1/admin/policies",
&[("authorization", &auth)],
Some(json!({
"product_slug": "rec-bad",
"name": "Bad",
"slug": "bad",
"is_recurring": true,
"renewal_period_days": 0
})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"is_recurring=true with renewal_period_days=0 must be rejected"
);
}
/// Edit-policy can flip a non-recurring policy to recurring on Pro tier
/// and a Pro-tier-gated operator gets a 402 trying the same.
#[tokio::test]
async fn edit_policy_to_recurring_respects_tier_gate() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
// Pre-create product + non-recurring policy directly via the repo,
// so we don't need to go through Pro tier just for setup.
let product = repo::create_product(
&state.db,
"rec-edit",
"Edit Test",
"",
100_000,
&json!({}),
)
.await
.expect("create_product");
let policy = repo::create_policy(
&state.db,
&product.id,
"Default",
"default",
0,
0,
1,
false,
None,
&[],
&json!({}),
None,
0,
None,
repo::RecurringConfig::off(),
None,
)
.await
.expect("create_policy");
// Creator-tier attempt to flip is_recurring=true → 402.
let req = build_request(
"PATCH",
&format!("/v1/admin/policies/{}", policy.id),
&[("authorization", &auth)],
Some(json!({
"is_recurring": true,
"renewal_period_days": 30
})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::PAYMENT_REQUIRED,
"flipping a policy to recurring on Creator tier must 402"
);
// Upgrade and try again.
upgrade_to_pro(&state).await;
let req = build_request(
"PATCH",
&format!("/v1/admin/policies/{}", policy.id),
&[("authorization", &auth)],
Some(json!({
"is_recurring": true,
"renewal_period_days": 30,
"trial_days": 7
})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::OK,
"Pro tier can flip a policy to recurring"
);
let body = body_json(resp).await;
assert_eq!(body["is_recurring"], true);
assert_eq!(body["renewal_period_days"], 30);
assert_eq!(body["trial_days"], 7);
// Idempotency: a second PATCH that LEAVES is_recurring true should
// succeed and not re-fire the tier gate. Drop back to Creator and
// PATCH a tangential field — must still work.
*state.self_tier.write().await = Tier::Unlicensed {
reason: "downgraded".into(),
};
let req = build_request(
"PATCH",
&format!("/v1/admin/policies/{}", policy.id),
&[("authorization", &auth)],
Some(json!({ "name": "Renamed Default" })),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::OK,
"name-only patch on a recurring policy must not re-fire the tier gate"
);
}
// ---------------------------------------------------------------------
// Subscription cancellation (Phase 6)
//
// Admin cancel: full trust, just needs the bearer token + the sub id.
// Buyer cancel: auth via license key in the body. The cancelled state
// is terminal — license stays valid through end-of-cycle, renewal
// worker stops creating new invoices, webhook fires.
// ---------------------------------------------------------------------
/// Helper: seed a license + active subscription tied to it, plus a
/// product + recurring policy. Returns (license_id, sub_id, key_string)
/// where `key_string` is the signed license key the buyer would have
/// in hand (used by the self-service cancel test).
async fn seed_subscription(state: &AppState) -> (String, String, String) {
let product = repo::create_product(
&state.db,
"sub-cancel-prod",
"Cancel Test",
"",
25_000,
&json!({}),
)
.await
.expect("create_product");
let policy = repo::create_policy(
&state.db,
&product.id,
"Monthly",
"monthly",
30 * 86_400,
0,
1,
false,
None,
&[],
&json!({}),
None,
0,
None,
repo::RecurringConfig {
is_recurring: true,
renewal_period_days: 30,
grace_period_days: 7,
trial_days: 0,
},
None,
)
.await
.expect("create_policy");
let license_id = Uuid::new_v4();
let issued_at = Utc::now();
repo::create_license(
&state.db,
&license_id.to_string(),
&product.id,
None,
&issued_at.to_rfc3339(),
&json!({}),
Some(&policy.id),
None,
0,
1,
&[],
false,
None,
None,
)
.await
.expect("create_license");
// Seed a placeholder cycle-1 invoice so the FK on subscription_invoices
// is satisfiable — the invoice details don't matter for the cancel
// tests, only that a row exists.
let invoice_id = Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO invoices(id, btcpay_invoice_id, product_id, amount_sats, \
checkout_url, status, created_at, updated_at, listed_currency, \
listed_value, policy_id) \
VALUES(?, ?, ?, 0, ?, 'pending', ?, ?, 'SAT', 0, ?)",
)
.bind(&invoice_id)
.bind(&format!("test-inv-{}", &invoice_id[..8]))
.bind(&product.id)
.bind("http://test.invalid/inv")
.bind(issued_at.to_rfc3339())
.bind(issued_at.to_rfc3339())
.bind(&policy.id)
.execute(&state.db)
.await
.expect("seed invoice");
let sub = keysat::subscriptions::create_subscription(
&state.db,
&license_id.to_string(),
&policy.id,
&product.id,
30,
"SAT",
25_000,
&invoice_id,
)
.await
.expect("create_subscription");
// Build a real signed key the buyer-cancel endpoint can verify.
let product_uuid = Uuid::parse_str(&product.id).expect("product id is uuid");
let payload = LicensePayload {
version: 2,
flags: 0,
product_id: product_uuid,
license_id,
issued_at: issued_at.timestamp(),
expires_at: 0,
fingerprint_hash: [0; 32],
entitlements: vec![],
};
let signature = crypto::sign_payload(&state.keypair.signing, &payload);
let key_string = crypto::encode_key(&payload, &signature);
(license_id.to_string(), sub.id, key_string)
}
#[tokio::test]
async fn admin_cancel_subscription_happy_path() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
let (_license_id, sub_id, _key) = seed_subscription(&state).await;
// Cancel.
let req = build_request(
"POST",
&format!("/v1/admin/subscriptions/{}/cancel", sub_id),
&[("authorization", &auth)],
Some(json!({"reason": "customer requested"})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["ok"], true);
assert_eq!(body["status"], "cancelled");
// DB row reflects the new state + cancelled_at is stamped.
let (status, cancelled_at): (String, Option<String>) = sqlx::query_as(
"SELECT status, cancelled_at FROM subscriptions WHERE id = ?",
)
.bind(&sub_id)
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(status, "cancelled");
assert!(cancelled_at.is_some(), "cancelled_at must be stamped");
// Audit row exists.
let n: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM audit_log WHERE action = 'subscription.cancel' \
AND target_id = ?",
)
.bind(&sub_id)
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(n, 1, "exactly one audit row for the cancel");
// Idempotency: cancelling a cancelled sub returns ok with the prior state.
let req = build_request(
"POST",
&format!("/v1/admin/subscriptions/{}/cancel", sub_id),
&[("authorization", &auth)],
Some(json!({})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["already"], "cancelled");
}
#[tokio::test]
async fn admin_cancel_unknown_subscription_404s() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
let req = build_request(
"POST",
"/v1/admin/subscriptions/no-such-sub/cancel",
&[("authorization", &auth)],
Some(json!({})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn buyer_cancel_subscription_via_license_key() {
let (state, _tmp) = make_test_state().await;
let (_license_id, sub_id, key_string) = seed_subscription(&state).await;
// Buyer self-cancels by POSTing the signed key. No admin auth.
let req = build_request(
"POST",
"/v1/subscriptions/cancel",
&[],
Some(json!({
"license_key": key_string,
"reason": "no longer needed"
})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::OK,
"buyer cancel should succeed with a valid key"
);
let body = body_json(resp).await;
assert_eq!(body["status"], "cancelled");
assert_eq!(body["subscription_id"], sub_id);
// Audit row carries actor=buyer.
let actor: Option<String> = sqlx::query_scalar(
"SELECT actor_kind FROM audit_log WHERE target_id = ? \
AND action = 'subscription.cancel'",
)
.bind(&sub_id)
.fetch_optional(&state.db)
.await
.unwrap();
assert_eq!(
actor.as_deref(),
Some("buyer_license_key"),
"audit must record the buyer-key actor kind"
);
}
// ---------------------------------------------------------------------
// Tier upgrade endpoints (Phase 3 of TIER_UPGRADES_DESIGN)
// ---------------------------------------------------------------------
/// Seed a USD perpetual product with Standard (rank 1) + Pro (rank 2)
/// policies, plus a license under Standard with a real signed key the
/// buyer would hold. Returns (license_id, key_string, standard_id, pro_id).
async fn seed_perpetual_ladder_with_key(state: &AppState) -> (String, String, String, String) {
let product = repo::create_product(
&state.db,
"upgrade-test",
"Upgrade Test",
"",
2500,
&json!({}),
)
.await
.expect("create_product");
sqlx::query("UPDATE products SET price_currency='USD', price_value=2500 WHERE id = ?")
.bind(&product.id)
.execute(&state.db)
.await
.unwrap();
let standard = repo::create_policy(
&state.db,
&product.id,
"Standard",
"standard",
0,
0,
1,
false,
Some(2500),
&["core".into()],
&json!({}),
None,
0,
None,
repo::RecurringConfig::off(),
Some(1),
)
.await
.expect("create standard");
let pro = repo::create_policy(
&state.db,
&product.id,
"Pro",
"pro",
0,
0,
3,
false,
Some(7500),
&["core".into(), "ai_summaries".into()],
&json!({}),
None,
0,
None,
repo::RecurringConfig::off(),
Some(2),
)
.await
.expect("create pro");
let license_id = Uuid::new_v4();
let issued_at = Utc::now();
repo::create_license(
&state.db,
&license_id.to_string(),
&product.id,
None,
&issued_at.to_rfc3339(),
&json!({}),
Some(&standard.id),
None,
0,
1,
&["core".to_string()],
false,
None,
None,
)
.await
.expect("create_license");
let product_uuid = Uuid::parse_str(&product.id).expect("product id is uuid");
let payload = LicensePayload {
version: 2,
flags: 0,
product_id: product_uuid,
license_id,
issued_at: issued_at.timestamp(),
expires_at: 0,
fingerprint_hash: [0; 32],
entitlements: vec!["core".into()],
};
let signature = crypto::sign_payload(&state.keypair.signing, &payload);
let key_string = crypto::encode_key(&payload, &signature);
(license_id.to_string(), key_string, standard.id, pro.id)
}
/// `/v1/upgrade-quote` returns the prorated charge for a valid
/// license + target combo.
#[tokio::test]
async fn upgrade_quote_returns_perpetual_difference() {
let (state, _tmp) = make_test_state().await;
let (_lic, key, _std, _pro) = seed_perpetual_ladder_with_key(&state).await;
let req = build_request(
"POST",
"/v1/upgrade-quote",
&[],
Some(json!({
"license_key": key,
"target_policy_slug": "pro"
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["direction"], "upgrade");
assert_eq!(body["listed_currency"], "USD");
// Pro $75 - Standard $25 = $50 = 5000 cents.
assert_eq!(body["proration_charge_value"], 5000);
assert_eq!(body["effective_at"], "immediate");
}
#[tokio::test]
async fn upgrade_quote_rejects_garbage_key() {
let (state, _tmp) = make_test_state().await;
let req = build_request(
"POST",
"/v1/upgrade-quote",
&[],
Some(json!({
"license_key": "not-a-real-key",
"target_policy_slug": "pro"
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn upgrade_quote_rejects_unknown_target_policy() {
let (state, _tmp) = make_test_state().await;
let (_lic, key, _, _) = seed_perpetual_ladder_with_key(&state).await;
let req = build_request(
"POST",
"/v1/upgrade-quote",
&[],
Some(json!({
"license_key": key,
"target_policy_slug": "no-such-policy"
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
/// `/v1/upgrade` against a paid path: creates a real provider invoice
/// (mock), persists a tier_changes row, returns checkout URL.
#[tokio::test]
async fn upgrade_start_creates_invoice_and_tier_change_row() {
let (state, _tmp) = make_test_state_with_mock_provider().await;
// Pin a USD/BTC rate so the rates fetcher doesn't try the network
// when we hit the upgrade path.
sqlx::query(
"INSERT INTO settings(key, value, updated_at) \
VALUES('manual_rate_pin_USD', '50000', ?)",
)
.bind(Utc::now().to_rfc3339())
.execute(&state.db)
.await
.unwrap();
let (license_id, key, _std, pro_id) = seed_perpetual_ladder_with_key(&state).await;
let req = build_request(
"POST",
"/v1/upgrade",
&[],
Some(json!({
"license_key": key,
"target_policy_slug": "pro"
})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::OK,
"upgrade start should succeed; got {}",
resp.status()
);
let body = body_json(resp).await;
let invoice_id = body["invoice_id"].as_str().expect("invoice_id").to_string();
assert!(body["checkout_url"].as_str().unwrap().contains("mock-checkout"));
assert_eq!(body["proration_charge_value"], 5000); // 5000 cents
assert!(body["amount_sats"].as_i64().unwrap() > 0,
"fiat conversion should produce a non-zero sat charge");
// tier_changes row exists with this invoice_id.
let tc = keysat::upgrades::get_tier_change_by_invoice(&state.db, &invoice_id)
.await
.unwrap()
.expect("tier_change row");
assert_eq!(tc.license_id, license_id);
assert_eq!(tc.to_policy_id, pro_id);
assert_eq!(tc.actor, "buyer");
assert_eq!(tc.direction, "upgrade");
assert_eq!(tc.invoice_id.as_deref(), Some(invoice_id.as_str()));
// License is NOT yet on Pro — that happens on settle (next test).
let license_now = repo::get_license_by_id(&state.db, &license_id)
.await
.unwrap()
.unwrap();
assert_ne!(
license_now.policy_id.as_deref(),
Some(pro_id.as_str()),
"license should NOT change tier until invoice settles"
);
}
/// Webhook settle on a tier-change invoice applies the change instead
/// of issuing a new license.
#[tokio::test]
async fn webhook_settle_on_tier_change_applies_instead_of_issuing() {
let (state, _tmp) = make_test_state_with_mock_provider().await;
sqlx::query(
"INSERT INTO settings(key, value, updated_at) \
VALUES('manual_rate_pin_USD', '50000', ?)",
)
.bind(Utc::now().to_rfc3339())
.execute(&state.db)
.await
.unwrap();
let (license_id, key, _std, pro_id) = seed_perpetual_ladder_with_key(&state).await;
// Start the upgrade, capture the provider invoice id.
let req = build_request(
"POST",
"/v1/upgrade",
&[],
Some(json!({
"license_key": key,
"target_policy_slug": "pro"
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
let invoice_id = body["invoice_id"].as_str().unwrap().to_string();
let provider_invoice_id = body["provider_invoice_id"].as_str().unwrap().to_string();
// Fire a "settled" webhook on that invoice. The MockPaymentProvider's
// validate_webhook reads the body as JSON.
let req = build_request(
"POST",
"/v1/btcpay/webhook",
&[],
Some(json!({
"kind": "settled",
"provider_invoice_id": provider_invoice_id
})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::OK,
"webhook should ack 200 on tier-change settle"
);
// The license is now on Pro. No NEW license was issued (count
// for this product still 1).
let license_after = repo::get_license_by_id(&state.db, &license_id)
.await
.unwrap()
.unwrap();
assert_eq!(
license_after.policy_id.as_deref(),
Some(pro_id.as_str()),
"settle webhook should have applied the tier change"
);
assert!(
license_after.entitlements.contains(&"ai_summaries".to_string()),
"Pro entitlements should now be on the license: {:?}",
license_after.entitlements
);
let n_licenses: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM licenses WHERE product_id = ?",
)
.bind(&license_after.product_id)
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(
n_licenses, 1,
"tier-change must NOT issue a new license; count must stay at 1"
);
// Re-delivering the same webhook is idempotent.
let req = build_request(
"POST",
"/v1/btcpay/webhook",
&[],
Some(json!({
"kind": "settled",
"provider_invoice_id": provider_invoice_id
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK, "re-delivery must ack 200");
let n_licenses_after: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM licenses WHERE product_id = ?",
)
.bind(&license_after.product_id)
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(n_licenses_after, 1, "re-delivery must not duplicate licenses");
// Suppress unused-var warning: invoice_id is used implicitly via
// the tier_changes lookup but kept named for readability.
let _ = invoice_id;
}
/// Admin can force-change a license to any policy under the same
/// product. skip_payment=true applies immediately with no invoice.
#[tokio::test]
async fn admin_change_tier_skip_payment_applies_immediately() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
let (license_id, _key, _std, pro_id) = seed_perpetual_ladder_with_key(&state).await;
let req = build_request(
"POST",
&format!("/v1/admin/licenses/{license_id}/change-tier"),
&[("authorization", &auth)],
Some(json!({
"to_policy_slug": "pro",
"skip_payment": true,
"reason": "comp upgrade per support ticket #1234"
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["applied"], true);
assert_eq!(body["skip_payment"], true);
let tc_id = body["tier_change_id"].as_str().unwrap().to_string();
let license_after = repo::get_license_by_id(&state.db, &license_id)
.await
.unwrap()
.unwrap();
assert_eq!(
license_after.policy_id.as_deref(),
Some(pro_id.as_str()),
"skip_payment=true should apply on the spot"
);
let tc = keysat::upgrades::get_tier_change(&state.db, &tc_id)
.await
.unwrap()
.unwrap();
assert_eq!(tc.actor, "admin");
assert_eq!(tc.proration_charge_value, 0);
assert_eq!(tc.invoice_id, None, "comp upgrade has no invoice");
assert_eq!(
tc.reason.as_deref(),
Some("comp upgrade per support ticket #1234")
);
}
/// Admin can force a perpetual downgrade. Buyer endpoint rejects
/// these (refund decision per design doc).
#[tokio::test]
async fn admin_change_tier_allows_perpetual_downgrade() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
let (license_id, _key, std_id, pro_id) = seed_perpetual_ladder_with_key(&state).await;
sqlx::query("UPDATE licenses SET policy_id = ? WHERE id = ?")
.bind(&pro_id)
.bind(&license_id)
.execute(&state.db)
.await
.unwrap();
let req = build_request(
"POST",
&format!("/v1/admin/licenses/{license_id}/change-tier"),
&[("authorization", &auth)],
Some(json!({
"to_policy_slug": "standard",
"skip_payment": true,
"reason": "honoring partial refund"
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let license_after = repo::get_license_by_id(&state.db, &license_id)
.await
.unwrap()
.unwrap();
assert_eq!(license_after.policy_id.as_deref(), Some(std_id.as_str()));
}
#[tokio::test]
async fn admin_change_tier_rejects_zero_charge_paid_path() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
let (license_id, _key, std_id, _pro) = seed_perpetual_ladder_with_key(&state).await;
let std_policy = repo::get_policy_by_id(&state.db, &std_id).await.unwrap().unwrap();
let _sideways = repo::create_policy(
&state.db,
&std_policy.product_id,
"Standard Plus",
"standard-plus",
0,
0,
1,
false,
Some(2500),
&["core".into()],
&json!({}),
None,
0,
None,
repo::RecurringConfig::off(),
Some(1),
)
.await
.unwrap();
let req = build_request(
"POST",
&format!("/v1/admin/licenses/{license_id}/change-tier"),
&[("authorization", &auth)],
Some(json!({
"to_policy_slug": "standard-plus",
"skip_payment": false
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = body_json(resp).await;
assert!(
body["message"].as_str().unwrap_or("").contains("skip_payment"),
"error should hint at the skip_payment toggle: {body:?}"
);
}
#[tokio::test]
async fn admin_change_tier_requires_admin_token() {
let (state, _tmp) = make_test_state().await;
let (license_id, _key, _std, _pro) = seed_perpetual_ladder_with_key(&state).await;
let req = build_request(
"POST",
&format!("/v1/admin/licenses/{license_id}/change-tier"),
&[],
Some(json!({"to_policy_slug": "pro", "skip_payment": true})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
/// Buyer-initiated downgrade is rejected from this endpoint in v0.2.x
/// (Phase 4 admin endpoint covers downgrades).
#[tokio::test]
async fn upgrade_endpoint_rejects_buyer_downgrade() {
let (state, _tmp) = make_test_state().await;
let (lic, _key, std_id, pro_id) = seed_perpetual_ladder_with_key(&state).await;
// Move the license to Pro by direct SQL so we can attempt a
// downgrade back to Standard. (Real flow: admin would have done
// this; we don't have an admin-change-tier endpoint until Phase 4.)
sqlx::query("UPDATE licenses SET policy_id = ? WHERE id = ?")
.bind(&pro_id)
.bind(&lic)
.execute(&state.db)
.await
.unwrap();
// Re-sign a key for the now-Pro license. We can reuse the same
// license_id + product_id — the entitlements in the payload are
// not checked by the upgrade endpoint (it goes by license_id).
let license = repo::get_license_by_id(&state.db, &lic).await.unwrap().unwrap();
let product_uuid = Uuid::parse_str(&license.product_id).unwrap();
let payload = LicensePayload {
version: 2,
flags: 0,
product_id: product_uuid,
license_id: Uuid::parse_str(&lic).unwrap(),
issued_at: Utc::now().timestamp(),
expires_at: 0,
fingerprint_hash: [0; 32],
entitlements: vec![],
};
let signature = crypto::sign_payload(&state.keypair.signing, &payload);
let key_string = crypto::encode_key(&payload, &signature);
let req = build_request(
"POST",
"/v1/upgrade",
&[],
Some(json!({
"license_key": key_string,
"target_policy_slug": "standard"
})),
);
let resp = send(&state, req).await;
// The quote function intercepts perpetual downgrades with a 400
// "admin-only" before the endpoint's blanket-Forbidden check
// fires. Either status is "this is not a buyer path"; the
// message-level distinction matters more than the code.
let status = resp.status();
assert!(
status == StatusCode::BAD_REQUEST || status == StatusCode::FORBIDDEN,
"buyer-initiated downgrade must be 400 or 403; got {status}"
);
if status == StatusCode::BAD_REQUEST {
let body = body_json(resp).await;
assert!(
body["message"].as_str().unwrap_or("").contains("admin-only"),
"400 should explain that downgrades are admin-only: {body:?}"
);
}
let _ = std_id;
}
#[tokio::test]
async fn buyer_cancel_rejects_garbage_key() {
let (state, _tmp) = make_test_state().await;
let _ = seed_subscription(&state).await;
let req = build_request(
"POST",
"/v1/subscriptions/cancel",
&[],
Some(json!({"license_key": "not-a-real-key"})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"garbage key must be 401, not 404 — don't leak which subs exist"
);
}
// ---------------------------------------------------------------------
// 0.2.0:12 — Scoped API keys + OpenAPI spec + Zaprite gate
// ---------------------------------------------------------------------
/// `GET /v1/openapi.json` — public, no auth. Returns a parseable spec
/// with the agent-relevant subset of endpoints documented.
#[tokio::test]
async fn openapi_spec_serves_valid_json() {
let (state, _tmp) = make_test_state().await;
let req = build_request("GET", "/v1/openapi.json", &[], None);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v = body_json(resp).await;
assert_eq!(v["openapi"], "3.1.0");
assert!(v["paths"].as_object().expect("paths is object").len() > 5);
// Spot-check that the agent-relevant endpoints are present.
assert!(v.pointer("/paths/~1v1~1admin~1api-keys").is_some());
assert!(v.pointer("/paths/~1v1~1admin~1licenses").is_some());
assert!(v.pointer("/paths/~1v1~1validate").is_some());
}
/// `POST /v1/admin/api-keys` — master admin creates a scoped key, the
/// raw token comes back once, and the role is recorded. Subsequent
/// `GET /v1/admin/api-keys` lists it without the token.
#[tokio::test]
async fn scoped_api_key_create_list_revoke_round_trip() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
// Create with a recognized role.
let req = build_request(
"POST",
"/v1/admin/api-keys",
&[("authorization", &auth)],
Some(json!({"label": "Smoke test bot", "role": "license-issuer"})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
let token = body["token"].as_str().expect("token returned");
assert!(token.starts_with("ks_"), "scoped token must use ks_ prefix");
let key_id = body["id"].as_str().expect("id returned").to_string();
assert_eq!(body["role"], "license-issuer");
// List sees the new key but never the raw token.
let req = build_request("GET", "/v1/admin/api-keys", &[("authorization", &auth)], None);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let list = body_json(resp).await;
let keys = list["api_keys"].as_array().expect("api_keys array");
assert_eq!(keys.len(), 1);
assert_eq!(keys[0]["label"], "Smoke test bot");
assert!(keys[0].get("token").is_none(), "list must not return raw tokens");
// Revoke. Idempotent on second call.
let path = format!("/v1/admin/api-keys/{}", key_id);
let req = build_request("DELETE", &path, &[("authorization", &auth)], None);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let req = build_request("DELETE", &path, &[("authorization", &auth)], None);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(body["already_revoked"], true);
}
/// Create endpoint rejects unknown role with 400.
#[tokio::test]
async fn scoped_api_key_create_rejects_unknown_role() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
let req = build_request(
"POST",
"/v1/admin/api-keys",
&[("authorization", &auth)],
Some(json!({"label": "bad role", "role": "god-mode"})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
/// `POST /v1/admin/api-keys` requires master admin, NOT a scoped
/// full-admin key — generating other API keys is a self-elevation path
/// that scoped keys are deliberately denied.
#[tokio::test]
async fn scoped_api_key_management_rejects_scoped_full_admin() {
let (state, _tmp) = make_test_state().await;
let master = format!("Bearer {}", TEST_ADMIN_KEY);
// Master creates a full-admin scoped key.
let req = build_request(
"POST",
"/v1/admin/api-keys",
&[("authorization", &master)],
Some(json!({"label": "Tries to elevate", "role": "full-admin"})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
let scoped_token = body["token"].as_str().expect("token").to_string();
let scoped_auth = format!("Bearer {}", scoped_token);
// Scoped full-admin tries to create another key. Should 403 — the
// /v1/admin/api-keys handler calls require_admin, not require_scope.
let req = build_request(
"POST",
"/v1/admin/api-keys",
&[("authorization", &scoped_auth)],
Some(json!({"label": "Pwn", "role": "read-only"})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"scoped keys (even full-admin) must NOT manage other keys"
);
}
/// Zaprite Connect refuses on Creator-tier (no `zaprite_payments`
/// entitlement) with 402. Switching the daemon's self-tier to a
/// Pro-flavored Licensed tier lets the Connect-precheck pass (it then
/// fails downstream on the unreachable test host, but the tier gate is
/// behind us).
#[tokio::test]
async fn zaprite_connect_gated_by_pro_entitlement() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
// Creator tier (default for test fixture) — Connect should 402.
let req = build_request(
"POST",
"/v1/admin/zaprite/connect",
&[("authorization", &auth)],
Some(json!({"api_key": "fake-zaprite-key"})),
);
let resp = send(&state, req).await;
assert_eq!(
resp.status(),
StatusCode::PAYMENT_REQUIRED,
"Zaprite Connect must 402 without zaprite_payments entitlement"
);
let body = body_json(resp).await;
assert_eq!(body["error"], "tier_cap");
assert!(body["upgrade_url"].as_str().expect("upgrade_url").contains("/buy/keysat"));
}
/// CORS — the public read-only endpoints answer cross-origin requests
/// from any browser origin so docs.keysat.xyz can fetch live pricing
/// from licensing.keysat.xyz without proxying. `allow_credentials` is
/// intentionally OFF: pages can read public responses but cannot ride
/// a logged-in admin session cookie to hit /v1/admin/*.
#[tokio::test]
async fn cors_allows_cross_origin_on_public_endpoints() {
let (state, _tmp) = make_test_state().await;
let req = build_request(
"GET",
"/v1/openapi.json",
&[("origin", "https://docs.keysat.xyz")],
None,
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let acao = resp
.headers()
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(acao, "*", "public endpoints should set ACAO: *");
// Credentials must NOT be allowed — combining `*` origin with
// credentials is rejected by browsers, and disabling it means a
// hostile cross-origin page can't ride a session cookie.
let acac = resp.headers().get("access-control-allow-credentials");
assert!(acac.is_none(), "credentials must not be allowed");
}
/// CORS preflight (OPTIONS) is handled by the CorsLayer directly and
/// never reaches the session-bridge or any handler. This is the path
/// browsers take before issuing an actual cross-origin POST.
#[tokio::test]
async fn cors_preflight_returns_2xx_without_auth() {
let (state, _tmp) = make_test_state().await;
let req = build_request(
"OPTIONS",
"/v1/admin/products",
&[
("origin", "https://example.com"),
("access-control-request-method", "POST"),
("access-control-request-headers", "authorization,content-type"),
],
None,
);
let resp = send(&state, req).await;
// CorsLayer answers preflight with 200 (or 204). No auth required.
assert!(
resp.status().is_success() || resp.status() == StatusCode::NO_CONTENT,
"preflight should be 2xx, got {}",
resp.status()
);
let acao = resp
.headers()
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(acao, "*");
}
/// Regression: `entitlements_catalog_json` was missing from every
/// product SELECT for ~a release, so admin UI edits appeared to drop
/// on the floor — the column was being written correctly but never
/// read back. This test creates a product, sets a catalog, reads it
/// back through the same code path the admin UI hits.
#[tokio::test]
async fn product_entitlements_catalog_round_trips_through_list_endpoint() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
// Create a product
let req = build_request(
"POST",
"/v1/admin/products",
&[("authorization", &auth)],
Some(json!({
"slug": "catalog-rt",
"name": "Catalog round-trip",
"description": "",
"price_currency": "SAT",
"price_value": 1000,
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK, "product create");
let created = body_json(resp).await;
let product_id = created["id"].as_str().expect("id").to_string();
// PATCH the catalog
let req = build_request(
"PATCH",
&format!("/v1/admin/products/{}", product_id),
&[("authorization", &auth)],
Some(json!({
"entitlements_catalog": [
{"slug": "self_host", "name": "Self-host on Start9", "description": "Run on your own hardware."},
{"slug": "unlimited_products", "name": "Unlimited products", "description": "No 5-product cap."}
]
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK, "product patch with catalog");
// Now read it back via /v1/products (same endpoint the admin UI uses)
let req = build_request("GET", "/v1/products", &[], None);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
let products = body["products"].as_array().expect("products array");
let found = products
.iter()
.find(|p| p["id"] == product_id)
.expect("product visible in list");
let catalog = found["entitlements_catalog"]
.as_array()
.expect("entitlements_catalog should be an array, not null");
assert_eq!(catalog.len(), 2, "both catalog entries should round-trip");
assert_eq!(catalog[0]["slug"], "self_host");
assert_eq!(catalog[1]["slug"], "unlimited_products");
}