//! 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=,v1=hex(HMAC-SHA256(secret, "."))`. fn stripe_signature(timestamp: i64, body: &str) -> String { let mut mac = Hmac::::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); }