- 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]>
149 lines
4.8 KiB
Rust
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);
|
|
}
|