mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
Merge PR #469: 修复用户可见性、额度、验证与 Codex 探测
This commit is contained in:
@@ -29,6 +29,7 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
||||
struct SeenExecutionRuntimeRequest {
|
||||
url: String,
|
||||
authorization: String,
|
||||
accept: String,
|
||||
provider_api_format: String,
|
||||
total_ms: Option<u64>,
|
||||
}
|
||||
@@ -68,6 +69,7 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
||||
.get("authorization")
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
accept: plan.headers.get("accept").cloned().unwrap_or_default(),
|
||||
provider_api_format: plan.provider_api_format.clone(),
|
||||
total_ms: plan
|
||||
.timeouts
|
||||
@@ -78,41 +80,22 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
||||
request_id: plan.request_id,
|
||||
candidate_id: None,
|
||||
status_code: 200,
|
||||
headers: BTreeMap::from([
|
||||
(
|
||||
"x-codex-primary-reset-after-seconds".to_string(),
|
||||
"18000".to_string(),
|
||||
),
|
||||
(
|
||||
"x-codex-primary-reset-at".to_string(),
|
||||
"1900000000".to_string(),
|
||||
),
|
||||
(
|
||||
"x-codex-secondary-reset-after-seconds".to_string(),
|
||||
"604800".to_string(),
|
||||
),
|
||||
(
|
||||
"x-codex-secondary-reset-at".to_string(),
|
||||
"1900500000".to_string(),
|
||||
),
|
||||
]),
|
||||
headers: BTreeMap::new(),
|
||||
body: Some(aether_contracts::ResponseBody {
|
||||
json_body: Some(json!({
|
||||
"plan_type": "plus",
|
||||
"rate_limit": {
|
||||
"primary_window": {
|
||||
"used_percent": 12.5,
|
||||
"window_minutes": 300
|
||||
},
|
||||
"secondary_window": {
|
||||
"used_percent": 55.0,
|
||||
"window_minutes": 10080
|
||||
}
|
||||
"user": {
|
||||
"id": "user-codex-123",
|
||||
"email": "codex@example.com",
|
||||
"name": "Codex User"
|
||||
},
|
||||
"credits": {
|
||||
"has_credits": true,
|
||||
"balance": 42.0,
|
||||
"unlimited": false
|
||||
"account": {
|
||||
"id": "acct-codex-123",
|
||||
"name": "Personal",
|
||||
"plan_type": "plus"
|
||||
},
|
||||
"plan": {
|
||||
"type": "Plus",
|
||||
"title": "ChatGPT Plus"
|
||||
}
|
||||
})),
|
||||
body_bytes_b64: None,
|
||||
@@ -147,7 +130,7 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
||||
)],
|
||||
));
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let (_upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let gateway = build_router_with_state(
|
||||
build_state_with_execution_runtime_override(execution_runtime_url.clone())
|
||||
@@ -184,18 +167,22 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
||||
);
|
||||
assert_eq!(payload["results"][0]["quota_snapshot"]["plan_type"], "plus");
|
||||
assert_eq!(
|
||||
payload["results"][0]["quota_snapshot"]["reset_at"],
|
||||
1_900_000_000u64
|
||||
);
|
||||
assert_eq!(
|
||||
payload["results"][0]["quota_snapshot"]["credits"]["balance"],
|
||||
json!(42.0)
|
||||
payload["results"][0]["quota_snapshot"]["exhausted"],
|
||||
json!(false)
|
||||
);
|
||||
assert_eq!(
|
||||
payload["results"][0]["quota_snapshot"]["windows"]
|
||||
.as_array()
|
||||
.map(Vec::len),
|
||||
Some(2usize)
|
||||
Some(0usize)
|
||||
);
|
||||
assert_eq!(
|
||||
payload["results"][0]["metadata"]["email"],
|
||||
"codex@example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["results"][0]["metadata"]["account_id"],
|
||||
"acct-codex-123"
|
||||
);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
@@ -206,12 +193,13 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
||||
.expect("execution runtime request should be captured");
|
||||
assert_eq!(
|
||||
seen_execution_runtime_request.url,
|
||||
"https://chatgpt.com/backend-api/wham/usage"
|
||||
"https://chatgpt.com/backend-api/me"
|
||||
);
|
||||
assert_eq!(
|
||||
seen_execution_runtime_request.authorization,
|
||||
"Bearer sk-codex-123"
|
||||
);
|
||||
assert_eq!(seen_execution_runtime_request.accept, "application/json");
|
||||
assert_eq!(
|
||||
seen_execution_runtime_request.provider_api_format,
|
||||
"openai:responses"
|
||||
@@ -237,32 +225,130 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("codex"))
|
||||
.and_then(|value| value.get("primary_used_percent")),
|
||||
Some(&json!(55.0))
|
||||
.and_then(|value| value.get("email")),
|
||||
Some(&json!("codex@example.com"))
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded[0]
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("codex"))
|
||||
.and_then(|value| value.get("primary_reset_at")),
|
||||
Some(&json!(1_900_500_000u64))
|
||||
.and_then(|value| value.get("account_id")),
|
||||
Some(&json!("acct-codex-123"))
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded[0]
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("codex"))
|
||||
.and_then(|value| value.get("secondary_used_percent")),
|
||||
Some(&json!(12.5))
|
||||
assert!(reloaded[0]
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("codex"))
|
||||
.and_then(|value| value.get("primary_used_percent"))
|
||||
.is_none());
|
||||
|
||||
gateway_handle.abort();
|
||||
execution_runtime_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_marks_codex_key_invalid_when_backend_me_returns_payment_required() {
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/endpoints/providers/provider-codex/refresh-quota",
|
||||
any(move |_request: Request| async move {
|
||||
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||
}),
|
||||
);
|
||||
|
||||
let execution_runtime = Router::new().route(
|
||||
"/v1/execute/sync",
|
||||
any(move |request: Request| async move {
|
||||
let plan: aether_contracts::ExecutionPlan = serde_json::from_slice(
|
||||
&to_bytes(request.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body should read"),
|
||||
)
|
||||
.expect("plan should parse");
|
||||
let result = aether_contracts::ExecutionResult {
|
||||
request_id: plan.request_id,
|
||||
candidate_id: None,
|
||||
status_code: 402,
|
||||
headers: BTreeMap::new(),
|
||||
body: Some(aether_contracts::ResponseBody {
|
||||
json_body: Some(json!({
|
||||
"error": {
|
||||
"message": "payment required"
|
||||
}
|
||||
})),
|
||||
body_bytes_b64: None,
|
||||
}),
|
||||
telemetry: None,
|
||||
error: None,
|
||||
};
|
||||
(StatusCode::OK, Json(result))
|
||||
}),
|
||||
);
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![StoredProviderCatalogProvider::new(
|
||||
"provider-codex".to_string(),
|
||||
"codex".to_string(),
|
||||
Some("https://example.com".to_string()),
|
||||
"codex".to_string(),
|
||||
)
|
||||
.expect("provider should build")],
|
||||
vec![sample_endpoint(
|
||||
"endpoint-codex-cli",
|
||||
"provider-codex",
|
||||
"openai:responses",
|
||||
"https://chatgpt.com/backend-api",
|
||||
)],
|
||||
vec![sample_key(
|
||||
"key-codex-a",
|
||||
"provider-codex",
|
||||
"openai:responses",
|
||||
"sk-codex-123",
|
||||
)],
|
||||
));
|
||||
|
||||
let (_upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let gateway = build_router_with_state(
|
||||
build_state_with_execution_runtime_override(execution_runtime_url.clone())
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_repository_for_tests(
|
||||
provider_catalog_repository.clone(),
|
||||
)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{gateway_url}/api/admin/endpoints/providers/provider-codex/refresh-quota"
|
||||
))
|
||||
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["success"], 0);
|
||||
assert_eq!(payload["failed"], 1);
|
||||
assert_eq!(payload["results"][0]["status"], "payment_required");
|
||||
assert_eq!(payload["results"][0]["status_code"], 402);
|
||||
|
||||
let reloaded = provider_catalog_repository
|
||||
.list_keys_by_ids(&["key-codex-a".to_string()])
|
||||
.await
|
||||
.expect("keys should read");
|
||||
assert_eq!(reloaded.len(), 1);
|
||||
assert!(reloaded[0].oauth_invalid_at_unix_secs.is_some());
|
||||
assert_eq!(
|
||||
reloaded[0]
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("codex"))
|
||||
.and_then(|value| value.get("secondary_reset_at")),
|
||||
Some(&json!(1_900_000_000u64))
|
||||
reloaded[0].oauth_invalid_reason.as_deref(),
|
||||
Some("[ACCOUNT_BLOCK] payment required")
|
||||
);
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -1259,7 +1345,7 @@ async fn gateway_reports_codex_quota_runtime_failures_locally_without_falling_ba
|
||||
assert!(payload["results"][0]["message"]
|
||||
.as_str()
|
||||
.expect("message should be string")
|
||||
.contains("wham/usage 请求执行失败: execution runtime returned HTTP 500"));
|
||||
.contains("backend-api/me 请求执行失败: execution runtime returned HTTP 500"));
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
let reloaded = provider_catalog_repository
|
||||
|
||||
@@ -194,16 +194,17 @@ fn codex_quota_execution_result(request_id: &str) -> serde_json::Value {
|
||||
"headers": {},
|
||||
"body": {
|
||||
"json_body": {
|
||||
"plan_type": "plus",
|
||||
"rate_limit": {
|
||||
"primary_window": {
|
||||
"used_percent": 10.0,
|
||||
"window_minutes": 300
|
||||
},
|
||||
"secondary_window": {
|
||||
"used_percent": 20.0,
|
||||
"window_minutes": 10080
|
||||
}
|
||||
"user": {
|
||||
"id": "user-codex-123",
|
||||
"email": "alice@example.com"
|
||||
},
|
||||
"account": {
|
||||
"id": "acct-codex-123",
|
||||
"plan_type": "plus"
|
||||
},
|
||||
"plan": {
|
||||
"type": "Plus",
|
||||
"title": "ChatGPT Plus"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2453,8 +2454,9 @@ async fn gateway_completes_admin_provider_oauth_key_locally_with_trusted_admin_p
|
||||
.as_str()
|
||||
.expect("account_state_recheck_error should be string when recheck is attempted");
|
||||
assert!(
|
||||
account_state_recheck_error == "wham/usage API 返回状态码 401"
|
||||
|| account_state_recheck_error.starts_with("wham/usage 请求执行失败:"),
|
||||
account_state_recheck_error == "backend-api/me API 返回状态码 401"
|
||||
|| account_state_recheck_error == "backend-api/me API 返回状态码 403"
|
||||
|| account_state_recheck_error.starts_with("backend-api/me 请求执行失败:"),
|
||||
"unexpected account_state_recheck_error: {account_state_recheck_error}"
|
||||
);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
@@ -4924,8 +4926,9 @@ async fn gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_p
|
||||
.as_str()
|
||||
.expect("account_state_recheck_error should be string when attempted");
|
||||
assert!(
|
||||
account_state_recheck_error == "wham/usage API 返回状态码 401"
|
||||
|| account_state_recheck_error.starts_with("wham/usage 请求执行失败:"),
|
||||
account_state_recheck_error == "backend-api/me API 返回状态码 401"
|
||||
|| account_state_recheck_error == "backend-api/me API 返回状态码 403"
|
||||
|| account_state_recheck_error.starts_with("backend-api/me 请求执行失败:"),
|
||||
"unexpected account_state_recheck_error: {account_state_recheck_error}"
|
||||
);
|
||||
} else {
|
||||
@@ -4943,7 +4946,7 @@ async fn gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_p
|
||||
.expect("execution runtime request should be captured");
|
||||
assert_eq!(
|
||||
seen_execution_runtime_request.url,
|
||||
"https://chatgpt.com/backend-api/wham/usage"
|
||||
"https://chatgpt.com/backend-api/me"
|
||||
);
|
||||
assert_eq!(
|
||||
seen_execution_runtime_request.authorization,
|
||||
@@ -4967,7 +4970,7 @@ async fn gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_p
|
||||
.expect("refreshed api key should decrypt");
|
||||
assert_eq!(decrypted_api_key, "refreshed-codex-access-token");
|
||||
if account_state_recheck_attempted
|
||||
&& payload["account_state_recheck_error"] == "wham/usage API 返回状态码 401"
|
||||
&& payload["account_state_recheck_error"] == "backend-api/me API 返回状态码 401"
|
||||
{
|
||||
assert!(stored_key.oauth_invalid_at_unix_secs.is_some());
|
||||
assert_eq!(
|
||||
|
||||
@@ -167,7 +167,7 @@ async fn gateway_handles_public_openai_models_without_hitting_fallback_probe() {
|
||||
assert_eq!(payload["object"], "list");
|
||||
assert_eq!(payload["data"][0]["id"], "gpt-4.1");
|
||||
assert_eq!(payload["data"][1]["id"], "gpt-5");
|
||||
assert_eq!(payload["data"][0]["owned_by"], "openai");
|
||||
assert_eq!(payload["data"][0]["owned_by"], "aether");
|
||||
assert_eq!(*fallback_probe_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -231,7 +231,7 @@ async fn gateway_handles_public_openai_models_with_cross_format_candidates_witho
|
||||
list_response.json().await.expect("json body should parse");
|
||||
assert_eq!(list_payload["object"], "list");
|
||||
assert_eq!(list_payload["data"][0]["id"], "claude-3-7-sonnet");
|
||||
assert_eq!(list_payload["data"][0]["owned_by"], "claude");
|
||||
assert_eq!(list_payload["data"][0]["owned_by"], "aether");
|
||||
|
||||
let detail_response = client
|
||||
.get(format!("{gateway_url}/v1/models/claude-3-7-sonnet"))
|
||||
@@ -245,7 +245,7 @@ async fn gateway_handles_public_openai_models_with_cross_format_candidates_witho
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(detail_payload["id"], "claude-3-7-sonnet");
|
||||
assert_eq!(detail_payload["owned_by"], "claude");
|
||||
assert_eq!(detail_payload["owned_by"], "aether");
|
||||
|
||||
assert_eq!(*fallback_probe_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
|
||||
@@ -758,7 +758,9 @@ async fn gateway_handles_public_catalog_providers_without_proxying_upstream() {
|
||||
let providers = payload.as_array().expect("providers should be an array");
|
||||
assert_eq!(providers.len(), 2);
|
||||
assert_eq!(providers[0]["id"], "provider-openai");
|
||||
assert_eq!(providers[0]["name"], "openai");
|
||||
assert!(providers[0].get("name").is_none());
|
||||
assert!(providers[0].get("description").is_none());
|
||||
assert!(providers[0].get("website").is_none());
|
||||
assert_eq!(providers[0]["provider_priority"], 10);
|
||||
assert_eq!(providers[0]["endpoints_count"], 1);
|
||||
assert_eq!(providers[0]["active_endpoints_count"], 1);
|
||||
@@ -835,7 +837,8 @@ async fn gateway_handles_public_catalog_models_without_proxying_upstream() {
|
||||
let models = payload.as_array().expect("models should be an array");
|
||||
assert_eq!(models.len(), 1);
|
||||
assert_eq!(models[0]["id"], "model-openai-gpt5");
|
||||
assert_eq!(models[0]["provider_name"], "openai");
|
||||
assert!(models[0].get("provider_id").is_none());
|
||||
assert!(models[0].get("provider_name").is_none());
|
||||
assert_eq!(models[0]["name"], "gpt-5");
|
||||
assert_eq!(models[0]["display_name"], "GPT 5");
|
||||
assert_eq!(models[0]["tags"], serde_json::Value::Null);
|
||||
@@ -994,7 +997,8 @@ async fn gateway_handles_public_catalog_search_models_without_proxying_upstream(
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
let models = payload.as_array().expect("models should be an array");
|
||||
assert_eq!(models.len(), 1);
|
||||
assert_eq!(models[0]["provider_name"], "claude");
|
||||
assert!(models[0].get("provider_id").is_none());
|
||||
assert!(models[0].get("provider_name").is_none());
|
||||
assert_eq!(models[0]["name"], "claude-sonnet-4-5");
|
||||
assert_eq!(models[0]["display_name"], "Claude Sonnet 4.5");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
@@ -1102,10 +1106,16 @@ async fn gateway_handles_public_global_models_without_proxying_upstream() {
|
||||
}),
|
||||
);
|
||||
|
||||
let mut gpt_model = sample_public_global_model("gm-3", "gpt-5", "GPT 5", true);
|
||||
gpt_model.config = Some(json!({
|
||||
"description": "Public description",
|
||||
"model_mappings": ["gpt-5-upstream"],
|
||||
"provider_model_mappings": [{"name": "provider-gpt-5"}],
|
||||
}));
|
||||
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(vec![
|
||||
sample_public_global_model("gm-1", "claude-sonnet-4-5", "Claude Sonnet 4.5", true),
|
||||
sample_public_global_model("gm-2", "disabled-model", "Disabled Model", false),
|
||||
sample_public_global_model("gm-3", "gpt-5", "GPT 5", true),
|
||||
gpt_model,
|
||||
]));
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
@@ -1132,6 +1142,16 @@ async fn gateway_handles_public_global_models_without_proxying_upstream() {
|
||||
assert_eq!(payload["models"][0]["name"], "gpt-5");
|
||||
assert_eq!(payload["models"][0]["display_name"], "GPT 5");
|
||||
assert_eq!(payload["models"][0]["usage_count"], 0);
|
||||
assert_eq!(
|
||||
payload["models"][0]["config"]["description"],
|
||||
"Public description"
|
||||
);
|
||||
assert!(payload["models"][0]["config"]
|
||||
.get("model_mappings")
|
||||
.is_none());
|
||||
assert!(payload["models"][0]["config"]
|
||||
.get("provider_model_mappings")
|
||||
.is_none());
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -1699,9 +1719,9 @@ async fn gateway_handles_public_providers_without_proxying_upstream() {
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["providers"][0]["name"], "openai");
|
||||
assert!(payload["providers"][0].get("name").is_none());
|
||||
assert_eq!(payload["providers"][0]["provider_priority"], 10);
|
||||
assert_eq!(payload["providers"][1]["name"], "anthropic");
|
||||
assert!(payload["providers"][1].get("name").is_none());
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -1749,7 +1769,7 @@ async fn gateway_handles_public_provider_detail_without_proxying_upstream() {
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["id"], "provider-1");
|
||||
assert_eq!(payload["name"], "openai");
|
||||
assert!(payload.get("name").is_none());
|
||||
assert_eq!(payload["provider_priority"], 10);
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
@@ -1758,11 +1778,9 @@ async fn gateway_handles_public_provider_detail_without_proxying_upstream() {
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["id"], "provider-1");
|
||||
assert_eq!(payload["name"], "openai");
|
||||
assert_eq!(payload["provider_priority"], 10);
|
||||
assert_eq!(payload["detail"], "Provider not found");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -1815,6 +1833,9 @@ async fn gateway_handles_public_providers_with_endpoints_without_proxying_upstre
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["providers"][0]["endpoints"][0]["id"], "endpoint-1");
|
||||
assert!(payload["providers"][0]["endpoints"][0]
|
||||
.get("base_url")
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
payload["providers"][0]["endpoints"][0]["api_format"],
|
||||
"openai:chat"
|
||||
@@ -1936,7 +1957,7 @@ async fn gateway_handles_public_test_connection_without_hitting_fallback_probe()
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"{gateway_url}/v1/test-connection?provider=openai&model=gpt-5&api_format=openai:chat"
|
||||
"{gateway_url}/v1/test-connection?provider=provider-1&model=gpt-5&api_format=openai:chat"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
@@ -1945,7 +1966,8 @@ async fn gateway_handles_public_test_connection_without_hitting_fallback_probe()
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["status"], "success");
|
||||
assert_eq!(payload["provider"], "openai");
|
||||
assert!(payload.get("provider").is_none());
|
||||
assert_eq!(payload["provider_id"], "provider-1");
|
||||
assert_eq!(payload["api_format"], "openai:chat");
|
||||
assert_eq!(payload["response_id"], "resp_local_test");
|
||||
assert_eq!(*provider_hits.lock().expect("mutex should lock"), 1);
|
||||
@@ -4781,7 +4803,13 @@ async fn gateway_handles_users_me_preferences_locally_without_proxying_upstream(
|
||||
"refresh-token-placeholder",
|
||||
now,
|
||||
)],
|
||||
std::iter::empty::<crate::data::state::StoredUserPreferenceRecord>(),
|
||||
{
|
||||
let mut preference =
|
||||
crate::data::state::StoredUserPreferenceRecord::default_for_user("user-auth-1");
|
||||
preference.default_provider_id = Some("provider-openai".to_string());
|
||||
preference.default_provider_name = Some("openai".to_string());
|
||||
vec![preference]
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -4801,6 +4829,8 @@ async fn gateway_handles_users_me_preferences_locally_without_proxying_upstream(
|
||||
assert_eq!(get_payload["language"], "zh-CN");
|
||||
assert_eq!(get_payload["timezone"], "Asia/Shanghai");
|
||||
assert_eq!(get_payload["notifications"]["email"], true);
|
||||
assert_eq!(get_payload["default_provider_id"], "provider-openai");
|
||||
assert!(get_payload.get("default_provider").is_none());
|
||||
|
||||
let put_response = client
|
||||
.put(format!("{gateway_url}/api/users/me/preferences"))
|
||||
@@ -5051,6 +5081,7 @@ async fn gateway_handles_users_me_usage_locally_without_proxying_upstream() {
|
||||
105
|
||||
);
|
||||
assert_eq!(payload["summary_by_model"][0]["total_input_context"], 120);
|
||||
assert!(payload.get("summary_by_provider").is_none());
|
||||
assert_eq!(payload["billing"]["id"], "wallet-auth-1");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
@@ -7562,8 +7593,10 @@ async fn gateway_handles_users_me_providers_locally_without_proxying_upstream()
|
||||
let providers = payload.as_array().expect("providers should be array");
|
||||
assert_eq!(providers.len(), 1);
|
||||
assert_eq!(providers[0]["id"], "provider-openai");
|
||||
assert_eq!(providers[0]["name"], "openai");
|
||||
assert!(providers[0].get("name").is_none());
|
||||
assert!(providers[0].get("description").is_none());
|
||||
assert_eq!(providers[0]["endpoints"][0]["id"], "endpoint-openai-1");
|
||||
assert!(providers[0]["endpoints"][0].get("base_url").is_none());
|
||||
assert_eq!(providers[0]["models"][0]["name"], "gpt-5");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
@@ -8545,6 +8578,115 @@ async fn gateway_handles_auth_send_verification_code_locally_without_proxying_up
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_requires_turnstile_token_before_auth_send_verification_code() {
|
||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||
start_auth_gateway_with_builder(|| {
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(
|
||||
crate::data::GatewayDataState::disabled().with_system_config_values_for_tests(
|
||||
vec![
|
||||
("smtp_host".to_string(), json!("smtp.example.com")),
|
||||
("smtp_from_email".to_string(), json!("noreply@example.com")),
|
||||
("turnstile_enabled".to_string(), json!(true)),
|
||||
("turnstile_site_key".to_string(), json!("site-key-123")),
|
||||
("turnstile_secret_key".to_string(), json!("secret-key-123")),
|
||||
],
|
||||
),
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/auth/send-verification-code"))
|
||||
.json(&json!({ "email": "alice@example.com" }))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["detail"], "请先完成人机验证");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_verifies_turnstile_token_before_auth_send_verification_code() {
|
||||
let (siteverify_url, turnstile_requests, siteverify_handle) =
|
||||
start_turnstile_siteverify_server(
|
||||
json!({
|
||||
"success": true,
|
||||
"action": "send_verification_code",
|
||||
"hostname": "localhost"
|
||||
}),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
|
||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||
start_auth_gateway_with_builder({
|
||||
let siteverify_url = siteverify_url.clone();
|
||||
move || {
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(
|
||||
crate::data::GatewayDataState::disabled()
|
||||
.with_system_config_values_for_tests(vec![
|
||||
("smtp_host".to_string(), json!("smtp.example.com")),
|
||||
("smtp_from_email".to_string(), json!("noreply@example.com")),
|
||||
("smtp_from_name".to_string(), json!("Aether Mail")),
|
||||
("turnstile_enabled".to_string(), json!(true)),
|
||||
("turnstile_site_key".to_string(), json!("site-key-123")),
|
||||
("turnstile_secret_key".to_string(), json!("secret-key-123")),
|
||||
(
|
||||
"turnstile_allowed_hostnames".to_string(),
|
||||
json!(["localhost"]),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.with_turnstile_siteverify_url_for_tests(&siteverify_url)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/auth/send-verification-code"))
|
||||
.json(&json!({
|
||||
"email": "alice@example.com",
|
||||
"turnstile_token": "turnstile-token-123"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["success"], true);
|
||||
let requests = turnstile_requests
|
||||
.lock()
|
||||
.expect("turnstile requests should lock");
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(
|
||||
requests[0].get("secret").map(String::as_str),
|
||||
Some("secret-key-123")
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].get("response").map(String::as_str),
|
||||
Some("turnstile-token-123")
|
||||
);
|
||||
assert!(requests[0].contains_key("idempotency_key"));
|
||||
drop(requests);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
siteverify_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_auth_verification_status_locally_without_proxying_upstream() {
|
||||
let now = Utc::now() - chrono::Duration::seconds(10);
|
||||
@@ -8764,6 +8906,203 @@ async fn gateway_handles_users_me_available_models_locally_without_proxying_upst
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_filters_users_me_available_models_by_group_policy_and_hides_model_mappings() {
|
||||
let now = Utc::now();
|
||||
let mut user = sample_auth_user(now);
|
||||
user.allowed_providers = None;
|
||||
user.allowed_providers_mode = "unrestricted".to_string();
|
||||
user.allowed_models = None;
|
||||
user.allowed_models_mode = "unrestricted".to_string();
|
||||
let access_token = build_test_auth_token(
|
||||
"access",
|
||||
serde_json::Map::from_iter([
|
||||
("user_id".to_string(), json!(user.id)),
|
||||
("role".to_string(), json!(user.role)),
|
||||
(
|
||||
"created_at".to_string(),
|
||||
json!(user.created_at.map(|value| value.to_rfc3339())),
|
||||
),
|
||||
(
|
||||
"session_id".to_string(),
|
||||
json!("session-users-me-group-models"),
|
||||
),
|
||||
]),
|
||||
now + chrono::Duration::hours(1),
|
||||
);
|
||||
let mut allowed_model =
|
||||
sample_public_global_model("gm-2", "claude-sonnet-4-5", "Claude Sonnet 4.5", true);
|
||||
allowed_model.config = Some(json!({
|
||||
"description": "Claude detail",
|
||||
"model_mappings": ["claude-upstream"]
|
||||
}));
|
||||
let mut blocked_model = sample_public_global_model("gm-1", "gpt-5", "GPT 5", true);
|
||||
blocked_model.config = Some(json!({
|
||||
"description": "GPT detail",
|
||||
"model_mappings": ["gpt-upstream"]
|
||||
}));
|
||||
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(vec![
|
||||
blocked_model,
|
||||
allowed_model,
|
||||
]));
|
||||
let user_repository: Arc<dyn UserReadRepository> =
|
||||
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
|
||||
let group = user_repository
|
||||
.create_user_group(UpsertUserGroupRecord {
|
||||
name: "Claude only".to_string(),
|
||||
description: None,
|
||||
priority: 0,
|
||||
allowed_providers: None,
|
||||
allowed_providers_mode: "unrestricted".to_string(),
|
||||
allowed_api_formats: None,
|
||||
allowed_api_formats_mode: "unrestricted".to_string(),
|
||||
allowed_models: Some(vec!["claude-sonnet-4-5".to_string()]),
|
||||
allowed_models_mode: "specific".to_string(),
|
||||
rate_limit: None,
|
||||
rate_limit_mode: "system".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("group should create")
|
||||
.expect("group should exist");
|
||||
user_repository
|
||||
.add_user_to_group(&group.id, "user-auth-1")
|
||||
.await
|
||||
.expect("group membership should create");
|
||||
|
||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||
start_auth_gateway_with_builder(|| {
|
||||
let data_state = crate::data::GatewayDataState::with_global_model_reader_for_tests(
|
||||
global_model_repository,
|
||||
)
|
||||
.with_user_reader(Arc::clone(&user_repository));
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(data_state)
|
||||
.with_auth_sessions_for_tests([sample_auth_session(
|
||||
"user-auth-1",
|
||||
"session-users-me-group-models",
|
||||
"device-users-me-group-models",
|
||||
"refresh-token-placeholder",
|
||||
now,
|
||||
)])
|
||||
})
|
||||
.await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(format!("{gateway_url}/api/users/me/available-models"))
|
||||
.header("authorization", format!("Bearer {access_token}"))
|
||||
.header("x-client-device-id", "device-users-me-group-models")
|
||||
.header("user-agent", "AetherTest/1.0")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
let models = payload["models"]
|
||||
.as_array()
|
||||
.expect("models should be an array");
|
||||
assert_eq!(payload["total"], 1);
|
||||
assert_eq!(models.len(), 1);
|
||||
assert_eq!(models[0]["name"], "claude-sonnet-4-5");
|
||||
assert_eq!(models[0]["config"]["description"], "Claude detail");
|
||||
assert!(models[0]["config"].get("model_mappings").is_none());
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_returns_no_users_me_available_models_when_group_denies_all_models() {
|
||||
let now = Utc::now();
|
||||
let mut user = sample_auth_user(now);
|
||||
user.allowed_providers = None;
|
||||
user.allowed_providers_mode = "unrestricted".to_string();
|
||||
user.allowed_models = None;
|
||||
user.allowed_models_mode = "unrestricted".to_string();
|
||||
let access_token = build_test_auth_token(
|
||||
"access",
|
||||
serde_json::Map::from_iter([
|
||||
("user_id".to_string(), json!(user.id)),
|
||||
("role".to_string(), json!(user.role)),
|
||||
(
|
||||
"created_at".to_string(),
|
||||
json!(user.created_at.map(|value| value.to_rfc3339())),
|
||||
),
|
||||
(
|
||||
"session_id".to_string(),
|
||||
json!("session-users-me-deny-all-models"),
|
||||
),
|
||||
]),
|
||||
now + chrono::Duration::hours(1),
|
||||
);
|
||||
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(vec![
|
||||
sample_public_global_model("gm-1", "gpt-5", "GPT 5", true),
|
||||
sample_public_global_model("gm-2", "claude-sonnet-4-5", "Claude Sonnet 4.5", true),
|
||||
]));
|
||||
let user_repository: Arc<dyn UserReadRepository> =
|
||||
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
|
||||
let group = user_repository
|
||||
.create_user_group(UpsertUserGroupRecord {
|
||||
name: "No models".to_string(),
|
||||
description: None,
|
||||
priority: 0,
|
||||
allowed_providers: None,
|
||||
allowed_providers_mode: "unrestricted".to_string(),
|
||||
allowed_api_formats: None,
|
||||
allowed_api_formats_mode: "unrestricted".to_string(),
|
||||
allowed_models: None,
|
||||
allowed_models_mode: "deny_all".to_string(),
|
||||
rate_limit: None,
|
||||
rate_limit_mode: "system".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("group should create")
|
||||
.expect("group should exist");
|
||||
user_repository
|
||||
.add_user_to_group(&group.id, "user-auth-1")
|
||||
.await
|
||||
.expect("group membership should create");
|
||||
|
||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||
start_auth_gateway_with_builder(|| {
|
||||
let data_state = crate::data::GatewayDataState::with_global_model_reader_for_tests(
|
||||
global_model_repository,
|
||||
)
|
||||
.with_user_reader(Arc::clone(&user_repository));
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(data_state)
|
||||
.with_auth_sessions_for_tests([sample_auth_session(
|
||||
"user-auth-1",
|
||||
"session-users-me-deny-all-models",
|
||||
"device-users-me-deny-all-models",
|
||||
"refresh-token-placeholder",
|
||||
now,
|
||||
)])
|
||||
})
|
||||
.await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(format!("{gateway_url}/api/users/me/available-models"))
|
||||
.header("authorization", format!("Bearer {access_token}"))
|
||||
.header("x-client-device-id", "device-users-me-deny-all-models")
|
||||
.header("user-agent", "AetherTest/1.0")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["total"], 0);
|
||||
assert_eq!(payload["models"].as_array().map(Vec::len), Some(0));
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_returns_service_unavailable_for_users_me_available_models_without_provider_catalog(
|
||||
) {
|
||||
|
||||
@@ -7,6 +7,40 @@ use super::{
|
||||
StoredAuthApiKeyExportRecord, StoredAuthApiKeySnapshot, StoredUserAuthRecord,
|
||||
StoredUserExportRow, Utc,
|
||||
};
|
||||
use aether_data_contracts::repository::billing::{
|
||||
BillingReadRepository, StoredBillingModelContext, UserDailyQuotaAvailabilityRecord,
|
||||
};
|
||||
use aether_data_contracts::DataLayerError;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StaticDailyQuotaBillingRepository {
|
||||
user_id: String,
|
||||
quota: UserDailyQuotaAvailabilityRecord,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl BillingReadRepository for StaticDailyQuotaBillingRepository {
|
||||
async fn find_model_context(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
provider_api_key_id: Option<&str>,
|
||||
global_model_name: &str,
|
||||
) -> Result<Option<StoredBillingModelContext>, DataLayerError> {
|
||||
let _ = (provider_id, provider_api_key_id, global_model_name);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn find_user_daily_quota_availability(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<UserDailyQuotaAvailabilityRecord>, DataLayerError> {
|
||||
if user_id == self.user_id {
|
||||
Ok(Some(self.quota.clone()))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stable_dashboard_now() -> chrono::DateTime<Utc> {
|
||||
Utc::now()
|
||||
@@ -173,6 +207,93 @@ async fn gateway_handles_dashboard_stats_locally_without_proxying_upstream() {
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_dashboard_stats_user_wallet_card_uses_wallet_center_balance_breakdown() {
|
||||
let now = stable_dashboard_now();
|
||||
let user = sample_auth_user(now);
|
||||
let access_token = build_test_auth_token(
|
||||
"access",
|
||||
serde_json::Map::from_iter([
|
||||
("user_id".to_string(), json!(user.id)),
|
||||
("role".to_string(), json!(user.role)),
|
||||
(
|
||||
"created_at".to_string(),
|
||||
json!(user.created_at.map(|value| value.to_rfc3339())),
|
||||
),
|
||||
("session_id".to_string(), json!("session-dashboard-wallet")),
|
||||
]),
|
||||
chrono::Utc::now() + chrono::Duration::hours(1),
|
||||
);
|
||||
let session = sample_auth_session(
|
||||
"user-auth-1",
|
||||
"session-dashboard-wallet",
|
||||
"device-dashboard-wallet",
|
||||
"refresh-dashboard-wallet",
|
||||
now,
|
||||
);
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![]));
|
||||
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
||||
user.clone()
|
||||
]));
|
||||
let mut wallet = sample_auth_wallet("user-auth-1", now);
|
||||
wallet.balance = 7.0;
|
||||
wallet.gift_balance = 3.0;
|
||||
let wallet_repository = Arc::new(InMemoryWalletRepository::seed(vec![wallet]));
|
||||
let billing_repository: Arc<dyn BillingReadRepository> =
|
||||
Arc::new(StaticDailyQuotaBillingRepository {
|
||||
user_id: "user-auth-1".to_string(),
|
||||
quota: UserDailyQuotaAvailabilityRecord {
|
||||
has_active_daily_quota: true,
|
||||
total_quota_usd: 120.0,
|
||||
used_usd: 20.0,
|
||||
remaining_usd: 100.0,
|
||||
allow_wallet_overage: true,
|
||||
},
|
||||
});
|
||||
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(Vec::<(
|
||||
Option<String>,
|
||||
StoredAuthApiKeySnapshot,
|
||||
)>::new()));
|
||||
|
||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||
start_auth_gateway_with_builder(|| {
|
||||
let data_state = GatewayDataState::with_usage_billing_and_wallet_for_tests(
|
||||
usage_repository,
|
||||
Arc::clone(&billing_repository),
|
||||
wallet_repository,
|
||||
)
|
||||
.with_user_reader(user_repository)
|
||||
.with_auth_api_key_reader(auth_repository);
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(data_state)
|
||||
.with_auth_sessions_for_tests([session])
|
||||
})
|
||||
.await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(format!("{gateway_url}/api/dashboard/stats?days=30"))
|
||||
.header("authorization", format!("Bearer {access_token}"))
|
||||
.header("x-client-device-id", "device-dashboard-wallet")
|
||||
.header("user-agent", "AetherTest/1.0")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["stats"][2]["name"], "钱包余额");
|
||||
assert_eq!(payload["stats"][2]["value"], "$110.00");
|
||||
assert_eq!(
|
||||
payload["stats"][2]["subValue"],
|
||||
"套餐额度 $100.00 · 钱包余额 $10.00"
|
||||
);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_dashboard_stats_include_end_of_day_boundary() {
|
||||
let now = stable_dashboard_now();
|
||||
@@ -1071,16 +1192,9 @@ async fn gateway_handles_dashboard_provider_status_locally_without_proxying_upst
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
let providers = payload["providers"].as_array().expect("array");
|
||||
assert_eq!(providers.len(), 3);
|
||||
assert_eq!(providers[0]["name"], "openai");
|
||||
assert_eq!(providers[0]["requests"], 2);
|
||||
assert_eq!(providers[1]["name"], "claude");
|
||||
assert_eq!(providers[1]["requests"], 1);
|
||||
assert_eq!(providers[2]["name"], "gemini");
|
||||
assert_eq!(providers[2]["requests"], 0);
|
||||
assert_eq!(payload["detail"], "仅管理员可查看供应商状态");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
|
||||
Reference in New Issue
Block a user