Files
ZyyandCodex 958e13c370 fix(errors): standardize client messages and correct error mappings
Return non-retryable client version errors and correct provider error types.
Keep safe user messages separate from operational diagnostics.
Unify error wording and document the conventions in the development skill.

Co-authored-by: Codex <noreply@openai.com>
2026-10-29 01:57:31 +08:00

614 lines
20 KiB
Rust

//! 验证管理接口的错误封装、状态码与输入信息脱敏
use axum::{
Router,
body::{Body, to_bytes},
http::{Method, Request, StatusCode, header},
};
use gateway_api::auth::SessionState;
use serde_json::{Value, json};
use tower::ServiceExt as _;
use super::{AdminTestFixture, AdminTestState};
const SESSION_COOKIE: &str = "cpr_session=valid-session";
fn app(state: AdminTestState) -> Router {
crate::openai::api_router_with_admin(state.admin_services().clone()).layer(axum::Extension(
axum::extract::ConnectInfo(std::net::SocketAddr::from(([127, 0, 0, 1], 41000))),
))
}
fn request(method: Method, uri: &str, body: Body) -> Request<Body> {
Request::builder()
.method(method)
.uri(uri)
.header("x-request-id", "req_admin_errors")
.body(body)
.expect("build admin error request")
}
async fn response_json(response: axum::response::Response) -> (StatusCode, String, Value) {
let status = response.status();
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_owned();
let body = to_bytes(response.into_body(), 64 * 1024)
.await
.expect("read admin error response body");
let body = serde_json::from_slice(&body).expect("parse admin error response JSON");
(status, content_type, body)
}
#[tokio::test]
async fn malformed_login_json_should_use_the_admin_error_envelope() {
let fixture = AdminTestFixture::new().await;
let mut request = request(
Method::POST,
"/api/auth/login",
Body::from(r#"{"password":"secret""#),
);
request
.headers_mut()
.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
let response = app(fixture.state())
.oneshot(request)
.await
.expect("malformed JSON response");
let actual = response_json(response).await;
assert_eq!(
actual,
(
StatusCode::BAD_REQUEST,
"application/json".to_owned(),
json!({
"code": 40000,
"message": "请求体不是合法 JSON",
"data": null
})
)
);
}
#[tokio::test]
async fn invalid_login_json_data_should_not_echo_the_submitted_value() {
let fixture = AdminTestFixture::new().await;
let submitted = "password-must-not-leak";
let mut request = request(
Method::POST,
"/api/auth/login",
Body::from(
json!({ "mode": "admin", "password": submitted, "rememberMe": true }).to_string(),
),
);
request
.headers_mut()
.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
let response = app(fixture.state())
.oneshot(request)
.await
.expect("invalid JSON data response");
let actual = response_json(response).await;
assert_eq!(
actual,
(
StatusCode::UNPROCESSABLE_ENTITY,
"application/json".to_owned(),
json!({
"code": 40001,
"message": "请求字段不合法",
"data": null
})
)
);
assert!(!actual.2.to_string().contains(submitted));
}
#[tokio::test]
async fn login_without_json_content_type_should_keep_415_with_an_admin_envelope() {
let fixture = AdminTestFixture::new().await;
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/auth/login",
Body::from(json!({ "mode": "admin", "password": "secret" }).to_string()),
))
.await
.expect("missing JSON content type response");
let actual = response_json(response).await;
assert_eq!(
actual,
(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"application/json".to_owned(),
json!({
"code": 40001,
"message": "请求必须使用 application/json",
"data": null
})
)
);
}
#[tokio::test]
async fn malformed_admin_query_should_use_a_safe_bad_request_envelope() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let mut request = request(
Method::GET,
"/api/admin/usage/records?page=not-a-number",
Body::empty(),
);
request
.headers_mut()
.insert(header::COOKIE, SESSION_COOKIE.parse().unwrap());
let response = app(fixture.state())
.oneshot(request)
.await
.expect("invalid query response");
let actual = response_json(response).await;
assert_eq!(
actual,
(
StatusCode::BAD_REQUEST,
"application/json".to_owned(),
json!({
"code": 40001,
"message": "请求参数不合法",
"data": null
})
)
);
}
#[tokio::test]
async fn client_key_validation_should_identify_the_invalid_field_without_echoing_values() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let secret = "private-key-must-not-leak\n";
for (field, value, message) in [
("name", json!(" "), "API Key 名称不能为空"),
(
"customKey",
json!(secret),
"自定义 API Key 仅支持英文字母、数字和英文符号",
),
(
"dailyLimitUsd",
json!("-1"),
"每日美元限额须非负,整数和小数各最多 10 位",
),
(
"weeklyLimitUsd",
json!("0.00000000001"),
"每周美元限额须非负,整数和小数各最多 10 位",
),
(
"maxConcurrency",
json!(u64::MAX),
"并发请求上限超出支持范围",
),
] {
for path in [
"/api/admin/client-keys/create",
"/api/admin/client-keys/update",
] {
if path.ends_with("/update") && field == "customKey" {
continue;
}
let mut payload = json!({
"name": "key",
"groupIds": [],
"maxConcurrency": 0,
"requestsPerMinute": 0,
});
if path.ends_with("/update") {
payload["id"] = json!("key_existing");
}
payload[field] = value.clone();
let mut request = request(Method::POST, path, Body::from(payload.to_string()));
request
.headers_mut()
.insert(header::COOKIE, SESSION_COOKIE.parse().unwrap());
request
.headers_mut()
.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
let response = app(fixture.state()).oneshot(request).await.unwrap();
let (status, _, body) = response_json(response).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
body,
json!({"code": 40001, "message": message, "data": null})
);
assert!(!body.to_string().contains(secret.trim()));
}
}
}
#[tokio::test]
async fn invalid_time_range_should_use_its_published_business_code() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let mut request = request(
Method::GET,
"/api/admin/dashboard/summary?startTime=not-a-time",
Body::empty(),
);
request
.headers_mut()
.insert(header::COOKIE, SESSION_COOKIE.parse().unwrap());
let response = app(fixture.state())
.oneshot(request)
.await
.expect("invalid time range response");
let actual = response_json(response).await;
assert_eq!(
actual,
(
StatusCode::BAD_REQUEST,
"application/json".to_owned(),
json!({
"code": 40002,
"message": "时间范围不合法",
"data": null
})
)
);
}
#[tokio::test]
async fn unknown_admin_path_should_not_fall_through_to_the_spa() {
let fixture = AdminTestFixture::new().await;
let response = app(fixture.state())
.oneshot(request(
Method::GET,
"/api/admin/not-a-real-route",
Body::empty(),
))
.await
.expect("unknown admin path response");
let actual = response_json(response).await;
assert_eq!(
actual,
(
StatusCode::NOT_FOUND,
"application/json".to_owned(),
json!({
"code": 40401,
"message": "管理接口不存在",
"data": null
})
)
);
}
#[tokio::test]
async fn unsupported_admin_method_should_keep_allow_and_return_an_envelope() {
let fixture = AdminTestFixture::new().await;
let response = app(fixture.state())
.oneshot(request(Method::POST, "/api/auth/status", Body::empty()))
.await
.expect("unsupported admin method response");
let allow = response
.headers()
.get(header::ALLOW)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_owned();
let actual = response_json(response).await;
assert_eq!(
(allow, actual),
(
"GET,HEAD".to_owned(),
(
StatusCode::METHOD_NOT_ALLOWED,
"application/json".to_owned(),
json!({
"code": 40001,
"message": "请求方法不受支持",
"data": null
})
)
)
);
}
#[tokio::test]
async fn admin_auth_failures_should_use_stable_chinese_contracts() {
let fixture = AdminTestFixture::new().await;
let unauthenticated = app(fixture.state())
.clone()
.oneshot(request(Method::GET, "/api/admin/accounts", Body::empty()))
.await
.expect("missing session response");
let mut invalid_api_key = request(Method::GET, "/api/admin/accounts", Body::empty());
invalid_api_key
.headers_mut()
.insert("x-api-key", "admin-invalid".parse().unwrap());
let invalid_api_key = app(fixture.state())
.clone()
.oneshot(invalid_api_key)
.await
.expect("invalid API key response");
let mut invalid_login = request(
Method::POST,
"/api/auth/login",
Body::from(json!({ "mode": "admin", "password": "wrong-password" }).to_string()),
);
invalid_login
.headers_mut()
.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
let invalid_login = app(fixture.state())
.oneshot(invalid_login)
.await
.expect("invalid credentials response");
let actual = [
response_json(unauthenticated).await,
response_json(invalid_login).await,
response_json(invalid_api_key).await,
];
assert_eq!(
actual.map(|(status, _, body)| (status, body["code"].clone(), body["message"].clone())),
[
(StatusCode::UNAUTHORIZED, json!(40101), json!("需要登录")),
(
StatusCode::UNAUTHORIZED,
json!(40102),
json!("登录凭据错误")
),
(
StatusCode::UNAUTHORIZED,
json!(40103),
json!("管理 API Key 无效")
),
]
);
}
mod provider {
use std::time::SystemTime;
use chrono::Utc;
use gateway_admin::{
model::{
Revision,
accounts::{AccountPageItem, AccountRecord},
},
ports::provider::{ProviderAdminError, ProviderAdminErrorKind as Kind},
};
use gateway_core::{
account::{
AccountStatusFacts, AccountWeight, CredentialState, QuotaState, resolve_account_status,
},
routing::ProviderKind,
};
use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
use super::*;
#[tokio::test]
async fn unsupported_quota_should_preserve_account_capacity_wire() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (used_slots, total_slots) in [(Some(3), Some(5)), (Some(0), None), (None, Some(10))] {
let mut stored = account("openai");
stored.capacity = gateway_admin::model::accounts::AccountCapacity {
used_slots,
total_slots,
};
*fixture.account.lock().unwrap() = Some(stored);
let mut request = request(
Method::GET,
"/api/admin/accounts/quota?accountId=acct_error_test",
Body::empty(),
);
request
.headers_mut()
.insert(header::COOKIE, SESSION_COOKIE.parse().unwrap());
let response = app(fixture.state()).oneshot(request).await.unwrap();
let (status, _, body) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["data"]["account"]["capacity"],
json!({
"usedSlots": used_slots,
"totalSlots": total_slots,
})
);
}
}
#[tokio::test]
async fn provider_public_errors_survive_import_credential_and_quota_refresh_handlers() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for provider in ["openai", "xai"] {
*fixture.account.lock().unwrap() = Some(account(provider));
for (kind, status, code, message) in [
(Kind::Invalid, 400, 40001, "刷新令牌已被使用,请重新授权"),
(
Kind::Conflict,
409,
40901,
"令牌刷新繁忙,请等待当前刷新完成后重试",
),
(
Kind::BadGateway,
502,
50201,
"OpenAI 返回的 Codex PAT 身份资料不完整或格式无效,请稍后重试",
),
(
Kind::Unavailable,
503,
50301,
"暂时无法向 OpenAI 验证 Codex PAT,请稍后重试",
),
(
Kind::Ambiguous,
502,
50202,
"令牌刷新结果未知,请先核对账号状态,不要立即重复刷新",
),
] {
*fixture.provider_error.lock().unwrap() = Some(
ProviderAdminError::new(kind)
.with_public_message(message)
.with_message("raw-upstream-secret-marker"),
);
for (path, body) in [
(
"/api/admin/accounts/refresh",
json!({"accountId": "acct_error_test"}),
),
(
"/api/admin/accounts/import",
json!({"provider": provider, "data": {"accessToken": "at-synthetic-test"}}),
),
(
"/api/admin/accounts/quota/refresh",
json!({"accountId": "acct_error_test"}),
),
] {
let response = send(&fixture, path, body).await;
assert_eq!(response.headers()["x-request-id"], "req_admin_errors");
let actual = response_json(response).await;
assert_eq!(
actual,
(
StatusCode::from_u16(status).unwrap(),
"application/json".to_owned(),
json!({"code": code, "message": message, "data": null}),
)
);
assert!(!actual.2.to_string().contains("raw-upstream-secret-marker"));
assert_eq!(fixture.auth.audit_count(), 0);
}
}
}
}
#[tokio::test]
async fn provider_diagnostics_without_public_messages_still_use_safe_fallbacks() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (kind, status, code, message) in [
(Kind::Invalid, 400, 40001, "服务商请求参数不合法"),
(Kind::Unsupported, 400, 40001, "该服务商不支持此操作"),
(Kind::BadGateway, 502, 50201, "上游服务请求失败"),
(Kind::Unavailable, 503, 50301, "服务商暂不可用"),
(
Kind::Ambiguous,
502,
50202,
"上游执行结果未知,请刷新状态后再决定是否重试",
),
(Kind::Internal, 500, 50001, "服务内部错误"),
] {
let error = ProviderAdminError::new(kind).with_message("private-provider-diagnostics");
// 即使 Provider 错标了公开文案,未知内部异常仍不得通过 500 信封下发
*fixture.provider_error.lock().unwrap() = Some(if kind == Kind::Internal {
error.with_public_message("internal-detail-must-stay-hidden")
} else {
error
});
let response = send(
&fixture,
"/api/admin/accounts/import",
json!({"provider": "openai", "data": {}}),
)
.await;
let (actual_status, _, body) = response_json(response).await;
assert_eq!(actual_status.as_u16(), status);
assert_eq!(
body,
json!({"code": code, "message": message, "data": null})
);
}
}
async fn send(fixture: &AdminTestFixture, path: &str, body: Value) -> axum::response::Response {
let mut request = request(Method::POST, path, Body::from(body.to_string()));
request
.headers_mut()
.insert(header::COOKIE, SESSION_COOKIE.parse().unwrap());
request
.headers_mut()
.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
let name = header::HeaderName::from_static("x-request-id");
app(fixture.state())
.layer(PropagateRequestIdLayer::new(name.clone()))
.layer(SetRequestIdLayer::new(name, MakeRequestUuid))
.oneshot(request)
.await
.unwrap()
}
fn account(provider: &str) -> AccountPageItem {
let now = Utc::now();
let facts = AccountStatusFacts {
enabled: true,
credential_state: CredentialState::Ready,
access_token_expires_at: None,
quota: QuotaState::unknown(),
cooldown: None,
last_error_reason: None,
last_error_message: None,
};
AccountPageItem {
protection_rules: Vec::new(),
capacity: gateway_admin::model::accounts::AccountCapacity {
used_slots: None,
total_slots: None,
},
account: AccountRecord {
outbound_proxy_id: None,
outbound_proxy_enabled: true,
notes: None,
model_access: Default::default(),
id: "acct_error_test".to_owned(),
provider_kind: ProviderKind::new(provider).unwrap(),
groups: Vec::new(),
name: "synthetic account".to_owned(),
email: None,
upstream_user_id: None,
upstream_account_id: None,
plan_type: None,
authentication_kind: "oauth".to_owned(),
credential_revision: Revision::new(1).unwrap(),
has_refresh_token: true,
access_token_expires_at: None,
next_refresh_at: None,
enabled: facts.enabled,
concurrency_limit: None,
weight: AccountWeight::default(),
outbound_proxy: None,
credential_state: facts.credential_state,
credential_observed_at: now,
quota: facts.quota,
last_error_reason: None,
last_error_message: None,
created_at: now,
updated_at: now,
},
projection: resolve_account_status(&facts, SystemTime::now()),
}
}
}