Files
Aether/apps/aether-gateway/src/tests/control/admin/endpoints/keys.rs
T
elky 579f2c7cc1 feat(security): harden gateway boundaries and usage policies
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change.

Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
2026-09-04 03:45:52 +08:00

2999 lines
111 KiB
Rust

use std::sync::{Arc, Mutex};
use aether_contracts::ExecutionPlan;
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data_contracts::repository::provider_catalog::{
ProviderCatalogKeyListQuery, ProviderCatalogReadRepository, StoredProviderCatalogEndpoint,
StoredProviderCatalogKey, StoredProviderCatalogKeyMaintenanceSummary,
StoredProviderCatalogKeyPage, StoredProviderCatalogKeyStats, StoredProviderCatalogProvider,
};
use aether_data_contracts::DataLayerError;
use axum::body::Body;
use axum::routing::any;
use axum::{extract::Request, Json, Router};
use http::StatusCode;
use serde_json::json;
use super::super::super::{
build_router_with_state, build_state_with_execution_runtime_override, sample_bound_auth_config,
sample_bound_key, sample_endpoint, sample_provider, start_server, AppState,
};
use crate::constants::{
GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
TRUSTED_ADMIN_USER_ROLE_HEADER,
};
use crate::data::GatewayDataState;
const PROVIDER_KEYS_TEST_STACK_BYTES: usize = 16 * 1024 * 1024;
fn open_provider_catalog_api_key_for_test(key: &StoredProviderCatalogKey) -> String {
let state = AppState::new()
.expect("gateway state should build")
.with_data_state_for_tests(
GatewayDataState::disabled().with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
);
state
.decrypt_provider_catalog_key_api_key(key)
.expect("provider catalog API key should decrypt")
.expect("provider catalog API key should be present")
}
fn run_provider_keys_test<F, Fut>(test_name: &'static str, make_future: F)
where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = ()> + 'static,
{
let handle = std::thread::Builder::new()
.name(test_name.to_string())
.stack_size(PROVIDER_KEYS_TEST_STACK_BYTES)
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime should build");
runtime.block_on(make_future());
})
.expect("provider keys test thread should spawn");
if let Err(payload) = handle.join() {
std::panic::resume_unwind(payload);
}
}
struct SummaryNullingProviderCatalogReadRepository {
inner: InMemoryProviderCatalogReadRepository,
}
impl SummaryNullingProviderCatalogReadRepository {
fn seed(
providers: Vec<StoredProviderCatalogProvider>,
endpoints: Vec<StoredProviderCatalogEndpoint>,
keys: Vec<StoredProviderCatalogKey>,
) -> Self {
Self {
inner: InMemoryProviderCatalogReadRepository::seed(providers, endpoints, keys),
}
}
}
#[async_trait::async_trait]
impl ProviderCatalogReadRepository for SummaryNullingProviderCatalogReadRepository {
async fn list_providers(
&self,
active_only: bool,
) -> Result<Vec<StoredProviderCatalogProvider>, DataLayerError> {
self.inner.list_providers(active_only).await
}
async fn list_providers_by_ids(
&self,
provider_ids: &[String],
) -> Result<Vec<StoredProviderCatalogProvider>, DataLayerError> {
self.inner.list_providers_by_ids(provider_ids).await
}
async fn list_endpoints_by_ids(
&self,
endpoint_ids: &[String],
) -> Result<Vec<StoredProviderCatalogEndpoint>, DataLayerError> {
self.inner.list_endpoints_by_ids(endpoint_ids).await
}
async fn list_endpoints_by_provider_ids(
&self,
provider_ids: &[String],
) -> Result<Vec<StoredProviderCatalogEndpoint>, DataLayerError> {
self.inner
.list_endpoints_by_provider_ids(provider_ids)
.await
}
async fn list_keys_by_ids(
&self,
key_ids: &[String],
) -> Result<Vec<StoredProviderCatalogKey>, DataLayerError> {
self.inner.list_keys_by_ids(key_ids).await
}
async fn list_keys_by_provider_ids(
&self,
provider_ids: &[String],
) -> Result<Vec<StoredProviderCatalogKey>, DataLayerError> {
self.inner.list_keys_by_provider_ids(provider_ids).await
}
async fn list_key_summaries_by_provider_ids(
&self,
provider_ids: &[String],
) -> Result<Vec<StoredProviderCatalogKey>, DataLayerError> {
let mut keys = self.inner.list_keys_by_provider_ids(provider_ids).await?;
for key in &mut keys {
key.internal_priority = 50;
key.global_priority_by_format = None;
key.rate_multipliers = None;
key.request_count = None;
key.success_count = None;
key.error_count = None;
key.total_response_time_ms = None;
key.circuit_breaker_by_format = None;
}
Ok(keys)
}
async fn list_key_maintenance_summaries_by_provider_ids(
&self,
provider_ids: &[String],
) -> Result<Vec<StoredProviderCatalogKeyMaintenanceSummary>, DataLayerError> {
self.inner
.list_key_maintenance_summaries_by_provider_ids(provider_ids)
.await
}
async fn list_keys_page(
&self,
query: &ProviderCatalogKeyListQuery,
) -> Result<StoredProviderCatalogKeyPage, DataLayerError> {
self.inner.list_keys_page(query).await
}
async fn list_key_stats_by_provider_ids(
&self,
provider_ids: &[String],
) -> Result<Vec<StoredProviderCatalogKeyStats>, DataLayerError> {
self.inner
.list_key_stats_by_provider_ids(provider_ids)
.await
}
}
#[tokio::test]
async fn gateway_handles_admin_provider_keys_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/endpoints/providers/provider-openai/keys",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let mut key_a = sample_bound_key(
"key-openai-a",
"provider-openai",
"openai:chat",
"sk-test-a",
);
key_a.internal_priority = 10;
key_a.request_count = Some(12);
key_a.success_count = Some(9);
key_a.error_count = Some(3);
key_a.total_response_time_ms = Some(1800);
key_a.created_at_unix_ms = Some(1_711_000_000);
key_a.updated_at_unix_secs = Some(1_711_000_100);
key_a.last_used_at_unix_secs = Some(1_711_000_120);
key_a.note = Some("primary key".to_string());
key_a.status_snapshot = Some(json!({
"oauth": {"code": "none", "requires_reauth": false, "expiring_soon": false},
"account": {"code": "ok", "blocked": false, "recoverable": false},
"quota": {"code": "unknown", "exhausted": false}
}));
let mut key_b = sample_bound_key(
"key-openai-b",
"provider-openai",
"openai:chat",
"sk-test-b",
);
key_b.internal_priority = 20;
key_b.request_count = Some(4);
key_b.success_count = Some(4);
key_b.error_count = Some(0);
key_b.total_response_time_ms = Some(400);
key_b.created_at_unix_ms = Some(1_711_100_000);
key_b.updated_at_unix_secs = Some(1_711_100_100);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![],
vec![key_a, key_b],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog_repository,
)),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/admin/endpoints/providers/provider-openai/keys?skip=0&limit=50"
))
.header(crate::constants::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");
let items = payload.as_array().expect("payload should be an array");
assert_eq!(items.len(), 2);
assert_eq!(items[0]["id"], "key-openai-a");
assert_eq!(items[0]["internal_priority"], 10);
assert_eq!(items[0]["request_count"], 12);
assert_eq!(items[0]["success_count"], 9);
assert_eq!(items[0]["error_count"], 3);
assert_eq!(items[0]["note"], "primary key");
assert_eq!(items[0]["api_key_masked"], "sk***-a");
assert_eq!(items[1]["id"], "key-openai-b");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_provider_keys_expose_circuit_breaker_and_recover_clears_it() {
let key = sample_bound_key("key-1", "provider-1", "openai:chat", "sk-test-a")
.with_health_fields(
Some(json!({"openai:chat": {
"health_score": 0.2,
"consecutive_failures": 8,
"last_failure_at": "2026-03-26T12:00:00+00:00"
}})),
Some(json!({"openai:chat": {
"open": true,
"open_at": "2026-03-26T12:00:00+00:00",
"reason": "consecutive_failures_8",
"next_probe_at": "2099-03-26T12:01:00+00:00",
"next_probe_at_unix_secs": 4078209660u64,
"probe_interval_minutes": 1,
"max_probe_interval_minutes": 32,
"half_open_until": null,
"half_open_successes": 0,
"half_open_failures": 0
}})),
);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-1", "openai", 10)],
vec![sample_endpoint(
"endpoint-1",
"provider-1",
"openai:chat",
"https://example.com/v1",
)],
vec![key],
));
let gateway_state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_repository_for_tests(Arc::clone(
&provider_catalog_repository,
)),
);
let gateway = build_router_with_state(gateway_state);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let client = reqwest::Client::new();
let response = client
.get(format!(
"{gateway_url}/api/admin/endpoints/providers/provider-1/keys?skip=0&limit=50"
))
.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[0]["circuit_breaker_open"], true);
assert_eq!(
payload[0]["circuit_breaker_by_format"]["openai:chat"]["reason"],
"consecutive_failures_8"
);
assert_eq!(
payload[0]["circuit_breaker_by_format"]["openai:chat"]["probe_interval_minutes"],
1
);
assert!(
payload[0]["circuit_breaker_by_format"]["openai:chat"]["next_probe_at_unix_secs"]
.as_u64()
.is_some()
);
let recover_response = client
.patch(format!(
"{gateway_url}/api/admin/endpoints/health/keys/key-1"
))
.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("recover request should succeed");
assert_eq!(recover_response.status(), StatusCode::OK);
let response = client
.get(format!(
"{gateway_url}/api/admin/endpoints/providers/provider-1/keys?skip=0&limit=50"
))
.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[0]["circuit_breaker_open"], false);
gateway_handle.abort();
}
#[tokio::test]
async fn gateway_handles_admin_provider_keys_page_locally_with_total() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/endpoints/providers/provider-openai/keys",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let mut key_a = sample_bound_key(
"key-openai-a",
"provider-openai",
"openai:chat",
"sk-test-a",
);
key_a.internal_priority = 10;
key_a.created_at_unix_ms = Some(1_711_000_000);
let mut key_b = sample_bound_key(
"key-openai-b",
"provider-openai",
"openai:chat",
"sk-test-b",
);
key_b.internal_priority = 20;
key_b.created_at_unix_ms = Some(1_711_100_000);
let mut key_c = sample_bound_key(
"key-openai-c",
"provider-openai",
"openai:chat",
"sk-test-c",
);
key_c.internal_priority = 30;
key_c.created_at_unix_ms = Some(1_711_200_000);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![],
vec![key_a, key_b, key_c],
));
let (_upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog_repository,
)),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/admin/endpoints/providers/provider-openai/keys?page=2&page_size=1"
))
.header(crate::constants::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["total"], 3);
assert_eq!(payload["page"], 2);
assert_eq!(payload["page_size"], 1);
let items = payload["keys"].as_array().expect("keys should be an array");
assert_eq!(items.len(), 1);
assert_eq!(items[0]["id"], "key-openai-b");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_admin_provider_keys_prefers_upstream_plan_type_over_auth_config() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/endpoints/providers/provider-codex/keys",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let mut provider = sample_provider("provider-codex", "codex", 10);
provider.provider_type = "codex".to_string();
let mut key = sample_bound_key(
"key-codex-oauth",
"provider-codex",
"openai:responses",
"oauth-placeholder",
);
key.auth_type = "oauth".to_string();
key.encrypted_auth_config = Some(sample_bound_auth_config(
"provider-codex",
"key-codex-oauth",
&json!({
"plan_type": "free",
"account_id": "acct-codex-legacy"
})
.to_string(),
));
key.upstream_metadata = Some(json!({
"codex": {
"plan_type": "plus",
"updated_at": 1_775_553_285u64
}
}));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
vec![],
vec![key],
));
let (_upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog_repository,
)
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/admin/endpoints/providers/provider-codex/keys?skip=0&limit=50"
))
.header(crate::constants::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");
let items = payload.as_array().expect("payload should be an array");
assert_eq!(items.len(), 1);
assert_eq!(items[0]["oauth_plan_type"], "plus");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_admin_provider_keys_marks_oauth_header_auth() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/endpoints/providers/provider-codex/keys",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let mut provider = sample_provider("provider-codex", "codex", 10);
provider.provider_type = "codex".to_string();
let mut key = sample_bound_key(
"key-codex-oauth-header",
"provider-codex",
"openai:responses",
"imported-session-token",
);
key.auth_type = "oauth".to_string();
key.encrypted_auth_config = Some(sample_bound_auth_config(
"provider-codex",
"key-codex-oauth-header",
r#"{"provider_type":"codex","headers":{"authorization":"Bearer imported-session-token"}}"#,
));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
vec![],
vec![key],
));
let (_upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog_repository,
)
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/admin/endpoints/providers/provider-codex/keys?skip=0&limit=50"
))
.header(crate::constants::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");
let items = payload.as_array().expect("payload should be an array");
assert_eq!(items.len(), 1);
assert_eq!(items[0]["api_key_masked"], "[OAuth Header]");
assert_eq!(items[0]["oauth_header_auth"], true);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_creates_admin_provider_key_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/endpoints/providers/provider-openai/keys",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![],
vec![],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.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-openai/keys"
))
.header(crate::constants::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")
.json(&json!({
"api_formats": ["openai:chat"],
"api_key": "sk-created-openai",
"name": "created key",
"internal_priority": 15,
"capabilities": {"cache_1h": true},
"note": "created from rust"
}))
.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["provider_id"], "provider-openai");
assert_eq!(payload["name"], "created key");
assert_eq!(payload["internal_priority"], 15);
assert_eq!(payload["api_formats"], json!(["openai:chat"]));
assert_eq!(payload["api_key_masked"], "sk-c***enai");
assert_eq!(payload["request_count"], 0);
assert_eq!(payload["success_count"], 0);
assert_eq!(payload["error_count"], 0);
assert_eq!(payload["note"], "created from rust");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
let keys = provider_catalog_repository
.list_keys_by_provider_ids(&["provider-openai".to_string()])
.await
.expect("keys should read");
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].name, "created key");
assert_eq!(keys[0].auth_type, "api_key");
assert_eq!(keys[0].internal_priority, 15);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn generic_key_routes_reject_agent_identity_credential_writes() {
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-codex", "codex", 10)],
vec![],
vec![sample_bound_key(
"key-codex-existing",
"provider-codex",
"openai:responses",
"existing-secret",
)],
));
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.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 agent_identity = json!({
"provider_type": "codex",
"auth_mode": "agentIdentity",
"agent_runtime_id": "runtime-bypass",
"agent_private_key": "private-key-must-use-dedicated-import"
});
let client = reqwest::Client::new();
let create_response = client
.post(format!(
"{gateway_url}/api/admin/endpoints/providers/provider-codex/keys"
))
.header(crate::constants::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")
.json(&json!({
"api_formats": ["openai:responses"],
"auth_type": "oauth",
"auth_config": agent_identity,
"name": "bypass create"
}))
.send()
.await
.expect("create request should complete");
assert_eq!(create_response.status(), StatusCode::BAD_REQUEST);
let create_payload: serde_json::Value = create_response
.json()
.await
.expect("create error should be JSON");
assert!(create_payload["detail"]
.as_str()
.is_some_and(|detail| detail.contains("专属创建或导入接口")));
let update_response = client
.put(format!(
"{gateway_url}/api/admin/endpoints/keys/key-codex-existing"
))
.header(crate::constants::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")
.json(&json!({
"auth_type": "oauth",
"auth_config": {
"provider_type": "codex",
"auth_mode": "agentIdentity",
"agent_runtime_id": "runtime-bypass-update",
"agent_private_key": "private-key-must-use-dedicated-import"
}
}))
.send()
.await
.expect("update request should complete");
assert_eq!(update_response.status(), StatusCode::BAD_REQUEST);
let update_payload: serde_json::Value = update_response
.json()
.await
.expect("update error should be JSON");
assert!(update_payload["detail"]
.as_str()
.is_some_and(|detail| detail.contains("专属创建或导入接口")));
let keys = provider_catalog_repository
.list_keys_by_provider_ids(&["provider-codex".to_string()])
.await
.expect("keys should read");
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].id, "key-codex-existing");
assert_eq!(keys[0].auth_type, "api_key");
gateway_handle.abort();
}
#[tokio::test]
async fn generic_codex_key_credential_switch_rotates_generation_and_clears_quota() {
let mut existing_key = sample_bound_key(
"key-codex-existing",
"provider-codex",
"openai:responses",
"old-oauth-access-token",
);
existing_key.auth_type = "oauth".to_string();
existing_key.encrypted_auth_config = Some(sample_bound_auth_config(
"provider-codex",
"key-codex-existing",
r#"{"provider_type":"codex","refresh_token":"old-refresh-token"}"#,
));
existing_key.upstream_metadata = Some(json!({
"codex": {
"credential_generation": "generation-before-switch",
"primary_used_percent": 80.0,
},
"unrelated": {"preserved": true},
}));
existing_key.status_snapshot = Some(json!({
"oauth": {"status": "valid"},
"quota": {"used_ratio": 0.8},
}));
let mut provider = sample_provider("provider-codex", "codex", 10);
provider.provider_type = "codex".to_string();
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
vec![],
vec![existing_key],
));
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.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()
.put(format!(
"{gateway_url}/api/admin/endpoints/keys/key-codex-existing"
))
.header(crate::constants::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")
.json(&json!({
"auth_type": "api_key",
"api_key": "new-codex-api-key"
}))
.send()
.await
.expect("credential switch should complete");
assert_eq!(response.status(), StatusCode::OK);
let reloaded = provider_catalog_repository
.list_keys_by_ids(&["key-codex-existing".to_string()])
.await
.expect("key should reload");
assert_eq!(reloaded.len(), 1);
let key = &reloaded[0];
assert_eq!(key.auth_type, "api_key");
let codex = key
.upstream_metadata
.as_ref()
.and_then(|metadata| metadata.get("codex"))
.and_then(serde_json::Value::as_object)
.expect("codex metadata should exist");
assert_eq!(codex.len(), 1, "unexpected Codex metadata: {codex:?}");
assert_ne!(
codex
.get(aether_admin::provider::quota::CODEX_CREDENTIAL_GENERATION_KEY)
.and_then(serde_json::Value::as_str),
Some("generation-before-switch")
);
assert_eq!(
key.upstream_metadata
.as_ref()
.and_then(|metadata| metadata.pointer("/unrelated/preserved")),
Some(&json!(true))
);
assert_eq!(
key.status_snapshot
.as_ref()
.and_then(|snapshot| snapshot.get("quota")),
Some(&serde_json::Value::Null)
);
gateway_handle.abort();
}
#[tokio::test]
async fn provider_key_concurrent_limit_create_and_list_responses() {
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![],
vec![],
));
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.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 create_with_limit_response = reqwest::Client::new()
.post(format!(
"{gateway_url}/api/admin/endpoints/providers/provider-openai/keys"
))
.header(crate::constants::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")
.json(&json!({
"api_formats": ["openai:chat"],
"api_key": "sk-created-openai-concurrent",
"name": "created key with concurrency",
"rpm_limit": 60,
"concurrent_limit": 3
}))
.send()
.await
.expect("request should succeed");
assert_eq!(create_with_limit_response.status(), StatusCode::OK);
let create_payload: serde_json::Value = create_with_limit_response
.json()
.await
.expect("json body should parse");
let create_with_limit_id = create_payload["id"]
.as_str()
.expect("created key id should be returned")
.to_string();
assert_eq!(create_payload["rpm_limit"], 60);
assert_eq!(create_payload["concurrent_limit"], 3);
let create_null_response = reqwest::Client::new()
.post(format!(
"{gateway_url}/api/admin/endpoints/providers/provider-openai/keys"
))
.header(crate::constants::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")
.json(&json!({
"api_formats": ["openai:chat"],
"api_key": "sk-created-openai-concurrent-null",
"name": "created key with null concurrency",
"concurrent_limit": null
}))
.send()
.await
.expect("request should succeed");
assert_eq!(create_null_response.status(), StatusCode::OK);
let null_payload: serde_json::Value = create_null_response
.json()
.await
.expect("json body should parse");
let create_null_id = null_payload["id"]
.as_str()
.expect("created null-limit key id should be returned")
.to_string();
assert_eq!(null_payload["concurrent_limit"], serde_json::Value::Null);
let create_negative_response = reqwest::Client::new()
.post(format!(
"{gateway_url}/api/admin/endpoints/providers/provider-openai/keys"
))
.header(crate::constants::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")
.json(&json!({
"api_formats": ["openai:chat"],
"api_key": "sk-created-openai-concurrent-negative",
"name": "created key with negative concurrency",
"concurrent_limit": -1
}))
.send()
.await
.expect("request should succeed");
assert_eq!(create_negative_response.status(), StatusCode::BAD_REQUEST);
let negative_payload: serde_json::Value = create_negative_response
.json()
.await
.expect("json body should parse");
assert!(negative_payload["detail"]
.as_str()
.expect("detail should be string")
.contains("concurrent_limit"));
let list_response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/admin/endpoints/providers/provider-openai/keys?skip=0&limit=50"
))
.header(crate::constants::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!(list_response.status(), StatusCode::OK);
let list_payload: serde_json::Value =
list_response.json().await.expect("json body should parse");
let items = list_payload.as_array().expect("payload should be an array");
assert_eq!(items.len(), 2);
let with_limit = items
.iter()
.find(|item| item["name"].as_str() == Some("created key with concurrency"))
.expect("created key with concurrency should be listed");
let with_null = items
.iter()
.find(|item| item["name"].as_str() == Some("created key with null concurrency"))
.expect("created key with null concurrency should be listed");
assert_eq!(with_limit["concurrent_limit"], 3);
assert_eq!(with_null["concurrent_limit"], serde_json::Value::Null);
let read_back = provider_catalog_repository
.list_keys_by_ids(&[create_with_limit_id, create_null_id])
.await
.expect("created keys should read by id");
assert_eq!(read_back.len(), 2);
assert!(read_back.iter().any(|key| {
key.name == "created key with concurrency" && key.concurrent_limit == Some(3)
}));
assert!(read_back.iter().any(|key| {
key.name == "created key with null concurrency" && key.concurrent_limit.is_none()
}));
let keys = provider_catalog_repository
.list_keys_by_provider_ids(&["provider-openai".to_string()])
.await
.expect("keys should read");
assert_eq!(keys.len(), 2);
assert!(keys
.iter()
.any(|key| key.name == "created key with concurrency" && key.concurrent_limit == Some(3)));
assert!(keys.iter().any(
|key| key.name == "created key with null concurrency" && key.concurrent_limit.is_none()
));
gateway_handle.abort();
}
#[tokio::test]
async fn provider_key_concurrent_limit_reads_existing_list_response() {
let mut key_a = sample_bound_key(
"provider-key-a",
"test-provider-a",
"openai:chat",
"sk-provider-key-a",
);
key_a.concurrent_limit = Some(1);
let mut key_b = sample_bound_key(
"provider-key-b",
"test-provider-a",
"openai:chat",
"sk-provider-key-b",
);
key_b.concurrent_limit = None;
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("test-provider-a", "openai", 10)],
vec![],
vec![key_a, key_b],
));
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog_repository,
)),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/admin/endpoints/providers/test-provider-a/keys?skip=0&limit=50"
))
.header(crate::constants::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");
let items = payload.as_array().expect("payload should be an array");
assert_eq!(items.len(), 2);
let provider_key_a = items
.iter()
.find(|item| item["id"].as_str() == Some("provider-key-a"))
.expect("provider-key-a should be listed");
let provider_key_b = items
.iter()
.find(|item| item["id"].as_str() == Some("provider-key-b"))
.expect("provider-key-b should be listed");
assert_eq!(provider_key_a["concurrent_limit"], 1);
assert_eq!(provider_key_b["concurrent_limit"], serde_json::Value::Null);
gateway_handle.abort();
}
#[test]
fn gateway_fetches_allowed_models_immediately_when_creating_key_with_auto_fetch() {
run_provider_keys_test(
"gateway_fetches_allowed_models_immediately_when_creating_key_with_auto_fetch",
gateway_fetches_allowed_models_immediately_when_creating_key_with_auto_fetch_impl,
);
}
async fn gateway_fetches_allowed_models_immediately_when_creating_key_with_auto_fetch_impl() {
let execution_runtime_hits = Arc::new(Mutex::new(0usize));
let execution_runtime_hits_clone = Arc::clone(&execution_runtime_hits);
let execution_runtime = Router::new().route(
"/v1/execute/sync",
any(move |Json(plan): Json<ExecutionPlan>| {
let execution_runtime_hits_inner = Arc::clone(&execution_runtime_hits_clone);
async move {
*execution_runtime_hits_inner
.lock()
.expect("mutex should lock") += 1;
assert_eq!(plan.url, "https://api.openai.example/v1/models");
assert_eq!(
plan.headers.get("authorization").map(String::as_str),
Some("Bearer sk-created-openai")
);
Json(json!({
"request_id": "req-create-key-auto-fetch",
"status_code": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"json_body": {
"data": [
{"id": "gpt-5-mini"},
{"id": "gpt-4.1-mini"}
]
}
}
}))
}
}),
);
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![sample_endpoint(
"endpoint-openai-chat",
"provider-openai",
"openai:chat",
"https://api.openai.example/v1",
)],
vec![],
));
let gateway = build_router_with_state(
build_state_with_execution_runtime_override(execution_runtime_url)
.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-openai/keys"
))
.header(crate::constants::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")
.json(&json!({
"api_formats": ["openai:chat"],
"api_key": "sk-created-openai",
"name": "created key with auto fetch",
"auto_fetch_models": true
}))
.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["provider_id"], "provider-openai");
assert_eq!(payload["name"], "created key with auto fetch");
assert_eq!(payload["auto_fetch_models"], true);
assert_eq!(
payload["allowed_models"],
json!(["gpt-4.1-mini", "gpt-5-mini"])
);
assert_eq!(payload["last_models_fetch_error"], serde_json::Value::Null);
assert_eq!(
*execution_runtime_hits.lock().expect("mutex should lock"),
1
);
let keys = provider_catalog_repository
.list_keys_by_provider_ids(&["provider-openai".to_string()])
.await
.expect("keys should read");
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].name, "created key with auto fetch");
assert!(keys[0].auto_fetch_models);
assert_eq!(
keys[0].allowed_models,
Some(json!(["gpt-4.1-mini", "gpt-5-mini"]))
);
gateway_handle.abort();
execution_runtime_handle.abort();
}
#[tokio::test]
async fn gateway_reveals_admin_provider_key_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/endpoints/keys/key-openai-a/reveal",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![],
vec![sample_bound_key(
"key-openai-a",
"provider-openai",
"openai:chat",
"sk-test-a",
)],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_repository_for_tests(
provider_catalog_repository,
)
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/admin/endpoints/keys/key-openai-a/reveal"
))
.header(crate::constants::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["auth_type"], "api_key");
assert_eq!(payload["api_key"], "sk-test-a");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_exports_admin_provider_key_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/endpoints/keys/key-kiro-a/export",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let mut key = sample_bound_key(
"key-kiro-a",
"provider-kiro",
"claude:messages",
"oauth-access-token",
);
key.auth_type = "oauth".to_string();
key.encrypted_auth_config = Some(sample_bound_auth_config(
"provider-kiro",
"key-kiro-a",
r#"{"provider_type":"kiro","auth_method":"idc","refresh_token":"rt-kiro-123"}"#,
));
key.upstream_metadata = Some(json!({"kiro": {"email": "[email protected]"}}));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-kiro", "kiro", 10)],
vec![],
vec![key],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog_repository,
)
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/admin/endpoints/keys/key-kiro-a/export"
))
.header(crate::constants::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["provider_type"], "kiro");
assert_eq!(payload["auth_method"], "idc");
assert_eq!(payload["refresh_token"], "rt-kiro-123");
assert_eq!(payload["email"], "[email protected]");
assert_eq!(payload["name"], "default");
assert!(payload.get("exported_at").is_some());
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_exports_admin_provider_key_access_token_when_refresh_token_is_missing() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/endpoints/keys/key-codex-a/export",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let mut key = sample_bound_key(
"key-codex-a",
"provider-codex",
"openai:responses",
"codex-access-token",
);
key.auth_type = "oauth".to_string();
key.encrypted_auth_config = Some(sample_bound_auth_config(
"provider-codex",
"key-codex-a",
r#"{"provider_type":"codex","email":"[email protected]","updated_at":1710000000}"#,
));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-codex", "codex", 10)],
vec![],
vec![key],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog_repository,
)
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/admin/endpoints/keys/key-codex-a/export"
))
.header(crate::constants::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["provider_type"], "codex");
assert_eq!(payload["email"], "[email protected]");
assert_eq!(payload["access_token"], "codex-access-token");
assert!(payload.get("refresh_token").is_none());
assert!(payload.get("updated_at").is_none());
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_export_does_not_emit_access_token_from_imported_authorization_header() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/endpoints/keys/key-codex-a/export",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let mut key = sample_bound_key(
"key-codex-a",
"provider-codex",
"openai:responses",
"imported-session-token",
);
key.auth_type = "oauth".to_string();
key.encrypted_auth_config = Some(sample_bound_auth_config(
"provider-codex",
"key-codex-a",
r#"{"provider_type":"codex","email":"[email protected]","headers":{"authorization":"Bearer imported-session-token"}}"#,
));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-codex", "codex", 10)],
vec![],
vec![key],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog_repository,
)
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/admin/endpoints/keys/key-codex-a/export"
))
.header(crate::constants::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["provider_type"], "codex");
assert_eq!(payload["email"], "[email protected]");
assert_eq!(
payload["headers"]["authorization"],
"Bearer imported-session-token"
);
assert!(payload.get("access_token").is_none());
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_export_preserves_distinct_imported_access_token_with_authorization_header() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/endpoints/keys/key-codex-a/export",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let mut key = sample_bound_key(
"key-codex-a",
"provider-codex",
"openai:responses",
"jwt-access-token",
);
key.auth_type = "oauth".to_string();
key.encrypted_auth_config = Some(sample_bound_auth_config(
"provider-codex",
"key-codex-a",
r#"{"provider_type":"codex","email":"[email protected]","access_token":"jwt-access-token","headers":{"authorization":"Bearer imported-session-token"}}"#,
));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-codex", "codex", 10)],
vec![],
vec![key],
));
let (_upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog_repository,
)
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/admin/endpoints/keys/key-codex-a/export"
))
.header(crate::constants::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["provider_type"], "codex");
assert_eq!(payload["access_token"], "jwt-access-token");
assert_eq!(
payload["headers"]["authorization"],
"Bearer imported-session-token"
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_generic_export_rejects_agent_identity_without_exposing_private_key() {
let private_key = "agent-private-key-must-not-leak";
let mut provider = sample_provider("provider-codex", "codex", 10);
provider.provider_type = "codex".to_string();
let mut key = sample_bound_key(
"key-codex-agent",
"provider-codex",
"openai:responses",
"__placeholder__",
);
key.auth_type = "oauth".to_string();
key.encrypted_auth_config = Some(sample_bound_auth_config(
"provider-codex",
"key-codex-agent",
&json!({
"provider_type": "codex",
"auth_mode": "agentIdentity",
"agent_runtime_id": "runtime-must-not-leak",
"agent_private_key": private_key,
"task_id": "task-must-not-leak"
})
.to_string(),
));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
vec![],
vec![key],
));
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog_repository,
)
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let client = reqwest::Client::new();
let reveal_response = client
.get(format!(
"{gateway_url}/api/admin/endpoints/keys/key-codex-agent/reveal"
))
.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("reveal request should complete");
assert_eq!(reveal_response.status(), StatusCode::BAD_REQUEST);
let reveal_body = reveal_response
.text()
.await
.expect("reveal error body should read");
assert!(reveal_body.contains("专属 provider-oauth 管理面"));
assert!(!reveal_body.contains(private_key));
assert!(!reveal_body.contains("runtime-must-not-leak"));
assert!(!reveal_body.contains("task-must-not-leak"));
let export_response = client
.get(format!(
"{gateway_url}/api/admin/endpoints/keys/key-codex-agent/export"
))
.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("export request should complete");
assert_eq!(export_response.status(), StatusCode::BAD_REQUEST);
let export_body = export_response
.text()
.await
.expect("export error body should read");
assert!(export_body.contains("专属 provider-oauth 管理面"));
assert!(!export_body.contains(private_key));
assert!(!export_body.contains("runtime-must-not-leak"));
assert!(!export_body.contains("task-must-not-leak"));
let list_response = client
.get(format!(
"{gateway_url}/api/admin/endpoints/providers/provider-codex/keys"
))
.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("list request should complete");
assert_eq!(list_response.status(), StatusCode::OK);
let list_payload: serde_json::Value = list_response
.json()
.await
.expect("list body should be JSON");
let agent = list_payload
.as_array()
.and_then(|items| items.iter().find(|item| item["id"] == "key-codex-agent"))
.expect("Agent Identity key should be listed");
assert_eq!(agent["agent_identity"], true);
assert_eq!(agent["can_export_oauth"], false);
gateway_handle.abort();
}
#[tokio::test]
async fn gateway_clears_admin_provider_key_oauth_invalid_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/endpoints/keys/key-openai-a/clear-oauth-invalid",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let mut key = sample_bound_key(
"key-openai-a",
"provider-openai",
"openai:chat",
"sk-test-a",
);
key.auth_type = "oauth".to_string();
key.oauth_invalid_at_unix_secs = Some(1_710_000_000);
key.oauth_invalid_reason = Some("token expired".to_string());
key.expires_at_unix_secs = Some(1_900_000_000);
key.status_snapshot = Some(json!({
"oauth": {
"code": "invalid",
"label": "已失效",
"reason": "token expired",
"expires_at": 1_900_000_000u64,
"invalid_at": 1_710_000_000u64,
"source": "oauth_invalid",
"requires_reauth": true,
"expiring_soon": false
},
"account": {
"code": "ok",
"label": serde_json::Value::Null,
"reason": serde_json::Value::Null,
"blocked": false,
"source": serde_json::Value::Null,
"recoverable": false
},
"quota": {
"code": "unknown",
"label": serde_json::Value::Null,
"reason": serde_json::Value::Null,
"exhausted": false,
"usage_ratio": serde_json::Value::Null,
"updated_at": serde_json::Value::Null,
"reset_seconds": serde_json::Value::Null,
"plan_type": serde_json::Value::Null
}
}));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![],
vec![key],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.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/keys/key-openai-a/clear-oauth-invalid"
))
.header(crate::constants::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["message"], "已清除 OAuth 失效标记");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
let reloaded = provider_catalog_repository
.list_keys_by_ids(&["key-openai-a".to_string()])
.await
.expect("keys should read");
assert_eq!(reloaded.len(), 1);
assert_eq!(reloaded[0].oauth_invalid_at_unix_secs, None);
assert_eq!(reloaded[0].oauth_invalid_reason, None);
let oauth_snapshot = reloaded[0]
.status_snapshot
.as_ref()
.and_then(serde_json::Value::as_object)
.and_then(|snapshot| snapshot.get("oauth"))
.and_then(serde_json::Value::as_object)
.expect("oauth snapshot should exist");
assert_eq!(oauth_snapshot.get("code"), Some(&json!("valid")));
assert_eq!(oauth_snapshot.get("label"), Some(&json!("有效")));
assert_eq!(oauth_snapshot.get("reason"), Some(&serde_json::Value::Null));
assert_eq!(
oauth_snapshot.get("invalid_at"),
Some(&serde_json::Value::Null)
);
assert_eq!(oauth_snapshot.get("source"), Some(&json!("expires_at")));
assert_eq!(oauth_snapshot.get("requires_reauth"), Some(&json!(false)));
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_noops_admin_provider_key_oauth_invalid_clear_when_marker_absent() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/endpoints/keys/key-openai-a/clear-oauth-invalid",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![],
vec![sample_bound_key(
"key-openai-a",
"provider-openai",
"openai:chat",
"sk-test-a",
)],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_repository_for_tests(
provider_catalog_repository,
)
.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/keys/key-openai-a/clear-oauth-invalid"
))
.header(crate::constants::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["message"], "该 Key 当前无失效标记,无需清除");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_updates_admin_provider_key_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/endpoints/keys/key-openai-a",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let mut key = sample_bound_key(
"key-openai-a",
"provider-openai",
"openai:chat",
"sk-test-a",
);
key.learned_rpm_limit = Some(88);
key.allowed_models = Some(json!(["gpt-4.1"]));
key.fingerprint = Some(json!({"user_agent": "old-ua"}));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![],
vec![key],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.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()
.put(format!(
"{gateway_url}/api/admin/endpoints/keys/key-openai-a"
))
.header(crate::constants::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")
.json(&json!({
"api_key": "sk-updated-openai",
"name": "updated key",
"internal_priority": 15,
"rpm_limit": null,
"allowed_models": [],
"note": "updated from rust",
"is_active": false,
"fingerprint": {"user_agent": "new-ua"}
}))
.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["name"], "updated key");
assert_eq!(payload["internal_priority"], 15);
assert_eq!(payload["rpm_limit"], serde_json::Value::Null);
assert_eq!(payload["learned_rpm_limit"], serde_json::Value::Null);
assert_eq!(payload["allowed_models"], json!([]));
assert_eq!(payload["note"], "updated from rust");
assert_eq!(payload["is_active"], false);
assert_eq!(payload["fingerprint"]["user_agent"], "new-ua");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
let reloaded = provider_catalog_repository
.list_keys_by_ids(&["key-openai-a".to_string()])
.await
.expect("keys should read");
assert_eq!(reloaded.len(), 1);
assert_eq!(reloaded[0].name, "updated key");
assert_eq!(reloaded[0].internal_priority, 15);
assert_eq!(reloaded[0].rpm_limit, None);
assert_eq!(reloaded[0].learned_rpm_limit, None);
assert_eq!(reloaded[0].allowed_models, None);
assert_eq!(reloaded[0].note.as_deref(), Some("updated from rust"));
assert!(!reloaded[0].is_active);
let decrypted = open_provider_catalog_api_key_for_test(&reloaded[0]);
assert_eq!(decrypted, "sk-updated-openai");
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn provider_key_concurrent_limit_update_presence_semantics() {
let mut key = sample_bound_key(
"key-openai-a",
"provider-openai",
"openai:chat",
"sk-test-a",
);
key.concurrent_limit = Some(4);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![],
vec![key],
));
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.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 omitted_response = reqwest::Client::new()
.put(format!(
"{gateway_url}/api/admin/endpoints/keys/key-openai-a"
))
.header(crate::constants::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")
.json(&json!({
"name": "renamed without concurrency"
}))
.send()
.await
.expect("request should succeed");
assert_eq!(omitted_response.status(), StatusCode::OK);
let omitted_payload: serde_json::Value = omitted_response
.json()
.await
.expect("json body should parse");
assert_eq!(omitted_payload["name"], "renamed without concurrency");
assert_eq!(omitted_payload["concurrent_limit"], 4);
let set_response = reqwest::Client::new()
.put(format!(
"{gateway_url}/api/admin/endpoints/keys/key-openai-a"
))
.header(crate::constants::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")
.json(&json!({
"concurrent_limit": 7
}))
.send()
.await
.expect("request should succeed");
assert_eq!(set_response.status(), StatusCode::OK);
let set_payload: serde_json::Value = set_response.json().await.expect("json body should parse");
assert_eq!(set_payload["concurrent_limit"], 7);
let clear_response = reqwest::Client::new()
.put(format!(
"{gateway_url}/api/admin/endpoints/keys/key-openai-a"
))
.header(crate::constants::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")
.json(&json!({
"concurrent_limit": null
}))
.send()
.await
.expect("request should succeed");
assert_eq!(clear_response.status(), StatusCode::OK);
let clear_payload: serde_json::Value =
clear_response.json().await.expect("json body should parse");
assert_eq!(clear_payload["concurrent_limit"], serde_json::Value::Null);
let negative_response = reqwest::Client::new()
.put(format!(
"{gateway_url}/api/admin/endpoints/keys/key-openai-a"
))
.header(crate::constants::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")
.json(&json!({
"concurrent_limit": -1
}))
.send()
.await
.expect("request should succeed");
assert_eq!(negative_response.status(), StatusCode::BAD_REQUEST);
let negative_payload: serde_json::Value = negative_response
.json()
.await
.expect("json body should parse");
assert!(negative_payload["detail"]
.as_str()
.expect("detail should be string")
.contains("concurrent_limit"));
let reloaded = provider_catalog_repository
.list_keys_by_ids(&["key-openai-a".to_string()])
.await
.expect("keys should read");
assert_eq!(reloaded.len(), 1);
assert_eq!(reloaded[0].name, "renamed without concurrency");
assert_eq!(reloaded[0].concurrent_limit, None);
gateway_handle.abort();
}
#[test]
fn gateway_clears_allowed_models_when_disabling_auto_fetch_on_provider_key_update() {
run_provider_keys_test(
"gateway_clears_allowed_models_when_disabling_auto_fetch_on_provider_key_update",
gateway_clears_allowed_models_when_disabling_auto_fetch_on_provider_key_update_impl,
);
}
async fn gateway_clears_allowed_models_when_disabling_auto_fetch_on_provider_key_update_impl() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/endpoints/keys/key-openai-a",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let mut key = sample_bound_key(
"key-openai-a",
"provider-openai",
"openai:chat",
"sk-test-a",
);
key.auto_fetch_models = true;
key.allowed_models = Some(json!(["gpt-5", "gpt-4.1-mini"]));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![],
vec![key],
));
let (_upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.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()
.put(format!(
"{gateway_url}/api/admin/endpoints/keys/key-openai-a"
))
.header(crate::constants::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")
.json(&json!({
"auto_fetch_models": false
}))
.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["auto_fetch_models"], false);
assert_eq!(payload["allowed_models"], json!([]));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
let reloaded = provider_catalog_repository
.list_keys_by_ids(&["key-openai-a".to_string()])
.await
.expect("keys should read");
assert_eq!(reloaded.len(), 1);
assert!(!reloaded[0].auto_fetch_models);
assert_eq!(reloaded[0].allowed_models, None);
gateway_handle.abort();
upstream_handle.abort();
}
#[test]
fn gateway_overwrites_allowed_models_immediately_when_enabling_auto_fetch() {
run_provider_keys_test(
"gateway_overwrites_allowed_models_immediately_when_enabling_auto_fetch",
gateway_overwrites_allowed_models_immediately_when_enabling_auto_fetch_impl,
);
}
async fn gateway_overwrites_allowed_models_immediately_when_enabling_auto_fetch_impl() {
let execution_runtime_hits = Arc::new(Mutex::new(0usize));
let execution_runtime_hits_clone = Arc::clone(&execution_runtime_hits);
let execution_runtime = Router::new().route(
"/v1/execute/sync",
any(move |Json(plan): Json<ExecutionPlan>| {
let execution_runtime_hits_inner = Arc::clone(&execution_runtime_hits_clone);
async move {
*execution_runtime_hits_inner
.lock()
.expect("mutex should lock") += 1;
assert_eq!(plan.url, "https://api.openai.example/v1/models");
assert_eq!(
plan.headers.get("authorization").map(String::as_str),
Some("Bearer sk-test-a")
);
Json(json!({
"request_id": "req-update-key-auto-fetch",
"status_code": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"json_body": {
"data": [
{"id": "gpt-5"},
{"id": "gpt-4.1"},
{"id": "gpt-o1"}
]
}
}
}))
}
}),
);
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
let mut key = sample_bound_key(
"key-openai-a",
"provider-openai",
"openai:chat",
"sk-test-a",
);
key.auto_fetch_models = false;
key.allowed_models = Some(json!(["manual-a", "manual-b"]));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![sample_endpoint(
"endpoint-openai-chat",
"provider-openai",
"openai:chat",
"https://api.openai.example/v1",
)],
vec![key],
));
let gateway = build_router_with_state(
build_state_with_execution_runtime_override(execution_runtime_url)
.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()
.put(format!(
"{gateway_url}/api/admin/endpoints/keys/key-openai-a"
))
.header(crate::constants::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")
.json(&json!({
"auto_fetch_models": true
}))
.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["auto_fetch_models"], true);
assert_eq!(
payload["allowed_models"],
json!(["gpt-4.1", "gpt-5", "gpt-o1"])
);
assert_eq!(payload["last_models_fetch_error"], serde_json::Value::Null);
assert_eq!(
*execution_runtime_hits.lock().expect("mutex should lock"),
1
);
let reloaded = provider_catalog_repository
.list_keys_by_ids(&["key-openai-a".to_string()])
.await
.expect("keys should read");
assert_eq!(reloaded.len(), 1);
assert!(reloaded[0].auto_fetch_models);
assert_eq!(
reloaded[0].allowed_models,
Some(json!(["gpt-4.1", "gpt-5", "gpt-o1"]))
);
assert_eq!(reloaded[0].locked_models, None);
gateway_handle.abort();
execution_runtime_handle.abort();
}
#[test]
fn gateway_fetches_allowed_models_immediately_when_enabling_auto_fetch_from_empty_state() {
run_provider_keys_test(
"gateway_fetches_allowed_models_immediately_when_enabling_auto_fetch_from_empty_state",
gateway_fetches_allowed_models_immediately_when_enabling_auto_fetch_from_empty_state_impl,
);
}
async fn gateway_fetches_allowed_models_immediately_when_enabling_auto_fetch_from_empty_state_impl()
{
let execution_runtime_hits = Arc::new(Mutex::new(0usize));
let execution_runtime_hits_clone = Arc::clone(&execution_runtime_hits);
let execution_runtime = Router::new().route(
"/v1/execute/sync",
any(move |Json(plan): Json<ExecutionPlan>| {
let execution_runtime_hits_inner = Arc::clone(&execution_runtime_hits_clone);
async move {
*execution_runtime_hits_inner
.lock()
.expect("mutex should lock") += 1;
assert_eq!(plan.url, "https://api.openai.example/v1/models");
Json(json!({
"request_id": "req-update-key-auto-fetch-empty",
"status_code": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"json_body": {
"data": [
{"id": "gpt-5-mini"},
{"id": "gpt-4.1-nano"}
]
}
}
}))
}
}),
);
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
let mut key = sample_bound_key(
"key-openai-a",
"provider-openai",
"openai:chat",
"sk-test-a",
);
key.auto_fetch_models = false;
key.allowed_models = None;
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![sample_endpoint(
"endpoint-openai-chat",
"provider-openai",
"openai:chat",
"https://api.openai.example/v1",
)],
vec![key],
));
let gateway = build_router_with_state(
build_state_with_execution_runtime_override(execution_runtime_url)
.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()
.put(format!(
"{gateway_url}/api/admin/endpoints/keys/key-openai-a"
))
.header(crate::constants::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")
.json(&json!({
"auto_fetch_models": true
}))
.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["auto_fetch_models"], true);
assert_eq!(
payload["allowed_models"],
json!(["gpt-4.1-nano", "gpt-5-mini"])
);
assert_eq!(
*execution_runtime_hits.lock().expect("mutex should lock"),
1
);
let reloaded = provider_catalog_repository
.list_keys_by_ids(&["key-openai-a".to_string()])
.await
.expect("keys should read");
assert_eq!(reloaded.len(), 1);
assert!(reloaded[0].auto_fetch_models);
assert_eq!(
reloaded[0].allowed_models,
Some(json!(["gpt-4.1-nano", "gpt-5-mini"]))
);
gateway_handle.abort();
execution_runtime_handle.abort();
}
#[test]
fn gateway_refreshes_allowed_models_when_updating_include_patterns_with_auto_fetch_enabled() {
run_provider_keys_test(
"gateway_refreshes_allowed_models_when_updating_include_patterns_with_auto_fetch_enabled",
gateway_refreshes_allowed_models_when_updating_include_patterns_with_auto_fetch_enabled_impl,
);
}
async fn gateway_refreshes_allowed_models_when_updating_include_patterns_with_auto_fetch_enabled_impl(
) {
let execution_runtime_hits = Arc::new(Mutex::new(0usize));
let execution_runtime_hits_clone = Arc::clone(&execution_runtime_hits);
let execution_runtime = Router::new().route(
"/v1/execute/sync",
any(move |Json(plan): Json<ExecutionPlan>| {
let execution_runtime_hits_inner = Arc::clone(&execution_runtime_hits_clone);
async move {
*execution_runtime_hits_inner
.lock()
.expect("mutex should lock") += 1;
assert_eq!(plan.url, "https://api.openai.example/v1/models");
Json(json!({
"request_id": "req-update-key-include-patterns",
"status_code": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"json_body": {
"data": [
{"id": "gpt-5"},
{"id": "gpt-4.1"},
{"id": "claude-3.7-sonnet"}
]
}
}
}))
}
}),
);
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
let mut key = sample_bound_key(
"key-openai-a",
"provider-openai",
"openai:chat",
"sk-test-a",
);
key.auto_fetch_models = true;
key.allowed_models = Some(json!(["gpt-4.1", "gpt-5", "claude-3.7-sonnet"]));
key.model_include_patterns = Some(json!(["gpt-*", "claude-*"]));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![sample_endpoint(
"endpoint-openai-chat",
"provider-openai",
"openai:chat",
"https://api.openai.example/v1",
)],
vec![key],
));
let gateway = build_router_with_state(
build_state_with_execution_runtime_override(execution_runtime_url)
.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()
.put(format!(
"{gateway_url}/api/admin/endpoints/keys/key-openai-a"
))
.header(crate::constants::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")
.json(&json!({
"model_include_patterns": ["gpt-5*"]
}))
.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["auto_fetch_models"], true);
assert_eq!(payload["model_include_patterns"], json!(["gpt-5*"]));
assert_eq!(payload["allowed_models"], json!(["gpt-5"]));
assert_eq!(
*execution_runtime_hits.lock().expect("mutex should lock"),
1
);
let reloaded = provider_catalog_repository
.list_keys_by_ids(&["key-openai-a".to_string()])
.await
.expect("keys should read");
assert_eq!(reloaded.len(), 1);
assert!(reloaded[0].auto_fetch_models);
assert_eq!(reloaded[0].model_include_patterns, Some(json!(["gpt-5*"])));
assert_eq!(reloaded[0].allowed_models, Some(json!(["gpt-5"])));
gateway_handle.abort();
execution_runtime_handle.abort();
}
#[test]
fn gateway_refreshes_allowed_models_when_updating_exclude_patterns_with_auto_fetch_enabled() {
run_provider_keys_test(
"gateway_refreshes_allowed_models_when_updating_exclude_patterns_with_auto_fetch_enabled",
gateway_refreshes_allowed_models_when_updating_exclude_patterns_with_auto_fetch_enabled_impl,
);
}
async fn gateway_refreshes_allowed_models_when_updating_exclude_patterns_with_auto_fetch_enabled_impl(
) {
let execution_runtime_hits = Arc::new(Mutex::new(0usize));
let execution_runtime_hits_clone = Arc::clone(&execution_runtime_hits);
let execution_runtime = Router::new().route(
"/v1/execute/sync",
any(move |Json(plan): Json<ExecutionPlan>| {
let execution_runtime_hits_inner = Arc::clone(&execution_runtime_hits_clone);
async move {
*execution_runtime_hits_inner
.lock()
.expect("mutex should lock") += 1;
assert_eq!(plan.url, "https://api.openai.example/v1/models");
Json(json!({
"request_id": "req-update-key-exclude-patterns",
"status_code": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"json_body": {
"data": [
{"id": "gpt-5"},
{"id": "gpt-4.1"},
{"id": "claude-3.7-sonnet"}
]
}
}
}))
}
}),
);
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
let mut key = sample_bound_key(
"key-openai-a",
"provider-openai",
"openai:chat",
"sk-test-a",
);
key.auto_fetch_models = true;
key.allowed_models = Some(json!(["gpt-5", "gpt-4.1", "claude-3.7-sonnet"]));
key.model_exclude_patterns = Some(json!(["claude-*"]));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![sample_endpoint(
"endpoint-openai-chat",
"provider-openai",
"openai:chat",
"https://api.openai.example/v1",
)],
vec![key],
));
let gateway = build_router_with_state(
build_state_with_execution_runtime_override(execution_runtime_url)
.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()
.put(format!(
"{gateway_url}/api/admin/endpoints/keys/key-openai-a"
))
.header(crate::constants::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")
.json(&json!({
"model_exclude_patterns": ["gpt-4*"]
}))
.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["auto_fetch_models"], true);
assert_eq!(payload["model_exclude_patterns"], json!(["gpt-4*"]));
assert_eq!(
payload["allowed_models"],
json!(["claude-3.7-sonnet", "gpt-5"])
);
assert_eq!(
*execution_runtime_hits.lock().expect("mutex should lock"),
1
);
let reloaded = provider_catalog_repository
.list_keys_by_ids(&["key-openai-a".to_string()])
.await
.expect("keys should read");
assert_eq!(reloaded.len(), 1);
assert!(reloaded[0].auto_fetch_models);
assert_eq!(reloaded[0].model_exclude_patterns, Some(json!(["gpt-4*"])));
assert_eq!(
reloaded[0].allowed_models,
Some(json!(["claude-3.7-sonnet", "gpt-5"]))
);
gateway_handle.abort();
execution_runtime_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_admin_provider_key_update_when_api_key_duplicates_existing_key() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/endpoints/keys/key-openai-a",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![],
vec![
sample_bound_key(
"key-openai-a",
"provider-openai",
"openai:chat",
"sk-test-a",
),
sample_bound_key(
"key-openai-b",
"provider-openai",
"openai:chat",
"sk-test-b",
),
],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_repository_for_tests(
provider_catalog_repository,
)
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.put(format!(
"{gateway_url}/api/admin/endpoints/keys/key-openai-a"
))
.header(crate::constants::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")
.json(&json!({
"api_key": "sk-test-b"
}))
.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!(payload["detail"]
.as_str()
.expect("detail should be string")
.contains("该 API Key 已存在于当前 Provider 中"));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_deletes_admin_provider_key_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/endpoints/keys/key-openai-a",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![],
vec![sample_bound_key(
"key-openai-a",
"provider-openai",
"openai:chat",
"sk-test-a",
)],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.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()
.delete(format!(
"{gateway_url}/api/admin/endpoints/keys/key-openai-a"
))
.header(crate::constants::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["message"], "Key key-openai-a 已删除");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
let reloaded = provider_catalog_repository
.list_keys_by_ids(&["key-openai-a".to_string()])
.await
.expect("keys should read");
assert!(reloaded.is_empty());
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_batch_deletes_admin_provider_keys_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/endpoints/keys/batch-delete",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![],
vec![
sample_bound_key(
"key-openai-a",
"provider-openai",
"openai:chat",
"sk-test-a",
),
sample_bound_key(
"key-openai-b",
"provider-openai",
"openai:chat",
"sk-test-b",
),
],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.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/keys/batch-delete"
))
.header(crate::constants::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")
.json(&json!({
"ids": ["key-openai-a", "key-missing"]
}))
.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_count"], 1);
assert_eq!(payload["failed_count"], 1);
assert_eq!(payload["failed"][0]["id"], "key-missing");
assert_eq!(payload["failed"][0]["error"], "not found");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
let reloaded = provider_catalog_repository
.list_keys_by_provider_ids(&["provider-openai".to_string()])
.await
.expect("keys should read");
assert_eq!(reloaded.len(), 1);
assert_eq!(reloaded[0].id, "key-openai-b");
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_admin_keys_grouped_by_format_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/endpoints/keys/grouped-by-format",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let mut key_a = sample_bound_key(
"key-openai-a",
"provider-openai",
"openai:chat",
"sk-test-a",
);
key_a.internal_priority = 10;
key_a.request_count = Some(12);
key_a.success_count = Some(9);
key_a.created_at_unix_ms = Some(1_711_000_000);
key_a.updated_at_unix_secs = Some(1_711_000_100);
key_a.capabilities = Some(json!({"cache_1h": true, "gemini_files": false}));
key_a.global_priority_by_format = Some(json!({"openai:chat": 3}));
key_a.health_by_format = Some(json!({"openai:chat": {"health_score": 0.8}}));
key_a.circuit_breaker_by_format = Some(json!({"openai:chat": {"open": false}}));
let mut key_b = sample_bound_key(
"key-claude-a",
"provider-claude",
"claude:messages",
"sk-ant-a",
);
key_b.internal_priority = 20;
key_b.request_count = Some(2);
key_b.success_count = Some(1);
key_b.created_at_unix_ms = Some(1_711_100_000);
key_b.updated_at_unix_secs = Some(1_711_100_100);
let mut key_agent = sample_bound_key(
"key-codex-agent",
"provider-codex",
"openai:responses",
"__placeholder__",
);
key_agent.auth_type = "oauth".to_string();
key_agent.encrypted_auth_config = Some(sample_bound_auth_config(
"provider-codex",
"key-codex-agent",
r#"{"provider_type":"codex","auth_mode":"agentIdentity","agent_runtime_id":"runtime-1","agent_private_key":"base64-private-key","task_id":"task-1"}"#,
));
let mut codex_provider = sample_provider("provider-codex", "codex", 30);
codex_provider.provider_type = "codex".to_string();
let provider_catalog_repository = Arc::new(SummaryNullingProviderCatalogReadRepository::seed(
vec![
sample_provider("provider-openai", "openai", 10),
sample_provider("provider-claude", "claude", 20)
.with_transport_fields(false, false, true, None, None, None, None, None, None),
codex_provider,
],
vec![
sample_endpoint(
"endpoint-openai-chat",
"provider-openai",
"openai:chat",
"https://api.openai.example",
),
sample_endpoint(
"endpoint-claude-chat",
"provider-claude",
"claude:messages",
"https://api.claude.example",
),
sample_endpoint(
"endpoint-codex-responses",
"provider-codex",
"openai:responses",
"https://api.codex.example",
),
],
vec![key_a, key_b, key_agent],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog_repository,
)
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/admin/endpoints/keys/grouped-by-format"
))
.header(crate::constants::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["openai:chat"][0]["id"], "key-openai-a");
assert_eq!(payload["openai:chat"][0]["provider_name"], "openai");
assert_eq!(
payload["openai:chat"][0]["endpoint_base_url"],
"https://api.openai.example"
);
assert_eq!(payload["openai:chat"][0]["capabilities"], json!(["1h缓存"]));
assert_eq!(payload["openai:chat"][0]["format_priority"], 3);
assert_eq!(
payload["openai:chat"][0]["global_priority_by_format"]["openai:chat"],
3
);
assert_eq!(payload["openai:chat"][0]["internal_priority"], 10);
assert_eq!(payload["claude:messages"][0]["provider_active"], false);
let agent_item = payload["openai:responses"]
.as_array()
.and_then(|items| items.iter().find(|item| item["id"] == "key-codex-agent"))
.expect("Agent Identity key should be grouped");
assert_eq!(agent_item["api_key_masked"], "[Agent Identity]");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}