mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 12:40:20 +08:00
2622 lines
100 KiB
Rust
2622 lines
100 KiB
Rust
use std::sync::{Arc, Mutex};
|
|
|
|
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
|
use aether_data::repository::auth::{
|
|
InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeyExportRecord, StoredAuthApiKeySnapshot,
|
|
};
|
|
use aether_data::repository::usage::InMemoryUsageReadRepository;
|
|
use aether_data::repository::users::{
|
|
InMemoryUserReadRepository, StoredUserAuthRecord, StoredUserExportRow, UpsertUserGroupRecord,
|
|
UserReadRepository,
|
|
};
|
|
use aether_data::repository::wallet::InMemoryWalletRepository;
|
|
use aether_data::repository::wallet::StoredWalletSnapshot;
|
|
use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
|
|
use axum::body::Body;
|
|
use axum::routing::{any, delete, get, patch, post, put};
|
|
use axum::{extract::Request, Router};
|
|
use chrono::{TimeZone, Utc};
|
|
use http::StatusCode;
|
|
use serde_json::json;
|
|
|
|
use super::super::{
|
|
build_router_with_state, hash_api_key, issue_test_admin_access_token, start_server, AppState,
|
|
};
|
|
use crate::constants::{
|
|
GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
|
|
TRUSTED_ADMIN_USER_ROLE_HEADER,
|
|
};
|
|
use crate::data::GatewayDataState;
|
|
|
|
fn sample_admin_user(user_id: &str) -> StoredUserAuthRecord {
|
|
sample_admin_user_with_role(user_id, "user", "alice@example.com", "alice")
|
|
}
|
|
|
|
fn sample_admin_user_with_role(
|
|
user_id: &str,
|
|
role: &str,
|
|
email: &str,
|
|
username: &str,
|
|
) -> StoredUserAuthRecord {
|
|
sample_admin_user_with_role_and_created_at(user_id, role, email, username, Utc::now())
|
|
}
|
|
|
|
fn sample_admin_user_with_role_and_created_at(
|
|
user_id: &str,
|
|
role: &str,
|
|
email: &str,
|
|
username: &str,
|
|
created_at: chrono::DateTime<Utc>,
|
|
) -> StoredUserAuthRecord {
|
|
StoredUserAuthRecord::new(
|
|
user_id.to_string(),
|
|
Some(email.to_string()),
|
|
true,
|
|
username.to_string(),
|
|
Some("hash".to_string()),
|
|
role.to_string(),
|
|
"local".to_string(),
|
|
Some(json!(["openai"])),
|
|
Some(json!(["openai:chat"])),
|
|
Some(json!(["gpt-4.1"])),
|
|
true,
|
|
false,
|
|
Some(created_at),
|
|
Some(Utc::now()),
|
|
)
|
|
.expect("user should build")
|
|
}
|
|
|
|
fn sample_admin_export_user(user_id: &str) -> StoredUserExportRow {
|
|
sample_admin_export_user_with("user", true, user_id, "alice@example.com", "alice")
|
|
}
|
|
|
|
fn sample_admin_export_user_with(
|
|
role: &str,
|
|
is_active: bool,
|
|
user_id: &str,
|
|
email: &str,
|
|
username: &str,
|
|
) -> StoredUserExportRow {
|
|
StoredUserExportRow::new(
|
|
user_id.to_string(),
|
|
Some(email.to_string()),
|
|
true,
|
|
username.to_string(),
|
|
Some("hash".to_string()),
|
|
role.to_string(),
|
|
"local".to_string(),
|
|
Some(json!(["openai"])),
|
|
Some(json!(["openai:chat"])),
|
|
Some(json!(["gpt-4.1"])),
|
|
Some(60),
|
|
None,
|
|
is_active,
|
|
)
|
|
.expect("user export row should build")
|
|
}
|
|
|
|
fn sample_admin_session(
|
|
user_id: &str,
|
|
session_id: &str,
|
|
) -> crate::data::state::StoredUserSessionRecord {
|
|
let now = Utc::now();
|
|
crate::data::state::StoredUserSessionRecord::new(
|
|
session_id.to_string(),
|
|
user_id.to_string(),
|
|
format!("device-{session_id}"),
|
|
None,
|
|
format!("refresh-{session_id}"),
|
|
None,
|
|
None,
|
|
Some(now),
|
|
Some(now + chrono::Duration::days(7)),
|
|
None,
|
|
None,
|
|
Some("127.0.0.1".to_string()),
|
|
Some("admin-test".to_string()),
|
|
Some(now),
|
|
Some(now),
|
|
)
|
|
.expect("session should build")
|
|
}
|
|
|
|
fn sample_admin_wallet(user_id: &str, limit_mode: &str) -> StoredWalletSnapshot {
|
|
StoredWalletSnapshot::new(
|
|
format!("wallet-{user_id}"),
|
|
Some(user_id.to_string()),
|
|
None,
|
|
12.5,
|
|
2.5,
|
|
limit_mode.to_string(),
|
|
"USD".to_string(),
|
|
"active".to_string(),
|
|
30.0,
|
|
10.0,
|
|
3.0,
|
|
1.5,
|
|
1_710_000_000,
|
|
)
|
|
.expect("wallet should build")
|
|
}
|
|
|
|
fn sample_usage_row(
|
|
id: &str,
|
|
request_id: &str,
|
|
user_id: Option<&str>,
|
|
provider_name: &str,
|
|
status: &str,
|
|
total_tokens: i32,
|
|
) -> StoredRequestUsageAudit {
|
|
StoredRequestUsageAudit::new(
|
|
id.to_string(),
|
|
request_id.to_string(),
|
|
user_id.map(str::to_string),
|
|
Some(format!("key-{id}")),
|
|
user_id.map(|value| format!("user-{value}")),
|
|
Some(format!("key-name-{id}")),
|
|
provider_name.to_string(),
|
|
"gpt-4.1".to_string(),
|
|
Some("gpt-4.1".to_string()),
|
|
Some("provider-1".to_string()),
|
|
Some("endpoint-1".to_string()),
|
|
Some("provider-key-1".to_string()),
|
|
Some("chat".to_string()),
|
|
Some("openai:chat".to_string()),
|
|
Some("openai".to_string()),
|
|
Some("chat".to_string()),
|
|
Some("openai:chat".to_string()),
|
|
Some("openai".to_string()),
|
|
Some("chat".to_string()),
|
|
false,
|
|
status == "streaming",
|
|
total_tokens,
|
|
0,
|
|
total_tokens,
|
|
0.1,
|
|
0.1,
|
|
Some(if status == "failed" { 500 } else { 200 }),
|
|
(status == "failed").then(|| "request failed".to_string()),
|
|
None,
|
|
Some(320),
|
|
Some(120),
|
|
status.to_string(),
|
|
"settled".to_string(),
|
|
1_711_000_000_000,
|
|
1_711_000_001,
|
|
Some(1_711_000_002),
|
|
)
|
|
.expect("usage row should build")
|
|
}
|
|
|
|
fn sample_admin_api_key_snapshot(user_id: &str, api_key_id: &str) -> StoredAuthApiKeySnapshot {
|
|
StoredAuthApiKeySnapshot::new(
|
|
user_id.to_string(),
|
|
"alice".to_string(),
|
|
Some("alice@example.com".to_string()),
|
|
"user".to_string(),
|
|
"local".to_string(),
|
|
true,
|
|
false,
|
|
Some(json!(["openai"])),
|
|
Some(json!(["openai:chat"])),
|
|
Some(json!(["gpt-4.1"])),
|
|
api_key_id.to_string(),
|
|
Some("default".to_string()),
|
|
true,
|
|
false,
|
|
false,
|
|
Some(60),
|
|
Some(5),
|
|
Some(200),
|
|
Some(json!(["openai"])),
|
|
Some(json!(["openai:chat"])),
|
|
Some(json!(["gpt-4.1"])),
|
|
)
|
|
.expect("api key snapshot should build")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_sorts_admin_users_by_created_at() {
|
|
let oldest = Utc
|
|
.with_ymd_and_hms(2026, 1, 10, 0, 0, 0)
|
|
.single()
|
|
.expect("valid timestamp");
|
|
let middle = Utc
|
|
.with_ymd_and_hms(2026, 2, 10, 0, 0, 0)
|
|
.single()
|
|
.expect("valid timestamp");
|
|
let newest = Utc
|
|
.with_ymd_and_hms(2026, 3, 10, 0, 0, 0)
|
|
.single()
|
|
.expect("valid timestamp");
|
|
|
|
let user_repository = Arc::new(
|
|
InMemoryUserReadRepository::seed_auth_users(vec![
|
|
sample_admin_user_with_role_and_created_at(
|
|
"user-old",
|
|
"user",
|
|
"old@example.com",
|
|
"old",
|
|
oldest,
|
|
),
|
|
sample_admin_user_with_role_and_created_at(
|
|
"user-middle",
|
|
"user",
|
|
"middle@example.com",
|
|
"middle",
|
|
middle,
|
|
),
|
|
sample_admin_user_with_role_and_created_at(
|
|
"user-new",
|
|
"user",
|
|
"new@example.com",
|
|
"new",
|
|
newest,
|
|
),
|
|
])
|
|
.with_export_users(vec![
|
|
sample_admin_export_user_with("user", true, "user-old", "old@example.com", "old"),
|
|
sample_admin_export_user_with(
|
|
"user",
|
|
true,
|
|
"user-middle",
|
|
"middle@example.com",
|
|
"middle",
|
|
),
|
|
sample_admin_export_user_with("user", true, "user-new", "new@example.com", "new"),
|
|
]),
|
|
);
|
|
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_user_reader_for_tests(
|
|
user_repository,
|
|
)),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
let client = reqwest::Client::new();
|
|
|
|
let desc_response = client
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/users?skip=0&limit=10&sort_by=created_at&sort_order=desc"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(desc_response.status(), StatusCode::OK);
|
|
let desc_payload: serde_json::Value = desc_response.json().await.expect("json should parse");
|
|
let desc_ids = desc_payload["items"]
|
|
.as_array()
|
|
.expect("items should be array")
|
|
.iter()
|
|
.map(|item| item["id"].as_str().expect("id should be string"))
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(desc_ids, vec!["user-new", "user-middle", "user-old"]);
|
|
|
|
let asc_response = client
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/users?skip=0&limit=10&sort_by=created_at&sort_order=asc"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(asc_response.status(), StatusCode::OK);
|
|
let asc_payload: serde_json::Value = asc_response.json().await.expect("json should parse");
|
|
let asc_ids = asc_payload["items"]
|
|
.as_array()
|
|
.expect("items should be array")
|
|
.iter()
|
|
.map(|item| item["id"].as_str().expect("id should be string"))
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(asc_ids, vec!["user-old", "user-middle", "user-new"]);
|
|
|
|
gateway_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_users_root_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/users",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let user_repository = Arc::new(
|
|
InMemoryUserReadRepository::seed_auth_users(vec![
|
|
sample_admin_user("user-1"),
|
|
sample_admin_user_with_role("user-2", "admin", "root@example.com", "root"),
|
|
sample_admin_user_with_role("user-3", "user", "carol@example.com", "carol"),
|
|
])
|
|
.with_export_users(vec![
|
|
sample_admin_export_user("user-1"),
|
|
sample_admin_export_user_with("admin", true, "user-2", "root@example.com", "root"),
|
|
sample_admin_export_user_with("user", false, "user-3", "carol@example.com", "carol"),
|
|
]),
|
|
);
|
|
let wallet_repository = Arc::new(InMemoryWalletRepository::seed(vec![
|
|
sample_admin_wallet("user-1", "unlimited"),
|
|
sample_admin_wallet("user-2", "monthly"),
|
|
sample_admin_wallet("user-3", "monthly"),
|
|
]));
|
|
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
|
|
sample_usage_row(
|
|
"usage-1",
|
|
"req-1",
|
|
Some("user-1"),
|
|
"OpenAI",
|
|
"completed",
|
|
80,
|
|
),
|
|
sample_usage_row("usage-2", "req-2", Some("user-1"), "OpenAI", "failed", 20),
|
|
sample_usage_row(
|
|
"usage-3",
|
|
"req-3",
|
|
Some("user-1"),
|
|
"OpenAI",
|
|
"streaming",
|
|
999,
|
|
),
|
|
sample_usage_row(
|
|
"usage-4",
|
|
"req-4",
|
|
Some("user-1"),
|
|
"pending",
|
|
"completed",
|
|
999,
|
|
),
|
|
sample_usage_row(
|
|
"usage-5",
|
|
"req-5",
|
|
Some("user-3"),
|
|
"OpenAI",
|
|
"completed",
|
|
777,
|
|
),
|
|
]));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_user_wallet_and_usage_for_tests(
|
|
user_repository,
|
|
wallet_repository,
|
|
usage_repository,
|
|
)),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/users?skip=0&limit=20&role=user&is_active=true"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["total"], 1);
|
|
assert_eq!(payload["skip"], 0);
|
|
assert_eq!(payload["limit"], 20);
|
|
assert_eq!(payload["has_more"], false);
|
|
let items = payload["items"].as_array().expect("items should be array");
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0]["id"], "user-1");
|
|
assert_eq!(items[0]["email"], "alice@example.com");
|
|
assert_eq!(items[0]["username"], "alice");
|
|
assert_eq!(items[0]["role"], "user");
|
|
assert_eq!(items[0]["allowed_providers"], json!(["openai"]));
|
|
assert_eq!(items[0]["allowed_api_formats"], json!(["openai:chat"]));
|
|
assert_eq!(items[0]["allowed_models"], json!(["gpt-4.1"]));
|
|
assert_eq!(items[0]["rate_limit"], 60);
|
|
assert_eq!(items[0]["unlimited"], true);
|
|
assert_eq!(items[0]["is_active"], true);
|
|
assert_eq!(items[0]["request_count"], 2);
|
|
assert_eq!(items[0]["total_tokens"], 100);
|
|
|
|
let search_response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/users?skip=0&limit=20&search=carol"
|
|
))
|
|
.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("search request should succeed");
|
|
|
|
assert_eq!(search_response.status(), StatusCode::OK);
|
|
let search_payload: serde_json::Value = search_response
|
|
.json()
|
|
.await
|
|
.expect("search json body should parse");
|
|
assert_eq!(search_payload["total"], 1);
|
|
let search_items = search_payload["items"]
|
|
.as_array()
|
|
.expect("search items should be array");
|
|
assert_eq!(search_items.len(), 1);
|
|
assert_eq!(search_items[0]["id"], "user-3");
|
|
assert_eq!(search_items[0]["email"], "carol@example.com");
|
|
|
|
let id_search_response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/users?skip=0&limit=20&search=user-3"
|
|
))
|
|
.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("id search request should succeed");
|
|
assert_eq!(id_search_response.status(), StatusCode::OK);
|
|
let id_search_payload: serde_json::Value = id_search_response
|
|
.json()
|
|
.await
|
|
.expect("id search json body should parse");
|
|
assert_eq!(id_search_payload["total"], 1);
|
|
let id_search_items = id_search_payload["items"]
|
|
.as_array()
|
|
.expect("id search items should be array");
|
|
assert_eq!(id_search_items.len(), 1);
|
|
assert_eq!(id_search_items[0]["id"], "user-3");
|
|
|
|
let limited_search_response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/users?skip=0&limit=2&search=example.com"
|
|
))
|
|
.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("limited search request should succeed");
|
|
assert_eq!(limited_search_response.status(), StatusCode::OK);
|
|
let limited_search_payload: serde_json::Value = limited_search_response
|
|
.json()
|
|
.await
|
|
.expect("limited search json body should parse");
|
|
assert_eq!(limited_search_payload["total"], 3);
|
|
assert_eq!(limited_search_payload["has_more"], true);
|
|
let limited_search_items = limited_search_payload["items"]
|
|
.as_array()
|
|
.expect("limited search items should be array");
|
|
assert_eq!(limited_search_items.len(), 2);
|
|
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
|
|
let create_gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_auth_users_for_tests(Vec::<StoredUserAuthRecord>::new())
|
|
.with_auth_wallets_for_tests(Vec::<StoredWalletSnapshot>::new()),
|
|
);
|
|
let (create_gateway_url, create_gateway_handle) = start_server(create_gateway).await;
|
|
|
|
let create_response = reqwest::Client::new()
|
|
.post(format!("{create_gateway_url}/api/admin/users"))
|
|
.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!({
|
|
"email": "new-user@example.com",
|
|
"username": "new_user",
|
|
"password": "NewUser123!",
|
|
"role": "user",
|
|
"unlimited": false,
|
|
"initial_gift_usd": 6.5
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(create_response.status(), StatusCode::OK);
|
|
let create_payload: serde_json::Value = create_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(create_payload["email"], "new-user@example.com");
|
|
assert_eq!(create_payload["username"], "new_user");
|
|
assert_eq!(create_payload["role"], "user");
|
|
assert_eq!(create_payload["rate_limit"], serde_json::Value::Null);
|
|
assert_eq!(create_payload["unlimited"], false);
|
|
|
|
let hidden_policy_response = reqwest::Client::new()
|
|
.post(format!("{create_gateway_url}/api/admin/users"))
|
|
.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!({
|
|
"email": "hidden-policy@example.com",
|
|
"username": "hidden_policy",
|
|
"password": "Hidden123!",
|
|
"allowed_models": ["gpt-4.1"]
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(hidden_policy_response.status(), StatusCode::BAD_REQUEST);
|
|
let hidden_policy_payload: serde_json::Value = hidden_policy_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(
|
|
hidden_policy_payload["detail"],
|
|
"allowed_models 已停用,请通过用户分组管理访问权限"
|
|
);
|
|
|
|
create_gateway_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_allows_default_user_group_access_policy_updates() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let user_repository = Arc::new(
|
|
InMemoryUserReadRepository::seed_auth_users(vec![
|
|
sample_admin_user_with_role("admin-1", "admin", "admin@example.com", "admin"),
|
|
sample_admin_user_with_role("user-2", "user", "bob@example.com", "bob"),
|
|
])
|
|
.with_export_users(vec![
|
|
sample_admin_export_user_with("admin", true, "admin-1", "admin@example.com", "admin"),
|
|
sample_admin_export_user_with("user", true, "user-2", "bob@example.com", "bob"),
|
|
]),
|
|
);
|
|
let group = user_repository
|
|
.create_user_group(UpsertUserGroupRecord {
|
|
name: "Baseline".to_string(),
|
|
description: None,
|
|
priority: 0,
|
|
allowed_providers: Some(vec!["openai".to_string()]),
|
|
allowed_providers_mode: "specific".to_string(),
|
|
allowed_api_formats: Some(vec!["openai:chat".to_string()]),
|
|
allowed_api_formats_mode: "specific".to_string(),
|
|
allowed_models: Some(vec!["gpt-4.1".to_string()]),
|
|
allowed_models_mode: "specific".to_string(),
|
|
rate_limit: Some(60),
|
|
rate_limit_mode: "custom".to_string(),
|
|
})
|
|
.await
|
|
.expect("user group should create")
|
|
.expect("user group should exist");
|
|
let group_id = group.id.clone();
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_user_reader_for_tests(user_repository.clone())
|
|
.with_system_config_values_for_tests(Vec::<(String, serde_json::Value)>::new()),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
let client = reqwest::Client::new();
|
|
|
|
let default_response = client
|
|
.put(format!("{gateway_url}/api/admin/user-groups/default"))
|
|
.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!({ "group_id": group_id }))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(default_response.status(), StatusCode::OK);
|
|
let default_payload: serde_json::Value = default_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(default_payload["default_group_id"], group_id);
|
|
|
|
let members = user_repository
|
|
.list_user_group_members(&group_id)
|
|
.await
|
|
.expect("default members should list");
|
|
assert_eq!(members.len(), 1);
|
|
assert_eq!(members[0].user_id, "user-2");
|
|
|
|
let update_response = client
|
|
.put(format!("{gateway_url}/api/admin/user-groups/{group_id}"))
|
|
.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!({
|
|
"name": "Baseline Limited",
|
|
"allowed_providers": ["openai", "anthropic"],
|
|
"allowed_providers_mode": "specific",
|
|
"allowed_api_formats": ["openai:chat"],
|
|
"allowed_api_formats_mode": "specific",
|
|
"allowed_models": ["gpt-4.1", "claude-sonnet-4-5"],
|
|
"allowed_models_mode": "specific",
|
|
"rate_limit": 25,
|
|
"rate_limit_mode": "custom"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(update_response.status(), StatusCode::OK);
|
|
let update_payload: serde_json::Value = update_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(update_payload["is_default"], true);
|
|
assert_eq!(update_payload["name"], "Baseline Limited");
|
|
assert_eq!(
|
|
update_payload["allowed_models"],
|
|
json!(["gpt-4.1", "claude-sonnet-4-5"])
|
|
);
|
|
assert_eq!(update_payload["rate_limit"], 25);
|
|
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_allows_removing_default_group_members_when_other_group_remains() {
|
|
let upstream = Router::new().fallback(any(|_request: Request| async {
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}));
|
|
|
|
let user_repository = Arc::new(
|
|
InMemoryUserReadRepository::seed_auth_users(vec![
|
|
sample_admin_user_with_role("admin-1", "admin", "admin@example.com", "admin"),
|
|
sample_admin_user_with_role("user-2", "user", "bob@example.com", "bob"),
|
|
sample_admin_user_with_role("user-3", "user", "carol@example.com", "carol"),
|
|
])
|
|
.with_export_users(vec![
|
|
sample_admin_export_user_with("admin", true, "admin-1", "admin@example.com", "admin"),
|
|
sample_admin_export_user_with("user", true, "user-2", "bob@example.com", "bob"),
|
|
sample_admin_export_user_with("user", true, "user-3", "carol@example.com", "carol"),
|
|
]),
|
|
);
|
|
let default_group = user_repository
|
|
.create_user_group(UpsertUserGroupRecord {
|
|
name: "Default".to_string(),
|
|
description: None,
|
|
priority: 0,
|
|
allowed_providers: None,
|
|
allowed_providers_mode: "unrestricted".to_string(),
|
|
allowed_api_formats: None,
|
|
allowed_api_formats_mode: "unrestricted".to_string(),
|
|
allowed_models: None,
|
|
allowed_models_mode: "unrestricted".to_string(),
|
|
rate_limit: None,
|
|
rate_limit_mode: "system".to_string(),
|
|
})
|
|
.await
|
|
.expect("default group should create")
|
|
.expect("default group should exist");
|
|
let team_group = user_repository
|
|
.create_user_group(UpsertUserGroupRecord {
|
|
name: "Team".to_string(),
|
|
description: None,
|
|
priority: 0,
|
|
allowed_providers: None,
|
|
allowed_providers_mode: "unrestricted".to_string(),
|
|
allowed_api_formats: None,
|
|
allowed_api_formats_mode: "unrestricted".to_string(),
|
|
allowed_models: None,
|
|
allowed_models_mode: "unrestricted".to_string(),
|
|
rate_limit: None,
|
|
rate_limit_mode: "system".to_string(),
|
|
})
|
|
.await
|
|
.expect("team group should create")
|
|
.expect("team group should exist");
|
|
user_repository
|
|
.add_user_to_group(&team_group.id, "user-2")
|
|
.await
|
|
.expect("team membership should create");
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_user_reader_for_tests(user_repository.clone())
|
|
.with_system_config_values_for_tests(vec![(
|
|
crate::constants::DEFAULT_USER_GROUP_CONFIG_KEY.to_string(),
|
|
json!(default_group.id),
|
|
)]),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
let client = reqwest::Client::new();
|
|
|
|
user_repository
|
|
.add_user_to_group(&default_group.id, "user-2")
|
|
.await
|
|
.expect("default membership should create");
|
|
user_repository
|
|
.add_user_to_group(&default_group.id, "user-3")
|
|
.await
|
|
.expect("default membership should create");
|
|
|
|
let remove_user_with_other_group = client
|
|
.put(format!(
|
|
"{gateway_url}/api/admin/user-groups/{}/members",
|
|
default_group.id
|
|
))
|
|
.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!({ "user_ids": ["user-3"] }))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(remove_user_with_other_group.status(), StatusCode::OK);
|
|
|
|
let reject_groupless_user = client
|
|
.put(format!(
|
|
"{gateway_url}/api/admin/user-groups/{}/members",
|
|
default_group.id
|
|
))
|
|
.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!({ "user_ids": [] }))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(reject_groupless_user.status(), StatusCode::BAD_REQUEST);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_resolves_admin_user_batch_selection_locally() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let user_repository = Arc::new(
|
|
InMemoryUserReadRepository::seed_auth_users(vec![
|
|
sample_admin_user("user-1"),
|
|
sample_admin_user_with_role("user-2", "admin", "root@example.com", "root"),
|
|
sample_admin_user_with_role("user-3", "user", "carol@example.com", "carol"),
|
|
])
|
|
.with_export_users(vec![
|
|
sample_admin_export_user("user-1"),
|
|
sample_admin_export_user_with("admin", true, "user-2", "root@example.com", "root"),
|
|
sample_admin_export_user_with("user", false, "user-3", "carol@example.com", "carol"),
|
|
]),
|
|
);
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_user_reader_for_tests(
|
|
user_repository,
|
|
)),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!("{gateway_url}/api/admin/users/resolve-selection"))
|
|
.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!({
|
|
"filters": {
|
|
"search": "ali",
|
|
"role": "user",
|
|
"is_active": true
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["total"], 1);
|
|
let items = payload["items"].as_array().expect("items should be array");
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0]["user_id"], "user-1");
|
|
assert_eq!(items[0]["username"], "alice");
|
|
assert_eq!(items[0]["email"], "alice@example.com");
|
|
assert_eq!(items[0]["role"], "user");
|
|
assert_eq!(items[0]["is_active"], true);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
let all_filtered_response = reqwest::Client::new()
|
|
.post(format!("{gateway_url}/api/admin/users/resolve-selection"))
|
|
.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!({ "filters": {} }))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(all_filtered_response.status(), StatusCode::OK);
|
|
let all_filtered_payload: serde_json::Value = all_filtered_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(all_filtered_payload["total"], 3);
|
|
|
|
let empty_selection_response = reqwest::Client::new()
|
|
.post(format!("{gateway_url}/api/admin/users/resolve-selection"))
|
|
.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!(empty_selection_response.status(), StatusCode::BAD_REQUEST);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_user_batch_actions_locally() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_auth_users_for_tests([sample_admin_user("user-1")])
|
|
.with_auth_wallets_for_tests([sample_admin_wallet("user-1", "unlimited")]),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
let client = reqwest::Client::new();
|
|
|
|
let disable_response = client
|
|
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
|
.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!({
|
|
"selection": {
|
|
"user_ids": ["user-1", "user-1", "missing-user"]
|
|
},
|
|
"action": "disable"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(disable_response.status(), StatusCode::OK);
|
|
let disable_payload: serde_json::Value = disable_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(disable_payload["total"], 2);
|
|
assert_eq!(disable_payload["success"], 1);
|
|
assert_eq!(disable_payload["failed"], 1);
|
|
assert_eq!(disable_payload["failures"][0]["user_id"], "missing-user");
|
|
|
|
let empty_selection_response = client
|
|
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
|
.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!({
|
|
"selection": {},
|
|
"action": "disable"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(empty_selection_response.status(), StatusCode::BAD_REQUEST);
|
|
|
|
let access_response = client
|
|
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
|
.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!({
|
|
"selection": {
|
|
"user_ids": ["user-1"]
|
|
},
|
|
"action": "update_access_control",
|
|
"payload": {
|
|
"unlimited": false
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(access_response.status(), StatusCode::OK);
|
|
let access_payload: serde_json::Value = access_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(access_payload["total"], 1);
|
|
assert_eq!(access_payload["success"], 1);
|
|
assert_eq!(access_payload["failed"], 0);
|
|
assert_eq!(access_payload["modified_fields"], json!(["unlimited"]));
|
|
|
|
let hidden_access_response = client
|
|
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
|
.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!({
|
|
"selection": {
|
|
"user_ids": ["user-1"]
|
|
},
|
|
"action": "update_access_control",
|
|
"payload": {
|
|
"rate_limit": 0
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(hidden_access_response.status(), StatusCode::BAD_REQUEST);
|
|
let hidden_access_payload: serde_json::Value = hidden_access_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(
|
|
hidden_access_payload["detail"],
|
|
"rate_limit 已停用,请通过用户分组管理访问权限"
|
|
);
|
|
|
|
let role_response = client
|
|
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
|
.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!({
|
|
"selection": {
|
|
"user_ids": ["user-1"]
|
|
},
|
|
"action": "update_role",
|
|
"payload": {
|
|
"role": "admin"
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(role_response.status(), StatusCode::OK);
|
|
let role_payload: serde_json::Value =
|
|
role_response.json().await.expect("json body should parse");
|
|
assert_eq!(role_payload["total"], 1);
|
|
assert_eq!(role_payload["success"], 1);
|
|
assert_eq!(role_payload["modified_fields"], json!(["role"]));
|
|
|
|
let blank_role_response = client
|
|
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
|
.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!({
|
|
"selection": {
|
|
"user_ids": ["user-1"]
|
|
},
|
|
"action": "update_role",
|
|
"payload": {
|
|
"role": ""
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(blank_role_response.status(), StatusCode::BAD_REQUEST);
|
|
|
|
let enable_admin_response = client
|
|
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
|
.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!({
|
|
"selection": {
|
|
"user_ids": ["user-1"]
|
|
},
|
|
"action": "enable"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(enable_admin_response.status(), StatusCode::OK);
|
|
|
|
let last_admin_demotion_response = client
|
|
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
|
.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!({
|
|
"selection": {
|
|
"user_ids": ["user-1"]
|
|
},
|
|
"action": "update_role",
|
|
"payload": {
|
|
"role": "user"
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(last_admin_demotion_response.status(), StatusCode::OK);
|
|
let last_admin_demotion_payload: serde_json::Value = last_admin_demotion_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(last_admin_demotion_payload["success"], 0);
|
|
assert_eq!(last_admin_demotion_payload["failed"], 1);
|
|
assert_eq!(
|
|
last_admin_demotion_payload["failures"][0]["reason"],
|
|
"不能降级最后一个管理员账户"
|
|
);
|
|
|
|
let last_admin_audit_demotion_response = client
|
|
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
|
.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!({
|
|
"selection": {
|
|
"user_ids": ["user-1"]
|
|
},
|
|
"action": "update_role",
|
|
"payload": {
|
|
"role": "audit_admin"
|
|
}
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(last_admin_audit_demotion_response.status(), StatusCode::OK);
|
|
let last_admin_audit_demotion_payload: serde_json::Value = last_admin_audit_demotion_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(last_admin_audit_demotion_payload["success"], 0);
|
|
assert_eq!(last_admin_audit_demotion_payload["failed"], 1);
|
|
assert_eq!(
|
|
last_admin_audit_demotion_payload["failures"][0]["reason"],
|
|
"不能降级最后一个管理员账户"
|
|
);
|
|
|
|
let detail_response = client
|
|
.get(format!("{gateway_url}/api/admin/users/user-1"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(detail_response.status(), StatusCode::OK);
|
|
let detail_payload: serde_json::Value = detail_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(detail_payload["role"], "admin");
|
|
assert_eq!(detail_payload["is_active"], true);
|
|
assert_eq!(detail_payload["unlimited"], false);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_users_root_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/users",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let user_repository = Arc::new(
|
|
InMemoryUserReadRepository::seed_auth_users(vec![sample_admin_user("user-1")])
|
|
.with_export_users(vec![sample_admin_export_user("user-1")]),
|
|
);
|
|
let wallet_repository = Arc::new(InMemoryWalletRepository::seed(vec![sample_admin_wallet(
|
|
"user-1",
|
|
"unlimited",
|
|
)]));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_user_and_wallet_for_tests(
|
|
user_repository,
|
|
wallet_repository,
|
|
));
|
|
let access_token = issue_test_admin_access_token(&state, "device-admin-users").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/users"))
|
|
.header("authorization", format!("Bearer {access_token}"))
|
|
.header("x-client-device-id", "device-admin-users")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["total"], 1);
|
|
let items = payload["items"].as_array().expect("items should be array");
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0]["id"], "user-1");
|
|
assert_eq!(items[0]["email"], "alice@example.com");
|
|
assert_eq!(items[0]["username"], "alice");
|
|
assert_eq!(items[0]["role"], "user");
|
|
assert_eq!(items[0]["allowed_providers"], json!(["openai"]));
|
|
assert_eq!(items[0]["allowed_api_formats"], json!(["openai:chat"]));
|
|
assert_eq!(items[0]["allowed_models"], json!(["gpt-4.1"]));
|
|
assert_eq!(items[0]["rate_limit"], 60);
|
|
assert_eq!(items[0]["unlimited"], true);
|
|
assert_eq!(items[0]["is_active"], true);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_user_detail_routes_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_auth_users_for_tests([
|
|
sample_admin_user("user-1"),
|
|
sample_admin_user_with_role("admin-1", "admin", "admin1@example.com", "admin_one"),
|
|
sample_admin_user_with_role("admin-2", "admin", "admin2@example.com", "admin_two"),
|
|
])
|
|
.with_auth_wallets_for_tests([sample_admin_wallet("user-1", "finite")]),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let client = reqwest::Client::new();
|
|
|
|
let update_response = client
|
|
.put(format!("{gateway_url}/api/admin/users/user-1"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"email": "alice-updated@example.com",
|
|
"username": "alice_updated",
|
|
"role": "admin",
|
|
"is_active": false,
|
|
"unlimited": true
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(update_response.status(), StatusCode::OK);
|
|
let update_payload: serde_json::Value = update_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(update_payload["email"], "alice-updated@example.com");
|
|
assert_eq!(update_payload["username"], "alice_updated");
|
|
assert_eq!(update_payload["role"], "admin");
|
|
assert_eq!(update_payload["rate_limit"], serde_json::Value::Null);
|
|
assert_eq!(update_payload["unlimited"], true);
|
|
assert_eq!(update_payload["is_active"], false);
|
|
|
|
let hidden_update_response = client
|
|
.put(format!("{gateway_url}/api/admin/users/user-1"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"allowed_providers": ["openai"]
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(hidden_update_response.status(), StatusCode::BAD_REQUEST);
|
|
let hidden_update_payload: serde_json::Value = hidden_update_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(
|
|
hidden_update_payload["detail"],
|
|
"allowed_providers 已停用,请通过用户分组管理访问权限"
|
|
);
|
|
|
|
let delete_response = client
|
|
.delete(format!("{gateway_url}/api/admin/users/user-1"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(delete_response.status(), StatusCode::OK);
|
|
let delete_payload: serde_json::Value = delete_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(delete_payload["message"], "用户删除成功");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_rejects_demoting_the_last_active_admin() {
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_auth_users_for_tests([sample_admin_user_with_role(
|
|
"admin-1",
|
|
"admin",
|
|
"admin@example.com",
|
|
"admin",
|
|
)]),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
let client = reqwest::Client::new();
|
|
|
|
for role in ["user", "audit_admin"] {
|
|
let response = client
|
|
.put(format!("{gateway_url}/api/admin/users/admin-1"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({ "role": role }))
|
|
.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"], "不能降级最后一个管理员账户");
|
|
}
|
|
|
|
gateway_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_user_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/users/user-1",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let user_repository = Arc::new(
|
|
InMemoryUserReadRepository::seed_auth_users(vec![sample_admin_user("user-1")])
|
|
.with_export_users(vec![sample_admin_export_user("user-1")]),
|
|
);
|
|
let wallet_repository = Arc::new(InMemoryWalletRepository::seed(vec![sample_admin_wallet(
|
|
"user-1",
|
|
"unlimited",
|
|
)]));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(GatewayDataState::with_user_and_wallet_for_tests(
|
|
user_repository,
|
|
wallet_repository,
|
|
)),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!("{gateway_url}/api/admin/users/user-1"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.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["id"], "user-1");
|
|
assert_eq!(payload["email"], "alice@example.com");
|
|
assert_eq!(payload["username"], "alice");
|
|
assert_eq!(payload["role"], "user");
|
|
assert_eq!(payload["allowed_providers"], json!(["openai"]));
|
|
assert_eq!(payload["allowed_api_formats"], json!(["openai:chat"]));
|
|
assert_eq!(payload["allowed_models"], json!(["gpt-4.1"]));
|
|
assert_eq!(payload["rate_limit"], 60);
|
|
assert_eq!(payload["unlimited"], true);
|
|
assert_eq!(payload["is_active"], true);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_user_session_routes_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/users/user-1/sessions",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
|
sample_admin_user("user-1"),
|
|
]));
|
|
let mut older = sample_admin_session("user-1", "session-1");
|
|
older.created_at =
|
|
Some(chrono::Utc::now() - chrono::Duration::days(2) - chrono::Duration::hours(1));
|
|
older.last_seen_at = Some(chrono::Utc::now() - chrono::Duration::hours(6));
|
|
older.ip_address = Some("10.0.0.1".to_string());
|
|
let mut newer = sample_admin_session("user-1", "session-2");
|
|
newer.created_at = Some(chrono::Utc::now() - chrono::Duration::days(1));
|
|
newer.last_seen_at = Some(chrono::Utc::now() - chrono::Duration::hours(1));
|
|
newer.ip_address = Some("10.0.0.2".to_string());
|
|
let mut revoked = sample_admin_session("user-1", "session-3");
|
|
revoked.revoked_at = Some(chrono::Utc::now());
|
|
let mut expired = sample_admin_session("user-1", "session-4");
|
|
expired.expires_at = Some(chrono::Utc::now() - chrono::Duration::minutes(1));
|
|
|
|
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(crate::data::GatewayDataState::with_user_reader_for_tests(
|
|
user_repository,
|
|
))
|
|
.with_auth_sessions_for_tests([older.clone(), newer.clone(), revoked, expired]),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!("{gateway_url}/api/admin/users/user-1/sessions"))
|
|
.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 sessions = payload
|
|
.as_array()
|
|
.expect("sessions payload should be array");
|
|
assert_eq!(sessions.len(), 2);
|
|
assert_eq!(sessions[0]["id"], newer.id);
|
|
assert_eq!(sessions[0]["device_label"], "未知设备");
|
|
assert_eq!(sessions[0]["device_type"], "unknown");
|
|
assert_eq!(sessions[0]["ip_address"], "10.0.0.2");
|
|
assert_eq!(sessions[0]["is_current"], false);
|
|
assert_eq!(sessions[1]["id"], older.id);
|
|
assert_eq!(sessions[1]["ip_address"], "10.0.0.1");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_handles_admin_user_api_key_routes_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().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let encrypted = encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "sk-user-1")
|
|
.expect("ciphertext should build");
|
|
let auth_repository = Arc::new(
|
|
InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
|
Some("hash-key-1".to_string()),
|
|
sample_admin_api_key_snapshot("user-1", "key-1"),
|
|
)])
|
|
.with_export_records(vec![StoredAuthApiKeyExportRecord::new(
|
|
"user-1".to_string(),
|
|
"key-1".to_string(),
|
|
"hash-key-1".to_string(),
|
|
Some(encrypted),
|
|
Some("default".to_string()),
|
|
Some(json!(["openai"])),
|
|
Some(json!(["openai:chat"])),
|
|
Some(json!(["gpt-4.1"])),
|
|
Some(60),
|
|
Some(5),
|
|
None,
|
|
true,
|
|
Some(200),
|
|
false,
|
|
9,
|
|
0,
|
|
1.5,
|
|
false,
|
|
)
|
|
.expect("export record should build")
|
|
.with_activity_timestamps(
|
|
Some(1_711_000_102),
|
|
Some(1_711_000_100),
|
|
Some(1_711_000_101),
|
|
)
|
|
.expect("export activity timestamps should build")]),
|
|
);
|
|
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
|
sample_admin_user("user-1"),
|
|
]));
|
|
|
|
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(
|
|
crate::data::GatewayDataState::with_auth_api_key_repository_for_tests(
|
|
auth_repository,
|
|
)
|
|
.with_user_reader(user_repository),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let client = reqwest::Client::new();
|
|
|
|
let create_response = client
|
|
.post(format!("{gateway_url}/api/admin/users/user-1/api-keys"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"name": "new-key",
|
|
"rate_limit": 90,
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(create_response.status(), StatusCode::OK);
|
|
let create_payload: serde_json::Value = create_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(create_payload["name"], "new-key");
|
|
assert_eq!(create_payload["rate_limit"], 90);
|
|
assert_eq!(create_payload["concurrent_limit"], serde_json::Value::Null);
|
|
assert_eq!(
|
|
create_payload["message"],
|
|
"API Key创建成功,请妥善保存完整密钥"
|
|
);
|
|
let created_at = create_payload["created_at"]
|
|
.as_str()
|
|
.expect("created_at should be string");
|
|
assert!(chrono::DateTime::parse_from_rfc3339(created_at).is_ok());
|
|
assert!(!created_at.starts_with("1970-01-01"));
|
|
assert!(create_payload["id"]
|
|
.as_str()
|
|
.is_some_and(|value| !value.is_empty()));
|
|
assert!(create_payload["key"]
|
|
.as_str()
|
|
.is_some_and(|value| value.starts_with("sk-")));
|
|
|
|
let update_response = client
|
|
.put(format!(
|
|
"{gateway_url}/api/admin/users/user-1/api-keys/key-1"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"name": "renamed",
|
|
"rate_limit": 120,
|
|
"concurrent_limit": 9,
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(update_response.status(), StatusCode::OK);
|
|
let update_payload: serde_json::Value = update_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(update_payload["id"], "key-1");
|
|
assert_eq!(update_payload["name"], "renamed");
|
|
assert_eq!(update_payload["is_locked"], false);
|
|
assert_eq!(update_payload["rate_limit"], 120);
|
|
assert_eq!(update_payload["concurrent_limit"], 9);
|
|
assert_eq!(update_payload["created_at"], "2024-03-21T05:48:20+00:00");
|
|
assert_eq!(update_payload["message"], "API Key更新成功");
|
|
|
|
let lock_response = client
|
|
.patch(format!(
|
|
"{gateway_url}/api/admin/users/user-1/api-keys/key-1/lock"
|
|
))
|
|
.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!({ "locked": true }))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(lock_response.status(), StatusCode::OK);
|
|
let lock_payload: serde_json::Value =
|
|
lock_response.json().await.expect("json body should parse");
|
|
assert_eq!(lock_payload["id"], "key-1");
|
|
assert_eq!(lock_payload["is_locked"], true);
|
|
assert_eq!(lock_payload["message"], "API密钥已锁定");
|
|
|
|
let delete_response = client
|
|
.delete(format!(
|
|
"{gateway_url}/api/admin/users/user-1/api-keys/key-1"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(delete_response.status(), StatusCode::OK);
|
|
let delete_payload: serde_json::Value = delete_response
|
|
.json()
|
|
.await
|
|
.expect("json body should parse");
|
|
assert_eq!(delete_payload["message"], "API Key已删除");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn admin_created_user_key_inherits_target_user_policy_not_admin_policy() {
|
|
let mut admin_snapshot = sample_admin_api_key_snapshot("admin-user", "admin-seed-key");
|
|
admin_snapshot.user_role = "admin".to_string();
|
|
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![
|
|
(Some(hash_api_key("sk-admin-seed")), admin_snapshot),
|
|
(
|
|
Some(hash_api_key("sk-target-seed")),
|
|
sample_admin_api_key_snapshot("target-user", "target-seed-key"),
|
|
),
|
|
]));
|
|
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
|
sample_admin_user_with_role("admin-user", "admin", "admin@example.com", "admin"),
|
|
sample_admin_user_with_role("target-user", "user", "target@example.com", "target"),
|
|
]));
|
|
|
|
let admin_group = user_repository
|
|
.create_user_group(UpsertUserGroupRecord {
|
|
name: "Admin OpenAI".to_string(),
|
|
description: None,
|
|
priority: 10,
|
|
allowed_providers: Some(vec!["openai".to_string()]),
|
|
allowed_providers_mode: "specific".to_string(),
|
|
allowed_api_formats: Some(vec!["openai:responses".to_string()]),
|
|
allowed_api_formats_mode: "specific".to_string(),
|
|
allowed_models: Some(vec!["gpt-5.4".to_string()]),
|
|
allowed_models_mode: "specific".to_string(),
|
|
rate_limit: Some(100),
|
|
rate_limit_mode: "custom".to_string(),
|
|
})
|
|
.await
|
|
.expect("admin group should create")
|
|
.expect("admin group should exist");
|
|
user_repository
|
|
.add_user_to_group(&admin_group.id, "admin-user")
|
|
.await
|
|
.expect("admin group membership should create");
|
|
|
|
let target_group = user_repository
|
|
.create_user_group(UpsertUserGroupRecord {
|
|
name: "Target Claude".to_string(),
|
|
description: None,
|
|
priority: 10,
|
|
allowed_providers: Some(vec!["anthropic".to_string()]),
|
|
allowed_providers_mode: "specific".to_string(),
|
|
allowed_api_formats: Some(vec!["claude:messages".to_string()]),
|
|
allowed_api_formats_mode: "specific".to_string(),
|
|
allowed_models: Some(vec!["claude-sonnet-4-5".to_string()]),
|
|
allowed_models_mode: "specific".to_string(),
|
|
rate_limit: Some(30),
|
|
rate_limit_mode: "custom".to_string(),
|
|
})
|
|
.await
|
|
.expect("target group should create")
|
|
.expect("target group should exist");
|
|
user_repository
|
|
.add_user_to_group(&target_group.id, "target-user")
|
|
.await
|
|
.expect("target group membership should create");
|
|
user_repository
|
|
.update_user_feature_settings(
|
|
"admin-user",
|
|
Some(json!({"chat_pii_redaction": {"enabled": false}})),
|
|
)
|
|
.await
|
|
.expect("admin feature settings should update");
|
|
user_repository
|
|
.update_user_feature_settings(
|
|
"target-user",
|
|
Some(json!({"chat_pii_redaction": {"enabled": true}})),
|
|
)
|
|
.await
|
|
.expect("target feature settings should update");
|
|
|
|
let data_state = GatewayDataState::with_auth_api_key_repository_for_tests(auth_repository)
|
|
.with_user_reader(user_repository);
|
|
let app_state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(data_state);
|
|
let inspection_state = app_state.clone();
|
|
let gateway = build_router_with_state(app_state);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{gateway_url}/api/admin/users/target-user/api-keys"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "admin-session")
|
|
.json(&json!({"name": "target-key"}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("response should parse");
|
|
let plaintext_key = payload["key"].as_str().expect("plaintext key should exist");
|
|
let api_key_id = payload["id"].as_str().expect("key id should exist");
|
|
|
|
let resolved = inspection_state
|
|
.read_cached_auth_api_key_snapshot_by_key_hash(
|
|
&hash_api_key(plaintext_key),
|
|
chrono::Utc::now().timestamp().max(0) as u64,
|
|
)
|
|
.await
|
|
.expect("created key snapshot should resolve")
|
|
.expect("created key snapshot should exist");
|
|
assert_eq!(resolved.user_id, "target-user");
|
|
assert_eq!(
|
|
resolved.effective_allowed_providers(),
|
|
Some(&["anthropic".to_string()][..])
|
|
);
|
|
assert_eq!(
|
|
resolved.effective_allowed_api_formats(),
|
|
Some(&["claude:messages".to_string()][..])
|
|
);
|
|
assert_eq!(
|
|
resolved.effective_allowed_models(),
|
|
Some(&["claude-sonnet-4-5".to_string()][..])
|
|
);
|
|
assert_eq!(resolved.user_rate_limit, Some(30));
|
|
|
|
inspection_state
|
|
.update_user_group(
|
|
&target_group.id,
|
|
UpsertUserGroupRecord {
|
|
name: "Target Gemini".to_string(),
|
|
description: None,
|
|
priority: 10,
|
|
allowed_providers: Some(vec!["google".to_string()]),
|
|
allowed_providers_mode: "specific".to_string(),
|
|
allowed_api_formats: Some(vec!["gemini:generate-content".to_string()]),
|
|
allowed_api_formats_mode: "specific".to_string(),
|
|
allowed_models: Some(vec!["gemini-2.5-pro".to_string()]),
|
|
allowed_models_mode: "specific".to_string(),
|
|
rate_limit: Some(15),
|
|
rate_limit_mode: "custom".to_string(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("target group should update")
|
|
.expect("target group should still exist");
|
|
let updated = inspection_state
|
|
.read_cached_auth_api_key_snapshot_by_key_hash(
|
|
&hash_api_key(plaintext_key),
|
|
chrono::Utc::now().timestamp().max(0) as u64,
|
|
)
|
|
.await
|
|
.expect("created key snapshot should refresh")
|
|
.expect("created key snapshot should still exist");
|
|
assert_eq!(updated.user_id, "target-user");
|
|
assert_eq!(
|
|
updated.effective_allowed_providers(),
|
|
Some(&["google".to_string()][..])
|
|
);
|
|
assert_eq!(
|
|
updated.effective_allowed_api_formats(),
|
|
Some(&["gemini:generate-content".to_string()][..])
|
|
);
|
|
assert_eq!(
|
|
updated.effective_allowed_models(),
|
|
Some(&["gemini-2.5-pro".to_string()][..])
|
|
);
|
|
assert_eq!(updated.user_rate_limit, Some(15));
|
|
|
|
let target_features = inspection_state
|
|
.read_user_feature_settings("target-user")
|
|
.await
|
|
.expect("target feature settings should resolve");
|
|
let key_features = inspection_state
|
|
.read_auth_api_key_feature_settings("target-user", api_key_id, false)
|
|
.await
|
|
.expect("key feature settings should resolve");
|
|
assert_eq!(
|
|
target_features,
|
|
Some(json!({"chat_pii_redaction": {"enabled": true}}))
|
|
);
|
|
assert_eq!(
|
|
key_features, None,
|
|
"an omitted key override must inherit target settings"
|
|
);
|
|
|
|
gateway_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_returns_conflict_for_admin_create_user_api_key_when_writer_unavailable() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let encrypted = encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "sk-user-1")
|
|
.expect("ciphertext should build");
|
|
let auth_repository = Arc::new(
|
|
InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
|
Some("hash-key-1".to_string()),
|
|
sample_admin_api_key_snapshot("user-1", "key-1"),
|
|
)])
|
|
.with_export_records(vec![StoredAuthApiKeyExportRecord::new(
|
|
"user-1".to_string(),
|
|
"key-1".to_string(),
|
|
"hash-key-1".to_string(),
|
|
Some(encrypted),
|
|
Some("default".to_string()),
|
|
Some(json!(["openai"])),
|
|
Some(json!(["openai:chat"])),
|
|
Some(json!(["gpt-4.1"])),
|
|
Some(60),
|
|
Some(5),
|
|
None,
|
|
true,
|
|
Some(200),
|
|
false,
|
|
9,
|
|
0,
|
|
1.5,
|
|
false,
|
|
)
|
|
.expect("export record should build")]),
|
|
);
|
|
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
|
sample_admin_user("user-1"),
|
|
]));
|
|
|
|
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(
|
|
crate::data::GatewayDataState::with_auth_api_key_reader_for_tests(auth_repository)
|
|
.with_user_reader(user_repository),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!("{gateway_url}/api/admin/users/user-1/api-keys"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({
|
|
"name": "new-key",
|
|
"rate_limit": 90,
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["error_code"], "read_only_mode");
|
|
assert_eq!(payload["detail"], "当前为只读模式,无法创建用户 API Key");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_returns_conflict_for_admin_create_user_when_writer_unavailable() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let mut state = AppState::new().expect("gateway should build");
|
|
state =
|
|
state.with_data_state_for_tests(crate::data::GatewayDataState::with_user_reader_for_tests(
|
|
Arc::new(InMemoryUserReadRepository::seed_auth_users(Vec::new()).read_only()),
|
|
));
|
|
state.auth_user_store = None;
|
|
state.auth_wallet_store = None;
|
|
let gateway = build_router_with_state(state);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!("{gateway_url}/api/admin/users"))
|
|
.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!({
|
|
"email": "new-user@example.com",
|
|
"username": "new_user",
|
|
"password": "NewUser123!",
|
|
"role": "user"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["error_code"], "read_only_mode");
|
|
assert_eq!(payload["detail"], "当前为只读模式,无法创建用户");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_returns_conflict_for_admin_lock_user_api_key_when_writer_unavailable() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let encrypted = encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "sk-user-1")
|
|
.expect("ciphertext should build");
|
|
let auth_repository = Arc::new(
|
|
InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
|
Some("hash-key-1".to_string()),
|
|
sample_admin_api_key_snapshot("user-1", "key-1"),
|
|
)])
|
|
.with_export_records(vec![StoredAuthApiKeyExportRecord::new(
|
|
"user-1".to_string(),
|
|
"key-1".to_string(),
|
|
"hash-key-1".to_string(),
|
|
Some(encrypted),
|
|
Some("default".to_string()),
|
|
Some(json!(["openai"])),
|
|
Some(json!(["openai:chat"])),
|
|
Some(json!(["gpt-4.1"])),
|
|
Some(60),
|
|
Some(5),
|
|
None,
|
|
true,
|
|
Some(200),
|
|
false,
|
|
9,
|
|
0,
|
|
1.5,
|
|
false,
|
|
)
|
|
.expect("export record should build")]),
|
|
);
|
|
|
|
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(
|
|
crate::data::GatewayDataState::with_auth_api_key_reader_for_tests(auth_repository),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.patch(format!(
|
|
"{gateway_url}/api/admin/users/user-1/api-keys/key-1/lock"
|
|
))
|
|
.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!({ "locked": true }))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["error_code"], "read_only_mode");
|
|
assert_eq!(
|
|
payload["detail"],
|
|
"当前为只读模式,无法锁定或解锁用户 API Key"
|
|
);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_allows_admin_update_user_to_clear_explicit_groups() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let user_repository = Arc::new(
|
|
InMemoryUserReadRepository::seed_auth_users(vec![sample_admin_user("user-1")])
|
|
.with_export_users(vec![sample_admin_export_user("user-1")]),
|
|
);
|
|
let default_group = user_repository
|
|
.create_user_group(UpsertUserGroupRecord {
|
|
name: "GPT Adapt".to_string(),
|
|
description: None,
|
|
priority: 0,
|
|
allowed_providers: None,
|
|
allowed_providers_mode: "unrestricted".to_string(),
|
|
allowed_api_formats: None,
|
|
allowed_api_formats_mode: "unrestricted".to_string(),
|
|
allowed_models: None,
|
|
allowed_models_mode: "unrestricted".to_string(),
|
|
rate_limit: None,
|
|
rate_limit_mode: "system".to_string(),
|
|
})
|
|
.await
|
|
.expect("default group should create")
|
|
.expect("default group should exist");
|
|
user_repository
|
|
.add_user_to_group(&default_group.id, "user-1")
|
|
.await
|
|
.expect("default membership should create");
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let gateway = build_router_with_state(
|
|
AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_user_reader_for_tests(user_repository.clone())
|
|
.with_system_config_values_for_tests(vec![(
|
|
crate::constants::DEFAULT_USER_GROUP_CONFIG_KEY.to_string(),
|
|
json!(default_group.id),
|
|
)]),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{gateway_url}/api/admin/users/user-1"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({ "group_ids": [] }))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["groups"], json!([]));
|
|
assert!(user_repository
|
|
.list_user_groups_for_user("user-1")
|
|
.await
|
|
.expect("memberships should load")
|
|
.is_empty());
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_returns_conflict_for_admin_update_user_when_writer_unavailable() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let user_repository = Arc::new(
|
|
InMemoryUserReadRepository::seed_auth_users(vec![sample_admin_user("user-1")]).read_only(),
|
|
);
|
|
|
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
|
let mut state = AppState::new()
|
|
.expect("gateway should build")
|
|
.with_data_state_for_tests(crate::data::GatewayDataState::with_user_reader_for_tests(
|
|
user_repository,
|
|
));
|
|
state.auth_user_store = None;
|
|
state.auth_wallet_store = None;
|
|
let gateway = build_router_with_state(state);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{gateway_url}/api/admin/users/user-1"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({ "email": "alice-updated@example.com" }))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["error_code"], "read_only_mode");
|
|
assert_eq!(payload["detail"], "当前为只读模式,无法更新用户");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_returns_conflict_for_admin_update_user_api_key_when_writer_unavailable() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let encrypted = encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "sk-user-1")
|
|
.expect("ciphertext should build");
|
|
let auth_repository = Arc::new(
|
|
InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
|
Some("hash-key-1".to_string()),
|
|
sample_admin_api_key_snapshot("user-1", "key-1"),
|
|
)])
|
|
.with_export_records(vec![StoredAuthApiKeyExportRecord::new(
|
|
"user-1".to_string(),
|
|
"key-1".to_string(),
|
|
"hash-key-1".to_string(),
|
|
Some(encrypted),
|
|
Some("default".to_string()),
|
|
Some(json!(["openai"])),
|
|
Some(json!(["openai:chat"])),
|
|
Some(json!(["gpt-4.1"])),
|
|
Some(60),
|
|
Some(5),
|
|
None,
|
|
true,
|
|
Some(200),
|
|
false,
|
|
9,
|
|
0,
|
|
1.5,
|
|
false,
|
|
)
|
|
.expect("export record should build")]),
|
|
);
|
|
|
|
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(
|
|
crate::data::GatewayDataState::with_auth_api_key_reader_for_tests(auth_repository),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.put(format!(
|
|
"{gateway_url}/api/admin/users/user-1/api-keys/key-1"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.json(&json!({ "name": "renamed", "rate_limit": 88 }))
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["error_code"], "read_only_mode");
|
|
assert_eq!(payload["detail"], "当前为只读模式,无法更新用户 API Key");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_returns_conflict_for_admin_delete_user_api_key_when_writer_unavailable() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().fallback(any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}));
|
|
|
|
let encrypted = encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "sk-user-1")
|
|
.expect("ciphertext should build");
|
|
let auth_repository = Arc::new(
|
|
InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
|
Some("hash-key-1".to_string()),
|
|
sample_admin_api_key_snapshot("user-1", "key-1"),
|
|
)])
|
|
.with_export_records(vec![StoredAuthApiKeyExportRecord::new(
|
|
"user-1".to_string(),
|
|
"key-1".to_string(),
|
|
"hash-key-1".to_string(),
|
|
Some(encrypted),
|
|
Some("default".to_string()),
|
|
Some(json!(["openai"])),
|
|
Some(json!(["openai:chat"])),
|
|
Some(json!(["gpt-4.1"])),
|
|
Some(60),
|
|
Some(5),
|
|
None,
|
|
true,
|
|
Some(200),
|
|
false,
|
|
9,
|
|
0,
|
|
1.5,
|
|
false,
|
|
)
|
|
.expect("export record should build")]),
|
|
);
|
|
|
|
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(
|
|
crate::data::GatewayDataState::with_auth_api_key_reader_for_tests(auth_repository),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.delete(format!(
|
|
"{gateway_url}/api/admin/users/user-1/api-keys/key-1"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["error_code"], "read_only_mode");
|
|
assert_eq!(payload["detail"], "当前为只读模式,无法删除用户 API Key");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_lists_admin_user_api_keys_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/users/user-1/api-keys",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let encrypted = encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "sk-user-1")
|
|
.expect("ciphertext should build");
|
|
let auth_repository = Arc::new(
|
|
InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
|
Some("hash-key-1".to_string()),
|
|
sample_admin_api_key_snapshot("user-1", "key-1"),
|
|
)])
|
|
.with_export_records(vec![StoredAuthApiKeyExportRecord::new(
|
|
"user-1".to_string(),
|
|
"key-1".to_string(),
|
|
"hash-key-1".to_string(),
|
|
Some(encrypted),
|
|
Some("default".to_string()),
|
|
Some(json!(["openai"])),
|
|
Some(json!(["openai:chat"])),
|
|
Some(json!(["gpt-4.1"])),
|
|
Some(60),
|
|
Some(5),
|
|
None,
|
|
true,
|
|
Some(200),
|
|
false,
|
|
9,
|
|
0,
|
|
1.5,
|
|
false,
|
|
)
|
|
.expect("export record should build")
|
|
.with_activity_timestamps(
|
|
Some(1_711_000_102),
|
|
Some(1_711_000_100),
|
|
Some(1_711_000_101),
|
|
)
|
|
.expect("export activity timestamps should build")]),
|
|
);
|
|
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
|
sample_admin_user("user-1"),
|
|
]));
|
|
|
|
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(
|
|
crate::data::GatewayDataState::with_user_reader_for_tests(user_repository)
|
|
.with_auth_api_key_reader(auth_repository),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!("{gateway_url}/api/admin/users/user-1/api-keys"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["total"], 1);
|
|
assert_eq!(payload["user_email"], "alice@example.com");
|
|
assert_eq!(payload["username"], "alice");
|
|
assert_eq!(payload["api_keys"][0]["id"], "key-1");
|
|
assert_eq!(payload["api_keys"][0]["name"], "default");
|
|
assert_eq!(payload["api_keys"][0]["key_display"], "sk-user-1...er-1");
|
|
assert_eq!(payload["api_keys"][0]["is_active"], true);
|
|
assert_eq!(payload["api_keys"][0]["is_locked"], false);
|
|
assert_eq!(payload["api_keys"][0]["total_requests"], 9);
|
|
assert_eq!(payload["api_keys"][0]["total_cost_usd"], 1.5);
|
|
assert_eq!(payload["api_keys"][0]["rate_limit"], 60);
|
|
assert_eq!(
|
|
payload["api_keys"][0]["created_at"],
|
|
"2024-03-21T05:48:20+00:00"
|
|
);
|
|
assert_eq!(
|
|
payload["api_keys"][0]["last_used_at"],
|
|
"2024-03-21T05:48:22+00:00"
|
|
);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_revokes_admin_user_session_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/users/user-1/sessions/session-1",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
|
sample_admin_user("user-1"),
|
|
]));
|
|
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(crate::data::GatewayDataState::with_user_reader_for_tests(
|
|
user_repository,
|
|
))
|
|
.with_auth_session_for_tests(sample_admin_session("user-1", "session-1")),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.delete(format!(
|
|
"{gateway_url}/api/admin/users/user-1/sessions/session-1"
|
|
))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["message"], "用户设备已强制下线");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_revokes_all_admin_user_sessions_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/users/user-1/sessions",
|
|
any(move |_request: Request| {
|
|
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
|
async move {
|
|
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
|
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
|
}
|
|
}),
|
|
);
|
|
|
|
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
|
sample_admin_user("user-1"),
|
|
]));
|
|
let sessions = vec![
|
|
sample_admin_session("user-1", "session-1"),
|
|
sample_admin_session("user-1", "session-2"),
|
|
];
|
|
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(crate::data::GatewayDataState::with_user_reader_for_tests(
|
|
user_repository,
|
|
))
|
|
.with_auth_sessions_for_tests(sessions),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.delete(format!("{gateway_url}/api/admin/users/user-1/sessions"))
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
|
.send()
|
|
.await
|
|
.expect("request should succeed");
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
|
assert_eq!(payload["message"], "已强制下线该用户所有设备");
|
|
assert_eq!(payload["revoked_count"], 2);
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn gateway_reveals_admin_user_full_key_locally_with_trusted_admin_principal() {
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
|
let upstream = Router::new().route(
|
|
"/api/admin/users/user-1/api-keys/key-1/full-key",
|
|
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 encrypted = encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "sk-user-1")
|
|
.expect("ciphertext should build");
|
|
let auth_repository = Arc::new(
|
|
InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
|
Some("hash-key-1".to_string()),
|
|
sample_admin_api_key_snapshot("user-1", "key-1"),
|
|
)])
|
|
.with_export_records(vec![StoredAuthApiKeyExportRecord::new(
|
|
"user-1".to_string(),
|
|
"key-1".to_string(),
|
|
"hash-key-1".to_string(),
|
|
Some(encrypted),
|
|
Some("default".to_string()),
|
|
Some(json!(["openai"])),
|
|
Some(json!(["openai:chat"])),
|
|
Some(json!(["gpt-4.1"])),
|
|
Some(60),
|
|
Some(5),
|
|
None,
|
|
true,
|
|
Some(200),
|
|
false,
|
|
9,
|
|
0,
|
|
1.5,
|
|
false,
|
|
)
|
|
.expect("export record should build")]),
|
|
);
|
|
|
|
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(
|
|
crate::data::GatewayDataState::with_auth_api_key_reader_for_tests(auth_repository),
|
|
),
|
|
);
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.get(format!(
|
|
"{gateway_url}/api/admin/users/user-1/api-keys/key-1/full-key"
|
|
))
|
|
.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["key"], "sk-user-1");
|
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
|
|
|
gateway_handle.abort();
|
|
upstream_handle.abort();
|
|
}
|