mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
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.
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
||||
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
|
||||
use aether_data::repository::auth::{
|
||||
InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeyExportRecord, StoredAuthApiKeySnapshot,
|
||||
AuthApiKeyWriteRepository, InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeyExportRecord,
|
||||
StoredAuthApiKeySnapshot,
|
||||
};
|
||||
use aether_data::repository::usage::InMemoryUsageReadRepository;
|
||||
use aether_data::repository::wallet::{InMemoryWalletRepository, StoredWalletSnapshot};
|
||||
@@ -12,6 +13,7 @@ use axum::routing::any;
|
||||
use axum::{extract::Request, Router};
|
||||
use http::StatusCode;
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::super::{build_router_with_state, start_server, AppState};
|
||||
use crate::constants::{
|
||||
@@ -28,6 +30,12 @@ fn admin_request(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
}
|
||||
|
||||
fn hash_api_key(value: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(value.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
async fn start_api_keys_upstream(
|
||||
path: &'static str,
|
||||
) -> (String, Arc<Mutex<usize>>, tokio::task::JoinHandle<()>) {
|
||||
@@ -85,14 +93,26 @@ fn sample_standalone_export_record(
|
||||
plaintext_key: &str,
|
||||
is_active: bool,
|
||||
) -> StoredAuthApiKeyExportRecord {
|
||||
let key_hash = hash_api_key(plaintext_key);
|
||||
let bootstrap = AppState::new()
|
||||
.expect("bootstrap state should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::disabled().with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
);
|
||||
let key_encrypted = crate::handlers::shared::seal_auth_api_key_secret(
|
||||
&bootstrap,
|
||||
user_id,
|
||||
api_key_id,
|
||||
&key_hash,
|
||||
true,
|
||||
plaintext_key,
|
||||
)
|
||||
.expect("key should encrypt");
|
||||
let mut record = StoredAuthApiKeyExportRecord::new(
|
||||
user_id.to_string(),
|
||||
api_key_id.to_string(),
|
||||
format!("hash-{api_key_id}"),
|
||||
Some(
|
||||
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, plaintext_key)
|
||||
.expect("key should encrypt"),
|
||||
),
|
||||
key_hash,
|
||||
Some(key_encrypted),
|
||||
Some(format!("key-{api_key_id}")),
|
||||
Some(json!(["openai"])),
|
||||
Some(json!(["openai:chat"])),
|
||||
@@ -240,10 +260,7 @@ async fn gateway_handles_admin_api_keys_list_locally_with_trusted_admin_principa
|
||||
assert_eq!(payload["skip"], json!(0));
|
||||
assert_eq!(payload["api_keys"][0]["id"], json!("key-1"));
|
||||
assert_eq!(payload["api_keys"][0]["is_standalone"], json!(true));
|
||||
assert_eq!(
|
||||
payload["api_keys"][0]["key_display"],
|
||||
json!("sk-key-1-p...text")
|
||||
);
|
||||
assert_eq!(payload["api_keys"][0]["key_display"], json!("sk-ke...text"));
|
||||
assert_eq!(payload["api_keys"][0]["total_requests"], json!(7));
|
||||
assert_eq!(payload["api_keys"][0]["total_tokens"], json!(0));
|
||||
assert_eq!(
|
||||
@@ -317,7 +334,7 @@ async fn gateway_handles_admin_api_keys_detail_locally_with_trusted_admin_princi
|
||||
assert_eq!(payload["wallet"]["id"], json!("wallet-key-1"));
|
||||
assert_eq!(payload["wallet"]["unlimited"], json!(true));
|
||||
assert_eq!(payload["wallet"]["balance"], json!(20.0));
|
||||
assert_eq!(payload["key_display"], json!("sk-key-1-p...text"));
|
||||
assert_eq!(payload["key_display"], json!("sk-ke...text"));
|
||||
assert_eq!(payload["total_tokens"], json!(77));
|
||||
assert_eq!(payload["created_at"], json!("2024-03-21T05:48:20+00:00"));
|
||||
assert_eq!(payload["last_used_at"], json!("2024-03-21T05:48:22+00:00"));
|
||||
@@ -442,8 +459,10 @@ async fn gateway_handles_admin_api_key_install_session_locally_with_trusted_admi
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_auth_api_key_repository_for_tests(auth_repository)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
GatewayDataState::with_auth_api_key_repository_for_tests(Arc::clone(
|
||||
&auth_repository,
|
||||
))
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
@@ -479,15 +498,78 @@ async fn gateway_handles_admin_api_key_install_session_locally_with_trusted_admi
|
||||
assert_eq!(
|
||||
payload["unix_command"],
|
||||
json!(format!(
|
||||
"curl -fsSL https://aether.example/install/{install_code} | sh"
|
||||
"curl -fsSL 'https://aether.example/install/{install_code}' | sh"
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
payload["powershell_command"],
|
||||
json!(format!(
|
||||
"irm https://aether.example/install/{install_code}.ps1 | iex"
|
||||
"irm 'https://aether.example/install/{install_code}.ps1' | iex"
|
||||
))
|
||||
);
|
||||
|
||||
let second_response = admin_request(reqwest::Client::new().post(format!(
|
||||
"{gateway_url}/api/admin/api-keys/key-1/install-sessions"
|
||||
)))
|
||||
.header("x-forwarded-host", "aether.example")
|
||||
.header("x-forwarded-proto", "https")
|
||||
.json(&json!({
|
||||
"target_cli": "codex_cli",
|
||||
"target_system": "linux",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("second install session should be created");
|
||||
assert_eq!(second_response.status(), StatusCode::OK);
|
||||
let second_payload: serde_json::Value = second_response
|
||||
.json()
|
||||
.await
|
||||
.expect("second install session response should parse");
|
||||
let second_install_code = second_payload["install_code"]
|
||||
.as_str()
|
||||
.expect("second install code should be returned");
|
||||
|
||||
let script_response = reqwest::Client::new()
|
||||
.get(format!("{gateway_url}/install/{install_code}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("install script should resolve");
|
||||
assert_eq!(script_response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
script_response
|
||||
.headers()
|
||||
.get("cache-control")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("no-store")
|
||||
);
|
||||
assert!(script_response
|
||||
.text()
|
||||
.await
|
||||
.expect("install script should be readable")
|
||||
.contains("sk-key-1-plaintext"));
|
||||
|
||||
let replay = reqwest::Client::new()
|
||||
.get(format!("{gateway_url}/install/{install_code}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("install replay should receive a response");
|
||||
assert_eq!(replay.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
assert!(auth_repository
|
||||
.delete_standalone_api_key("key-1")
|
||||
.await
|
||||
.expect("test API key deletion should succeed"));
|
||||
let deleted_key_session = reqwest::Client::new()
|
||||
.get(format!("{gateway_url}/install/{second_install_code}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("deleted-key install session should receive a response");
|
||||
assert_eq!(deleted_key_session.status(), StatusCode::NOT_FOUND);
|
||||
assert!(!deleted_key_session
|
||||
.text()
|
||||
.await
|
||||
.expect("deleted-key response should be readable")
|
||||
.contains("sk-key-1-plaintext"));
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use aether_contracts::ExecutionPlan;
|
||||
use aether_crypto::{
|
||||
decrypt_python_fernet_ciphertext, encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY,
|
||||
};
|
||||
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
ProviderCatalogKeyListQuery, ProviderCatalogReadRepository, StoredProviderCatalogEndpoint,
|
||||
@@ -18,8 +16,8 @@ use http::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::super::{
|
||||
build_router_with_state, build_state_with_execution_runtime_override, sample_endpoint,
|
||||
sample_key, sample_provider, start_server, AppState,
|
||||
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,
|
||||
@@ -29,6 +27,18 @@ 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,
|
||||
@@ -172,7 +182,7 @@ async fn gateway_handles_admin_provider_keys_locally_with_trusted_admin_principa
|
||||
}),
|
||||
);
|
||||
|
||||
let mut key_a = sample_key(
|
||||
let mut key_a = sample_bound_key(
|
||||
"key-openai-a",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -193,7 +203,7 @@ async fn gateway_handles_admin_provider_keys_locally_with_trusted_admin_principa
|
||||
"quota": {"code": "unknown", "exhausted": false}
|
||||
}));
|
||||
|
||||
let mut key_b = sample_key(
|
||||
let mut key_b = sample_bound_key(
|
||||
"key-openai-b",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -245,7 +255,7 @@ async fn gateway_handles_admin_provider_keys_locally_with_trusted_admin_principa
|
||||
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-test-a***");
|
||||
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);
|
||||
|
||||
@@ -255,25 +265,26 @@ async fn gateway_handles_admin_provider_keys_locally_with_trusted_admin_principa
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_provider_keys_expose_circuit_breaker_and_recover_clears_it() {
|
||||
let key = sample_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 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(
|
||||
@@ -369,7 +380,7 @@ async fn gateway_handles_admin_provider_keys_page_locally_with_total() {
|
||||
}),
|
||||
);
|
||||
|
||||
let mut key_a = sample_key(
|
||||
let mut key_a = sample_bound_key(
|
||||
"key-openai-a",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -378,7 +389,7 @@ async fn gateway_handles_admin_provider_keys_page_locally_with_total() {
|
||||
key_a.internal_priority = 10;
|
||||
key_a.created_at_unix_ms = Some(1_711_000_000);
|
||||
|
||||
let mut key_b = sample_key(
|
||||
let mut key_b = sample_bound_key(
|
||||
"key-openai-b",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -387,7 +398,7 @@ async fn gateway_handles_admin_provider_keys_page_locally_with_total() {
|
||||
key_b.internal_priority = 20;
|
||||
key_b.created_at_unix_ms = Some(1_711_100_000);
|
||||
|
||||
let mut key_c = sample_key(
|
||||
let mut key_c = sample_bound_key(
|
||||
"key-openai-c",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -455,24 +466,22 @@ async fn gateway_admin_provider_keys_prefers_upstream_plan_type_over_auth_config
|
||||
|
||||
let mut provider = sample_provider("provider-codex", "codex", 10);
|
||||
provider.provider_type = "codex".to_string();
|
||||
let mut key = sample_key(
|
||||
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(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
&json!({
|
||||
"plan_type": "free",
|
||||
"account_id": "acct-codex-legacy"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
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",
|
||||
@@ -539,20 +548,18 @@ async fn gateway_admin_provider_keys_marks_oauth_header_auth() {
|
||||
|
||||
let mut provider = sample_provider("provider-codex", "codex", 10);
|
||||
provider.provider_type = "codex".to_string();
|
||||
let mut key = sample_key(
|
||||
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(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"codex","headers":{"authorization":"Bearer imported-session-token"}}"#,
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
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],
|
||||
@@ -657,7 +664,7 @@ async fn gateway_creates_admin_provider_key_locally_with_trusted_admin_principal
|
||||
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-creat***enai");
|
||||
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);
|
||||
@@ -682,7 +689,7 @@ 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_key(
|
||||
vec![sample_bound_key(
|
||||
"key-codex-existing",
|
||||
"provider-codex",
|
||||
"openai:responses",
|
||||
@@ -776,20 +783,18 @@ async fn generic_key_routes_reject_agent_identity_credential_writes() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn generic_codex_key_credential_switch_rotates_generation_and_clears_quota() {
|
||||
let mut existing_key = sample_key(
|
||||
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(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"codex","refresh_token":"old-refresh-token"}"#,
|
||||
)
|
||||
.expect("old auth config should encrypt"),
|
||||
);
|
||||
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",
|
||||
@@ -1038,7 +1043,7 @@ async fn provider_key_concurrent_limit_create_and_list_responses() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_key_concurrent_limit_reads_existing_list_response() {
|
||||
let mut key_a = sample_key(
|
||||
let mut key_a = sample_bound_key(
|
||||
"provider-key-a",
|
||||
"test-provider-a",
|
||||
"openai:chat",
|
||||
@@ -1046,7 +1051,7 @@ async fn provider_key_concurrent_limit_reads_existing_list_response() {
|
||||
);
|
||||
key_a.concurrent_limit = Some(1);
|
||||
|
||||
let mut key_b = sample_key(
|
||||
let mut key_b = sample_bound_key(
|
||||
"provider-key-b",
|
||||
"test-provider-a",
|
||||
"openai:chat",
|
||||
@@ -1232,7 +1237,7 @@ async fn gateway_reveals_admin_provider_key_locally_with_trusted_admin_principal
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider("provider-openai", "openai", 10)],
|
||||
vec![],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-openai-a",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -1290,20 +1295,18 @@ async fn gateway_exports_admin_provider_key_locally_with_trusted_admin_principal
|
||||
}),
|
||||
);
|
||||
|
||||
let mut key = sample_key(
|
||||
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(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"kiro","auth_method":"idc","refresh_token":"rt-kiro-123"}"#,
|
||||
)
|
||||
.expect("auth config ciphertext should build"),
|
||||
);
|
||||
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(
|
||||
@@ -1366,20 +1369,18 @@ async fn gateway_exports_admin_provider_key_access_token_when_refresh_token_is_m
|
||||
}),
|
||||
);
|
||||
|
||||
let mut key = sample_key(
|
||||
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(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"codex","email":"[email protected]","updated_at":1710000000}"#,
|
||||
)
|
||||
.expect("auth config ciphertext should build"),
|
||||
);
|
||||
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)],
|
||||
@@ -1440,20 +1441,18 @@ async fn gateway_export_does_not_emit_access_token_from_imported_authorization_h
|
||||
}),
|
||||
);
|
||||
|
||||
let mut key = sample_key(
|
||||
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(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"codex","email":"[email protected]","headers":{"authorization":"Bearer imported-session-token"}}"#,
|
||||
)
|
||||
.expect("auth config ciphertext should build"),
|
||||
);
|
||||
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)],
|
||||
@@ -1516,20 +1515,18 @@ async fn gateway_export_preserves_distinct_imported_access_token_with_authorizat
|
||||
}),
|
||||
);
|
||||
|
||||
let mut key = sample_key(
|
||||
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(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"codex","email":"[email protected]","access_token":"jwt-access-token","headers":{"authorization":"Bearer imported-session-token"}}"#,
|
||||
)
|
||||
.expect("auth config ciphertext should build"),
|
||||
);
|
||||
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)],
|
||||
@@ -1581,27 +1578,25 @@ async fn gateway_generic_export_rejects_agent_identity_without_exposing_private_
|
||||
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_key(
|
||||
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(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
&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(),
|
||||
)
|
||||
.expect("Agent Identity auth config should encrypt"),
|
||||
);
|
||||
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![],
|
||||
@@ -1706,7 +1701,7 @@ async fn gateway_clears_admin_provider_key_oauth_invalid_locally_with_trusted_ad
|
||||
}),
|
||||
);
|
||||
|
||||
let mut key = sample_key(
|
||||
let mut key = sample_bound_key(
|
||||
"key-openai-a",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -1829,7 +1824,7 @@ async fn gateway_noops_admin_provider_key_oauth_invalid_clear_when_marker_absent
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider("provider-openai", "openai", 10)],
|
||||
vec![],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-openai-a",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -1886,7 +1881,7 @@ async fn gateway_updates_admin_provider_key_locally_with_trusted_admin_principal
|
||||
}),
|
||||
);
|
||||
|
||||
let mut key = sample_key(
|
||||
let mut key = sample_bound_key(
|
||||
"key-openai-a",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -1961,14 +1956,7 @@ async fn gateway_updates_admin_provider_key_locally_with_trusted_admin_principal
|
||||
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 = decrypt_python_fernet_ciphertext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
reloaded[0]
|
||||
.encrypted_api_key
|
||||
.as_deref()
|
||||
.expect("api key should be present"),
|
||||
)
|
||||
.expect("ciphertext should decrypt");
|
||||
let decrypted = open_provider_catalog_api_key_for_test(&reloaded[0]);
|
||||
assert_eq!(decrypted, "sk-updated-openai");
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -1977,7 +1965,7 @@ async fn gateway_updates_admin_provider_key_locally_with_trusted_admin_principal
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_key_concurrent_limit_update_presence_semantics() {
|
||||
let mut key = sample_key(
|
||||
let mut key = sample_bound_key(
|
||||
"key-openai-a",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -2123,7 +2111,7 @@ async fn gateway_clears_allowed_models_when_disabling_auto_fetch_on_provider_key
|
||||
}),
|
||||
);
|
||||
|
||||
let mut key = sample_key(
|
||||
let mut key = sample_bound_key(
|
||||
"key-openai-a",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -2229,7 +2217,7 @@ async fn gateway_overwrites_allowed_models_immediately_when_enabling_auto_fetch_
|
||||
);
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
|
||||
let mut key = sample_key(
|
||||
let mut key = sample_bound_key(
|
||||
"key-openai-a",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -2345,7 +2333,7 @@ async fn gateway_fetches_allowed_models_immediately_when_enabling_auto_fetch_fro
|
||||
);
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
|
||||
let mut key = sample_key(
|
||||
let mut key = sample_bound_key(
|
||||
"key-openai-a",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -2460,7 +2448,7 @@ async fn gateway_refreshes_allowed_models_when_updating_include_patterns_with_au
|
||||
);
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
|
||||
let mut key = sample_key(
|
||||
let mut key = sample_bound_key(
|
||||
"key-openai-a",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -2572,7 +2560,7 @@ async fn gateway_refreshes_allowed_models_when_updating_exclude_patterns_with_au
|
||||
);
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
|
||||
let mut key = sample_key(
|
||||
let mut key = sample_bound_key(
|
||||
"key-openai-a",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -2667,13 +2655,13 @@ async fn gateway_rejects_admin_provider_key_update_when_api_key_duplicates_exist
|
||||
vec![sample_provider("provider-openai", "openai", 10)],
|
||||
vec![],
|
||||
vec![
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-a",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"sk-test-a",
|
||||
),
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-b",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -2740,7 +2728,7 @@ async fn gateway_deletes_admin_provider_key_locally_with_trusted_admin_principal
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider("provider-openai", "openai", 10)],
|
||||
vec![],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-openai-a",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -2807,13 +2795,13 @@ async fn gateway_batch_deletes_admin_provider_keys_locally_with_trusted_admin_pr
|
||||
vec![sample_provider("provider-openai", "openai", 10)],
|
||||
vec![],
|
||||
vec![
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-a",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"sk-test-a",
|
||||
),
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-b",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -2884,7 +2872,7 @@ async fn gateway_handles_admin_keys_grouped_by_format_locally_with_trusted_admin
|
||||
}),
|
||||
);
|
||||
|
||||
let mut key_a = sample_key(
|
||||
let mut key_a = sample_bound_key(
|
||||
"key-openai-a",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -2900,7 +2888,7 @@ async fn gateway_handles_admin_keys_grouped_by_format_locally_with_trusted_admin
|
||||
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_key(
|
||||
let mut key_b = sample_bound_key(
|
||||
"key-claude-a",
|
||||
"provider-claude",
|
||||
"claude:messages",
|
||||
@@ -2912,20 +2900,18 @@ async fn gateway_handles_admin_keys_grouped_by_format_locally_with_trusted_admin
|
||||
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_key(
|
||||
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(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"codex","auth_mode":"agentIdentity","agent_runtime_id":"runtime-1","agent_private_key":"base64-private-key","task_id":"task-1"}"#,
|
||||
)
|
||||
.expect("Agent Identity auth config should encrypt"),
|
||||
);
|
||||
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();
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use aether_crypto::{
|
||||
decrypt_python_fernet_ciphertext, encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY,
|
||||
};
|
||||
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use aether_data::repository::proxy_nodes::InMemoryProxyNodeRepository;
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
@@ -16,8 +14,8 @@ use http::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::super::{
|
||||
build_router_with_state, build_state_with_execution_runtime_override, sample_endpoint,
|
||||
sample_key, sample_proxy_node, start_server,
|
||||
build_router_with_state, build_state_with_execution_runtime_override, sample_bound_auth_config,
|
||||
sample_bound_key, sample_endpoint, sample_key, sample_proxy_node, start_server, AppState,
|
||||
};
|
||||
use crate::constants::{
|
||||
GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
|
||||
@@ -27,6 +25,28 @@ use crate::data::GatewayDataState;
|
||||
|
||||
const PROVIDER_QUOTA_TEST_STACK_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
fn open_provider_catalog_credential_for_test(
|
||||
key: &StoredProviderCatalogKey,
|
||||
field: &str,
|
||||
) -> 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),
|
||||
);
|
||||
match field {
|
||||
"api_key" => state
|
||||
.decrypt_provider_catalog_key_api_key(key)
|
||||
.expect("provider catalog API key should decrypt")
|
||||
.expect("provider catalog API key should be present"),
|
||||
"auth_config" => state
|
||||
.decrypt_provider_catalog_key_auth_config(key)
|
||||
.expect("provider catalog auth config should decrypt")
|
||||
.expect("provider catalog auth config should be present"),
|
||||
_ => panic!("unsupported provider catalog credential field: {field}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_provider_quota_test<F, Fut>(test_name: &'static str, make_future: F)
|
||||
where
|
||||
F: FnOnce() -> Fut + Send + 'static,
|
||||
@@ -486,23 +506,9 @@ async fn gateway_codex_quota_refresh_persists_after_automatic_oauth_token_refres
|
||||
.await
|
||||
.expect("key should reload");
|
||||
let persisted = reloaded.first().expect("key should remain installed");
|
||||
let decrypted_api_key = decrypt_python_fernet_ciphertext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
persisted
|
||||
.encrypted_api_key
|
||||
.as_deref()
|
||||
.expect("api key should persist"),
|
||||
)
|
||||
.expect("api key should decrypt");
|
||||
let decrypted_api_key = open_provider_catalog_credential_for_test(persisted, "api_key");
|
||||
assert_eq!(decrypted_api_key, "refreshed-codex-access-token");
|
||||
let decrypted_auth_config = decrypt_python_fernet_ciphertext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
persisted
|
||||
.encrypted_auth_config
|
||||
.as_deref()
|
||||
.expect("auth config should persist"),
|
||||
)
|
||||
.expect("auth config should decrypt");
|
||||
let decrypted_auth_config = open_provider_catalog_credential_for_test(persisted, "auth_config");
|
||||
let auth_config: serde_json::Value =
|
||||
serde_json::from_str(&decrypted_auth_config).expect("auth config should parse");
|
||||
assert_eq!(auth_config["refresh_token"], "rotated-codex-refresh-token");
|
||||
@@ -1390,40 +1396,23 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_kiro_with_trusted_ad
|
||||
}),
|
||||
);
|
||||
|
||||
let encrypted_auth_config = encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
let mut key = sample_bound_key(
|
||||
"key-kiro-a",
|
||||
"provider-kiro",
|
||||
"claude:messages",
|
||||
"__placeholder__",
|
||||
);
|
||||
key.auth_type = "bearer".to_string();
|
||||
key.encrypted_auth_config = Some(sample_bound_auth_config(
|
||||
"provider-kiro",
|
||||
"key-kiro-a",
|
||||
r#"{
|
||||
"access_token":"kiro-access-token",
|
||||
"api_region":"us-west-2",
|
||||
"machine_id":"123e4567-e89b-12d3-a456-426614174000",
|
||||
"kiro_version":"1.2.3"
|
||||
}"#,
|
||||
)
|
||||
.expect("auth config ciphertext should build");
|
||||
let encrypted_api_key =
|
||||
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "__placeholder__")
|
||||
.expect("api key ciphertext should build");
|
||||
let key = StoredProviderCatalogKey::new(
|
||||
"key-kiro-a".to_string(),
|
||||
"provider-kiro".to_string(),
|
||||
"default".to_string(),
|
||||
"bearer".to_string(),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("key should build")
|
||||
.with_transport_fields(
|
||||
Some(json!(["claude:messages"])),
|
||||
encrypted_api_key,
|
||||
Some(encrypted_auth_config),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("key transport should build");
|
||||
));
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![StoredProviderCatalogProvider::new(
|
||||
@@ -1479,6 +1468,11 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_kiro_with_trusted_ad
|
||||
);
|
||||
assert_eq!(
|
||||
payload["results"][0]["quota_snapshot"]["plan_type"],
|
||||
serde_json::Value::Null,
|
||||
"quota snapshot token projection must reject unsafely formatted plan labels"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["results"][0]["metadata"]["subscription_title"],
|
||||
"KIRO PRO+"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1556,7 +1550,8 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_kiro_with_trusted_ad
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("quota"))
|
||||
.and_then(|value| value.get("plan_type")),
|
||||
Some(&json!("KIRO PRO+"))
|
||||
None,
|
||||
"persisted admin-safe status must omit unsafely formatted plan labels"
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded[0]
|
||||
@@ -1771,35 +1766,18 @@ async fn gateway_refresh_kiro_quota_reconciles_missing_fixed_endpoint_before_ref
|
||||
}),
|
||||
);
|
||||
|
||||
let encrypted_auth_config = encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
let mut key = sample_bound_key(
|
||||
"key-kiro-reconcile",
|
||||
"provider-kiro-reconcile",
|
||||
"claude:messages",
|
||||
"__placeholder__",
|
||||
);
|
||||
key.auth_type = "bearer".to_string();
|
||||
key.encrypted_auth_config = Some(sample_bound_auth_config(
|
||||
"provider-kiro-reconcile",
|
||||
"key-kiro-reconcile",
|
||||
r#"{"access_token":"kiro-access-token","api_region":"us-west-2"}"#,
|
||||
)
|
||||
.expect("auth config ciphertext should build");
|
||||
let encrypted_api_key =
|
||||
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "__placeholder__")
|
||||
.expect("api key ciphertext should build");
|
||||
let key = StoredProviderCatalogKey::new(
|
||||
"key-kiro-reconcile".to_string(),
|
||||
"provider-kiro-reconcile".to_string(),
|
||||
"default".to_string(),
|
||||
"bearer".to_string(),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("key should build")
|
||||
.with_transport_fields(
|
||||
Some(json!(["claude:messages"])),
|
||||
encrypted_api_key,
|
||||
Some(encrypted_auth_config),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("key transport should build");
|
||||
));
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![StoredProviderCatalogProvider::new(
|
||||
@@ -2234,7 +2212,7 @@ async fn gateway_reports_codex_quota_runtime_failures_locally_without_falling_ba
|
||||
assert!(payload["results"][0]["message"]
|
||||
.as_str()
|
||||
.expect("message should be string")
|
||||
.contains("wham/usage 请求执行失败: execution runtime returned HTTP 500"));
|
||||
.contains("wham/usage 请求执行失败"));
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
let reloaded = provider_catalog_repository
|
||||
|
||||
@@ -9,7 +9,8 @@ use http::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::super::{
|
||||
build_router_with_state, sample_endpoint, sample_key, sample_provider, start_server, AppState,
|
||||
build_router_with_state, sample_bound_key as sample_key, sample_endpoint, sample_provider,
|
||||
start_server, AppState,
|
||||
};
|
||||
use crate::constants::{
|
||||
GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
|
||||
@@ -541,7 +542,7 @@ async fn gateway_creates_admin_provider_endpoint_locally_with_trusted_admin_prin
|
||||
assert_eq!(payload["max_retries"], 5);
|
||||
assert_eq!(payload["total_keys"], 0);
|
||||
assert_eq!(payload["active_keys"], 0);
|
||||
assert_eq!(payload["proxy"]["url"], "http://proxy.internal");
|
||||
assert_eq!(payload["proxy"]["url"], "http://proxy.internal/");
|
||||
assert_eq!(payload["proxy"]["password"], "***");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
@@ -741,8 +742,8 @@ async fn gateway_updates_admin_provider_endpoint_locally_with_trusted_admin_prin
|
||||
assert_eq!(payload["is_active"], false);
|
||||
assert_eq!(payload["total_keys"], 1);
|
||||
assert_eq!(payload["active_keys"], 1);
|
||||
assert_eq!(payload["proxy"]["url"], "http://proxy-2.internal");
|
||||
assert_eq!(payload["proxy"]["password"], "***");
|
||||
assert_eq!(payload["proxy"]["url"], "http://proxy-2.internal/");
|
||||
assert_eq!(payload["proxy"]["password"], serde_json::Value::Null);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
let endpoints = provider_catalog_repository
|
||||
@@ -757,7 +758,7 @@ async fn gateway_updates_admin_provider_endpoint_locally_with_trusted_admin_prin
|
||||
assert_eq!(endpoints[0].config, Some(json!({"foo":"new"})));
|
||||
assert_eq!(
|
||||
endpoints[0].proxy,
|
||||
Some(json!({"url":"http://proxy-2.internal","password":"secret"}))
|
||||
Some(json!({"url":"http://proxy-2.internal/"}))
|
||||
);
|
||||
|
||||
gateway_handle.abort();
|
||||
|
||||
@@ -4,7 +4,9 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
|
||||
use aether_data::repository::auth_modules::InMemoryAuthModuleReadRepository;
|
||||
use aether_data::repository::candidates::InMemoryRequestCandidateRepository;
|
||||
use aether_data::repository::management_tokens::InMemoryManagementTokenRepository;
|
||||
use aether_data::repository::management_tokens::{
|
||||
InMemoryManagementTokenRepository, ManagementTokenListQuery, ManagementTokenReadRepository,
|
||||
};
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
|
||||
use aether_data_contracts::repository::provider_catalog::ProviderCatalogReadRepository;
|
||||
@@ -15,9 +17,10 @@ use http::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::{
|
||||
build_router_with_state, hash_management_token, issue_test_admin_access_token, sample_endpoint,
|
||||
sample_key, sample_ldap_module_config, sample_management_token, sample_oauth_module_provider,
|
||||
sample_provider, sample_request_candidate, start_server, AppState,
|
||||
build_router_with_state, hash_management_token, issue_test_admin_access_token,
|
||||
sample_bound_key, sample_endpoint, sample_ldap_module_config, sample_management_token,
|
||||
sample_oauth_module_provider, sample_provider, sample_request_candidate, start_server,
|
||||
AppState,
|
||||
};
|
||||
use crate::constants::{
|
||||
GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
|
||||
@@ -96,7 +99,7 @@ async fn gateway_handles_admin_health_api_formats_locally_with_trusted_admin_pri
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-openai",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -248,7 +251,7 @@ async fn gateway_handles_admin_health_summary_locally_with_trusted_admin_princip
|
||||
.with_health_score(0.2),
|
||||
],
|
||||
vec![
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-active",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -258,7 +261,7 @@ async fn gateway_handles_admin_health_summary_locally_with_trusted_admin_princip
|
||||
Some(json!({"openai:chat": {"health_score": 0.9}})),
|
||||
Some(json!({"openai:chat": {"open": false}})),
|
||||
),
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-circuit",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -334,7 +337,7 @@ async fn gateway_handles_admin_key_health_locally_with_trusted_admin_principal()
|
||||
"https://api.openai.example",
|
||||
)],
|
||||
vec![
|
||||
sample_key("key-openai", "provider-openai", "openai:chat", "sk-test")
|
||||
sample_bound_key("key-openai", "provider-openai", "openai:chat", "sk-test")
|
||||
.with_rate_limit_fields(None, None, None, None, None, None, None, Some(10), Some(7))
|
||||
.with_usage_fields(Some(3), Some(2100))
|
||||
.with_health_fields(
|
||||
@@ -432,7 +435,7 @@ async fn gateway_admin_key_health_summary_treats_expired_unix_circuit_as_closed(
|
||||
"https://api.openai.example",
|
||||
)],
|
||||
vec![
|
||||
sample_key("key-openai", "provider-openai", "openai:chat", "sk-test")
|
||||
sample_bound_key("key-openai", "provider-openai", "openai:chat", "sk-test")
|
||||
.with_health_fields(
|
||||
Some(json!({"openai:chat": {
|
||||
"health_score": 0.7,
|
||||
@@ -511,7 +514,7 @@ async fn gateway_recovers_admin_key_health_locally_with_trusted_admin_principal(
|
||||
"https://api.openai.example",
|
||||
)],
|
||||
vec![
|
||||
sample_key("key-openai", "provider-openai", "openai:chat", "sk-test")
|
||||
sample_bound_key("key-openai", "provider-openai", "openai:chat", "sk-test")
|
||||
.with_health_fields(
|
||||
Some(json!({"openai:chat": {
|
||||
"health_score": 0.2,
|
||||
@@ -620,7 +623,7 @@ async fn gateway_recovers_all_admin_key_health_locally_with_trusted_admin_princi
|
||||
"https://api.openai.example",
|
||||
)],
|
||||
vec![
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-circuit",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -630,7 +633,7 @@ async fn gateway_recovers_all_admin_key_health_locally_with_trusted_admin_princi
|
||||
Some(json!({"openai:chat": {"health_score": 0.3}})),
|
||||
Some(json!({"openai:chat": {"open": true}})),
|
||||
),
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-healthy",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -729,7 +732,7 @@ async fn gateway_handles_admin_health_status_locally_with_trusted_admin_principa
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-openai",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -1308,11 +1311,11 @@ async fn gateway_handles_admin_management_tokens_locally_with_trusted_admin_prin
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_allows_full_management_token_to_fetch_permission_catalog() {
|
||||
async fn gateway_rejects_constrained_full_management_token_creating_unconstrained_child() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/management-tokens/permissions/catalog",
|
||||
"/api/admin/management-tokens",
|
||||
any(move |_request: Request| {
|
||||
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||
async move {
|
||||
@@ -1341,38 +1344,116 @@ async fn gateway_allows_full_management_token_to_fetch_permission_catalog() {
|
||||
let raw_token = "ae-management-full-access";
|
||||
let mut management_token =
|
||||
sample_management_token("mt-admin-full", &admin_user.id, "management-full", true);
|
||||
management_token.token.allowed_ips = None;
|
||||
management_token.token.allowed_ips = Some(json!(["127.0.0.1"]));
|
||||
management_token.token.expires_at_unix_secs = Some(
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock should be after epoch")
|
||||
.as_secs()
|
||||
+ 3_600,
|
||||
);
|
||||
management_token.token.permissions = Some(json!(all_assignable_management_token_permissions()));
|
||||
let legacy_raw_token = "ae-management-legacy-full-access";
|
||||
let mut legacy_management_token = sample_management_token(
|
||||
"mt-admin-legacy-full",
|
||||
&admin_user.id,
|
||||
"management-legacy-full",
|
||||
true,
|
||||
);
|
||||
legacy_management_token.token.allowed_ips = Some(json!(["127.0.0.1"]));
|
||||
legacy_management_token.token.expires_at_unix_secs =
|
||||
management_token.token.expires_at_unix_secs;
|
||||
legacy_management_token.token.permissions = None;
|
||||
let management_token_repository =
|
||||
Arc::new(InMemoryManagementTokenRepository::seed_with_hashes(
|
||||
vec![management_token],
|
||||
vec![(
|
||||
hash_management_token(raw_token),
|
||||
"mt-admin-full".to_string(),
|
||||
)],
|
||||
vec![management_token, legacy_management_token],
|
||||
vec![
|
||||
(
|
||||
hash_management_token(raw_token),
|
||||
"mt-admin-full".to_string(),
|
||||
),
|
||||
(
|
||||
hash_management_token(legacy_raw_token),
|
||||
"mt-admin-legacy-full".to_string(),
|
||||
),
|
||||
],
|
||||
));
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(state.with_data_state_for_tests(
|
||||
GatewayDataState::with_management_token_repository_for_tests(management_token_repository),
|
||||
GatewayDataState::with_management_token_repository_for_tests(Arc::clone(
|
||||
&management_token_repository,
|
||||
)),
|
||||
));
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"{gateway_url}/api/admin/management-tokens/permissions/catalog"
|
||||
))
|
||||
.post(format!("{gateway_url}/api/admin/management-tokens"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.bearer_auth(raw_token)
|
||||
.json(&json!({
|
||||
"name": "unconstrained-child",
|
||||
"permissions": all_assignable_management_token_permissions(),
|
||||
}))
|
||||
.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}");
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "body={body}");
|
||||
let payload: serde_json::Value = serde_json::from_str(&body).expect("json body should parse");
|
||||
assert!(payload["items"].is_array());
|
||||
assert_eq!(payload["detail"], "management token permission denied");
|
||||
assert_eq!(
|
||||
payload["required_permission"],
|
||||
"admin:management_tokens:admin"
|
||||
);
|
||||
|
||||
let legacy_response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/management-tokens"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.bearer_auth(legacy_raw_token)
|
||||
.json(&json!({
|
||||
"name": "unconstrained-legacy-child",
|
||||
"permissions": all_assignable_management_token_permissions(),
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("legacy request should succeed");
|
||||
let legacy_status = legacy_response.status();
|
||||
let legacy_body = legacy_response.text().await.expect("body should read");
|
||||
assert_eq!(legacy_status, StatusCode::FORBIDDEN, "body={legacy_body}");
|
||||
let legacy_payload: serde_json::Value =
|
||||
serde_json::from_str(&legacy_body).expect("json body should parse");
|
||||
assert_eq!(
|
||||
legacy_payload["detail"],
|
||||
"management token permission denied"
|
||||
);
|
||||
assert_eq!(
|
||||
legacy_payload["required_permission"],
|
||||
"admin:management_tokens:admin"
|
||||
);
|
||||
|
||||
let tokens = management_token_repository
|
||||
.list_management_tokens(&ManagementTokenListQuery {
|
||||
user_id: None,
|
||||
is_active: None,
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
})
|
||||
.await
|
||||
.expect("management token list should succeed");
|
||||
assert_eq!(
|
||||
tokens.total, 2,
|
||||
"unconstrained children must not be created"
|
||||
);
|
||||
let mut token_ids = tokens
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.token.id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
token_ids.sort_unstable();
|
||||
assert_eq!(token_ids, vec!["mt-admin-full", "mt-admin-legacy-full"]);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
|
||||
@@ -223,3 +223,69 @@ async fn gateway_tests_admin_ldap_connection_locally_with_trusted_admin_principa
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_ldap_filter_and_attribute_injection_on_admin_update() {
|
||||
let auth_module_repository = Arc::new(InMemoryAuthModuleReadRepository::seed(
|
||||
Vec::<StoredOAuthProviderModuleConfig>::new(),
|
||||
Some(sample_ldap_module_config()),
|
||||
));
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(GatewayDataState::with_auth_module_repository_for_tests(
|
||||
auth_module_repository,
|
||||
)),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
for (search_filter, username_attr, expected_detail) in [
|
||||
(
|
||||
"(uid={username})(objectClass=*)",
|
||||
"uid",
|
||||
"搜索过滤器格式无效",
|
||||
),
|
||||
(
|
||||
"(uid={username})",
|
||||
"uid)(|(objectClass=*)",
|
||||
"用户名属性必须是有效的 LDAP 属性名称",
|
||||
),
|
||||
] {
|
||||
let response = client
|
||||
.put(format!("{gateway_url}/api/admin/ldap/config"))
|
||||
.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!({
|
||||
"server_url": "mockldap://ldap.internal.example.com",
|
||||
"bind_dn": "cn=svc,dc=example,dc=com",
|
||||
"bind_password": "secret123",
|
||||
"base_dn": "ou=people,dc=example,dc=com",
|
||||
"user_search_filter": search_filter,
|
||||
"username_attr": username_attr,
|
||||
"email_attr": "mail",
|
||||
"display_name_attr": "cn",
|
||||
"is_enabled": true,
|
||||
"is_exclusive": false,
|
||||
"use_starttls": false,
|
||||
"connect_timeout": 20
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("invalid LDAP update should complete locally");
|
||||
|
||||
let status = response.status();
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "payload={payload}");
|
||||
assert!(
|
||||
payload["detail"]
|
||||
.as_str()
|
||||
.is_some_and(|detail| detail.contains(expected_detail)),
|
||||
"payload={payload}"
|
||||
);
|
||||
}
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
|
||||
use aether_data::repository::proxy_nodes::{InMemoryProxyNodeRepository, StoredProxyNode};
|
||||
use axum::body::Body;
|
||||
use axum::extract::ws::Message;
|
||||
@@ -11,7 +12,10 @@ use http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tokio::sync::watch;
|
||||
|
||||
use super::super::super::{build_router_with_state, sample_proxy_node, start_server, AppState};
|
||||
use super::super::super::{
|
||||
build_router_with_state, sample_proxy_node, start_server, with_tunnel_control_plane_key,
|
||||
AppState, TUNNEL_CONTROL_PLANE_TEST_GENERATION, TUNNEL_CONTROL_PLANE_TEST_PSK,
|
||||
};
|
||||
use crate::constants::{
|
||||
GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
|
||||
TRUSTED_ADMIN_USER_ROLE_HEADER,
|
||||
@@ -314,7 +318,10 @@ async fn gateway_fetches_external_models_through_connected_tunnel_node() {
|
||||
let source_url = "https://models.dev.test/api.json";
|
||||
let _guard = set_admin_external_models_source_url_for_tests(source_url);
|
||||
|
||||
let mut tunnel_node = sample_proxy_node("tunnel-node");
|
||||
let mut tunnel_node = with_tunnel_control_plane_key(
|
||||
sample_proxy_node("tunnel-node"),
|
||||
TUNNEL_CONTROL_PLANE_TEST_PSK,
|
||||
);
|
||||
tunnel_node.name = "Tunnel Node".to_string();
|
||||
tunnel_node.status = "online".to_string();
|
||||
tunnel_node.tunnel_mode = true;
|
||||
@@ -322,6 +329,7 @@ async fn gateway_fetches_external_models_through_connected_tunnel_node() {
|
||||
tunnel_node.remote_config = None;
|
||||
let repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![tunnel_node]));
|
||||
let data_state = GatewayDataState::with_proxy_node_repository_for_tests(repository)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY)
|
||||
.with_system_config_values_for_tests([(
|
||||
"external_models_proxy_node_id".to_string(),
|
||||
json!("tunnel-node"),
|
||||
@@ -332,9 +340,8 @@ async fn gateway_fetches_external_models_through_connected_tunnel_node() {
|
||||
let tunnel_state = state.tunnel.app_state();
|
||||
let (proxy_tx, mut proxy_rx) = aether_runtime::bounded_queue(8);
|
||||
let (proxy_close_tx, _) = watch::channel(false);
|
||||
tunnel_state
|
||||
.hub
|
||||
.register_proxy(Arc::new(TunnelProxyConn::new(
|
||||
tunnel_state.hub.register_proxy(Arc::new(
|
||||
TunnelProxyConn::new(
|
||||
700,
|
||||
"tunnel-node".to_string(),
|
||||
"Tunnel Node".to_string(),
|
||||
@@ -342,7 +349,10 @@ async fn gateway_fetches_external_models_through_connected_tunnel_node() {
|
||||
proxy_close_tx,
|
||||
16,
|
||||
2,
|
||||
)));
|
||||
)
|
||||
.with_tunnel_generation(TUNNEL_CONTROL_PLANE_TEST_GENERATION.to_string())
|
||||
.with_authenticated_key(TUNNEL_CONTROL_PLANE_TEST_PSK.to_string()),
|
||||
));
|
||||
|
||||
let gateway = build_router_with_state(state);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
@@ -371,7 +381,7 @@ async fn gateway_fetches_external_models_through_connected_tunnel_node() {
|
||||
serde_json::from_slice(&meta_payload).expect("request meta should parse");
|
||||
assert_eq!(meta.method, "GET");
|
||||
assert_eq!(meta.url, source_url);
|
||||
assert_eq!(meta.follow_redirects, Some(true));
|
||||
assert_eq!(meta.follow_redirects, Some(false));
|
||||
assert_eq!(
|
||||
meta.headers.get("accept").map(String::as_str),
|
||||
Some("application/json")
|
||||
|
||||
@@ -2,9 +2,11 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use aether_data::repository::global_models::InMemoryGlobalModelReadRepository;
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use aether_data::repository::routing_profiles::InMemoryRoutingGroupRepository;
|
||||
use aether_data_contracts::repository::global_models::{
|
||||
AdminProviderModelListQuery, GlobalModelReadRepository,
|
||||
};
|
||||
use aether_data_contracts::repository::routing_profiles::StoredRoutingGroup;
|
||||
use axum::body::Body;
|
||||
use axum::routing::any;
|
||||
use axum::{extract::Request, Router};
|
||||
@@ -13,7 +15,7 @@ use serde_json::json;
|
||||
|
||||
use super::super::super::{
|
||||
build_router_with_state, issue_test_admin_access_token, sample_admin_global_model,
|
||||
sample_admin_provider_model, sample_endpoint, sample_key, sample_provider, start_server,
|
||||
sample_admin_provider_model, sample_bound_key, sample_endpoint, sample_provider, start_server,
|
||||
AppState,
|
||||
};
|
||||
use crate::constants::{
|
||||
@@ -772,7 +774,7 @@ async fn gateway_handles_admin_global_model_routing_locally_with_trusted_admin_p
|
||||
),
|
||||
],
|
||||
{
|
||||
let mut primary_key = sample_key(
|
||||
let mut primary_key = sample_bound_key(
|
||||
"key-openai-routing",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -792,7 +794,7 @@ async fn gateway_handles_admin_global_model_routing_locally_with_trusted_admin_p
|
||||
"openai:chat": {"open": true, "next_probe_at": "2099-03-27T15:00:00Z"}
|
||||
}));
|
||||
|
||||
let mut mapped_key = sample_key(
|
||||
let mut mapped_key = sample_bound_key(
|
||||
"key-alt-routing",
|
||||
"provider-alt",
|
||||
"openai:chat",
|
||||
@@ -804,7 +806,7 @@ async fn gateway_handles_admin_global_model_routing_locally_with_trusted_admin_p
|
||||
mapped_key.allowed_models = Some(json!(["gpt-5-upstream"]));
|
||||
mapped_key.rpm_limit = Some(120);
|
||||
|
||||
let mut unlinked_key = sample_key(
|
||||
let mut unlinked_key = sample_bound_key(
|
||||
"key-unlinked-routing",
|
||||
"provider-unlinked",
|
||||
"openai:chat",
|
||||
@@ -841,6 +843,29 @@ async fn gateway_handles_admin_global_model_routing_locally_with_trusted_admin_p
|
||||
]),
|
||||
);
|
||||
|
||||
let routing_group_repository = Arc::new(InMemoryRoutingGroupRepository::seed(
|
||||
[StoredRoutingGroup {
|
||||
id: "system-default".to_string(),
|
||||
name: "system-default".to_string(),
|
||||
description: Some("test system default routing strategy".to_string()),
|
||||
enabled: true,
|
||||
is_system_default: true,
|
||||
sort_order: 0,
|
||||
config_json: json!({
|
||||
"default_policy": {
|
||||
"priority_mode": "global_key",
|
||||
"scheduling_mode": "fixed_order"
|
||||
}
|
||||
}),
|
||||
version: 1,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
published_at: Some(1),
|
||||
}],
|
||||
std::iter::empty(),
|
||||
std::iter::empty(),
|
||||
));
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
@@ -850,10 +875,7 @@ async fn gateway_handles_admin_global_model_routing_locally_with_trusted_admin_p
|
||||
provider_catalog_repository,
|
||||
)
|
||||
.with_global_model_repository_for_tests(global_model_repository)
|
||||
.with_system_config_values_for_tests(vec![
|
||||
("scheduling_mode".to_string(), json!("fixed_order")),
|
||||
("provider_priority_mode".to_string(), json!("global_key")),
|
||||
]),
|
||||
.with_routing_group_repository_for_tests(routing_group_repository),
|
||||
),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
@@ -898,7 +920,7 @@ async fn gateway_handles_admin_global_model_routing_locally_with_trusted_admin_p
|
||||
let openai_keys = openai_endpoints[0]["keys"].as_array().expect("keys array");
|
||||
assert_eq!(openai_keys.len(), 1);
|
||||
assert_eq!(openai_keys[0]["name"], "primary");
|
||||
assert_eq!(openai_keys[0]["masked_key"], "sk-opena***1234");
|
||||
assert_eq!(openai_keys[0]["masked_key"], "sk-open***1234");
|
||||
assert_eq!(openai_keys[0]["is_adaptive"], true);
|
||||
assert_eq!(openai_keys[0]["effective_rpm"], 77);
|
||||
assert_eq!(openai_keys[0]["allowed_models"], json!(["gpt-5"]));
|
||||
@@ -955,7 +977,7 @@ async fn gateway_global_model_routing_counts_image_provider_keys_by_provider_mod
|
||||
image_provider.provider_type = "chatgpt_web".to_string();
|
||||
let grok_provider = sample_provider("provider-grok", "grok2api", 20);
|
||||
|
||||
let mut image_key = sample_key(
|
||||
let mut image_key = sample_bound_key(
|
||||
"key-image-routing",
|
||||
"provider-image",
|
||||
"legacy:mismatch",
|
||||
@@ -965,7 +987,7 @@ async fn gateway_global_model_routing_counts_image_provider_keys_by_provider_mod
|
||||
image_key.auth_type = "oauth".to_string();
|
||||
image_key.allowed_models = Some(json!(["gpt-image-2"]));
|
||||
|
||||
let mut grok_key = sample_key(
|
||||
let mut grok_key = sample_bound_key(
|
||||
"key-grok-routing",
|
||||
"provider-grok",
|
||||
"openai:chat",
|
||||
|
||||
@@ -528,7 +528,7 @@ async fn gateway_handles_admin_monitoring_trace_request_locally_with_trusted_adm
|
||||
assert_eq!(payload["candidates"][0]["provider_name"], json!("OpenAI"));
|
||||
assert_eq!(
|
||||
payload["candidates"][0]["provider_website"],
|
||||
json!("https://openai.com")
|
||||
json!("https://openai.com/")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["candidates"][0]["endpoint_name"],
|
||||
@@ -761,11 +761,12 @@ async fn gateway_handles_admin_monitoring_cache_affinities_locally_with_trusted_
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(
|
||||
crate::data::GatewayDataState::with_provider_catalog_reader_for_tests(
|
||||
crate::data::GatewayDataState::with_provider_catalog_repository_for_tests(
|
||||
provider_catalog,
|
||||
)
|
||||
.with_user_reader(user_repository)
|
||||
.with_auth_api_key_reader(auth_repository),
|
||||
.with_auth_api_key_reader(auth_repository)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
)
|
||||
.with_admin_monitoring_cache_affinity_entry_for_tests(
|
||||
"cache_affinity:user-key-1:openai:model-alpha",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -249,10 +249,9 @@ async fn gateway_handles_admin_payments_expire_order_locally_with_trusted_admin_
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["expired"], true);
|
||||
assert_eq!(payload["order"]["status"], "expired");
|
||||
assert_eq!(
|
||||
payload["order"]["gateway_response"]["expire_reason"],
|
||||
"admin_mark_expired"
|
||||
);
|
||||
assert!(payload["order"]["gateway_response"]
|
||||
.get("expire_reason")
|
||||
.is_none());
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -305,15 +304,13 @@ async fn gateway_handles_admin_payments_credit_order_locally_with_trusted_admin_
|
||||
assert_eq!(payload["order"]["pay_amount"], 91.25);
|
||||
assert_eq!(payload["order"]["pay_currency"], "CNY");
|
||||
assert_eq!(payload["order"]["exchange_rate"], 7.3);
|
||||
assert_eq!(
|
||||
payload["order"]["gateway_response"]["channel"],
|
||||
"manual-review"
|
||||
);
|
||||
assert!(payload["order"]["gateway_response"]
|
||||
.get("channel")
|
||||
.is_none());
|
||||
assert_eq!(payload["order"]["gateway_response"]["manual_credit"], true);
|
||||
assert_eq!(
|
||||
payload["order"]["gateway_response"]["credited_by"],
|
||||
"admin-user-123"
|
||||
);
|
||||
assert!(payload["order"]["gateway_response"]
|
||||
.get("credited_by")
|
||||
.is_none());
|
||||
assert!(payload["order"]["paid_at"].as_str().is_some());
|
||||
assert!(payload["order"]["credited_at"].as_str().is_some());
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
@@ -354,10 +351,9 @@ async fn gateway_handles_admin_payments_fail_order_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["order"]["status"], "failed");
|
||||
assert_eq!(
|
||||
payload["order"]["gateway_response"]["failure_reason"],
|
||||
"admin_mark_failed"
|
||||
);
|
||||
assert!(payload["order"]["gateway_response"]
|
||||
.get("failure_reason")
|
||||
.is_none());
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -410,7 +406,8 @@ async fn gateway_handles_admin_payments_callbacks_locally_with_trusted_admin_pri
|
||||
assert_eq!(items[0]["callback_key"], "callback-key-1");
|
||||
assert_eq!(items[0]["signature_valid"], true);
|
||||
assert_eq!(items[0]["status"], "processed");
|
||||
assert_eq!(items[0]["payload"]["source"], "callback-1");
|
||||
assert!(items[0]["payload"].is_null());
|
||||
assert_eq!(items[0]["payload_summary"]["objects"], 1);
|
||||
assert!(items[0]["processed_at"].as_str().is_some());
|
||||
assert_eq!(payload["total"], 1);
|
||||
assert_eq!(payload["limit"], 10);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
||||
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
|
||||
use aether_data::repository::pool_scores::InMemoryPoolMemberScoreRepository;
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use aether_data::repository::usage::InMemoryUsageReadRepository;
|
||||
@@ -16,7 +16,8 @@ use http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::{
|
||||
build_router_with_state, sample_endpoint, sample_key, sample_provider, start_server, AppState,
|
||||
build_router_with_state, sample_bound_auth_config, sample_bound_key as sample_key,
|
||||
sample_endpoint, sample_provider, start_server, AppState,
|
||||
};
|
||||
use crate::admin_api::{maybe_build_local_admin_pool_response, AdminAppState, AdminRequestContext};
|
||||
use crate::ai_serving::{provider_key_pool_score_id, provider_key_pool_score_scope};
|
||||
@@ -1683,13 +1684,11 @@ async fn gateway_handles_admin_pool_list_keys_with_quota_compatibility_fields()
|
||||
key.name = "quota-key".to_string();
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.expires_at_unix_secs = Some(1_775_556_730);
|
||||
key.encrypted_auth_config = Some(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"plan_type":"pro","account_id":"acct-antigravity-1","account_name":"quota-user","account_user_id":"quota-user-1","organizations":[{"id":"org-1","name":"Org One"}]}"#,
|
||||
)
|
||||
.expect("auth config ciphertext should build"),
|
||||
);
|
||||
key.encrypted_auth_config = Some(sample_bound_auth_config(
|
||||
"provider-antigravity",
|
||||
"key-antigravity-a",
|
||||
r#"{"plan_type":"pro","account_id":"acct-antigravity-1","account_name":"quota-user","account_user_id":"quota-user-1","organizations":[{"id":"org-1","name":"Org One"}]}"#,
|
||||
));
|
||||
key.status_snapshot = Some(json!({
|
||||
"oauth": {
|
||||
"code": "expired",
|
||||
@@ -1806,20 +1805,18 @@ async fn gateway_includes_pool_quota_and_compat_fields_in_list_keys_response() {
|
||||
key.name = "quota key".to_string();
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.expires_at_unix_secs = Some(1_775_556_730);
|
||||
key.encrypted_auth_config = Some(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
&json!({
|
||||
"plan_type": "pro",
|
||||
"account_id": "acct-demo-001",
|
||||
"account_name": "Demo Account",
|
||||
"account_user_id": "user-demo-001",
|
||||
"organizations": [],
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
key.encrypted_auth_config = Some(sample_bound_auth_config(
|
||||
"provider-antigravity",
|
||||
"key-antigravity-oauth",
|
||||
&json!({
|
||||
"plan_type": "pro",
|
||||
"account_id": "acct-demo-001",
|
||||
"account_name": "Demo Account",
|
||||
"account_user_id": "user-demo-001",
|
||||
"organizations": [],
|
||||
})
|
||||
.to_string(),
|
||||
));
|
||||
key.upstream_metadata = Some(json!({
|
||||
"antigravity": {
|
||||
"updated_at": 1_775_553_285u64,
|
||||
@@ -2942,17 +2939,15 @@ async fn gateway_pool_prefers_upstream_plan_type_over_auth_config() {
|
||||
"oauth-placeholder",
|
||||
);
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.encrypted_auth_config = Some(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
&json!({
|
||||
"plan_type": "free",
|
||||
"account_id": "acct-codex-legacy"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
key.encrypted_auth_config = Some(sample_bound_auth_config(
|
||||
"provider-codex",
|
||||
"key-codex-precedence",
|
||||
&json!({
|
||||
"plan_type": "free",
|
||||
"account_id": "acct-codex-legacy"
|
||||
})
|
||||
.to_string(),
|
||||
));
|
||||
key.upstream_metadata = Some(json!({
|
||||
"codex": {
|
||||
"plan_type": "plus",
|
||||
@@ -3017,17 +3012,15 @@ async fn gateway_pool_plan_free_selector_prefers_upstream_plan_type() {
|
||||
"oauth-placeholder",
|
||||
);
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.encrypted_auth_config = Some(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
&json!({
|
||||
"plan_type": "free",
|
||||
"account_id": "acct-codex-legacy"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
key.encrypted_auth_config = Some(sample_bound_auth_config(
|
||||
"provider-codex",
|
||||
"key-codex-selector",
|
||||
&json!({
|
||||
"plan_type": "free",
|
||||
"account_id": "acct-codex-legacy"
|
||||
})
|
||||
.to_string(),
|
||||
));
|
||||
key.upstream_metadata = Some(json!({
|
||||
"codex": {
|
||||
"plan_type": "plus",
|
||||
@@ -3099,13 +3092,11 @@ async fn gateway_pool_keys_classify_oauth_credentials() {
|
||||
"imported-session-token",
|
||||
);
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.encrypted_auth_config = Some(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"codex","headers":{"authorization":"Bearer imported-session-token"}}"#,
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
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 mut agent_key = sample_key(
|
||||
"key-codex-agent-identity",
|
||||
"provider-codex",
|
||||
@@ -3113,13 +3104,11 @@ async fn gateway_pool_keys_classify_oauth_credentials() {
|
||||
"",
|
||||
);
|
||||
agent_key.auth_type = "oauth".to_string();
|
||||
agent_key.encrypted_auth_config = Some(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"codex","auth_mode":"agentIdentity","agent_runtime_id":"runtime-1","agent_private_key":"base64-private-key","task_id":"task-1"}"#,
|
||||
)
|
||||
.expect("Agent Identity auth config should encrypt"),
|
||||
);
|
||||
agent_key.encrypted_auth_config = Some(sample_bound_auth_config(
|
||||
"provider-codex",
|
||||
"key-codex-agent-identity",
|
||||
r#"{"provider_type":"codex","auth_mode":"agentIdentity","agent_runtime_id":"runtime-1","agent_private_key":"base64-private-key","task_id":"task-1"}"#,
|
||||
));
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
@@ -3191,13 +3180,11 @@ async fn gateway_pool_resolve_selection_marks_oauth_header_auth() {
|
||||
"imported-session-token",
|
||||
);
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.encrypted_auth_config = Some(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"codex","headers":{"authorization":"Bearer imported-session-token"}}"#,
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
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],
|
||||
@@ -3265,7 +3252,7 @@ async fn gateway_handles_admin_pool_resolve_selection_locally_with_trusted_admin
|
||||
proxy_key.name = "alpha proxy".to_string();
|
||||
proxy_key.proxy = Some(json!({
|
||||
"mode": "direct",
|
||||
"url": "https://proxy.example.com"
|
||||
"url": "https://proxy.example.com/"
|
||||
}));
|
||||
let mut plain_key = sample_key("key-openai-b", "provider-openai", "openai:chat", "sk-b");
|
||||
plain_key.name = "beta".to_string();
|
||||
@@ -3275,7 +3262,7 @@ async fn gateway_handles_admin_pool_resolve_selection_locally_with_trusted_admin
|
||||
disabled_proxy_key.is_active = false;
|
||||
disabled_proxy_key.proxy = Some(json!({
|
||||
"mode": "direct",
|
||||
"url": "https://proxy-disabled.example.com"
|
||||
"url": "https://proxy-disabled.example.com/"
|
||||
}));
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
@@ -3455,13 +3442,11 @@ async fn gateway_resolve_selection_marks_legacy_kiro_bearer_keys_as_oauth_manage
|
||||
"kiro-access-token",
|
||||
);
|
||||
key.auth_type = "bearer".to_string();
|
||||
key.encrypted_auth_config = Some(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"kiro","email":"[email protected]","refresh_token":"legacy-kiro-refresh-token"}"#,
|
||||
)
|
||||
.expect("auth config ciphertext should build"),
|
||||
);
|
||||
key.encrypted_auth_config = Some(sample_bound_auth_config(
|
||||
"provider-kiro",
|
||||
"key-kiro-legacy",
|
||||
r#"{"provider_type":"kiro","email":"[email protected]","refresh_token":"legacy-kiro-refresh-token"}"#,
|
||||
));
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
|
||||
@@ -3,9 +3,7 @@ use std::sync::{Arc, Mutex};
|
||||
use aether_contracts::{
|
||||
ExecutionPlan, EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER, EXECUTION_REQUEST_HTTP1_ONLY_HEADER,
|
||||
};
|
||||
use aether_crypto::{
|
||||
decrypt_python_fernet_ciphertext, encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY,
|
||||
};
|
||||
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use aether_data::repository::proxy_nodes::InMemoryProxyNodeRepository;
|
||||
use aether_data_contracts::repository::provider_catalog::ProviderCatalogReadRepository;
|
||||
@@ -31,9 +29,40 @@ use crate::constants::{
|
||||
TRUSTED_ADMIN_USER_ROLE_HEADER,
|
||||
};
|
||||
use crate::data::{GatewayDataConfig, GatewayDataState};
|
||||
use crate::handlers::shared::{
|
||||
open_provider_ops_credential, provider_ops_credential_binding_from_config,
|
||||
};
|
||||
|
||||
const PROVIDER_OPS_TEST_STACK_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
fn open_stored_provider_ops_credential(
|
||||
provider: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider,
|
||||
field: &str,
|
||||
stored: &str,
|
||||
) -> String {
|
||||
let provider_ops = provider
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|config| config.get("provider_ops"))
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.expect("provider ops config should be present");
|
||||
let base_url = provider_ops
|
||||
.get("base_url")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.expect("provider ops base_url should be present");
|
||||
let binding = provider_ops_credential_binding_from_config(&provider.id, provider_ops, base_url)
|
||||
.expect("provider ops credential binding should be valid");
|
||||
let state = AppState::new()
|
||||
.expect("gateway state should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::disabled().with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
);
|
||||
open_provider_ops_credential(&state, &binding, field, stored)
|
||||
.expect("provider ops credential should decrypt")
|
||||
.plaintext
|
||||
}
|
||||
|
||||
fn run_provider_ops_test<F, Fut>(test_name: &'static str, make_future: F)
|
||||
where
|
||||
F: FnOnce() -> Fut + Send + 'static,
|
||||
@@ -548,7 +577,10 @@ async fn gateway_saves_admin_provider_ops_config_locally_with_trusted_admin_prin
|
||||
"tenant": "acme"
|
||||
},
|
||||
"credentials": {
|
||||
"refresh_token": "************",
|
||||
// A binding change (architecture/auth type/base URL) must be
|
||||
// accompanied by a newly supplied secret; masked values are
|
||||
// intentionally rejected by the handler.
|
||||
"refresh_token": "new-refresh-secret",
|
||||
"api_key": "live-secret-api-key",
|
||||
}
|
||||
},
|
||||
@@ -615,9 +647,8 @@ async fn gateway_saves_admin_provider_ops_config_locally_with_trusted_admin_prin
|
||||
.expect("refresh token should be string");
|
||||
assert_ne!(stored_refresh, "refresh-secret-1234");
|
||||
assert_eq!(
|
||||
decrypt_python_fernet_ciphertext(DEVELOPMENT_ENCRYPTION_KEY, stored_refresh)
|
||||
.expect("refresh token should decrypt"),
|
||||
"refresh-secret-1234"
|
||||
open_stored_provider_ops_credential(&stored_provider, "refresh_token", stored_refresh),
|
||||
"new-refresh-secret"
|
||||
);
|
||||
let stored_api_key = credentials
|
||||
.get("api_key")
|
||||
@@ -625,8 +656,7 @@ async fn gateway_saves_admin_provider_ops_config_locally_with_trusted_admin_prin
|
||||
.expect("api key should be string");
|
||||
assert_ne!(stored_api_key, "live-secret-api-key");
|
||||
assert_eq!(
|
||||
decrypt_python_fernet_ciphertext(DEVELOPMENT_ENCRYPTION_KEY, stored_api_key)
|
||||
.expect("api key should decrypt"),
|
||||
open_stored_provider_ops_credential(&stored_provider, "api_key", stored_api_key),
|
||||
"live-secret-api-key"
|
||||
);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
@@ -1301,7 +1331,7 @@ async fn gateway_verifies_admin_provider_ops_locally_for_anyrouter_proxy_mode_im
|
||||
plan.headers
|
||||
.get(EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER)
|
||||
.map(String::as_str),
|
||||
None
|
||||
Some("false")
|
||||
);
|
||||
Json(json!({
|
||||
"request_id": plan.request_id,
|
||||
@@ -1717,7 +1747,9 @@ async fn gateway_verifies_admin_provider_ops_locally_for_new_api_with_trusted_ad
|
||||
assert_eq!(payload["data"]["used_quota"], 12.5);
|
||||
assert_eq!(payload["data"]["request_count"], 9);
|
||||
assert_eq!(payload["data"]["email"], "");
|
||||
assert_eq!(payload["data"]["extra"]["group"], "default");
|
||||
// The safe verification projection intentionally drops unknown upstream
|
||||
// fields such as `group`; only the documented fields are returned.
|
||||
assert_eq!(payload["data"]["extra"], json!({}));
|
||||
assert_eq!(payload["updated_credentials"], serde_json::Value::Null);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
@@ -2536,8 +2568,11 @@ async fn gateway_verifies_admin_provider_ops_sub2api_persists_rotated_runtime_cr
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.expect("refresh token should be string");
|
||||
assert_eq!(
|
||||
decrypt_python_fernet_ciphertext(DEVELOPMENT_ENCRYPTION_KEY, stored_refresh_token)
|
||||
.expect("refresh token should decrypt"),
|
||||
open_stored_provider_ops_credential(
|
||||
&stored_provider,
|
||||
"refresh_token",
|
||||
stored_refresh_token
|
||||
),
|
||||
"refresh-token-new"
|
||||
);
|
||||
let stored_cached_access_token = credentials
|
||||
@@ -2545,8 +2580,11 @@ async fn gateway_verifies_admin_provider_ops_sub2api_persists_rotated_runtime_cr
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.expect("cached access token should be string");
|
||||
assert_eq!(
|
||||
decrypt_python_fernet_ciphertext(DEVELOPMENT_ENCRYPTION_KEY, stored_cached_access_token,)
|
||||
.expect("cached access token should decrypt"),
|
||||
open_stored_provider_ops_credential(
|
||||
&stored_provider,
|
||||
"_cached_access_token",
|
||||
stored_cached_access_token,
|
||||
),
|
||||
"access-token-new"
|
||||
);
|
||||
assert!(
|
||||
@@ -2933,7 +2971,7 @@ async fn gateway_handles_admin_provider_ops_balance_locally_for_generic_api_prox
|
||||
assert_eq!(payload["data"]["total_available"], 5.0);
|
||||
assert_eq!(payload["data"]["total_used"], 1.0);
|
||||
assert_eq!(payload["data"]["extra"]["checkin_success"], true);
|
||||
assert_eq!(payload["data"]["extra"]["checkin_message"], "代理签到成功");
|
||||
assert_eq!(payload["data"]["extra"]["checkin_message"], "签到成功");
|
||||
|
||||
let plans = execution_plans.lock().expect("mutex should lock");
|
||||
assert_eq!(plans.len(), 2);
|
||||
@@ -3078,10 +3116,7 @@ async fn gateway_handles_admin_provider_ops_balance_locally_without_proxy_via_ex
|
||||
assert_eq!(payload["data"]["total_available"], 5.0);
|
||||
assert_eq!(payload["data"]["total_used"], 1.0);
|
||||
assert_eq!(payload["data"]["extra"]["checkin_success"], true);
|
||||
assert_eq!(
|
||||
payload["data"]["extra"]["checkin_message"],
|
||||
"执行层签到成功"
|
||||
);
|
||||
assert_eq!(payload["data"]["extra"]["checkin_message"], "签到成功");
|
||||
|
||||
let plans = execution_plans.lock().expect("mutex should lock");
|
||||
assert_eq!(plans.len(), 2);
|
||||
@@ -3208,7 +3243,7 @@ async fn gateway_handles_admin_provider_ops_checkin_locally_with_trusted_admin_p
|
||||
assert_eq!(payload["action_type"], "checkin");
|
||||
assert_eq!(payload["data"]["reward"], 1.5);
|
||||
assert_eq!(payload["data"]["streak_days"], 3);
|
||||
assert_eq!(payload["data"]["message"], "今日签到完成");
|
||||
assert_eq!(payload["data"]["message"], "签到成功");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -3341,7 +3376,7 @@ async fn gateway_handles_admin_provider_ops_checkin_locally_for_generic_api_prox
|
||||
assert_eq!(payload["action_type"], "checkin");
|
||||
assert_eq!(payload["data"]["reward"], 1.5);
|
||||
assert_eq!(payload["data"]["streak_days"], 3);
|
||||
assert_eq!(payload["data"]["message"], "今日签到完成");
|
||||
assert_eq!(payload["data"]["message"], "签到成功");
|
||||
assert_eq!(execution_plans.lock().expect("mutex should lock").len(), 1);
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -3582,7 +3617,7 @@ async fn gateway_handles_admin_provider_ops_batch_balance_locally_with_trusted_a
|
||||
);
|
||||
assert_eq!(
|
||||
payload["provider-anyrouter"]["data"]["extra"]["checkin_message"],
|
||||
"Anyrouter 签到成功"
|
||||
"签到成功"
|
||||
);
|
||||
assert_eq!(payload["provider-missing"]["status"], "not_configured");
|
||||
assert_eq!(payload["provider-missing"]["message"], "未配置操作设置");
|
||||
@@ -4785,8 +4820,11 @@ async fn gateway_handles_admin_provider_ops_sub2api_balance_with_refresh_token_r
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.expect("refresh token should be string");
|
||||
assert_eq!(
|
||||
decrypt_python_fernet_ciphertext(DEVELOPMENT_ENCRYPTION_KEY, stored_refresh_token)
|
||||
.expect("refresh token should decrypt"),
|
||||
open_stored_provider_ops_credential(
|
||||
&stored_provider,
|
||||
"refresh_token",
|
||||
stored_refresh_token
|
||||
),
|
||||
"refresh-token-new"
|
||||
);
|
||||
let stored_cached_access_token = credentials
|
||||
@@ -4794,8 +4832,11 @@ async fn gateway_handles_admin_provider_ops_sub2api_balance_with_refresh_token_r
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.expect("cached access token should be string");
|
||||
assert_eq!(
|
||||
decrypt_python_fernet_ciphertext(DEVELOPMENT_ENCRYPTION_KEY, stored_cached_access_token,)
|
||||
.expect("cached access token should decrypt"),
|
||||
open_stored_provider_ops_credential(
|
||||
&stored_provider,
|
||||
"_cached_access_token",
|
||||
stored_cached_access_token,
|
||||
),
|
||||
"access-token-new"
|
||||
);
|
||||
assert!(
|
||||
@@ -5187,8 +5228,11 @@ async fn gateway_handles_admin_provider_ops_sub2api_balance_with_session_login_i
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.expect("refresh token should be string");
|
||||
assert_eq!(
|
||||
decrypt_python_fernet_ciphertext(DEVELOPMENT_ENCRYPTION_KEY, stored_refresh_token)
|
||||
.expect("refresh token should decrypt"),
|
||||
open_stored_provider_ops_credential(
|
||||
&stored_provider,
|
||||
"refresh_token",
|
||||
stored_refresh_token
|
||||
),
|
||||
"login-refresh-token"
|
||||
);
|
||||
let stored_cached_access_token = credentials
|
||||
@@ -5196,8 +5240,11 @@ async fn gateway_handles_admin_provider_ops_sub2api_balance_with_session_login_i
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.expect("cached access token should be string");
|
||||
assert_eq!(
|
||||
decrypt_python_fernet_ciphertext(DEVELOPMENT_ENCRYPTION_KEY, stored_cached_access_token,)
|
||||
.expect("cached access token should decrypt"),
|
||||
open_stored_provider_ops_credential(
|
||||
&stored_provider,
|
||||
"_cached_access_token",
|
||||
stored_cached_access_token,
|
||||
),
|
||||
"login-access-token"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -20,8 +20,8 @@ use serde_json::json;
|
||||
|
||||
use super::super::{
|
||||
build_router_with_state, build_state_with_execution_runtime_override,
|
||||
sample_admin_provider_model, sample_endpoint, sample_key, sample_provider, start_server,
|
||||
AppState,
|
||||
sample_admin_provider_model, 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,
|
||||
@@ -216,7 +216,7 @@ async fn gateway_handles_admin_provider_query_models_fetches_upstream_for_select
|
||||
None,
|
||||
)
|
||||
.expect("endpoint transport should build")],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-openai-selected",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -342,7 +342,7 @@ async fn gateway_handles_admin_provider_query_models_fetches_windsurf_model_conf
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let mut provider = sample_provider("provider-windsurf", "Windsurf", 10);
|
||||
provider.provider_type = "windsurf".to_string();
|
||||
let mut windsurf_key = sample_key(
|
||||
let mut windsurf_key = sample_bound_key(
|
||||
"key-windsurf-selected",
|
||||
"provider-windsurf",
|
||||
"openai:chat",
|
||||
@@ -487,7 +487,7 @@ async fn gateway_handles_admin_provider_query_models_with_openai_responses_endpo
|
||||
None,
|
||||
)
|
||||
.expect("endpoint transport should build")],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-openai-responses",
|
||||
"provider-openai",
|
||||
"openai:responses",
|
||||
@@ -600,7 +600,7 @@ async fn gateway_recovers_codex_slug_only_models_from_an_empty_legacy_cache_impl
|
||||
"openai:responses",
|
||||
"https://chatgpt.com/backend-api/codex",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-codex-dynamic",
|
||||
"provider-codex-dynamic",
|
||||
"openai:responses",
|
||||
@@ -743,7 +743,7 @@ async fn gateway_handles_admin_provider_query_models_falls_back_to_codex_preset_
|
||||
"openai:responses",
|
||||
"https://chatgpt.com/backend-api/codex",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-codex-invalidated",
|
||||
"provider-codex",
|
||||
"openai:responses",
|
||||
@@ -782,7 +782,13 @@ async fn gateway_handles_admin_provider_query_models_falls_back_to_codex_preset_
|
||||
.as_str()
|
||||
.expect("Codex fallback warning should be present");
|
||||
assert!(warning.contains("Codex 动态模型目录不可用"));
|
||||
assert!(warning.contains("invalidated"));
|
||||
// Model-fetch diagnostics are intentionally projected to a credential-safe
|
||||
// category before being returned from the admin endpoint. The raw
|
||||
// upstream invalidation text must not cross the response boundary.
|
||||
assert!(
|
||||
warning.contains("authorization failed (status 403)"),
|
||||
"warning={warning}"
|
||||
);
|
||||
let model_ids = payload["data"]["models"]
|
||||
.as_array()
|
||||
.expect("models should be an array")
|
||||
@@ -900,7 +906,7 @@ async fn gateway_handles_admin_provider_query_models_respecting_key_api_formats_
|
||||
)
|
||||
.expect("endpoint transport should build"),
|
||||
],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-openai-cli",
|
||||
"provider-openai",
|
||||
"openai:responses",
|
||||
@@ -1036,13 +1042,13 @@ async fn gateway_handles_admin_provider_query_models_aggregating_active_keys_imp
|
||||
)
|
||||
.expect("endpoint transport should build")],
|
||||
vec![
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-1",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"sk-test-1",
|
||||
),
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-2",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -1131,7 +1137,7 @@ async fn gateway_handles_admin_provider_query_models_for_fixed_provider_without_
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-codex-oauth",
|
||||
"provider-codex",
|
||||
"openai:responses",
|
||||
@@ -1251,7 +1257,7 @@ async fn gateway_handles_admin_provider_query_test_model_locally_with_trusted_ad
|
||||
"openai:chat",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-openai-primary",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -1365,7 +1371,7 @@ async fn gateway_handles_admin_provider_query_embedding_model_test_impl() {
|
||||
"openai:embedding",
|
||||
"https://api.siliconflow.example",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-siliconflow-embedding",
|
||||
"provider-siliconflow",
|
||||
"openai:embedding",
|
||||
@@ -1486,7 +1492,7 @@ async fn gateway_handles_admin_provider_query_doubao_text_embedding_model_test_i
|
||||
"doubao:embedding",
|
||||
"https://ark.volces.example/api/v3",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-doubao-embedding",
|
||||
"provider-doubao",
|
||||
"doubao:embedding",
|
||||
@@ -1608,7 +1614,7 @@ async fn gateway_handles_admin_provider_query_gemini_embedding_model_test_impl()
|
||||
"gemini:embedding",
|
||||
"https://generativelanguage.googleapis.com/v1beta",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-gemini-embedding",
|
||||
"provider-gemini",
|
||||
"gemini:embedding",
|
||||
@@ -1726,7 +1732,7 @@ async fn gateway_handles_admin_provider_query_vertex_gemini_embedding_model_test
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let mut provider = sample_provider("provider-vertex-ai", "Vertex AI", 10);
|
||||
provider.provider_type = "vertex_ai".to_string();
|
||||
let mut key = sample_key(
|
||||
let mut key = sample_bound_key(
|
||||
"key-vertex-gemini-embedding",
|
||||
"provider-vertex-ai",
|
||||
"gemini:embedding",
|
||||
@@ -1873,7 +1879,7 @@ async fn gateway_handles_admin_provider_query_jina_embedding_model_test_impl() {
|
||||
"jina:embedding",
|
||||
"https://api.jina.example",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-jina-embedding",
|
||||
"provider-jina-embedding",
|
||||
"jina:embedding",
|
||||
@@ -1995,7 +2001,7 @@ async fn gateway_handles_admin_provider_query_openai_rerank_model_test_impl() {
|
||||
"openai:rerank",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-openai-rerank",
|
||||
"provider-openai-rerank",
|
||||
"openai:rerank",
|
||||
@@ -2123,7 +2129,7 @@ async fn gateway_handles_admin_provider_query_rerank_model_test_impl() {
|
||||
"jina:rerank",
|
||||
"https://api.jina.example",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-jina-rerank",
|
||||
"provider-jina",
|
||||
"jina:rerank",
|
||||
@@ -2254,7 +2260,7 @@ async fn gateway_maps_admin_provider_model_before_model_list_test_request_impl()
|
||||
"openai:chat",
|
||||
"https://api.minimax.example",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-minimax-primary",
|
||||
"provider-minimax",
|
||||
"openai:chat",
|
||||
@@ -2498,7 +2504,7 @@ async fn gateway_streams_codex_openai_responses_upstream_for_admin_pool_model_te
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![endpoint],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-codex-primary",
|
||||
"provider-codex",
|
||||
"openai:responses",
|
||||
@@ -2656,20 +2662,18 @@ async fn gateway_executes_codex_search_admin_pool_model_test_with_search_contrac
|
||||
"https://chatgpt.com/backend-api/codex",
|
||||
);
|
||||
endpoint.config = Some(json!({"upstream_stream_policy": "force_stream"}));
|
||||
let mut key = sample_key(
|
||||
let mut key = sample_bound_key(
|
||||
"key-codex-search",
|
||||
"provider-codex-search",
|
||||
"openai:search",
|
||||
"codex-search-access-token",
|
||||
);
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.encrypted_auth_config = Some(
|
||||
aether_crypto::encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"codex","account_id":"account-search-admin","is_fedramp":true}"#,
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
key.encrypted_auth_config = Some(sample_bound_auth_config(
|
||||
"provider-codex-search",
|
||||
"key-codex-search",
|
||||
r#"{"provider_type":"codex","account_id":"account-search-admin","is_fedramp":true}"#,
|
||||
));
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![endpoint],
|
||||
@@ -2796,24 +2800,22 @@ async fn gateway_routes_grok_responses_admin_pool_model_test_through_grok_runtim
|
||||
let mut provider = sample_provider("provider-grok", "Grok", 10);
|
||||
provider.provider_type = "grok".to_string();
|
||||
provider.config = Some(json!({"pool_advanced": {}}));
|
||||
let mut key = sample_key(
|
||||
let mut key = sample_bound_key(
|
||||
"key-grok-oauth",
|
||||
"provider-grok",
|
||||
"openai:responses",
|
||||
"__placeholder__",
|
||||
);
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.encrypted_auth_config = Some(
|
||||
aether_crypto::encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{
|
||||
key.encrypted_auth_config = Some(sample_bound_auth_config(
|
||||
"provider-grok",
|
||||
"key-grok-oauth",
|
||||
r#"{
|
||||
"provider_type":"grok",
|
||||
"sso_token":"grok-sso",
|
||||
"sso_rw_token":"grok-rw"
|
||||
}"#,
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
));
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![sample_endpoint(
|
||||
@@ -2933,7 +2935,7 @@ async fn gateway_streams_windsurf_connect_upstream_for_admin_model_test_impl() {
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let mut provider = sample_provider("provider-windsurf", "Windsurf", 10);
|
||||
provider.provider_type = "windsurf".to_string();
|
||||
let mut key = sample_key(
|
||||
let mut key = sample_bound_key(
|
||||
"key-windsurf-primary",
|
||||
"provider-windsurf",
|
||||
"openai:chat",
|
||||
@@ -3045,7 +3047,7 @@ async fn gateway_uses_pool_scheduler_order_for_admin_pool_model_test_impl() {
|
||||
]
|
||||
}
|
||||
}));
|
||||
let mut free_key = sample_key(
|
||||
let mut free_key = sample_bound_key(
|
||||
"key-codex-free",
|
||||
"provider-codex",
|
||||
"openai:responses",
|
||||
@@ -3060,7 +3062,7 @@ async fn gateway_uses_pool_scheduler_order_for_admin_pool_model_test_impl() {
|
||||
"usage_ratio": 0.1
|
||||
}
|
||||
}));
|
||||
let mut plus_key = sample_key(
|
||||
let mut plus_key = sample_bound_key(
|
||||
"key-codex-plus",
|
||||
"provider-codex",
|
||||
"openai:responses",
|
||||
@@ -3253,13 +3255,13 @@ async fn gateway_handles_admin_provider_query_test_model_failover_locally_with_t
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-first",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"sk-test-first",
|
||||
),
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-second",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -3382,17 +3384,17 @@ async fn gateway_handles_admin_provider_query_test_model_for_kiro_locally_impl()
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let mut provider = sample_provider("provider-kiro", "Kiro", 10);
|
||||
provider.provider_type = "kiro".to_string();
|
||||
let mut key = sample_key(
|
||||
let mut key = sample_bound_key(
|
||||
"key-kiro-primary",
|
||||
"provider-kiro",
|
||||
"claude:messages",
|
||||
"__placeholder__",
|
||||
);
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.encrypted_auth_config = Some(
|
||||
aether_crypto::encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{
|
||||
key.encrypted_auth_config = Some(sample_bound_auth_config(
|
||||
"provider-kiro",
|
||||
"key-kiro-primary",
|
||||
r#"{
|
||||
"provider_type":"kiro",
|
||||
"auth_method":"idc",
|
||||
"access_token":"cached-kiro-token",
|
||||
@@ -3403,9 +3405,7 @@ async fn gateway_handles_admin_provider_query_test_model_for_kiro_locally_impl()
|
||||
"client_id":"client-id",
|
||||
"client_secret":"client-secret"
|
||||
}"#,
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
));
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
@@ -3518,17 +3518,17 @@ async fn gateway_uses_kiro_mapped_model_name_for_explicit_model_mapping_test_imp
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let mut provider = sample_provider("provider-kiro", "Kiro", 10);
|
||||
provider.provider_type = "kiro".to_string();
|
||||
let mut key = sample_key(
|
||||
let mut key = sample_bound_key(
|
||||
"key-kiro-primary",
|
||||
"provider-kiro",
|
||||
"claude:messages",
|
||||
"__placeholder__",
|
||||
);
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.encrypted_auth_config = Some(
|
||||
aether_crypto::encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{
|
||||
key.encrypted_auth_config = Some(sample_bound_auth_config(
|
||||
"provider-kiro",
|
||||
"key-kiro-primary",
|
||||
r#"{
|
||||
"provider_type":"kiro",
|
||||
"auth_method":"idc",
|
||||
"access_token":"cached-kiro-token",
|
||||
@@ -3539,9 +3539,7 @@ async fn gateway_uses_kiro_mapped_model_name_for_explicit_model_mapping_test_imp
|
||||
"client_id":"client-id",
|
||||
"client_secret":"client-secret"
|
||||
}"#,
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
));
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
@@ -3728,12 +3726,12 @@ async fn gateway_handles_admin_provider_query_test_model_failover_for_kiro_local
|
||||
let mut provider = sample_provider("provider-kiro", "Kiro", 10);
|
||||
provider.provider_type = "kiro".to_string();
|
||||
let build_key = |id: &str| {
|
||||
let mut key = sample_key(id, "provider-kiro", "claude:messages", "__placeholder__");
|
||||
let mut key = sample_bound_key(id, "provider-kiro", "claude:messages", "__placeholder__");
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.encrypted_auth_config = Some(
|
||||
aether_crypto::encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{
|
||||
key.encrypted_auth_config = Some(sample_bound_auth_config(
|
||||
"provider-kiro",
|
||||
id,
|
||||
r#"{
|
||||
"provider_type":"kiro",
|
||||
"auth_method":"idc",
|
||||
"access_token":"cached-kiro-token",
|
||||
@@ -3744,9 +3742,7 @@ async fn gateway_handles_admin_provider_query_test_model_failover_for_kiro_local
|
||||
"client_id":"client-id",
|
||||
"client_secret":"client-secret"
|
||||
}"#,
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
));
|
||||
key
|
||||
};
|
||||
|
||||
@@ -3890,12 +3886,12 @@ async fn gateway_retries_kiro_failover_after_http_error_without_message_impl() {
|
||||
let mut provider = sample_provider("provider-kiro", "Kiro", 10);
|
||||
provider.provider_type = "kiro".to_string();
|
||||
let build_key = |id: &str| {
|
||||
let mut key = sample_key(id, "provider-kiro", "claude:messages", "__placeholder__");
|
||||
let mut key = sample_bound_key(id, "provider-kiro", "claude:messages", "__placeholder__");
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.encrypted_auth_config = Some(
|
||||
aether_crypto::encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{
|
||||
key.encrypted_auth_config = Some(sample_bound_auth_config(
|
||||
"provider-kiro",
|
||||
id,
|
||||
r#"{
|
||||
"provider_type":"kiro",
|
||||
"auth_method":"idc",
|
||||
"access_token":"cached-kiro-token",
|
||||
@@ -3906,9 +3902,7 @@ async fn gateway_retries_kiro_failover_after_http_error_without_message_impl() {
|
||||
"client_id":"client-id",
|
||||
"client_secret":"client-secret"
|
||||
}"#,
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
));
|
||||
key
|
||||
};
|
||||
|
||||
@@ -4055,7 +4049,7 @@ async fn gateway_handles_non_kiro_multi_model_failover_locally_impl() {
|
||||
"openai:chat",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-openai-primary",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -4235,7 +4229,7 @@ async fn gateway_handles_openai_responses_test_model_locally_impl() {
|
||||
"openai:responses",
|
||||
"https://tiger.bookapi.cc/codex",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-openai-cli",
|
||||
"provider-openai",
|
||||
"openai:responses",
|
||||
@@ -4367,7 +4361,7 @@ async fn gateway_handles_openai_image_test_model_locally_impl() {
|
||||
"openai:image",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-openai-image",
|
||||
"provider-openai",
|
||||
"openai:image",
|
||||
@@ -4431,7 +4425,7 @@ async fn gateway_reports_transport_unsupported_reason_for_non_kiro_provider_impl
|
||||
"gemini:generate_content",
|
||||
"https://cloudcode-pa.googleapis.com",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-antigravity-gemini",
|
||||
"provider-antigravity",
|
||||
"gemini:generate_content",
|
||||
@@ -4582,26 +4576,24 @@ async fn gateway_handles_antigravity_endpoint_test_model_locally_impl() {
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let mut provider = sample_provider("provider-antigravity", "Antigravity", 10);
|
||||
provider.provider_type = "antigravity".to_string();
|
||||
let mut key = sample_key(
|
||||
let mut key = sample_bound_key(
|
||||
"key-antigravity-gemini",
|
||||
"provider-antigravity",
|
||||
"gemini:generate_content",
|
||||
"cached-antigravity-token",
|
||||
);
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.encrypted_auth_config = Some(
|
||||
aether_crypto::encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{
|
||||
key.encrypted_auth_config = Some(sample_bound_auth_config(
|
||||
"provider-antigravity",
|
||||
"key-antigravity-gemini",
|
||||
r#"{
|
||||
"provider_type":"antigravity",
|
||||
"project_id":"project-ant-123",
|
||||
"client_version":"1.2.3",
|
||||
"session_id":"sess-ant-123",
|
||||
"refresh_token":"rt-ant-123"
|
||||
}"#,
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
));
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![sample_endpoint(
|
||||
@@ -4764,20 +4756,18 @@ async fn gateway_hydrates_antigravity_project_id_from_load_code_assist_for_test_
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let mut provider = sample_provider("provider-antigravity", "Antigravity", 10);
|
||||
provider.provider_type = "antigravity".to_string();
|
||||
let mut key = sample_key(
|
||||
let mut key = sample_bound_key(
|
||||
"key-antigravity-gemini",
|
||||
"provider-antigravity",
|
||||
"gemini:generate_content",
|
||||
"cached-antigravity-token",
|
||||
);
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.encrypted_auth_config = Some(
|
||||
aether_crypto::encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"antigravity","refresh_token":"rt-antigravity-123"}"#,
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
key.encrypted_auth_config = Some(sample_bound_auth_config(
|
||||
"provider-antigravity",
|
||||
"key-antigravity-gemini",
|
||||
r#"{"provider_type":"antigravity","refresh_token":"rt-antigravity-123"}"#,
|
||||
));
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![sample_endpoint(
|
||||
@@ -4906,13 +4896,13 @@ async fn gateway_prefers_supported_non_kiro_endpoint_when_api_format_is_omitted_
|
||||
),
|
||||
],
|
||||
vec![
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-cli",
|
||||
"provider-openai",
|
||||
"openai:responses",
|
||||
"sk-test-cli",
|
||||
),
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-chat",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -5020,7 +5010,7 @@ async fn gateway_prefers_transport_supported_non_kiro_endpoint_when_api_format_i
|
||||
"https://api.openai.example/v1",
|
||||
),
|
||||
],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-openai-chat",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -5123,7 +5113,7 @@ async fn gateway_prefers_supported_non_kiro_endpoint_with_compatible_key_when_ap
|
||||
"https://api.openai.example/v1",
|
||||
),
|
||||
],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-openai-chat",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -5225,7 +5215,7 @@ async fn gateway_uses_compatible_cli_endpoint_when_api_format_is_omitted_impl()
|
||||
"https://api.openai.example/v1",
|
||||
),
|
||||
],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-openai-cli",
|
||||
"provider-openai",
|
||||
"openai:responses",
|
||||
@@ -5325,7 +5315,7 @@ async fn gateway_uses_runnable_cli_endpoint_after_chat_preference_when_api_forma
|
||||
"openai:responses",
|
||||
"https://api.openai.example/v1",
|
||||
);
|
||||
let mut shared_key = sample_key(
|
||||
let mut shared_key = sample_bound_key(
|
||||
"key-openai-shared",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -5426,7 +5416,7 @@ async fn gateway_handles_openai_responses_test_model_failover_locally_impl() {
|
||||
"openai:responses",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-openai-cli",
|
||||
"provider-openai",
|
||||
"openai:responses",
|
||||
@@ -5527,7 +5517,7 @@ async fn gateway_handles_claude_cli_test_model_locally_impl() {
|
||||
"claude:messages",
|
||||
"https://api.anthropic.example/v1",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-claude-cli",
|
||||
"provider-claude",
|
||||
"claude:messages",
|
||||
@@ -5622,7 +5612,7 @@ async fn gateway_uses_compatible_claude_cli_endpoint_when_api_format_is_omitted_
|
||||
"claude:messages",
|
||||
"https://api.anthropic.example/v1",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-claude-cli",
|
||||
"provider-claude",
|
||||
"claude:messages",
|
||||
@@ -5718,7 +5708,7 @@ async fn gateway_handles_claude_cli_test_model_failover_locally_impl() {
|
||||
"claude:messages",
|
||||
"https://api.anthropic.example/v1",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-claude-cli",
|
||||
"provider-claude",
|
||||
"claude:messages",
|
||||
@@ -5822,7 +5812,7 @@ async fn gateway_handles_gemini_cli_test_model_locally_impl() {
|
||||
"gemini:generate_content",
|
||||
"https://generativelanguage.googleapis.com",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-gemini-cli",
|
||||
"provider-gemini",
|
||||
"gemini:generate_content",
|
||||
@@ -5931,20 +5921,18 @@ async fn gateway_handles_gemini_cli_test_model_with_oauth_header_fallback_impl()
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let mut provider = sample_provider("provider-gemini", "Gemini", 10);
|
||||
provider.provider_type = "gemini_cli".to_string();
|
||||
let mut key = sample_key(
|
||||
let mut key = sample_bound_key(
|
||||
"key-gemini-cli",
|
||||
"provider-gemini",
|
||||
"gemini:generate_content",
|
||||
"cached-gemini-cli-token",
|
||||
);
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.encrypted_auth_config = Some(
|
||||
aether_crypto::encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"gemini_cli","project_id":"project-1"}"#,
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
key.encrypted_auth_config = Some(sample_bound_auth_config(
|
||||
"provider-gemini",
|
||||
"key-gemini-cli",
|
||||
r#"{"provider_type":"gemini_cli","project_id":"project-1"}"#,
|
||||
));
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![sample_endpoint(
|
||||
@@ -6086,20 +6074,18 @@ async fn gateway_hydrates_gemini_cli_project_id_from_load_code_assist_for_test_m
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let mut provider = sample_provider("provider-gemini", "Gemini", 10);
|
||||
provider.provider_type = "gemini_cli".to_string();
|
||||
let mut key = sample_key(
|
||||
let mut key = sample_bound_key(
|
||||
"key-gemini-cli",
|
||||
"provider-gemini",
|
||||
"gemini:generate_content",
|
||||
"cached-gemini-cli-token",
|
||||
);
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.encrypted_auth_config = Some(
|
||||
aether_crypto::encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"gemini_cli","refresh_token":"rt-gemini-cli-123"}"#,
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
key.encrypted_auth_config = Some(sample_bound_auth_config(
|
||||
"provider-gemini",
|
||||
"key-gemini-cli",
|
||||
r#"{"provider_type":"gemini_cli","refresh_token":"rt-gemini-cli-123"}"#,
|
||||
));
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![sample_endpoint(
|
||||
@@ -6219,7 +6205,7 @@ async fn gateway_uses_compatible_gemini_cli_endpoint_when_api_format_is_omitted_
|
||||
"gemini:generate_content",
|
||||
"https://generativelanguage.googleapis.com",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-gemini-cli",
|
||||
"provider-gemini",
|
||||
"gemini:generate_content",
|
||||
@@ -6315,7 +6301,7 @@ async fn gateway_handles_gemini_cli_test_model_failover_locally_impl() {
|
||||
"gemini:generate_content",
|
||||
"https://generativelanguage.googleapis.com",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-gemini-cli",
|
||||
"provider-gemini",
|
||||
"gemini:generate_content",
|
||||
@@ -6431,20 +6417,18 @@ async fn gateway_unwraps_gemini_cli_v1internal_response_for_failover_model_test_
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let mut provider = sample_provider("provider-gemini-cli", "Gemini CLI", 10);
|
||||
provider.provider_type = "gemini_cli".to_string();
|
||||
let mut key = sample_key(
|
||||
let mut key = sample_bound_key(
|
||||
"key-gemini-cli",
|
||||
"provider-gemini-cli",
|
||||
"gemini:generate_content",
|
||||
"cached-gemini-cli-token",
|
||||
);
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.encrypted_auth_config = Some(
|
||||
aether_crypto::encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"gemini_cli","project_id":"project-1"}"#,
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
key.encrypted_auth_config = Some(sample_bound_auth_config(
|
||||
"provider-gemini-cli",
|
||||
"key-gemini-cli",
|
||||
r#"{"provider_type":"gemini_cli","project_id":"project-1"}"#,
|
||||
));
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![sample_endpoint(
|
||||
@@ -6546,7 +6530,7 @@ async fn gateway_handles_admin_provider_query_test_model_failover_with_single_mo
|
||||
"openai:chat",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-openai-alias",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -6667,13 +6651,13 @@ async fn gateway_retries_non_kiro_failover_after_http_error_without_message_impl
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-first",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"sk-test-first",
|
||||
),
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-second",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -6794,13 +6778,13 @@ async fn gateway_retries_non_kiro_failover_after_success_status_without_body_imp
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-first",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"sk-test-first",
|
||||
),
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-second",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
@@ -6850,7 +6834,9 @@ async fn gateway_retries_non_kiro_failover_after_success_status_without_body_imp
|
||||
assert_eq!(attempts[0]["status_code"], json!(200));
|
||||
assert_eq!(
|
||||
attempts[0]["error_message"],
|
||||
json!("Provider returned HTTP 200 without a model-test response body")
|
||||
// Attempt diagnostics intentionally expose only the status class;
|
||||
// detailed provider response text is not returned to the admin UI.
|
||||
json!("HTTP 200")
|
||||
);
|
||||
assert_eq!(attempts[1]["status"], json!("success"));
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
||||
use aether_data::repository::candidates::InMemoryRequestCandidateRepository;
|
||||
use aether_data::repository::global_models::InMemoryGlobalModelReadRepository;
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
@@ -23,9 +22,10 @@ use serde_json::json;
|
||||
|
||||
use super::super::{
|
||||
build_router_with_state, issue_test_admin_access_token, sample_admin_provider_model,
|
||||
sample_endpoint, sample_key, sample_provider, sample_provider_active_global_model,
|
||||
sample_provider_model_stats, sample_provider_quota, sample_public_global_model_with_mappings,
|
||||
sample_request_candidate, start_server, AppState,
|
||||
sample_bound_key, sample_bound_key as sample_key, sample_bound_provider_proxy, sample_endpoint,
|
||||
sample_provider, sample_provider_active_global_model, sample_provider_model_stats,
|
||||
sample_provider_quota, sample_public_global_model_with_mappings, sample_request_candidate,
|
||||
start_server, AppState,
|
||||
};
|
||||
use crate::admin_api::{
|
||||
maybe_build_local_admin_providers_response, AdminAppState, AdminRequestContext,
|
||||
@@ -233,7 +233,11 @@ async fn gateway_handles_admin_provider_summary_locally_with_trusted_admin_princ
|
||||
true,
|
||||
None,
|
||||
Some(4),
|
||||
Some(json!({"host": "proxy.example", "password": "secret"})),
|
||||
Some(sample_bound_provider_proxy(
|
||||
"provider-openai",
|
||||
"proxy.example",
|
||||
"secret",
|
||||
)),
|
||||
Some(45.0),
|
||||
Some(12.0),
|
||||
Some(json!({
|
||||
@@ -269,25 +273,12 @@ async fn gateway_handles_admin_provider_summary_locally_with_trusted_admin_princ
|
||||
"sk-test-chat",
|
||||
)
|
||||
.with_health_fields(Some(json!({"openai:chat": {"health_score": 0.25}})), None),
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-openai-cli",
|
||||
"provider-openai",
|
||||
"openai:responses",
|
||||
"sk-test-cli",
|
||||
"sk-test-cli-2",
|
||||
)
|
||||
.with_transport_fields(
|
||||
Some(json!(["openai:responses"])),
|
||||
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "sk-test-cli-2")
|
||||
.expect("api key ciphertext should build"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("key transport should build")
|
||||
.with_health_fields(
|
||||
Some(json!({"openai:responses": {"health_score": 0.75}})),
|
||||
None,
|
||||
@@ -879,7 +870,7 @@ async fn gateway_updates_admin_provider_locally_with_trusted_admin_principal() {
|
||||
Some(aether_contracts::MAX_EXECUTION_REQUEST_TIMEOUT_SECS as f64)
|
||||
);
|
||||
assert_eq!(payload["stream_first_byte_timeout"], 11.0);
|
||||
assert_eq!(payload["proxy"], json!({"url": "https://proxy.example"}));
|
||||
assert_eq!(payload["proxy"], json!({"url": "https://proxy.example/"}));
|
||||
assert_eq!(payload["claude_code_advanced"], json!({"pool_size": 2}));
|
||||
assert_eq!(payload["pool_advanced"], json!({}));
|
||||
assert_eq!(payload["failover_rules"], json!({"strategy": "ordered"}));
|
||||
@@ -1751,7 +1742,7 @@ async fn gateway_handles_admin_provider_mapping_preview_locally_with_trusted_adm
|
||||
let keys = payload["keys"].as_array().expect("keys should be an array");
|
||||
assert_eq!(keys.len(), 1);
|
||||
assert_eq!(keys[0]["key_id"], "key-openai-preview");
|
||||
assert_eq!(keys[0]["masked_key"], "sk-p***1234");
|
||||
assert_eq!(keys[0]["masked_key"], "sk-p***234");
|
||||
assert_eq!(keys[0]["allowed_models"], json!(["gpt-5", "gpt-4.1-mini"]));
|
||||
|
||||
let matches = keys[0]["matching_global_models"]
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use aether_data::repository::management_tokens::InMemoryManagementTokenRepository;
|
||||
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
|
||||
use aether_data::repository::management_tokens::{
|
||||
InMemoryManagementTokenRepository, ManagementTokenListQuery, ManagementTokenReadRepository,
|
||||
};
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use aether_data::repository::proxy_nodes::{
|
||||
InMemoryProxyNodeRepository, ProxyNodeHeartbeatMutation, StoredProxyNodeEvent,
|
||||
};
|
||||
use aether_data::repository::users::InMemoryUserReadRepository;
|
||||
use axum::body::Body;
|
||||
use axum::extract::ws::Message;
|
||||
use axum::routing::any;
|
||||
@@ -16,8 +20,10 @@ use serde_json::json;
|
||||
use tokio::sync::watch;
|
||||
|
||||
use super::super::{
|
||||
build_router_with_state, hash_management_token, sample_endpoint, sample_key,
|
||||
sample_management_token, sample_provider, sample_proxy_node, start_server, AppState,
|
||||
authenticated_tunnel_control_plane_request, build_router_with_state, hash_management_token,
|
||||
sample_endpoint, sample_key, sample_management_token, sample_provider, sample_proxy_node,
|
||||
start_server, with_tunnel_control_plane_key, AppState, TUNNEL_CONTROL_PLANE_TEST_GENERATION,
|
||||
TUNNEL_CONTROL_PLANE_TEST_PSK,
|
||||
};
|
||||
use crate::constants::{
|
||||
GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
|
||||
@@ -30,6 +36,16 @@ use crate::maintenance::{
|
||||
};
|
||||
use crate::tunnel::{tunnel_protocol, TunnelProxyConn};
|
||||
|
||||
async fn recv_tunnel_test_frame(
|
||||
proxy_rx: &mut aether_runtime::BoundedQueueReceiver<Message>,
|
||||
description: &str,
|
||||
) -> Message {
|
||||
tokio::time::timeout(Duration::from_secs(5), proxy_rx.recv())
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("timed out waiting for {description}"))
|
||||
.unwrap_or_else(|| panic!("proxy channel closed before {description}"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_proxy_nodes_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
@@ -103,7 +119,8 @@ async fn gateway_handles_admin_proxy_nodes_locally_with_trusted_admin_principal(
|
||||
assert_eq!(items[0]["is_manual"], true);
|
||||
assert_eq!(items[0]["proxy_url"], "http://proxy.example:8080");
|
||||
assert_eq!(items[0]["proxy_username"], "alice");
|
||||
assert_eq!(items[0]["proxy_password"], "su****et");
|
||||
assert_eq!(items[0]["has_proxy_password"], true);
|
||||
assert!(items[0].get("proxy_password").is_none());
|
||||
assert!(items[0]["created_at"].is_string());
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
@@ -112,7 +129,7 @@ async fn gateway_handles_admin_proxy_nodes_locally_with_trusted_admin_principal(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_returns_full_manual_proxy_node_detail_locally_with_trusted_admin_principal() {
|
||||
async fn gateway_does_not_return_manual_proxy_password_in_node_detail() {
|
||||
let mut manual_node = sample_proxy_node("proxy-node-manual");
|
||||
manual_node.name = "alpha-manual".to_string();
|
||||
manual_node.status = "online".to_string();
|
||||
@@ -149,7 +166,8 @@ async fn gateway_returns_full_manual_proxy_node_detail_locally_with_trusted_admi
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["node"]["id"], "proxy-node-manual");
|
||||
assert_eq!(payload["node"]["proxy_username"], "alice");
|
||||
assert_eq!(payload["node"]["proxy_password"], "supersecret");
|
||||
assert_eq!(payload["node"]["has_proxy_password"], true);
|
||||
assert!(payload["node"].get("proxy_password").is_none());
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
@@ -191,6 +209,7 @@ async fn gateway_reports_active_proxy_upgrade_rollout_in_proxy_node_list() {
|
||||
data_state
|
||||
.apply_proxy_node_heartbeat(&ProxyNodeHeartbeatMutation {
|
||||
node_id: "node-alpha".to_string(),
|
||||
expected_tunnel_generation: None,
|
||||
heartbeat_interval: None,
|
||||
active_connections: None,
|
||||
total_requests_delta: None,
|
||||
@@ -354,6 +373,7 @@ async fn gateway_clears_proxy_upgrade_rollout_conflicts_locally() {
|
||||
.update_proxy_node_remote_config(
|
||||
&aether_data::repository::proxy_nodes::ProxyNodeRemoteConfigMutation {
|
||||
node_id: "node-beta".to_string(),
|
||||
expected_tunnel_generation: None,
|
||||
node_name: None,
|
||||
allowed_ports: None,
|
||||
log_level: None,
|
||||
@@ -932,7 +952,177 @@ async fn gateway_rejects_management_token_without_required_admin_route_permissio
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_registers_proxy_node_with_management_token_when_allowed_ips_is_json_null() {
|
||||
async fn proxy_node_install_session_requires_admin_and_preserves_parent_token_constraints() {
|
||||
let write_raw_token = "ae-proxy-install-write-only";
|
||||
let admin_raw_token = "ae-proxy-install-admin";
|
||||
let state = AppState::new().expect("gateway should build");
|
||||
let admin_user = state
|
||||
.create_local_auth_user_with_settings(
|
||||
Some("[email protected]".to_string()),
|
||||
true,
|
||||
"admin".to_string(),
|
||||
"hash".to_string(),
|
||||
"admin".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("admin user should be created")
|
||||
.expect("admin user should exist");
|
||||
|
||||
let parent_allowed_ips = json!(["127.0.0.1"]);
|
||||
let parent_expires_at = 4_102_444_800;
|
||||
let mut write_parent = sample_management_token(
|
||||
"token-proxy-install-write",
|
||||
&admin_user.id,
|
||||
"proxy-install-write",
|
||||
true,
|
||||
);
|
||||
write_parent.token.allowed_ips = Some(parent_allowed_ips.clone());
|
||||
write_parent.token.permissions = Some(json!(["admin:proxy_nodes:write"]));
|
||||
write_parent.token.expires_at_unix_secs = Some(parent_expires_at);
|
||||
let mut admin_parent = sample_management_token(
|
||||
"token-proxy-install-admin",
|
||||
&admin_user.id,
|
||||
"proxy-install-admin",
|
||||
true,
|
||||
);
|
||||
admin_parent.token.allowed_ips = Some(parent_allowed_ips.clone());
|
||||
admin_parent.token.permissions = Some(json!(["admin:proxy_nodes:admin"]));
|
||||
admin_parent.token.expires_at_unix_secs = Some(parent_expires_at);
|
||||
|
||||
let management_token_repository =
|
||||
Arc::new(InMemoryManagementTokenRepository::seed_with_hashes(
|
||||
vec![write_parent, admin_parent],
|
||||
vec![
|
||||
(
|
||||
hash_management_token(write_raw_token),
|
||||
"token-proxy-install-write".to_string(),
|
||||
),
|
||||
(
|
||||
hash_management_token(admin_raw_token),
|
||||
"token-proxy-install-admin".to_string(),
|
||||
),
|
||||
],
|
||||
));
|
||||
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users([
|
||||
admin_user.clone()
|
||||
]));
|
||||
let state = state.with_data_state_for_tests(
|
||||
GatewayDataState::with_management_token_repository_for_tests(Arc::clone(
|
||||
&management_token_repository,
|
||||
))
|
||||
.with_user_reader(user_repository),
|
||||
);
|
||||
let gateway = build_router_with_state(state);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let denied = client
|
||||
.post(format!(
|
||||
"{gateway_url}/api/admin/proxy-nodes/install-sessions"
|
||||
))
|
||||
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||
.bearer_auth(write_raw_token)
|
||||
.json(&json!({ "node_name": "write-only-node" }))
|
||||
.send()
|
||||
.await
|
||||
.expect("write-only install request should complete");
|
||||
assert_eq!(denied.status(), StatusCode::FORBIDDEN);
|
||||
let denied_payload: serde_json::Value = denied.json().await.expect("denial should be json");
|
||||
assert_eq!(
|
||||
denied_payload["required_permission"],
|
||||
json!("admin:proxy_nodes:admin")
|
||||
);
|
||||
|
||||
let accepted = client
|
||||
.post(format!(
|
||||
"{gateway_url}/api/admin/proxy-nodes/install-sessions"
|
||||
))
|
||||
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||
.bearer_auth(admin_raw_token)
|
||||
.json(&json!({ "node_name": "constrained-node" }))
|
||||
.send()
|
||||
.await
|
||||
.expect("admin install request should complete");
|
||||
let accepted_status = accepted.status();
|
||||
let accepted_body = accepted.text().await.expect("response body should read");
|
||||
assert_eq!(
|
||||
accepted_status,
|
||||
StatusCode::OK,
|
||||
"unexpected response body: {accepted_body}"
|
||||
);
|
||||
let accepted_payload: serde_json::Value =
|
||||
serde_json::from_str(&accepted_body).expect("install response should be JSON");
|
||||
let install_code = accepted_payload["install_code"]
|
||||
.as_str()
|
||||
.expect("install code should be returned")
|
||||
.to_string();
|
||||
|
||||
let tokens = management_token_repository
|
||||
.list_management_tokens(&ManagementTokenListQuery {
|
||||
user_id: Some(admin_user.id.clone()),
|
||||
is_active: None,
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
})
|
||||
.await
|
||||
.expect("management tokens should list");
|
||||
assert_eq!(tokens.total, 3);
|
||||
let child = tokens
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| {
|
||||
!matches!(
|
||||
item.token.id.as_str(),
|
||||
"token-proxy-install-write" | "token-proxy-install-admin"
|
||||
)
|
||||
})
|
||||
.expect("install session should create one child management token");
|
||||
assert_eq!(child.token.allowed_ips, Some(parent_allowed_ips));
|
||||
assert_eq!(child.token.expires_at_unix_secs, Some(parent_expires_at));
|
||||
assert_eq!(
|
||||
child.token.permissions,
|
||||
Some(json!(["admin:proxy_nodes:write"]))
|
||||
);
|
||||
assert!(
|
||||
!child.token.is_active,
|
||||
"unused install-session token must remain disabled"
|
||||
);
|
||||
let child_id = child.token.id.clone();
|
||||
|
||||
// The in-memory repository cannot atomically verify the administrator row and therefore
|
||||
// rejects one-time activation. SQL-backed repositories cover the successful atomic path.
|
||||
let install_script = client
|
||||
.get(format!("{gateway_url}/install-tunnel/{install_code}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("tunnel install script should receive a response");
|
||||
assert_eq!(install_script.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
let consumed = management_token_repository
|
||||
.get_management_token_with_user(&child_id)
|
||||
.await
|
||||
.expect("consumed token lookup should succeed");
|
||||
assert!(
|
||||
consumed.is_none(),
|
||||
"failed one-time activation must discard the pending bearer"
|
||||
);
|
||||
|
||||
let replay = client
|
||||
.get(format!("{gateway_url}/install-tunnel/{install_code}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("tunnel install replay should receive a response");
|
||||
assert_eq!(replay.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_proxy_node_registration_when_allowed_ips_is_json_null() {
|
||||
let raw_token = "ae_proxy_register_json_null";
|
||||
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::default());
|
||||
let state = AppState::new().expect("gateway should build");
|
||||
@@ -989,16 +1179,11 @@ async fn gateway_registers_proxy_node_with_management_token_when_allowed_ips_is_
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(register_response.status(), StatusCode::OK);
|
||||
let register_payload: serde_json::Value = register_response
|
||||
.json()
|
||||
assert_eq!(register_response.status(), StatusCode::UNAUTHORIZED);
|
||||
let _ = register_response
|
||||
.bytes()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(register_payload["node"]["name"], "proxy-json-null");
|
||||
assert_eq!(
|
||||
register_payload["node"]["registered_by"],
|
||||
json!(admin_user.id)
|
||||
);
|
||||
.expect("error body should be readable");
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
@@ -1071,7 +1256,8 @@ async fn gateway_creates_updates_and_tests_manual_proxy_nodes_locally() {
|
||||
assert_eq!(create_payload["node"]["status"], "online");
|
||||
assert_eq!(create_payload["node"]["proxy_url"], proxy_url);
|
||||
assert_eq!(create_payload["node"]["proxy_username"], "alice");
|
||||
assert_eq!(create_payload["node"]["proxy_password"], "su****et");
|
||||
assert_eq!(create_payload["node"]["has_proxy_password"], true);
|
||||
assert!(create_payload["node"].get("proxy_password").is_none());
|
||||
|
||||
let test_url_response = client
|
||||
.post(format!("{gateway_url}/api/admin/proxy-nodes/test-url"))
|
||||
@@ -1202,22 +1388,25 @@ async fn gateway_tests_connected_tunnel_proxy_nodes_with_active_probe() {
|
||||
"https://probe.example/cdn-cgi/trace",
|
||||
);
|
||||
|
||||
let mut node = sample_proxy_node("node-online");
|
||||
let mut node = with_tunnel_control_plane_key(
|
||||
sample_proxy_node("node-online"),
|
||||
TUNNEL_CONTROL_PLANE_TEST_PSK,
|
||||
);
|
||||
node.status = "online".to_string();
|
||||
node.tunnel_connected = true;
|
||||
|
||||
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![node]));
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(GatewayDataState::with_proxy_node_repository_for_tests(
|
||||
proxy_node_repository,
|
||||
));
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_proxy_node_repository_for_tests(proxy_node_repository)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
);
|
||||
let tunnel_state = state.tunnel.app_state();
|
||||
let (proxy_tx, mut proxy_rx) = aether_runtime::bounded_queue(8);
|
||||
let (proxy_close_tx, _) = watch::channel(false);
|
||||
tunnel_state
|
||||
.hub
|
||||
.register_proxy(Arc::new(TunnelProxyConn::new(
|
||||
tunnel_state.hub.register_proxy(Arc::new(
|
||||
TunnelProxyConn::new(
|
||||
500,
|
||||
"node-online".to_string(),
|
||||
"Node Online".to_string(),
|
||||
@@ -1225,7 +1414,10 @@ async fn gateway_tests_connected_tunnel_proxy_nodes_with_active_probe() {
|
||||
proxy_close_tx,
|
||||
16,
|
||||
2,
|
||||
)));
|
||||
)
|
||||
.with_tunnel_generation(TUNNEL_CONTROL_PLANE_TEST_GENERATION.to_string())
|
||||
.with_authenticated_key(TUNNEL_CONTROL_PLANE_TEST_PSK.to_string()),
|
||||
));
|
||||
|
||||
let gateway = build_router_with_state(state);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
@@ -1246,7 +1438,7 @@ async fn gateway_tests_connected_tunnel_proxy_nodes_with_active_probe() {
|
||||
}
|
||||
});
|
||||
|
||||
let request_headers = match proxy_rx.recv().await.expect("headers frame should arrive") {
|
||||
let request_headers = match recv_tunnel_test_frame(&mut proxy_rx, "probe headers frame").await {
|
||||
Message::Binary(data) => data,
|
||||
other => panic!("unexpected message: {other:?}"),
|
||||
};
|
||||
@@ -1261,7 +1453,7 @@ async fn gateway_tests_connected_tunnel_proxy_nodes_with_active_probe() {
|
||||
assert_eq!(meta.url, "https://probe.example/cdn-cgi/trace");
|
||||
assert_eq!(meta.follow_redirects, Some(false));
|
||||
|
||||
let request_body = match proxy_rx.recv().await.expect("body frame should arrive") {
|
||||
let request_body = match recv_tunnel_test_frame(&mut proxy_rx, "probe body frame").await {
|
||||
Message::Binary(data) => data,
|
||||
other => panic!("unexpected message: {other:?}"),
|
||||
};
|
||||
@@ -1602,10 +1794,9 @@ async fn gateway_handles_admin_proxy_node_events_locally_with_trusted_admin_prin
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_reports_proxy_node_metrics_and_filters_events_locally() {
|
||||
let proxy_node_repository =
|
||||
Arc::new(InMemoryProxyNodeRepository::seed(vec![sample_proxy_node(
|
||||
"node-1",
|
||||
)]));
|
||||
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![
|
||||
with_tunnel_control_plane_key(sample_proxy_node("node-1"), TUNNEL_CONTROL_PLANE_TEST_PSK),
|
||||
]));
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
@@ -1620,61 +1811,73 @@ async fn gateway_reports_proxy_node_metrics_and_filters_events_locally() {
|
||||
.expect("system time should be after epoch")
|
||||
.as_secs();
|
||||
|
||||
let baseline_heartbeat_response = client
|
||||
.post(format!("{gateway_url}/api/internal/tunnel/heartbeat"))
|
||||
.json(&json!({
|
||||
"node_id": "node-1",
|
||||
"heartbeat_id": 90,
|
||||
"heartbeat_interval": 30,
|
||||
"active_connections": 0,
|
||||
"proxy_metadata": {
|
||||
"tunnel_metrics": {
|
||||
"connect_errors": 0,
|
||||
"disconnects": 0,
|
||||
"error_events_total": 0,
|
||||
"ws_in_bytes": 0,
|
||||
"ws_out_bytes": 0,
|
||||
"ws_in_frames": 0,
|
||||
"ws_out_frames": 0,
|
||||
"heartbeat_rtt_last_ms": 0
|
||||
}
|
||||
},
|
||||
"proxy_version": "2.0.0"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("baseline heartbeat request should succeed");
|
||||
let baseline_heartbeat = json!({
|
||||
"node_id": "node-1",
|
||||
"heartbeat_session_id": "node-1-session",
|
||||
"heartbeat_id": 90,
|
||||
"heartbeat_interval": 30,
|
||||
"active_connections": 0,
|
||||
"proxy_metadata": {
|
||||
"tunnel_metrics": {
|
||||
"connect_errors": 0,
|
||||
"disconnects": 0,
|
||||
"error_events_total": 0,
|
||||
"ws_in_bytes": 0,
|
||||
"ws_out_bytes": 0,
|
||||
"ws_in_frames": 0,
|
||||
"ws_out_frames": 0,
|
||||
"heartbeat_rtt_last_ms": 0
|
||||
}
|
||||
},
|
||||
"proxy_version": "2.0.0"
|
||||
});
|
||||
let baseline_heartbeat_response = authenticated_tunnel_control_plane_request(
|
||||
&client,
|
||||
format!("{gateway_url}/api/internal/tunnel/heartbeat"),
|
||||
"/api/internal/tunnel/heartbeat",
|
||||
"node-1",
|
||||
&baseline_heartbeat,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("baseline heartbeat request should succeed");
|
||||
assert_eq!(baseline_heartbeat_response.status(), StatusCode::OK);
|
||||
|
||||
let heartbeat_response = client
|
||||
.post(format!("{gateway_url}/api/internal/tunnel/heartbeat"))
|
||||
.json(&json!({
|
||||
"node_id": "node-1",
|
||||
"heartbeat_id": 91,
|
||||
"heartbeat_interval": 30,
|
||||
"active_connections": 7,
|
||||
"proxy_metadata": {
|
||||
"tunnel_metrics": {
|
||||
"connect_errors": 3,
|
||||
"disconnects": 1,
|
||||
"error_events_total": 1,
|
||||
"ws_in_bytes": 1000,
|
||||
"ws_out_bytes": 2000,
|
||||
"ws_in_frames": 10,
|
||||
"ws_out_frames": 20,
|
||||
"heartbeat_rtt_last_ms": 42
|
||||
},
|
||||
"recent_tunnel_errors": [{
|
||||
"timestamp_unix_secs": now_unix_secs,
|
||||
"category": "tcp_connect_timeout",
|
||||
"message": "tunnel TCP connect timeout"
|
||||
}]
|
||||
let heartbeat = json!({
|
||||
"node_id": "node-1",
|
||||
"heartbeat_session_id": "node-1-session",
|
||||
"heartbeat_id": 91,
|
||||
"heartbeat_interval": 30,
|
||||
"active_connections": 7,
|
||||
"proxy_metadata": {
|
||||
"tunnel_metrics": {
|
||||
"connect_errors": 3,
|
||||
"disconnects": 1,
|
||||
"error_events_total": 1,
|
||||
"ws_in_bytes": 1000,
|
||||
"ws_out_bytes": 2000,
|
||||
"ws_in_frames": 10,
|
||||
"ws_out_frames": 20,
|
||||
"heartbeat_rtt_last_ms": 42
|
||||
},
|
||||
"proxy_version": "2.0.0"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("heartbeat request should succeed");
|
||||
"recent_tunnel_errors": [{
|
||||
"timestamp_unix_secs": now_unix_secs,
|
||||
"category": "tcp_connect_timeout",
|
||||
"message": "tunnel TCP connect timeout"
|
||||
}]
|
||||
},
|
||||
"proxy_version": "2.0.0"
|
||||
});
|
||||
let heartbeat_response = authenticated_tunnel_control_plane_request(
|
||||
&client,
|
||||
format!("{gateway_url}/api/internal/tunnel/heartbeat"),
|
||||
"/api/internal/tunnel/heartbeat",
|
||||
"node-1",
|
||||
&heartbeat,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("heartbeat request should succeed");
|
||||
assert_eq!(heartbeat_response.status(), StatusCode::OK);
|
||||
|
||||
let from = now_unix_secs.saturating_sub(120);
|
||||
@@ -1794,6 +1997,7 @@ async fn gateway_updates_proxy_node_config_and_dispatches_upgrade_targets_locall
|
||||
);
|
||||
|
||||
let mut online_node = sample_proxy_node("node-online");
|
||||
online_node = with_tunnel_control_plane_key(online_node, TUNNEL_CONTROL_PLANE_TEST_PSK);
|
||||
online_node.status = "online".to_string();
|
||||
online_node.tunnel_connected = true;
|
||||
let mut online_node_2 = sample_proxy_node("node-zeta");
|
||||
@@ -1895,21 +2099,27 @@ async fn gateway_updates_proxy_node_config_and_dispatches_upgrade_targets_locall
|
||||
assert_eq!(blocked_upgrade_payload["updated"], 0);
|
||||
assert_eq!(blocked_upgrade_payload["skipped"], 3);
|
||||
|
||||
let heartbeat_response = client
|
||||
.post(format!("{gateway_url}/api/internal/tunnel/heartbeat"))
|
||||
.json(&json!({
|
||||
"node_id": "node-online",
|
||||
"heartbeat_id": 77,
|
||||
"heartbeat_interval": 45,
|
||||
"active_connections": 3,
|
||||
"total_requests": 5,
|
||||
"avg_latency_ms": 10.0,
|
||||
"proxy_metadata": { "arch": "arm64" },
|
||||
"proxy_version": "2.0.0"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
let heartbeat = json!({
|
||||
"node_id": "node-online",
|
||||
"heartbeat_session_id": "node-online-session",
|
||||
"heartbeat_id": 77,
|
||||
"heartbeat_interval": 45,
|
||||
"active_connections": 3,
|
||||
"total_requests": 5,
|
||||
"avg_latency_ms": 10.0,
|
||||
"proxy_metadata": { "arch": "arm64" },
|
||||
"proxy_version": "2.0.0"
|
||||
});
|
||||
let heartbeat_response = authenticated_tunnel_control_plane_request(
|
||||
&client,
|
||||
format!("{gateway_url}/api/internal/tunnel/heartbeat"),
|
||||
"/api/internal/tunnel/heartbeat",
|
||||
"node-online",
|
||||
&heartbeat,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(heartbeat_response.status(), StatusCode::OK);
|
||||
let heartbeat_payload: serde_json::Value = heartbeat_response
|
||||
.json()
|
||||
|
||||
@@ -7,6 +7,9 @@ use http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::json;
|
||||
|
||||
use aether_runtime_state::{RedisClientConfig, RuntimeState};
|
||||
use aether_test_support::ManagedRedisServer;
|
||||
|
||||
use super::super::super::send_request;
|
||||
use super::super::{build_router_with_state, start_server, AppState};
|
||||
use crate::admin_api::{
|
||||
@@ -108,6 +111,59 @@ async fn gateway_blocks_forwarded_ip_from_trusted_proxy() {
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_fails_closed_when_ip_blacklist_state_is_unavailable() {
|
||||
let mut redis = match ManagedRedisServer::start().await {
|
||||
Ok(redis) => redis,
|
||||
Err(error) if error.to_string().contains("No such file or directory") => {
|
||||
eprintln!("skipping IP blacklist Redis outage test: {error}");
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("Redis test server should start: {error}"),
|
||||
};
|
||||
let runtime_state = Arc::new(
|
||||
RuntimeState::redis(
|
||||
RedisClientConfig {
|
||||
url: redis.redis_url().to_string(),
|
||||
key_prefix: Some(format!("blacklist-outage-{}", std::process::id())),
|
||||
},
|
||||
Some(250),
|
||||
)
|
||||
.await
|
||||
.expect("Redis runtime state should build"),
|
||||
);
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_runtime_state(runtime_state),
|
||||
);
|
||||
redis.stop().expect("Redis test server should stop");
|
||||
|
||||
let request = Request::builder()
|
||||
.uri("/api/public/system")
|
||||
.body(Body::empty())
|
||||
.expect("request should build");
|
||||
let response = send_request(gateway, request).await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(crate::constants::EXECUTION_PATH_HEADER)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(crate::constants::EXECUTION_PATH_LOCAL_AUTH_DENIED)
|
||||
);
|
||||
let payload = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should collect")
|
||||
.to_bytes();
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_slice(&payload).expect("response should be json");
|
||||
assert_eq!(payload["error"]["message"], "IP 访问控制暂时不可用");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_security_whitelist_matches_cidr() {
|
||||
let state = AppState::new()
|
||||
|
||||
@@ -23,21 +23,28 @@ use axum::routing::{any, delete, get, post, put};
|
||||
use axum::{extract::Request, Router};
|
||||
use http::StatusCode;
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::super::{
|
||||
build_router_with_state, issue_test_admin_access_token, sample_admin_global_model,
|
||||
sample_admin_provider_model, sample_endpoint, sample_key, sample_ldap_module_config,
|
||||
sample_oauth_provider_config, sample_provider, sample_proxy_node,
|
||||
sample_admin_provider_model, sample_bound_key, sample_endpoint, sample_key,
|
||||
sample_ldap_module_config, sample_oauth_provider_config, sample_provider, sample_proxy_node,
|
||||
sample_recent_key_rpm_candidate, start_server, AppState,
|
||||
};
|
||||
use crate::admin_api::AdminAppState;
|
||||
use crate::constants::{
|
||||
GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
|
||||
TRUSTED_ADMIN_USER_ROLE_HEADER,
|
||||
};
|
||||
use crate::data::GatewayDataState;
|
||||
use crate::handlers::admin::SystemExportMode;
|
||||
|
||||
static SYSTEM_UPDATE_TEST_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
fn sha256_hex(value: &str) -> String {
|
||||
format!("{:x}", Sha256::digest(value.as_bytes()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_system_version_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
@@ -231,7 +238,7 @@ async fn gateway_prepares_admin_system_update_locally() {
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
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);
|
||||
@@ -270,7 +277,7 @@ async fn gateway_rejects_admin_system_apply_update_without_prepared_version() {
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
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);
|
||||
@@ -391,7 +398,7 @@ async fn gateway_rejects_admin_system_apply_update_with_nonexistent_version() {
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
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);
|
||||
@@ -747,9 +754,14 @@ async fn gateway_handles_admin_system_config_export_locally_with_trusted_admin_p
|
||||
"gpt-5",
|
||||
)]),
|
||||
);
|
||||
let mut ldap_config = sample_ldap_module_config();
|
||||
ldap_config.bind_password_encrypted = Some(
|
||||
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "ldap-bind-secret")
|
||||
.expect("LDAP password should encrypt"),
|
||||
);
|
||||
let auth_module_repository = Arc::new(InMemoryAuthModuleReadRepository::seed(
|
||||
Vec::<StoredOAuthProviderModuleConfig>::new(),
|
||||
Some(sample_ldap_module_config()),
|
||||
Some(ldap_config),
|
||||
));
|
||||
let oauth_provider_repository = Arc::new(InMemoryOAuthProviderRepository::seed(vec![
|
||||
sample_oauth_provider_config("linuxdo"),
|
||||
@@ -767,27 +779,109 @@ async fn gateway_handles_admin_system_config_export_locally_with_trusted_admin_p
|
||||
let data_state = GatewayDataState::disabled()
|
||||
.attach_provider_catalog_repository_for_tests(provider_catalog_repository)
|
||||
.with_global_model_repository_for_tests(global_model_repository)
|
||||
.attach_auth_module_reader_for_tests(auth_module_repository)
|
||||
.attach_auth_module_repository_for_tests(auth_module_repository)
|
||||
.attach_oauth_provider_repository_for_tests(oauth_provider_repository)
|
||||
.attach_proxy_node_repository_for_tests(proxy_node_repository)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY)
|
||||
.with_system_config_values_for_tests(vec![
|
||||
(
|
||||
"smtp_password".to_string(),
|
||||
json!(
|
||||
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "smtp-secret",)
|
||||
.expect("smtp secret should encrypt")
|
||||
),
|
||||
),
|
||||
("smtp_password".to_string(), json!("smtp-secret")),
|
||||
("smtp_host".to_string(), json!("smtp.example.test")),
|
||||
("turnstile_secret_key".to_string(), serde_json::Value::Null),
|
||||
("smtp_user".to_string(), json!("smtp-user")),
|
||||
("site_name".to_string(), json!("Aether Test")),
|
||||
]);
|
||||
|
||||
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(data_state),
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(data_state);
|
||||
let recovery_payload = AdminAppState::new(&state)
|
||||
.build_admin_system_config_export_payload(SystemExportMode::RecoveryBackup)
|
||||
.await
|
||||
.expect("recovery config export should build");
|
||||
assert!(recovery_payload.get("credential_state").is_none());
|
||||
assert_eq!(
|
||||
recovery_payload["providers"][0]["config"]["provider_ops"]["connector"]["credentials"]
|
||||
["refresh_token"],
|
||||
"provider-refresh-token"
|
||||
);
|
||||
assert_eq!(
|
||||
recovery_payload["providers"][0]["api_keys"][0]["api_key"],
|
||||
"live-api-key"
|
||||
);
|
||||
assert_eq!(
|
||||
recovery_payload["providers"][0]["api_keys"][0]["auth_config"],
|
||||
r#"{"refresh_token":"oauth-refresh"}"#
|
||||
);
|
||||
assert_eq!(
|
||||
recovery_payload["providers"][0]["api_keys"][0]["is_active"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
recovery_payload["ldap_config"]["bind_password"],
|
||||
"ldap-bind-secret"
|
||||
);
|
||||
assert_eq!(recovery_payload["ldap_config"]["is_enabled"], true);
|
||||
assert_eq!(
|
||||
recovery_payload["oauth_providers"][0]["client_secret"],
|
||||
"secret-value"
|
||||
);
|
||||
assert_eq!(recovery_payload["oauth_providers"][0]["is_enabled"], true);
|
||||
assert_eq!(
|
||||
recovery_payload["proxy_nodes"][0]["proxy_username"],
|
||||
"proxy-user"
|
||||
);
|
||||
assert_eq!(
|
||||
recovery_payload["proxy_nodes"][0]["proxy_password"],
|
||||
"proxy-pass"
|
||||
);
|
||||
let recovery_system_configs = recovery_payload["system_configs"]
|
||||
.as_array()
|
||||
.expect("recovery system configs should be an array");
|
||||
assert_eq!(
|
||||
recovery_system_configs
|
||||
.iter()
|
||||
.find(|entry| entry["key"] == "smtp_user")
|
||||
.expect("SMTP user should be recoverable")["value"],
|
||||
"smtp-user"
|
||||
);
|
||||
assert_eq!(
|
||||
recovery_system_configs
|
||||
.iter()
|
||||
.find(|entry| entry["key"] == "smtp_password")
|
||||
.expect("SMTP password should be recoverable")["value"],
|
||||
"smtp-secret"
|
||||
);
|
||||
assert_eq!(
|
||||
recovery_system_configs
|
||||
.iter()
|
||||
.find(|entry| entry["key"] == "turnstile_secret_key")
|
||||
.expect("unset Turnstile secret should remain recoverable")["value"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
|
||||
let rollback_checkpoint = AdminAppState::new(&state)
|
||||
.build_admin_system_config_export_payload(SystemExportMode::RollbackCheckpoint)
|
||||
.await
|
||||
.expect("rollback config checkpoint should build");
|
||||
assert_eq!(
|
||||
rollback_checkpoint["providers"][0]["api_keys"][0]["is_active"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
rollback_checkpoint["providers"][0]["api_keys"][0]["credential_state"],
|
||||
"not_exported"
|
||||
);
|
||||
assert!(rollback_checkpoint["providers"][0]["api_keys"][0]
|
||||
.get("api_key")
|
||||
.is_none());
|
||||
assert_eq!(rollback_checkpoint["ldap_config"]["is_enabled"], true);
|
||||
assert_eq!(
|
||||
rollback_checkpoint["oauth_providers"][0]["is_enabled"],
|
||||
true
|
||||
);
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(state);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
@@ -801,25 +895,35 @@ async fn gateway_handles_admin_system_config_export_locally_with_trusted_admin_p
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(http::header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("no-store")
|
||||
);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
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["credential_state"], "not_exported");
|
||||
assert_eq!(
|
||||
payload["providers"][0]["config"]["provider_ops"]["connector"]["credentials"]
|
||||
["refresh_token"],
|
||||
"provider-refresh-token"
|
||||
payload["providers"][0]["config"]["provider_ops"]["connector"]["credentials"],
|
||||
"***"
|
||||
);
|
||||
assert!(payload["providers"][0]["api_keys"][0]
|
||||
.get("api_key")
|
||||
.is_none());
|
||||
assert!(payload["providers"][0]["api_keys"][0]
|
||||
.get("auth_config")
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
payload["providers"][0]["api_keys"][0]["api_key"],
|
||||
"live-api-key"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["providers"][0]["api_keys"][0]["auth_config"],
|
||||
r#"{"refresh_token":"oauth-refresh"}"#
|
||||
payload["providers"][0]["api_keys"][0]["credential_state"],
|
||||
"not_exported"
|
||||
);
|
||||
assert_eq!(payload["providers"][0]["api_keys"][0]["is_active"], false);
|
||||
assert_eq!(
|
||||
payload["providers"][0]["api_keys"][0]["supported_endpoints"],
|
||||
json!(["openai:chat"])
|
||||
@@ -828,29 +932,77 @@ async fn gateway_handles_admin_system_config_export_locally_with_trusted_admin_p
|
||||
payload["providers"][0]["models"][0]["global_model_name"],
|
||||
"gpt-5"
|
||||
);
|
||||
assert_eq!(payload["ldap_config"]["bind_password"], "");
|
||||
assert_eq!(
|
||||
payload["oauth_providers"][0]["client_secret"],
|
||||
"secret-value"
|
||||
);
|
||||
assert!(payload["ldap_config"].get("bind_password").is_none());
|
||||
assert_eq!(payload["ldap_config"]["is_enabled"], false);
|
||||
assert!(payload["oauth_providers"][0].get("client_secret").is_none());
|
||||
assert_eq!(payload["oauth_providers"][0]["is_enabled"], false);
|
||||
assert_eq!(
|
||||
payload["proxy_nodes"][0]["proxy_url"],
|
||||
"http://proxy.local:8080"
|
||||
);
|
||||
assert!(payload["proxy_nodes"][0].get("proxy_username").is_none());
|
||||
assert!(payload["proxy_nodes"][0].get("proxy_password").is_none());
|
||||
let smtp_password = payload["system_configs"]
|
||||
.as_array()
|
||||
.expect("system configs should be array")
|
||||
.iter()
|
||||
.find(|entry| entry["key"] == "smtp_password")
|
||||
.cloned()
|
||||
.expect("smtp_password should exist");
|
||||
assert_eq!(smtp_password["value"], "smtp-secret");
|
||||
.cloned();
|
||||
assert!(smtp_password.is_none());
|
||||
let serialized = payload.to_string();
|
||||
for secret in [
|
||||
"provider-refresh-token",
|
||||
"live-api-key",
|
||||
"oauth-refresh",
|
||||
"secret-value",
|
||||
"proxy-user",
|
||||
"proxy-pass",
|
||||
"smtp-user",
|
||||
"smtp-secret",
|
||||
] {
|
||||
assert!(!serialized.contains(secret), "leaked secret: {secret}");
|
||||
}
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_sensitive_system_exports_for_audit_admin() {
|
||||
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
for path in [
|
||||
"/api/admin/system/config/export",
|
||||
"/api/admin/system/users/export",
|
||||
"/api/admin/system/data/export",
|
||||
] {
|
||||
let response = reqwest::Client::new()
|
||||
.get(format!("{gateway_url}{path}"))
|
||||
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "audit-admin-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "audit_admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-audit-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN, "path: {path}");
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(
|
||||
payload["detail"], "management token permission denied",
|
||||
"path: {path}"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["required_permission"], "admin:system:admin",
|
||||
"path: {path}"
|
||||
);
|
||||
}
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_system_users_export_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
@@ -917,7 +1069,7 @@ async fn gateway_handles_admin_system_users_export_locally_with_trusted_admin_pr
|
||||
StoredAuthApiKeyExportRecord::new(
|
||||
"user-1".to_string(),
|
||||
"key-user-1".to_string(),
|
||||
"hash-user-1".to_string(),
|
||||
sha256_hex("ak-user-live-1"),
|
||||
Some(
|
||||
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "ak-user-live-1")
|
||||
.expect("user api key should encrypt"),
|
||||
@@ -941,7 +1093,7 @@ async fn gateway_handles_admin_system_users_export_locally_with_trusted_admin_pr
|
||||
StoredAuthApiKeyExportRecord::new(
|
||||
"admin-owner".to_string(),
|
||||
"key-standalone-1".to_string(),
|
||||
"hash-standalone-1".to_string(),
|
||||
sha256_hex("ak-standalone-live-1"),
|
||||
Some(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
@@ -1001,17 +1153,59 @@ async fn gateway_handles_admin_system_users_export_locally_with_trusted_admin_pr
|
||||
)
|
||||
.expect("standalone wallet should build"),
|
||||
]));
|
||||
let data_state =
|
||||
GatewayDataState::with_auth_and_wallet_for_tests(auth_repository, wallet_repository)
|
||||
.with_user_reader(user_repository)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY);
|
||||
let data_state = GatewayDataState::with_auth_and_wallet_for_tests(
|
||||
auth_repository.clone(),
|
||||
wallet_repository,
|
||||
)
|
||||
.attach_auth_api_key_repository_for_tests(auth_repository)
|
||||
.with_user_reader(user_repository)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY);
|
||||
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(data_state);
|
||||
let recovery_payload = AdminAppState::new(&state)
|
||||
.build_admin_system_users_export_payload(SystemExportMode::RecoveryBackup)
|
||||
.await
|
||||
.expect("recovery users export should build");
|
||||
let migrated_keys = state
|
||||
.list_auth_api_key_export_records_by_ids(&[
|
||||
"key-user-1".to_string(),
|
||||
"key-standalone-1".to_string(),
|
||||
])
|
||||
.await
|
||||
.expect("migrated API keys should reload");
|
||||
assert_eq!(migrated_keys.len(), 2);
|
||||
assert!(migrated_keys.iter().all(|key| key
|
||||
.key_encrypted
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.starts_with("aether-auth-api-key-secret-v2:"))));
|
||||
assert_eq!(recovery_payload["version"], "1.5");
|
||||
assert_eq!(recovery_payload["users"][0]["password_hash"], "argon2-hash");
|
||||
assert_eq!(
|
||||
recovery_payload["users"][0]["api_keys"][0]["key_hash"],
|
||||
sha256_hex("ak-user-live-1")
|
||||
);
|
||||
assert_eq!(
|
||||
recovery_payload["users"][0]["api_keys"][0]["key"],
|
||||
"ak-user-live-1"
|
||||
);
|
||||
assert_eq!(
|
||||
recovery_payload["users"][0]["api_keys"][0]["is_active"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
recovery_payload["standalone_keys"][0]["key_hash"],
|
||||
sha256_hex("ak-standalone-live-1")
|
||||
);
|
||||
assert_eq!(
|
||||
recovery_payload["standalone_keys"][0]["key"],
|
||||
"ak-standalone-live-1"
|
||||
);
|
||||
assert_eq!(recovery_payload["standalone_keys"][0]["is_active"], true);
|
||||
|
||||
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(data_state),
|
||||
);
|
||||
let gateway = build_router_with_state(state);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
@@ -1025,8 +1219,15 @@ async fn gateway_handles_admin_system_users_export_locally_with_trusted_admin_pr
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(http::header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("no-store")
|
||||
);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["version"], "1.5");
|
||||
assert_eq!(payload["version"], "1.6");
|
||||
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());
|
||||
@@ -1035,6 +1236,8 @@ async fn gateway_handles_admin_system_users_export_locally_with_trusted_admin_pr
|
||||
json!(["gpt-5"])
|
||||
);
|
||||
assert_eq!(payload["users"][0]["email"], "[email protected]");
|
||||
assert!(payload["users"][0].get("password_hash").is_none());
|
||||
assert!(!payload.to_string().contains("argon2-hash"));
|
||||
assert_eq!(
|
||||
payload["users"][0]["allowed_models_mode"],
|
||||
json!("specific")
|
||||
@@ -1058,11 +1261,29 @@ async fn gateway_handles_admin_system_users_export_locally_with_trusted_admin_pr
|
||||
json!(10.0)
|
||||
);
|
||||
assert_eq!(payload["users"][0]["unlimited"], json!(false));
|
||||
assert_eq!(payload["users"][0]["api_keys"][0]["key"], "ak-user-live-1");
|
||||
assert_eq!(
|
||||
payload["users"][0]["api_keys"][0]["key_hash"],
|
||||
"hash-user-1"
|
||||
payload["users"][0]["api_keys"][0]["credential_state"],
|
||||
"not_exported"
|
||||
);
|
||||
assert_eq!(payload["users"][0]["api_keys"][0]["is_active"], false);
|
||||
for credential_field in ["key", "key_hash", "key_encrypted"] {
|
||||
assert!(payload["users"][0]["api_keys"][0]
|
||||
.get(credential_field)
|
||||
.is_none());
|
||||
assert!(payload["standalone_keys"][0]
|
||||
.get(credential_field)
|
||||
.is_none());
|
||||
}
|
||||
let serialized = payload.to_string();
|
||||
for secret in ["ak-user-live-1", "ak-standalone-live-1"] {
|
||||
assert!(!serialized.contains(secret));
|
||||
}
|
||||
for secret_hash in [
|
||||
sha256_hex("ak-user-live-1"),
|
||||
sha256_hex("ak-standalone-live-1"),
|
||||
] {
|
||||
assert!(!serialized.contains(&secret_hash));
|
||||
}
|
||||
assert_eq!(
|
||||
payload["users"][0]["api_keys"][0]["is_standalone"],
|
||||
json!(false)
|
||||
@@ -1076,9 +1297,10 @@ async fn gateway_handles_admin_system_users_export_locally_with_trusted_admin_pr
|
||||
json!(420)
|
||||
);
|
||||
assert_eq!(
|
||||
payload["standalone_keys"][0]["key"],
|
||||
json!("ak-standalone-live-1")
|
||||
payload["standalone_keys"][0]["credential_state"],
|
||||
"not_exported"
|
||||
);
|
||||
assert_eq!(payload["standalone_keys"][0]["is_active"], false);
|
||||
assert_eq!(
|
||||
payload["standalone_keys"][0]["api_key_id"],
|
||||
json!("key-standalone-1")
|
||||
@@ -1948,11 +2170,11 @@ async fn gateway_validates_chat_pii_redaction_system_config_locally_with_trusted
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_system_provider_priority_mode_locally_with_bearer_admin_session() {
|
||||
async fn gateway_handles_admin_system_config_locally_with_bearer_admin_session() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/system/configs/provider_priority_mode",
|
||||
"/api/admin/system/configs/site_name",
|
||||
any(move |_request: Request| {
|
||||
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||
async move {
|
||||
@@ -1969,9 +2191,7 @@ async fn gateway_handles_admin_system_provider_priority_mode_locally_with_bearer
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"{gateway_url}/api/admin/system/configs/provider_priority_mode"
|
||||
))
|
||||
.get(format!("{gateway_url}/api/admin/system/configs/site_name"))
|
||||
.header("authorization", format!("Bearer {access_token}"))
|
||||
.header("x-client-device-id", "device-admin-config")
|
||||
.send()
|
||||
@@ -1980,8 +2200,8 @@ async fn gateway_handles_admin_system_provider_priority_mode_locally_with_bearer
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["key"], "provider_priority_mode");
|
||||
assert_eq!(payload["value"], "provider");
|
||||
assert_eq!(payload["key"], "site_name");
|
||||
assert_eq!(payload["value"], "Aether");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -2003,8 +2223,12 @@ async fn gateway_sets_admin_system_config_locally_with_trusted_admin_principal()
|
||||
}),
|
||||
);
|
||||
|
||||
let data_state =
|
||||
GatewayDataState::disabled().with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY);
|
||||
let data_state = GatewayDataState::disabled()
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY)
|
||||
.with_system_config_values_for_tests(vec![
|
||||
("smtp_host".to_string(), json!("smtp.example.com")),
|
||||
("smtp_user".to_string(), json!("smtp-user")),
|
||||
]);
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
@@ -2146,7 +2370,7 @@ async fn gateway_handles_admin_key_rpm_locally_with_trusted_admin_principal() {
|
||||
"https://api.openai.example",
|
||||
)],
|
||||
vec![
|
||||
sample_key("key-openai", "provider-openai", "openai:chat", "sk-test")
|
||||
sample_bound_key("key-openai", "provider-openai", "openai:chat", "sk-test")
|
||||
.with_rate_limit_fields(Some(60), None, None, None, None, None, None, None, None),
|
||||
],
|
||||
));
|
||||
@@ -2233,7 +2457,7 @@ async fn gateway_resets_admin_key_rpm_locally_with_trusted_admin_principal() {
|
||||
"https://api.openai.example",
|
||||
)],
|
||||
vec![
|
||||
sample_key("key-openai", "provider-openai", "openai:chat", "sk-test")
|
||||
sample_bound_key("key-openai", "provider-openai", "openai:chat", "sk-test")
|
||||
.with_rate_limit_fields(Some(60), None, None, None, None, None, None, None, None),
|
||||
],
|
||||
));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,8 +20,8 @@ use http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::{
|
||||
build_router_with_state, issue_test_admin_access_token, sample_endpoint, sample_key,
|
||||
sample_provider, start_server, AppState,
|
||||
build_router_with_state, issue_test_admin_access_token, sample_bound_key, sample_endpoint,
|
||||
sample_key, sample_provider, start_server, AppState,
|
||||
};
|
||||
use crate::admin_api::{
|
||||
maybe_build_local_admin_usage_response, AdminAppState, AdminRequestContext,
|
||||
@@ -986,7 +986,8 @@ async fn gateway_handles_admin_usage_active_locally_with_trusted_admin_principal
|
||||
DAY_1_UNIX_SECS,
|
||||
),
|
||||
]));
|
||||
let mut provider_key = sample_key("provider-key-1", "provider-1", "openai:chat", "sk-upstream");
|
||||
let mut provider_key =
|
||||
sample_bound_key("provider-key-1", "provider-1", "openai:chat", "sk-upstream");
|
||||
provider_key.name = "upstream-primary".to_string();
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider("provider-1", "OpenAI", 10)],
|
||||
@@ -1283,7 +1284,8 @@ async fn gateway_handles_admin_usage_records_locally_with_trusted_admin_principa
|
||||
DAY_2_UNIX_SECS,
|
||||
),
|
||||
]));
|
||||
let mut provider_key = sample_key("provider-key-1", "provider-1", "openai:chat", "sk-upstream");
|
||||
let mut provider_key =
|
||||
sample_bound_key("provider-key-1", "provider-1", "openai:chat", "sk-upstream");
|
||||
provider_key.name = "upstream-primary".to_string();
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider("provider-1", "OpenAI", 10)],
|
||||
@@ -3460,6 +3462,7 @@ async fn gateway_handles_admin_usage_cache_affinity_interval_timeline_with_legac
|
||||
allowed_models_mode: "unrestricted".to_string(),
|
||||
is_active: true,
|
||||
is_deleted: false,
|
||||
security_version: 0,
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
}]),
|
||||
|
||||
@@ -4,6 +4,7 @@ use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY}
|
||||
use aether_data::repository::auth::{
|
||||
InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeyExportRecord, StoredAuthApiKeySnapshot,
|
||||
};
|
||||
use aether_data::repository::management_tokens::InMemoryManagementTokenRepository;
|
||||
use aether_data::repository::usage::InMemoryUsageReadRepository;
|
||||
use aether_data::repository::users::{
|
||||
InMemoryUserReadRepository, StoredUserAuthRecord, StoredUserExportRow, UpsertUserGroupRecord,
|
||||
@@ -20,7 +21,8 @@ use http::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::{
|
||||
build_router_with_state, hash_api_key, issue_test_admin_access_token, start_server, AppState,
|
||||
build_router_with_state, hash_api_key, hash_management_token, issue_test_admin_access_token,
|
||||
sample_management_token, start_server, AppState,
|
||||
};
|
||||
use crate::constants::{
|
||||
GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
|
||||
@@ -216,6 +218,27 @@ fn sample_admin_api_key_snapshot(user_id: &str, api_key_id: &str) -> StoredAuthA
|
||||
.expect("api key snapshot should build")
|
||||
}
|
||||
|
||||
/// Build an auth API-key fixture with the provider/user-bound envelope used by
|
||||
/// production writes. Read-only test repositories cannot perform the legacy
|
||||
/// Fernet migration, so ordinary list/reveal fixtures must use this format.
|
||||
fn sample_bound_admin_api_key_secret(
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
plaintext: &str,
|
||||
) -> (String, String) {
|
||||
let key_hash = hash_api_key(plaintext);
|
||||
let state = AppState::new()
|
||||
.expect("bootstrap state should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::disabled().with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
);
|
||||
let encrypted = crate::handlers::shared::seal_auth_api_key_secret(
|
||||
&state, user_id, api_key_id, &key_hash, false, plaintext,
|
||||
)
|
||||
.expect("bound API-key ciphertext should build");
|
||||
(key_hash, encrypted)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_sorts_admin_users_by_created_at() {
|
||||
let oldest = Utc
|
||||
@@ -1231,6 +1254,280 @@ async fn gateway_handles_admin_users_root_locally_with_bearer_admin_session() {
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_users_write_management_token_for_administrator_account_mutations() {
|
||||
let raw_token = "ae-users-write-cannot-escalate";
|
||||
let token_owner = sample_admin_user_with_role(
|
||||
"token-owner",
|
||||
"admin",
|
||||
"[email protected]",
|
||||
"token_owner",
|
||||
);
|
||||
let target_user =
|
||||
sample_admin_user_with_role("target-user", "user", "[email protected]", "target_user");
|
||||
let target_admin = sample_admin_user_with_role(
|
||||
"target-admin",
|
||||
"admin",
|
||||
"[email protected]",
|
||||
"target_admin",
|
||||
);
|
||||
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
||||
token_owner.clone(),
|
||||
target_user.clone(),
|
||||
target_admin.clone(),
|
||||
]));
|
||||
let mut token = sample_management_token(
|
||||
"token-users-write-cannot-escalate",
|
||||
&token_owner.id,
|
||||
&token_owner.username,
|
||||
true,
|
||||
);
|
||||
token.token.allowed_ips = None;
|
||||
token.token.permissions = Some(json!(["admin:users:write"]));
|
||||
let token_repository = Arc::new(InMemoryManagementTokenRepository::seed_with_hashes(
|
||||
vec![token],
|
||||
vec![(
|
||||
hash_management_token(raw_token),
|
||||
"token-users-write-cannot-escalate".to_string(),
|
||||
)],
|
||||
));
|
||||
let data = GatewayDataState::with_management_token_repository_for_tests(token_repository)
|
||||
.with_user_reader(user_repository.clone());
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(data)
|
||||
.with_auth_users_for_tests([
|
||||
token_owner.clone(),
|
||||
target_user.clone(),
|
||||
target_admin.clone(),
|
||||
]);
|
||||
let inspection_state = state.clone();
|
||||
let gateway = build_router_with_state(state);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
let client = reqwest::Client::new();
|
||||
let cases = [
|
||||
(
|
||||
reqwest::Method::POST,
|
||||
"/api/admin/users",
|
||||
json!({
|
||||
"email": "[email protected]",
|
||||
"username": "created_admin",
|
||||
"password": "CreatedAdmin123!",
|
||||
"role": "admin"
|
||||
}),
|
||||
"create_user",
|
||||
),
|
||||
(
|
||||
reqwest::Method::PUT,
|
||||
"/api/admin/users/target-user",
|
||||
json!({ "role": "admin" }),
|
||||
"update_user",
|
||||
),
|
||||
(
|
||||
reqwest::Method::PUT,
|
||||
"/api/admin/users/target-admin",
|
||||
json!({ "password": "ResetAdmin123!" }),
|
||||
"update_user",
|
||||
),
|
||||
(
|
||||
reqwest::Method::PUT,
|
||||
"/api/admin/users/target-admin",
|
||||
json!({ "role": "user" }),
|
||||
"update_user",
|
||||
),
|
||||
(
|
||||
reqwest::Method::PUT,
|
||||
"/api/admin/users/target-admin",
|
||||
json!({ "is_active": false }),
|
||||
"update_user",
|
||||
),
|
||||
(
|
||||
reqwest::Method::PUT,
|
||||
"/api/admin/users/target-admin",
|
||||
json!({ "email": "[email protected]" }),
|
||||
"update_user",
|
||||
),
|
||||
(
|
||||
reqwest::Method::PUT,
|
||||
"/api/admin/users/target-admin",
|
||||
json!({ "group_ids": [] }),
|
||||
"update_user",
|
||||
),
|
||||
(
|
||||
reqwest::Method::PUT,
|
||||
"/api/admin/users/target-admin",
|
||||
json!({ "unlimited": true }),
|
||||
"update_user",
|
||||
),
|
||||
(
|
||||
reqwest::Method::DELETE,
|
||||
"/api/admin/users/target-admin",
|
||||
json!({}),
|
||||
"delete_user",
|
||||
),
|
||||
(
|
||||
reqwest::Method::POST,
|
||||
"/api/admin/users/batch-action",
|
||||
json!({
|
||||
"selection": { "user_ids": ["target-user"] },
|
||||
"action": "update_role",
|
||||
"payload": { "role": "admin" }
|
||||
}),
|
||||
"batch_action_users",
|
||||
),
|
||||
(
|
||||
reqwest::Method::POST,
|
||||
"/api/admin/users/batch-action",
|
||||
json!({
|
||||
"selection": { "user_ids": ["target-admin"] },
|
||||
"action": "update_role",
|
||||
"payload": { "role": "user" }
|
||||
}),
|
||||
"batch_action_users",
|
||||
),
|
||||
(
|
||||
reqwest::Method::POST,
|
||||
"/api/admin/users/batch-action",
|
||||
json!({
|
||||
"selection": { "user_ids": ["target-user", "target-admin"] },
|
||||
"action": "disable"
|
||||
}),
|
||||
"batch_action_users",
|
||||
),
|
||||
];
|
||||
|
||||
for (method, path, body, route_kind) in cases {
|
||||
let response = client
|
||||
.request(method, format!("{gateway_url}{path}"))
|
||||
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||
.bearer_auth(raw_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN, "path: {path}");
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["detail"], "management token permission denied");
|
||||
assert_eq!(payload["required_permission"], "admin:users:admin");
|
||||
assert_eq!(payload["route_kind"], route_kind);
|
||||
}
|
||||
|
||||
let ordinary_create_response = client
|
||||
.post(format!("{gateway_url}/api/admin/users"))
|
||||
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||
.bearer_auth(raw_token)
|
||||
.json(&json!({
|
||||
"email": "[email protected]",
|
||||
"username": "delegated_user",
|
||||
"password": "DelegatedUser123!",
|
||||
"role": "user"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("ordinary user creation request should succeed");
|
||||
assert_ne!(ordinary_create_response.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let ordinary_update_response = client
|
||||
.put(format!("{gateway_url}/api/admin/users/target-user"))
|
||||
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||
.bearer_auth(raw_token)
|
||||
.json(&json!({ "is_active": false }))
|
||||
.send()
|
||||
.await
|
||||
.expect("ordinary user update request should succeed");
|
||||
assert_eq!(ordinary_update_response.status(), StatusCode::OK);
|
||||
|
||||
assert!(inspection_state
|
||||
.find_user_auth_by_identifier("created_admin")
|
||||
.await
|
||||
.expect("created user lookup should succeed")
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
inspection_state
|
||||
.find_user_auth_by_id("target-user")
|
||||
.await
|
||||
.expect("target user lookup should succeed")
|
||||
.expect("target user should still exist")
|
||||
.is_active,
|
||||
false
|
||||
);
|
||||
let stored_target_admin = inspection_state
|
||||
.find_user_auth_by_id("target-admin")
|
||||
.await
|
||||
.expect("target admin lookup should succeed")
|
||||
.expect("target admin should still exist");
|
||||
assert_eq!(stored_target_admin.role, "admin");
|
||||
assert!(stored_target_admin.is_active);
|
||||
assert_eq!(
|
||||
stored_target_admin.password_hash,
|
||||
target_admin.password_hash
|
||||
);
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_requires_users_admin_to_revoke_privileged_user_sessions() {
|
||||
for (role, suffix) in [("admin", "admin"), ("audit_admin", "audit-admin")] {
|
||||
let raw_token = format!("ae-users-write-session-{suffix}");
|
||||
let token_owner = sample_admin_user_with_role(
|
||||
&format!("token-owner-{suffix}"),
|
||||
"admin",
|
||||
&format!("token-owner-{suffix}@example.com"),
|
||||
&format!("token_owner_{suffix}"),
|
||||
);
|
||||
let target_id = format!("target-{suffix}");
|
||||
let target = sample_admin_user_with_role(
|
||||
&target_id,
|
||||
role,
|
||||
&format!("target-{suffix}@example.com"),
|
||||
&format!("target_{suffix}"),
|
||||
);
|
||||
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
||||
token_owner.clone(),
|
||||
target.clone(),
|
||||
]));
|
||||
let token_id = format!("token-users-write-session-{suffix}");
|
||||
let mut token =
|
||||
sample_management_token(&token_id, &token_owner.id, &token_owner.username, true);
|
||||
token.token.allowed_ips = None;
|
||||
token.token.permissions = Some(json!(["admin:users:write"]));
|
||||
let token_repository = Arc::new(InMemoryManagementTokenRepository::seed_with_hashes(
|
||||
vec![token],
|
||||
vec![(hash_management_token(&raw_token), token_id)],
|
||||
));
|
||||
let data = GatewayDataState::with_management_token_repository_for_tests(token_repository)
|
||||
.with_user_reader(user_repository);
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(data)
|
||||
.with_auth_users_for_tests([token_owner, target])
|
||||
.with_auth_session_for_tests(sample_admin_session(&target_id, "session-1"));
|
||||
let gateway = build_router_with_state(state);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
for path in [
|
||||
format!("/api/admin/users/{target_id}/sessions/session-1"),
|
||||
format!("/api/admin/users/{target_id}/sessions"),
|
||||
] {
|
||||
let response = client
|
||||
.delete(format!("{gateway_url}{path}"))
|
||||
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||
.bearer_auth(&raw_token)
|
||||
.send()
|
||||
.await
|
||||
.expect("session revocation request should complete");
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN, "path: {path}");
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["required_permission"], "admin:users:admin");
|
||||
}
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_user_detail_routes_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
@@ -1244,16 +1541,16 @@ async fn gateway_handles_admin_user_detail_routes_locally_with_trusted_admin_pri
|
||||
}));
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_auth_users_for_tests([
|
||||
sample_admin_user("user-1"),
|
||||
sample_admin_user_with_role("admin-1", "admin", "admin1@example.com", "admin_one"),
|
||||
sample_admin_user_with_role("admin-2", "admin", "[email protected]", "admin_two"),
|
||||
])
|
||||
.with_auth_wallets_for_tests([sample_admin_wallet("user-1", "finite")]),
|
||||
);
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_auth_users_for_tests([
|
||||
sample_admin_user("user-1"),
|
||||
sample_admin_user_with_role("admin-1", "admin", "[email protected]", "admin_one"),
|
||||
sample_admin_user_with_role("admin-2", "admin", "admin2@example.com", "admin_two"),
|
||||
])
|
||||
.with_auth_wallets_for_tests([sample_admin_wallet("user-1", "finite")]);
|
||||
let inspection_state = state.clone();
|
||||
let gateway = build_router_with_state(state);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
@@ -1286,6 +1583,17 @@ async fn gateway_handles_admin_user_detail_routes_locally_with_trusted_admin_pri
|
||||
assert_eq!(update_payload["unlimited"], true);
|
||||
assert_eq!(update_payload["is_active"], false);
|
||||
|
||||
let updated_user = inspection_state
|
||||
.find_user_auth_by_id("user-1")
|
||||
.await
|
||||
.expect("updated user lookup should succeed")
|
||||
.expect("updated user should exist");
|
||||
assert_eq!(
|
||||
updated_user.email.as_deref(),
|
||||
Some("[email protected]")
|
||||
);
|
||||
assert!(!updated_user.email_verified);
|
||||
|
||||
let hidden_update_response = client
|
||||
.put(format!("{gateway_url}/api/admin/users/user-1"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
@@ -1357,7 +1665,7 @@ async fn gateway_rejects_demoting_the_last_active_admin() {
|
||||
.expect("request should succeed");
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["detail"], "不能降级最后一个管理员账户");
|
||||
assert_eq!(payload["detail"], "不能降级或停用最后一个管理员账户");
|
||||
}
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -2392,17 +2700,16 @@ async fn gateway_lists_admin_user_api_keys_locally_with_trusted_admin_principal(
|
||||
}),
|
||||
);
|
||||
|
||||
let encrypted = encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "sk-user-1")
|
||||
.expect("ciphertext should build");
|
||||
let (key_hash, encrypted) = sample_bound_admin_api_key_secret("user-1", "key-1", "sk-user-1");
|
||||
let auth_repository = Arc::new(
|
||||
InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some("hash-key-1".to_string()),
|
||||
Some(key_hash.clone()),
|
||||
sample_admin_api_key_snapshot("user-1", "key-1"),
|
||||
)])
|
||||
.with_export_records(vec![StoredAuthApiKeyExportRecord::new(
|
||||
"user-1".to_string(),
|
||||
"key-1".to_string(),
|
||||
"hash-key-1".to_string(),
|
||||
key_hash,
|
||||
Some(encrypted),
|
||||
Some("default".to_string()),
|
||||
Some(json!(["openai"])),
|
||||
@@ -2459,7 +2766,7 @@ async fn gateway_lists_admin_user_api_keys_locally_with_trusted_admin_principal(
|
||||
assert_eq!(payload["username"], "alice");
|
||||
assert_eq!(payload["api_keys"][0]["id"], "key-1");
|
||||
assert_eq!(payload["api_keys"][0]["name"], "default");
|
||||
assert_eq!(payload["api_keys"][0]["key_display"], "sk-user-1...er-1");
|
||||
assert_eq!(payload["api_keys"][0]["key_display"], "sk...-1");
|
||||
assert_eq!(payload["api_keys"][0]["is_active"], true);
|
||||
assert_eq!(payload["api_keys"][0]["is_locked"], false);
|
||||
assert_eq!(payload["api_keys"][0]["total_requests"], 9);
|
||||
@@ -2597,17 +2904,16 @@ async fn gateway_reveals_admin_user_full_key_locally_with_trusted_admin_principa
|
||||
}),
|
||||
);
|
||||
|
||||
let encrypted = encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "sk-user-1")
|
||||
.expect("ciphertext should build");
|
||||
let (key_hash, encrypted) = sample_bound_admin_api_key_secret("user-1", "key-1", "sk-user-1");
|
||||
let auth_repository = Arc::new(
|
||||
InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some("hash-key-1".to_string()),
|
||||
Some(key_hash.clone()),
|
||||
sample_admin_api_key_snapshot("user-1", "key-1"),
|
||||
)])
|
||||
.with_export_records(vec![StoredAuthApiKeyExportRecord::new(
|
||||
"user-1".to_string(),
|
||||
"key-1".to_string(),
|
||||
"hash-key-1".to_string(),
|
||||
key_hash,
|
||||
Some(encrypted),
|
||||
Some("default".to_string()),
|
||||
Some(json!(["openai"])),
|
||||
|
||||
@@ -15,8 +15,8 @@ use http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::{
|
||||
build_router_with_state, build_state_with_execution_runtime_override, sample_endpoint,
|
||||
sample_key, sample_provider, start_server, AppState,
|
||||
build_router_with_state, build_state_with_execution_runtime_override, sample_bound_key,
|
||||
sample_endpoint, sample_provider, start_server, AppState,
|
||||
};
|
||||
use crate::admin_api::{
|
||||
maybe_build_local_admin_video_tasks_response, AdminAppState, AdminRequestContext,
|
||||
@@ -130,7 +130,7 @@ fn sample_admin_video_task(
|
||||
updated_at_unix_secs: created_at_unix_ms + 5,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
video_url: Some(format!("https://example.com/{id}.mp4")),
|
||||
video_url: Some(format!("https://8.8.8.8/{id}.mp4")),
|
||||
request_metadata: None,
|
||||
}
|
||||
}
|
||||
@@ -221,12 +221,13 @@ async fn gateway_handles_admin_video_tasks_list_locally_with_trusted_admin_princ
|
||||
assert_eq!(payload["pages"], json!(1));
|
||||
assert_eq!(payload["items"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(payload["items"][0]["id"], "task-completed");
|
||||
assert_eq!(payload["items"][0]["username"], "alice");
|
||||
// Video-task persistence intentionally drops user-facing PII. The admin
|
||||
// projection must therefore use the privacy-safe fallback when no separate
|
||||
// user snapshot is joined.
|
||||
assert_eq!(payload["items"][0]["username"], "Unknown");
|
||||
assert_eq!(payload["items"][0]["provider_name"], "OpenAI");
|
||||
assert_eq!(payload["items"][0]["status"], "completed");
|
||||
assert!(payload["items"][0]["prompt"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.ends_with("...")));
|
||||
assert!(payload["items"][0]["prompt"].is_null());
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -392,7 +393,7 @@ async fn gateway_handles_admin_video_task_detail_locally_with_trusted_admin_prin
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["id"], "task-detail");
|
||||
assert_eq!(payload["username"], "charlie");
|
||||
assert_eq!(payload["username"], "Unknown");
|
||||
assert_eq!(payload["provider_name"], "OpenAI");
|
||||
assert_eq!(payload["endpoint"]["id"], "endpoint-1");
|
||||
assert_eq!(payload["endpoint"]["api_format"], "openai:video");
|
||||
@@ -592,11 +593,36 @@ async fn gateway_cancels_admin_video_task_locally_with_trusted_admin_principal()
|
||||
.await
|
||||
.expect("upsert should succeed");
|
||||
|
||||
// The persisted task intentionally omits its request/transport snapshot.
|
||||
// Reconstructing an admin cancellation must therefore resolve the current
|
||||
// provider catalog, including a record-bound credential that a read-only
|
||||
// fixture can decrypt without a migration writer.
|
||||
let provider_catalog_repository: Arc<dyn ProviderCatalogReadRepository> =
|
||||
Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider("provider-openai", "OpenAI", 10)],
|
||||
vec![sample_endpoint(
|
||||
"endpoint-1",
|
||||
"provider-openai",
|
||||
"openai:video",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![sample_bound_key(
|
||||
"provider-key-1",
|
||||
"provider-openai",
|
||||
"openai:video",
|
||||
"sk-upstream-openai-video",
|
||||
)],
|
||||
));
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let gateway = build_router_with_state(
|
||||
build_state_with_execution_runtime_override(execution_runtime_url)
|
||||
.with_video_task_data_repository_for_tests(Arc::clone(&repository)),
|
||||
.with_video_task_repository_and_provider_transport_for_tests(
|
||||
Arc::clone(&repository),
|
||||
provider_catalog_repository,
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
@@ -639,10 +665,10 @@ async fn gateway_cancels_admin_video_task_locally_with_trusted_admin_principal()
|
||||
assert_eq!(detail.status(), StatusCode::OK);
|
||||
let detail_json: serde_json::Value = detail.json().await.expect("detail should parse");
|
||||
assert_eq!(detail_json["status"], "cancelled");
|
||||
assert_eq!(
|
||||
detail_json["request_metadata"]["rust_local_snapshot"]["OpenAi"]["status"],
|
||||
"Cancelled"
|
||||
);
|
||||
assert!(detail_json["original_request_body"].is_null());
|
||||
assert!(detail_json["progress_message"].is_null());
|
||||
assert!(detail_json["error_message"].is_null());
|
||||
assert!(detail_json["request_metadata"].is_null());
|
||||
|
||||
let seen_execution_runtime_request = seen_execution_runtime
|
||||
.lock()
|
||||
@@ -708,7 +734,7 @@ async fn local_admin_video_task_cancel_attaches_explicit_audit() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_redirects_admin_video_task_video_locally_with_trusted_admin_principal() {
|
||||
async fn gateway_does_not_redirect_sanitized_openai_video_url_or_forward_upstream() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
@@ -723,7 +749,7 @@ async fn gateway_redirects_admin_video_task_video_locally_with_trusted_admin_pri
|
||||
);
|
||||
|
||||
let repository = Arc::new(InMemoryVideoTaskRepository::default());
|
||||
repository
|
||||
let stored = repository
|
||||
.upsert(sample_admin_video_task(
|
||||
"task-redirect",
|
||||
VideoTaskStatus::Completed,
|
||||
@@ -736,8 +762,9 @@ async fn gateway_redirects_admin_video_task_video_locally_with_trusted_admin_pri
|
||||
))
|
||||
.await
|
||||
.expect("task should upsert");
|
||||
assert_eq!(stored.video_url, None);
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let (_upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway state should build")
|
||||
@@ -761,14 +788,7 @@ async fn gateway_redirects_admin_video_task_video_locally_with_trusted_admin_pri
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(http::header::LOCATION)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("https://example.com/task-redirect.mp4")
|
||||
);
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -776,9 +796,9 @@ async fn gateway_redirects_admin_video_task_video_locally_with_trusted_admin_pri
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_admin_video_task_video_redirect_attaches_explicit_audit() {
|
||||
async fn local_admin_video_task_video_is_unavailable_after_openai_url_sanitization() {
|
||||
let repository = Arc::new(InMemoryVideoTaskRepository::default());
|
||||
repository
|
||||
let stored = repository
|
||||
.upsert(sample_admin_video_task(
|
||||
"task-video-audit",
|
||||
VideoTaskStatus::Completed,
|
||||
@@ -791,6 +811,7 @@ async fn local_admin_video_task_video_redirect_attaches_explicit_audit() {
|
||||
))
|
||||
.await
|
||||
.expect("task should upsert");
|
||||
assert_eq!(stored.video_url, None);
|
||||
|
||||
let state = AppState::new()
|
||||
.expect("gateway state should build")
|
||||
@@ -804,20 +825,12 @@ async fn local_admin_video_task_video_redirect_attaches_explicit_audit() {
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
|
||||
let audit = response
|
||||
.extensions()
|
||||
.get::<AdminAuditEvent>()
|
||||
.cloned()
|
||||
.expect("video task video should attach audit");
|
||||
assert_eq!(audit.event_name, "admin_video_task_video_viewed");
|
||||
assert_eq!(audit.action, "view_video_task_video");
|
||||
assert_eq!(audit.target_type, "video_task_video");
|
||||
assert_eq!(audit.target_id, "task-video-audit");
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
assert!(response.extensions().get::<AdminAuditEvent>().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_proxies_admin_video_task_video_locally_with_trusted_admin_principal() {
|
||||
async fn gateway_rejects_cross_origin_admin_gemini_video_without_sending_provider_key() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let seen_api_key = Arc::new(Mutex::new(None::<String>));
|
||||
@@ -887,7 +900,7 @@ async fn gateway_proxies_admin_video_task_video_locally_with_trusted_admin_princ
|
||||
"gemini:video",
|
||||
"https://generativelanguage.googleapis.com",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-gemini",
|
||||
"provider-gemini",
|
||||
"gemini:video",
|
||||
@@ -919,28 +932,10 @@ async fn gateway_proxies_admin_video_task_video_locally_with_trusted_admin_princ
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(http::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("video/mp4")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(http::header::CONTENT_DISPOSITION)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("inline; filename=\"video_task-proxy.mp4\"")
|
||||
);
|
||||
assert_eq!(
|
||||
response.bytes().await.expect("body should read"),
|
||||
Bytes::from_static(b"proxied-video-bytes")
|
||||
);
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
assert_eq!(
|
||||
seen_api_key.lock().expect("mutex should lock").as_deref(),
|
||||
Some("gemini-upstream-secret")
|
||||
None
|
||||
);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ fn sample_refund_record(
|
||||
payment_order_id: payment_order_id.map(ToOwned::to_owned),
|
||||
source_type: "payment_order".to_string(),
|
||||
source_id: payment_order_id.map(ToOwned::to_owned),
|
||||
refund_mode: "original".to_string(),
|
||||
refund_mode: "original_channel".to_string(),
|
||||
amount_usd,
|
||||
status: status.to_string(),
|
||||
reason: Some("用户申请退款".to_string()),
|
||||
@@ -1300,6 +1300,29 @@ async fn gateway_handles_admin_wallets_process_refund_locally_with_trusted_admin
|
||||
assert_eq!(payload["transaction"]["reason_code"], json!("refund_out"));
|
||||
assert_eq!(payload["transaction"]["amount"], json!(-4.0));
|
||||
assert_eq!(payload["transaction"]["description"], json!("退款占款"));
|
||||
|
||||
let ledger_response = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"{gateway_url}/api/admin/wallets/wallet-123/transactions?limit=20&offset=0"
|
||||
))
|
||||
.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("transaction list request should succeed");
|
||||
assert_eq!(ledger_response.status(), StatusCode::OK);
|
||||
let ledger_payload: serde_json::Value = ledger_response
|
||||
.json()
|
||||
.await
|
||||
.expect("transaction list response should be json");
|
||||
assert_eq!(ledger_payload["total"], json!(1));
|
||||
assert_eq!(
|
||||
ledger_payload["items"][0]["reason_code"],
|
||||
json!("refund_out")
|
||||
);
|
||||
assert_eq!(ledger_payload["items"][0]["amount"], json!(-4.0));
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -1425,7 +1448,7 @@ async fn gateway_handles_admin_wallets_complete_refund_locally_with_trusted_admi
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_wallets_fail_refund_locally_with_trusted_admin_principal() {
|
||||
async fn gateway_rejects_admin_wallets_fail_processing_refund_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(
|
||||
@@ -1485,20 +1508,115 @@ async fn gateway_handles_admin_wallets_fail_refund_locally_with_trusted_admin_pr
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(
|
||||
payload["detail"],
|
||||
json!("cannot fail refund while gateway settlement is processing")
|
||||
);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_releases_offline_processing_refund_without_gateway_evidence() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/wallets/wallet-123/refunds/refund-1/fail",
|
||||
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 wallet = sample_wallet_snapshot("wallet-123", Some("user-1"), None, "finite");
|
||||
wallet.balance = 8.5;
|
||||
wallet.total_refunded = 7.0;
|
||||
let mut refund = sample_refund_record(
|
||||
"refund-1",
|
||||
"wallet-123",
|
||||
Some("user-1"),
|
||||
Some("po-1"),
|
||||
4.0,
|
||||
"processing",
|
||||
Some("admin-user-123"),
|
||||
Some("admin-user-123"),
|
||||
Some(1_710_000_500),
|
||||
);
|
||||
refund.refund_mode = "offline_payout".to_string();
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_auth_wallets_for_tests([wallet])
|
||||
.with_admin_wallet_payment_orders_for_tests([sample_payment_order_record(
|
||||
"po-1",
|
||||
"wallet-123",
|
||||
Some("user-1"),
|
||||
10.0,
|
||||
5.0,
|
||||
5.0,
|
||||
)])
|
||||
.with_admin_wallet_refunds_for_tests([refund]),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{gateway_url}/api/admin/wallets/wallet-123/refunds/refund-1/fail"
|
||||
))
|
||||
.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!({
|
||||
"reason": "线下退款未打款"
|
||||
}))
|
||||
.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["wallet"]["id"], json!("wallet-123"));
|
||||
assert_eq!(payload["wallet"]["balance"], json!(15.0));
|
||||
assert_eq!(payload["wallet"]["recharge_balance"], json!(12.5));
|
||||
assert_eq!(payload["wallet"]["total_refunded"], json!(3.0));
|
||||
assert_eq!(payload["refund"]["status"], json!("failed"));
|
||||
assert_eq!(payload["refund"]["failure_reason"], json!("原路退款失败"));
|
||||
assert_eq!(
|
||||
payload["transaction"]["reason_code"],
|
||||
json!("refund_revert")
|
||||
);
|
||||
assert_eq!(payload["transaction"]["amount"], json!(4.0));
|
||||
assert_eq!(payload["transaction"]["description"], json!("退款失败回补"));
|
||||
|
||||
let ledger_response = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"{gateway_url}/api/admin/wallets/wallet-123/transactions?limit=20&offset=0"
|
||||
))
|
||||
.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("transaction list request should succeed");
|
||||
assert_eq!(ledger_response.status(), StatusCode::OK);
|
||||
let ledger_payload: serde_json::Value = ledger_response
|
||||
.json()
|
||||
.await
|
||||
.expect("transaction list response should be json");
|
||||
assert_eq!(ledger_payload["total"], json!(1));
|
||||
assert_eq!(
|
||||
ledger_payload["items"][0]["reason_code"],
|
||||
json!("refund_revert")
|
||||
);
|
||||
assert_eq!(ledger_payload["items"][0]["amount"], json!(4.0));
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
|
||||
@@ -13,7 +13,96 @@ use super::{
|
||||
StoredProviderModelStats, StoredProviderQuotaSnapshot, StoredProxyNode,
|
||||
StoredPublicGlobalModel, StoredRequestCandidate,
|
||||
};
|
||||
use crate::AppState;
|
||||
use crate::{data::GatewayDataState, AppState};
|
||||
|
||||
pub(super) const TUNNEL_CONTROL_PLANE_TEST_PSK: &str =
|
||||
"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=";
|
||||
pub(super) const TUNNEL_CONTROL_PLANE_TEST_GENERATION: &str = "test-generation-1";
|
||||
|
||||
pub(super) fn with_tunnel_control_plane_key(
|
||||
mut node: StoredProxyNode,
|
||||
key: &str,
|
||||
) -> StoredProxyNode {
|
||||
let mut metadata = node.proxy_metadata.take().unwrap_or_else(|| json!({}));
|
||||
let metadata = metadata
|
||||
.as_object_mut()
|
||||
.expect("sample proxy metadata should be an object");
|
||||
metadata.insert(
|
||||
"tunnel_security".to_string(),
|
||||
json!({ "encryption_key": key }),
|
||||
);
|
||||
node.proxy_metadata = Some(serde_json::Value::Object(metadata.clone()));
|
||||
node
|
||||
}
|
||||
|
||||
pub(super) fn authenticated_tunnel_control_plane_request(
|
||||
client: &reqwest::Client,
|
||||
url: String,
|
||||
path: &str,
|
||||
node_id: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> reqwest::RequestBuilder {
|
||||
authenticated_tunnel_control_plane_request_for_generation(
|
||||
client,
|
||||
url,
|
||||
path,
|
||||
node_id,
|
||||
TUNNEL_CONTROL_PLANE_TEST_GENERATION,
|
||||
body,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn authenticated_tunnel_control_plane_request_for_generation(
|
||||
client: &reqwest::Client,
|
||||
url: String,
|
||||
path: &str,
|
||||
node_id: &str,
|
||||
tunnel_generation: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> reqwest::RequestBuilder {
|
||||
let body = serde_json::to_vec(body).expect("control-plane payload should encode");
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::SystemTime::UNIX_EPOCH)
|
||||
.expect("system clock")
|
||||
.as_secs();
|
||||
let nonce = uuid::Uuid::new_v4().simple().to_string();
|
||||
let signature =
|
||||
aether_contracts::tunnel_security::sign_tunnel_control_plane_request_for_generation(
|
||||
TUNNEL_CONTROL_PLANE_TEST_PSK,
|
||||
"POST",
|
||||
path,
|
||||
node_id,
|
||||
tunnel_generation,
|
||||
timestamp,
|
||||
&nonce,
|
||||
&body,
|
||||
)
|
||||
.expect("control-plane request should sign");
|
||||
client
|
||||
.post(url)
|
||||
.header("content-type", "application/json")
|
||||
.header(
|
||||
aether_contracts::tunnel_security::TUNNEL_CONTROL_PLANE_NODE_ID_HEADER,
|
||||
node_id,
|
||||
)
|
||||
.header(
|
||||
aether_contracts::tunnel_security::TUNNEL_CONTROL_PLANE_GENERATION_HEADER,
|
||||
tunnel_generation,
|
||||
)
|
||||
.header(
|
||||
aether_contracts::tunnel_security::TUNNEL_CONTROL_PLANE_TIMESTAMP_HEADER,
|
||||
timestamp,
|
||||
)
|
||||
.header(
|
||||
aether_contracts::tunnel_security::TUNNEL_CONTROL_PLANE_NONCE_HEADER,
|
||||
nonce,
|
||||
)
|
||||
.header(
|
||||
aether_contracts::tunnel_security::TUNNEL_CONTROL_PLANE_SIGNATURE_HEADER,
|
||||
signature,
|
||||
)
|
||||
.body(body)
|
||||
}
|
||||
|
||||
pub(super) fn sample_currently_usable_auth_snapshot(
|
||||
api_key_id: &str,
|
||||
@@ -78,7 +167,7 @@ pub(super) fn test_auth_secret() -> String {
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "aether-rust-dev-jwt-secret".to_string())
|
||||
.unwrap_or_else(|| "aether-rust-test-jwt-secret-32-bytes-minimum".to_string())
|
||||
}
|
||||
|
||||
pub(super) fn build_test_auth_token(
|
||||
@@ -112,7 +201,7 @@ pub(super) fn build_test_auth_token(
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn issue_test_admin_access_token(
|
||||
pub(in crate::tests) async fn issue_test_admin_access_token(
|
||||
state: &AppState,
|
||||
client_device_id: &str,
|
||||
) -> String {
|
||||
@@ -222,6 +311,7 @@ pub(super) fn sample_proxy_node(node_id: &str) -> StoredProxyNode {
|
||||
Some(1_709_000_000),
|
||||
Some(1_710_000_100),
|
||||
)
|
||||
.with_tunnel_generation(TUNNEL_CONTROL_PLANE_TEST_GENERATION.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn sample_provider_quota(provider_id: &str) -> StoredProviderQuotaSnapshot {
|
||||
@@ -501,6 +591,67 @@ pub(super) fn sample_key(
|
||||
.expect("key transport should build")
|
||||
}
|
||||
|
||||
/// Build a provider catalog key using the same provider/key-bound v2 envelope
|
||||
/// that production writes use. Most control-plane tests intentionally mount
|
||||
/// a read-only catalog repository; using a legacy Fernet fixture there would
|
||||
/// force the reader to migrate the credential and fail closed when no writer
|
||||
/// is available. Keep `sample_key` for tests that explicitly exercise the
|
||||
/// legacy migration path, and use this helper for ordinary catalog fixtures.
|
||||
pub(super) fn sample_bound_key(
|
||||
id: &str,
|
||||
provider_id: &str,
|
||||
api_format: &str,
|
||||
secret: &str,
|
||||
) -> StoredProviderCatalogKey {
|
||||
let bootstrap = AppState::new()
|
||||
.expect("bootstrap state should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::disabled().with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
);
|
||||
let mut key = sample_key(id, provider_id, api_format, secret);
|
||||
key.encrypted_api_key = Some(
|
||||
bootstrap
|
||||
.seal_provider_catalog_key_api_key(provider_id, id, secret)
|
||||
.expect("bound provider api key ciphertext should build"),
|
||||
);
|
||||
key
|
||||
}
|
||||
|
||||
/// Build the provider-scoped proxy representation used by the catalog reader.
|
||||
/// Stored proxy credentials are record-bound before they are accepted by
|
||||
/// read-only repositories, so fixtures must use the same envelope.
|
||||
pub(super) fn sample_bound_provider_proxy(provider_id: &str, host: &str, password: &str) -> Value {
|
||||
let bootstrap = AppState::new()
|
||||
.expect("bootstrap state should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::disabled().with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
);
|
||||
let purpose = format!(
|
||||
"provider-catalog-proxy-credential-v2\0scope=provider\0field=password\0record-id-bytes={}\0{provider_id}",
|
||||
provider_id.len(),
|
||||
);
|
||||
let sealed =
|
||||
crate::handlers::shared::seal_runtime_secret_payload(&bootstrap, &purpose, password)
|
||||
.expect("provider proxy password should seal");
|
||||
json!({
|
||||
"host": host,
|
||||
"password": format!("aether-provider-catalog-proxy-secret-v2:{sealed}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Seal an auth-config fixture with the provider/key-bound v2 envelope.
|
||||
/// Ordinary read-only catalog tests must not rely on the migration writer.
|
||||
pub(super) fn sample_bound_auth_config(provider_id: &str, key_id: &str, plaintext: &str) -> String {
|
||||
let bootstrap = AppState::new()
|
||||
.expect("bootstrap state should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::disabled().with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
);
|
||||
bootstrap
|
||||
.seal_provider_catalog_key_auth_config(provider_id, key_id, plaintext)
|
||||
.expect("bound provider auth config ciphertext should build")
|
||||
}
|
||||
|
||||
pub(super) fn sample_request_candidate(
|
||||
id: &str,
|
||||
request_id: &str,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ use aether_data::repository::proxy_nodes::{
|
||||
InMemoryProxyNodeRepository, ProxyNodeReadRepository, StoredProxyNode, StoredProxyNodeEvent,
|
||||
};
|
||||
use aether_data::repository::quota::InMemoryProviderQuotaRepository;
|
||||
use aether_data::repository::users::InMemoryUserReadRepository;
|
||||
use aether_data::repository::wallet::InMemoryWalletRepository;
|
||||
use aether_data_contracts::repository::{
|
||||
candidates::{RequestCandidateStatus, StoredRequestCandidate},
|
||||
@@ -49,6 +50,8 @@ mod helpers;
|
||||
mod internal;
|
||||
mod proxy;
|
||||
|
||||
pub(super) use helpers::issue_test_admin_access_token as issue_shared_test_admin_access_token;
|
||||
|
||||
use super::{
|
||||
build_router, build_router_with_execution_runtime_override, build_router_with_state,
|
||||
build_state_with_execution_runtime_override, start_server, wait_until, AppState,
|
||||
|
||||
@@ -10,7 +10,7 @@ use serde_json::json;
|
||||
|
||||
use super::super::{
|
||||
any, build_router_with_state, build_state_with_execution_runtime_override, hash_api_key,
|
||||
sample_currently_usable_auth_snapshot, sample_endpoint, sample_key, sample_provider,
|
||||
sample_bound_key, sample_currently_usable_auth_snapshot, sample_endpoint, sample_provider,
|
||||
start_server, AppState, GatewayDataState, InMemoryAuthApiKeySnapshotRepository,
|
||||
InMemoryProviderCatalogReadRepository, Json, Router,
|
||||
};
|
||||
@@ -74,7 +74,7 @@ fn embedding_success_state(execution_runtime_url: String) -> AppState {
|
||||
"openai:embedding",
|
||||
"https://api.openai.example",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-upstream-embedding",
|
||||
"provider-embedding",
|
||||
"openai:embedding",
|
||||
@@ -135,7 +135,7 @@ fn gemini_embedding_success_state(
|
||||
"gemini:embedding",
|
||||
"https://generativelanguage.googleapis.com/v1beta",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-upstream-gemini-embedding",
|
||||
"provider-gemini-embedding",
|
||||
"gemini:embedding",
|
||||
@@ -175,7 +175,7 @@ fn vertex_gemini_embedding_success_state(execution_runtime_url: String) -> AppSt
|
||||
]));
|
||||
let mut provider = sample_provider("provider-vertex-gemini-embedding", "Vertex AI", 1);
|
||||
provider.provider_type = "vertex_ai".to_string();
|
||||
let mut key = sample_key(
|
||||
let mut key = sample_bound_key(
|
||||
"key-upstream-vertex-gemini-embedding",
|
||||
"provider-vertex-gemini-embedding",
|
||||
"gemini:embedding",
|
||||
@@ -233,7 +233,7 @@ fn aliyun_embedding_success_state(execution_runtime_url: String) -> AppState {
|
||||
"aliyun:multimodal_embedding",
|
||||
"https://dashscope.aliyuncs.com",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-upstream-aliyun-embedding",
|
||||
"provider-aliyun-embedding",
|
||||
"aliyun:multimodal_embedding",
|
||||
@@ -301,13 +301,13 @@ fn mixed_embedding_success_state(execution_runtime_url: String) -> AppState {
|
||||
),
|
||||
],
|
||||
vec![
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-upstream-embedding",
|
||||
"provider-embedding",
|
||||
"openai:embedding",
|
||||
"sk-upstream-embedding",
|
||||
),
|
||||
sample_key(
|
||||
sample_bound_key(
|
||||
"key-upstream-aliyun-embedding",
|
||||
"provider-aliyun-embedding",
|
||||
"aliyun:multimodal_embedding",
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use aether_contracts::tunnel::{
|
||||
sign_tunnel_relay_request, tunnel_relay_payload_digest, TUNNEL_RELAY_AUTH_NONCE_HEADER,
|
||||
TUNNEL_RELAY_AUTH_PAYLOAD_HEADER, TUNNEL_RELAY_AUTH_SENDER_HEADER,
|
||||
TUNNEL_RELAY_AUTH_SIGNATURE_HEADER, TUNNEL_RELAY_AUTH_TIMESTAMP_HEADER,
|
||||
TUNNEL_RELAY_FORWARDED_BY_HEADER, TUNNEL_RELAY_OWNER_INSTANCE_HEADER,
|
||||
};
|
||||
use axum::body::Body;
|
||||
use axum::routing::any;
|
||||
use axum::{extract::Request, Json, Router};
|
||||
use http::StatusCode;
|
||||
use http::{HeaderMap, HeaderValue, Method, StatusCode, Uri};
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::{
|
||||
@@ -14,10 +21,144 @@ use super::super::{
|
||||
};
|
||||
use crate::constants::{
|
||||
CONTROL_ROUTE_CLASS_HEADER, EXECUTION_PATH_HEADER, EXECUTION_PATH_LOCAL_AUTH_DENIED,
|
||||
GATEWAY_HEADER, TRACE_ID_HEADER, TRUSTED_AUTH_ACCESS_ALLOWED_HEADER,
|
||||
FORWARDED_FOR_HEADER, GATEWAY_HEADER, TRACE_ID_HEADER, TRUSTED_AUTH_ACCESS_ALLOWED_HEADER,
|
||||
TRUSTED_AUTH_API_KEY_ID_HEADER, TRUSTED_AUTH_BALANCE_HEADER, TRUSTED_AUTH_USER_ID_HEADER,
|
||||
TUNNEL_AFFINITY_FORWARDED_BY_HEADER, TUNNEL_AFFINITY_NODE_ID_HEADER,
|
||||
TUNNEL_AFFINITY_OWNER_INSTANCE_HEADER,
|
||||
};
|
||||
|
||||
const RELAY_TEST_SECRET: &str = "relay-test-secret-at-least-32-bytes";
|
||||
const RELAY_TEST_SENDER: &str = "gateway-a";
|
||||
const RELAY_TEST_OWNER: &str = "gateway-b";
|
||||
const RELAY_TEST_NODE_ID: &str = "node-1";
|
||||
const AFFINITY_TEST_BODY: &str = "{\"model\":\"gpt-5\",\"messages\":[]}";
|
||||
|
||||
fn signed_affinity_headers(
|
||||
method: &Method,
|
||||
uri: &Uri,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
access_allowed: bool,
|
||||
balance: Option<&str>,
|
||||
body: &[u8],
|
||||
) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
GATEWAY_HEADER,
|
||||
HeaderValue::from_static("rust-phase3b-affinity"),
|
||||
);
|
||||
headers.insert(
|
||||
TUNNEL_RELAY_FORWARDED_BY_HEADER,
|
||||
HeaderValue::from_static(RELAY_TEST_SENDER),
|
||||
);
|
||||
headers.insert(
|
||||
TUNNEL_AFFINITY_FORWARDED_BY_HEADER,
|
||||
HeaderValue::from_static(RELAY_TEST_SENDER),
|
||||
);
|
||||
headers.insert(
|
||||
TUNNEL_AFFINITY_OWNER_INSTANCE_HEADER,
|
||||
HeaderValue::from_static(RELAY_TEST_OWNER),
|
||||
);
|
||||
headers.insert(
|
||||
TUNNEL_AFFINITY_NODE_ID_HEADER,
|
||||
HeaderValue::from_static(RELAY_TEST_NODE_ID),
|
||||
);
|
||||
headers.insert(
|
||||
TRUSTED_AUTH_USER_ID_HEADER,
|
||||
HeaderValue::from_str(user_id).expect("trusted user ID should be a valid header value"),
|
||||
);
|
||||
headers.insert(
|
||||
TRUSTED_AUTH_API_KEY_ID_HEADER,
|
||||
HeaderValue::from_str(api_key_id)
|
||||
.expect("trusted API key ID should be a valid header value"),
|
||||
);
|
||||
headers.insert(
|
||||
TRUSTED_AUTH_ACCESS_ALLOWED_HEADER,
|
||||
HeaderValue::from_static(if access_allowed { "true" } else { "false" }),
|
||||
);
|
||||
headers.insert(
|
||||
FORWARDED_FOR_HEADER,
|
||||
HeaderValue::from_static("203.0.113.10"),
|
||||
);
|
||||
if let Some(balance) = balance {
|
||||
headers.insert(
|
||||
TRUSTED_AUTH_BALANCE_HEADER,
|
||||
HeaderValue::from_str(balance).expect("trusted balance should be a valid header value"),
|
||||
);
|
||||
}
|
||||
|
||||
let metadata = crate::tunnel::build_tunnel_affinity_auth_metadata(method, uri, &headers)
|
||||
.expect("affinity authentication metadata should build");
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should be after epoch")
|
||||
.as_secs();
|
||||
let nonce = uuid::Uuid::new_v4().simple().to_string();
|
||||
let payload_digest = tunnel_relay_payload_digest(&metadata, body);
|
||||
let signature = sign_tunnel_relay_request(
|
||||
RELAY_TEST_SECRET.as_bytes(),
|
||||
RELAY_TEST_SENDER,
|
||||
RELAY_TEST_OWNER,
|
||||
RELAY_TEST_NODE_ID,
|
||||
RELAY_TEST_SENDER,
|
||||
false,
|
||||
timestamp,
|
||||
&nonce,
|
||||
&payload_digest,
|
||||
);
|
||||
headers.insert(
|
||||
TUNNEL_RELAY_AUTH_SENDER_HEADER,
|
||||
HeaderValue::from_static(RELAY_TEST_SENDER),
|
||||
);
|
||||
headers.insert(
|
||||
TUNNEL_RELAY_OWNER_INSTANCE_HEADER,
|
||||
HeaderValue::from_static(RELAY_TEST_OWNER),
|
||||
);
|
||||
headers.insert(
|
||||
TUNNEL_RELAY_AUTH_TIMESTAMP_HEADER,
|
||||
HeaderValue::from_str(×tamp.to_string()).expect("timestamp should be a valid header"),
|
||||
);
|
||||
headers.insert(
|
||||
TUNNEL_RELAY_AUTH_NONCE_HEADER,
|
||||
HeaderValue::from_str(&nonce).expect("nonce should be a valid header"),
|
||||
);
|
||||
headers.insert(
|
||||
TUNNEL_RELAY_AUTH_PAYLOAD_HEADER,
|
||||
HeaderValue::from_str(&payload_digest.encode_header_value())
|
||||
.expect("payload digest should be a valid header"),
|
||||
);
|
||||
headers.insert(
|
||||
TUNNEL_RELAY_AUTH_SIGNATURE_HEADER,
|
||||
HeaderValue::from_str(&signature).expect("signature should be a valid header"),
|
||||
);
|
||||
headers
|
||||
}
|
||||
|
||||
fn signed_affinity_request(
|
||||
client: &reqwest::Client,
|
||||
url: String,
|
||||
path: &str,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
access_allowed: bool,
|
||||
balance: Option<&str>,
|
||||
body: &[u8],
|
||||
) -> reqwest::RequestBuilder {
|
||||
let method = Method::POST;
|
||||
let uri = path.parse::<Uri>().expect("request path should be valid");
|
||||
client
|
||||
.request(method.clone(), url)
|
||||
.headers(signed_affinity_headers(
|
||||
&method,
|
||||
&uri,
|
||||
user_id,
|
||||
api_key_id,
|
||||
access_allowed,
|
||||
balance,
|
||||
body,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_locally_denies_explicit_trusted_balance_failure_without_hitting_control_or_upstream(
|
||||
) {
|
||||
@@ -63,23 +204,32 @@ async fn gateway_locally_denies_explicit_trusted_balance_failure_without_hitting
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway state should build")
|
||||
.with_auth_api_key_data_reader_for_tests(repository),
|
||||
.with_auth_api_key_data_reader_for_tests(repository)
|
||||
.with_tunnel_identity_and_relay_secret_for_tests(
|
||||
RELAY_TEST_OWNER,
|
||||
None,
|
||||
RELAY_TEST_SECRET,
|
||||
),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/v1/chat/completions"))
|
||||
.header(http::header::CONTENT_TYPE, "application/json")
|
||||
.header(TRACE_ID_HEADER, "trace-control-balance-denied-1")
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_AUTH_USER_ID_HEADER, "user-123")
|
||||
.header(TRUSTED_AUTH_API_KEY_ID_HEADER, "key-123")
|
||||
.header(TRUSTED_AUTH_BALANCE_HEADER, "0")
|
||||
.header(TRUSTED_AUTH_ACCESS_ALLOWED_HEADER, "false")
|
||||
.body("{\"model\":\"gpt-5\",\"messages\":[]}")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
let client = reqwest::Client::new();
|
||||
let response = signed_affinity_request(
|
||||
&client,
|
||||
format!("{gateway_url}/v1/chat/completions"),
|
||||
"/v1/chat/completions",
|
||||
"user-123",
|
||||
"key-123",
|
||||
false,
|
||||
Some("0"),
|
||||
AFFINITY_TEST_BODY.as_bytes(),
|
||||
)
|
||||
.header(http::header::CONTENT_TYPE, "application/json")
|
||||
.header(TRACE_ID_HEADER, "trace-control-balance-denied-1")
|
||||
.body(AFFINITY_TEST_BODY)
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
assert_eq!(
|
||||
@@ -152,21 +302,32 @@ async fn gateway_locally_denies_invalid_trusted_snapshot_without_hitting_control
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway state should build")
|
||||
.with_auth_api_key_data_reader_for_tests(repository),
|
||||
.with_auth_api_key_data_reader_for_tests(repository)
|
||||
.with_tunnel_identity_and_relay_secret_for_tests(
|
||||
RELAY_TEST_OWNER,
|
||||
None,
|
||||
RELAY_TEST_SECRET,
|
||||
),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/v1/chat/completions"))
|
||||
.header(http::header::CONTENT_TYPE, "application/json")
|
||||
.header(TRACE_ID_HEADER, "trace-control-invalid-trusted-1")
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_AUTH_USER_ID_HEADER, "user-123")
|
||||
.header(TRUSTED_AUTH_API_KEY_ID_HEADER, "key-123")
|
||||
.body("{\"model\":\"gpt-5\",\"messages\":[]}")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
let client = reqwest::Client::new();
|
||||
let response = signed_affinity_request(
|
||||
&client,
|
||||
format!("{gateway_url}/v1/chat/completions"),
|
||||
"/v1/chat/completions",
|
||||
"user-123",
|
||||
"key-123",
|
||||
true,
|
||||
None,
|
||||
AFFINITY_TEST_BODY.as_bytes(),
|
||||
)
|
||||
.header(http::header::CONTENT_TYPE, "application/json")
|
||||
.header(TRACE_ID_HEADER, "trace-control-invalid-trusted-1")
|
||||
.body(AFFINITY_TEST_BODY)
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(
|
||||
@@ -227,21 +388,32 @@ async fn gateway_locally_denies_missing_wallet_without_hitting_control_or_upstre
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway state should build")
|
||||
.with_data_state_for_tests(data_state),
|
||||
.with_data_state_for_tests(data_state)
|
||||
.with_tunnel_identity_and_relay_secret_for_tests(
|
||||
RELAY_TEST_OWNER,
|
||||
None,
|
||||
RELAY_TEST_SECRET,
|
||||
),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/v1/chat/completions"))
|
||||
.header(http::header::CONTENT_TYPE, "application/json")
|
||||
.header(TRACE_ID_HEADER, "trace-control-wallet-missing-1")
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_AUTH_USER_ID_HEADER, "user-123")
|
||||
.header(TRUSTED_AUTH_API_KEY_ID_HEADER, "key-123")
|
||||
.body("{\"model\":\"gpt-5\",\"messages\":[]}")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
let client = reqwest::Client::new();
|
||||
let response = signed_affinity_request(
|
||||
&client,
|
||||
format!("{gateway_url}/v1/chat/completions"),
|
||||
"/v1/chat/completions",
|
||||
"user-123",
|
||||
"key-123",
|
||||
true,
|
||||
None,
|
||||
AFFINITY_TEST_BODY.as_bytes(),
|
||||
)
|
||||
.header(http::header::CONTENT_TYPE, "application/json")
|
||||
.header(TRACE_ID_HEADER, "trace-control-wallet-missing-1")
|
||||
.body(AFFINITY_TEST_BODY)
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
@@ -757,21 +929,32 @@ async fn gateway_locally_denies_locked_trusted_snapshot_without_hitting_control_
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway state should build")
|
||||
.with_auth_api_key_data_reader_for_tests(repository),
|
||||
.with_auth_api_key_data_reader_for_tests(repository)
|
||||
.with_tunnel_identity_and_relay_secret_for_tests(
|
||||
RELAY_TEST_OWNER,
|
||||
None,
|
||||
RELAY_TEST_SECRET,
|
||||
),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/v1/chat/completions"))
|
||||
.header(http::header::CONTENT_TYPE, "application/json")
|
||||
.header(TRACE_ID_HEADER, "trace-control-locked-trusted-1")
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_AUTH_USER_ID_HEADER, "user-locked-123")
|
||||
.header(TRUSTED_AUTH_API_KEY_ID_HEADER, "key-locked-123")
|
||||
.body("{\"model\":\"gpt-5\",\"messages\":[]}")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
let client = reqwest::Client::new();
|
||||
let response = signed_affinity_request(
|
||||
&client,
|
||||
format!("{gateway_url}/v1/chat/completions"),
|
||||
"/v1/chat/completions",
|
||||
"user-locked-123",
|
||||
"key-locked-123",
|
||||
true,
|
||||
None,
|
||||
AFFINITY_TEST_BODY.as_bytes(),
|
||||
)
|
||||
.header(http::header::CONTENT_TYPE, "application/json")
|
||||
.header(TRACE_ID_HEADER, "trace-control-locked-trusted-1")
|
||||
.body(AFFINITY_TEST_BODY)
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
|
||||
@@ -10,7 +10,7 @@ use serde_json::json;
|
||||
|
||||
use super::super::{
|
||||
any, build_router_with_state, build_state_with_execution_runtime_override, hash_api_key,
|
||||
sample_currently_usable_auth_snapshot, sample_endpoint, sample_key, sample_provider,
|
||||
sample_bound_key, sample_currently_usable_auth_snapshot, sample_endpoint, sample_provider,
|
||||
start_server, AppState, GatewayDataState, InMemoryAuthApiKeySnapshotRepository,
|
||||
InMemoryProviderCatalogReadRepository, Json, Router,
|
||||
};
|
||||
@@ -69,7 +69,7 @@ fn rerank_success_state(execution_runtime_url: String) -> AppState {
|
||||
"openai:rerank",
|
||||
"https://api.openai.example",
|
||||
)],
|
||||
vec![sample_key(
|
||||
vec![sample_bound_key(
|
||||
"key-upstream-rerank",
|
||||
"provider-rerank",
|
||||
"openai:rerank",
|
||||
|
||||
Reference in New Issue
Block a user