Files
codex-proxy-rs/backend/crates/gateway-api/tests/auth.rs
T
ZyyandCodex f6538d433d test: align backend coverage with current behavior
Consolidate overlapping scenarios and remove obsolete assertions and
migration-specific SQL tests. Keep persistence fixtures on the current schema
and document the test maintenance rules in the development skill.

Validation: 3,997 tests passed across 12 crates, plus rustfmt, strict Clippy,
and skill validation. All 33 migration SQL files passed temporary isolated
execution, and the opt-in xAI official network test passed.

Co-authored-by: Codex <noreply@openai.com>
2026-10-19 11:36:06 +08:00

737 lines
24 KiB
Rust

//! 验证 HTTP 登录身份、会话轮换与管理员和 Key 会话的权限隔离
use std::sync::atomic::Ordering;
use axum::{
body::Body,
http::{Method, Request, StatusCode, header},
};
use serde_json::{Value, json};
use tower::ServiceExt as _;
use crate::support::{
RAW_KEY, auth_app, cookie_request, empty_request, json_request, response_json,
};
fn session_cookie(response: &axum::response::Response) -> String {
response.headers()[header::SET_COOKIE]
.to_str()
.expect("cookie")
.split(';')
.next()
.expect("cookie pair")
.to_owned()
}
fn refresh_cookie(response: &axum::response::Response) -> String {
response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.find(|value| value.starts_with("cpr_refresh="))
.expect("refresh cookie")
.split(';')
.next()
.unwrap()
.to_owned()
}
async fn refresh(app: &axum::Router, cookie: &str) -> axum::response::Response {
app.clone()
.oneshot(cookie_request(Method::POST, "/api/auth/refresh", cookie))
.await
.unwrap()
}
async fn login(
app: &axum::Router,
body: Value,
previous: Option<&str>,
) -> axum::response::Response {
let mut request = json_request(Method::POST, "/api/auth/login", body);
if let Some(cookie) = previous {
request
.headers_mut()
.insert(header::COOKIE, cookie.parse().expect("cookie"));
}
app.clone().oneshot(request).await.expect("login response")
}
async fn get(app: &axum::Router, path: &str, cookie: &str) -> axum::response::Response {
app.clone()
.oneshot(cookie_request(Method::GET, path, cookie))
.await
.expect("GET response")
}
async fn change_password(
app: &axum::Router,
cookie: Option<&str>,
current: &str,
new: &str,
) -> axum::response::Response {
let mut request = json_request(
Method::POST,
"/api/auth/password",
json!({"currentPassword": current, "newPassword": new}),
);
if let Some(cookie) = cookie {
request
.headers_mut()
.insert(header::COOKIE, cookie.parse().unwrap());
}
app.clone().oneshot(request).await.unwrap()
}
#[tokio::test]
async fn password_change_revokes_all_admin_sessions_but_preserves_key_sessions() {
let (app, fixture) = auth_app().await;
let admin_body = json!({"mode": "admin", "password": "strong-admin-password"});
let first = session_cookie(&login(&app, admin_body.clone(), None).await);
let second = session_cookie(&login(&app, admin_body.clone(), None).await);
let key = session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
fixture.set_api_key(&format!("admin-{}", "b".repeat(64)));
assert_eq!(
get(&app, "/api/admin/system/version", &first)
.await
.status(),
StatusCode::OK
);
let response = change_password(
&app,
Some(&first),
"strong-admin-password",
"new-strong-admin-password",
)
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
assert!(
response.headers()[header::SET_COOKIE]
.to_str()
.unwrap()
.contains("Max-Age=0")
);
for cookie in [&first, &second] {
assert_eq!(
get(&app, "/api/admin/system/version", cookie)
.await
.status(),
StatusCode::UNAUTHORIZED
);
}
assert_eq!(
response_json(get(&app, "/api/auth/status", &key).await).await["data"]["authenticated"],
true
);
assert_eq!(
login(&app, admin_body, None).await.status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
login(
&app,
json!({"mode": "admin", "password": "new-strong-admin-password"}),
None
)
.await
.status(),
StatusCode::OK
);
}
#[tokio::test]
async fn password_change_requires_admin_session_and_keeps_session_on_validation_failure() {
let (app, _) = auth_app().await;
assert_eq!(
change_password(&app, None, "strong-admin-password", "new-strong-password")
.await
.status(),
StatusCode::UNAUTHORIZED
);
let key = session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
assert_eq!(
change_password(
&app,
Some(&key),
"strong-admin-password",
"new-strong-password"
)
.await
.status(),
StatusCode::FORBIDDEN
);
let admin = session_cookie(
&login(
&app,
json!({"mode": "admin", "password": "strong-admin-password"}),
None,
)
.await,
);
for (current, new) in [
("wrong-current-password", "new-strong-password"),
("strong-admin-password", "short"),
("strong-admin-password", "strong-admin-password"),
] {
assert_eq!(
change_password(&app, Some(&admin), current, new)
.await
.status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
get(&app, "/api/admin/system/version", &admin)
.await
.status(),
StatusCode::OK
);
}
}
#[tokio::test]
async fn password_change_audit_failure_preserves_password_and_session() {
let (app, fixture) = auth_app().await;
let admin = session_cookie(
&login(
&app,
json!({"mode": "admin", "password": "strong-admin-password"}),
None,
)
.await,
);
fixture.fail_audit(true);
assert_eq!(
change_password(
&app,
Some(&admin),
"strong-admin-password",
"new-strong-password"
)
.await
.status(),
StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(
get(&app, "/api/admin/system/version", &admin)
.await
.status(),
StatusCode::OK
);
fixture.fail_audit(false);
assert_eq!(
login(
&app,
json!({"mode": "admin", "password": "strong-admin-password"}),
None
)
.await
.status(),
StatusCode::OK
);
}
#[tokio::test]
async fn unified_login_returns_server_identity_and_rotates_the_previous_session() {
let (app, _) = auth_app().await;
let key = login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await;
assert_eq!(key.status(), StatusCode::OK);
let key_cookie = session_cookie(&key);
let key_data = response_json(key).await["data"].clone();
assert_eq!(key_data["role"], "key");
assert!(key_data["expiresAt"].is_string());
assert_eq!(key_data.as_object().expect("session object").len(), 2);
assert_eq!(
response_json(get(&app, "/api/auth/status", &key_cookie).await).await["data"],
json!({"authenticated": true, "session": key_data})
);
let admin = login(
&app,
json!({"mode": "admin", "username": "admin_1", "password": "strong-admin-password"}),
Some(&key_cookie),
)
.await;
assert_eq!(admin.status(), StatusCode::OK);
let admin_cookie = session_cookie(&admin);
let admin_data = response_json(admin).await["data"].clone();
assert_eq!(admin_data["role"], "admin");
assert!(admin_data["expiresAt"].is_string());
assert_eq!(admin_data.as_object().expect("session object").len(), 2);
assert_ne!(key_cookie, admin_cookie);
assert_eq!(
response_json(get(&app, "/api/auth/status", &admin_cookie).await).await["data"],
json!({"authenticated": true, "session": admin_data})
);
assert_eq!(
response_json(get(&app, "/api/auth/status", &key_cookie).await).await["data"],
json!({"authenticated": false, "session": null})
);
assert_eq!(
get(&app, "/api/admin/system/version", &admin_cookie)
.await
.status(),
StatusCode::OK
);
let key = login(
&app,
json!({"mode": "key", "apiKey": RAW_KEY}),
Some(&admin_cookie),
)
.await;
assert_eq!(key.status(), StatusCode::OK);
let renewed = session_cookie(&key);
assert_ne!(renewed, key_cookie);
assert_eq!(
get(&app, "/api/admin/system/version", &admin_cookie)
.await
.status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
response_json(get(&app, "/api/auth/status", &renewed).await).await["data"]["authenticated"],
true
);
}
#[tokio::test]
async fn login_rejects_missing_mode_and_unknown_fields_without_replacing_the_session() {
let (app, _) = auth_app().await;
let cookie =
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
for body in [
json!({"apiKey": RAW_KEY}),
json!({"password": "strong-admin-password"}),
json!({"mode": "key", "unknownField": true, "apiKey": RAW_KEY}),
json!({"mode": "admin", "unknownField": true, "password": "strong-admin-password"}),
] {
let response = login(&app, body, Some(&cookie)).await;
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
assert!(!response.headers().contains_key(header::SET_COOKIE));
let error = response_json(response).await.to_string();
assert!(!error.contains(RAW_KEY));
assert!(!error.contains("strong-admin-password"));
}
assert_eq!(
response_json(get(&app, "/api/auth/status", &cookie).await).await["data"]["session"]["role"],
"key"
);
}
#[tokio::test]
async fn valid_key_session_cannot_read_or_mutate_admin_resources() {
let (app, _) = auth_app().await;
let cookie =
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
for path in [
"/api/admin/system/version",
"/api/admin/accounts",
"/api/admin/client-keys",
"/api/admin/settings",
] {
let response = get(&app, path, &cookie).await;
assert_eq!(response.status(), StatusCode::FORBIDDEN, "{path}");
assert_eq!(response_json(response).await["code"], 40301);
}
let mut request = json_request(
Method::POST,
"/api/admin/settings/admin-api-key/regenerate",
json!({}),
);
request
.headers_mut()
.insert(header::COOKIE, cookie.parse().unwrap());
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response_json(get(&app, "/api/auth/status", &cookie).await).await["data"]["authenticated"],
true
);
}
#[tokio::test]
async fn declared_roles_and_foreign_key_ids_cannot_override_server_authority() {
let (app, _) = auth_app().await;
let cookie =
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
for body in [
json!({"mode": "admin", "apiKey": RAW_KEY}),
json!({"mode": "key", "apiKey": RAW_KEY, "role": "admin"}),
json!({"mode": "admin", "password": RAW_KEY, "role": "key"}),
json!({"role": "key", "apiKey": RAW_KEY}),
json!({"mode": "key", "apiKey": RAW_KEY, "adminUserId": "admin_1"}),
json!({"mode": "key", "apiKey": RAW_KEY, "clientKeyId": "other-key"}),
json!({"mode": "superadmin", "apiKey": RAW_KEY}),
json!({"apiKey": RAW_KEY}),
] {
let response = login(&app, body, Some(&cookie)).await;
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert!(!response_json(response).await.to_string().contains(RAW_KEY));
}
let invalid = login(
&app,
json!({"mode": "admin", "password": RAW_KEY}),
Some(&cookie),
)
.await;
assert_eq!(invalid.status(), StatusCode::UNAUTHORIZED);
assert!(!invalid.headers().contains_key(header::SET_COOKIE));
assert_eq!(response_json(invalid).await["code"], 40102);
assert_eq!(
response_json(get(&app, "/api/auth/status", &cookie).await).await["data"]["session"]["role"],
"key"
);
}
#[tokio::test]
async fn data_plane_keys_and_browser_sessions_are_not_interchangeable() {
let (app, _) = auth_app().await;
let cookie =
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
assert_eq!(
get(&app, "/v1/models", &cookie).await.status(),
StatusCode::UNAUTHORIZED
);
for (name, value) in [
(header::AUTHORIZATION.as_str(), format!("Bearer {RAW_KEY}")),
("x-api-key", RAW_KEY.to_owned()),
] {
let request = Request::get("/api/auth/status")
.header(name, value)
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(
response_json(response).await["data"]["authenticated"],
false
);
}
assert_eq!(
response_json(get(&app, "/api/auth/status", &format!("cpr_session={RAW_KEY}")).await).await
["data"]["authenticated"],
false
);
}
#[tokio::test]
async fn disabled_key_revokes_its_session_and_reenabling_does_not_restore_it() {
let (app, store) = auth_app().await;
let cookie =
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
store.enabled.store(false, Ordering::SeqCst);
assert_eq!(
response_json(get(&app, "/api/auth/status", &cookie).await).await["data"],
json!({"authenticated": false, "session": null})
);
assert_eq!(
login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None)
.await
.status(),
StatusCode::UNAUTHORIZED
);
store.enabled.store(true, Ordering::SeqCst);
assert_eq!(
response_json(get(&app, "/api/auth/status", &cookie).await).await["data"]["authenticated"],
false
);
}
#[tokio::test]
async fn key_store_outage_is_unavailable_not_expired_or_authenticated() {
let (app, store) = auth_app().await;
let cookie =
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
store.unavailable.store(true, Ordering::SeqCst);
for path in ["/api/auth/status", "/api/admin/system/version"] {
let response = get(&app, path, &cookie).await;
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{path}");
assert!(!response.headers().contains_key(header::SET_COOKIE));
}
store.unavailable.store(false, Ordering::SeqCst);
assert_eq!(
response_json(get(&app, "/api/auth/status", &cookie).await).await["data"]["authenticated"],
true
);
}
#[tokio::test]
async fn unknown_auth_routes_return_json_not_found() {
let (app, _) = auth_app().await;
let response = app
.oneshot(empty_request(Method::GET, "/api/auth/unknown"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(response_json(response).await["code"], 40401);
}
#[tokio::test]
async fn session_authentication_requires_the_configured_cookie_name() {
let (app, _) = auth_app().await;
let cookie =
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
let unrelated_cookie = cookie.replace("cpr_session", "unrelated_session");
assert_eq!(
response_json(get(&app, "/api/auth/status", &unrelated_cookie).await).await["data"]["authenticated"],
false
);
}
#[test]
fn login_wire_never_prints_either_credential_in_debug() {
for body in [
json!({"mode": "key", "apiKey": RAW_KEY}),
json!({"mode": "admin", "password": RAW_KEY}),
] {
let request: gateway_api::auth::LoginRequest = serde_json::from_value(body).unwrap();
let debug = format!("{request:?}");
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains(RAW_KEY));
}
}
#[tokio::test]
async fn key_auth_should_issue_restore_and_clear_a_unified_cookie() {
let (app, _) = auth_app().await;
let login = app
.clone()
.oneshot(json_request(
Method::POST,
"/api/auth/login",
json!({ "mode": "key", "apiKey": RAW_KEY }),
))
.await
.expect("client login response");
assert_eq!(login.status(), StatusCode::OK);
assert_eq!(login.headers()[header::CACHE_CONTROL], "no-store");
let set_cookie = login.headers()[header::SET_COOKIE]
.to_str()
.expect("client session cookie")
.to_owned();
assert!(set_cookie.starts_with("cpr_session=session_"));
assert!(set_cookie.contains("Path=/;"));
assert!(set_cookie.contains("; Secure; HttpOnly; SameSite=Lax"));
assert!(set_cookie.contains("; Max-Age="));
assert!(set_cookie.contains("; Expires="));
assert!(!set_cookie.contains(RAW_KEY));
let cookie = set_cookie
.split(';')
.next()
.expect("cookie pair")
.to_owned();
let login_body = response_json(login).await;
assert_eq!(login_body["data"]["role"], "key");
assert!(login_body["data"].get("key").is_none());
assert!(!login_body.to_string().contains(RAW_KEY));
let status = app
.clone()
.oneshot(cookie_request(Method::GET, "/api/auth/status", &cookie))
.await
.expect("client status response");
assert_eq!(status.status(), StatusCode::OK);
let status_body = response_json(status).await;
assert_eq!(status_body["data"]["authenticated"], true);
assert_eq!(status_body["data"]["session"]["role"], "key");
let logout = app
.clone()
.oneshot(cookie_request(Method::POST, "/api/auth/logout", &cookie))
.await
.expect("client logout response");
assert_eq!(logout.status(), StatusCode::OK);
let cleared = logout.headers()[header::SET_COOKIE]
.to_str()
.expect("cleared cookie");
assert!(cleared.starts_with("cpr_session=;"));
assert!(cleared.contains("Path=/;"));
assert!(cleared.contains("Max-Age=0"));
let status = app
.oneshot(cookie_request(Method::GET, "/api/auth/status", &cookie))
.await
.expect("logged out");
assert_eq!(response_json(status).await["data"]["authenticated"], false);
}
#[tokio::test]
async fn refresh_restores_expired_access_with_only_the_refresh_cookie() {
let (app, fixture) = auth_app().await;
let response = login(
&app,
json!({"mode": "admin", "password": "strong-admin-password"}),
None,
)
.await;
let access = session_cookie(&response);
let renewal = refresh_cookie(&response);
let cookies: Vec<_> = response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.map(|value| value.to_str().unwrap())
.collect();
assert_eq!(cookies.len(), 2);
assert!(cookies[0].contains("Path=/;"));
assert!(cookies[1].contains("Path=/api/auth;"));
assert!(
cookies
.iter()
.all(|value| value.contains("HttpOnly") && value.contains("SameSite=Lax"))
);
assert_eq!(
response_json(refresh(&app, &access).await).await["data"]["authenticated"],
false
);
fixture.set_session_expiry(
access.split_once('=').unwrap().1,
chrono::Utc::now() - chrono::Duration::seconds(1),
);
assert_eq!(
get(&app, "/api/admin/system/version", &access)
.await
.status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
response_json(get(&app, "/api/auth/status", &renewal).await).await["data"]["authenticated"],
false
);
let response = refresh(&app, &renewal).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
let next_access = session_cookie(&response);
let next_renewal = refresh_cookie(&response);
assert_ne!(next_access, access);
assert_ne!(next_renewal, renewal);
assert_eq!(response_json(response).await["data"]["authenticated"], true);
assert_eq!(
get(&app, "/api/admin/system/version", &next_access)
.await
.status(),
StatusCode::OK
);
assert_eq!(
get(&app, "/api/admin/system/version", &access)
.await
.status(),
StatusCode::UNAUTHORIZED
);
// 模拟响应丢失后的重试,返回同一代 Cookie
let retry = refresh(&app, &renewal).await;
assert_eq!(session_cookie(&retry), next_access);
assert_eq!(refresh_cookie(&retry), next_renewal);
}
#[tokio::test]
async fn key_refresh_preserves_fixed_expiry_and_clears_admin_refresh_cookie() {
let (app, _) = auth_app().await;
let admin = login(
&app,
json!({"mode": "admin", "password": "strong-admin-password"}),
None,
)
.await;
let renewal = refresh_cookie(&admin);
let response = login(
&app,
json!({"mode": "key", "apiKey": RAW_KEY}),
Some(&renewal),
)
.await;
assert_eq!(refresh_cookie(&response), "cpr_refresh=");
assert!(
response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.any(|value| {
let value = value.to_str().unwrap();
value.starts_with("cpr_refresh=")
&& value.contains("Max-Age=0")
&& value.contains("Path=/api/auth;")
})
);
let cookie = session_cookie(&response);
let original = response_json(response).await["data"]["expiresAt"].clone();
let response = refresh(&app, &cookie).await;
assert_eq!(session_cookie(&response), cookie);
let data = response_json(response).await;
assert_eq!(data["data"]["authenticated"], true);
assert_eq!(data["data"]["session"]["expiresAt"], original);
assert_eq!(
response_json(refresh(&app, &renewal).await).await["data"]["authenticated"],
false
);
}
#[tokio::test]
async fn refresh_after_logout_or_password_change_cannot_recreate_a_session() {
let (app, fixture) = auth_app().await;
for password_change in [false, true] {
let login_response = login(
&app,
json!({"mode": "admin", "password": "strong-admin-password"}),
None,
)
.await;
let cookie = session_cookie(&login_response);
let renewal = refresh_cookie(&login_response);
let response = if password_change {
change_password(
&app,
Some(&cookie),
"strong-admin-password",
"new-strong-admin-password",
)
.await
} else {
fixture.set_session_expiry(
cookie.split_once('=').unwrap().1,
chrono::Utc::now() - chrono::Duration::seconds(1),
);
app.clone()
.oneshot(cookie_request(Method::POST, "/api/auth/logout", &renewal))
.await
.unwrap()
};
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.count(),
2
);
assert!(
response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.all(|value| value.to_str().unwrap().contains("Max-Age=0"))
);
let response = app
.clone()
.oneshot(cookie_request(Method::POST, "/api/auth/refresh", &renewal))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(!response.headers().contains_key(header::SET_COOKIE));
assert_eq!(
response_json(response).await["data"]["authenticated"],
false
);
assert_eq!(
get(&app, "/api/admin/system/version", &cookie)
.await
.status(),
StatusCode::UNAUTHORIZED
);
}
}