diff --git a/licensing-service/src/api/mod.rs b/licensing-service/src/api/mod.rs index d9c32e6..a56197e 100644 --- a/licensing-service/src/api/mod.rs +++ b/licensing-service/src/api/mod.rs @@ -108,6 +108,15 @@ pub struct AppState { /// `Arc` so call sites get cheap clones; swapped under a /// write lock when the operator runs Connect / Disconnect. pub payment: Arc>>>, + /// Test-only injection seam. When `Some`, the merchant-profile + /// resolver (`resolve_provider_for_profile_rail`, `payment_provider_by_id`) + /// returns THIS provider instead of constructing a real BTCPay/Zaprite + /// client from the DB row via `payment::build_provider`. The DB still + /// drives profile/rail/row resolution, so that logic is exercised for + /// real — only the network-talking impl is swapped. Always `None` in + /// production (`main.rs`); set by integration tests so they can drive + /// the real purchase/settle path with a `MockPaymentProvider`. + pub provider_override: Option>, pub config: Arc, /// Keysat-licenses-Keysat tier. Read at boot, swapped when the /// operator activates a fresh license via the admin endpoint. @@ -199,7 +208,20 @@ impl AppState { .ok_or_else(|| { AppError::NotFound(format!("payment provider {provider_id}")) })?; - crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref()) + self.provider_from_row(&row) + } + + /// Instantiate a `PaymentProvider` from a resolved DB row, honoring the + /// test-only `provider_override` seam. In production `provider_override` + /// is always `None`, so this just delegates to `payment::build_provider`. + fn provider_from_row( + &self, + row: &crate::db::repo::PaymentProviderRow, + ) -> AppResult> { + if let Some(p) = &self.provider_override { + return Ok(p.clone()); + } + crate::payment::build_provider(row, self.config.btcpay_public_url.as_deref()) .map_err(AppError::Internal) } @@ -241,9 +263,7 @@ impl AppState { pref.payment_provider_id )) })?; - let provider = - crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref()) - .map_err(AppError::Internal)?; + let provider = self.provider_from_row(&row)?; return Ok((row, provider)); } @@ -271,9 +291,7 @@ impl AppState { ))), [only] => { let row = (*only).clone(); - let provider = - crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref()) - .map_err(AppError::Internal)?; + let provider = self.provider_from_row(&row)?; Ok((row, provider)) } [first, ..] => { @@ -287,9 +305,7 @@ impl AppState { this warning." ); let row = (*first).clone(); - let provider = - crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref()) - .map_err(AppError::Internal)?; + let provider = self.provider_from_row(&row)?; Ok((row, provider)) } } diff --git a/licensing-service/src/api/webhook.rs b/licensing-service/src/api/webhook.rs index e63916c..3799c38 100644 --- a/licensing-service/src/api/webhook.rs +++ b/licensing-service/src/api/webhook.rs @@ -117,6 +117,50 @@ async fn handle_inner( "webhook event applied" ); + // Anti-forgery: never settle on the webhook body's claim alone. Re-fetch + // the authoritative status from the provider's own API and require it to + // actually be Settled before we mark the invoice paid or take ANY + // settle-derived action. This guard runs ahead of every downstream effect + // — status persistence, tier-change application, subscription renewal, and + // license issuance — so confirming once here gates all of them. + // This is load-bearing for providers without webhook signatures: Zaprite + // webhooks carry no HMAC, so a forged `order.change`/`status=PAID` POST + // with a buyer-visible order id would otherwise mint a free license. The + // re-fetch also defeats replay of a stale settled body against an invoice + // that has since expired/refunded (the provider reports the current state, + // not the replayed one). BTCPay is HMAC-verified upstream and is settled + // already, so this is cheap belt-and-suspenders there. On a provider + // error we fail closed — the reconcile loop re-confirms on its next tick. + if new_status == "settled" { + match provider.get_invoice_status(&provider_invoice_id).await { + Ok(crate::payment::ProviderInvoiceStatus::Settled) => {} + Ok(other) => { + tracing::warn!( + provider = provider.kind().as_str(), + provider_invoice_id = %provider_invoice_id, + provider_status = ?other, + "settle webhook NOT confirmed by provider API; refusing to settle/issue" + ); + return Ok(StatusCode::OK); + } + Err(e) => { + // Ack 200 rather than erroring: a non-2xx makes BTCPay/Zaprite + // re-deliver aggressively, so a transient provider-API outage + // would turn every in-flight webhook into a retry storm. We + // simply don't issue now — the reconcile loop re-fetches the + // status on its next tick and issues then, so issuance is still + // "fail closed" without depending on this delivery. + tracing::warn!( + provider = provider.kind().as_str(), + provider_invoice_id = %provider_invoice_id, + error = format!("{e:#}"), + "could not reach provider to confirm settle; not issuing now, deferring to reconciler" + ); + return Ok(StatusCode::OK); + } + } + } + // Persist status. repo::update_invoice_status(&state.db, &provider_invoice_id, new_status).await?; diff --git a/licensing-service/src/main.rs b/licensing-service/src/main.rs index 254e1e4..ad7afff 100644 --- a/licensing-service/src/main.rs +++ b/licensing-service/src/main.rs @@ -128,6 +128,7 @@ async fn main() -> anyhow::Result<()> { db: pool, keypair: Arc::new(keypair), payment: Arc::new(tokio::sync::RwLock::new(provider)), + provider_override: None, config: Arc::new(cfg.clone()), self_tier, rates: keysat::rates::RateCache::new(), diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs index a301723..bc4607e 100644 --- a/licensing-service/tests/api.rs +++ b/licensing-service/tests/api.rs @@ -109,6 +109,7 @@ async fn make_test_state() -> (AppState, NamedTempFile) { 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(), @@ -379,14 +380,45 @@ async fn validate_accepts_well_formed_license() { // 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, } 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), + } + } + + /// 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), + } + } + + /// 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, } } } @@ -412,9 +444,15 @@ impl PaymentProvider for MockPaymentProvider { &self, _provider_invoice_id: &str, ) -> Result { - // Reconcile loop isn't exercised by these tests; return a sane - // default in case it gets called transitively. - Ok(ProviderInvoiceStatus::Settled) + // 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(s), + StatusReport::Unavailable => { + anyhow::bail!("mock: provider status API unavailable") + } + } } /// Test-friendly webhook validator. Production providers would @@ -455,10 +493,49 @@ impl PaymentProvider for MockPaymentProvider { /// Build a state with a MockPaymentProvider already installed. Mirror of /// `make_test_state` for tests that drive the purchase / webhook paths. async fn make_test_state_with_mock_provider() -> (AppState, NamedTempFile) { - let (state, tmp) = make_test_state().await; - state - .set_payment_provider(Arc::new(MockPaymentProvider::new())) - .await; + 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) } @@ -618,6 +695,149 @@ async fn paid_purchase_creates_invoice_via_provider() { 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)" + ); +} + /// 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 @@ -1207,163 +1427,6 @@ async fn paid_purchase_in_usd_records_listed_currency_and_rate() { assert_eq!(row.4, 98_000); } -/// Active-provider preference round-trip. Pins the contract that -/// `Activate ` flips both the in-memory provider AND the -/// persisted preference so the next daemon boot picks the same one. -/// -/// Simulates the operator's lifecycle: -/// 1. Configure both BTCPay and Zaprite (both rows in DB) -/// 2. Activate Zaprite → preference flag = "zaprite" -/// 3. Activate BTCPay → preference flag = "btcpay" -/// 4. Disconnect BTCPay → preference flag cleared (because it -/// pointed at the wiped config) -/// 5. Disconnect Zaprite while preference was already "btcpay" -/// → preference NOT cleared (stays at "btcpay" because it -/// was pointing at a different provider) -#[tokio::test] -async fn payment_provider_preference_round_trip() { - use keysat::payment::{self, ProviderKind}; - - let (state, _tmp) = make_test_state().await; - let auth = format!("Bearer {}", TEST_ADMIN_KEY); - - // Zaprite activation requires the `zaprite_payments` entitlement - // (Pro tier and above). Pin the daemon's self-tier to a Licensed - // tier carrying that entitlement so the activate path doesn't - // 402. BTCPay is unconditional and works at every tier. - { - let mut guard = state.self_tier.write().await; - *guard = keysat::license_self::Tier::Licensed { - license_id: uuid::Uuid::new_v4(), - product_id: uuid::Uuid::new_v4(), - expires_at: 0, - entitlements: vec![ - "unlimited_products".to_string(), - "unlimited_policies".to_string(), - "unlimited_codes".to_string(), - "recurring_billing".to_string(), - "zaprite_payments".to_string(), - ], - }; - } - - // Pre-seed both configs as if the operator had run Connect on - // each at some point. We bypass the actual Connect endpoints - // because they call out to BTCPay / Zaprite to validate the - // credentials, which we don't want to do in unit tests. - let now = Utc::now().to_rfc3339(); - sqlx::query( - "INSERT INTO btcpay_config(id, base_url, api_key, store_id, webhook_id, \ - webhook_secret, connected_at) \ - VALUES(1, 'http://btcpay.test', 'btcpay-key', 'store-1', 'wh-1', \ - '0123456789abcdef', ?)", - ) - .bind(&now) - .execute(&state.db) - .await - .unwrap(); - sqlx::query( - "INSERT INTO zaprite_config(id, api_key, base_url, webhook_id, connected_at, updated_at) \ - VALUES(1, 'zaprite-key', 'https://api.zaprite.test', NULL, ?, ?)", - ) - .bind(&now) - .bind(&now) - .execute(&state.db) - .await - .unwrap(); - - // Step 1: no preference recorded yet. - let pref = payment::read_active_provider_preference(&state.db).await; - assert_eq!(pref, None); - - // Step 2: GET status surfaces both as configured, no active yet. - let req = build_request( - "GET", - "/v1/admin/payment-provider/status", - &[("authorization", &auth)], - None, - ); - let resp = send(&state, req).await; - assert_eq!(resp.status(), StatusCode::OK); - let body = body_json(resp).await; - assert_eq!(body["btcpay_configured"], true); - assert_eq!(body["zaprite_configured"], true); - assert!(body["preferred"].is_null()); - - // Step 3: Activate Zaprite. The endpoint reads the saved - // zaprite_config to build the provider — the saved key - // 'zaprite-key' won't talk to a real API but the activate - // path doesn't ping; that's only on Connect. - let req = build_request( - "POST", - "/v1/admin/payment-provider/activate", - &[("authorization", &auth)], - Some(json!({"provider": "zaprite"})), - ); - let resp = send(&state, req).await; - assert_eq!( - resp.status(), - StatusCode::OK, - "activate zaprite should succeed when zaprite_config is present" - ); - let pref = payment::read_active_provider_preference(&state.db).await; - assert_eq!(pref, Some(ProviderKind::Zaprite)); - - // Step 4: Activate BTCPay. Preference flips. - let req = build_request( - "POST", - "/v1/admin/payment-provider/activate", - &[("authorization", &auth)], - Some(json!({"provider": "btcpay"})), - ); - let resp = send(&state, req).await; - assert_eq!(resp.status(), StatusCode::OK); - let pref = payment::read_active_provider_preference(&state.db).await; - assert_eq!(pref, Some(ProviderKind::Btcpay)); - - // Step 5: Activate something that's not configured. Should 400. - sqlx::query("DELETE FROM zaprite_config WHERE id = 1") - .execute(&state.db) - .await - .unwrap(); - let req = build_request( - "POST", - "/v1/admin/payment-provider/activate", - &[("authorization", &auth)], - Some(json!({"provider": "zaprite"})), - ); - let resp = send(&state, req).await; - assert_eq!( - resp.status(), - StatusCode::BAD_REQUEST, - "activating an unconfigured provider must 400 with 'run Connect first'" - ); - - // Step 6: Bad provider name → 400. - let req = build_request( - "POST", - "/v1/admin/payment-provider/activate", - &[("authorization", &auth)], - Some(json!({"provider": "stripe"})), - ); - let resp = send(&state, req).await; - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); - - // Step 7: write_active_provider_preference invariant — - // explicit setting survives a re-read (durability across the - // simulated restart that the boot-time loader cares about). - payment::write_active_provider_preference(&state.db, ProviderKind::Btcpay) - .await - .unwrap(); - let pref = payment::read_active_provider_preference(&state.db).await; - assert_eq!(pref, Some(ProviderKind::Btcpay)); - payment::write_active_provider_preference(&state.db, ProviderKind::Zaprite) - .await - .unwrap(); - let pref = payment::read_active_provider_preference(&state.db).await; - assert_eq!(pref, Some(ProviderKind::Zaprite)); -} - /// Zaprite webhook authentication contract. /// /// Zaprite doesn't sign webhooks (verified May 2026 — no HMAC, diff --git a/licensing-service/tests/subscriptions.rs b/licensing-service/tests/subscriptions.rs index 26bdbc7..378f188 100644 --- a/licensing-service/tests/subscriptions.rs +++ b/licensing-service/tests/subscriptions.rs @@ -85,6 +85,7 @@ async fn make_state() -> (AppState, NamedTempFile, Arc) { payment: Arc::new(RwLock::new(Some( mock.clone() as Arc, ))), + provider_override: None, config: Arc::new(cfg), self_tier: Arc::new(RwLock::new(Tier::Unlicensed { reason: "test".into(), diff --git a/licensing-service/tests/upgrades.rs b/licensing-service/tests/upgrades.rs index cdbf0cc..498ce53 100644 --- a/licensing-service/tests/upgrades.rs +++ b/licensing-service/tests/upgrades.rs @@ -66,6 +66,7 @@ async fn make_state() -> (AppState, NamedTempFile) { 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".into(), diff --git a/licensing-service/tests/worker.rs b/licensing-service/tests/worker.rs index f29687e..328b09c 100644 --- a/licensing-service/tests/worker.rs +++ b/licensing-service/tests/worker.rs @@ -65,6 +65,7 @@ async fn make_state() -> (AppState, NamedTempFile) { 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".into(),