mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
Merge origin/main into fix/gemini-cli-v1internal
This commit is contained in:
@@ -7,8 +7,8 @@ use aether_crypto::{
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
ProviderCatalogKeyListQuery, ProviderCatalogReadRepository, StoredProviderCatalogEndpoint,
|
||||
StoredProviderCatalogKey, StoredProviderCatalogKeyPage, StoredProviderCatalogKeyStats,
|
||||
StoredProviderCatalogProvider,
|
||||
StoredProviderCatalogKey, StoredProviderCatalogKeyMaintenanceSummary,
|
||||
StoredProviderCatalogKeyPage, StoredProviderCatalogKeyStats, StoredProviderCatalogProvider,
|
||||
};
|
||||
use aether_data_contracts::DataLayerError;
|
||||
use axum::body::Body;
|
||||
@@ -107,6 +107,15 @@ impl ProviderCatalogReadRepository for SummaryNullingProviderCatalogReadReposito
|
||||
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,
|
||||
@@ -232,8 +241,8 @@ async fn gateway_provider_keys_expose_circuit_breaker_and_recover_clears_it() {
|
||||
"open": true,
|
||||
"open_at": "2026-03-26T12:00:00+00:00",
|
||||
"reason": "consecutive_failures_8",
|
||||
"next_probe_at": "2026-03-26T12:01:00+00:00",
|
||||
"next_probe_at_unix_secs": 1774526460u64,
|
||||
"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,
|
||||
|
||||
@@ -1533,8 +1533,7 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_gemini_cli_with_trus
|
||||
assert_eq!(
|
||||
seen_request.request_body,
|
||||
Some(json!({
|
||||
"project": "gemini-cli-project-1",
|
||||
"userAgent": "GeminiCLI/0.1.5 (Windows; AMD64)"
|
||||
"project": "gemini-cli-project-1"
|
||||
}))
|
||||
);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
@@ -346,7 +346,7 @@ async fn gateway_handles_admin_key_health_locally_with_trusted_admin_principal()
|
||||
Some(json!({"openai:chat": {
|
||||
"open": true,
|
||||
"open_at": "2026-03-26T12:01:00+00:00",
|
||||
"next_probe_at": "2026-03-26T12:05:00+00:00",
|
||||
"next_probe_at": "2099-03-26T12:05:00+00:00",
|
||||
"half_open_until": null,
|
||||
"half_open_successes": 1,
|
||||
"half_open_failures": 0
|
||||
@@ -399,7 +399,7 @@ async fn gateway_handles_admin_key_health_locally_with_trusted_admin_principal()
|
||||
payload["circuit_breaker_open_at"],
|
||||
"2026-03-26T12:01:00+00:00"
|
||||
);
|
||||
assert_eq!(payload["next_probe_at"], "2026-03-26T12:05:00+00:00");
|
||||
assert_eq!(payload["next_probe_at"], "2099-03-26T12:05:00+00:00");
|
||||
assert_eq!(payload["half_open_successes"], 1);
|
||||
assert_eq!(payload["half_open_failures"], 0);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
@@ -408,6 +408,85 @@ async fn gateway_handles_admin_key_health_locally_with_trusted_admin_principal()
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_admin_key_health_summary_treats_expired_unix_circuit_as_closed() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/endpoints/health/key/key-openai",
|
||||
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![sample_endpoint(
|
||||
"endpoint-openai",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
)],
|
||||
vec![
|
||||
sample_key("key-openai", "provider-openai", "openai:chat", "sk-test")
|
||||
.with_health_fields(
|
||||
Some(json!({"openai:chat": {
|
||||
"health_score": 0.7,
|
||||
"consecutive_failures": 2
|
||||
}})),
|
||||
Some(json!({"openai:chat": {
|
||||
"open": true,
|
||||
"open_at": "2026-03-26T12:01:00+00:00",
|
||||
"next_probe_at_unix_secs": 1u64
|
||||
}})),
|
||||
),
|
||||
],
|
||||
));
|
||||
|
||||
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/health/key/key-openai"
|
||||
))
|
||||
.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");
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text().await.expect("body should read");
|
||||
assert_eq!(status, StatusCode::OK, "body={body}");
|
||||
let payload: serde_json::Value = serde_json::from_str(&body).expect("json body should parse");
|
||||
let circuit = &payload["health_by_format"]["openai:chat"]["circuit_breaker"];
|
||||
assert_eq!(payload["any_circuit_open"], false);
|
||||
assert_eq!(circuit["open"], false);
|
||||
assert_eq!(circuit["state"], "closed");
|
||||
assert_eq!(payload["key_health_score"], 0.7);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_recovers_admin_key_health_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
@@ -442,7 +521,7 @@ async fn gateway_recovers_admin_key_health_locally_with_trusted_admin_principal(
|
||||
Some(json!({"openai:chat": {
|
||||
"open": true,
|
||||
"open_at": "2026-03-26T12:01:00+00:00",
|
||||
"next_probe_at": "2026-03-26T12:05:00+00:00",
|
||||
"next_probe_at": "2099-03-26T12:05:00+00:00",
|
||||
"half_open_until": null,
|
||||
"half_open_successes": 0,
|
||||
"half_open_failures": 1
|
||||
@@ -818,6 +897,15 @@ async fn gateway_handles_admin_modules_status_locally_with_trusted_admin_princip
|
||||
);
|
||||
assert_eq!(payload["bark_push"]["display_name"], "Bark 推送");
|
||||
assert_eq!(payload["bark_push"]["admin_route"], "/admin/modules/bark");
|
||||
assert_eq!(payload["s3_backup"]["display_name"], "S3 备份");
|
||||
assert_eq!(
|
||||
payload["s3_backup"]["admin_route"],
|
||||
"/admin/modules/s3-backup"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["s3_backup"]["admin_menu_group"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
|
||||
@@ -789,7 +789,7 @@ async fn gateway_handles_admin_global_model_routing_locally_with_trusted_admin_p
|
||||
"openai:chat": {"health_score": 0.66}
|
||||
}));
|
||||
primary_key.circuit_breaker_by_format = Some(json!({
|
||||
"openai:chat": {"open": true, "next_probe_at": "2026-03-27T15:00:00Z"}
|
||||
"openai:chat": {"open": true, "next_probe_at": "2099-03-27T15:00:00Z"}
|
||||
}));
|
||||
|
||||
let mut mapped_key = sample_key(
|
||||
@@ -907,7 +907,7 @@ async fn gateway_handles_admin_global_model_routing_locally_with_trusted_admin_p
|
||||
openai_keys[0]["circuit_breaker_formats"],
|
||||
json!(["openai:chat"])
|
||||
);
|
||||
assert_eq!(openai_keys[0]["next_probe_at"], "2026-03-27T15:00:00Z");
|
||||
assert_eq!(openai_keys[0]["next_probe_at"], "2099-03-27T15:00:00Z");
|
||||
|
||||
let alt_endpoints = providers[1]["endpoints"]
|
||||
.as_array()
|
||||
|
||||
@@ -2040,7 +2040,8 @@ async fn gateway_handles_admin_monitoring_resilience_circuit_history_locally_wit
|
||||
"openai:chat": {
|
||||
"open": true,
|
||||
"open_at": "2026-03-30T12:00:00+00:00",
|
||||
"next_probe_at": "2026-03-30T12:05:00+00:00",
|
||||
"next_probe_at": "2099-03-30T12:05:00+00:00",
|
||||
"recovery_seconds": 300,
|
||||
"reason": "错误率过高"
|
||||
}
|
||||
})),
|
||||
|
||||
@@ -225,7 +225,7 @@ fn windsurf_register_user_execution_result(request_id: &str) -> serde_json::Valu
|
||||
},
|
||||
"body": {
|
||||
"json_body": {
|
||||
"apiKey": "devin-session-token$registered",
|
||||
"sessionToken": "devin-session-token$registered",
|
||||
"name": "Windsurf User",
|
||||
"apiServerUrl": "https://server.codeium.com"
|
||||
}
|
||||
@@ -431,7 +431,7 @@ async fn gateway_rejects_generic_oauth_start_for_windsurf_provider() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_provider_oauth_device_poll_for_windsurf_callback_token() {
|
||||
async fn gateway_handles_admin_provider_oauth_device_poll_for_windsurf_one_time_token() {
|
||||
let execution_plans = Arc::new(Mutex::new(Vec::<ExecutionPlan>::new()));
|
||||
let execution_plans_clone = Arc::clone(&execution_plans);
|
||||
let execution_runtime = Router::new().route(
|
||||
@@ -521,7 +521,7 @@ async fn gateway_handles_admin_provider_oauth_device_poll_for_windsurf_callback_
|
||||
"/api/admin/provider-oauth/providers/provider-windsurf/device-poll",
|
||||
Some(json!({
|
||||
"session_id": "session-windsurf",
|
||||
"callback_url": "https://windsurf.com/show-auth-token?token=firebase-id-token&state=session-windsurf&provider=google"
|
||||
"token": "ott$browser-token"
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
@@ -590,14 +590,22 @@ async fn gateway_handles_admin_provider_oauth_device_poll_for_windsurf_callback_
|
||||
.expect("register plan should execute");
|
||||
assert_eq!(register_plan.method, "POST");
|
||||
assert_eq!(
|
||||
register_plan
|
||||
.body
|
||||
.json_body
|
||||
.as_ref()
|
||||
.and_then(|body| body.get("firebase_id_token"))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("firebase-id-token")
|
||||
register_plan.content_type.as_deref(),
|
||||
Some("application/proto")
|
||||
);
|
||||
assert!(register_plan.body.json_body.is_none());
|
||||
let encoded_body = register_plan
|
||||
.body
|
||||
.body_bytes_b64
|
||||
.as_deref()
|
||||
.expect("register body should be bytes");
|
||||
use base64::Engine as _;
|
||||
let body_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded_body)
|
||||
.expect("register body should decode");
|
||||
let mut expected_body = vec![0x0a, "ott$browser-token".len() as u8];
|
||||
expected_body.extend_from_slice(b"ott$browser-token");
|
||||
assert_eq!(body_bytes, expected_body);
|
||||
assert_eq!(
|
||||
register_plan
|
||||
.proxy
|
||||
@@ -661,7 +669,7 @@ async fn gateway_rejects_windsurf_callback_state_mismatch_and_missing_token() {
|
||||
"/api/admin/provider-oauth/providers/provider-windsurf/device-poll",
|
||||
Some(json!({
|
||||
"session_id": "session-windsurf",
|
||||
"callback_url": "https://windsurf.com/show-auth-token?token=firebase-id-token&state=wrong-state"
|
||||
"callback_url": "https://windsurf.com/show-auth-token?token=ott$wrong-state&state=wrong-state"
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
@@ -2874,15 +2882,10 @@ async fn gateway_completes_admin_provider_oauth_key_locally_with_trusted_admin_p
|
||||
assert_eq!(payload["has_refresh_token"], true);
|
||||
assert_eq!(payload["expires_at"], 4_102_444_800u64);
|
||||
assert_eq!(payload["email"], "[email protected]");
|
||||
assert_eq!(payload["account_state_recheck_attempted"], true);
|
||||
let account_state_recheck_error = payload["account_state_recheck_error"]
|
||||
.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 == "wham/usage API 返回状态码 403"
|
||||
|| account_state_recheck_error.starts_with("wham/usage 请求执行失败:"),
|
||||
"unexpected account_state_recheck_error: {account_state_recheck_error}"
|
||||
assert_eq!(payload["account_state_recheck_attempted"], false);
|
||||
assert_eq!(
|
||||
payload["account_state_recheck_error"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*token_hits.lock().expect("mutex should lock"), 1);
|
||||
@@ -6144,10 +6147,10 @@ async fn gateway_auto_removes_manual_oauth_refresh_failure_after_access_token_ex
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": {
|
||||
"message": "Your refresh token has already been used to generate a new access token. Please try signing in again.",
|
||||
"message": "Could not validate your refresh token. Please try signing in again.",
|
||||
"type": "invalid_request_error",
|
||||
"param": serde_json::Value::Null,
|
||||
"code": "refresh_token_reused"
|
||||
"code": "refresh_token_expired"
|
||||
}
|
||||
})),
|
||||
)
|
||||
@@ -6178,19 +6181,18 @@ async fn gateway_auto_removes_manual_oauth_refresh_failure_after_access_token_ex
|
||||
"openai:responses",
|
||||
"https://chatgpt.com/backend-api/codex",
|
||||
);
|
||||
|
||||
let mut key = sample_key(
|
||||
"key-codex-oauth-refresh-expired",
|
||||
"provider-codex",
|
||||
"openai:responses",
|
||||
"stale-codex-access-token",
|
||||
"expired-codex-access-token",
|
||||
);
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.expires_at_unix_secs = Some(1);
|
||||
key.encrypted_auth_config = Some(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"codex","refresh_token":"used-refresh-token","email":"[email protected]","account_id":"acct-codex-123","plan_type":"plus","expires_at":1}"#,
|
||||
r#"{"provider_type":"codex","refresh_token":"expired-refresh-token","email":"[email protected]","account_id":"acct-codex-123","plan_type":"plus","expires_at":1}"#,
|
||||
)
|
||||
.expect("auth config ciphertext should build"),
|
||||
);
|
||||
@@ -6200,7 +6202,6 @@ async fn gateway_auto_removes_manual_oauth_refresh_failure_after_access_token_ex
|
||||
vec![endpoint],
|
||||
vec![key],
|
||||
));
|
||||
|
||||
let (token_url, token_handle) = start_server(token_server).await;
|
||||
let oauth_refresh =
|
||||
crate::provider_transport::LocalOAuthRefreshCoordinator::with_adapters_for_tests(vec![
|
||||
@@ -6240,6 +6241,7 @@ async fn gateway_auto_removes_manual_oauth_refresh_failure_after_access_token_ex
|
||||
.await
|
||||
.expect("refresh payload should parse");
|
||||
assert_eq!(refresh_payload["status"], json!("auto_removed"));
|
||||
assert_eq!(refresh_payload["message"], json!("已自动删除"));
|
||||
assert_eq!(*token_hits.lock().expect("mutex should lock"), 1);
|
||||
|
||||
let keys = provider_catalog_repository
|
||||
|
||||
@@ -3526,6 +3526,14 @@ async fn gateway_cleans_up_admin_pool_banned_keys_locally_with_trusted_admin_pri
|
||||
);
|
||||
banned_key.name = "banned".to_string();
|
||||
banned_key.oauth_invalid_reason = Some("account_banned".to_string());
|
||||
let mut oauth_expired_key = sample_key(
|
||||
"key-openai-oauth-expired",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"sk-oauth-expired",
|
||||
);
|
||||
oauth_expired_key.name = "oauth-expired".to_string();
|
||||
oauth_expired_key.oauth_invalid_reason = Some("[OAUTH_EXPIRED] token invalidated".to_string());
|
||||
let mut healthy_key = sample_key(
|
||||
"key-openai-healthy",
|
||||
"provider-openai",
|
||||
@@ -3537,7 +3545,7 @@ async fn gateway_cleans_up_admin_pool_banned_keys_locally_with_trusted_admin_pri
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
Vec::new(),
|
||||
vec![banned_key, healthy_key],
|
||||
vec![banned_key, oauth_expired_key, healthy_key],
|
||||
));
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
@@ -3574,8 +3582,13 @@ async fn gateway_cleans_up_admin_pool_banned_keys_locally_with_trusted_admin_pri
|
||||
.list_keys_by_provider_ids(&["provider-openai".to_string()])
|
||||
.await
|
||||
.expect("remaining keys should load");
|
||||
assert_eq!(remaining_keys.len(), 1);
|
||||
assert_eq!(remaining_keys[0].id, "key-openai-healthy");
|
||||
assert_eq!(remaining_keys.len(), 2);
|
||||
assert!(remaining_keys
|
||||
.iter()
|
||||
.any(|key| key.id == "key-openai-oauth-expired"));
|
||||
assert!(remaining_keys
|
||||
.iter()
|
||||
.any(|key| key.id == "key-openai-healthy"));
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
|
||||
@@ -32,6 +32,30 @@ use crate::constants::{
|
||||
};
|
||||
use crate::data::{GatewayDataConfig, GatewayDataState};
|
||||
|
||||
const SUB2API_PROVIDER_OPS_BALANCE_TEST_STACK_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
fn run_sub2api_provider_ops_balance_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(SUB2API_PROVIDER_OPS_BALANCE_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("sub2api provider ops balance test thread should spawn");
|
||||
|
||||
if let Err(payload) = handle.join() {
|
||||
std::panic::resume_unwind(payload);
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_managed_redis_or_skip() -> Option<ManagedRedisServer> {
|
||||
match ManagedRedisServer::start().await {
|
||||
Ok(server) => Some(server),
|
||||
@@ -4328,8 +4352,15 @@ async fn gateway_handles_admin_provider_ops_batch_balance_with_pending_cache_hit
|
||||
ops_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_provider_ops_sub2api_balance_with_refresh_token_rotation() {
|
||||
#[test]
|
||||
fn gateway_handles_admin_provider_ops_sub2api_balance_with_refresh_token_rotation() {
|
||||
run_sub2api_provider_ops_balance_test(
|
||||
"gateway_handles_admin_provider_ops_sub2api_balance_with_refresh_token_rotation",
|
||||
gateway_handles_admin_provider_ops_sub2api_balance_with_refresh_token_rotation_impl,
|
||||
);
|
||||
}
|
||||
|
||||
async fn gateway_handles_admin_provider_ops_sub2api_balance_with_refresh_token_rotation_impl() {
|
||||
let ops = Router::new()
|
||||
.route(
|
||||
"/api/v1/auth/refresh",
|
||||
@@ -4533,8 +4564,15 @@ async fn gateway_handles_admin_provider_ops_sub2api_balance_with_refresh_token_r
|
||||
ops_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_provider_ops_sub2api_balance_against_site_root_when_base_url_has_path(
|
||||
#[test]
|
||||
fn gateway_handles_admin_provider_ops_sub2api_balance_against_site_root_when_base_url_has_path() {
|
||||
run_sub2api_provider_ops_balance_test(
|
||||
"gateway_handles_admin_provider_ops_sub2api_balance_against_site_root_when_base_url_has_path",
|
||||
gateway_handles_admin_provider_ops_sub2api_balance_against_site_root_when_base_url_has_path_impl,
|
||||
);
|
||||
}
|
||||
|
||||
async fn gateway_handles_admin_provider_ops_sub2api_balance_against_site_root_when_base_url_has_path_impl(
|
||||
) {
|
||||
let nested_refresh_hits = Arc::new(Mutex::new(0usize));
|
||||
let nested_refresh_hits_clone = Arc::clone(&nested_refresh_hits);
|
||||
@@ -4742,8 +4780,15 @@ async fn gateway_handles_admin_provider_ops_sub2api_balance_against_site_root_wh
|
||||
ops_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_provider_ops_sub2api_balance_with_session_login() {
|
||||
#[test]
|
||||
fn gateway_handles_admin_provider_ops_sub2api_balance_with_session_login() {
|
||||
run_sub2api_provider_ops_balance_test(
|
||||
"gateway_handles_admin_provider_ops_sub2api_balance_with_session_login",
|
||||
gateway_handles_admin_provider_ops_sub2api_balance_with_session_login_impl,
|
||||
);
|
||||
}
|
||||
|
||||
async fn gateway_handles_admin_provider_ops_sub2api_balance_with_session_login_impl() {
|
||||
let ops = Router::new()
|
||||
.route(
|
||||
"/api/v1/auth/login",
|
||||
|
||||
@@ -36,6 +36,8 @@ use crate::constants::{
|
||||
};
|
||||
use crate::data::GatewayDataState;
|
||||
|
||||
static SYSTEM_UPDATE_TEST_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_system_version_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
@@ -158,6 +160,269 @@ async fn gateway_handles_admin_system_check_update_locally_with_bearer_admin_ses
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_system_update_capability_locally() {
|
||||
let _lock = SYSTEM_UPDATE_TEST_MUTEX.lock().await;
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/system/update-capability",
|
||||
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 (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(format!("{gateway_url}/api/admin/system/update-capability"))
|
||||
.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!(payload["supported"].is_boolean());
|
||||
assert!(payload["build_type"].is_string());
|
||||
assert!(payload["task_status"].is_string());
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_prepares_admin_system_update_locally() {
|
||||
let _lock = SYSTEM_UPDATE_TEST_MUTEX.lock().await;
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/system/prepare-update",
|
||||
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 (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/system/prepare-update"))
|
||||
.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::SERVICE_UNAVAILABLE);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert!(payload["detail"].is_string());
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_admin_system_apply_update_without_prepared_version() {
|
||||
let _lock = SYSTEM_UPDATE_TEST_MUTEX.lock().await;
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/system/apply-update",
|
||||
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 (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/system/apply-update"))
|
||||
.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::BAD_REQUEST);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert!(payload["detail"].is_string());
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_admin_system_rollback_without_previous_release() {
|
||||
let _lock = SYSTEM_UPDATE_TEST_MUTEX.lock().await;
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/system/rollback",
|
||||
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 (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/system/rollback"))
|
||||
.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::PRECONDITION_REQUIRED);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert!(payload["detail"].is_string());
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_system_releases_locally() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/system/releases",
|
||||
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 (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(format!("{gateway_url}/api/admin/system/releases"))
|
||||
.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!(payload["current_version"]
|
||||
.as_str()
|
||||
.is_some_and(|value| !value.is_empty()));
|
||||
assert!(payload["releases"].is_array());
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_admin_system_apply_update_with_nonexistent_version() {
|
||||
let _lock = SYSTEM_UPDATE_TEST_MUTEX.lock().await;
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/system/apply-update",
|
||||
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 (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/system/apply-update"))
|
||||
.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")
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"version":"v99.99.99"}"#)
|
||||
.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"].is_string());
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_system_update_status_locally() {
|
||||
let _lock = SYSTEM_UPDATE_TEST_MUTEX.lock().await;
|
||||
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(format!("{gateway_url}/api/admin/system/update-status"))
|
||||
.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!(payload["phase"].is_string());
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_system_aws_regions_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
@@ -470,11 +735,11 @@ async fn gateway_handles_admin_system_config_export_locally_with_trusted_admin_p
|
||||
));
|
||||
let global_model_repository = Arc::new(
|
||||
InMemoryGlobalModelReadRepository::seed(Vec::<StoredPublicGlobalModel>::new())
|
||||
.with_admin_global_models(vec![sample_admin_global_model(
|
||||
"global-gpt-5",
|
||||
"gpt-5",
|
||||
"GPT 5",
|
||||
)])
|
||||
.with_admin_global_models(vec![{
|
||||
let mut model = sample_admin_global_model("global-gpt-5", "gpt-5", "GPT 5");
|
||||
model.usage_count = 7;
|
||||
model
|
||||
}])
|
||||
.with_admin_provider_models(vec![sample_admin_provider_model(
|
||||
"model-gpt-5",
|
||||
&provider_id,
|
||||
@@ -537,9 +802,10 @@ async fn gateway_handles_admin_system_config_export_locally_with_trusted_admin_p
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["version"], "2.2");
|
||||
assert_eq!(payload["version"], "2.3");
|
||||
assert!(payload["exported_at"].as_str().is_some());
|
||||
assert_eq!(payload["global_models"][0]["name"], "gpt-5");
|
||||
assert_eq!(payload["global_models"][0]["usage_count"], json!(7));
|
||||
assert_eq!(payload["providers"][0]["name"], "openai");
|
||||
assert_eq!(
|
||||
payload["providers"][0]["config"]["provider_ops"]["connector"]["credentials"]
|
||||
@@ -760,7 +1026,7 @@ async fn gateway_handles_admin_system_users_export_locally_with_trusted_admin_pr
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["version"], "1.4");
|
||||
assert_eq!(payload["version"], "1.5");
|
||||
assert!(payload["exported_at"].as_str().is_some());
|
||||
assert_eq!(payload["user_groups"][0]["name"], "Restricted GPT");
|
||||
assert!(payload["user_groups"][0].get("priority").is_none());
|
||||
@@ -778,6 +1044,9 @@ async fn gateway_handles_admin_system_users_export_locally_with_trusted_admin_pr
|
||||
payload["users"][0]["group_names"],
|
||||
json!(["Restricted GPT"])
|
||||
);
|
||||
assert_eq!(payload["users"][0]["id"], json!("user-1"));
|
||||
assert_eq!(payload["users"][0]["request_count"], json!(0));
|
||||
assert_eq!(payload["users"][0]["total_tokens"], json!(0));
|
||||
assert_eq!(payload["users"][0]["wallet"]["balance"], json!(12.5));
|
||||
assert_eq!(
|
||||
payload["users"][0]["wallet"]["recharge_balance"],
|
||||
@@ -798,6 +1067,10 @@ async fn gateway_handles_admin_system_users_export_locally_with_trusted_admin_pr
|
||||
payload["users"][0]["api_keys"][0]["is_standalone"],
|
||||
json!(false)
|
||||
);
|
||||
assert_eq!(
|
||||
payload["users"][0]["api_keys"][0]["api_key_id"],
|
||||
json!("key-user-1")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["users"][0]["api_keys"][0]["total_tokens"],
|
||||
json!(420)
|
||||
@@ -806,12 +1079,22 @@ async fn gateway_handles_admin_system_users_export_locally_with_trusted_admin_pr
|
||||
payload["standalone_keys"][0]["key"],
|
||||
json!("ak-standalone-live-1")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["standalone_keys"][0]["api_key_id"],
|
||||
json!("key-standalone-1")
|
||||
);
|
||||
assert_eq!(payload["standalone_keys"][0]["total_tokens"], json!(84));
|
||||
assert_eq!(
|
||||
payload["standalone_keys"][0]["wallet"]["unlimited"],
|
||||
json!(true)
|
||||
);
|
||||
assert_eq!(payload["standalone_keys"][0].get("is_standalone"), None,);
|
||||
assert_eq!(payload["usage_aggregates"]["stats_daily"], json!([]));
|
||||
assert_eq!(payload["usage_aggregates"]["stats_user_daily"], json!([]));
|
||||
assert_eq!(
|
||||
payload["usage_aggregates"]["stats_daily_api_key"],
|
||||
json!([])
|
||||
);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
|
||||
@@ -4,7 +4,9 @@ use aether_contracts::ExecutionPlan;
|
||||
use aether_crypto::{
|
||||
decrypt_python_fernet_ciphertext, encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY,
|
||||
};
|
||||
use aether_data::repository::auth::InMemoryAuthApiKeySnapshotRepository;
|
||||
use aether_data::repository::auth::{
|
||||
InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeyExportRecord, StoredAuthApiKeySnapshot,
|
||||
};
|
||||
use aether_data::repository::auth_modules::{
|
||||
AuthModuleReadRepository, InMemoryAuthModuleReadRepository, StoredOAuthProviderModuleConfig,
|
||||
};
|
||||
@@ -27,7 +29,7 @@ use axum::{extract::Request, Json, Router};
|
||||
use http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::super::helpers::{sample_endpoint, sample_key, sample_provider};
|
||||
use super::super::helpers::{hash_api_key, sample_endpoint, sample_key, sample_provider};
|
||||
use super::super::{
|
||||
build_router_with_state, build_state_with_execution_runtime_override, start_server, AppState,
|
||||
};
|
||||
@@ -79,6 +81,7 @@ fn sample_system_import_payload() -> Value {
|
||||
"global_models": [{
|
||||
"name": "gpt-5",
|
||||
"display_name": "GPT 5",
|
||||
"usage_count": 123,
|
||||
"default_price_per_request": 0.03,
|
||||
"default_tiered_pricing": {
|
||||
"tiers": [{
|
||||
@@ -122,6 +125,14 @@ fn sample_system_import_payload() -> Value {
|
||||
"name": "primary",
|
||||
"api_formats": ["openai:chat"],
|
||||
"auth_type": "api_key",
|
||||
"auth_type_by_format": {
|
||||
"openai:chat": "api_key",
|
||||
"openai:video": "bearer"
|
||||
},
|
||||
"allow_auth_channel_mismatch_formats": [
|
||||
"openai:chat",
|
||||
"openai:video"
|
||||
],
|
||||
"api_key": "sk-import-123",
|
||||
"internal_priority": 5,
|
||||
"is_active": true
|
||||
@@ -335,6 +346,7 @@ async fn gateway_imports_admin_system_config_locally_and_persists_data() {
|
||||
.expect("global models should load");
|
||||
assert_eq!(global_models.items.len(), 1);
|
||||
assert_eq!(global_models.items[0].name, "gpt-5");
|
||||
assert_eq!(global_models.items[0].usage_count, 123);
|
||||
|
||||
let providers = provider_catalog_repository
|
||||
.list_providers(false)
|
||||
@@ -371,6 +383,15 @@ async fn gateway_imports_admin_system_config_locally_and_persists_data() {
|
||||
.expect("api key should decrypt"),
|
||||
"sk-import-123"
|
||||
);
|
||||
assert_eq!(keys[0].api_formats, Some(json!(["openai:chat"])));
|
||||
assert_eq!(
|
||||
keys[0].auth_type_by_format,
|
||||
Some(json!({ "openai:chat": "api_key" }))
|
||||
);
|
||||
assert_eq!(
|
||||
keys[0].allow_auth_channel_mismatch_formats,
|
||||
Some(json!(["openai:chat"]))
|
||||
);
|
||||
|
||||
let provider_models = global_model_repository
|
||||
.list_admin_provider_models(&AdminProviderModelListQuery {
|
||||
@@ -817,7 +838,7 @@ async fn gateway_rejects_unknown_admin_system_config_import_versions() {
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
for version in ["1.9", "2.3"] {
|
||||
for version in ["1.9", "2.4"] {
|
||||
let response = client
|
||||
.post(format!("{gateway_url}/api/admin/system/config/import"))
|
||||
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||
@@ -840,7 +861,7 @@ async fn gateway_rejects_unknown_admin_system_config_import_versions() {
|
||||
.as_str()
|
||||
.expect("detail should be a string");
|
||||
assert!(detail.contains(&format!("不支持的配置版本: {version}")));
|
||||
assert!(detail.contains("支持的版本: 2.0, 2.1, 2.2"));
|
||||
assert!(detail.contains("支持的版本: 2.0, 2.1, 2.2, 2.3"));
|
||||
}
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -1052,6 +1073,7 @@ async fn gateway_imports_admin_system_users_locally_and_persists_data() {
|
||||
.expect("user api keys should load");
|
||||
assert_eq!(user_api_keys.len(), 1);
|
||||
assert_eq!(user_api_keys[0].name.as_deref(), Some("Alice CLI"));
|
||||
assert_eq!(user_api_keys[0].total_requests, 12);
|
||||
assert_eq!(user_api_keys[0].total_tokens, 3456);
|
||||
assert_eq!(user_api_keys[0].total_cost_usd, 1.25);
|
||||
assert_eq!(
|
||||
@@ -1079,6 +1101,7 @@ async fn gateway_imports_admin_system_users_locally_and_persists_data() {
|
||||
standalone_keys[0].name.as_deref(),
|
||||
Some("Imported Standalone")
|
||||
);
|
||||
assert_eq!(standalone_keys[0].total_requests, 3);
|
||||
assert_eq!(standalone_keys[0].total_tokens, 789);
|
||||
assert_eq!(standalone_keys[0].total_cost_usd, 0.75);
|
||||
assert_eq!(
|
||||
@@ -1119,6 +1142,211 @@ async fn gateway_imports_admin_system_users_locally_and_persists_data() {
|
||||
let _ = upstream_url;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_overwrites_existing_admin_system_user_key_usage_totals() {
|
||||
let user_key_hash = hash_api_key("sk-existing-user-key");
|
||||
let standalone_key_hash = hash_api_key("sk-existing-standalone-key");
|
||||
let existing_user = StoredUserAuthRecord::new(
|
||||
"user-existing".to_string(),
|
||||
Some("[email protected]".to_string()),
|
||||
true,
|
||||
"existing".to_string(),
|
||||
Some("existing-hash".to_string()),
|
||||
"user".to_string(),
|
||||
"local".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
Some(chrono::Utc::now()),
|
||||
Some(chrono::Utc::now()),
|
||||
)
|
||||
.expect("existing user should build");
|
||||
let user_key_snapshot = StoredAuthApiKeySnapshot::new(
|
||||
"user-existing".to_string(),
|
||||
"existing".to_string(),
|
||||
Some("[email protected]".to_string()),
|
||||
"user".to_string(),
|
||||
"local".to_string(),
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"key-user-existing".to_string(),
|
||||
Some("Existing User Key".to_string()),
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
Some(10),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("user key snapshot should build");
|
||||
let standalone_key_snapshot = StoredAuthApiKeySnapshot::new(
|
||||
"admin-user-123".to_string(),
|
||||
"admin".to_string(),
|
||||
Some("[email protected]".to_string()),
|
||||
"admin".to_string(),
|
||||
"local".to_string(),
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"key-standalone-existing".to_string(),
|
||||
Some("Existing Standalone Key".to_string()),
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
Some(20),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("standalone key snapshot should build");
|
||||
let auth_repository = Arc::new(
|
||||
InMemoryAuthApiKeySnapshotRepository::seed(vec![
|
||||
(Some(user_key_hash.clone()), user_key_snapshot),
|
||||
(Some(standalone_key_hash.clone()), standalone_key_snapshot),
|
||||
])
|
||||
.with_export_records(vec![
|
||||
StoredAuthApiKeyExportRecord::new(
|
||||
"user-existing".to_string(),
|
||||
"key-user-existing".to_string(),
|
||||
user_key_hash.clone(),
|
||||
None,
|
||||
Some("Existing User Key".to_string()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(10),
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
false,
|
||||
1,
|
||||
2,
|
||||
0.03,
|
||||
false,
|
||||
)
|
||||
.expect("existing user key export should build"),
|
||||
StoredAuthApiKeyExportRecord::new(
|
||||
"admin-user-123".to_string(),
|
||||
"key-standalone-existing".to_string(),
|
||||
standalone_key_hash.clone(),
|
||||
None,
|
||||
Some("Existing Standalone Key".to_string()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(20),
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
false,
|
||||
4,
|
||||
5,
|
||||
0.06,
|
||||
true,
|
||||
)
|
||||
.expect("existing standalone key export should build"),
|
||||
]),
|
||||
);
|
||||
let user_repository =
|
||||
Arc::new(aether_data::repository::users::InMemoryUserReadRepository::default());
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_auth_api_key_repository_for_tests(Arc::clone(&auth_repository))
|
||||
.with_user_reader(user_repository)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
)
|
||||
.with_auth_users_for_tests([sample_import_admin_user("admin-user-123"), existing_user])
|
||||
.with_auth_wallets_for_tests(Vec::<StoredWalletSnapshot>::new());
|
||||
let gateway = build_router_with_state(state.clone());
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/system/users/import"))
|
||||
.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")
|
||||
.json(&json!({
|
||||
"version": "1.4",
|
||||
"merge_mode": "overwrite",
|
||||
"users": [{
|
||||
"id": "source-user-existing",
|
||||
"email": "[email protected]",
|
||||
"username": "existing",
|
||||
"password_hash": "existing-hash",
|
||||
"role": "user",
|
||||
"is_active": true,
|
||||
"api_keys": [{
|
||||
"api_key_id": "source-user-key",
|
||||
"key_hash": user_key_hash,
|
||||
"name": "Imported User Key",
|
||||
"is_active": true,
|
||||
"total_requests": 222,
|
||||
"total_tokens": 3333,
|
||||
"total_cost_usd": 4.56
|
||||
}]
|
||||
}],
|
||||
"standalone_keys": [{
|
||||
"api_key_id": "source-standalone-key",
|
||||
"key_hash": standalone_key_hash,
|
||||
"name": "Imported Standalone Key",
|
||||
"is_active": true,
|
||||
"total_requests": 444,
|
||||
"total_tokens": 5555,
|
||||
"total_cost_usd": 6.78
|
||||
}]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
let status = response.status();
|
||||
let payload: Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
||||
assert_eq!(payload["stats"]["users"]["updated"], json!(1));
|
||||
assert_eq!(payload["stats"]["api_keys"]["updated"], json!(1));
|
||||
assert_eq!(payload["stats"]["standalone_keys"]["updated"], json!(1));
|
||||
|
||||
let updated_records = state
|
||||
.list_auth_api_key_export_records_by_ids(&[
|
||||
"key-user-existing".to_string(),
|
||||
"key-standalone-existing".to_string(),
|
||||
])
|
||||
.await
|
||||
.expect("api key export records should load");
|
||||
let user_key = updated_records
|
||||
.iter()
|
||||
.find(|record| record.api_key_id == "key-user-existing")
|
||||
.expect("updated user key should exist");
|
||||
assert_eq!(user_key.total_requests, 222);
|
||||
assert_eq!(user_key.total_tokens, 3333);
|
||||
assert_eq!(user_key.total_cost_usd, 4.56);
|
||||
let standalone_key = updated_records
|
||||
.iter()
|
||||
.find(|record| record.api_key_id == "key-standalone-existing")
|
||||
.expect("updated standalone key should exist");
|
||||
assert_eq!(standalone_key.total_requests, 444);
|
||||
assert_eq!(standalone_key.total_tokens, 5555);
|
||||
assert_eq!(standalone_key.total_cost_usd, 6.78);
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_imports_admin_system_config_fixture_v22() {
|
||||
let gateway = build_router_with_state(
|
||||
@@ -1671,33 +1899,20 @@ async fn gateway_overwrites_oauth_provider_key_credentials_from_admin_system_imp
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_overwrites_oauth_provider_key_credentials_from_admin_system_import_and_forces_refresh(
|
||||
async fn gateway_overwrites_oauth_provider_key_credentials_from_admin_system_import_without_refresh(
|
||||
) {
|
||||
#[derive(Debug, Clone)]
|
||||
struct SeenRefreshRequest {
|
||||
content_type: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
let seen_refresh = Arc::new(Mutex::new(None::<SeenRefreshRequest>));
|
||||
let seen_refresh = Arc::new(Mutex::new(false));
|
||||
let seen_refresh_clone = Arc::clone(&seen_refresh);
|
||||
let refresh_hits = Arc::new(Mutex::new(0usize));
|
||||
let refresh_hits_clone = Arc::clone(&refresh_hits);
|
||||
let refresh_server = Router::new().route(
|
||||
"/oauth/token",
|
||||
post(move |headers: HeaderMap, body: Bytes| {
|
||||
post(move |_headers: HeaderMap, _body: Bytes| {
|
||||
let seen_refresh_inner = Arc::clone(&seen_refresh_clone);
|
||||
let refresh_hits_inner = Arc::clone(&refresh_hits_clone);
|
||||
async move {
|
||||
*refresh_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
*seen_refresh_inner.lock().expect("mutex should lock") = Some(SeenRefreshRequest {
|
||||
content_type: headers
|
||||
.get(http::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
body: String::from_utf8(body.to_vec()).unwrap_or_default(),
|
||||
});
|
||||
*seen_refresh_inner.lock().expect("mutex should lock") = true;
|
||||
axum::Json(json!({
|
||||
"access_token": "oauth-access-token-refreshed",
|
||||
"refresh_token": "oauth-refresh-token-refreshed",
|
||||
@@ -1792,21 +2007,8 @@ async fn gateway_overwrites_oauth_provider_key_credentials_from_admin_system_imp
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(*refresh_hits.lock().expect("mutex should lock"), 1);
|
||||
|
||||
let seen_refresh = seen_refresh
|
||||
.lock()
|
||||
.expect("mutex should lock")
|
||||
.clone()
|
||||
.expect("refresh request should be captured");
|
||||
assert_eq!(
|
||||
seen_refresh.content_type,
|
||||
"application/x-www-form-urlencoded"
|
||||
);
|
||||
assert!(seen_refresh.body.contains("grant_type=refresh_token"));
|
||||
assert!(seen_refresh
|
||||
.body
|
||||
.contains("refresh_token=oauth-refresh-token-new"));
|
||||
assert_eq!(*refresh_hits.lock().expect("mutex should lock"), 0);
|
||||
assert!(!*seen_refresh.lock().expect("mutex should lock"));
|
||||
|
||||
let providers = provider_catalog_repository
|
||||
.list_providers(false)
|
||||
@@ -1823,7 +2025,7 @@ async fn gateway_overwrites_oauth_provider_key_credentials_from_admin_system_imp
|
||||
assert_eq!(key.name, "oauth-primary");
|
||||
assert_eq!(key.oauth_invalid_at_unix_secs, None);
|
||||
assert_eq!(key.oauth_invalid_reason, None);
|
||||
assert!(key.expires_at_unix_secs.is_some());
|
||||
assert_eq!(key.expires_at_unix_secs, None);
|
||||
assert_eq!(
|
||||
decrypt_python_fernet_ciphertext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
@@ -1832,7 +2034,7 @@ async fn gateway_overwrites_oauth_provider_key_credentials_from_admin_system_imp
|
||||
.expect("api key should be present"),
|
||||
)
|
||||
.expect("oauth access token should decrypt"),
|
||||
"oauth-access-token-refreshed"
|
||||
"oauth-access-token-new"
|
||||
);
|
||||
|
||||
let auth_config = decrypt_python_fernet_ciphertext(
|
||||
@@ -1845,15 +2047,12 @@ async fn gateway_overwrites_oauth_provider_key_credentials_from_admin_system_imp
|
||||
let auth_config: Value =
|
||||
serde_json::from_str(&auth_config).expect("oauth auth config json should parse");
|
||||
assert_eq!(auth_config["provider_type"], "codex");
|
||||
assert_eq!(
|
||||
auth_config["refresh_token"],
|
||||
"oauth-refresh-token-refreshed"
|
||||
);
|
||||
assert_eq!(auth_config["refresh_token"], "oauth-refresh-token-new");
|
||||
assert_eq!(auth_config["email"], "[email protected]");
|
||||
assert_eq!(auth_config["account_id"], "acct-codex-123");
|
||||
assert_eq!(auth_config["plan_type"], "plus");
|
||||
assert_eq!(auth_config["token_type"], "Bearer");
|
||||
assert_eq!(auth_config["expires_at"].as_u64(), key.expires_at_unix_secs);
|
||||
assert!(auth_config.get("token_type").is_none());
|
||||
assert!(auth_config.get("expires_at").is_none());
|
||||
|
||||
gateway_handle.abort();
|
||||
refresh_handle.abort();
|
||||
|
||||
@@ -15,7 +15,7 @@ use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
|
||||
use axum::body::Body;
|
||||
use axum::routing::{any, delete, get, patch, post, put};
|
||||
use axum::{extract::Request, Router};
|
||||
use chrono::Utc;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use http::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -37,6 +37,16 @@ fn sample_admin_user_with_role(
|
||||
role: &str,
|
||||
email: &str,
|
||||
username: &str,
|
||||
) -> StoredUserAuthRecord {
|
||||
sample_admin_user_with_role_and_created_at(user_id, role, email, username, Utc::now())
|
||||
}
|
||||
|
||||
fn sample_admin_user_with_role_and_created_at(
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
email: &str,
|
||||
username: &str,
|
||||
created_at: chrono::DateTime<Utc>,
|
||||
) -> StoredUserAuthRecord {
|
||||
StoredUserAuthRecord::new(
|
||||
user_id.to_string(),
|
||||
@@ -51,7 +61,7 @@ fn sample_admin_user_with_role(
|
||||
Some(json!(["gpt-4.1"])),
|
||||
true,
|
||||
false,
|
||||
Some(Utc::now()),
|
||||
Some(created_at),
|
||||
Some(Utc::now()),
|
||||
)
|
||||
.expect("user should build")
|
||||
@@ -206,6 +216,113 @@ fn sample_admin_api_key_snapshot(user_id: &str, api_key_id: &str) -> StoredAuthA
|
||||
.expect("api key snapshot should build")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_sorts_admin_users_by_created_at() {
|
||||
let oldest = Utc
|
||||
.with_ymd_and_hms(2026, 1, 10, 0, 0, 0)
|
||||
.single()
|
||||
.expect("valid timestamp");
|
||||
let middle = Utc
|
||||
.with_ymd_and_hms(2026, 2, 10, 0, 0, 0)
|
||||
.single()
|
||||
.expect("valid timestamp");
|
||||
let newest = Utc
|
||||
.with_ymd_and_hms(2026, 3, 10, 0, 0, 0)
|
||||
.single()
|
||||
.expect("valid timestamp");
|
||||
|
||||
let user_repository = Arc::new(
|
||||
InMemoryUserReadRepository::seed_auth_users(vec![
|
||||
sample_admin_user_with_role_and_created_at(
|
||||
"user-old",
|
||||
"user",
|
||||
"[email protected]",
|
||||
"old",
|
||||
oldest,
|
||||
),
|
||||
sample_admin_user_with_role_and_created_at(
|
||||
"user-middle",
|
||||
"user",
|
||||
"[email protected]",
|
||||
"middle",
|
||||
middle,
|
||||
),
|
||||
sample_admin_user_with_role_and_created_at(
|
||||
"user-new",
|
||||
"user",
|
||||
"[email protected]",
|
||||
"new",
|
||||
newest,
|
||||
),
|
||||
])
|
||||
.with_export_users(vec![
|
||||
sample_admin_export_user_with("user", true, "user-old", "[email protected]", "old"),
|
||||
sample_admin_export_user_with(
|
||||
"user",
|
||||
true,
|
||||
"user-middle",
|
||||
"[email protected]",
|
||||
"middle",
|
||||
),
|
||||
sample_admin_export_user_with("user", true, "user-new", "[email protected]", "new"),
|
||||
]),
|
||||
);
|
||||
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(GatewayDataState::with_user_reader_for_tests(
|
||||
user_repository,
|
||||
)),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let desc_response = client
|
||||
.get(format!(
|
||||
"{gateway_url}/api/admin/users?skip=0&limit=10&sort_by=created_at&sort_order=desc"
|
||||
))
|
||||
.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!(desc_response.status(), StatusCode::OK);
|
||||
let desc_payload: serde_json::Value = desc_response.json().await.expect("json should parse");
|
||||
let desc_ids = desc_payload["items"]
|
||||
.as_array()
|
||||
.expect("items should be array")
|
||||
.iter()
|
||||
.map(|item| item["id"].as_str().expect("id should be string"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(desc_ids, vec!["user-new", "user-middle", "user-old"]);
|
||||
|
||||
let asc_response = client
|
||||
.get(format!(
|
||||
"{gateway_url}/api/admin/users?skip=0&limit=10&sort_by=created_at&sort_order=asc"
|
||||
))
|
||||
.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!(asc_response.status(), StatusCode::OK);
|
||||
let asc_payload: serde_json::Value = asc_response.json().await.expect("json should parse");
|
||||
let asc_ids = asc_payload["items"]
|
||||
.as_array()
|
||||
.expect("items should be array")
|
||||
.iter()
|
||||
.map(|item| item["id"].as_str().expect("id should be string"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(asc_ids, vec!["user-old", "user-middle", "user-new"]);
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_users_root_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
@@ -1698,6 +1815,83 @@ async fn gateway_returns_conflict_for_admin_lock_user_api_key_when_writer_unavai
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_allows_admin_update_user_to_clear_explicit_groups() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().fallback(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 user_repository = Arc::new(
|
||||
InMemoryUserReadRepository::seed_auth_users(vec![sample_admin_user("user-1")])
|
||||
.with_export_users(vec![sample_admin_export_user("user-1")]),
|
||||
);
|
||||
let default_group = user_repository
|
||||
.create_user_group(UpsertUserGroupRecord {
|
||||
name: "GPT Adapt".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: "unrestricted".to_string(),
|
||||
rate_limit: None,
|
||||
rate_limit_mode: "system".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("default group should create")
|
||||
.expect("default group should exist");
|
||||
user_repository
|
||||
.add_user_to_group(&default_group.id, "user-1")
|
||||
.await
|
||||
.expect("default membership should create");
|
||||
|
||||
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_user_reader_for_tests(user_repository.clone())
|
||||
.with_system_config_values_for_tests(vec![(
|
||||
crate::constants::DEFAULT_USER_GROUP_CONFIG_KEY.to_string(),
|
||||
json!(default_group.id),
|
||||
)]),
|
||||
),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.put(format!("{gateway_url}/api/admin/users/user-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")
|
||||
.json(&json!({ "group_ids": [] }))
|
||||
.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["groups"], json!([]));
|
||||
assert!(user_repository
|
||||
.list_user_groups_for_user("user-1")
|
||||
.await
|
||||
.expect("memberships should load")
|
||||
.is_empty());
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_returns_conflict_for_admin_update_user_when_writer_unavailable() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
|
||||
Reference in New Issue
Block a user