All checks were successful
CI / Format (push) Successful in 44s
CI / CUDA type-check (push) Successful in 1m40s
CI / Clippy (push) Successful in 3m14s
CI / Test (push) Successful in 6m29s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
The human-facing account surface the helexa.ai frontend (F4) consumes, on top of B2's authz surface. Email+password auth with JWT sessions (distinct from inference API keys); plain-JSON errors (the #63 envelope stays on the authz surface). - Auth lifecycle: register → email-verify → login → password-reset (request/confirm). argon2id passwords; verify/reset via single-use sha256-hashed email tokens; register and reset-request always return 202 (no account enumeration). Email via a pluggable EmailSender (lettre Smtp + dev Log transport). - API keys: create (sk-helexa-<base62(32 OsRng)>, raw shown once, stored as sha256 + non-secret prefix), list (prefix never the secret), archive, PATCH per-key limit (percent|hardcap). Protected by a JWT session middleware. - Account balance endpoint (allocation total/spent/reserved). - Silent fingerprint abuse: register captures the browser fingerprint; >= threshold (default 5) accounts sharing one fingerprint are silently deactivated + flagged — registration still returns a normal 202, and a deactivated account's key resolves as an ordinary 401 at the authz surface (no "banned" signal anywhere). - crypto: argon2 hash/verify + CSPRNG token/key minting (base62). config gains [auth] + [email]. CORS on the app for the browser SPA. Validated against a throwaway Postgres 16: verify-once, full lifecycle (register→verify→login→create key→account→list→authz resolve→archive→401), and 5-same-fingerprint → all accounts silently deactivated + no-clue 401. 8 unit + 11 gated integration tests; all skip cleanly without UPSTREAM_TEST_DATABASE_URL so CI stays green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
244 lines
7.5 KiB
Rust
244 lines
7.5 KiB
Rust
//! Integration tests for the `/authz/v1` surface against a real Postgres,
|
|
//! driving the built axum app over HTTP. Gated on `UPSTREAM_TEST_DATABASE_URL`
|
|
//! (skips cleanly when unset, so CI stays green without a DB):
|
|
//!
|
|
//! UPSTREAM_TEST_DATABASE_URL=postgres://helexa:helexa@localhost/helexa_test \
|
|
//! cargo test -p helexa-upstream --test authz_pg
|
|
|
|
use helexa_upstream::config::{ClientToken, UpstreamConfig};
|
|
use helexa_upstream::crypto::sha256;
|
|
use helexa_upstream::db::connect_and_migrate;
|
|
use helexa_upstream::state::AppState;
|
|
use serde_json::{Value, json};
|
|
use sqlx::Executor;
|
|
use sqlx::Row;
|
|
use sqlx::postgres::PgPool;
|
|
use uuid::Uuid;
|
|
|
|
const CLIENT_TOKEN: &str = "test-operator-token";
|
|
|
|
async fn spawn_or_skip(test: &str) -> Option<(String, PgPool)> {
|
|
let Ok(url) = std::env::var("UPSTREAM_TEST_DATABASE_URL") else {
|
|
eprintln!("skipping {test}: UPSTREAM_TEST_DATABASE_URL not set");
|
|
return None;
|
|
};
|
|
let pool = connect_and_migrate(&url, 16).await.expect("migrate");
|
|
|
|
let mut config = UpstreamConfig {
|
|
server: Default::default(),
|
|
db: helexa_upstream::config::DbSettings {
|
|
url,
|
|
max_connections: 16,
|
|
},
|
|
grant: Default::default(),
|
|
abuse: Default::default(),
|
|
client_auth: Default::default(),
|
|
authz: Default::default(),
|
|
auth: Default::default(),
|
|
email: Default::default(),
|
|
};
|
|
config.client_auth.tokens.push(ClientToken {
|
|
token: CLIENT_TOKEN.into(),
|
|
operator_id: "op-test".into(),
|
|
});
|
|
|
|
let email = helexa_upstream::email::EmailSender::from_config(&config.email).unwrap();
|
|
let state = AppState::new(pool.clone(), config, email);
|
|
let app = helexa_upstream::build_app(state);
|
|
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();
|
|
});
|
|
Some((format!("http://{addr}"), pool))
|
|
}
|
|
|
|
/// Seed an account with `total` allocation and an active key with raw value
|
|
/// `raw` (percent=100). Optionally deactivate the account. Returns
|
|
/// (account_id, key_id).
|
|
async fn seed_key(pool: &PgPool, total: i64, raw: &str, deactivated: bool) -> (Uuid, Uuid) {
|
|
let user_id: Uuid = pool
|
|
.fetch_one(
|
|
sqlx::query(
|
|
"INSERT INTO users (email, password_hash, email_verified) VALUES ($1,'x',true) RETURNING id",
|
|
)
|
|
.bind(format!("u-{}@t.local", Uuid::new_v4())),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.get("id");
|
|
let status = if deactivated { "deactivated" } else { "active" };
|
|
let account_id: Uuid = pool
|
|
.fetch_one(
|
|
sqlx::query(
|
|
"INSERT INTO accounts (owner_user_id, allocation_total, status) VALUES ($1,$2,$3) RETURNING id",
|
|
)
|
|
.bind(user_id)
|
|
.bind(total)
|
|
.bind(status),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.get("id");
|
|
let key_id: Uuid = pool
|
|
.fetch_one(
|
|
sqlx::query(
|
|
"INSERT INTO api_keys (account_id, key_hash, key_prefix, limit_kind, limit_value) \
|
|
VALUES ($1,$2,'sk-test','percent',100) RETURNING id",
|
|
)
|
|
.bind(account_id)
|
|
.bind(sha256(raw)),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.get("id");
|
|
(account_id, key_id)
|
|
}
|
|
|
|
fn client() -> reqwest::Client {
|
|
reqwest::Client::new()
|
|
}
|
|
|
|
async fn post(
|
|
c: &reqwest::Client,
|
|
url: String,
|
|
body: Value,
|
|
bearer: Option<&str>,
|
|
) -> reqwest::Response {
|
|
let mut req = c.post(url).json(&body);
|
|
if let Some(b) = bearer {
|
|
req = req.bearer_auth(b);
|
|
}
|
|
req.send().await.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_reserve_settle_round_trip() {
|
|
let Some((base, pool)) = spawn_or_skip("resolve_reserve_settle_round_trip").await else {
|
|
return;
|
|
};
|
|
let raw = format!("sk-{}", Uuid::new_v4());
|
|
let (account_id, key_id) = seed_key(&pool, 1000, &raw, false).await;
|
|
let c = client();
|
|
|
|
// resolve
|
|
let r = post(
|
|
&c,
|
|
format!("{base}/authz/v1/resolve"),
|
|
json!({"api_key": raw}),
|
|
Some(CLIENT_TOKEN),
|
|
)
|
|
.await;
|
|
assert_eq!(r.status(), 200);
|
|
let body: Value = r.json().await.unwrap();
|
|
assert_eq!(body["principal"]["account_id"], account_id.to_string());
|
|
assert_eq!(body["principal"]["key_id"], key_id.to_string());
|
|
assert_eq!(body["snapshot"]["hard_cap"], 1000);
|
|
|
|
// reserve 400
|
|
let r = post(
|
|
&c,
|
|
format!("{base}/authz/v1/reserve"),
|
|
json!({"account_id": account_id, "key_id": key_id, "max_tokens": 400}),
|
|
Some(CLIENT_TOKEN),
|
|
)
|
|
.await;
|
|
assert_eq!(r.status(), 200);
|
|
let body: Value = r.json().await.unwrap();
|
|
let rid = body["reservation_id"].as_i64().expect("granted");
|
|
|
|
// settle 150
|
|
let r = post(
|
|
&c,
|
|
format!("{base}/authz/v1/settle"),
|
|
json!({"reservation_id": rid, "actual_tokens": 150}),
|
|
Some(CLIENT_TOKEN),
|
|
)
|
|
.await;
|
|
assert_eq!(r.status(), 204);
|
|
|
|
// snapshot reflects spend
|
|
let r = post(
|
|
&c,
|
|
format!("{base}/authz/v1/snapshot"),
|
|
json!({"account_id": account_id, "key_id": key_id}),
|
|
Some(CLIENT_TOKEN),
|
|
)
|
|
.await;
|
|
let body: Value = r.json().await.unwrap();
|
|
assert_eq!(body["spent"], 150);
|
|
assert_eq!(body["reserved"], 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn over_cap_reserve_is_rejected_not_errored() {
|
|
let Some((base, pool)) = spawn_or_skip("over_cap_reserve_is_rejected_not_errored").await else {
|
|
return;
|
|
};
|
|
let raw = format!("sk-{}", Uuid::new_v4());
|
|
let (account_id, key_id) = seed_key(&pool, 100, &raw, false).await;
|
|
let c = client();
|
|
let r = post(
|
|
&c,
|
|
format!("{base}/authz/v1/reserve"),
|
|
json!({"account_id": account_id, "key_id": key_id, "max_tokens": 999}),
|
|
Some(CLIENT_TOKEN),
|
|
)
|
|
.await;
|
|
assert_eq!(r.status(), 200, "budget refusal is an authoritative 200");
|
|
let body: Value = r.json().await.unwrap();
|
|
assert!(body["reservation_id"].is_null());
|
|
assert_eq!(body["rejected"]["kind"], "insufficient_quota");
|
|
assert_eq!(body["rejected"]["available"], 100);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn deactivated_account_resolves_as_invalid_no_clue() {
|
|
let Some((base, pool)) = spawn_or_skip("deactivated_account_resolves_as_invalid_no_clue").await
|
|
else {
|
|
return;
|
|
};
|
|
let raw = format!("sk-{}", Uuid::new_v4());
|
|
seed_key(&pool, 1000, &raw, true).await; // deactivated
|
|
let c = client();
|
|
let r = post(
|
|
&c,
|
|
format!("{base}/authz/v1/resolve"),
|
|
json!({"api_key": raw}),
|
|
Some(CLIENT_TOKEN),
|
|
)
|
|
.await;
|
|
// Indistinguishable from an unknown key.
|
|
assert_eq!(r.status(), 401);
|
|
let body: Value = r.json().await.unwrap();
|
|
assert_eq!(body["error"]["code"], "invalid_api_key");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_client_auth_is_401_before_db() {
|
|
let Some((base, pool)) = spawn_or_skip("missing_client_auth_is_401_before_db").await else {
|
|
return;
|
|
};
|
|
let raw = format!("sk-{}", Uuid::new_v4());
|
|
seed_key(&pool, 1000, &raw, false).await;
|
|
let c = client();
|
|
// No bearer → rejected by client_auth.
|
|
let r = post(
|
|
&c,
|
|
format!("{base}/authz/v1/resolve"),
|
|
json!({"api_key": raw}),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(r.status(), 401);
|
|
// Wrong bearer → also rejected.
|
|
let r = post(
|
|
&c,
|
|
format!("{base}/authz/v1/resolve"),
|
|
json!({"api_key": raw}),
|
|
Some("wrong"),
|
|
)
|
|
.await;
|
|
assert_eq!(r.status(), 401);
|
|
}
|