Add tier-cap enforcement test
Verifies the 402 PAYMENT_REQUIRED gate on /v1/admin/products fires at
the Creator-tier product cap (5), and that swapping `self_tier` to a
Licensed tier with `unlimited_products` lifts the cap without a
daemon restart. Mirrors what the admin UI's "Activate Keysat license"
flow does at runtime.
Validates two production-correctness invariants:
- the 402 carries an `upgrade_url` so the SPA can render the
upgrade CTA inline (rather than a generic error)
- the failed attempt does NOT leak a row into the products table —
the cap fires BEFORE the insert
This commit is contained in:
@@ -666,3 +666,108 @@ async fn webhook_settles_invoice_and_issues_license_idempotently() {
|
|||||||
"redelivered settle webhook MUST NOT duplicate the license"
|
"redelivered settle webhook MUST NOT duplicate the license"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tier caps: an Unlicensed (or Creator-tier) operator may create up
|
||||||
|
/// to `CREATOR_PRODUCT_CAP` products. The Nth+1 attempt returns 402
|
||||||
|
/// with `upgrade_url` populated so the admin SPA can render the
|
||||||
|
/// "Upgrade to Pro" CTA inline.
|
||||||
|
///
|
||||||
|
/// Then we swap the daemon's `self_tier` to a Licensed tier with the
|
||||||
|
/// `unlimited_products` entitlement (the same entitlement the master
|
||||||
|
/// Keysat issues to paying operators) and verify the same N+1 attempt
|
||||||
|
/// now succeeds. This is the dynamic-swap behavior that lets operators
|
||||||
|
/// activate a new license via the admin API and keep working without a
|
||||||
|
/// daemon restart.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tier_caps_block_at_creator_limit_and_unlock_after_upgrade() {
|
||||||
|
let (state, _tmp) = make_test_state().await;
|
||||||
|
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
|
||||||
|
|
||||||
|
// Reach the cap. CREATOR_PRODUCT_CAP is 5; create exactly five.
|
||||||
|
for i in 0..5 {
|
||||||
|
let req = build_request(
|
||||||
|
"POST",
|
||||||
|
"/v1/admin/products",
|
||||||
|
&[("authorization", &auth)],
|
||||||
|
Some(json!({
|
||||||
|
"slug": format!("p{i}"),
|
||||||
|
"name": format!("Product {i}"),
|
||||||
|
"price_sats": 1_000,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
let resp = send(&state, req).await;
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"product {i} should succeed (under cap)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sixth product → 402 with upgrade_url.
|
||||||
|
let req = build_request(
|
||||||
|
"POST",
|
||||||
|
"/v1/admin/products",
|
||||||
|
&[("authorization", &auth)],
|
||||||
|
Some(json!({
|
||||||
|
"slug": "p-over-cap",
|
||||||
|
"name": "Over The Cap",
|
||||||
|
"price_sats": 1_000,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
let resp = send(&state, req).await;
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::PAYMENT_REQUIRED,
|
||||||
|
"6th product should be blocked by the Creator-tier cap"
|
||||||
|
);
|
||||||
|
let body = body_json(resp).await;
|
||||||
|
assert!(
|
||||||
|
body["upgrade_url"]
|
||||||
|
.as_str()
|
||||||
|
.map_or(false, |u| u.contains("/buy/keysat")),
|
||||||
|
"402 response should carry an upgrade_url pointing at the master Keysat: {body:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// DB should still reflect exactly 5 products — the 6th must not
|
||||||
|
// have leaked through.
|
||||||
|
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM products")
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 5);
|
||||||
|
|
||||||
|
// Swap self_tier to a Licensed tier with `unlimited_products`.
|
||||||
|
// Mirrors what `Activate Keysat license` does in the admin UI: the
|
||||||
|
// operator pastes their Keysat-licenses-Keysat key, the daemon
|
||||||
|
// verifies it against the master pubkey, and writes the parsed
|
||||||
|
// entitlements into self_tier under a write lock — no restart.
|
||||||
|
*state.self_tier.write().await = Tier::Licensed {
|
||||||
|
license_id: Uuid::new_v4(),
|
||||||
|
product_id: Uuid::new_v4(),
|
||||||
|
expires_at: 0,
|
||||||
|
entitlements: vec!["self_host".into(), "unlimited_products".into()],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Now the same 6th product attempt succeeds.
|
||||||
|
let req = build_request(
|
||||||
|
"POST",
|
||||||
|
"/v1/admin/products",
|
||||||
|
&[("authorization", &auth)],
|
||||||
|
Some(json!({
|
||||||
|
"slug": "p-after-upgrade",
|
||||||
|
"name": "Pro Tier Now",
|
||||||
|
"price_sats": 1_000,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
let resp = send(&state, req).await;
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"after the tier swap, the cap should no longer fire"
|
||||||
|
);
|
||||||
|
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM products")
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 6, "the previously-blocked product should now exist");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user