Confirm settle with provider API before issuing; add test-injection seam
The settle-webhook honored payment on the webhook body's claim alone. Zaprite webhooks carry no signature, so a forged order.change/status=PAID POST with a buyer-visible order id minted a signed license without payment. handle_inner now re-fetches provider.get_invoice_status and requires Settled before persisting "settled" or taking any settle-derived action (issuance, tier-change, subscription renewal — the guard precedes all of them). On a provider-API error it acks 200 without issuing, so a transient outage can't trigger a webhook retry storm; the reconcile loop re-confirms and issues later. Adds the always-compiled AppState::provider_override seam (None in prod), honored by provider_from_row at every resolution site, so integration tests drive the real resolver with a MockPaymentProvider. Greens the two paid_purchase_* tests, deletes the dead payment_provider_preference_round_trip, and adds forged-settle + provider-unreachable regression tests. api 47/47. Not addressed: a literal paid-amount/currency check (needs a trait change).
This commit is contained in:
@@ -108,6 +108,15 @@ pub struct AppState {
|
|||||||
/// `Arc<dyn ...>` so call sites get cheap clones; swapped under a
|
/// `Arc<dyn ...>` so call sites get cheap clones; swapped under a
|
||||||
/// write lock when the operator runs Connect / Disconnect.
|
/// write lock when the operator runs Connect / Disconnect.
|
||||||
pub payment: Arc<RwLock<Option<Arc<dyn crate::payment::PaymentProvider>>>>,
|
pub payment: Arc<RwLock<Option<Arc<dyn crate::payment::PaymentProvider>>>>,
|
||||||
|
/// 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<Arc<dyn crate::payment::PaymentProvider>>,
|
||||||
pub config: Arc<Config>,
|
pub config: Arc<Config>,
|
||||||
/// Keysat-licenses-Keysat tier. Read at boot, swapped when the
|
/// Keysat-licenses-Keysat tier. Read at boot, swapped when the
|
||||||
/// operator activates a fresh license via the admin endpoint.
|
/// operator activates a fresh license via the admin endpoint.
|
||||||
@@ -199,7 +208,20 @@ impl AppState {
|
|||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
AppError::NotFound(format!("payment provider {provider_id}"))
|
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<Arc<dyn crate::payment::PaymentProvider>> {
|
||||||
|
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)
|
.map_err(AppError::Internal)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,9 +263,7 @@ impl AppState {
|
|||||||
pref.payment_provider_id
|
pref.payment_provider_id
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let provider =
|
let provider = self.provider_from_row(&row)?;
|
||||||
crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref())
|
|
||||||
.map_err(AppError::Internal)?;
|
|
||||||
return Ok((row, provider));
|
return Ok((row, provider));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,9 +291,7 @@ impl AppState {
|
|||||||
))),
|
))),
|
||||||
[only] => {
|
[only] => {
|
||||||
let row = (*only).clone();
|
let row = (*only).clone();
|
||||||
let provider =
|
let provider = self.provider_from_row(&row)?;
|
||||||
crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref())
|
|
||||||
.map_err(AppError::Internal)?;
|
|
||||||
Ok((row, provider))
|
Ok((row, provider))
|
||||||
}
|
}
|
||||||
[first, ..] => {
|
[first, ..] => {
|
||||||
@@ -287,9 +305,7 @@ impl AppState {
|
|||||||
this warning."
|
this warning."
|
||||||
);
|
);
|
||||||
let row = (*first).clone();
|
let row = (*first).clone();
|
||||||
let provider =
|
let provider = self.provider_from_row(&row)?;
|
||||||
crate::payment::build_provider(&row, self.config.btcpay_public_url.as_deref())
|
|
||||||
.map_err(AppError::Internal)?;
|
|
||||||
Ok((row, provider))
|
Ok((row, provider))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,50 @@ async fn handle_inner(
|
|||||||
"webhook event applied"
|
"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.
|
// Persist status.
|
||||||
repo::update_invoice_status(&state.db, &provider_invoice_id, new_status).await?;
|
repo::update_invoice_status(&state.db, &provider_invoice_id, new_status).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
db: pool,
|
db: pool,
|
||||||
keypair: Arc::new(keypair),
|
keypair: Arc::new(keypair),
|
||||||
payment: Arc::new(tokio::sync::RwLock::new(provider)),
|
payment: Arc::new(tokio::sync::RwLock::new(provider)),
|
||||||
|
provider_override: None,
|
||||||
config: Arc::new(cfg.clone()),
|
config: Arc::new(cfg.clone()),
|
||||||
self_tier,
|
self_tier,
|
||||||
rates: keysat::rates::RateCache::new(),
|
rates: keysat::rates::RateCache::new(),
|
||||||
|
|||||||
+227
-164
@@ -109,6 +109,7 @@ async fn make_test_state() -> (AppState, NamedTempFile) {
|
|||||||
db: pool,
|
db: pool,
|
||||||
keypair: Arc::new(keypair),
|
keypair: Arc::new(keypair),
|
||||||
payment: Arc::new(RwLock::new(None)),
|
payment: Arc::new(RwLock::new(None)),
|
||||||
|
provider_override: None,
|
||||||
config: Arc::new(cfg),
|
config: Arc::new(cfg),
|
||||||
self_tier: Arc::new(RwLock::new(Tier::Unlicensed {
|
self_tier: Arc::new(RwLock::new(Tier::Unlicensed {
|
||||||
reason: "test fixture".into(),
|
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.
|
// 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 {
|
struct MockPaymentProvider {
|
||||||
next_invoice_id: AtomicU64,
|
next_invoice_id: AtomicU64,
|
||||||
|
status_report: StatusReport,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MockPaymentProvider {
|
impl MockPaymentProvider {
|
||||||
|
/// Happy path: the provider confirms the invoice is settled.
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
next_invoice_id: AtomicU64::new(1),
|
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,
|
&self,
|
||||||
_provider_invoice_id: &str,
|
_provider_invoice_id: &str,
|
||||||
) -> Result<ProviderInvoiceStatus> {
|
) -> Result<ProviderInvoiceStatus> {
|
||||||
// Reconcile loop isn't exercised by these tests; return a sane
|
// The webhook handler re-fetches this to confirm a settle claim
|
||||||
// default in case it gets called transitively.
|
// before issuing. Configurable per-mock so a test can simulate the
|
||||||
Ok(ProviderInvoiceStatus::Settled)
|
// 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
|
/// 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
|
/// Build a state with a MockPaymentProvider already installed. Mirror of
|
||||||
/// `make_test_state` for tests that drive the purchase / webhook paths.
|
/// `make_test_state` for tests that drive the purchase / webhook paths.
|
||||||
async fn make_test_state_with_mock_provider() -> (AppState, NamedTempFile) {
|
async fn make_test_state_with_mock_provider() -> (AppState, NamedTempFile) {
|
||||||
let (state, tmp) = make_test_state().await;
|
install_mock_provider(MockPaymentProvider::new()).await
|
||||||
state
|
}
|
||||||
.set_payment_provider(Arc::new(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<dyn PaymentProvider> = 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)
|
(state, tmp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,6 +695,149 @@ async fn paid_purchase_creates_invoice_via_provider() {
|
|||||||
assert_eq!(licenses, 0);
|
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
|
/// The settle webhook: provider POSTs an InvoiceSettled event, daemon
|
||||||
/// flips the invoice status and issues a license. Re-POSTing the same
|
/// flips the invoice status and issues a license. Re-POSTing the same
|
||||||
/// webhook (which providers DO retry, sometimes aggressively) must not
|
/// 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);
|
assert_eq!(row.4, 98_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Active-provider preference round-trip. Pins the contract that
|
|
||||||
/// `Activate <provider>` flips both the in-memory provider AND the
|
|
||||||
/// persisted preference so the next daemon boot picks the same one.
|
|
||||||
///
|
|
||||||
/// Simulates the operator's lifecycle:
|
|
||||||
/// 1. Configure both BTCPay and Zaprite (both rows in DB)
|
|
||||||
/// 2. Activate Zaprite → preference flag = "zaprite"
|
|
||||||
/// 3. Activate BTCPay → preference flag = "btcpay"
|
|
||||||
/// 4. Disconnect BTCPay → preference flag cleared (because it
|
|
||||||
/// pointed at the wiped config)
|
|
||||||
/// 5. Disconnect Zaprite while preference was already "btcpay"
|
|
||||||
/// → preference NOT cleared (stays at "btcpay" because it
|
|
||||||
/// was pointing at a different provider)
|
|
||||||
#[tokio::test]
|
|
||||||
async fn payment_provider_preference_round_trip() {
|
|
||||||
use keysat::payment::{self, ProviderKind};
|
|
||||||
|
|
||||||
let (state, _tmp) = make_test_state().await;
|
|
||||||
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
|
|
||||||
|
|
||||||
// Zaprite activation requires the `zaprite_payments` entitlement
|
|
||||||
// (Pro tier and above). Pin the daemon's self-tier to a Licensed
|
|
||||||
// tier carrying that entitlement so the activate path doesn't
|
|
||||||
// 402. BTCPay is unconditional and works at every tier.
|
|
||||||
{
|
|
||||||
let mut guard = state.self_tier.write().await;
|
|
||||||
*guard = keysat::license_self::Tier::Licensed {
|
|
||||||
license_id: uuid::Uuid::new_v4(),
|
|
||||||
product_id: uuid::Uuid::new_v4(),
|
|
||||||
expires_at: 0,
|
|
||||||
entitlements: vec![
|
|
||||||
"unlimited_products".to_string(),
|
|
||||||
"unlimited_policies".to_string(),
|
|
||||||
"unlimited_codes".to_string(),
|
|
||||||
"recurring_billing".to_string(),
|
|
||||||
"zaprite_payments".to_string(),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-seed both configs as if the operator had run Connect on
|
|
||||||
// each at some point. We bypass the actual Connect endpoints
|
|
||||||
// because they call out to BTCPay / Zaprite to validate the
|
|
||||||
// credentials, which we don't want to do in unit tests.
|
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO btcpay_config(id, base_url, api_key, store_id, webhook_id, \
|
|
||||||
webhook_secret, connected_at) \
|
|
||||||
VALUES(1, 'http://btcpay.test', 'btcpay-key', 'store-1', 'wh-1', \
|
|
||||||
'0123456789abcdef', ?)",
|
|
||||||
)
|
|
||||||
.bind(&now)
|
|
||||||
.execute(&state.db)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO zaprite_config(id, api_key, base_url, webhook_id, connected_at, updated_at) \
|
|
||||||
VALUES(1, 'zaprite-key', 'https://api.zaprite.test', NULL, ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(&now)
|
|
||||||
.bind(&now)
|
|
||||||
.execute(&state.db)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Step 1: no preference recorded yet.
|
|
||||||
let pref = payment::read_active_provider_preference(&state.db).await;
|
|
||||||
assert_eq!(pref, None);
|
|
||||||
|
|
||||||
// Step 2: GET status surfaces both as configured, no active yet.
|
|
||||||
let req = build_request(
|
|
||||||
"GET",
|
|
||||||
"/v1/admin/payment-provider/status",
|
|
||||||
&[("authorization", &auth)],
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let resp = send(&state, req).await;
|
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
|
||||||
let body = body_json(resp).await;
|
|
||||||
assert_eq!(body["btcpay_configured"], true);
|
|
||||||
assert_eq!(body["zaprite_configured"], true);
|
|
||||||
assert!(body["preferred"].is_null());
|
|
||||||
|
|
||||||
// Step 3: Activate Zaprite. The endpoint reads the saved
|
|
||||||
// zaprite_config to build the provider — the saved key
|
|
||||||
// 'zaprite-key' won't talk to a real API but the activate
|
|
||||||
// path doesn't ping; that's only on Connect.
|
|
||||||
let req = build_request(
|
|
||||||
"POST",
|
|
||||||
"/v1/admin/payment-provider/activate",
|
|
||||||
&[("authorization", &auth)],
|
|
||||||
Some(json!({"provider": "zaprite"})),
|
|
||||||
);
|
|
||||||
let resp = send(&state, req).await;
|
|
||||||
assert_eq!(
|
|
||||||
resp.status(),
|
|
||||||
StatusCode::OK,
|
|
||||||
"activate zaprite should succeed when zaprite_config is present"
|
|
||||||
);
|
|
||||||
let pref = payment::read_active_provider_preference(&state.db).await;
|
|
||||||
assert_eq!(pref, Some(ProviderKind::Zaprite));
|
|
||||||
|
|
||||||
// Step 4: Activate BTCPay. Preference flips.
|
|
||||||
let req = build_request(
|
|
||||||
"POST",
|
|
||||||
"/v1/admin/payment-provider/activate",
|
|
||||||
&[("authorization", &auth)],
|
|
||||||
Some(json!({"provider": "btcpay"})),
|
|
||||||
);
|
|
||||||
let resp = send(&state, req).await;
|
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
|
||||||
let pref = payment::read_active_provider_preference(&state.db).await;
|
|
||||||
assert_eq!(pref, Some(ProviderKind::Btcpay));
|
|
||||||
|
|
||||||
// Step 5: Activate something that's not configured. Should 400.
|
|
||||||
sqlx::query("DELETE FROM zaprite_config WHERE id = 1")
|
|
||||||
.execute(&state.db)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let req = build_request(
|
|
||||||
"POST",
|
|
||||||
"/v1/admin/payment-provider/activate",
|
|
||||||
&[("authorization", &auth)],
|
|
||||||
Some(json!({"provider": "zaprite"})),
|
|
||||||
);
|
|
||||||
let resp = send(&state, req).await;
|
|
||||||
assert_eq!(
|
|
||||||
resp.status(),
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"activating an unconfigured provider must 400 with 'run Connect first'"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 6: Bad provider name → 400.
|
|
||||||
let req = build_request(
|
|
||||||
"POST",
|
|
||||||
"/v1/admin/payment-provider/activate",
|
|
||||||
&[("authorization", &auth)],
|
|
||||||
Some(json!({"provider": "stripe"})),
|
|
||||||
);
|
|
||||||
let resp = send(&state, req).await;
|
|
||||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
||||||
|
|
||||||
// Step 7: write_active_provider_preference invariant —
|
|
||||||
// explicit setting survives a re-read (durability across the
|
|
||||||
// simulated restart that the boot-time loader cares about).
|
|
||||||
payment::write_active_provider_preference(&state.db, ProviderKind::Btcpay)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let pref = payment::read_active_provider_preference(&state.db).await;
|
|
||||||
assert_eq!(pref, Some(ProviderKind::Btcpay));
|
|
||||||
payment::write_active_provider_preference(&state.db, ProviderKind::Zaprite)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let pref = payment::read_active_provider_preference(&state.db).await;
|
|
||||||
assert_eq!(pref, Some(ProviderKind::Zaprite));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Zaprite webhook authentication contract.
|
/// Zaprite webhook authentication contract.
|
||||||
///
|
///
|
||||||
/// Zaprite doesn't sign webhooks (verified May 2026 — no HMAC,
|
/// Zaprite doesn't sign webhooks (verified May 2026 — no HMAC,
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ async fn make_state() -> (AppState, NamedTempFile, Arc<MockProvider>) {
|
|||||||
payment: Arc::new(RwLock::new(Some(
|
payment: Arc::new(RwLock::new(Some(
|
||||||
mock.clone() as Arc<dyn PaymentProvider>,
|
mock.clone() as Arc<dyn PaymentProvider>,
|
||||||
))),
|
))),
|
||||||
|
provider_override: None,
|
||||||
config: Arc::new(cfg),
|
config: Arc::new(cfg),
|
||||||
self_tier: Arc::new(RwLock::new(Tier::Unlicensed {
|
self_tier: Arc::new(RwLock::new(Tier::Unlicensed {
|
||||||
reason: "test".into(),
|
reason: "test".into(),
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ async fn make_state() -> (AppState, NamedTempFile) {
|
|||||||
db: pool,
|
db: pool,
|
||||||
keypair: Arc::new(keypair),
|
keypair: Arc::new(keypair),
|
||||||
payment: Arc::new(RwLock::new(None)),
|
payment: Arc::new(RwLock::new(None)),
|
||||||
|
provider_override: None,
|
||||||
config: Arc::new(cfg),
|
config: Arc::new(cfg),
|
||||||
self_tier: Arc::new(RwLock::new(Tier::Unlicensed {
|
self_tier: Arc::new(RwLock::new(Tier::Unlicensed {
|
||||||
reason: "test".into(),
|
reason: "test".into(),
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ async fn make_state() -> (AppState, NamedTempFile) {
|
|||||||
db: pool,
|
db: pool,
|
||||||
keypair: Arc::new(keypair),
|
keypair: Arc::new(keypair),
|
||||||
payment: Arc::new(RwLock::new(None)),
|
payment: Arc::new(RwLock::new(None)),
|
||||||
|
provider_override: None,
|
||||||
config: Arc::new(cfg),
|
config: Arc::new(cfg),
|
||||||
self_tier: Arc::new(RwLock::new(Tier::Unlicensed {
|
self_tier: Arc::new(RwLock::new(Tier::Unlicensed {
|
||||||
reason: "test".into(),
|
reason: "test".into(),
|
||||||
|
|||||||
Reference in New Issue
Block a user