Files
clawmates/crates/cm-api/tests/stripe_billing.rs
T
Omar SobhandClaude Fable 5 f3f08a8edd R4 backend: team org-chart + leaderboard, Stripe credits, workspace apps
- GET /api/team/orgchart — each member grouped with the claws they manage
  (agents.managed_by); GET /api/team/leaderboard — every claw ranked by
  its real usage_events rollup (credits/tokens/runs, zeros included).
  Both tested against real Postgres.
- Stripe Buy-credits (the Slack/Clerk integration pattern): [billing]
  config (stripe keys + price + webhook secret + credits_per_pack);
  POST /api/credits/checkout opens a real Checkout Session; POST
  /api/billing/stripe verifies Stripe's t=,v1= HMAC (constant-time) and
  grants one credit lot, idempotent on the session id; GET
  /api/billing/config gates the button (honest degradation when unset).
  Offline tests: signed grant + replay no-double-grant + forged-sig 400 +
  config flag. Live checkout creation deferred to a CM_LIVE_STRIPE test.
- /apps global page support: clawId now optional on connect + directory;
  absent => workspace-wide connection (app_connections.agent_id NULL) via
  new connections::list_for_workspace.
- ApiError gains a From<sqlx::Error> so inline queries use ? cleanly.

cm-api 10 test files incl. team_tabs (2) + stripe_billing (3); clippy clean.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 19:58:09 -05:00

149 lines
4.8 KiB
Rust

//! Stripe Buy-credits: the signed webhook grants a credit lot exactly once
//! (real HMAC over the Stripe payload, the same constant-time pattern as
//! Slack). Live checkout-session creation is covered by a separate
//! CM_LIVE_STRIPE test; here we exercise the verification + grant path
//! offline with self-signed payloads.
use std::sync::Arc;
use cm_api::AppState;
use cm_auth::AuthService;
use cm_config::BillingConfig;
use cm_domain::{Role, User, UserId, Workspace, WorkspaceId};
use cm_llm::ScriptedProvider;
use cm_runtime::{Runtime, RuntimeConfig};
use hmac::{Hmac, Mac};
use serde_json::{json, Value};
const WEBHOOK_SECRET: &str = "whsec_test_secret";
async fn serve(pool: sqlx::PgPool) -> (String, reqwest::Client, WorkspaceId) {
let ws = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
cm_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
let owner = User {
id: UserId::new(),
workspace_id: ws.id,
email: format!("{}@acme.test", UserId::new()),
role: Role::Owner,
display_name: "Owner".into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
};
cm_db::repo::users::insert(&pool, &owner).await.unwrap();
AuthService::new(pool.clone())
.set_password(owner.id, "pw")
.await
.unwrap();
let runtime = Runtime::new(
pool.clone(),
Arc::new(ScriptedProvider::from_toml("").unwrap()),
RuntimeConfig::basic("scripted", 1024),
);
let billing = BillingConfig {
stripe_secret_key: Some("sk_test_x".into()),
stripe_price_id: Some("price_x".into()),
stripe_webhook_secret: Some(WEBHOOK_SECRET.into()),
credits_per_pack: 1000,
return_base: Some("http://localhost:3000".into()),
};
let app = cm_api::router(AppState::new(pool, runtime).with_billing(billing));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(format!("http://{addr}"), reqwest::Client::new(), ws.id)
}
/// Stripe's scheme: `t=<ts>,v1=hex(HMAC-SHA256(secret, "<ts>.<body>"))`.
fn stripe_signature(timestamp: i64, body: &str) -> String {
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(WEBHOOK_SECRET.as_bytes()).unwrap();
mac.update(format!("{timestamp}.{body}").as_bytes());
format!(
"t={timestamp},v1={}",
hex::encode(mac.finalize().into_bytes())
)
}
fn event(session_id: &str, workspace_id: WorkspaceId) -> String {
json!({
"id": "evt_1",
"type": "checkout.session.completed",
"data": { "object": {
"id": session_id,
"metadata": { "workspace_id": workspace_id.as_uuid().to_string() }
}}
})
.to_string()
}
#[tokio::test]
async fn a_signed_checkout_completion_grants_credits_exactly_once() {
let pool = cm_testkit::test_pool().await;
let (base, client, ws) = serve(pool.clone()).await;
let body = event("cs_test_1", ws);
let ts = 1_700_000_000;
let sig = stripe_signature(ts, &body);
let post = || {
client
.post(format!("{base}/api/billing/stripe"))
.header("stripe-signature", &sig)
.header("content-type", "application/json")
.body(body.clone())
.send()
};
assert_eq!(post().await.unwrap().status(), 200);
assert_eq!(
cm_db::repo::credits::balance(&pool, ws).await.unwrap(),
1000,
"one pack granted"
);
// Replayed event (same session id) is idempotent — no double grant.
assert_eq!(post().await.unwrap().status(), 200);
assert_eq!(
cm_db::repo::credits::balance(&pool, ws).await.unwrap(),
1000,
"replay must not double-grant"
);
}
#[tokio::test]
async fn a_forged_signature_is_refused_and_grants_nothing() {
let pool = cm_testkit::test_pool().await;
let (base, client, ws) = serve(pool.clone()).await;
let body = event("cs_test_2", ws);
let res = client
.post(format!("{base}/api/billing/stripe"))
.header("stripe-signature", "t=1700000000,v1=deadbeef")
.header("content-type", "application/json")
.body(body)
.send()
.await
.unwrap();
assert_eq!(res.status(), 400);
assert_eq!(cm_db::repo::credits::balance(&pool, ws).await.unwrap(), 0);
}
#[tokio::test]
async fn the_config_endpoint_reports_buy_credits_enabled() {
let pool = cm_testkit::test_pool().await;
let (base, client, _ws) = serve(pool.clone()).await;
let cfg: Value = client
.get(format!("{base}/api/billing/config"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(cfg["buy_credits_enabled"], true);
}