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:
+58
-4
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user