v0.1.0:42 — webhook idempotency test + free-purchase test

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.
This commit is contained in:
Grant
2026-05-08 09:24:57 -05:00
parent 81066dfe62
commit c11764898b
2 changed files with 322 additions and 2 deletions
+305 -1
View File
@@ -11,8 +11,9 @@
//! pool, signing key, and admin token are reachable from inside the test //! pool, signing key, and admin token are reachable from inside the test
//! body. //! body.
use anyhow::Result;
use axum::body::{to_bytes, Body}; use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode}; use axum::http::{HeaderMap, Request, StatusCode};
use axum::response::Response; use axum::response::Response;
use chrono::Utc; use chrono::Utc;
use keysat::api::{self, AppState}; use keysat::api::{self, AppState};
@@ -20,12 +21,18 @@ use keysat::config::Config;
use keysat::crypto::{self, LicensePayload}; use keysat::crypto::{self, LicensePayload};
use keysat::db::repo; use keysat::db::repo;
use keysat::license_self::Tier; use keysat::license_self::Tier;
use keysat::payment::{
CreateInvoiceParams, CreatedInvoiceHandle, PaymentProvider, ProviderInvoiceStatus,
ProviderKind, ProviderWebhookEvent,
};
use serde_json::{json, Value}; use serde_json::{json, Value};
use sqlx::sqlite::{ use sqlx::sqlite::{
SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteSynchronous, SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteSynchronous,
}; };
use std::any::Any;
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
@@ -362,3 +369,300 @@ async fn validate_accepts_well_formed_license() {
assert_eq!(body["product_id"], product.id); assert_eq!(body["product_id"], product.id);
assert_eq!(body["status"], "active"); assert_eq!(body["status"], "active");
} }
// ---------------------------------------------------------------------
// MockPaymentProvider — exercises the purchase + webhook code paths
// without talking to a real BTCPay. Reports kind=Btcpay so the daemon's
// BTCPay-specific compat accessors keep working; produces deterministic
// invoice ids so tests can assert on them; bypasses HMAC verification
// in `validate_webhook` and instead parses the test-supplied JSON body.
// ---------------------------------------------------------------------
struct MockPaymentProvider {
next_invoice_id: AtomicU64,
}
impl MockPaymentProvider {
fn new() -> Self {
Self {
next_invoice_id: AtomicU64::new(1),
}
}
}
#[async_trait::async_trait]
impl PaymentProvider for MockPaymentProvider {
fn kind(&self) -> ProviderKind {
ProviderKind::Btcpay
}
async fn create_invoice(
&self,
_params: CreateInvoiceParams<'_>,
) -> Result<CreatedInvoiceHandle> {
let n = self.next_invoice_id.fetch_add(1, Ordering::SeqCst);
Ok(CreatedInvoiceHandle {
provider_invoice_id: format!("mock-inv-{n}"),
checkout_url: format!("http://mock-checkout.test/i/{n}"),
})
}
async fn get_invoice_status(
&self,
_provider_invoice_id: &str,
) -> Result<ProviderInvoiceStatus> {
// Reconcile loop isn't exercised by these tests; return a sane
// default in case it gets called transitively.
Ok(ProviderInvoiceStatus::Settled)
}
/// Test-friendly webhook validator. Production providers would
/// HMAC-verify the body; we instead parse the body as JSON of
/// shape `{"kind": "settled"|"expired"|"invalid"|"refunded"|<other>,
/// "provider_invoice_id": "..."}`. Tests construct their own
/// payloads with no signature ceremony.
fn validate_webhook(
&self,
_headers: &HeaderMap,
body: &[u8],
) -> Result<ProviderWebhookEvent> {
let v: Value = serde_json::from_slice(body)?;
let kind = v["kind"].as_str().unwrap_or("");
let id = v["provider_invoice_id"].as_str().unwrap_or("").to_string();
Ok(match kind {
"settled" => ProviderWebhookEvent::InvoiceSettled {
provider_invoice_id: id,
},
"expired" => ProviderWebhookEvent::InvoiceExpired {
provider_invoice_id: id,
},
"invalid" => ProviderWebhookEvent::InvoiceInvalid {
provider_invoice_id: id,
},
other => ProviderWebhookEvent::Other {
kind: other.to_string(),
provider_invoice_id: Some(id).filter(|s| !s.is_empty()),
},
})
}
fn as_any(&self) -> &dyn Any {
self
}
}
/// Build a state with a MockPaymentProvider already installed. Mirror of
/// `make_test_state` for tests that drive the purchase / webhook paths.
async fn make_test_state_with_mock_provider() -> (AppState, NamedTempFile) {
let (state, tmp) = make_test_state().await;
state
.set_payment_provider(Arc::new(MockPaymentProvider::new()))
.await;
(state, tmp)
}
// ---------------------------------------------------------------------
// Purchase + webhook tests
// ---------------------------------------------------------------------
/// The free-tier shortcut: when post-discount, post-policy-override
/// price is 0 sats, the daemon synthesizes a settled invoice locally,
/// issues a license inline, and returns the signed key in the response.
/// No payment provider involved — `payment` stays `None`. This test
/// verifies that fast path end-to-end.
#[tokio::test]
async fn free_purchase_issues_license_inline() {
let (state, _tmp) = make_test_state().await;
let now = Utc::now().to_rfc3339();
// Seed a product (price > 0) plus a "free" policy that overrides
// the price to 0 sats. This is the common shape: paid product with
// an optional free tier on the buy page.
let product = repo::create_product(
&state.db,
"free-test",
"Free Test",
"",
10_000,
&json!({}),
)
.await
.expect("create_product");
sqlx::query(
"INSERT INTO policies(id, product_id, name, slug, price_sats_override, \
max_machines, public, created_at, updated_at) \
VALUES('pol-free', ?, 'Free', 'free', 0, 1, 1, ?, ?)",
)
.bind(&product.id)
.bind(&now)
.bind(&now)
.execute(&state.db)
.await
.expect("insert free policy");
let req = build_request(
"POST",
"/v1/purchase",
&[],
Some(json!({
"product": "free-test",
"policy_slug": "free"
})),
);
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(
body["amount_sats"], 0,
"free policy should produce zero-sat invoice"
);
assert!(
body["license_key"].is_string(),
"free purchase should return license inline: {body:?}"
);
assert_eq!(body["checkout_url"], "");
// License row exists in DB.
let licenses: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses")
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(licenses, 1, "exactly one license should be issued");
// The inline license_key validates round-trip via /v1/validate.
let key = body["license_key"].as_str().unwrap().to_string();
let req = build_request("POST", "/v1/validate", &[], Some(json!({"key": key})));
let resp = send(&state, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let validation = body_json(resp).await;
assert_eq!(
validation["ok"], true,
"the inlined license_key must validate cleanly: {validation:?}"
);
}
// 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"
);
}
+17 -1
View File
@@ -9,8 +9,24 @@
import { VersionInfo } from '@start9labs/start-sdk' import { VersionInfo } from '@start9labs/start-sdk'
export const v0_1_0 = VersionInfo.of({ export const v0_1_0 = VersionInfo.of({
version: '0.1.0:41', version: '0.1.0:42',
releaseNotes: [ 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.`, `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.`, `**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.`,