mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
4906 lines
187 KiB
Rust
4906 lines
187 KiB
Rust
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_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
|
use aether_data::repository::proxy_nodes::InMemoryProxyNodeRepository;
|
|
use aether_data_contracts::repository::provider_catalog::ProviderCatalogReadRepository;
|
|
use aether_runtime_state::{RedisClientConfig, RuntimeState};
|
|
use aether_testkit::ManagedRedisServer;
|
|
use axum::body::to_bytes;
|
|
use axum::body::Body;
|
|
use axum::routing::{any, get, post};
|
|
use axum::{extract::Request, Json, Router};
|
|
use base64::Engine as _;
|
|
use flate2::{write::GzEncoder, Compression};
|
|
use http::StatusCode;
|
|
use serde_json::json;
|
|
use std::io::Write;
|
|
|
|
use super::super::{
|
|
build_router_with_state, build_state_with_execution_runtime_override,
|
|
issue_test_admin_access_token, sample_endpoint, sample_provider, sample_proxy_node,
|
|
start_server, wait_until, AppState,
|
|
};
|
|
use crate::constants::{
|
|
GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
|
|
TRUSTED_ADMIN_USER_ROLE_HEADER,
|
|
};
|
|
use crate::data::{GatewayDataConfig, GatewayDataState};
|
|
|
|
async fn start_managed_redis_or_skip() -> Option<ManagedRedisServer> {
|
|
match ManagedRedisServer::start().await {
|
|
Ok(server) => Some(server),
|
|
Err(err) if err.to_string().contains("No such file or directory") => {
|
|
eprintln!("skipping redis-backed provider ops test: {err}");
|
|
None
|
|
}
|
|
Err(err) => panic!("redis server should start: {err}"),
|
|
}
|
|
}
|
|
|
|
async fn redis_runtime_state_for_test(
|
|
redis: &ManagedRedisServer,
|
|
key_prefix: &str,
|
|
) -> Arc<RuntimeState> {
|
|
Arc::new(
|
|
RuntimeState::redis(
|
|
RedisClientConfig {
|
|
url: redis.redis_url().to_string(),
|
|
key_prefix: Some(key_prefix.to_string()),
|
|
},
|
|
None,
|
|
)
|
|
.await
|
|
.expect("redis runtime state should build"),
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_ops_architectures_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-ops/architectures",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-ops/architectures"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
let items = payload.as_array().expect("items should be array");
|
|
assert_eq!(items.len(), 6);
|
|
assert!(items
|
|
.iter()
|
|
.all(|item| item["architecture_id"] != "generic_api"));
|
|
assert_eq!(items[0]["architecture_id"], "anyrouter");
|
|
assert_eq!(items[0]["default_connector"], "cookie");
|
|
assert_eq!(items[3]["architecture_id"], "new_api");
|
|
assert_eq!(items[3]["supported_auth_types"][0]["type"], "api_key");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_ops_architectures_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/provider-ops/architectures",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let state = AppState::new().expect("gateway should build");
|
|
let access_token = issue_test_admin_access_token(&state, "device-admin-provider-ops").await;
|
|
let gateway = build_router_with_state(state);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-ops/architectures"
|
|
))
|
|
.header("authorization", format!("Bearer {access_token}"))
|
|
.header("x-client-device-id", "device-admin-provider-ops")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
let items = payload.as_array().expect("items should be array");
|
|
assert_eq!(items.len(), 6);
|
|
assert!(items
|
|
.iter()
|
|
.all(|item| item["architecture_id"] != "generic_api"));
|
|
assert_eq!(items[0]["architecture_id"], "anyrouter");
|
|
assert_eq!(items[0]["default_connector"], "cookie");
|
|
assert_eq!(items[3]["architecture_id"], "new_api");
|
|
assert_eq!(items[3]["supported_auth_types"][0]["type"], "api_key");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_ops_architecture_detail_locally_with_trusted_admin_principal(
|
|
) {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-ops/architectures/generic_api",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-ops/architectures/generic_api"
|
|
))
|
|
.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["architecture_id"], "generic_api");
|
|
assert_eq!(payload["display_name"], "通用 API");
|
|
assert_eq!(payload["default_connector"], "api_key");
|
|
assert_eq!(payload["supported_actions"][0]["type"], "query_balance");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_ops_status_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-ops/providers/provider-openai/status",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "anyrouter",
|
|
"connector": {
|
|
"auth_type": "cookie",
|
|
"config": {"cookie_name": "session"},
|
|
},
|
|
"actions": {
|
|
"query_balance": {"enabled": true},
|
|
"checkin": {"enabled": false},
|
|
"get_models": {},
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository,
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/status"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["provider_id"], "provider-openai");
|
|
assert_eq!(payload["is_configured"], true);
|
|
assert_eq!(payload["architecture_id"], "anyrouter");
|
|
assert_eq!(payload["connection_status"]["status"], "disconnected");
|
|
assert_eq!(payload["connection_status"]["auth_type"], "cookie");
|
|
assert_eq!(
|
|
payload["enabled_actions"],
|
|
json!(["get_models", "query_balance"])
|
|
);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_ops_config_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-ops/providers/provider-openai/config",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "cubence",
|
|
"connector": {
|
|
"auth_type": "session_login",
|
|
"config": {"username": "alice"},
|
|
"credentials": {
|
|
"refresh_token": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"refresh-secret-1234",
|
|
).expect("refresh token should encrypt"),
|
|
"password": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"top-secret-password",
|
|
).expect("password should encrypt"),
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![sample_endpoint(
|
|
"endpoint-openai-chat",
|
|
"provider-openai",
|
|
"openai:chat",
|
|
"https://api.openai.example",
|
|
)],
|
|
vec![],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository,
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/config"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["provider_id"], "provider-openai");
|
|
assert_eq!(payload["is_configured"], true);
|
|
assert_eq!(payload["architecture_id"], "cubence");
|
|
assert_eq!(payload["base_url"], "https://api.openai.example");
|
|
assert_eq!(payload["connector"]["auth_type"], "session_login");
|
|
assert_eq!(payload["connector"]["config"]["username"], "alice");
|
|
assert_eq!(
|
|
payload["connector"]["credentials"]["refresh_token"],
|
|
"refr****1234"
|
|
);
|
|
assert_eq!(payload["connector"]["credentials"]["password"], "********");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_saves_admin_provider_ops_config_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-ops/providers/provider-openai/config",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"feature_flag": true,
|
|
"provider_ops": {
|
|
"architecture_id": "cubence",
|
|
"connector": {
|
|
"auth_type": "session_login",
|
|
"config": {"username": "alice"},
|
|
"credentials": {
|
|
"refresh_token": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"refresh-secret-1234",
|
|
).expect("refresh token should encrypt"),
|
|
"_cached_access_token": "cached-token",
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(Arc::clone(
|
|
&provider_catalog_repository,
|
|
)),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.put(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/config"
|
|
))
|
|
.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!({
|
|
"architecture_id": "anyrouter",
|
|
"base_url": "https://ops.example",
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {
|
|
"tenant": "acme"
|
|
},
|
|
"credentials": {
|
|
"refresh_token": "************",
|
|
"api_key": "live-secret-api-key",
|
|
}
|
|
},
|
|
"actions": {
|
|
"query_balance": {
|
|
"enabled": true,
|
|
"config": {
|
|
"currency": "USD"
|
|
}
|
|
}
|
|
},
|
|
"schedule": {
|
|
"query_balance": "0 0 * * *"
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["success"], true, "payload={payload}");
|
|
assert_eq!(payload["message"], "配置保存成功");
|
|
|
|
let stored_provider = provider_catalog_repository
|
|
.list_providers_by_ids(&["provider-openai".to_string()])
|
|
.await
|
|
.expect("providers should list")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored provider should exist");
|
|
let provider_config = stored_provider
|
|
.config
|
|
.as_ref()
|
|
.and_then(serde_json::Value::as_object)
|
|
.expect("provider config should be object");
|
|
assert_eq!(provider_config.get("feature_flag"), Some(&json!(true)));
|
|
let provider_ops = provider_config
|
|
.get("provider_ops")
|
|
.and_then(serde_json::Value::as_object)
|
|
.expect("provider ops config should exist");
|
|
assert_eq!(
|
|
provider_ops.get("architecture_id"),
|
|
Some(&json!("anyrouter"))
|
|
);
|
|
assert_eq!(
|
|
provider_ops.get("base_url"),
|
|
Some(&json!("https://ops.example"))
|
|
);
|
|
let connector = provider_ops
|
|
.get("connector")
|
|
.and_then(serde_json::Value::as_object)
|
|
.expect("connector should be object");
|
|
assert_eq!(connector.get("auth_type"), Some(&json!("api_key")));
|
|
assert_eq!(connector.get("config"), Some(&json!({"tenant": "acme"})));
|
|
let credentials = connector
|
|
.get("credentials")
|
|
.and_then(serde_json::Value::as_object)
|
|
.expect("credentials should be object");
|
|
assert!(!credentials.contains_key("_cached_access_token"));
|
|
let stored_refresh = credentials
|
|
.get("refresh_token")
|
|
.and_then(serde_json::Value::as_str)
|
|
.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"
|
|
);
|
|
let stored_api_key = credentials
|
|
.get("api_key")
|
|
.and_then(serde_json::Value::as_str)
|
|
.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"),
|
|
"live-secret-api-key"
|
|
);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_deletes_admin_provider_ops_config_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-ops/providers/provider-openai/config",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"feature_flag": true,
|
|
"provider_ops": {
|
|
"architecture_id": "anyrouter",
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"credentials": {
|
|
"api_key": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"live-secret-api-key",
|
|
).expect("api key should encrypt"),
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(Arc::clone(
|
|
&provider_catalog_repository,
|
|
)),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.delete(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/config"
|
|
))
|
|
.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["success"], true);
|
|
assert_eq!(payload["message"], "配置已删除");
|
|
|
|
let stored_provider = provider_catalog_repository
|
|
.list_providers_by_ids(&["provider-openai".to_string()])
|
|
.await
|
|
.expect("providers should list")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored provider should exist");
|
|
let provider_config = stored_provider
|
|
.config
|
|
.as_ref()
|
|
.and_then(serde_json::Value::as_object)
|
|
.expect("provider config should be object");
|
|
assert_eq!(provider_config.get("feature_flag"), Some(&json!(true)));
|
|
assert!(!provider_config.contains_key("provider_ops"));
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_disconnects_admin_provider_ops_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-ops/providers/provider-openai/disconnect",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/disconnect"
|
|
))
|
|
.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["success"], true);
|
|
assert_eq!(payload["message"], "已断开连接");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_verifies_admin_provider_ops_locally_for_generic_api_with_trusted_admin_principal()
|
|
{
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-ops/providers/provider-openai/verify",
|
|
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,
|
|
Json(json!({ "success": false, "message": "unexpected upstream hit" })),
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
|
|
let verify_target = Router::new().route(
|
|
"/api/user/self",
|
|
any(|headers: axum::http::HeaderMap| async move {
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Bearer live-secret-api-key")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"username": "alice",
|
|
"display_name": "Alice",
|
|
"quota": 42.5
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_provider("provider-openai", "openai", 10)],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (verify_url, verify_handle) = start_server(verify_target).await;
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository,
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/verify"
|
|
))
|
|
.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!({
|
|
"architecture_id": "generic_api",
|
|
"base_url": verify_url,
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {
|
|
"auth_method": "bearer",
|
|
},
|
|
"credentials": {
|
|
"api_key": "live-secret-api-key",
|
|
}
|
|
},
|
|
"actions": {},
|
|
"schedule": {},
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["success"], true);
|
|
assert_eq!(payload["data"]["username"], "alice");
|
|
assert_eq!(payload["data"]["display_name"], "Alice");
|
|
assert_eq!(payload["data"]["quota"], 42.5);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
verify_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_verifies_admin_provider_ops_locally_for_anyrouter_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-ops/providers/provider-openai/verify",
|
|
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,
|
|
Json(json!({
|
|
"success": false,
|
|
"message": "fallback probe",
|
|
})),
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
|
|
let verify_target = Router::new()
|
|
.route(
|
|
"/",
|
|
get(|| async move {
|
|
(
|
|
StatusCode::OK,
|
|
[(
|
|
axum::http::header::SET_COOKIE,
|
|
axum::http::HeaderValue::from_static(
|
|
"acw_tc=test-acw-tc; Path=/; HttpOnly, cdn_sec_tc=test-cdn-sec; Path=/; HttpOnly",
|
|
),
|
|
)],
|
|
Body::from(
|
|
"<html><script>var arg1 = '0123456789abcdef0123456789abcdef01234567';</script></html>",
|
|
),
|
|
)
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/user/self",
|
|
get(|headers: axum::http::HeaderMap| async move {
|
|
let cookie = headers
|
|
.get(axum::http::header::COOKIE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.expect("cookie header should exist");
|
|
assert!(!cookie.contains("acw_tc="));
|
|
assert!(!cookie.contains("cdn_sec_tc="));
|
|
assert!(cookie.contains("acw_sc__v2="));
|
|
assert!(cookie.contains(
|
|
"session=MTIzfGVIaDRlQUpwWkFOcGJuU3F1d0RfVkhsNWVYa0lkWE5sY201aGJXVUdjM1J5YVc1bkRCQUFCV0ZzYVdObHxzaWc"
|
|
));
|
|
assert_eq!(
|
|
headers
|
|
.get("New-Api-User")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("42")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"id": 42,
|
|
"username": "alice",
|
|
"display_name": "Alice",
|
|
"quota": 7.5,
|
|
"used_quota": 1.25,
|
|
"request_count": 8
|
|
})),
|
|
)
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_provider("provider-openai", "openai", 10)],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (verify_url, verify_handle) = start_server(verify_target).await;
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository,
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/verify"
|
|
))
|
|
.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!({
|
|
"architecture_id": "anyrouter",
|
|
"base_url": verify_url,
|
|
"connector": {
|
|
"auth_type": "cookie",
|
|
"config": {},
|
|
"credentials": {
|
|
"session_cookie": "session=MTIzfGVIaDRlQUpwWkFOcGJuU3F1d0RfVkhsNWVYa0lkWE5sY201aGJXVUdjM1J5YVc1bkRCQUFCV0ZzYVdObHxzaWc",
|
|
}
|
|
},
|
|
"actions": {},
|
|
"schedule": {},
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["success"], true);
|
|
assert_eq!(payload["data"]["username"], "alice");
|
|
assert_eq!(payload["data"]["display_name"], "Alice");
|
|
assert_eq!(payload["data"]["quota"], 7.5);
|
|
assert_eq!(payload["data"]["used_quota"], 1.25);
|
|
assert_eq!(payload["data"]["request_count"], 8);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
verify_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_verifies_admin_provider_ops_locally_for_anyrouter_with_cookie_auth_failure_message(
|
|
) {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-ops/providers/provider-openai/verify",
|
|
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,
|
|
Json(json!({
|
|
"success": false,
|
|
"message": "fallback probe",
|
|
})),
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
|
|
let verify_target = Router::new()
|
|
.route(
|
|
"/",
|
|
get(|| async move {
|
|
(
|
|
StatusCode::OK,
|
|
Body::from(
|
|
"<html><script>var arg1 = '0123456789abcdef0123456789abcdef01234567';</script></html>",
|
|
),
|
|
)
|
|
}),
|
|
)
|
|
.route("/api/user/self", get(|| async move { StatusCode::UNAUTHORIZED }));
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_provider("provider-openai", "openai", 10)],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (verify_url, verify_handle) = start_server(verify_target).await;
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository,
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/verify"
|
|
))
|
|
.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!({
|
|
"architecture_id": "anyrouter",
|
|
"base_url": verify_url,
|
|
"connector": {
|
|
"auth_type": "cookie",
|
|
"config": {},
|
|
"credentials": {
|
|
"session_cookie": "session=MTIzfGVIaDRlQUpwWkFOcGJuU3F1d0RfVkhsNWVYa0lkWE5sY201aGJXVUdjM1J5YVc1bkRCQUFCV0ZzYVdObHxzaWc",
|
|
}
|
|
},
|
|
"actions": {},
|
|
"schedule": {},
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["success"], false);
|
|
assert_eq!(payload["message"], "Cookie 已失效,请重新配置");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
verify_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_verifies_admin_provider_ops_locally_for_anyrouter_when_acw_redirect_body_contains_arg1(
|
|
) {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-ops/providers/provider-openai/verify",
|
|
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,
|
|
Json(json!({
|
|
"success": false,
|
|
"message": "fallback probe",
|
|
})),
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
|
|
let verify_target = Router::new()
|
|
.route(
|
|
"/",
|
|
get(|| async move {
|
|
(
|
|
StatusCode::FOUND,
|
|
[(
|
|
axum::http::header::LOCATION,
|
|
axum::http::HeaderValue::from_static("/landing"),
|
|
),
|
|
(
|
|
axum::http::header::SET_COOKIE,
|
|
axum::http::HeaderValue::from_static(
|
|
"acw_tc=redirect-acw-tc; Path=/; HttpOnly, cdn_sec_tc=redirect-cdn-sec; Path=/; HttpOnly",
|
|
),
|
|
)],
|
|
Body::from(
|
|
"<html><script>var arg1 = '0123456789abcdef0123456789abcdef01234567';</script></html>",
|
|
),
|
|
)
|
|
}),
|
|
)
|
|
.route("/landing", get(|| async move { (StatusCode::OK, Body::from("landing")) }))
|
|
.route(
|
|
"/api/user/self",
|
|
get(|headers: axum::http::HeaderMap| async move {
|
|
let cookie = headers
|
|
.get(axum::http::header::COOKIE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.expect("cookie header should exist");
|
|
assert!(!cookie.contains("acw_tc="));
|
|
assert!(!cookie.contains("cdn_sec_tc="));
|
|
assert!(cookie.contains("acw_sc__v2="));
|
|
assert!(cookie.contains(
|
|
"session=MTIzfGVIaDRlQUpwWkFOcGJuU3F1d0RfVkhsNWVYa0lkWE5sY201aGJXVUdjM1J5YVc1bkRCQUFCV0ZzYVdObHxzaWc"
|
|
));
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"id": 42,
|
|
"username": "alice",
|
|
"display_name": "Alice",
|
|
"quota": 7.5
|
|
})),
|
|
)
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_provider("provider-openai", "openai", 10)],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (verify_url, verify_handle) = start_server(verify_target).await;
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository,
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/verify"
|
|
))
|
|
.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!({
|
|
"architecture_id": "anyrouter",
|
|
"base_url": verify_url,
|
|
"connector": {
|
|
"auth_type": "cookie",
|
|
"config": {},
|
|
"credentials": {
|
|
"session_cookie": "session=MTIzfGVIaDRlQUpwWkFOcGJuU3F1d0RfVkhsNWVYa0lkWE5sY201aGJXVUdjM1J5YVc1bkRCQUFCV0ZzYVdObHxzaWc",
|
|
}
|
|
},
|
|
"actions": {},
|
|
"schedule": {},
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["success"], true);
|
|
assert_eq!(payload["data"]["username"], "alice");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
verify_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_verifies_admin_provider_ops_locally_for_anyrouter_proxy_mode() {
|
|
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");
|
|
let proxy_url = proxy
|
|
.url
|
|
.as_deref()
|
|
.expect("manual proxy url should exist");
|
|
let parsed_proxy = url::Url::parse(proxy_url).expect("proxy url should parse");
|
|
assert_eq!(parsed_proxy.scheme(), "http");
|
|
assert_eq!(parsed_proxy.host_str(), Some("proxy.example"));
|
|
assert_eq!(parsed_proxy.port_or_known_default(), Some(8080));
|
|
assert_eq!(parsed_proxy.username(), "alice");
|
|
assert_eq!(parsed_proxy.password(), Some("supersecret"));
|
|
|
|
if plan.url.ends_with("/api/user/self") {
|
|
assert_eq!(
|
|
plan.headers
|
|
.get(EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER)
|
|
.map(String::as_str),
|
|
None
|
|
);
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"id": 42,
|
|
"username": "alice",
|
|
"display_name": "Alice",
|
|
"quota": 7.5,
|
|
"used_quota": 1.25,
|
|
"request_count": 8
|
|
}
|
|
}
|
|
}))
|
|
} else {
|
|
assert_eq!(plan.request_id, "provider-ops-acw:anyrouter");
|
|
assert_eq!(plan.url, "https://ops.example");
|
|
assert_eq!(
|
|
plan.headers
|
|
.get(EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER)
|
|
.map(String::as_str),
|
|
Some("false")
|
|
);
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "text/html"
|
|
},
|
|
"body": {
|
|
"body_bytes_b64": base64::engine::general_purpose::STANDARD.encode(
|
|
"<html><script>var arg1 = '0123456789abcdef0123456789abcdef01234567';</script></html>"
|
|
)
|
|
}
|
|
}))
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_provider("provider-openai", "openai", 10)],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
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 (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,
|
|
)
|
|
.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-manual"),
|
|
)]),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/verify"
|
|
))
|
|
.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!({
|
|
"architecture_id": "anyrouter",
|
|
"base_url": "https://ops.example",
|
|
"connector": {
|
|
"auth_type": "cookie",
|
|
"config": {},
|
|
"credentials": {
|
|
"session_cookie": "session=MTIzfGVIaDRlQUpwWkFOcGJuU3F1d0RfVkhsNWVYa0lkWE5sY201aGJXVUdjM1J5YVc1bkRCQUFCV0ZzYVdObHxzaWc",
|
|
}
|
|
},
|
|
"actions": {},
|
|
"schedule": {},
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["success"], true);
|
|
assert_eq!(payload["data"]["username"], "alice");
|
|
assert_eq!(payload["data"]["used_quota"], 1.25);
|
|
assert_eq!(payload["data"]["request_count"], 8);
|
|
|
|
let plans = execution_plans.lock().expect("mutex should lock");
|
|
assert_eq!(plans.len(), 2);
|
|
assert_eq!(plans[0].request_id, "provider-ops-acw:anyrouter");
|
|
assert_eq!(plans[0].url, "https://ops.example");
|
|
assert_eq!(plans[1].request_id, "provider-ops-verify:anyrouter");
|
|
assert_eq!(plans[1].url, "https://ops.example/api/user/self");
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_verifies_admin_provider_ops_sub2api_proxy_mode_via_execution_runtime_http1_only() {
|
|
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-sub2api"));
|
|
assert_eq!(
|
|
plan.headers
|
|
.get(EXECUTION_REQUEST_HTTP1_ONLY_HEADER)
|
|
.map(String::as_str),
|
|
Some("true")
|
|
);
|
|
|
|
if plan.url.ends_with("/api/v1/auth/refresh") {
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"code": 0,
|
|
"data": {
|
|
"access_token": "sub2api-access-token",
|
|
"refresh_token": "sub2api-refresh-token-new"
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
} else {
|
|
assert_eq!(
|
|
plan.headers.get("authorization").map(String::as_str),
|
|
Some("Bearer sub2api-access-token")
|
|
);
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"code": 0,
|
|
"data": {
|
|
"username": "sub2api-user",
|
|
"email": "[email protected]",
|
|
"balance": 8.5,
|
|
"points": 1.5,
|
|
"status": "active",
|
|
"concurrency": 3
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_provider("provider-openai", "openai", 10)],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
let mut manual_node = sample_proxy_node("proxy-node-sub2api");
|
|
manual_node.name = "sub2api-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());
|
|
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![manual_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,
|
|
)
|
|
.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-sub2api"),
|
|
)]),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/verify"
|
|
))
|
|
.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!({
|
|
"architecture_id": "sub2api",
|
|
"base_url": "https://sub2api.example",
|
|
"connector": {
|
|
"auth_type": "session_login",
|
|
"config": {},
|
|
"credentials": {
|
|
"refresh_token": "refresh-token-old",
|
|
}
|
|
},
|
|
"actions": {},
|
|
"schedule": {},
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["success"], true);
|
|
assert_eq!(payload["data"]["username"], "sub2api-user");
|
|
assert_eq!(
|
|
payload["updated_credentials"]["refresh_token"],
|
|
"sub2api-refresh-token-new"
|
|
);
|
|
|
|
let plans = execution_plans.lock().expect("mutex should lock");
|
|
assert_eq!(plans.len(), 2);
|
|
assert_eq!(plans[0].url, "https://sub2api.example/api/v1/auth/refresh");
|
|
assert!(
|
|
plans[1]
|
|
.url
|
|
.starts_with("https://sub2api.example/api/v1/auth/me"),
|
|
"url={}",
|
|
plans[1].url
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_verifies_admin_provider_ops_locally_for_new_api_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-ops/providers/provider-openai/verify",
|
|
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,
|
|
Json(json!({ "success": false, "message": "unexpected upstream hit" })),
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
|
|
let verify_target = Router::new().route(
|
|
"/api/user/self",
|
|
any(|headers: axum::http::HeaderMap| async move {
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Bearer live-secret-api-key")
|
|
);
|
|
assert_eq!(
|
|
headers
|
|
.get("New-Api-User")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("42")
|
|
);
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::COOKIE)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("session=foo")
|
|
);
|
|
assert!(headers.contains_key("sec-ch-ua"));
|
|
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
|
encoder
|
|
.write_all(
|
|
json!({
|
|
"success": true,
|
|
"data": {
|
|
"username": "alice",
|
|
"display_name": "Alice",
|
|
"quota": 42.5,
|
|
"used_quota": 12.5,
|
|
"request_count": 9,
|
|
"email": "",
|
|
"group": "default"
|
|
}
|
|
})
|
|
.to_string()
|
|
.as_bytes(),
|
|
)
|
|
.expect("gzip body should write");
|
|
let compressed = encoder.finish().expect("gzip body should finish");
|
|
(
|
|
StatusCode::OK,
|
|
[
|
|
(
|
|
axum::http::header::CONTENT_TYPE,
|
|
axum::http::HeaderValue::from_static("application/json"),
|
|
),
|
|
(
|
|
axum::http::header::CONTENT_ENCODING,
|
|
axum::http::HeaderValue::from_static("gzip"),
|
|
),
|
|
],
|
|
Body::from(compressed),
|
|
)
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_provider("provider-openai", "openai", 10)],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (verify_url, verify_handle) = start_server(verify_target).await;
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository,
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/verify"
|
|
))
|
|
.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!({
|
|
"architecture_id": "new_api",
|
|
"base_url": verify_url,
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {},
|
|
"credentials": {
|
|
"api_key": "live-secret-api-key",
|
|
"user_id": "42",
|
|
"cookie": "session=foo"
|
|
}
|
|
},
|
|
"actions": {},
|
|
"schedule": {},
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["success"], true);
|
|
assert_eq!(payload["message"], serde_json::Value::Null);
|
|
assert_eq!(payload["data"]["username"], "alice");
|
|
assert_eq!(payload["data"]["quota"], 42.5);
|
|
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");
|
|
assert_eq!(payload["updated_credentials"], serde_json::Value::Null);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
verify_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_verifies_admin_provider_ops_locally_for_new_api_proxy_node_mode() {
|
|
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());
|
|
assert_eq!(plan.request_id, "provider-ops-verify:new_api");
|
|
assert_eq!(plan.url, "https://ops.example/api/user/self");
|
|
assert_eq!(
|
|
plan.headers.get("authorization").map(String::as_str),
|
|
Some("Bearer live-secret-api-key")
|
|
);
|
|
assert_eq!(
|
|
plan.headers.get("new-api-user").map(String::as_str),
|
|
Some("42")
|
|
);
|
|
assert!(!plan.headers.contains_key("accept-encoding"));
|
|
let proxy = plan.proxy.as_ref().expect("proxy snapshot should exist");
|
|
assert_eq!(proxy.node_id.as_deref(), Some("proxy-node-manual"));
|
|
let proxy_url = proxy.url.as_deref().expect("manual proxy url should exist");
|
|
let parsed_proxy = url::Url::parse(proxy_url).expect("proxy url should parse");
|
|
assert_eq!(parsed_proxy.host_str(), Some("proxy.example"));
|
|
assert_eq!(parsed_proxy.username(), "alice");
|
|
assert_eq!(parsed_proxy.password(), Some("supersecret"));
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"success": true,
|
|
"data": {
|
|
"username": "alice",
|
|
"display_name": "Alice",
|
|
"quota": 42.5,
|
|
"used_quota": 12.5,
|
|
"request_count": 9
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_provider("provider-openai", "openai", 10)],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
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 (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,
|
|
)
|
|
.attach_proxy_node_repository_for_tests(proxy_node_repository),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/verify"
|
|
))
|
|
.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!({
|
|
"architecture_id": "new_api",
|
|
"base_url": "https://ops.example",
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {
|
|
"proxy_node_id": "proxy-node-manual"
|
|
},
|
|
"credentials": {
|
|
"api_key": "live-secret-api-key",
|
|
"user_id": "42",
|
|
"cookie": "session=foo"
|
|
}
|
|
},
|
|
"actions": {},
|
|
"schedule": {},
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["success"], true);
|
|
assert_eq!(payload["message"], serde_json::Value::Null);
|
|
assert_eq!(payload["data"]["used_quota"], 12.5);
|
|
assert_eq!(payload["data"]["request_count"], 9);
|
|
assert_eq!(payload["data"]["extra"], json!({}));
|
|
assert_eq!(payload["updated_credentials"], serde_json::Value::Null);
|
|
assert_eq!(execution_plans.lock().expect("mutex should lock").len(), 1);
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_verifies_admin_provider_ops_locally_for_new_api_without_proxy_via_execution_runtime(
|
|
) {
|
|
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());
|
|
assert_eq!(plan.request_id, "provider-ops-verify:new_api");
|
|
assert_eq!(plan.url, "https://ops.example/api/user/self");
|
|
assert_eq!(
|
|
plan.headers.get("authorization").map(String::as_str),
|
|
Some("Bearer live-secret-api-key")
|
|
);
|
|
assert_eq!(
|
|
plan.headers.get("new-api-user").map(String::as_str),
|
|
Some("42")
|
|
);
|
|
assert!(plan.proxy.is_none());
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"success": true,
|
|
"data": {
|
|
"username": "alice",
|
|
"display_name": "Alice",
|
|
"quota": 42.5,
|
|
"used_quota": 12.5,
|
|
"request_count": 9
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_provider("provider-openai", "openai", 10)],
|
|
vec![],
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/verify"
|
|
))
|
|
.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!({
|
|
"architecture_id": "new_api",
|
|
"base_url": "https://ops.example",
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {},
|
|
"credentials": {
|
|
"api_key": "live-secret-api-key",
|
|
"user_id": "42",
|
|
"cookie": "session=foo"
|
|
}
|
|
},
|
|
"actions": {},
|
|
"schedule": {},
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["success"], true);
|
|
assert_eq!(payload["data"]["username"], "alice");
|
|
assert_eq!(payload["data"]["used_quota"], 12.5);
|
|
assert_eq!(payload["data"]["request_count"], 9);
|
|
assert_eq!(execution_plans.lock().expect("mutex should lock").len(), 1);
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_verifies_admin_provider_ops_locally_for_sub2api_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-ops/providers/provider-openai/verify",
|
|
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,
|
|
Json(json!({ "success": false, "message": "unexpected upstream hit" })),
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
|
|
let verify_target = Router::new()
|
|
.route(
|
|
"/api/v1/auth/refresh",
|
|
any(|request: Request| async move {
|
|
let body = to_bytes(request.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read");
|
|
let payload: serde_json::Value =
|
|
serde_json::from_slice(&body).expect("json body should parse");
|
|
assert_eq!(payload["refresh_token"], "refresh-token-old");
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 0,
|
|
"data": {
|
|
"access_token": "access-token-new",
|
|
"refresh_token": "refresh-token-new",
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/v1/auth/me",
|
|
get(|headers: axum::http::HeaderMap| async move {
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Bearer access-token-new")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 0,
|
|
"data": {
|
|
"username": "sub2api-user",
|
|
"email": "[email protected]",
|
|
"balance": 8.5,
|
|
"points": 1.5,
|
|
"status": "active",
|
|
"concurrency": 3,
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_provider("provider-openai", "openai", 10)],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (verify_url, verify_handle) = start_server(verify_target).await;
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository,
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/verify"
|
|
))
|
|
.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!({
|
|
"architecture_id": "sub2api",
|
|
"base_url": verify_url,
|
|
"connector": {
|
|
"auth_type": "session_login",
|
|
"config": {},
|
|
"credentials": {
|
|
"refresh_token": "refresh-token-old",
|
|
}
|
|
},
|
|
"actions": {},
|
|
"schedule": {},
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["success"], true);
|
|
assert_eq!(payload["data"]["username"], "sub2api-user");
|
|
assert_eq!(payload["data"]["display_name"], "sub2api-user");
|
|
assert_eq!(payload["data"]["email"], "[email protected]");
|
|
assert_eq!(payload["data"]["quota"], 10.0);
|
|
assert_eq!(payload["data"]["extra"]["balance"], 8.5);
|
|
assert_eq!(payload["data"]["extra"]["points"], 1.5);
|
|
assert_eq!(
|
|
payload["updated_credentials"]["refresh_token"],
|
|
"refresh-token-new"
|
|
);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
verify_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_verifies_admin_provider_ops_sub2api_against_site_root_when_base_url_has_path() {
|
|
let nested_refresh_hits = Arc::new(Mutex::new(0usize));
|
|
let nested_refresh_hits_clone = Arc::clone(&nested_refresh_hits);
|
|
let root_refresh_hits = Arc::new(Mutex::new(0usize));
|
|
let root_refresh_hits_clone = Arc::clone(&root_refresh_hits);
|
|
let verify_target = Router::new()
|
|
.route(
|
|
"/nested/api/v1/auth/refresh",
|
|
post(move |_request: Request| {
|
|
let nested_refresh_hits_inner = Arc::clone(&nested_refresh_hits_clone);
|
|
async move {
|
|
*nested_refresh_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 1,
|
|
"message": "invalid refresh token",
|
|
})),
|
|
)
|
|
}
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/v1/auth/refresh",
|
|
post(move |request: Request| {
|
|
let root_refresh_hits_inner = Arc::clone(&root_refresh_hits_clone);
|
|
async move {
|
|
*root_refresh_hits_inner.lock().expect("mutex should lock") += 1;
|
|
let body = to_bytes(request.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read");
|
|
let payload: serde_json::Value =
|
|
serde_json::from_slice(&body).expect("json body should parse");
|
|
assert_eq!(payload["refresh_token"], "refresh-token-old");
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 0,
|
|
"data": {
|
|
"access_token": "access-token-new",
|
|
"refresh_token": "refresh-token-new",
|
|
}
|
|
})),
|
|
)
|
|
}
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/v1/auth/me",
|
|
get(|headers: axum::http::HeaderMap| async move {
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Bearer access-token-new")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 0,
|
|
"data": {
|
|
"username": "sub2api-user",
|
|
"email": "[email protected]",
|
|
"balance": 8.5,
|
|
"points": 1.5,
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_provider("provider-openai", "openai", 10)],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (verify_url, verify_handle) = start_server(verify_target).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository,
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/verify"
|
|
))
|
|
.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!({
|
|
"architecture_id": "sub2api",
|
|
"base_url": format!("{verify_url}/nested"),
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {},
|
|
"credentials": {
|
|
"refresh_token": "refresh-token-old",
|
|
}
|
|
},
|
|
"actions": {},
|
|
"schedule": {},
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["success"], true);
|
|
assert_eq!(payload["data"]["username"], "sub2api-user");
|
|
assert_eq!(
|
|
payload["updated_credentials"]["refresh_token"],
|
|
"refresh-token-new"
|
|
);
|
|
assert_eq!(*root_refresh_hits.lock().expect("mutex should lock"), 1);
|
|
assert_eq!(*nested_refresh_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
verify_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_verifies_admin_provider_ops_sub2api_with_cached_access_token_without_refresh() {
|
|
let refresh_hits = Arc::new(Mutex::new(0usize));
|
|
let refresh_hits_clone = Arc::clone(&refresh_hits);
|
|
let ops = Router::new()
|
|
.route(
|
|
"/api/v1/auth/refresh",
|
|
post(move |_request: Request| {
|
|
let refresh_hits_inner = Arc::clone(&refresh_hits_clone);
|
|
async move {
|
|
*refresh_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 1,
|
|
"message": "invalid refresh token",
|
|
})),
|
|
)
|
|
}
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/v1/auth/me",
|
|
get(|headers: axum::http::HeaderMap| async move {
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Bearer cached-access-token")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 0,
|
|
"data": {
|
|
"username": "sub2api-user",
|
|
"email": "[email protected]",
|
|
"balance": 8.5,
|
|
"points": 1.5,
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
);
|
|
|
|
let (ops_url, ops_handle) = start_server(ops).await;
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "sub2api",
|
|
"base_url": ops_url,
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {},
|
|
"credentials": {
|
|
"refresh_token": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"refresh-token-old",
|
|
).expect("refresh token should encrypt"),
|
|
"_cached_access_token": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"cached-access-token",
|
|
).expect("cached access token should encrypt"),
|
|
"_cached_token_expires_at": 4102444800.0,
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository,
|
|
)
|
|
.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/provider-ops/providers/provider-openai/verify"
|
|
))
|
|
.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!({
|
|
"architecture_id": "sub2api",
|
|
"base_url": ops_url,
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {},
|
|
"credentials": {}
|
|
},
|
|
"actions": {},
|
|
"schedule": {},
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["success"], true);
|
|
assert_eq!(payload["data"]["username"], "sub2api-user");
|
|
assert_eq!(payload["updated_credentials"], serde_json::Value::Null);
|
|
assert_eq!(*refresh_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
ops_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_verifies_admin_provider_ops_sub2api_persists_rotated_runtime_credentials() {
|
|
let ops = Router::new()
|
|
.route(
|
|
"/api/v1/auth/refresh",
|
|
post(|request: Request| async move {
|
|
let body = to_bytes(request.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read");
|
|
let payload: serde_json::Value =
|
|
serde_json::from_slice(&body).expect("json body should parse");
|
|
assert_eq!(payload["refresh_token"], "refresh-token-old");
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 0,
|
|
"data": {
|
|
"access_token": "access-token-new",
|
|
"refresh_token": "refresh-token-new",
|
|
"expires_in": 900
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/v1/auth/me",
|
|
get(|headers: axum::http::HeaderMap| async move {
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Bearer access-token-new")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 0,
|
|
"data": {
|
|
"username": "sub2api-user",
|
|
"email": "[email protected]",
|
|
"balance": 8.5,
|
|
"points": 1.5,
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
);
|
|
|
|
let (ops_url, ops_handle) = start_server(ops).await;
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "sub2api",
|
|
"base_url": ops_url,
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {},
|
|
"credentials": {
|
|
"refresh_token": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"refresh-token-old",
|
|
).expect("refresh token should encrypt"),
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(Arc::clone(
|
|
&provider_catalog_repository,
|
|
))
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/verify"
|
|
))
|
|
.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!({
|
|
"architecture_id": "sub2api",
|
|
"base_url": ops_url,
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {},
|
|
"credentials": {}
|
|
},
|
|
"actions": {},
|
|
"schedule": {},
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["success"], true);
|
|
assert_eq!(
|
|
payload["updated_credentials"]["refresh_token"],
|
|
"refresh-token-new"
|
|
);
|
|
|
|
let stored_provider = provider_catalog_repository
|
|
.list_providers_by_ids(&["provider-openai".to_string()])
|
|
.await
|
|
.expect("providers should list")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored provider should exist");
|
|
let credentials = stored_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)
|
|
.and_then(|config| config.get("connector"))
|
|
.and_then(serde_json::Value::as_object)
|
|
.and_then(|connector| connector.get("credentials"))
|
|
.and_then(serde_json::Value::as_object)
|
|
.expect("credentials should be object");
|
|
let stored_refresh_token = credentials
|
|
.get("refresh_token")
|
|
.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"),
|
|
"refresh-token-new"
|
|
);
|
|
let stored_cached_access_token = credentials
|
|
.get("_cached_access_token")
|
|
.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"),
|
|
"access-token-new"
|
|
);
|
|
assert!(
|
|
credentials
|
|
.get("_cached_token_expires_at")
|
|
.and_then(serde_json::Value::as_f64)
|
|
.expect("cached token expires at should be number")
|
|
> std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.expect("time should move forward")
|
|
.as_secs_f64()
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
ops_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_rejects_admin_provider_ops_connect_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-ops/providers/provider-openai/connect",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "anyrouter",
|
|
"base_url": "https://ops.example",
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"credentials": {
|
|
"api_key": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"live-secret-api-key",
|
|
).expect("api key should encrypt"),
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository,
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/connect"
|
|
))
|
|
.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!({}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(
|
|
payload["detail"],
|
|
"Provider 连接仅支持 Rust execution runtime"
|
|
);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_ops_balance_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-ops/providers/provider-openai/balance",
|
|
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 ops = Router::new()
|
|
.route(
|
|
"/api/user/checkin",
|
|
post(|headers: axum::http::HeaderMap| async move {
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Bearer live-secret-api-key")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"success": true,
|
|
"message": "签到成功",
|
|
})),
|
|
)
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/user/balance",
|
|
get(|headers: axum::http::HeaderMap| async move {
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Bearer live-secret-api-key")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"quota": 5000000,
|
|
"used_quota": 1000000
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
);
|
|
|
|
let (ops_url, ops_handle) = start_server(ops).await;
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "generic_api",
|
|
"base_url": ops_url,
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {
|
|
"auth_method": "bearer"
|
|
},
|
|
"credentials": {
|
|
"api_key": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"live-secret-api-key",
|
|
).expect("api key should encrypt"),
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository,
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/balance?refresh=false"
|
|
))
|
|
.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["status"], "success");
|
|
assert_eq!(payload["action_type"], "query_balance");
|
|
assert_eq!(payload["data"]["total_available"], 10.0);
|
|
assert_eq!(payload["data"]["total_used"], 2.0);
|
|
assert_eq!(payload["data"]["extra"]["checkin_success"], true);
|
|
assert_eq!(payload["data"]["extra"]["checkin_message"], "签到成功");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
ops_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_ops_balance_locally_for_generic_api_proxy_node_mode() {
|
|
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-manual"));
|
|
assert_eq!(
|
|
plan.headers.get("authorization").map(String::as_str),
|
|
Some("Bearer live-secret-api-key")
|
|
);
|
|
|
|
if plan.url.ends_with("/api/user/checkin") {
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"success": true,
|
|
"message": "代理签到成功"
|
|
}
|
|
}
|
|
}))
|
|
} else {
|
|
assert!(plan.url.ends_with("/api/user/balance"));
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"success": true,
|
|
"data": {
|
|
"quota": 2500000,
|
|
"used_quota": 500000
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "generic_api",
|
|
"base_url": "https://ops.example",
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {
|
|
"auth_method": "bearer",
|
|
"proxy_node_id": "proxy-node-manual"
|
|
},
|
|
"credentials": {
|
|
"api_key": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"live-secret-api-key",
|
|
).expect("api key should encrypt"),
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
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 (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,
|
|
)
|
|
.attach_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/provider-ops/providers/provider-openai/balance?refresh=false"
|
|
))
|
|
.header(GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["status"], "success");
|
|
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"], "代理签到成功");
|
|
|
|
let plans = execution_plans.lock().expect("mutex should lock");
|
|
assert_eq!(plans.len(), 2);
|
|
assert!(plans.iter().any(|plan| {
|
|
plan.request_id == "provider-ops-action:probe_checkin"
|
|
&& plan.url == "https://ops.example/api/user/checkin"
|
|
}));
|
|
assert!(plans.iter().any(|plan| {
|
|
plan.request_id == "provider-ops-action:generic_api:query_balance:provider-openai"
|
|
&& plan.url == "https://ops.example/api/user/balance"
|
|
}));
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_ops_balance_locally_without_proxy_via_execution_runtime() {
|
|
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());
|
|
assert!(plan.proxy.is_none());
|
|
assert_eq!(
|
|
plan.headers.get("authorization").map(String::as_str),
|
|
Some("Bearer live-secret-api-key")
|
|
);
|
|
|
|
if plan.url.ends_with("/api/user/checkin") {
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"success": true,
|
|
"message": "执行层签到成功"
|
|
}
|
|
}
|
|
}))
|
|
} else {
|
|
assert!(plan.url.ends_with("/api/user/balance"));
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"success": true,
|
|
"data": {
|
|
"quota": 2500000,
|
|
"used_quota": 500000
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "generic_api",
|
|
"base_url": "https://ops.example",
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {
|
|
"auth_method": "bearer"
|
|
},
|
|
"credentials": {
|
|
"api_key": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"live-secret-api-key",
|
|
).expect("api key should encrypt"),
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/balance?refresh=false"
|
|
))
|
|
.header(GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["status"], "success");
|
|
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"],
|
|
"执行层签到成功"
|
|
);
|
|
|
|
let plans = execution_plans.lock().expect("mutex should lock");
|
|
assert_eq!(plans.len(), 2);
|
|
assert!(plans.iter().all(|plan| plan.proxy.is_none()));
|
|
assert!(plans
|
|
.iter()
|
|
.any(|plan| plan.request_id == "provider-ops-action:probe_checkin"));
|
|
assert!(plans.iter().any(|plan| {
|
|
plan.request_id == "provider-ops-action:generic_api:query_balance:provider-openai"
|
|
}));
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_ops_checkin_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-ops/providers/provider-openai/checkin",
|
|
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 ops = Router::new().route(
|
|
"/api/user/checkin",
|
|
post(|headers: axum::http::HeaderMap| async move {
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Bearer live-secret-api-key")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"success": true,
|
|
"message": "今日签到完成",
|
|
"data": {
|
|
"reward": 1.5,
|
|
"streak_days": 3,
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
);
|
|
|
|
let (ops_url, ops_handle) = start_server(ops).await;
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "generic_api",
|
|
"base_url": ops_url,
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {
|
|
"auth_method": "bearer"
|
|
},
|
|
"credentials": {
|
|
"api_key": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"live-secret-api-key",
|
|
).expect("api key should encrypt"),
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository,
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/checkin"
|
|
))
|
|
.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["status"], "success");
|
|
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!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
ops_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_ops_checkin_locally_for_generic_api_proxy_node_mode() {
|
|
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-manual"));
|
|
assert_eq!(plan.request_id, "provider-ops-action:generic_api:checkin");
|
|
assert_eq!(plan.url, "https://ops.example/api/user/checkin");
|
|
assert_eq!(
|
|
plan.headers.get("authorization").map(String::as_str),
|
|
Some("Bearer live-secret-api-key")
|
|
);
|
|
Json(json!({
|
|
"request_id": plan.request_id,
|
|
"status_code": 200,
|
|
"headers": {
|
|
"content-type": "application/json"
|
|
},
|
|
"body": {
|
|
"json_body": {
|
|
"success": true,
|
|
"message": "今日签到完成",
|
|
"data": {
|
|
"reward": 1.5,
|
|
"streak_days": 3
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "generic_api",
|
|
"base_url": "https://ops.example",
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {
|
|
"auth_method": "bearer",
|
|
"proxy_node_id": "proxy-node-manual"
|
|
},
|
|
"credentials": {
|
|
"api_key": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"live-secret-api-key",
|
|
).expect("api key should encrypt"),
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
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 (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,
|
|
)
|
|
.attach_proxy_node_repository_for_tests(proxy_node_repository),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/checkin"
|
|
))
|
|
.header(GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["status"], "success");
|
|
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!(execution_plans.lock().expect("mutex should lock").len(), 1);
|
|
|
|
gateway_handle.abort();
|
|
execution_runtime_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_ops_batch_balance_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/provider-ops/batch/balance",
|
|
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 ops = Router::new()
|
|
.route(
|
|
"/",
|
|
get(|| async move {
|
|
(
|
|
StatusCode::OK,
|
|
[(
|
|
axum::http::header::SET_COOKIE,
|
|
axum::http::HeaderValue::from_static(
|
|
"acw_tc=batch-acw-tc; Path=/; HttpOnly, cdn_sec_tc=batch-cdn-sec; Path=/; HttpOnly",
|
|
),
|
|
)],
|
|
Body::from(
|
|
"<html><script>var arg1 = '0123456789abcdef0123456789abcdef01234567';</script></html>",
|
|
),
|
|
)
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/user/checkin",
|
|
post(|| async move {
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"success": true,
|
|
"message": "签到成功",
|
|
})),
|
|
)
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/user/sign_in",
|
|
post(|headers: axum::http::HeaderMap| async move {
|
|
let cookie = headers
|
|
.get(axum::http::header::COOKIE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.expect("cookie header should exist");
|
|
assert!(!cookie.contains("acw_tc="));
|
|
assert!(!cookie.contains("cdn_sec_tc="));
|
|
assert!(cookie.contains("acw_sc__v2="));
|
|
assert!(cookie.contains(
|
|
"session=MTIzfGVIaDRlQUpwWkFOcGJuU3F1d0RfVkhsNWVYa0lkWE5sY201aGJXVUdjM1J5YVc1bkRCQUFCV0ZzYVdObHxzaWc"
|
|
));
|
|
assert_eq!(
|
|
headers
|
|
.get("New-Api-User")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("42")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"success": true,
|
|
"message": "Anyrouter 签到成功",
|
|
})),
|
|
)
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/user/balance",
|
|
get(|| async move {
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"quota": 2500000,
|
|
"used_quota": 500000
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
);
|
|
let ops = ops.route(
|
|
"/api/user/self",
|
|
get(|headers: axum::http::HeaderMap| async move {
|
|
let cookie = headers
|
|
.get(axum::http::header::COOKIE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.expect("cookie header should exist");
|
|
assert!(!cookie.contains("acw_tc="));
|
|
assert!(!cookie.contains("cdn_sec_tc="));
|
|
assert!(cookie.contains("acw_sc__v2="));
|
|
assert!(cookie.contains(
|
|
"session=MTIzfGVIaDRlQUpwWkFOcGJuU3F1d0RfVkhsNWVYa0lkWE5sY201aGJXVUdjM1J5YVc1bkRCQUFCV0ZzYVdObHxzaWc"
|
|
));
|
|
assert_eq!(
|
|
headers
|
|
.get("New-Api-User")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("42")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"quota": 3500000,
|
|
"used_quota": 500000
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
);
|
|
|
|
let (ops_url, ops_handle) = start_server(ops).await;
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "generic_api",
|
|
"base_url": ops_url,
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {
|
|
"auth_method": "bearer"
|
|
},
|
|
"credentials": {
|
|
"api_key": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"live-secret-api-key",
|
|
).expect("api key should encrypt"),
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
sample_provider("provider-anyrouter", "openai", 20).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "anyrouter",
|
|
"base_url": ops_url,
|
|
"connector": {
|
|
"auth_type": "cookie",
|
|
"config": {},
|
|
"credentials": {
|
|
"session_cookie": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"session=MTIzfGVIaDRlQUpwWkFOcGJuU3F1d0RfVkhsNWVYa0lkWE5sY201aGJXVUdjM1J5YVc1bkRCQUFCV0ZzYVdObHxzaWc",
|
|
).expect("session cookie should encrypt"),
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository,
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/batch/balance"
|
|
))
|
|
.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!([
|
|
"provider-openai",
|
|
"provider-anyrouter",
|
|
"provider-missing"
|
|
]))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["provider-openai"]["status"], "success");
|
|
assert_eq!(payload["provider-openai"]["data"]["total_available"], 5.0);
|
|
assert_eq!(payload["provider-anyrouter"]["status"], "success");
|
|
assert_eq!(
|
|
payload["provider-anyrouter"]["data"]["total_available"],
|
|
7.0
|
|
);
|
|
assert_eq!(
|
|
payload["provider-anyrouter"]["data"]["extra"]["checkin_success"],
|
|
true
|
|
);
|
|
assert_eq!(
|
|
payload["provider-anyrouter"]["data"]["extra"]["checkin_message"],
|
|
"Anyrouter 签到成功"
|
|
);
|
|
assert_eq!(payload["provider-missing"]["status"], "not_configured");
|
|
assert_eq!(payload["provider-missing"]["message"], "未配置操作设置");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
ops_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_ops_anyrouter_balance_with_auth_expired_cookie() {
|
|
let ops = Router::new()
|
|
.route(
|
|
"/",
|
|
get(|| async move {
|
|
(
|
|
StatusCode::OK,
|
|
[(
|
|
axum::http::header::SET_COOKIE,
|
|
axum::http::HeaderValue::from_static(
|
|
"acw_tc=expired-acw-tc; Path=/; HttpOnly, cdn_sec_tc=expired-cdn-sec; Path=/; HttpOnly",
|
|
),
|
|
)],
|
|
Body::from(
|
|
"<html><script>var arg1 = '0123456789abcdef0123456789abcdef01234567';</script></html>",
|
|
),
|
|
)
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/user/sign_in",
|
|
post(|| async move {
|
|
(
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(json!({
|
|
"success": false,
|
|
"message": "Cookie 已失效",
|
|
})),
|
|
)
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/user/self",
|
|
get(|headers: axum::http::HeaderMap| async move {
|
|
let cookie = headers
|
|
.get(axum::http::header::COOKIE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.expect("cookie header should exist");
|
|
assert!(!cookie.contains("acw_tc="));
|
|
assert!(!cookie.contains("cdn_sec_tc="));
|
|
assert!(cookie.contains("acw_sc__v2="));
|
|
assert!(cookie.contains(
|
|
"session=MTIzfGVIaDRlQUpwWkFOcGJuU3F1d0RfVkhsNWVYa0lkWE5sY201aGJXVUdjM1J5YVc1bkRCQUFCV0ZzYVdObHxzaWc"
|
|
));
|
|
assert_eq!(
|
|
headers
|
|
.get("New-Api-User")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("42")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"quota": 3500000,
|
|
"used_quota": 500000
|
|
})),
|
|
)
|
|
}),
|
|
);
|
|
|
|
let (ops_url, ops_handle) = start_server(ops).await;
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-anyrouter", "openai", 20).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "anyrouter",
|
|
"base_url": ops_url,
|
|
"connector": {
|
|
"auth_type": "cookie",
|
|
"config": {},
|
|
"credentials": {
|
|
"session_cookie": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"session=MTIzfGVIaDRlQUpwWkFOcGJuU3F1d0RfVkhsNWVYa0lkWE5sY201aGJXVUdjM1J5YVc1bkRCQUFCV0ZzYVdObHxzaWc",
|
|
).expect("session cookie should encrypt"),
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(
|
|
provider_catalog_repository,
|
|
),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-anyrouter/balance"
|
|
))
|
|
.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["status"], "auth_expired");
|
|
assert_eq!(payload["data"]["total_available"], 7.0);
|
|
assert_eq!(payload["data"]["total_used"], 1.0);
|
|
assert_eq!(payload["data"]["extra"]["cookie_expired"], true);
|
|
assert_eq!(
|
|
payload["data"]["extra"]["cookie_expired_message"],
|
|
"Cookie 已失效"
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
ops_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_ops_balance_cache_refresh_modes_with_redis() {
|
|
let Some(redis) = start_managed_redis_or_skip().await else {
|
|
return;
|
|
};
|
|
let balance_hits = Arc::new(Mutex::new(0usize));
|
|
let balance_hits_clone = Arc::clone(&balance_hits);
|
|
let ops = Router::new().route(
|
|
"/api/user/balance",
|
|
get(move |headers: axum::http::HeaderMap| {
|
|
let balance_hits_inner = Arc::clone(&balance_hits_clone);
|
|
async move {
|
|
*balance_hits_inner.lock().expect("mutex should lock") += 1;
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Bearer live-secret-api-key")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"quota": 2000000,
|
|
"used_quota": 500000
|
|
}
|
|
})),
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
|
|
let (ops_url, ops_handle) = start_server(ops).await;
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "generic_api",
|
|
"base_url": ops_url,
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {
|
|
"auth_method": "bearer"
|
|
},
|
|
"credentials": {
|
|
"api_key": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"live-secret-api-key",
|
|
).expect("api key should encrypt"),
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
let data_state = GatewayDataState::from_config(
|
|
GatewayDataConfig::disabled().with_encryption_key(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.expect("data state should build")
|
|
.attach_provider_catalog_repository_for_tests(Arc::clone(&provider_catalog_repository));
|
|
let runtime_state = redis_runtime_state_for_test(&redis, "provider_ops_balance_cache").await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(data_state)
|
|
.with_runtime_state(runtime_state),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
let client = reqwest::Client::new();
|
|
|
|
let pending_response = client
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/balance?refresh=true"
|
|
))
|
|
.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!(pending_response.status(), StatusCode::OK);
|
|
let pending_payload: serde_json::Value = pending_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(pending_payload["status"], "pending");
|
|
|
|
wait_until(5000, || {
|
|
*balance_hits.lock().expect("mutex should lock") == 1
|
|
})
|
|
.await;
|
|
|
|
let cached_response = client
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/balance?refresh=false"
|
|
))
|
|
.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!(cached_response.status(), StatusCode::OK);
|
|
let cached_payload: serde_json::Value = cached_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(cached_payload["status"], "success");
|
|
assert_eq!(cached_payload["data"]["total_available"], 4.0);
|
|
assert_eq!(cached_payload["data"]["total_used"], 1.0);
|
|
assert_eq!(*balance_hits.lock().expect("mutex should lock"), 1);
|
|
|
|
let refresh_response = client
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/balance?refresh=true"
|
|
))
|
|
.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!(refresh_response.status(), StatusCode::OK);
|
|
let refresh_payload: serde_json::Value = refresh_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(refresh_payload["status"], "success");
|
|
wait_until(5000, || {
|
|
*balance_hits.lock().expect("mutex should lock") == 2
|
|
})
|
|
.await;
|
|
|
|
gateway_handle.abort();
|
|
ops_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_ops_balance_cache_miss_without_refresh_returns_live_payload_once_with_redis(
|
|
) {
|
|
let Some(redis) = start_managed_redis_or_skip().await else {
|
|
return;
|
|
};
|
|
let balance_hits = Arc::new(Mutex::new(0usize));
|
|
let balance_hits_clone = Arc::clone(&balance_hits);
|
|
let ops = Router::new().route(
|
|
"/api/user/balance",
|
|
get(move |headers: axum::http::HeaderMap| {
|
|
let balance_hits_inner = Arc::clone(&balance_hits_clone);
|
|
async move {
|
|
*balance_hits_inner.lock().expect("mutex should lock") += 1;
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Bearer live-secret-api-key")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"quota": 2000000,
|
|
"used_quota": 500000
|
|
}
|
|
})),
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
|
|
let (ops_url, ops_handle) = start_server(ops).await;
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "generic_api",
|
|
"base_url": ops_url,
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {
|
|
"auth_method": "bearer"
|
|
},
|
|
"credentials": {
|
|
"api_key": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"live-secret-api-key",
|
|
).expect("api key should encrypt"),
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
let data_state = GatewayDataState::from_config(
|
|
GatewayDataConfig::disabled().with_encryption_key(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.expect("data state should build")
|
|
.attach_provider_catalog_repository_for_tests(Arc::clone(&provider_catalog_repository));
|
|
let runtime_state =
|
|
redis_runtime_state_for_test(&redis, "provider_ops_balance_cache_sync_miss").await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(data_state)
|
|
.with_runtime_state(runtime_state),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
let client = reqwest::Client::new();
|
|
|
|
let live_response = client
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/balance?refresh=false"
|
|
))
|
|
.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!(live_response.status(), StatusCode::OK);
|
|
let live_payload: serde_json::Value =
|
|
live_response.json().await.expect("json body should parse");
|
|
assert_eq!(live_payload["status"], "success");
|
|
assert_eq!(live_payload["data"]["total_available"], 4.0);
|
|
assert_eq!(live_payload["data"]["total_used"], 1.0);
|
|
assert_eq!(*balance_hits.lock().expect("mutex should lock"), 1);
|
|
|
|
let cached_response = client
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/balance?refresh=false"
|
|
))
|
|
.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!(cached_response.status(), StatusCode::OK);
|
|
let cached_payload: serde_json::Value = cached_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(cached_payload["status"], "success");
|
|
assert_eq!(cached_payload["data"]["total_available"], 4.0);
|
|
assert_eq!(cached_payload["data"]["total_used"], 1.0);
|
|
assert_eq!(*balance_hits.lock().expect("mutex should lock"), 1);
|
|
|
|
gateway_handle.abort();
|
|
ops_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_clears_admin_provider_ops_balance_cache_after_config_save_with_redis() {
|
|
let Some(redis) = start_managed_redis_or_skip().await else {
|
|
return;
|
|
};
|
|
let balance_hits_v1 = Arc::new(Mutex::new(0usize));
|
|
let balance_hits_v1_clone = Arc::clone(&balance_hits_v1);
|
|
let ops_v1 = Router::new().route(
|
|
"/api/user/balance",
|
|
get(move |headers: axum::http::HeaderMap| {
|
|
let balance_hits_inner = Arc::clone(&balance_hits_v1_clone);
|
|
async move {
|
|
*balance_hits_inner.lock().expect("mutex should lock") += 1;
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Bearer live-secret-api-key")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"quota": 2000000,
|
|
"used_quota": 500000
|
|
}
|
|
})),
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
let balance_hits_v2 = Arc::new(Mutex::new(0usize));
|
|
let balance_hits_v2_clone = Arc::clone(&balance_hits_v2);
|
|
let ops_v2 = Router::new().route(
|
|
"/api/user/balance",
|
|
get(move |headers: axum::http::HeaderMap| {
|
|
let balance_hits_inner = Arc::clone(&balance_hits_v2_clone);
|
|
async move {
|
|
*balance_hits_inner.lock().expect("mutex should lock") += 1;
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Bearer next-secret-api-key")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"quota": 3500000,
|
|
"used_quota": 1000000
|
|
}
|
|
})),
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
|
|
let (ops_v1_url, ops_v1_handle) = start_server(ops_v1).await;
|
|
let (ops_v2_url, ops_v2_handle) = start_server(ops_v2).await;
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "generic_api",
|
|
"base_url": ops_v1_url,
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {
|
|
"auth_method": "bearer"
|
|
},
|
|
"credentials": {
|
|
"api_key": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"live-secret-api-key",
|
|
).expect("api key should encrypt"),
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
let data_state = GatewayDataState::from_config(
|
|
GatewayDataConfig::disabled().with_encryption_key(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.expect("data state should build")
|
|
.attach_provider_catalog_repository_for_tests(Arc::clone(&provider_catalog_repository));
|
|
let runtime_state =
|
|
redis_runtime_state_for_test(&redis, "provider_ops_balance_cache_config_save").await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(data_state)
|
|
.with_runtime_state(runtime_state),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
let client = reqwest::Client::new();
|
|
|
|
let initial_response = client
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/balance?refresh=false"
|
|
))
|
|
.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!(initial_response.status(), StatusCode::OK);
|
|
let initial_payload: serde_json::Value = initial_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(initial_payload["status"], "success");
|
|
assert_eq!(initial_payload["data"]["total_available"], 4.0);
|
|
assert_eq!(*balance_hits_v1.lock().expect("mutex should lock"), 1);
|
|
assert_eq!(*balance_hits_v2.lock().expect("mutex should lock"), 0);
|
|
|
|
let save_response = client
|
|
.put(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/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!({
|
|
"architecture_id": "generic_api",
|
|
"base_url": ops_v2_url,
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {
|
|
"auth_method": "bearer"
|
|
},
|
|
"credentials": {
|
|
"api_key": "next-secret-api-key"
|
|
}
|
|
},
|
|
"actions": {},
|
|
"schedule": {}
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(save_response.status(), StatusCode::OK);
|
|
|
|
let updated_response = client
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/balance?refresh=false"
|
|
))
|
|
.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!(updated_response.status(), StatusCode::OK);
|
|
let updated_payload: serde_json::Value = updated_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(updated_payload["status"], "success");
|
|
assert_eq!(updated_payload["data"]["total_available"], 7.0);
|
|
assert_eq!(updated_payload["data"]["total_used"], 2.0);
|
|
assert_eq!(*balance_hits_v1.lock().expect("mutex should lock"), 1);
|
|
assert_eq!(*balance_hits_v2.lock().expect("mutex should lock"), 1);
|
|
|
|
gateway_handle.abort();
|
|
ops_v1_handle.abort();
|
|
ops_v2_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_verify_does_not_pollute_balance_cache_and_balance_uses_saved_action_config() {
|
|
let Some(redis) = start_managed_redis_or_skip().await else {
|
|
return;
|
|
};
|
|
let saved_balance_hits = Arc::new(Mutex::new(0usize));
|
|
let saved_balance_hits_clone = Arc::clone(&saved_balance_hits);
|
|
let verify_hits = Arc::new(Mutex::new(0usize));
|
|
let verify_hits_clone = Arc::clone(&verify_hits);
|
|
let saved_ops = Router::new()
|
|
.route(
|
|
"/api/user/self",
|
|
any(|| async move {
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"username": "saved-user",
|
|
"quota": 9999999
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/custom-balance",
|
|
get(move |headers: axum::http::HeaderMap| {
|
|
let saved_balance_hits_inner = Arc::clone(&saved_balance_hits_clone);
|
|
async move {
|
|
*saved_balance_hits_inner.lock().expect("mutex should lock") += 1;
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Bearer live-secret-api-key")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"quota": 8000000,
|
|
"used_quota": 2000000
|
|
}
|
|
})),
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
let verify_ops = Router::new().route(
|
|
"/api/user/self",
|
|
any(move |headers: axum::http::HeaderMap| {
|
|
let verify_hits_inner = Arc::clone(&verify_hits_clone);
|
|
async move {
|
|
*verify_hits_inner.lock().expect("mutex should lock") += 1;
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Bearer verify-secret-api-key")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"username": "verify-user",
|
|
"quota": 12000000,
|
|
"used_quota": 4000000
|
|
}
|
|
})),
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
|
|
let (saved_ops_url, saved_ops_handle) = start_server(saved_ops).await;
|
|
let (verify_ops_url, verify_ops_handle) = start_server(verify_ops).await;
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "generic_api",
|
|
"base_url": saved_ops_url,
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {
|
|
"auth_method": "bearer"
|
|
},
|
|
"credentials": {
|
|
"api_key": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"live-secret-api-key",
|
|
).expect("api key should encrypt"),
|
|
}
|
|
},
|
|
"actions": {
|
|
"query_balance": {
|
|
"enabled": true,
|
|
"config": {
|
|
"endpoint": "/api/custom-balance",
|
|
"quota_divisor": 2000000,
|
|
"currency": "CNY"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
let data_state = GatewayDataState::from_config(
|
|
GatewayDataConfig::disabled().with_encryption_key(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.expect("data state should build")
|
|
.attach_provider_catalog_repository_for_tests(Arc::clone(&provider_catalog_repository));
|
|
let runtime_state =
|
|
redis_runtime_state_for_test(&redis, "provider_ops_verify_cache_isolation").await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(data_state)
|
|
.with_runtime_state(runtime_state),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
let client = reqwest::Client::new();
|
|
|
|
let verify_response = client
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/verify"
|
|
))
|
|
.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!({
|
|
"architecture_id": "generic_api",
|
|
"base_url": verify_ops_url,
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {
|
|
"auth_method": "bearer"
|
|
},
|
|
"credentials": {
|
|
"api_key": "verify-secret-api-key"
|
|
}
|
|
},
|
|
"actions": {
|
|
"query_balance": {
|
|
"enabled": true,
|
|
"config": {
|
|
"endpoint": "/api/user/self",
|
|
"quota_divisor": 1,
|
|
"currency": "USD"
|
|
}
|
|
}
|
|
},
|
|
"schedule": {}
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("verify request should succeed");
|
|
assert_eq!(verify_response.status(), StatusCode::OK);
|
|
let verify_payload: serde_json::Value = verify_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(verify_payload["success"], true);
|
|
assert_eq!(verify_payload["data"]["username"], "verify-user");
|
|
assert_eq!(*verify_hits.lock().expect("mutex should lock"), 1);
|
|
assert_eq!(*saved_balance_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
let first_balance_response = client
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/balance?refresh=false"
|
|
))
|
|
.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("balance request should succeed");
|
|
assert_eq!(first_balance_response.status(), StatusCode::OK);
|
|
let first_balance_payload: serde_json::Value = first_balance_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(first_balance_payload["status"], "success");
|
|
assert_eq!(first_balance_payload["data"]["total_available"], 4.0);
|
|
assert_eq!(first_balance_payload["data"]["total_used"], 1.0);
|
|
assert_eq!(first_balance_payload["data"]["currency"], "CNY");
|
|
assert_eq!(*saved_balance_hits.lock().expect("mutex should lock"), 1);
|
|
|
|
let cached_balance_response = client
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/balance?refresh=false"
|
|
))
|
|
.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("cached balance request should succeed");
|
|
assert_eq!(cached_balance_response.status(), StatusCode::OK);
|
|
let cached_balance_payload: serde_json::Value = cached_balance_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(cached_balance_payload["status"], "success");
|
|
assert_eq!(cached_balance_payload["data"]["total_available"], 4.0);
|
|
assert_eq!(cached_balance_payload["data"]["total_used"], 1.0);
|
|
assert_eq!(cached_balance_payload["data"]["currency"], "CNY");
|
|
assert_eq!(*saved_balance_hits.lock().expect("mutex should lock"), 1);
|
|
|
|
gateway_handle.abort();
|
|
saved_ops_handle.abort();
|
|
verify_ops_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_ops_batch_balance_with_pending_cache_hits_and_redis() {
|
|
let Some(redis) = start_managed_redis_or_skip().await else {
|
|
return;
|
|
};
|
|
let balance_hits = Arc::new(Mutex::new(0usize));
|
|
let balance_hits_clone = Arc::clone(&balance_hits);
|
|
let ops = Router::new().route(
|
|
"/api/user/balance",
|
|
get(move || {
|
|
let balance_hits_inner = Arc::clone(&balance_hits_clone);
|
|
async move {
|
|
*balance_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"quota": 1500000,
|
|
"used_quota": 500000
|
|
}
|
|
})),
|
|
)
|
|
}
|
|
}),
|
|
);
|
|
|
|
let (ops_url, ops_handle) = start_server(ops).await;
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "generic_api",
|
|
"base_url": ops_url,
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {
|
|
"auth_method": "bearer"
|
|
},
|
|
"credentials": {
|
|
"api_key": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"live-secret-api-key",
|
|
).expect("api key should encrypt"),
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
let data_state = GatewayDataState::from_config(
|
|
GatewayDataConfig::disabled().with_encryption_key(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.expect("data state should build")
|
|
.attach_provider_catalog_repository_for_tests(Arc::clone(&provider_catalog_repository));
|
|
let runtime_state = redis_runtime_state_for_test(&redis, "provider_ops_batch_balance").await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(data_state)
|
|
.with_runtime_state(runtime_state),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
let client = reqwest::Client::new();
|
|
|
|
let pending_response = client
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/batch/balance"
|
|
))
|
|
.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!(["provider-openai"]))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
let pending_payload: serde_json::Value = pending_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(pending_payload["provider-openai"]["status"], "pending");
|
|
|
|
wait_until(5000, || {
|
|
*balance_hits.lock().expect("mutex should lock") == 1
|
|
})
|
|
.await;
|
|
|
|
let cached_response = client
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/batch/balance"
|
|
))
|
|
.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!(["provider-openai"]))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
let cached_payload: serde_json::Value = cached_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(cached_payload["provider-openai"]["status"], "success");
|
|
assert_eq!(
|
|
cached_payload["provider-openai"]["data"]["total_available"],
|
|
3.0
|
|
);
|
|
wait_until(5000, || {
|
|
*balance_hits.lock().expect("mutex should lock") == 2
|
|
})
|
|
.await;
|
|
|
|
gateway_handle.abort();
|
|
ops_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_ops_sub2api_balance_with_refresh_token_rotation() {
|
|
let ops = Router::new()
|
|
.route(
|
|
"/api/v1/auth/refresh",
|
|
post(|request: Request| async move {
|
|
let body = to_bytes(request.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read");
|
|
let payload: serde_json::Value =
|
|
serde_json::from_slice(&body).expect("json body should parse");
|
|
assert_eq!(payload["refresh_token"], "refresh-token-old");
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 0,
|
|
"data": {
|
|
"access_token": "access-token-new",
|
|
"refresh_token": "refresh-token-new",
|
|
"expires_in": 900
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/v1/auth/me",
|
|
get(|headers: axum::http::HeaderMap| async move {
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Bearer access-token-new")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 0,
|
|
"data": {
|
|
"username": "sub2api-user",
|
|
"email": "[email protected]",
|
|
"balance": 8.5,
|
|
"points": 1.5
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/v1/subscriptions/summary",
|
|
get(|headers: axum::http::HeaderMap| async move {
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Bearer access-token-new")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 0,
|
|
"data": {
|
|
"active_count": 2,
|
|
"total_used_usd": 3.5,
|
|
"subscriptions": [
|
|
{
|
|
"group_name": "Pro",
|
|
"status": "active",
|
|
"monthly_used_usd": 1.2,
|
|
"monthly_limit_usd": 20,
|
|
"expires_at": "2099-12-31T00:00:00Z"
|
|
}
|
|
]
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
);
|
|
|
|
let (ops_url, ops_handle) = start_server(ops).await;
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "sub2api",
|
|
"base_url": ops_url,
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {},
|
|
"credentials": {
|
|
"refresh_token": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"refresh-token-old",
|
|
).expect("refresh token should encrypt"),
|
|
"_cached_access_token": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"stale-access-token",
|
|
).expect("cached access token should encrypt"),
|
|
"_cached_token_expires_at": 0.0,
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(Arc::clone(
|
|
&provider_catalog_repository,
|
|
))
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/balance"
|
|
))
|
|
.header(GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["status"], "success");
|
|
assert_eq!(payload["data"]["total_available"], 10.0);
|
|
assert_eq!(payload["data"]["extra"]["balance"], 8.5);
|
|
assert_eq!(payload["data"]["extra"]["points"], 1.5);
|
|
assert_eq!(payload["data"]["extra"]["active_subscriptions"], 2);
|
|
assert_eq!(payload["data"]["extra"]["total_used_usd"], 3.5);
|
|
assert_eq!(
|
|
payload["data"]["extra"]["subscriptions"][0]["group_name"],
|
|
"Pro"
|
|
);
|
|
|
|
let stored_provider = provider_catalog_repository
|
|
.list_providers_by_ids(&["provider-openai".to_string()])
|
|
.await
|
|
.expect("providers should list")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored provider should exist");
|
|
let credentials = stored_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)
|
|
.and_then(|config| config.get("connector"))
|
|
.and_then(serde_json::Value::as_object)
|
|
.and_then(|connector| connector.get("credentials"))
|
|
.and_then(serde_json::Value::as_object)
|
|
.expect("credentials should be object");
|
|
let stored_refresh_token = credentials
|
|
.get("refresh_token")
|
|
.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"),
|
|
"refresh-token-new"
|
|
);
|
|
let stored_cached_access_token = credentials
|
|
.get("_cached_access_token")
|
|
.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"),
|
|
"access-token-new"
|
|
);
|
|
assert!(
|
|
credentials
|
|
.get("_cached_token_expires_at")
|
|
.and_then(serde_json::Value::as_f64)
|
|
.expect("cached token expires at should be number")
|
|
> std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.expect("time should move forward")
|
|
.as_secs_f64()
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
ops_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_ops_sub2api_balance_against_site_root_when_base_url_has_path(
|
|
) {
|
|
let nested_refresh_hits = Arc::new(Mutex::new(0usize));
|
|
let nested_refresh_hits_clone = Arc::clone(&nested_refresh_hits);
|
|
let nested_me_hits = Arc::new(Mutex::new(0usize));
|
|
let nested_me_hits_clone = Arc::clone(&nested_me_hits);
|
|
let nested_subscription_hits = Arc::new(Mutex::new(0usize));
|
|
let nested_subscription_hits_clone = Arc::clone(&nested_subscription_hits);
|
|
let ops = Router::new()
|
|
.route(
|
|
"/nested/api/v1/auth/refresh",
|
|
post(move |_request: Request| {
|
|
let nested_refresh_hits_inner = Arc::clone(&nested_refresh_hits_clone);
|
|
async move {
|
|
*nested_refresh_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 1,
|
|
"message": "invalid refresh token",
|
|
})),
|
|
)
|
|
}
|
|
}),
|
|
)
|
|
.route(
|
|
"/nested/api/v1/auth/me",
|
|
get(move || {
|
|
let nested_me_hits_inner = Arc::clone(&nested_me_hits_clone);
|
|
async move {
|
|
*nested_me_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 1,
|
|
"message": "unexpected nested me request",
|
|
})),
|
|
)
|
|
}
|
|
}),
|
|
)
|
|
.route(
|
|
"/nested/api/v1/subscriptions/summary",
|
|
get(move || {
|
|
let nested_subscription_hits_inner = Arc::clone(&nested_subscription_hits_clone);
|
|
async move {
|
|
*nested_subscription_hits_inner
|
|
.lock()
|
|
.expect("mutex should lock") += 1;
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 1,
|
|
"message": "unexpected nested subscriptions request",
|
|
})),
|
|
)
|
|
}
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/v1/auth/refresh",
|
|
post(|request: Request| async move {
|
|
let body = to_bytes(request.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read");
|
|
let payload: serde_json::Value =
|
|
serde_json::from_slice(&body).expect("json body should parse");
|
|
assert_eq!(payload["refresh_token"], "refresh-token-old");
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 0,
|
|
"data": {
|
|
"access_token": "access-token-new",
|
|
"refresh_token": "refresh-token-new",
|
|
"expires_in": 900
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/v1/auth/me",
|
|
get(|headers: axum::http::HeaderMap| async move {
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Bearer access-token-new")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 0,
|
|
"data": {
|
|
"username": "sub2api-user",
|
|
"email": "[email protected]",
|
|
"balance": 8.5,
|
|
"points": 1.5
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/v1/subscriptions/summary",
|
|
get(|headers: axum::http::HeaderMap| async move {
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Bearer access-token-new")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 0,
|
|
"data": {
|
|
"active_count": 2,
|
|
"total_used_usd": 3.5,
|
|
"subscriptions": [
|
|
{
|
|
"group_name": "Pro",
|
|
"status": "active",
|
|
"monthly_used_usd": 1.2,
|
|
"monthly_limit_usd": 20,
|
|
"expires_at": "2099-12-31T00:00:00Z"
|
|
}
|
|
]
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
);
|
|
|
|
let (ops_url, ops_handle) = start_server(ops).await;
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "sub2api",
|
|
"base_url": format!("{ops_url}/nested"),
|
|
"connector": {
|
|
"auth_type": "api_key",
|
|
"config": {},
|
|
"credentials": {
|
|
"refresh_token": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"refresh-token-old",
|
|
).expect("refresh token should encrypt"),
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(Arc::clone(
|
|
&provider_catalog_repository,
|
|
))
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/balance"
|
|
))
|
|
.header(GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["status"], "success");
|
|
assert_eq!(payload["data"]["total_available"], 10.0);
|
|
assert_eq!(payload["data"]["extra"]["active_subscriptions"], 2);
|
|
assert_eq!(*nested_refresh_hits.lock().expect("mutex should lock"), 0);
|
|
assert_eq!(*nested_me_hits.lock().expect("mutex should lock"), 0);
|
|
assert_eq!(
|
|
*nested_subscription_hits.lock().expect("mutex should lock"),
|
|
0
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
ops_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_provider_ops_sub2api_balance_with_session_login() {
|
|
let ops = Router::new()
|
|
.route(
|
|
"/api/v1/auth/login",
|
|
post(|request: Request| async move {
|
|
let body = to_bytes(request.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should read");
|
|
let payload: serde_json::Value =
|
|
serde_json::from_slice(&body).expect("json body should parse");
|
|
assert_eq!(payload["email"], "[email protected]");
|
|
assert_eq!(payload["password"], "top-secret-password");
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 0,
|
|
"data": {
|
|
"access_token": "login-access-token",
|
|
"refresh_token": "login-refresh-token",
|
|
"expires_in": 900
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/v1/auth/me",
|
|
get(|headers: axum::http::HeaderMap| async move {
|
|
assert_eq!(
|
|
headers
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("Bearer login-access-token")
|
|
);
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 0,
|
|
"data": {
|
|
"username": "sub2api-user",
|
|
"balance": 6,
|
|
"points": 2
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
)
|
|
.route(
|
|
"/api/v1/subscriptions/summary",
|
|
get(|| async move {
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"code": 0,
|
|
"data": {
|
|
"active_count": 1,
|
|
"subscriptions": []
|
|
}
|
|
})),
|
|
)
|
|
}),
|
|
);
|
|
|
|
let (ops_url, ops_handle) = start_server(ops).await;
|
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![
|
|
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
|
true,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({
|
|
"provider_ops": {
|
|
"architecture_id": "sub2api",
|
|
"base_url": ops_url,
|
|
"connector": {
|
|
"auth_type": "session_login",
|
|
"config": {},
|
|
"credentials": {
|
|
"email": "[email protected]",
|
|
"password": encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
"top-secret-password",
|
|
).expect("password should encrypt"),
|
|
}
|
|
}
|
|
}
|
|
})),
|
|
),
|
|
],
|
|
vec![],
|
|
vec![],
|
|
));
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(Arc::clone(
|
|
&provider_catalog_repository,
|
|
))
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/balance"
|
|
))
|
|
.header(GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["status"], "success");
|
|
assert_eq!(payload["data"]["total_available"], 8.0);
|
|
assert_eq!(payload["data"]["extra"]["active_subscriptions"], 1);
|
|
|
|
let stored_provider = provider_catalog_repository
|
|
.list_providers_by_ids(&["provider-openai".to_string()])
|
|
.await
|
|
.expect("providers should list")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored provider should exist");
|
|
let credentials = stored_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)
|
|
.and_then(|config| config.get("connector"))
|
|
.and_then(serde_json::Value::as_object)
|
|
.and_then(|connector| connector.get("credentials"))
|
|
.and_then(serde_json::Value::as_object)
|
|
.expect("credentials should be object");
|
|
let stored_refresh_token = credentials
|
|
.get("refresh_token")
|
|
.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"),
|
|
"login-refresh-token"
|
|
);
|
|
let stored_cached_access_token = credentials
|
|
.get("_cached_access_token")
|
|
.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"),
|
|
"login-access-token"
|
|
);
|
|
assert!(
|
|
credentials
|
|
.get("_cached_token_expires_at")
|
|
.and_then(serde_json::Value::as_f64)
|
|
.expect("cached token expires at should be number")
|
|
> std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.expect("time should move forward")
|
|
.as_secs_f64()
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
ops_handle.abort();
|
|
}
|