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:
Grant
2026-06-12 22:36:42 -05:00
parent 8c4baccf6b
commit 783372c03b
7 changed files with 301 additions and 174 deletions
+26 -10
View File
@@ -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))
} }
} }
+44
View File
@@ -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?;
+1
View File
@@ -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
View File
@@ -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,
+1
View File
@@ -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(),
+1
View File
@@ -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(),
+1
View File
@@ -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(),