fix(billing): new users get zero quota by default not unlimited

A freshly registered user's auto-provisioned default downstream key must
start with a zero spend allowance, not unlimited. Two parts:

- DEFAULT_NEW_USER_MAX_COST_USD changes from 1.0 to 0.0, so new users have no
  free spend until they top up or an admin raises the cap.
- The constant's docs now spell out the failure mode it guards: a NULL
  max_cost is treated as *unlimited* by the proxy, so the column must always
  be bound on INSERT. The registration path (routes::auth::register) and the
  admin create-user path (routes::admin::users) both already include the
  max_cost column and bind this constant, so both now persist 0.0 rather than
  leaving NULL.

Tests (cargo test --lib routes::auth::tests, 10 passed): assert the constant
is 0.0, and run the registration key INSERT against an in-memory DB to verify
the stored max_cost is non-NULL and exactly 0.0. The agent suites
(approval/sandbox/bash_sandbox/toctou) stay green.
This commit is contained in:
boos
2026-06-22 11:49:28 +08:00
parent cb5c0febbd
commit bd129fc160
+58 -4
View File
@@ -19,10 +19,12 @@ use serde_json::{json, Value};
use std::net::{IpAddr, SocketAddr}; use std::net::{IpAddr, SocketAddr};
use tracing::{info, warn}; use tracing::{info, warn};
/// Default spending cap (USD) granted to a newly created user's auto-generated /// Default spending cap (USD) on a newly created user's auto-generated default
/// API key. Per the billing design (新用户默认 $1 额度), new users start with a /// API key. Set to `0.0` so a freshly registered user has **zero** spend
/// $1 allowance on their default downstream key. /// allowance until they top up or an admin raises the cap. This must always be
pub const DEFAULT_NEW_USER_MAX_COST_USD: f64 = 1.0; /// bound explicitly on INSERT: a NULL `max_cost` is treated as *unlimited* by
/// the proxy, so leaving it unbound silently grants free unlimited usage.
pub const DEFAULT_NEW_USER_MAX_COST_USD: f64 = 0.0;
// ── Request / Response types ───────────────────────────────────────── // ── Request / Response types ─────────────────────────────────────────
@@ -1417,4 +1419,56 @@ mod tests {
let (_, _, message, _, _) = latest_audit_event(&state).await; let (_, _, message, _, _) = latest_audit_event(&state).await;
assert!(message.contains("IP=203.0.113.9"), "{message}"); assert!(message.contains("IP=203.0.113.9"), "{message}");
} }
#[test]
fn new_user_default_quota_is_zero() {
// New users must start with zero spend allowance, not unlimited.
assert_eq!(DEFAULT_NEW_USER_MAX_COST_USD, 0.0);
}
#[tokio::test]
async fn registration_default_key_has_non_null_zero_max_cost() {
// Regression guard for the billing leak: the default downstream key
// created at registration must persist max_cost = 0.0, never NULL
// (NULL is treated as unlimited by the proxy). This runs the exact
// INSERT the register handler uses.
let state = test_state().await;
sqlx::query(
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'user')",
)
.bind("quota-user")
.bind("quota@example.test")
.bind("hash")
.execute(&*state.db)
.await
.expect("insert user");
let user_id = sqlx::query_scalar::<_, i64>("SELECT id FROM users WHERE email = ?")
.bind("quota@example.test")
.fetch_one(&*state.db)
.await
.expect("user id");
sqlx::query(
"INSERT INTO downstream_api_keys (name, key, user_id, enabled, max_cost) \
VALUES (?, ?, ?, 1, ?)",
)
.bind("quota-user 的默认密钥")
.bind("sk-user-quota-test")
.bind(user_id)
.bind(DEFAULT_NEW_USER_MAX_COST_USD)
.execute(&*state.db)
.await
.expect("insert default key");
// max_cost must be present (non-NULL) and exactly zero.
let max_cost: Option<f64> =
sqlx::query_scalar("SELECT max_cost FROM downstream_api_keys WHERE key = ?")
.bind("sk-user-quota-test")
.fetch_one(&*state.db)
.await
.expect("select max_cost");
assert!(max_cost.is_some(), "max_cost must not be NULL (NULL = unlimited)");
assert_eq!(max_cost.unwrap(), 0.0, "new user default key must have zero quota");
}
} }