mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 03:09:50 +08:00
2966 lines
112 KiB
Rust
2966 lines
112 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::management_tokens::InMemoryManagementTokenRepository;
|
|||
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, hash_management_token, issue_test_admin_access_token,
|
|||
|
|
sample_management_token, start_server, AppState,
|
||
};
|
|||
|
|
use crate::constants::{
|
||
GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
|
|||
|
|
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", "[email protected]", "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, "[email protected]", "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("[email protected]".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")
|
||
|
|
}
|
||
|
|
|
||
/// Build an auth API-key fixture with the provider/user-bound envelope used by
|
|||
|
|
/// production writes. Read-only test repositories cannot perform the legacy
|
||
|
|
/// Fernet migration, so ordinary list/reveal fixtures must use this format.
|
||
|
|
fn sample_bound_admin_api_key_secret(
|
||
|
|
user_id: &str,
|
||
|
|
api_key_id: &str,
|
||
|
|
plaintext: &str,
|
||
|
|
) -> (String, String) {
|
||
|
|
let key_hash = hash_api_key(plaintext);
|
||
|
|
let state = AppState::new()
|
||
|
|
.expect("bootstrap state should build")
|
||
|
|
.with_data_state_for_tests(
|
||
|
|
GatewayDataState::disabled().with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||
|
|
);
|
||
|
|
let encrypted = crate::handlers::shared::seal_auth_api_key_secret(
|
||
|
|
&state, user_id, api_key_id, &key_hash, false, plaintext,
|
||
|
|
)
|
||
|
|
.expect("bound API-key ciphertext should build");
|
||
|
|
(key_hash, encrypted)
|
||
|
|
}
|
||
|
|
|
||
#[tokio::test]
|
|||
|
|
async fn gateway_sorts_admin_users_by_created_at() {
|
||
|
|
let oldest = Utc
|
||
|
|
.with_ymd_and_hms(2026, 1, 10, 0, 0, 0)
|
||
|
|
.single()
|
||
|
|
.expect("valid timestamp");
|
||
|
|
let middle = Utc
|
||
|
|
.with_ymd_and_hms(2026, 2, 10, 0, 0, 0)
|
||
|
|
.single()
|
||
|
|
.expect("valid timestamp");
|
||
|
|
let newest = Utc
|
||
|
|
.with_ymd_and_hms(2026, 3, 10, 0, 0, 0)
|
||
|
|
.single()
|
||
|
|
.expect("valid timestamp");
|
||
|
|
|
||
|
|
let user_repository = Arc::new(
|
||
|
|
InMemoryUserReadRepository::seed_auth_users(vec![
|
||
|
|
sample_admin_user_with_role_and_created_at(
|
||
|
|
"user-old",
|
||
|
|
"user",
|
||
|
|
"[email protected]",
|
||
|
|
"old",
|
||
|
|
oldest,
|
||
|
|
),
|
||
|
|
sample_admin_user_with_role_and_created_at(
|
||
|
|
"user-middle",
|
||
|
|
"user",
|
||
|
|
"[email protected]",
|
||
|
|
"middle",
|
||
|
|
middle,
|
||
|
|
),
|
||
|
|
sample_admin_user_with_role_and_created_at(
|
||
|
|
"user-new",
|
||
|
|
"user",
|
||
|
|
"[email protected]",
|
||
|
|
"new",
|
||
|
|
newest,
|
||
|
|
),
|
||
|
|
])
|
||
|
|
.with_export_users(vec![
|
||
|
|
sample_admin_export_user_with("user", true, "user-old", "[email protected]", "old"),
|
||
|
|
sample_admin_export_user_with(
|
||
|
|
"user",
|
||
|
|
true,
|
||
|
|
"user-middle",
|
||
|
|
"[email protected]",
|
||
|
|
"middle",
|
||
|
|
),
|
||
|
|
sample_admin_export_user_with("user", true, "user-new", "[email protected]", "new"),
|
||
|
|
]),
|
||
|
|
);
|
||
|
|
|
||
|
|
let gateway = build_router_with_state(
|
||
|
|
AppState::new()
|
||
|
|
.expect("gateway should build")
|
||
|
|
.with_data_state_for_tests(GatewayDataState::with_user_reader_for_tests(
|
||
|
|
user_repository,
|
||
|
|
)),
|
||
|
|
);
|
||
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||
|
|
let client = reqwest::Client::new();
|
||
|
|
|
||
|
|
let desc_response = client
|
||
|
|
.get(format!(
|
||
|
|
"{gateway_url}/api/admin/users?skip=0&limit=10&sort_by=created_at&sort_order=desc"
|
||
|
|
))
|
||
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||
|
|
.send()
|
||
|
|
.await
|
||
|
|
.expect("request should succeed");
|
||
|
|
assert_eq!(desc_response.status(), StatusCode::OK);
|
||
|
|
let desc_payload: serde_json::Value = desc_response.json().await.expect("json should parse");
|
||
|
|
let desc_ids = desc_payload["items"]
|
||
|
|
.as_array()
|
||
|
|
.expect("items should be array")
|
||
|
|
.iter()
|
||
|
|
.map(|item| item["id"].as_str().expect("id should be string"))
|
||
|
|
.collect::<Vec<_>>();
|
||
|
|
assert_eq!(desc_ids, vec!["user-new", "user-middle", "user-old"]);
|
||
|
|
|
||
|
|
let asc_response = client
|
||
|
|
.get(format!(
|
||
|
|
"{gateway_url}/api/admin/users?skip=0&limit=10&sort_by=created_at&sort_order=asc"
|
||
|
|
))
|
||
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||
|
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||
|
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||
|
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||
|
|
.send()
|
||
|
|
.await
|
||
|
|
.expect("request should succeed");
|
||
|
|
assert_eq!(asc_response.status(), StatusCode::OK);
|
||
|
|
let asc_payload: serde_json::Value = asc_response.json().await.expect("json should parse");
|
||
|
|
let asc_ids = asc_payload["items"]
|
||
|
|
.as_array()
|
||
|
|
.expect("items should be array")
|
||
|
|
.iter()
|
||
|
|
.map(|item| item["id"].as_str().expect("id should be string"))
|
||
|
|
.collect::<Vec<_>>();
|
||
|
|
assert_eq!(asc_ids, vec!["user-old", "user-middle", "user-new"]);
|
||
|
|
|
||
|
|
gateway_handle.abort();
|
||
|
|
}
|
||
|
|
|
||
#[tokio::test]
|
|||
|
|
async fn gateway_handles_admin_users_root_locally_with_trusted_admin_principal() {
|
||
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||
|
|
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", "[email protected]", "root"),
|
||
|
|
sample_admin_user_with_role("user-3", "user", "[email protected]", "carol"),
|
||
|
|
])
|
||
|
|
.with_export_users(vec![
|
||
|
|
sample_admin_export_user("user-1"),
|
||
|
|
sample_admin_export_user_with("admin", true, "user-2", "[email protected]", "root"),
|
||
|
|
sample_admin_export_user_with("user", false, "user-3", "[email protected]", "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"], "[email protected]");
|
||
|
|
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"], "[email protected]");
|
||
|
|
|
||
|
|
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": "[email protected]",
|
||
|
|
"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"], "[email protected]");
|
||
|
|
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": "[email protected]",
|
||
|
|
"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", "[email protected]", "admin"),
|
|||
sample_admin_user_with_role("user-2", "user", "[email protected]", "bob"),
|
|||
|
|
])
|
||
|
|
.with_export_users(vec![
|
||
sample_admin_export_user_with("admin", true, "admin-1", "[email protected]", "admin"),
|
|||
sample_admin_export_user_with("user", true, "user-2", "[email protected]", "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", "[email protected]", "admin"),
|
||
|
|
sample_admin_user_with_role("user-2", "user", "[email protected]", "bob"),
|
||
|
|
sample_admin_user_with_role("user-3", "user", "[email protected]", "carol"),
|
||
|
|
])
|
||
|
|
.with_export_users(vec![
|
||
|
|
sample_admin_export_user_with("admin", true, "admin-1", "[email protected]", "admin"),
|
||
|
|
sample_admin_export_user_with("user", true, "user-2", "[email protected]", "bob"),
|
||
|
|
sample_admin_export_user_with("user", true, "user-3", "[email protected]", "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", "[email protected]", "root"),
|
||
|
|
sample_admin_user_with_role("user-3", "user", "[email protected]", "carol"),
|
||
|
|
])
|
||
|
|
.with_export_users(vec![
|
||
|
|
sample_admin_export_user("user-1"),
|
||
|
|
sample_admin_export_user_with("admin", true, "user-2", "[email protected]", "root"),
|
||
|
|
sample_admin_export_user_with("user", false, "user-3", "[email protected]", "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"], "[email protected]");
|
||
|
|
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"], "[email protected]");
|
||
|
|
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_rejects_users_write_management_token_for_administrator_account_mutations() {
|
||
|
|
let raw_token = "ae-users-write-cannot-escalate";
|
||
|
|
let token_owner = sample_admin_user_with_role(
|
||
|
|
"token-owner",
|
||
|
|
"admin",
|
||
|
|
"[email protected]",
|
||
|
|
"token_owner",
|
||
|
|
);
|
||
|
|
let target_user =
|
||
|
|
sample_admin_user_with_role("target-user", "user", "[email protected]", "target_user");
|
||
|
|
let target_admin = sample_admin_user_with_role(
|
||
|
|
"target-admin",
|
||
|
|
"admin",
|
||
|
|
"[email protected]",
|
||
|
|
"target_admin",
|
||
|
|
);
|
||
|
|
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
||
|
|
token_owner.clone(),
|
||
|
|
target_user.clone(),
|
||
|
|
target_admin.clone(),
|
||
|
|
]));
|
||
|
|
let mut token = sample_management_token(
|
||
|
|
"token-users-write-cannot-escalate",
|
||
|
|
&token_owner.id,
|
||
|
|
&token_owner.username,
|
||
|
|
true,
|
||
|
|
);
|
||
|
|
token.token.allowed_ips = None;
|
||
|
|
token.token.permissions = Some(json!(["admin:users:write"]));
|
||
|
|
let token_repository = Arc::new(InMemoryManagementTokenRepository::seed_with_hashes(
|
||
|
|
vec![token],
|
||
|
|
vec![(
|
||
|
|
hash_management_token(raw_token),
|
||
|
|
"token-users-write-cannot-escalate".to_string(),
|
||
|
|
)],
|
||
|
|
));
|
||
|
|
let data = GatewayDataState::with_management_token_repository_for_tests(token_repository)
|
||
|
|
.with_user_reader(user_repository.clone());
|
||
|
|
let state = AppState::new()
|
||
|
|
.expect("gateway should build")
|
||
|
|
.with_data_state_for_tests(data)
|
||
|
|
.with_auth_users_for_tests([
|
||
|
|
token_owner.clone(),
|
||
|
|
target_user.clone(),
|
||
|
|
target_admin.clone(),
|
||
|
|
]);
|
||
|
|
let inspection_state = state.clone();
|
||
|
|
let gateway = build_router_with_state(state);
|
||
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||
|
|
let client = reqwest::Client::new();
|
||
|
|
let cases = [
|
||
|
|
(
|
||
|
|
reqwest::Method::POST,
|
||
|
|
"/api/admin/users",
|
||
|
|
json!({
|
||
|
|
"email": "[email protected]",
|
||
|
|
"username": "created_admin",
|
||
|
|
"password": "CreatedAdmin123!",
|
||
|
|
"role": "admin"
|
||
|
|
}),
|
||
|
|
"create_user",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
reqwest::Method::PUT,
|
||
|
|
"/api/admin/users/target-user",
|
||
|
|
json!({ "role": "admin" }),
|
||
|
|
"update_user",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
reqwest::Method::PUT,
|
||
|
|
"/api/admin/users/target-admin",
|
||
|
|
json!({ "password": "ResetAdmin123!" }),
|
||
|
|
"update_user",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
reqwest::Method::PUT,
|
||
|
|
"/api/admin/users/target-admin",
|
||
|
|
json!({ "role": "user" }),
|
||
|
|
"update_user",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
reqwest::Method::PUT,
|
||
|
|
"/api/admin/users/target-admin",
|
||
|
|
json!({ "is_active": false }),
|
||
|
|
"update_user",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
reqwest::Method::PUT,
|
||
|
|
"/api/admin/users/target-admin",
|
||
|
|
json!({ "email": "[email protected]" }),
|
||
|
|
"update_user",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
reqwest::Method::PUT,
|
||
|
|
"/api/admin/users/target-admin",
|
||
|
|
json!({ "group_ids": [] }),
|
||
|
|
"update_user",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
reqwest::Method::PUT,
|
||
|
|
"/api/admin/users/target-admin",
|
||
|
|
json!({ "unlimited": true }),
|
||
|
|
"update_user",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
reqwest::Method::DELETE,
|
||
|
|
"/api/admin/users/target-admin",
|
||
|
|
json!({}),
|
||
|
|
"delete_user",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
reqwest::Method::POST,
|
||
|
|
"/api/admin/users/batch-action",
|
||
|
|
json!({
|
||
|
|
"selection": { "user_ids": ["target-user"] },
|
||
|
|
"action": "update_role",
|
||
|
|
"payload": { "role": "admin" }
|
||
|
|
}),
|
||
|
|
"batch_action_users",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
reqwest::Method::POST,
|
||
|
|
"/api/admin/users/batch-action",
|
||
|
|
json!({
|
||
|
|
"selection": { "user_ids": ["target-admin"] },
|
||
|
|
"action": "update_role",
|
||
|
|
"payload": { "role": "user" }
|
||
|
|
}),
|
||
|
|
"batch_action_users",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
reqwest::Method::POST,
|
||
|
|
"/api/admin/users/batch-action",
|
||
|
|
json!({
|
||
|
|
"selection": { "user_ids": ["target-user", "target-admin"] },
|
||
|
|
"action": "disable"
|
||
|
|
}),
|
||
|
|
"batch_action_users",
|
||
|
|
),
|
||
|
|
];
|
||
|
|
|
||
|
|
for (method, path, body, route_kind) in cases {
|
||
|
|
let response = client
|
||
|
|
.request(method, format!("{gateway_url}{path}"))
|
||
|
|
.header(GATEWAY_HEADER, "rust-phase3b")
|
||
|
|
.bearer_auth(raw_token)
|
||
|
|
.json(&body)
|
||
|
|
.send()
|
||
|
|
.await
|
||
|
|
.expect("request should succeed");
|
||
|
|
|
||
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN, "path: {path}");
|
||
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||
|
|
assert_eq!(payload["detail"], "management token permission denied");
|
||
|
|
assert_eq!(payload["required_permission"], "admin:users:admin");
|
||
|
|
assert_eq!(payload["route_kind"], route_kind);
|
||
|
|
}
|
||
|
|
|
||
|
|
let ordinary_create_response = client
|
||
|
|
.post(format!("{gateway_url}/api/admin/users"))
|
||
|
|
.header(GATEWAY_HEADER, "rust-phase3b")
|
||
|
|
.bearer_auth(raw_token)
|
||
|
|
.json(&json!({
|
||
|
|
"email": "[email protected]",
|
||
|
|
"username": "delegated_user",
|
||
|
|
"password": "DelegatedUser123!",
|
||
|
|
"role": "user"
|
||
|
|
}))
|
||
|
|
.send()
|
||
|
|
.await
|
||
|
|
.expect("ordinary user creation request should succeed");
|
||
|
|
assert_ne!(ordinary_create_response.status(), StatusCode::FORBIDDEN);
|
||
|
|
|
||
|
|
let ordinary_update_response = client
|
||
|
|
.put(format!("{gateway_url}/api/admin/users/target-user"))
|
||
|
|
.header(GATEWAY_HEADER, "rust-phase3b")
|
||
|
|
.bearer_auth(raw_token)
|
||
|
|
.json(&json!({ "is_active": false }))
|
||
|
|
.send()
|
||
|
|
.await
|
||
|
|
.expect("ordinary user update request should succeed");
|
||
|
|
assert_eq!(ordinary_update_response.status(), StatusCode::OK);
|
||
|
|
|
||
|
|
assert!(inspection_state
|
||
|
|
.find_user_auth_by_identifier("created_admin")
|
||
|
|
.await
|
||
|
|
.expect("created user lookup should succeed")
|
||
|
|
.is_none());
|
||
|
|
assert_eq!(
|
||
|
|
inspection_state
|
||
|
|
.find_user_auth_by_id("target-user")
|
||
|
|
.await
|
||
|
|
.expect("target user lookup should succeed")
|
||
|
|
.expect("target user should still exist")
|
||
|
|
.is_active,
|
||
|
|
false
|
||
|
|
);
|
||
|
|
let stored_target_admin = inspection_state
|
||
|
|
.find_user_auth_by_id("target-admin")
|
||
|
|
.await
|
||
|
|
.expect("target admin lookup should succeed")
|
||
|
|
.expect("target admin should still exist");
|
||
|
|
assert_eq!(stored_target_admin.role, "admin");
|
||
|
|
assert!(stored_target_admin.is_active);
|
||
|
|
assert_eq!(
|
||
|
|
stored_target_admin.password_hash,
|
||
|
|
target_admin.password_hash
|
||
|
|
);
|
||
|
|
|
||
|
|
gateway_handle.abort();
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn gateway_requires_users_admin_to_revoke_privileged_user_sessions() {
|
||
|
|
for (role, suffix) in [("admin", "admin"), ("audit_admin", "audit-admin")] {
|
||
|
|
let raw_token = format!("ae-users-write-session-{suffix}");
|
||
|
|
let token_owner = sample_admin_user_with_role(
|
||
|
|
&format!("token-owner-{suffix}"),
|
||
|
|
"admin",
|
||
|
|
&format!("token-owner-{suffix}@example.com"),
|
||
|
|
&format!("token_owner_{suffix}"),
|
||
|
|
);
|
||
|
|
let target_id = format!("target-{suffix}");
|
||
|
|
let target = sample_admin_user_with_role(
|
||
|
|
&target_id,
|
||
|
|
role,
|
||
|
|
&format!("target-{suffix}@example.com"),
|
||
|
|
&format!("target_{suffix}"),
|
||
|
|
);
|
||
|
|
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
||
|
|
token_owner.clone(),
|
||
|
|
target.clone(),
|
||
|
|
]));
|
||
|
|
let token_id = format!("token-users-write-session-{suffix}");
|
||
|
|
let mut token =
|
||
|
|
sample_management_token(&token_id, &token_owner.id, &token_owner.username, true);
|
||
|
|
token.token.allowed_ips = None;
|
||
|
|
token.token.permissions = Some(json!(["admin:users:write"]));
|
||
|
|
let token_repository = Arc::new(InMemoryManagementTokenRepository::seed_with_hashes(
|
||
|
|
vec![token],
|
||
|
|
vec![(hash_management_token(&raw_token), token_id)],
|
||
|
|
));
|
||
|
|
let data = GatewayDataState::with_management_token_repository_for_tests(token_repository)
|
||
|
|
.with_user_reader(user_repository);
|
||
|
|
let state = AppState::new()
|
||
|
|
.expect("gateway should build")
|
||
|
|
.with_data_state_for_tests(data)
|
||
|
|
.with_auth_users_for_tests([token_owner, target])
|
||
|
|
.with_auth_session_for_tests(sample_admin_session(&target_id, "session-1"));
|
||
|
|
let gateway = build_router_with_state(state);
|
||
|
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||
|
|
let client = reqwest::Client::new();
|
||
|
|
|
||
|
|
for path in [
|
||
|
|
format!("/api/admin/users/{target_id}/sessions/session-1"),
|
||
|
|
format!("/api/admin/users/{target_id}/sessions"),
|
||
|
|
] {
|
||
|
|
let response = client
|
||
|
|
.delete(format!("{gateway_url}{path}"))
|
||
|
|
.header(GATEWAY_HEADER, "rust-phase3b")
|
||
|
|
.bearer_auth(&raw_token)
|
||
|
|
.send()
|
||
|
|
.await
|
||
|
|
.expect("session revocation request should complete");
|
||
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN, "path: {path}");
|
||
|
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||
|
|
assert_eq!(payload["required_permission"], "admin:users:admin");
|
||
|
|
}
|
||
|
|
|
||
|
|
gateway_handle.abort();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
#[tokio::test]
|
|||
|
|
async fn gateway_handles_admin_user_detail_routes_locally_with_trusted_admin_principal() {
|
||
|
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||
|
|
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 state = AppState::new()
|
|||
|
|
.expect("gateway should build")
|
||
|
|
.with_auth_users_for_tests([
|
||
|
|
sample_admin_user("user-1"),
|
||
|
|
sample_admin_user_with_role("admin-1", "admin", "[email protected]", "admin_one"),
|
||
|
|
sample_admin_user_with_role("admin-2", "admin", "[email protected]", "admin_two"),
|
||
|
|
])
|
||
|
|
.with_auth_wallets_for_tests([sample_admin_wallet("user-1", "finite")]);
|
||
|
|
let inspection_state = state.clone();
|
||
|
|
let gateway = build_router_with_state(state);
|
||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
|||
|
|
|
||
|
|
let client = reqwest::Client::new();
|
||
|
|
|
||
|
|
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": "[email protected]",
|
||
|
|
"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"], "[email protected]");
|
||
|
|
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 updated_user = inspection_state
|
|||
|
|
.find_user_auth_by_id("user-1")
|
||
|
|
.await
|
||
|
|
.expect("updated user lookup should succeed")
|
||
|
|
.expect("updated user should exist");
|
||
|
|
assert_eq!(
|
||
|
|
updated_user.email.as_deref(),
|
||
|
|
Some("[email protected]")
|
||
|
|
);
|
||
|
|
assert!(!updated_user.email_verified);
|
||
|
|
|
||
let hidden_update_response = client
|
|||
|
|
.put(format!("{gateway_url}/api/admin/users/user-1"))
|
||
|
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||
|
|
.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",
|
||
|
|
"[email protected]",
|
||
|
|
"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"], "[email protected]");
|
||
|
|
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_keys_inherit_owner_group_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", "[email protected]", "admin"),
|
||
|
|
sample_admin_user_with_role("target-user", "user", "[email protected]", "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"
|
||
|
|
);
|
||
|
|
|
||
let response = reqwest::Client::new()
|
|||
|
|
.post(format!("{gateway_url}/api/admin/users/admin-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": "admin-user-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 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("admin-owned user key snapshot should resolve")
|
||
|
|
.expect("admin-owned user key snapshot should exist");
|
||
|
|
assert_eq!(resolved.user_id, "admin-user");
|
||
|
|
assert!(!resolved.api_key_is_standalone);
|
||
|
|
assert_eq!(
|
||
|
|
resolved.effective_allowed_providers(),
|
||
|
|
Some(&["openai".to_string()][..])
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
resolved.effective_allowed_api_formats(),
|
||
|
|
Some(&["openai:responses".to_string()][..])
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
resolved.effective_allowed_models(),
|
||
|
|
Some(&["gpt-5.4".to_string()][..])
|
||
|
|
);
|
||
|
|
assert_eq!(resolved.user_rate_limit, Some(100));
|
||
|
|
|
||
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": "[email protected]",
|
||
|
|
"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": "[email protected]" }))
|
||
|
|
.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 (key_hash, encrypted) = sample_bound_admin_api_key_secret("user-1", "key-1", "sk-user-1");
|
|||
let auth_repository = Arc::new(
|
|||
|
|
InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||
Some(key_hash.clone()),
|
|||
sample_admin_api_key_snapshot("user-1", "key-1"),
|
|||
|
|
)])
|
||
|
|
.with_export_records(vec![StoredAuthApiKeyExportRecord::new(
|
||
|
|
"user-1".to_string(),
|
||
|
|
"key-1".to_string(),
|
||
key_hash,
|
|||
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"], "[email protected]");
|
||
|
|
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...-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 (key_hash, encrypted) = sample_bound_admin_api_key_secret("user-1", "key-1", "sk-user-1");
|
|||
let auth_repository = Arc::new(
|
|||
|
|
InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||
Some(key_hash.clone()),
|
|||
sample_admin_api_key_snapshot("user-1", "key-1"),
|
|||
|
|
)])
|
||
|
|
.with_export_records(vec![StoredAuthApiKeyExportRecord::new(
|
||
|
|
"user-1".to_string(),
|
||
|
|
"key-1".to_string(),
|
||
key_hash,
|
|||
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();
|
||
|
|
}
|