//! 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, Money, PaymentProvider, ProviderInvoiceSnapshot, 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)), provider_override: 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) -> 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, ) -> Request { 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. // --------------------------------------------------------------------- /// How the mock answers the handler's settle-confirmation re-fetch /// (`get_invoice_status`). #[derive(Clone, Copy)] enum StatusReport { /// Report this authoritative status. Reports(ProviderInvoiceStatus), /// Simulate the provider's status API being unreachable (network error). Unavailable, } struct MockPaymentProvider { next_invoice_id: AtomicU64, status_report: StatusReport, /// Amount `get_invoice_status` reports the invoice is denominated for. /// `None` (the default) = "no opinion", which disables the advisory /// settle-amount tripwire; `Some` lets a test drive an amount mismatch. settled_amount: Option, } impl MockPaymentProvider { /// Happy path: the provider confirms the invoice is settled. fn new() -> Self { Self { next_invoice_id: AtomicU64::new(1), status_report: StatusReport::Reports(ProviderInvoiceStatus::Settled), settled_amount: None, } } /// Authoritative status does NOT confirm payment, so a `settled` webhook /// body is a forgery the handler must refuse. fn new_unconfirmed() -> Self { Self { next_invoice_id: AtomicU64::new(1), status_report: StatusReport::Reports(ProviderInvoiceStatus::Pending), settled_amount: None, } } /// The provider's status API is unreachable, so the handler can't confirm /// a settle and must ack-without-issuing (deferring to the reconciler). fn new_status_unavailable() -> Self { Self { next_invoice_id: AtomicU64::new(1), status_report: StatusReport::Unavailable, settled_amount: None, } } /// Confirms `Settled` but reports a specific denominated amount, so a test /// can exercise the advisory settle-amount tripwire (mismatch → still /// issues, but audits). fn new_settled_with_amount(amount: Money) -> Self { Self { next_invoice_id: AtomicU64::new(1), status_report: StatusReport::Reports(ProviderInvoiceStatus::Settled), settled_amount: Some(amount), } } } #[async_trait::async_trait] impl PaymentProvider for MockPaymentProvider { fn kind(&self) -> ProviderKind { ProviderKind::Btcpay } async fn create_invoice( &self, _params: CreateInvoiceParams<'_>, ) -> Result { 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 { // The webhook handler re-fetches this to confirm a settle claim // before issuing. Configurable per-mock so a test can simulate the // provider disagreeing with a forged "settled" body, or being down. match self.status_report { StatusReport::Reports(s) => Ok(ProviderInvoiceSnapshot { status: s, amount: self.settled_amount.clone(), }), StatusReport::Unavailable => { anyhow::bail!("mock: provider status API unavailable") } } } /// 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"|, /// "provider_invoice_id": "..."}`. Tests construct their own /// payloads with no signature ceremony. fn validate_webhook( &self, _headers: &HeaderMap, body: &[u8], ) -> Result { 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) { install_mock_provider(MockPaymentProvider::new()).await } /// Install a specific `MockPaymentProvider` on a fresh test state, wiring it /// into both the legacy singleton and the merchant-profile resolver (see the /// two-seams note below). Lets tests vary the mock's behavior — e.g. an /// unconfirmed-status mock to exercise the settle-confirmation guard. async fn install_mock_provider(mock_impl: MockPaymentProvider) -> (AppState, NamedTempFile) { let (mut state, tmp) = make_test_state().await; let mock: Arc = Arc::new(mock_impl); // Two seams, two code paths: // - The legacy singleton (`set_payment_provider`) backs the back-compat // `/v1/{kind}/webhook` route via `state.payment_provider()`. // - The `provider_override` field backs the merchant-profile resolver // (`resolve_provider_for_profile_rail` / `payment_provider_by_id`) that // the real `/v1/purchase` path uses. Both point at the same mock so a // test can drive purchase → settle end-to-end. state.set_payment_provider(mock.clone()).await; state.provider_override = Some(mock); // The resolver still reads profile/rail/row from the DB before swapping in // the override, so a real provider row must exist on the default profile — // otherwise the purchase path 400s with "no payment providers connected". // build_provider is never called for it (the override short-circuits), so // the BTCPay credentials here are inert placeholders. let default_profile = repo::get_default_merchant_profile(&state.db) .await .expect("query default profile") .expect("migration 0020 auto-creates a default merchant profile"); repo::create_payment_provider( &state.db, "test-provider-1", &default_profile.id, "btcpay", "Test BTCPay", "inert-test-key", "http://btcpay.test", None, Some("deadbeef"), Some("store-test"), &Utc::now().to_rfc3339(), ) .await .expect("seed test payment provider"); (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; let status = resp.status(); let body = body_json(resp).await; assert_eq!( status, StatusCode::OK, "paid purchase should succeed against the mock provider; body={body:?}" ); 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); } /// Anti-forgery (P0): a `settled` webhook whose provider API does NOT /// confirm payment must not settle the invoice or issue a license. This is /// the defense for signature-less providers (Zaprite) — a forged settle /// POST with a known order id would otherwise mint a free license. The /// handler re-fetches `get_invoice_status`; the unconfirmed mock reports /// `Pending`, so the claim is refused: 200 ack (so the provider stops /// retrying) but no state change and no license. #[tokio::test] async fn forged_settle_webhook_without_provider_confirmation_is_refused() { let (state, _tmp) = install_mock_provider(MockPaymentProvider::new_unconfirmed()).await; let product = repo::create_product( &state.db, "forgery-test", "Forgery Test", "", 7_000, &json!({}), ) .await .expect("create_product"); let internal_invoice_id = Uuid::new_v4().to_string(); let provider_invoice_id = "mock-inv-forged".to_string(); repo::create_invoice( &state.db, &internal_invoice_id, &provider_invoice_id, &product.id, 7_000, "http://mock-checkout.test/i/forged", None, // buyer_email None, // buyer_note None, // policy_id None, // payment_provider_id ) .await .expect("create_invoice"); let req = build_request( "POST", "/v1/btcpay/webhook", &[("content-type", "application/json")], Some(json!({ "kind": "settled", "provider_invoice_id": provider_invoice_id })), ); let resp = send(&state, req).await; assert_eq!( resp.status(), StatusCode::OK, "handler should ack the forged webhook so the provider stops retrying" ); let status_after: 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, "pending", "forged settle must NOT flip the invoice to settled" ); let licenses: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses") .fetch_one(&state.db) .await .unwrap(); assert_eq!(licenses, 0, "forged settle must NOT issue a license"); } /// When the provider's status API is unreachable, a settle webhook must be /// acked (200, so the provider doesn't retry-storm) WITHOUT issuing — the /// reconcile loop re-confirms and issues later. Pins the fail-open-on-ack / /// fail-closed-on-issuance behavior so a future refactor can't turn this /// into a 5xx retry storm or, worse, issue on an unconfirmable settle. #[tokio::test] async fn settle_webhook_acks_without_issuing_when_provider_unreachable() { let (state, _tmp) = install_mock_provider(MockPaymentProvider::new_status_unavailable()).await; let product = repo::create_product( &state.db, "unreachable-test", "Unreachable Test", "", 6_000, &json!({}), ) .await .expect("create_product"); let internal_invoice_id = Uuid::new_v4().to_string(); let provider_invoice_id = "mock-inv-unreachable".to_string(); repo::create_invoice( &state.db, &internal_invoice_id, &provider_invoice_id, &product.id, 6_000, "http://mock-checkout.test/i/unreachable", None, // buyer_email None, // buyer_note None, // policy_id None, // payment_provider_id ) .await .expect("create_invoice"); let req = build_request( "POST", "/v1/btcpay/webhook", &[("content-type", "application/json")], Some(json!({ "kind": "settled", "provider_invoice_id": provider_invoice_id })), ); let resp = send(&state, req).await; assert_eq!( resp.status(), StatusCode::OK, "unconfirmable settle must ack 200, not 5xx (a non-2xx triggers retry storms)" ); let status_after: 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, "pending", "unconfirmable settle must NOT flip the invoice to settled" ); let licenses: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses") .fetch_one(&state.db) .await .unwrap(); assert_eq!( licenses, 0, "unconfirmable settle must NOT issue a license (reconciler handles it later)" ); } /// Advisory settle-amount tripwire (P1): when the provider confirms `Settled` /// but reports a different amount than we charged, the handler STILL issues /// the license — the amount check is advisory, NOT a gate — and records an /// `invoice.amount_mismatch` audit row so the drift is observable. This pins /// the deliberate non-blocking behavior: a hard gate would false-reject /// operators running a BTCPay payment tolerance. See docs/guides/payments.md. #[tokio::test] async fn settled_amount_mismatch_issues_license_but_audits() { let (state, _tmp) = install_mock_provider(MockPaymentProvider::new_settled_with_amount(Money::sats(1))).await; let product = repo::create_product( &state.db, "amount-mismatch-test", "Amount Mismatch Test", "", 7_000, &json!({}), ) .await .expect("create_product"); let internal_invoice_id = Uuid::new_v4().to_string(); let provider_invoice_id = "mock-inv-mismatch".to_string(); repo::create_invoice( &state.db, &internal_invoice_id, &provider_invoice_id, &product.id, 7_000, "http://mock-checkout.test/i/mismatch", None, // buyer_email None, // buyer_note None, // policy_id None, // payment_provider_id ) .await .expect("create_invoice"); let req = build_request( "POST", "/v1/btcpay/webhook", &[("content-type", "application/json")], Some(json!({ "kind": "settled", "provider_invoice_id": provider_invoice_id })), ); let resp = send(&state, req).await; assert_eq!(resp.status(), StatusCode::OK); // The settle is confirmed (status Settled), so issuance proceeds despite // the amount mismatch — the tripwire is advisory. let status_after: 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, "settled"); let licenses: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses") .fetch_one(&state.db) .await .unwrap(); assert_eq!(licenses, 1, "advisory amount mismatch must NOT block issuance"); // ...but the drift is recorded for the operator to investigate. let mismatches: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM audit_log WHERE action = 'invoice.amount_mismatch'", ) .fetch_one(&state.db) .await .unwrap(); assert_eq!( mismatches, 1, "amount/currency drift must be recorded in the audit log" ); } /// Fiat-denominated settles have no clean SAT comparison basis, so the advisory /// tripwire SKIPS them — issues, no audit row. This is the case of a USD /// subscription renewal, where the provider charges in the listed fiat currency /// (not sats) and `amount_sats` is not the charged amount. Regression guard for /// the false-positive a naive SAT comparison would emit on every fiat renewal. #[tokio::test] async fn settled_non_sat_settle_skips_amount_tripwire() { let (state, _tmp) = install_mock_provider(MockPaymentProvider::new_settled_with_amount( Money { currency: "USD".to_string(), amount: 999, }, )) .await; let product = repo::create_product(&state.db, "non-sat-test", "Non-SAT Test", "", 7_000, &json!({})) .await .expect("create_product"); let internal_invoice_id = Uuid::new_v4().to_string(); let provider_invoice_id = "mock-inv-nonsat".to_string(); repo::create_invoice( &state.db, &internal_invoice_id, &provider_invoice_id, &product.id, 7_000, "http://mock-checkout.test/i/nonsat", None, None, None, None, ) .await .expect("create_invoice"); let req = build_request( "POST", "/v1/btcpay/webhook", &[("content-type", "application/json")], Some(json!({ "kind": "settled", "provider_invoice_id": provider_invoice_id })), ); assert_eq!(send(&state, req).await.status(), StatusCode::OK); let licenses: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses") .fetch_one(&state.db) .await .unwrap(); assert_eq!(licenses, 1, "non-SAT settle must still issue the license"); let mismatches: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM audit_log WHERE action = 'invoice.amount_mismatch'", ) .fetch_one(&state.db) .await .unwrap(); assert_eq!( mismatches, 0, "non-SAT settle has no SAT comparison basis — skip, do NOT audit as a mismatch" ); } /// When the provider reports no parseable amount (`None`), the tripwire has no /// opinion and is skipped: the license issues and no `invoice.amount_mismatch` /// row is written. Pins the "None = skip, not mismatch" contract. #[tokio::test] async fn settled_without_provider_amount_skips_tripwire() { // make_test_state_with_mock_provider uses MockPaymentProvider::new() — // confirms Settled but reports no amount (settled_amount = None). let (state, _tmp) = make_test_state_with_mock_provider().await; let product = repo::create_product(&state.db, "none-amt-test", "None Amt", "", 5_000, &json!({})) .await .expect("create_product"); let internal_invoice_id = Uuid::new_v4().to_string(); let provider_invoice_id = "mock-inv-noneamt".to_string(); repo::create_invoice( &state.db, &internal_invoice_id, &provider_invoice_id, &product.id, 5_000, "http://mock-checkout.test/i/noneamt", None, None, None, None, ) .await .expect("create_invoice"); let req = build_request( "POST", "/v1/btcpay/webhook", &[("content-type", "application/json")], Some(json!({ "kind": "settled", "provider_invoice_id": provider_invoice_id })), ); assert_eq!(send(&state, req).await.status(), StatusCode::OK); let licenses: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses") .fetch_one(&state.db) .await .unwrap(); assert_eq!(licenses, 1); let mismatches: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM audit_log WHERE action = 'invoice.amount_mismatch'", ) .fetch_one(&state.db) .await .unwrap(); assert_eq!(mismatches, 0, "no provider amount → tripwire skipped, no audit row"); } /// 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 None, // payment_provider_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, None, // payment_provider_id ) .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, Option, Option, Option, 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); } /// 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, None, // merchant_profile_id None, // payment_provider_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) = 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 = 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" ); } /// Mint a scoped API key of `role` via the master-authed create endpoint and /// return its raw bearer token. Exercises the real issue path the same way an /// operator would. async fn mint_scoped_key(state: &AppState, role: &str) -> String { let auth = format!("Bearer {}", TEST_ADMIN_KEY); let req = build_request( "POST", "/v1/admin/api-keys", &[("authorization", &auth)], Some(json!({ "label": format!("{role} key"), "role": role })), ); let resp = send(state, req).await; assert_eq!(resp.status(), StatusCode::OK, "minting a {role} key should succeed"); body_json(resp) .await .get("token") .and_then(|t| t.as_str()) .expect("create returns the raw token once") .to_string() } /// Read-only scoped keys can hit read endpoints but are 403 on writes, and are /// still denied the endpoints we deliberately keep master-only (db-info). #[tokio::test] async fn scoped_read_only_key_reads_but_cannot_write() { let (state, _tmp) = make_test_state().await; let auth = format!("Bearer {}", mint_scoped_key(&state, "read-only").await); // Read endpoint — allowed (every role grants `:read`). Use a param-free // getter so the only gate exercised is the scope check (GET // /v1/admin/licenses requires a product_id query param that 400s at the // extractor before auth even runs). let req = build_request( "GET", "/v1/admin/settings/operator-name", &[("authorization", &auth)], None, ); assert_eq!(send(&state, req).await.status(), StatusCode::OK); // db-info stays master-only even for reads. let req = build_request("GET", "/v1/admin/db-info", &[("authorization", &auth)], None); assert_eq!( send(&state, req).await.status(), StatusCode::FORBIDDEN, "db-info is master-only; a read-only scoped key must be denied" ); // Write endpoint — denied (products:write is full-admin only). let req = build_request( "POST", "/v1/admin/products", &[("authorization", &auth)], Some(json!({ "slug": "ro-denied", "name": "Nope", "price_sats": 1000 })), ); assert_eq!(send(&state, req).await.status(), StatusCode::FORBIDDEN); } /// License-issuer scoped keys can issue licenses (licenses:write) but cannot /// manage the catalog (products:write is full-admin only). #[tokio::test] async fn scoped_license_issuer_key_issues_but_cannot_manage_catalog() { let (state, _tmp) = make_test_state().await; let master = format!("Bearer {}", TEST_ADMIN_KEY); // Master seeds a product to issue against. let req = build_request( "POST", "/v1/admin/products", &[("authorization", &master)], Some(json!({ "slug": "issuer-prod", "name": "Issuer Prod", "price_sats": 1000 })), ); assert_eq!(send(&state, req).await.status(), StatusCode::OK); let auth = format!("Bearer {}", mint_scoped_key(&state, "license-issuer").await); // Issue a license — allowed. let req = build_request( "POST", "/v1/admin/licenses", &[("authorization", &auth)], Some(json!({ "product_slug": "issuer-prod" })), ); assert_eq!( send(&state, req).await.status(), StatusCode::OK, "license-issuer must be able to issue licenses" ); // Create a product — denied. let req = build_request( "POST", "/v1/admin/products", &[("authorization", &auth)], Some(json!({ "slug": "issuer-cant", "name": "Nope", "price_sats": 1000 })), ); assert_eq!( send(&state, req).await.status(), StatusCode::FORBIDDEN, "license-issuer must NOT manage the catalog" ); } /// Support scoped keys are granted subscription/machine writes but not catalog /// writes. The cancel of a nonexistent subscription is expected to fail /// downstream (not found) — what matters is that authorization PASSED (not /// 401/403), which isolates the scope grant from the business logic. #[tokio::test] async fn scoped_support_key_allowed_support_writes_not_catalog() { let (state, _tmp) = make_test_state().await; let auth = format!("Bearer {}", mint_scoped_key(&state, "support").await); // subscriptions:write — auth passes; missing sub yields a non-403/401 status. let req = build_request( "POST", "/v1/admin/subscriptions/does-not-exist/cancel", &[("authorization", &auth)], None, ); let status = send(&state, req).await.status(); assert_ne!(status, StatusCode::FORBIDDEN, "support is granted subscriptions:write"); assert_ne!(status, StatusCode::UNAUTHORIZED); // Catalog write — denied. let req = build_request( "POST", "/v1/admin/products", &[("authorization", &auth)], Some(json!({ "slug": "sup-cant", "name": "Nope", "price_sats": 1000 })), ); assert_eq!(send(&state, req).await.status(), StatusCode::FORBIDDEN); } /// Full-admin scoped keys CAN manage the catalog (products:write). The /// master-only denial (minting other keys, etc.) is covered by /// `scoped_api_key_management_rejects_scoped_full_admin`. #[tokio::test] async fn scoped_full_admin_key_manages_catalog() { let (state, _tmp) = make_test_state().await; let auth = format!("Bearer {}", mint_scoped_key(&state, "full-admin").await); let req = build_request( "POST", "/v1/admin/products", &[("authorization", &auth)], Some(json!({ "slug": "fa-prod", "name": "FA Prod", "price_sats": 1000 })), ); assert_eq!( send(&state, req).await.status(), StatusCode::OK, "full-admin must be able to manage the catalog" ); } /// 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"); } /// Audit coverage for the `:52` merchant-profile / payment-provider model. /// These queries are runtime-prepared (`sqlx::query(&format!(...))`), so column /// errors only surface when executed — and the resolution path had no passing /// test, which let an ambiguous-column bug ship in `get_merchant_profile_for_product`. /// This drives the write + preference + resolution queries end to end so the /// whole `:52` SQL surface is exercised by at least one green test. #[tokio::test] async fn merchant_profile_provider_resolution_queries_round_trip() { let (state, _tmp) = make_test_state().await; let now = "2026-06-12T00:00:00Z"; // Default profile is created by migration 0020. let default = repo::get_default_merchant_profile(&state.db) .await .expect("get_default_merchant_profile") .expect("a default profile exists post-migration"); // Profile CRUD reads/writes. let p2 = Uuid::new_v4().to_string(); repo::create_merchant_profile( &state.db, &p2, "Recaps", None, None, None, None, None, false, now, ) .await .expect("create_merchant_profile"); repo::get_merchant_profile_by_id(&state.db, &p2) .await .expect("get_merchant_profile_by_id") .expect("created profile exists"); assert!( repo::list_merchant_profiles(&state.db) .await .expect("list_merchant_profiles") .len() >= 2 ); // Attach a BTCPay provider to the default profile (store_id present so // build_provider can construct a client without a network call). let prov = Uuid::new_v4().to_string(); repo::create_payment_provider( &state.db, &prov, &default.id, "btcpay", "Test BTCPay", "api-key", "http://btcpay.test", Some("wh-1"), Some("secret"), Some("store-1"), now, ) .await .expect("create_payment_provider"); repo::get_payment_provider_by_id(&state.db, &prov) .await .expect("get_payment_provider_by_id") .expect("created provider exists"); assert_eq!( repo::list_payment_providers_for_profile(&state.db, &default.id) .await .expect("list_payment_providers_for_profile") .len(), 1 ); repo::list_all_payment_providers(&state.db) .await .expect("list_all_payment_providers"); // Rail preference write + read. repo::set_rail_preference(&state.db, &default.id, "lightning", &prov) .await .expect("set_rail_preference"); assert_eq!( repo::list_rail_preferences_for_profile(&state.db, &default.id) .await .expect("list_rail_preferences_for_profile") .len(), 1 ); // The production purchase path's resolution, both branches: // - Lightning resolves via the explicit preference just set. // - OnChain has no preference but BTCPay serves it → served-rail fallback. let (row_pref, _p) = state .resolve_provider_for_profile_rail(&default.id, keysat::payment::Rail::Lightning) .await .expect("resolve lightning via explicit preference"); assert_eq!(row_pref.id, prov); let (row_fallback, _p) = state .resolve_provider_for_profile_rail(&default.id, keysat::payment::Rail::Onchain) .await .expect("resolve onchain via served-rail fallback"); assert_eq!(row_fallback.id, prov); }