Phase 3 backend: tier-gate enforcement + billing webhook
- Tier capability model (pro-layers, geo-analytics, custom-domain, retention) - Pro-only layer types (video/particle/animatedGradient) rejected on card create/replace/patch for Free; geo analytics gated to Pro+ (402 tier_limit) - Gates read the authoritative DB tier, so upgrades apply without re-login - POST /v1/webhooks/revenuecat: shared-secret auth (constant-time), maps RevenueCat events to users.tier (purchase→pro/team/enterprise, cancel→free), unknown user = 2xx no-op; users::update_tier query 93 backend tests; fmt + clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9b7c845319
commit
4ad3220917
@@ -106,7 +106,8 @@ async fn ingest_rejects_server_only_event_type() {
|
||||
#[tokio::test]
|
||||
async fn geo_breakdown_groups_by_country() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
let (token, user_id) = app.register_and_user().await;
|
||||
app.set_tier(&user_id, "pro").await; // geo analytics are Pro-gated
|
||||
let (id, _) = create_and_publish(&app, &token).await;
|
||||
|
||||
// Two client events with a forwarded IP → FakeGeo resolves both to US.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Billing webhook → tier update (PRD §19.2).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
const SECRET: &str = "test-webhook-secret";
|
||||
|
||||
fn rc_event(event_type: &str, user_id: &str, ents: &[&str]) -> serde_json::Value {
|
||||
json!({
|
||||
"event": {
|
||||
"type": event_type,
|
||||
"app_user_id": user_id,
|
||||
"entitlement_ids": ents,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webhook_upgrades_then_cancels_tier() {
|
||||
let app = require_app!();
|
||||
let (_, user_id) = app.register_and_user().await;
|
||||
assert_eq!(app.get_tier(&user_id).await, "free");
|
||||
|
||||
// Purchase → pro.
|
||||
let (status, _) = app
|
||||
.request_with_headers(
|
||||
"POST",
|
||||
"/v1/webhooks/revenuecat",
|
||||
None,
|
||||
Some(rc_event("INITIAL_PURCHASE", &user_id, &["pro"])),
|
||||
&[("authorization", SECRET)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(app.get_tier(&user_id).await, "pro");
|
||||
|
||||
// Cancellation → back to free.
|
||||
app.request_with_headers(
|
||||
"POST",
|
||||
"/v1/webhooks/revenuecat",
|
||||
None,
|
||||
Some(rc_event("CANCELLATION", &user_id, &[])),
|
||||
&[("authorization", SECRET)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(app.get_tier(&user_id).await, "free");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webhook_rejects_bad_secret_and_does_not_change_tier() {
|
||||
let app = require_app!();
|
||||
let (_, user_id) = app.register_and_user().await;
|
||||
|
||||
let (status, _) = app
|
||||
.request_with_headers(
|
||||
"POST",
|
||||
"/v1/webhooks/revenuecat",
|
||||
None,
|
||||
Some(rc_event("INITIAL_PURCHASE", &user_id, &["pro"])),
|
||||
&[("authorization", "wrong-secret")],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(app.get_tier(&user_id).await, "free");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webhook_unknown_user_is_ok_noop() {
|
||||
let app = require_app!();
|
||||
let (status, _) = app
|
||||
.request_with_headers(
|
||||
"POST",
|
||||
"/v1/webhooks/revenuecat",
|
||||
None,
|
||||
Some(rc_event(
|
||||
"INITIAL_PURCHASE",
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
&["pro"],
|
||||
)),
|
||||
&[("authorization", SECRET)],
|
||||
)
|
||||
.await;
|
||||
// 2xx so RevenueCat doesn't retry forever.
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
@@ -89,6 +89,7 @@ pub async fn try_setup() -> Option<TestApp> {
|
||||
apple_audience: "com.cardclaws.test".into(),
|
||||
profile_base_url: "https://cardclaws.test".into(),
|
||||
ip_hash_secret: "test-ip-salt".into(),
|
||||
billing_webhook_secret: "test-webhook-secret".into(),
|
||||
wallet: WalletConfig {
|
||||
apple_pass_type_id: "pass.com.cardclaws.test".into(),
|
||||
apple_team_id: "TEST123".into(),
|
||||
@@ -283,6 +284,49 @@ impl TestApp {
|
||||
(resp.status(), location)
|
||||
}
|
||||
|
||||
/// Register a fresh user; return its access token and user id.
|
||||
pub async fn register_and_user(&self) -> (String, String) {
|
||||
let (email, handle) = unique_identity();
|
||||
let (status, body) = self
|
||||
.post(
|
||||
"/v1/auth/register",
|
||||
serde_json::json!({
|
||||
"email": email,
|
||||
"password": "correct horse battery",
|
||||
"handle": handle,
|
||||
"display_name": "Tier User",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "registration failed: {body}");
|
||||
(
|
||||
body["access_token"].as_str().unwrap().to_string(),
|
||||
body["user"]["id"].as_str().unwrap().to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Read a user's current tier from the DB.
|
||||
pub async fn get_tier(&self, user_id: &str) -> String {
|
||||
let id = uuid::Uuid::parse_str(user_id).unwrap();
|
||||
let (tier,): (String,) = sqlx::query_as("SELECT tier FROM users WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_one(&self.db)
|
||||
.await
|
||||
.unwrap();
|
||||
tier
|
||||
}
|
||||
|
||||
/// Force a user's tier directly in the DB (simulates a billing webhook).
|
||||
pub async fn set_tier(&self, user_id: &str, tier: &str) {
|
||||
let id = uuid::Uuid::parse_str(user_id).unwrap();
|
||||
sqlx::query("UPDATE users SET tier = $1 WHERE id = $2")
|
||||
.bind(tier)
|
||||
.bind(id)
|
||||
.execute(&self.db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Register a fresh user and return its access token.
|
||||
pub async fn register_and_token(&self) -> String {
|
||||
let (email, handle) = unique_identity();
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Tier-gate enforcement (PRD §19.1). Tier is read from the DB so a simulated
|
||||
//! upgrade (set_tier) takes effect immediately.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use common::unique_handle;
|
||||
|
||||
fn card_with_particle() -> Value {
|
||||
json!({
|
||||
"face": {
|
||||
"layers": [
|
||||
{ "id": "p1", "type": "particle", "x": 0, "y": 0, "width": 1, "height": 1,
|
||||
"opacity": 1, "zIndex": 1 }
|
||||
],
|
||||
"background": { "type": "solid", "value": "#101014" }
|
||||
},
|
||||
"back": { "layers": [] }
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn free_tier_cannot_use_pro_layers_but_pro_can() {
|
||||
let app = require_app!();
|
||||
let (token, user_id) = app.register_and_user().await;
|
||||
|
||||
// Free tier: a particle layer is rejected with a tier-limit (402).
|
||||
let (status, body) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/v1/cards",
|
||||
Some(&token),
|
||||
Some(json!({ "handle": unique_handle(), "definition": card_with_particle() })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::PAYMENT_REQUIRED);
|
||||
assert_eq!(body["code"], "tier_limit");
|
||||
|
||||
// Upgrade to Pro → the same card is now accepted.
|
||||
app.set_tier(&user_id, "pro").await;
|
||||
let (status2, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/v1/cards",
|
||||
Some(&token),
|
||||
Some(json!({ "handle": unique_handle(), "definition": card_with_particle() })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status2, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn free_tier_plain_card_still_works() {
|
||||
let app = require_app!();
|
||||
let (token, _) = app.register_and_user().await;
|
||||
let plain = json!({
|
||||
"face": { "layers": [], "background": { "type": "solid", "value": "#101014" } },
|
||||
"back": { "layers": [] }
|
||||
});
|
||||
let (status, _) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/v1/cards",
|
||||
Some(&token),
|
||||
Some(json!({ "handle": unique_handle(), "definition": plain })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn geo_analytics_gated_to_pro() {
|
||||
let app = require_app!();
|
||||
let (token, user_id) = app.register_and_user().await;
|
||||
let (_, created) = app
|
||||
.request(
|
||||
"POST",
|
||||
"/v1/cards",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"handle": unique_handle(),
|
||||
"definition": { "face": { "layers": [], "background": { "type": "solid", "value": "#101014" } }, "back": { "layers": [] } }
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
let id = created["id"].as_str().unwrap();
|
||||
|
||||
// Free tier: geo is gated.
|
||||
let (free_status, body) = app
|
||||
.request(
|
||||
"GET",
|
||||
&format!("/v1/cards/{id}/analytics/geo"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(free_status, StatusCode::PAYMENT_REQUIRED);
|
||||
assert_eq!(body["code"], "tier_limit");
|
||||
|
||||
// Pro tier: geo is allowed (empty list here, but 200).
|
||||
app.set_tier(&user_id, "pro").await;
|
||||
let (pro_status, _) = app
|
||||
.request(
|
||||
"GET",
|
||||
&format!("/v1/cards/{id}/analytics/geo"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(pro_status, StatusCode::OK);
|
||||
}
|
||||
Reference in New Issue
Block a user