mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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>
614 lines
20 KiB
Rust
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()),
|
|
}
|
|
}
|
|
}
|