mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
feat(proxy): 重构 Proxy 节点管理与隧道系统
- 重构 proxy_nodes 管理端,支持节点注册、心跳、隧道生命周期管理 - 增强 tunnel 嵌入式 hub 和隧道协议 - 重构 aether-proxy 配置、隧道客户端、心跳和调度机制 - 调整 admin OAuth/配额/导入等处理器的参数传递 - 扩展数据迁移模块 - 补充 proxy nodes、OAuth、配额、系统导入等测试 - 更新前端 proxy nodes 视图和 API
This commit is contained in:
@@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
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, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
};
|
||||
@@ -14,7 +15,7 @@ use serde_json::json;
|
||||
|
||||
use super::super::super::{
|
||||
build_router_with_state, build_state_with_execution_runtime_override, sample_endpoint,
|
||||
sample_key, start_server,
|
||||
sample_key, sample_proxy_node, start_server,
|
||||
};
|
||||
use crate::constants::{
|
||||
GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
|
||||
@@ -29,6 +30,7 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
||||
url: String,
|
||||
authorization: String,
|
||||
provider_api_format: String,
|
||||
total_ms: Option<u64>,
|
||||
}
|
||||
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
@@ -67,6 +69,10 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
provider_api_format: plan.provider_api_format.clone(),
|
||||
total_ms: plan
|
||||
.timeouts
|
||||
.as_ref()
|
||||
.and_then(|timeouts| timeouts.total_ms),
|
||||
});
|
||||
let result = aether_contracts::ExecutionResult {
|
||||
request_id: plan.request_id,
|
||||
@@ -178,6 +184,7 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
||||
seen_execution_runtime_request.provider_api_format,
|
||||
"openai:cli"
|
||||
);
|
||||
assert_eq!(seen_execution_runtime_request.total_ms, Some(30_000));
|
||||
|
||||
let reloaded = provider_catalog_repository
|
||||
.list_keys_by_ids(&["key-codex-a".to_string()])
|
||||
@@ -215,6 +222,143 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_refreshes_admin_provider_quota_for_codex_proxy_with_extended_timeout() {
|
||||
let upstream =
|
||||
Router::new().route(
|
||||
"/api/admin/endpoints/providers/provider-codex/refresh-quota",
|
||||
any(|_request: Request| async move {
|
||||
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||
}),
|
||||
);
|
||||
|
||||
let seen_execution_runtime = Arc::new(Mutex::new(None::<aether_contracts::ExecutionPlan>));
|
||||
let seen_execution_runtime_clone = Arc::clone(&seen_execution_runtime);
|
||||
let execution_runtime = Router::new().route(
|
||||
"/v1/execute/sync",
|
||||
any(move |request: Request| {
|
||||
let seen_execution_runtime_inner = Arc::clone(&seen_execution_runtime_clone);
|
||||
async move {
|
||||
let plan: aether_contracts::ExecutionPlan = serde_json::from_slice(
|
||||
&to_bytes(request.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body should read"),
|
||||
)
|
||||
.expect("plan should parse");
|
||||
*seen_execution_runtime_inner
|
||||
.lock()
|
||||
.expect("mutex should lock") = Some(plan.clone());
|
||||
let result = aether_contracts::ExecutionResult {
|
||||
request_id: plan.request_id,
|
||||
candidate_id: None,
|
||||
status_code: 200,
|
||||
headers: BTreeMap::new(),
|
||||
body: Some(aether_contracts::ResponseBody {
|
||||
json_body: Some(json!({
|
||||
"plan_type": "plus",
|
||||
"rate_limit": {
|
||||
"primary_window": {
|
||||
"used_percent": 12.5,
|
||||
"reset_after_seconds": 18000,
|
||||
"reset_at": 1_900_000_000u64,
|
||||
"window_minutes": 300
|
||||
}
|
||||
}
|
||||
})),
|
||||
body_bytes_b64: None,
|
||||
}),
|
||||
telemetry: None,
|
||||
error: None,
|
||||
};
|
||||
(StatusCode::OK, Json(result))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let mut provider = StoredProviderCatalogProvider::new(
|
||||
"provider-codex".to_string(),
|
||||
"codex".to_string(),
|
||||
Some("https://example.com".to_string()),
|
||||
"codex".to_string(),
|
||||
)
|
||||
.expect("provider should build");
|
||||
provider.proxy = Some(json!({
|
||||
"node_id": "proxy-node-codex-quota",
|
||||
"enabled": true
|
||||
}));
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![sample_endpoint(
|
||||
"endpoint-codex-cli",
|
||||
"provider-codex",
|
||||
"openai:cli",
|
||||
"https://chatgpt.com/backend-api",
|
||||
)],
|
||||
vec![sample_key(
|
||||
"key-codex-a",
|
||||
"provider-codex",
|
||||
"openai:cli",
|
||||
"sk-codex-123",
|
||||
)],
|
||||
));
|
||||
let mut manual_node = sample_proxy_node("proxy-node-codex-quota");
|
||||
manual_node.status = "online".to_string();
|
||||
manual_node.is_manual = true;
|
||||
manual_node.tunnel_mode = false;
|
||||
manual_node.tunnel_connected = false;
|
||||
manual_node.proxy_url = Some("http://proxy.example:8080".to_string());
|
||||
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![manual_node]));
|
||||
|
||||
let (_upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let gateway = build_router_with_state(
|
||||
build_state_with_execution_runtime_override(execution_runtime_url.clone())
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_repository_for_tests(
|
||||
provider_catalog_repository,
|
||||
)
|
||||
.attach_proxy_node_repository_for_tests(proxy_node_repository)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{gateway_url}/api/admin/endpoints/providers/provider-codex/refresh-quota"
|
||||
))
|
||||
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let plan = seen_execution_runtime
|
||||
.lock()
|
||||
.expect("mutex should lock")
|
||||
.clone()
|
||||
.expect("execution runtime request should be captured");
|
||||
assert_eq!(
|
||||
plan.proxy
|
||||
.as_ref()
|
||||
.and_then(|proxy| proxy.node_id.as_deref()),
|
||||
Some("proxy-node-codex-quota")
|
||||
);
|
||||
let timeouts = plan.timeouts.expect("timeouts should exist");
|
||||
assert_eq!(timeouts.connect_ms, Some(60_000));
|
||||
assert_eq!(timeouts.read_ms, Some(60_000));
|
||||
assert_eq!(timeouts.write_ms, Some(60_000));
|
||||
assert_eq!(timeouts.pool_ms, Some(60_000));
|
||||
assert_eq!(timeouts.total_ms, Some(60_000));
|
||||
|
||||
gateway_handle.abort();
|
||||
execution_runtime_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_refreshes_admin_provider_quota_locally_for_kiro_with_trusted_admin_principal() {
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -398,10 +398,7 @@ async fn gateway_handles_admin_provider_oauth_device_poll_locally_with_trusted_a
|
||||
.next()
|
||||
.expect("persisted key should exist");
|
||||
assert_eq!(persisted.auth_type, "oauth");
|
||||
assert_eq!(
|
||||
persisted.proxy,
|
||||
Some(json!({"node_id":"proxy-node-kiro","enabled":true}))
|
||||
);
|
||||
assert_eq!(persisted.proxy, None);
|
||||
let decrypted_api_key =
|
||||
decrypt_python_fernet_ciphertext(DEVELOPMENT_ENCRYPTION_KEY, &persisted.encrypted_api_key)
|
||||
.expect("api key should decrypt");
|
||||
@@ -1133,10 +1130,7 @@ async fn gateway_batch_imports_admin_provider_oauth_locally_with_trusted_admin_p
|
||||
.expect("keys should load");
|
||||
let persisted = reloaded.first().expect("persisted key should exist");
|
||||
assert!(persisted.is_active);
|
||||
assert_eq!(
|
||||
persisted.proxy,
|
||||
Some(json!({"node_id":"proxy-node-batch-import","enabled":true}))
|
||||
);
|
||||
assert_eq!(persisted.proxy, None);
|
||||
let decrypted_api_key =
|
||||
decrypt_python_fernet_ciphertext(DEVELOPMENT_ENCRYPTION_KEY, &persisted.encrypted_api_key)
|
||||
.expect("api key should decrypt");
|
||||
@@ -1604,10 +1598,7 @@ async fn gateway_completes_admin_provider_oauth_provider_locally_with_trusted_ad
|
||||
.expect("keys should load");
|
||||
let persisted = reloaded.first().expect("persisted key should exist");
|
||||
assert!(persisted.is_active);
|
||||
assert_eq!(
|
||||
persisted.proxy,
|
||||
Some(json!({"node_id":"proxy-node-codex-oauth","enabled":true}))
|
||||
);
|
||||
assert_eq!(persisted.proxy, None);
|
||||
let decrypted_api_key =
|
||||
decrypt_python_fernet_ciphertext(DEVELOPMENT_ENCRYPTION_KEY, &persisted.encrypted_api_key)
|
||||
.expect("api key should decrypt");
|
||||
@@ -1775,10 +1766,7 @@ async fn gateway_imports_admin_provider_oauth_refresh_token_locally_with_trusted
|
||||
.expect("keys should load");
|
||||
let persisted = reloaded.first().expect("persisted key should exist");
|
||||
assert!(persisted.is_active);
|
||||
assert_eq!(
|
||||
persisted.proxy,
|
||||
Some(json!({"node_id":"proxy-node-codex-import","enabled":true}))
|
||||
);
|
||||
assert_eq!(persisted.proxy, None);
|
||||
let decrypted_api_key =
|
||||
decrypt_python_fernet_ciphertext(DEVELOPMENT_ENCRYPTION_KEY, &persisted.encrypted_api_key)
|
||||
.expect("api key should decrypt");
|
||||
@@ -1819,6 +1807,12 @@ async fn gateway_imports_admin_provider_oauth_refresh_token_via_execution_runtim
|
||||
.push(plan.clone());
|
||||
let proxy = plan.proxy.as_ref().expect("proxy snapshot should exist");
|
||||
assert_eq!(proxy.node_id.as_deref(), Some("proxy-node-codex-import"));
|
||||
let timeouts = plan.timeouts.as_ref().expect("timeouts should exist");
|
||||
assert_eq!(timeouts.connect_ms, Some(60_000));
|
||||
assert_eq!(timeouts.read_ms, Some(60_000));
|
||||
assert_eq!(timeouts.write_ms, Some(60_000));
|
||||
assert_eq!(timeouts.pool_ms, Some(60_000));
|
||||
assert_eq!(timeouts.total_ms, Some(60_000));
|
||||
assert_eq!(plan.request_id, "provider-oauth:refresh-token");
|
||||
assert_eq!(plan.method, "POST");
|
||||
assert_eq!(plan.url, "https://oauth.example/oauth/token");
|
||||
@@ -1920,10 +1914,7 @@ async fn gateway_imports_admin_provider_oauth_refresh_token_via_execution_runtim
|
||||
.await
|
||||
.expect("keys should load");
|
||||
assert_eq!(keys.len(), 1);
|
||||
assert_eq!(
|
||||
keys[0].proxy,
|
||||
Some(json!({"node_id":"proxy-node-codex-import","enabled":true}))
|
||||
);
|
||||
assert_eq!(keys[0].proxy, None);
|
||||
|
||||
let plans = execution_plans.lock().expect("mutex should lock");
|
||||
assert_eq!(plans.len(), 1);
|
||||
@@ -1932,6 +1923,136 @@ async fn gateway_imports_admin_provider_oauth_refresh_token_via_execution_runtim
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_imports_admin_provider_oauth_refresh_token_via_execution_runtime_provider_proxy_before_system_proxy(
|
||||
) {
|
||||
let execution_plans = Arc::new(Mutex::new(Vec::<ExecutionPlan>::new()));
|
||||
let execution_plans_clone = Arc::clone(&execution_plans);
|
||||
let execution_runtime = Router::new().route(
|
||||
"/v1/execute/sync",
|
||||
any(move |Json(plan): Json<ExecutionPlan>| {
|
||||
let execution_plans_inner = Arc::clone(&execution_plans_clone);
|
||||
async move {
|
||||
execution_plans_inner
|
||||
.lock()
|
||||
.expect("mutex should lock")
|
||||
.push(plan.clone());
|
||||
let proxy = plan.proxy.as_ref().expect("proxy snapshot should exist");
|
||||
assert_eq!(proxy.node_id.as_deref(), Some("proxy-node-codex-provider"));
|
||||
Json(json!({
|
||||
"request_id": plan.request_id,
|
||||
"status_code": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": {
|
||||
"json_body": {
|
||||
"access_token": "imported-codex-access-token",
|
||||
"refresh_token": "imported-codex-refresh-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 1800,
|
||||
"scope": "openid email profile offline_access",
|
||||
"email": "alice@example.com",
|
||||
"account_id": "acct-codex-123",
|
||||
"plan_type": "plus"
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let mut provider = sample_provider("provider-codex", "codex", 10);
|
||||
provider.provider_type = "codex".to_string();
|
||||
provider.proxy = Some(json!({"node_id":"proxy-node-codex-provider","enabled":true}));
|
||||
let endpoint = sample_endpoint(
|
||||
"endpoint-codex-chat",
|
||||
"provider-codex",
|
||||
"openai:chat",
|
||||
"https://chatgpt.com/backend-api/codex",
|
||||
);
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![endpoint],
|
||||
vec![],
|
||||
));
|
||||
let mut provider_node = sample_proxy_node("proxy-node-codex-provider");
|
||||
provider_node.status = "online".to_string();
|
||||
provider_node.is_manual = true;
|
||||
provider_node.tunnel_mode = false;
|
||||
provider_node.tunnel_connected = false;
|
||||
provider_node.proxy_url = Some("http://proxy-provider.example:8080".to_string());
|
||||
let mut system_node = sample_proxy_node("proxy-node-codex-system");
|
||||
system_node.status = "online".to_string();
|
||||
system_node.is_manual = true;
|
||||
system_node.tunnel_mode = false;
|
||||
system_node.tunnel_connected = false;
|
||||
system_node.proxy_url = Some("http://proxy-system.example:8080".to_string());
|
||||
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![
|
||||
provider_node,
|
||||
system_node,
|
||||
]));
|
||||
|
||||
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_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_repository_for_tests(
|
||||
provider_catalog_repository.clone(),
|
||||
)
|
||||
.attach_proxy_node_repository_for_tests(proxy_node_repository)
|
||||
.with_system_config_values_for_tests(vec![(
|
||||
"system_proxy_node_id".to_string(),
|
||||
json!("proxy-node-codex-system"),
|
||||
)])
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
)
|
||||
.with_provider_oauth_token_url_for_tests("codex", "https://oauth.example/oauth/token"),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/import-refresh-token"
|
||||
))
|
||||
.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!({
|
||||
"refresh_token": "provider-import-refresh-token",
|
||||
"name": "codex-import"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
let status = response.status();
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
||||
assert_eq!(payload["provider_type"], "codex");
|
||||
|
||||
let keys = provider_catalog_repository
|
||||
.list_keys_by_provider_ids(&["provider-codex".to_string()])
|
||||
.await
|
||||
.expect("keys should load");
|
||||
assert_eq!(keys.len(), 1);
|
||||
assert_eq!(keys[0].proxy, None);
|
||||
|
||||
let plans = execution_plans.lock().expect("mutex should lock");
|
||||
assert_eq!(plans.len(), 1);
|
||||
assert_eq!(
|
||||
plans[0]
|
||||
.proxy
|
||||
.as_ref()
|
||||
.and_then(|proxy| proxy.node_id.as_deref()),
|
||||
Some("proxy-node-codex-provider")
|
||||
);
|
||||
|
||||
gateway_handle.abort();
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_imports_admin_provider_oauth_refresh_token_via_execution_runtime_system_proxy() {
|
||||
let execution_plans = Arc::new(Mutex::new(Vec::<ExecutionPlan>::new()));
|
||||
@@ -2044,6 +2165,71 @@ async fn gateway_imports_admin_provider_oauth_refresh_token_via_execution_runtim
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_import_refresh_token_surfaces_execution_runtime_error_detail() {
|
||||
let execution_runtime = Router::new().route(
|
||||
"/v1/execute/sync",
|
||||
any(|| async { StatusCode::INTERNAL_SERVER_ERROR }),
|
||||
);
|
||||
|
||||
let mut provider = sample_provider("provider-codex", "codex", 10);
|
||||
provider.provider_type = "codex".to_string();
|
||||
let endpoint = sample_endpoint(
|
||||
"endpoint-codex-chat",
|
||||
"provider-codex",
|
||||
"openai:chat",
|
||||
"https://chatgpt.com/backend-api/codex",
|
||||
);
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![endpoint],
|
||||
vec![],
|
||||
));
|
||||
|
||||
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_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_repository_for_tests(
|
||||
provider_catalog_repository,
|
||||
)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
)
|
||||
.with_provider_oauth_token_url_for_tests("codex", "https://oauth.example/oauth/token"),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/import-refresh-token"
|
||||
))
|
||||
.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!({
|
||||
"refresh_token": "provider-import-refresh-token",
|
||||
"name": "codex-import"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
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()
|
||||
.expect("detail should be string")
|
||||
.contains("execution runtime returned HTTP 500"),
|
||||
"payload={payload}"
|
||||
);
|
||||
|
||||
gateway_handle.abort();
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_batch_imports_admin_provider_oauth_kiro_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
@@ -2162,10 +2348,7 @@ async fn gateway_batch_imports_admin_provider_oauth_kiro_locally_with_trusted_ad
|
||||
.next()
|
||||
.expect("persisted key should exist");
|
||||
assert!(stored_key.is_active);
|
||||
assert_eq!(
|
||||
stored_key.proxy,
|
||||
Some(json!({"node_id":"proxy-node-kiro-batch","enabled":true}))
|
||||
);
|
||||
assert_eq!(stored_key.proxy, None);
|
||||
let decrypted_auth_config = decrypt_python_fernet_ciphertext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
stored_key
|
||||
@@ -2324,10 +2507,7 @@ async fn gateway_batch_imports_admin_provider_oauth_kiro_via_execution_runtime_p
|
||||
.next()
|
||||
.expect("persisted key should exist");
|
||||
assert!(stored_key.is_active);
|
||||
assert_eq!(
|
||||
stored_key.proxy,
|
||||
Some(json!({"node_id":"proxy-node-kiro-batch-runtime","enabled":true}))
|
||||
);
|
||||
assert_eq!(stored_key.proxy, None);
|
||||
let decrypted_auth_config = decrypt_python_fernet_ciphertext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
stored_key
|
||||
@@ -2653,6 +2833,160 @@ async fn gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_p
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_refreshes_admin_provider_oauth_key_locally_via_execution_runtime_provider_proxy_before_system_proxy(
|
||||
) {
|
||||
let execution_plans = Arc::new(Mutex::new(Vec::<ExecutionPlan>::new()));
|
||||
let execution_plans_clone = Arc::clone(&execution_plans);
|
||||
let execution_runtime = Router::new().route(
|
||||
"/v1/execute/sync",
|
||||
any(move |Json(plan): Json<ExecutionPlan>| {
|
||||
let execution_plans_inner = Arc::clone(&execution_plans_clone);
|
||||
async move {
|
||||
execution_plans_inner
|
||||
.lock()
|
||||
.expect("mutex should lock")
|
||||
.push(plan.clone());
|
||||
if plan.request_id == "provider-oauth:local-refresh-token" {
|
||||
Json(json!({
|
||||
"request_id": plan.request_id,
|
||||
"status_code": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": {
|
||||
"json_body": {
|
||||
"access_token": "refreshed-codex-access-token",
|
||||
"refresh_token": "refreshed-codex-refresh-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 1800,
|
||||
"scope": "openid email profile offline_access",
|
||||
"email": "alice@example.com",
|
||||
"account_id": "acct-codex-123",
|
||||
"plan_type": "plus"
|
||||
}
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
Json(json!({
|
||||
"request_id": plan.request_id,
|
||||
"status_code": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": {
|
||||
"json_body": {}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let mut provider = sample_provider("provider-codex", "codex", 10);
|
||||
provider.provider_type = "codex".to_string();
|
||||
provider.proxy = Some(json!({"node_id":"proxy-node-provider","enabled":true}));
|
||||
let endpoint = sample_endpoint(
|
||||
"endpoint-codex-cli",
|
||||
"provider-codex",
|
||||
"openai:cli",
|
||||
"https://chatgpt.com/backend-api/codex",
|
||||
);
|
||||
|
||||
let mut key = sample_key(
|
||||
"key-codex-oauth-refresh-provider",
|
||||
"provider-codex",
|
||||
"openai:cli",
|
||||
"stale-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","refresh_token":"old-codex-refresh-token","email":"alice@example.com","account_id":"acct-codex-123","plan_type":"plus","expires_at":1}"#,
|
||||
)
|
||||
.expect("auth config ciphertext should build"),
|
||||
);
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![endpoint],
|
||||
vec![key],
|
||||
));
|
||||
let mut provider_node = sample_proxy_node("proxy-node-provider");
|
||||
provider_node.status = "online".to_string();
|
||||
provider_node.is_manual = true;
|
||||
provider_node.tunnel_mode = false;
|
||||
provider_node.tunnel_connected = false;
|
||||
provider_node.proxy_url = Some("http://proxy-provider.example:8080".to_string());
|
||||
let mut system_node = sample_proxy_node("proxy-node-system");
|
||||
system_node.status = "online".to_string();
|
||||
system_node.is_manual = true;
|
||||
system_node.tunnel_mode = false;
|
||||
system_node.tunnel_connected = false;
|
||||
system_node.proxy_url = Some("http://proxy-system.example:8080".to_string());
|
||||
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![
|
||||
provider_node,
|
||||
system_node,
|
||||
]));
|
||||
|
||||
let oauth_refresh =
|
||||
crate::provider_transport::LocalOAuthRefreshCoordinator::with_adapters_for_tests(vec![
|
||||
Arc::new(
|
||||
crate::provider_transport::oauth_refresh::GenericOAuthRefreshAdapter::default()
|
||||
.with_token_url_for_tests("codex", "https://oauth.example/oauth/token"),
|
||||
),
|
||||
]);
|
||||
|
||||
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_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_repository_for_tests(
|
||||
provider_catalog_repository.clone(),
|
||||
)
|
||||
.attach_proxy_node_repository_for_tests(proxy_node_repository)
|
||||
.with_system_config_values_for_tests(vec![(
|
||||
"system_proxy_node_id".to_string(),
|
||||
json!("proxy-node-system"),
|
||||
)])
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
)
|
||||
.with_oauth_refresh_coordinator_for_tests(oauth_refresh),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{gateway_url}/api/admin/provider-oauth/keys/key-codex-oauth-refresh-provider/refresh"
|
||||
))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let plans = execution_plans.lock().expect("mutex should lock");
|
||||
let refresh_plan = plans
|
||||
.iter()
|
||||
.find(|plan| plan.request_id == "provider-oauth:local-refresh-token")
|
||||
.expect("local refresh plan should exist");
|
||||
assert_eq!(
|
||||
refresh_plan
|
||||
.proxy
|
||||
.as_ref()
|
||||
.and_then(|proxy| proxy.node_id.as_deref()),
|
||||
Some("proxy-node-provider")
|
||||
);
|
||||
|
||||
gateway_handle.abort();
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_refreshes_admin_provider_oauth_key_locally_via_execution_runtime_key_proxy_before_system_proxy(
|
||||
) {
|
||||
|
||||
@@ -6,11 +6,13 @@ use aether_data::repository::proxy_nodes::{
|
||||
InMemoryProxyNodeRepository, ProxyNodeHeartbeatMutation, StoredProxyNodeEvent,
|
||||
};
|
||||
use axum::body::Body;
|
||||
use axum::extract::ws::Message;
|
||||
use axum::routing::any;
|
||||
use axum::{extract::Request, Router};
|
||||
use base64::Engine as _;
|
||||
use http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::watch;
|
||||
|
||||
use super::super::{
|
||||
build_router_with_state, hash_management_token, sample_endpoint, sample_key,
|
||||
@@ -25,6 +27,7 @@ use crate::maintenance::{
|
||||
record_proxy_upgrade_traffic_success, skip_proxy_upgrade_rollout_node,
|
||||
start_proxy_upgrade_rollout,
|
||||
};
|
||||
use crate::tunnel::{tunnel_protocol, TunnelProxyConn};
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_proxy_nodes_locally_with_trusted_admin_principal() {
|
||||
@@ -107,6 +110,49 @@ async fn gateway_handles_admin_proxy_nodes_locally_with_trusted_admin_principal(
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_returns_full_manual_proxy_node_detail_locally_with_trusted_admin_principal() {
|
||||
let mut manual_node = sample_proxy_node("proxy-node-manual");
|
||||
manual_node.name = "alpha-manual".to_string();
|
||||
manual_node.status = "online".to_string();
|
||||
manual_node.is_manual = true;
|
||||
manual_node.tunnel_mode = false;
|
||||
manual_node.tunnel_connected = false;
|
||||
manual_node.proxy_url = Some("http://proxy.example:8080".to_string());
|
||||
manual_node.proxy_username = Some("alice".to_string());
|
||||
manual_node.proxy_password = Some("supersecret".to_string());
|
||||
|
||||
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![manual_node]));
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(GatewayDataState::with_proxy_node_repository_for_tests(
|
||||
proxy_node_repository,
|
||||
)),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"{gateway_url}/api/admin/proxy-nodes/proxy-node-manual"
|
||||
))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["node"]["id"], "proxy-node-manual");
|
||||
assert_eq!(payload["node"]["proxy_username"], "alice");
|
||||
assert_eq!(payload["node"]["proxy_password"], "supersecret");
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_reports_active_proxy_upgrade_rollout_in_proxy_node_list() {
|
||||
let mut alpha = sample_proxy_node("node-alpha");
|
||||
@@ -719,15 +765,28 @@ async fn gateway_registers_and_unregisters_proxy_nodes_locally_with_management_t
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_creates_updates_and_tests_manual_proxy_nodes_locally() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("listener should bind");
|
||||
let proxy_port = listener
|
||||
.local_addr()
|
||||
.expect("listener addr should resolve")
|
||||
.port();
|
||||
let accept_handle =
|
||||
tokio::spawn(async move { while let Ok((_stream, _addr)) = listener.accept().await {} });
|
||||
let proxy_auths = Arc::new(Mutex::new(Vec::<Option<String>>::new()));
|
||||
let proxy_auths_clone = Arc::clone(&proxy_auths);
|
||||
let proxy = Router::new().fallback(any(move |request: Request| {
|
||||
let proxy_auths_inner = Arc::clone(&proxy_auths_clone);
|
||||
async move {
|
||||
proxy_auths_inner.lock().expect("mutex should lock").push(
|
||||
request
|
||||
.headers()
|
||||
.get("proxy-authorization")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string),
|
||||
);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Body::from("fl=1234\nip=203.0.113.10\nwarp=off\n"),
|
||||
)
|
||||
}
|
||||
}));
|
||||
let (proxy_url, proxy_handle) = start_server(proxy).await;
|
||||
let _probe_url_guard = crate::handlers::admin::override_proxy_connectivity_probe_url_for_tests(
|
||||
"http://probe.example/cdn-cgi/trace",
|
||||
);
|
||||
|
||||
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::default());
|
||||
let gateway = build_router_with_state(
|
||||
@@ -739,7 +798,6 @@ async fn gateway_creates_updates_and_tests_manual_proxy_nodes_locally() {
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
let client = reqwest::Client::new();
|
||||
let proxy_url = format!("http://127.0.0.1:{proxy_port}");
|
||||
|
||||
let create_response = client
|
||||
.post(format!("{gateway_url}/api/admin/proxy-nodes/manual"))
|
||||
@@ -749,7 +807,9 @@ async fn gateway_creates_updates_and_tests_manual_proxy_nodes_locally() {
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.json(&json!({
|
||||
"name": "manual-node",
|
||||
"proxy_url": proxy_url,
|
||||
"proxy_url": proxy_url.clone(),
|
||||
"username": "alice",
|
||||
"password": "supersecret",
|
||||
"region": "US-West"
|
||||
}))
|
||||
.send()
|
||||
@@ -770,6 +830,8 @@ async fn gateway_creates_updates_and_tests_manual_proxy_nodes_locally() {
|
||||
assert_eq!(create_payload["node"]["is_manual"], true);
|
||||
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");
|
||||
|
||||
let test_url_response = client
|
||||
.post(format!("{gateway_url}/api/admin/proxy-nodes/test-url"))
|
||||
@@ -778,7 +840,9 @@ async fn gateway_creates_updates_and_tests_manual_proxy_nodes_locally() {
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.json(&json!({
|
||||
"proxy_url": proxy_url
|
||||
"proxy_url": proxy_url.clone(),
|
||||
"username": "alice",
|
||||
"password": "supersecret"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
@@ -790,6 +854,7 @@ async fn gateway_creates_updates_and_tests_manual_proxy_nodes_locally() {
|
||||
.expect("json body should parse");
|
||||
assert_eq!(test_url_payload["success"], true);
|
||||
assert!(test_url_payload["latency_ms"].is_u64());
|
||||
assert_eq!(test_url_payload["exit_ip"], "203.0.113.10");
|
||||
|
||||
let test_node_response = client
|
||||
.post(format!(
|
||||
@@ -808,6 +873,19 @@ async fn gateway_creates_updates_and_tests_manual_proxy_nodes_locally() {
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(test_node_payload["success"], true);
|
||||
assert_eq!(test_node_payload["exit_ip"], "203.0.113.10");
|
||||
|
||||
let expected_proxy_auth = format!(
|
||||
"Basic {}",
|
||||
base64::engine::general_purpose::STANDARD.encode("alice:supersecret")
|
||||
);
|
||||
assert_eq!(
|
||||
proxy_auths.lock().expect("mutex should lock").as_slice(),
|
||||
[
|
||||
Some(expected_proxy_auth.clone()),
|
||||
Some(expected_proxy_auth.clone()),
|
||||
]
|
||||
);
|
||||
|
||||
let update_response = client
|
||||
.patch(format!("{gateway_url}/api/admin/proxy-nodes/{node_id}"))
|
||||
@@ -832,7 +910,7 @@ async fn gateway_creates_updates_and_tests_manual_proxy_nodes_locally() {
|
||||
assert_eq!(update_payload["node"]["proxy_url"], proxy_url);
|
||||
|
||||
gateway_handle.abort();
|
||||
accept_handle.abort();
|
||||
proxy_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -869,6 +947,135 @@ async fn gateway_tests_disconnected_tunnel_proxy_nodes_locally() {
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_tests_connected_tunnel_proxy_nodes_with_active_probe() {
|
||||
let _probe_url_guard = crate::handlers::admin::override_proxy_connectivity_probe_url_for_tests(
|
||||
"https://probe.example/cdn-cgi/trace",
|
||||
);
|
||||
|
||||
let mut node = sample_proxy_node("node-online");
|
||||
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,
|
||||
));
|
||||
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(
|
||||
500,
|
||||
"node-online".to_string(),
|
||||
"Node Online".to_string(),
|
||||
proxy_tx,
|
||||
proxy_close_tx,
|
||||
16,
|
||||
)));
|
||||
|
||||
let gateway = build_router_with_state(state);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let request_task = tokio::spawn({
|
||||
let gateway_url = gateway_url.clone();
|
||||
async move {
|
||||
reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{gateway_url}/api/admin/proxy-nodes/node-online/test"
|
||||
))
|
||||
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
});
|
||||
|
||||
let request_headers = match proxy_rx.recv().await.expect("headers frame should arrive") {
|
||||
Message::Binary(data) => data,
|
||||
other => panic!("unexpected message: {other:?}"),
|
||||
};
|
||||
let request_header =
|
||||
tunnel_protocol::FrameHeader::parse(&request_headers).expect("request header should parse");
|
||||
assert_eq!(request_header.msg_type, tunnel_protocol::REQUEST_HEADERS);
|
||||
let meta_payload = tunnel_protocol::decode_payload(&request_headers, &request_header)
|
||||
.expect("request header payload should decode");
|
||||
let meta: tunnel_protocol::RequestMeta =
|
||||
serde_json::from_slice(&meta_payload).expect("request meta should parse");
|
||||
assert_eq!(meta.method, "GET");
|
||||
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") {
|
||||
Message::Binary(data) => data,
|
||||
other => panic!("unexpected message: {other:?}"),
|
||||
};
|
||||
let request_body_header =
|
||||
tunnel_protocol::FrameHeader::parse(&request_body).expect("request body should parse");
|
||||
assert_eq!(request_body_header.msg_type, tunnel_protocol::REQUEST_BODY);
|
||||
assert_ne!(
|
||||
request_body_header.flags & tunnel_protocol::FLAG_END_STREAM,
|
||||
0,
|
||||
"probe body frame should close the stream"
|
||||
);
|
||||
|
||||
let response_meta = tunnel_protocol::ResponseMeta {
|
||||
status: 200,
|
||||
headers: vec![("content-type".to_string(), "text/plain".to_string())],
|
||||
};
|
||||
let response_meta_bytes =
|
||||
serde_json::to_vec(&response_meta).expect("response meta should serialize");
|
||||
let mut response_headers_frame = tunnel_protocol::encode_frame(
|
||||
request_header.stream_id,
|
||||
tunnel_protocol::RESPONSE_HEADERS,
|
||||
0,
|
||||
&response_meta_bytes,
|
||||
);
|
||||
tunnel_state
|
||||
.hub
|
||||
.handle_proxy_frame(500, &mut response_headers_frame)
|
||||
.await;
|
||||
|
||||
let mut response_body_frame = tunnel_protocol::encode_frame(
|
||||
request_header.stream_id,
|
||||
tunnel_protocol::RESPONSE_BODY,
|
||||
0,
|
||||
b"fl=1234\nip=203.0.113.10\nwarp=off\n",
|
||||
);
|
||||
tunnel_state
|
||||
.hub
|
||||
.handle_proxy_frame(500, &mut response_body_frame)
|
||||
.await;
|
||||
|
||||
let mut response_end_frame = tunnel_protocol::encode_frame(
|
||||
request_header.stream_id,
|
||||
tunnel_protocol::STREAM_END,
|
||||
0,
|
||||
&[],
|
||||
);
|
||||
tunnel_state
|
||||
.hub
|
||||
.handle_proxy_frame(500, &mut response_end_frame)
|
||||
.await;
|
||||
|
||||
let response = request_task
|
||||
.await
|
||||
.expect("request task should complete")
|
||||
.expect("test-node request should succeed");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["success"], true);
|
||||
assert!(payload["latency_ms"].is_u64());
|
||||
assert_eq!(payload["exit_ip"], "203.0.113.10");
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_deletes_proxy_nodes_and_clears_proxy_refs_locally() {
|
||||
let mut manual_node = sample_proxy_node("manual-node-1");
|
||||
@@ -1164,6 +1371,7 @@ async fn gateway_updates_proxy_node_config_and_batches_upgrade_locally() {
|
||||
.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,
|
||||
@@ -1179,6 +1387,7 @@ async fn gateway_updates_proxy_node_config_and_batches_upgrade_locally() {
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(heartbeat_payload["heartbeat_id"], 77);
|
||||
assert_eq!(heartbeat_payload["config_version"], 10);
|
||||
assert!(heartbeat_payload.get("upgrade_to").is_none());
|
||||
assert_eq!(heartbeat_payload["remote_config"]["allowed_ports"][1], 8443);
|
||||
|
||||
@@ -493,7 +493,7 @@ async fn gateway_returns_503_for_admin_system_config_import_when_local_data_is_u
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_accepts_legacy_admin_system_config_import_versions() {
|
||||
async fn gateway_rejects_legacy_admin_system_config_import_versions() {
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
@@ -519,10 +519,13 @@ async fn gateway_accepts_legacy_admin_system_config_import_versions() {
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let payload: Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["message"], "配置导入成功");
|
||||
assert_eq!(payload["stats"]["errors"], json!([]));
|
||||
let detail = payload["detail"]
|
||||
.as_str()
|
||||
.expect("detail should be a string");
|
||||
assert!(detail.contains(&format!("不支持的配置版本: {version}")));
|
||||
assert!(detail.contains("支持的版本: 2.2"));
|
||||
}
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -562,7 +565,7 @@ async fn gateway_imports_admin_system_users_locally_and_persists_data() {
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.json(&json!({
|
||||
"version": "1.3",
|
||||
"version": "2.2",
|
||||
"merge_mode": "overwrite",
|
||||
"users": [{
|
||||
"email": "alice@example.com",
|
||||
@@ -761,8 +764,40 @@ async fn gateway_imports_admin_system_users_locally_and_persists_data() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_imports_admin_system_config_fixtures_from_legacy_exports() {
|
||||
for fixture in ["v20", "v21", "v22"] {
|
||||
async fn gateway_imports_admin_system_config_fixture_v22() {
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(build_empty_admin_system_data_state()),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/system/config/import"))
|
||||
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.json(&fixture_system_import_payload("v22"))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["message"], "配置导入成功");
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_admin_system_config_fixtures_from_removed_legacy_exports() {
|
||||
for fixture in ["v20", "v21"] {
|
||||
let version = match fixture {
|
||||
"v20" => "2.0",
|
||||
"v21" => "2.1",
|
||||
_ => unreachable!("unexpected fixture"),
|
||||
};
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
@@ -783,16 +818,59 @@ async fn gateway_imports_admin_system_config_fixtures_from_legacy_exports() {
|
||||
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"fixture {fixture} should import"
|
||||
StatusCode::BAD_REQUEST,
|
||||
"fixture {fixture} should be rejected"
|
||||
);
|
||||
let payload: Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["message"], "配置导入成功");
|
||||
let detail = payload["detail"]
|
||||
.as_str()
|
||||
.expect("detail should be a string");
|
||||
assert!(detail.contains(&format!("不支持的配置版本: {version}")));
|
||||
assert!(detail.contains("支持的版本: 2.2"));
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_legacy_user_import_string_bool_field() {
|
||||
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::default());
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_auth_api_key_repository_for_tests(Arc::clone(&auth_repository))
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
)
|
||||
.with_auth_users_for_tests([sample_import_admin_user("admin-user-123")])
|
||||
.with_auth_wallets_for_tests(Vec::<StoredWalletSnapshot>::new());
|
||||
let gateway = build_router_with_state(state);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/system/users/import"))
|
||||
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.json(&json!({
|
||||
"version": "2.2",
|
||||
"merge_mode": "overwrite",
|
||||
"users": [{
|
||||
"email": "legacy@example.com",
|
||||
"email_verified": "true"
|
||||
}]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let payload: Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["detail"], "字段必须是布尔值");
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_reports_field_path_for_invalid_admin_system_config_import_shape() {
|
||||
let gateway = build_router_with_state(
|
||||
@@ -835,7 +913,7 @@ async fn gateway_reports_field_path_for_invalid_admin_system_config_import_shape
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_imports_admin_system_config_with_numeric_string_prices() {
|
||||
async fn gateway_rejects_admin_system_config_with_numeric_string_prices() {
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
@@ -860,12 +938,11 @@ async fn gateway_imports_admin_system_config_with_numeric_string_prices() {
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body: Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(body["message"], "配置导入成功");
|
||||
assert_eq!(body["stats"]["global_models"]["created"], json!(1));
|
||||
assert_eq!(body["stats"]["providers"]["created"], json!(1));
|
||||
assert_eq!(body["stats"]["models"]["created"], json!(1));
|
||||
let detail = body["detail"].as_str().expect("detail should be a string");
|
||||
assert!(detail.contains("配置文件格式无效"));
|
||||
assert!(detail.contains("default_price_per_request"));
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
@@ -76,6 +76,37 @@ async fn gateway_handles_internal_tunnel_heartbeat_locally_with_loopback() {
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_internal_tunnel_heartbeat_without_heartbeat_id() {
|
||||
let repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![sample_proxy_node(
|
||||
"node-123",
|
||||
)]));
|
||||
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(GatewayDataState::with_proxy_node_repository_for_tests(
|
||||
Arc::clone(&repository),
|
||||
)),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/internal/tunnel/heartbeat"))
|
||||
.json(&json!({
|
||||
"node_id": "node-123",
|
||||
"heartbeat_interval": 45,
|
||||
"active_connections": 5
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_internal_tunnel_node_status_locally_with_loopback() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
|
||||
Reference in New Issue
Block a user