From c11764898b69920d3ff03a4e19dbe9effbd571fd Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 8 May 2026 09:24:57 -0500 Subject: [PATCH] =?UTF-8?q?v0.1.0:42=20=E2=80=94=20webhook=20idempotency?= =?UTF-8?q?=20test=20+=20free-purchase=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new API integration tests, both targeting production-correctness invariants worth locking down: - free_purchase_issues_license_inline: exercises the price=0 shortcut (price_sats_override=0 on a "free" tier policy). Verifies the daemon synthesizes a settled invoice locally, issues a license inline, and the inlined license_key validates round-trip via /v1/validate. - webhook_settles_invoice_and_issues_license_idempotently: the most important new test in this set. A pending invoice + an InvoiceSettled webhook → license issued, status flipped. Re-delivering the SAME webhook (which providers DO retry, sometimes aggressively) must NOT duplicate the license. A duplicated license here means duplicated revenue and duplicated revocation surface area — both bad. This test pins the invariant. MockPaymentProvider added to tests/api.rs: a test-only PaymentProvider impl that bypasses HMAC verification and parses test-supplied JSON bodies into ProviderWebhookEvent variants. Lets us drive deterministic settle/expire/invalid events without a real BTCPay roundtrip. Never compiled into the production binary. Paid-purchase test deferred: purchase::start still uses the legacy state.btcpay_client() compat accessor that downcasts to the concrete BtcpayProvider, which the mock can't satisfy. Documented inline. Slots in trivially after the trait migration on the v0.3 backlog. Version bump to v0.1.0:42 with release notes covering everything since :41 was published: lib.rs library refactor, the original 5 API tests from 81066df, the 2 new ones above, KEYSAT_INTEGRATION.md restoration. No daemon-behaviour changes for operators; straight drop-in upgrade from :41. Test count: 20 (9 unit + 4 migration + 7 API), up from 13 in :41. --- licensing-service/tests/api.rs | 306 ++++++++++++++++++++++++++++++++- startos/versions/v0.1.0.ts | 18 +- 2 files changed, 322 insertions(+), 2 deletions(-) diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs index fdc5093..7d8d8d6 100644 --- a/licensing-service/tests/api.rs +++ b/licensing-service/tests/api.rs @@ -11,8 +11,9 @@ //! 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::{Request, StatusCode}; +use axum::http::{HeaderMap, Request, StatusCode}; use axum::response::Response; use chrono::Utc; use keysat::api::{self, AppState}; @@ -20,12 +21,18 @@ use keysat::config::Config; use keysat::crypto::{self, LicensePayload}; use keysat::db::repo; use keysat::license_self::Tier; +use keysat::payment::{ + CreateInvoiceParams, CreatedInvoiceHandle, PaymentProvider, ProviderInvoiceStatus, + ProviderKind, ProviderWebhookEvent, +}; use serde_json::{json, Value}; use sqlx::sqlite::{ SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteSynchronous, }; +use std::any::Any; use std::path::PathBuf; use std::str::FromStr; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; use tempfile::NamedTempFile; @@ -362,3 +369,300 @@ async fn validate_accepts_well_formed_license() { assert_eq!(body["product_id"], product.id); assert_eq!(body["status"], "active"); } + +// --------------------------------------------------------------------- +// MockPaymentProvider — exercises the purchase + webhook code paths +// without talking to a real BTCPay. Reports kind=Btcpay so the daemon's +// BTCPay-specific compat accessors keep working; produces deterministic +// invoice ids so tests can assert on them; bypasses HMAC verification +// in `validate_webhook` and instead parses the test-supplied JSON body. +// --------------------------------------------------------------------- + +struct MockPaymentProvider { + next_invoice_id: AtomicU64, +} + +impl MockPaymentProvider { + fn new() -> Self { + Self { + next_invoice_id: AtomicU64::new(1), + } + } +} + +#[async_trait::async_trait] +impl PaymentProvider for MockPaymentProvider { + fn kind(&self) -> ProviderKind { + ProviderKind::Btcpay + } + + async fn create_invoice( + &self, + _params: CreateInvoiceParams<'_>, + ) -> Result { + 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 { + // Reconcile loop isn't exercised by these tests; return a sane + // default in case it gets called transitively. + Ok(ProviderInvoiceStatus::Settled) + } + + /// Test-friendly webhook validator. Production providers would + /// HMAC-verify the body; we instead parse the body as JSON of + /// shape `{"kind": "settled"|"expired"|"invalid"|"refunded"|, + /// "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) { + let (state, tmp) = make_test_state().await; + state + .set_payment_provider(Arc::new(MockPaymentProvider::new())) + .await; + (state, tmp) +} + +// --------------------------------------------------------------------- +// Purchase + webhook tests +// --------------------------------------------------------------------- + +/// The free-tier shortcut: when post-discount, post-policy-override +/// price is 0 sats, the daemon synthesizes a settled invoice locally, +/// issues a license inline, and returns the signed key in the response. +/// No payment provider involved — `payment` stays `None`. This test +/// verifies that fast path end-to-end. +#[tokio::test] +async fn free_purchase_issues_license_inline() { + let (state, _tmp) = make_test_state().await; + let now = Utc::now().to_rfc3339(); + + // Seed a product (price > 0) plus a "free" policy that overrides + // the price to 0 sats. This is the common shape: paid product with + // an optional free tier on the buy page. + let product = repo::create_product( + &state.db, + "free-test", + "Free Test", + "", + 10_000, + &json!({}), + ) + .await + .expect("create_product"); + + sqlx::query( + "INSERT INTO policies(id, product_id, name, slug, price_sats_override, \ + max_machines, public, created_at, updated_at) \ + VALUES('pol-free', ?, 'Free', 'free', 0, 1, 1, ?, ?)", + ) + .bind(&product.id) + .bind(&now) + .bind(&now) + .execute(&state.db) + .await + .expect("insert free policy"); + + let req = build_request( + "POST", + "/v1/purchase", + &[], + Some(json!({ + "product": "free-test", + "policy_slug": "free" + })), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::OK); + + let body = body_json(resp).await; + assert_eq!( + body["amount_sats"], 0, + "free policy should produce zero-sat invoice" + ); + assert!( + body["license_key"].is_string(), + "free purchase should return license inline: {body:?}" + ); + assert_eq!(body["checkout_url"], ""); + + // License row exists in DB. + let licenses: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses") + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(licenses, 1, "exactly one license should be issued"); + + // The inline license_key validates round-trip via /v1/validate. + let key = body["license_key"].as_str().unwrap().to_string(); + let req = build_request("POST", "/v1/validate", &[], Some(json!({"key": key}))); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let validation = body_json(resp).await; + assert_eq!( + validation["ok"], true, + "the inlined license_key must validate cleanly: {validation:?}" + ); +} + +// Note on the missing paid-purchase test: +// +// `purchase::start` still uses the legacy compat accessor +// `state.btcpay_client()`, which downcasts the active provider +// specifically to the concrete `BtcpayProvider` type rather than +// going through the `PaymentProvider` trait. A `MockPaymentProvider` +// can't satisfy that downcast — it'd need to BE a `BtcpayProvider`, +// which requires a working HTTP client. +// +// The fix is a small refactor of `purchase::start` to use +// `state.payment_provider().await?.create_invoice(...)` instead of +// the compat path. That's already on the v0.3 backlog (see +// `src/payment/mod.rs` "Why a trait" doc comment). Once it lands, a +// `paid_purchase_creates_invoice_via_provider` test slots right in. +// For now we test the webhook handler — which IS already on the +// trait surface — directly against a fixture invoice. + +/// The settle webhook: provider POSTs an InvoiceSettled event, daemon +/// flips the invoice status and issues a license. Re-POSTing the same +/// webhook (which providers DO retry, sometimes aggressively) must not +/// duplicate the license — idempotency is critical because a flaky +/// network or provider retries can deliver the same event multiple +/// times. This is the production-correctness invariant we most need to +/// hold. +#[tokio::test] +async fn webhook_settles_invoice_and_issues_license_idempotently() { + let (state, _tmp) = make_test_state_with_mock_provider().await; + + // Seed a product + a pending invoice directly via the repo (the + // HTTP purchase endpoint still uses BTCPay-specific compat code — + // see the comment block above). The webhook handler itself is on + // the abstract `PaymentProvider` trait, which the mock satisfies, + // so we can drive it through the router. + let product = repo::create_product( + &state.db, + "webhook-test", + "Webhook Test", + "", + 5_000, + &json!({}), + ) + .await + .expect("create_product"); + + let internal_invoice_id = Uuid::new_v4().to_string(); + let provider_invoice_id = "mock-inv-fixture".to_string(); + repo::create_invoice( + &state.db, + &internal_invoice_id, + &provider_invoice_id, + &product.id, + 5_000, + "http://mock-checkout.test/i/1", + None, // buyer_email + None, // buyer_note + None, // policy_id + ) + .await + .expect("create_invoice"); + + // First webhook delivery: daemon flips invoice → settled, issues + // license. + let webhook_body = json!({ + "kind": "settled", + "provider_invoice_id": provider_invoice_id, + }); + let req = build_request( + "POST", + "/v1/btcpay/webhook", + &[("content-type", "application/json")], + Some(webhook_body.clone()), + ); + let resp = send(&state, req).await; + assert_eq!( + resp.status(), + StatusCode::OK, + "settle webhook should ack 200" + ); + + // Verify state changes. + let status_after_first: String = sqlx::query_scalar( + "SELECT status FROM invoices WHERE btcpay_invoice_id = ?", + ) + .bind(&provider_invoice_id) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(status_after_first, "settled"); + + let licenses_after_first: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses") + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!( + licenses_after_first, 1, + "first settle webhook should issue exactly one license" + ); + + // Re-deliver the same webhook. Daemon must NOT issue a second + // license — provider retries are routine and a duplicated license + // means duplicated revenue or duplicated revocation surface area. + let req = build_request( + "POST", + "/v1/btcpay/webhook", + &[("content-type", "application/json")], + Some(webhook_body), + ); + let resp = send(&state, req).await; + assert_eq!( + resp.status(), + StatusCode::OK, + "redelivered webhook should also ack 200" + ); + + let licenses_after_second: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses") + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!( + licenses_after_second, 1, + "redelivered settle webhook MUST NOT duplicate the license" + ); +} diff --git a/startos/versions/v0.1.0.ts b/startos/versions/v0.1.0.ts index fdec4c3..63898a0 100644 --- a/startos/versions/v0.1.0.ts +++ b/startos/versions/v0.1.0.ts @@ -9,8 +9,24 @@ import { VersionInfo } from '@start9labs/start-sdk' export const v0_1_0 = VersionInfo.of({ - version: '0.1.0:41', + version: '0.1.0:42', releaseNotes: [ + `Alpha-iteration revision 42 of v0.1.0 — Test infrastructure expansion. No daemon-behaviour changes for operators; this release ships safety nets for future development. Skip if v0.1.0:41 is running cleanly — the next release with new functionality will roll forward whatever shipped here.`, + ``, + `**API integration tests.** New \`tests/api.rs\` with seven integration tests that drive real HTTP requests through the daemon's \`axum::Router\` against a real SQLite tempfile. Covers: health endpoint, admin auth (401 vs 403 paths), admin product creation (full happy path: auth → handler → DB → audit log → response), license validation (parse-fail surface plus full crypto round-trip), free-tier purchase with inline license issuance, and BTCPay webhook idempotency (re-delivered settle webhooks MUST NOT duplicate the license — the production-correctness invariant we most needed to lock down).`, + ``, + `**Library refactor.** Adds \`src/lib.rs\` that re-exports the daemon's modules so integration tests under \`tests/\` can drive \`AppState\` + \`Router\` directly. Production binary at \`src/main.rs\` is now a thin wrapper that imports from the library. No runtime behaviour change — same module sources, same compiled binary modulo build metadata.`, + ``, + `**MockPaymentProvider.** A test-only \`PaymentProvider\` impl that bypasses HMAC signature verification and parses test-supplied JSON bodies into \`ProviderWebhookEvent\` variants. Used by the webhook test to drive deterministic settle / expire / invalid events without a real BTCPay round-trip. Lives entirely in \`tests/api.rs\` — never compiled into the production binary.`, + ``, + `**Test count.** 9 unit + 4 migration + 7 API = 20 tests, up from 13 in v0.1.0:41. \`cargo test\` exercises the full set in under a second.`, + ``, + `**KEYSAT_INTEGRATION.md restored.** v0.1.0:41's release notes erroneously claimed the file was "removed from this repo" — that was a misread of intent. The 1378-line integration guide is back at the daemon repo's root where integrators (and LLMs running fresh against a target codebase) expect to find it via \`https://raw.githubusercontent.com/keysat-xyz/keysat/main/KEYSAT_INTEGRATION.md\`. The 12-line stub at the parent licensing/ folder was removed since it referred to a "moved" arrangement that no longer applies.`, + ``, + `**Known limitation: paid-purchase test deferred.** The HTTP \`/v1/purchase\` endpoint's paid path still uses the legacy \`state.btcpay_client()\` compat accessor (which downcasts the active provider specifically to the concrete \`BtcpayProvider\` type) rather than the abstract \`PaymentProvider\` trait. A \`MockPaymentProvider\` can't satisfy that downcast. Migrating \`purchase::start\` to \`state.payment_provider().await?.create_invoice(...)\` is a small refactor on the v0.3 backlog; once it lands, a paid-purchase integration test slots right in. The webhook handler IS already on the trait surface, which is why the webhook idempotency test works.`, + ``, + `**Upgrade path.** v0.1.0:41 → v0.1.0:42 is a straight drop-in. No new migrations (the migration set still ends at 0009). No schema changes. No on-disk format changes. If you hit a checksum-mismatch error on boot, you're upgrading from a version older than :41 — see :41's release notes for the recovery command.`, + ``, `Alpha-iteration revision 41 of v0.1.0 — Second hotfix to migration 0009. v40 passed on a clean install but crashed on any database with existing rows in discount_redemptions. Also lands the migration test infrastructure that would have caught this before it shipped.`, ``, `**The bug.** v40 rebuilt only the discount_codes table (using \`PRAGMA defer_foreign_keys = 1\` to avoid intra-transaction FK errors). On a clean install that works. On any install with even one row in discount_redemptions — the child table whose foreign key points back into discount_codes — it doesn't. SQLite's deferred FK check fires at COMMIT, sees the dropped parent's row-deletion bookkeeping as unsatisfied (regardless of whether the rebuilt table contains the same IDs), and rolls the whole transaction back with "FOREIGN KEY constraint failed (787)". Daemon kept restart-looping; API never came up.`,