Files
Aether/apps/aether-gateway/src/tests/frontdoor/public_support.rs
T
elky 066ea87d72 feat: revamp analytics dashboards and harden database migrations
Add dashboard and overview analytics, health monitoring, provider expense tracking, and announcement updates across the gateway and frontend.

Keep schema migrations free of historical backfills while preserving automatic backfill execution. Bound migration deadlines, run schema preparation before Compose replacement, and anonymize deleted dashboard users.

Include the current documentation cleanup and regression coverage.
2026-10-01 11:48:17 +08:00

12353 lines
448 KiB
Rust

use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::{
hash_api_key, sample_endpoint, sample_key, sample_models_candidate_row, sample_provider,
sample_public_catalog_model, sample_public_global_model,
sample_public_global_model_with_capabilities, sample_request_candidate,
InMemoryAnnouncementReadRepository, InMemoryGlobalModelReadRepository,
InMemoryMinimalCandidateSelectionReadRepository, InMemoryProviderCatalogReadRepository,
InMemoryRequestCandidateRepository, RequestCandidateStatus, StoredAnnouncement,
StoredPublicGlobalModel,
};
use crate::data::GatewayDataState;
use crate::tests::{
any, build_router, build_router_with_state, json, start_server, to_bytes, AppState, Arc, Body,
Json, Mutex, Request, Router, StatusCode, CONTROL_ROUTE_FAMILY_HEADER,
CONTROL_ROUTE_KIND_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
TRUSTED_ADMIN_USER_ROLE_HEADER,
};
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
use aether_data::repository::announcements::{AnnouncementListQuery, AnnouncementReadRepository};
use aether_data::repository::auth::{
InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeyExportRecord, StoredAuthApiKeySnapshot,
};
use aether_data::repository::auth_modules::{
InMemoryAuthModuleReadRepository, StoredLdapModuleConfig, StoredOAuthProviderModuleConfig,
};
use aether_data::repository::billing::InMemoryBillingReadRepository;
use aether_data::repository::management_tokens::{
InMemoryManagementTokenRepository, ManagementTokenReadRepository, StoredManagementToken,
StoredManagementTokenUserSummary, StoredManagementTokenWithUser,
};
use aether_data::repository::usage::InMemoryUsageReadRepository;
use aether_data::repository::users::{
InMemoryUserReadRepository, StoredUserAuthRecord, StoredUserExportRow, UpsertUserGroupRecord,
UserReadRepository,
};
use aether_data::repository::wallet::{
InMemoryWalletRepository, StoredWalletSnapshot, WalletWriteRepository,
};
use aether_data_contracts::repository::billing::{
AdminBillingMutationOutcome, BillingPlanWriteInput, BillingReadRepository,
PaymentGatewayConfigWriteInput,
};
use aether_data_contracts::repository::global_models::StoredProviderActiveGlobalModel;
use aether_data_contracts::repository::provider_catalog::ProviderCatalogReadRepository;
use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageRepository};
use axum::response::IntoResponse;
use chrono::{TimeZone, Utc};
const TEST_EMAIL_VERIFICATION_TOKEN: &str =
"test-email-verification-token-00000000000000000000000000000000";
#[path = "public_support/auth_cookie.rs"]
mod auth_cookie;
#[path = "public_support/announcement_user_list.rs"]
mod announcement_user_list;
#[path = "public_support/dashboard.rs"]
mod dashboard;
#[path = "public_support/vscodex.rs"]
mod vscodex;
#[tokio::test]
async fn health_v2_public_scope_filters_summary_lists_and_details_before_projection() {
let now = Utc::now();
let mut published = sample_user_usage_audit(
"private-detail-id",
"private-request-id",
"private-user-id",
"internal-published-model",
"private-provider-name",
"failed",
now - chrono::Duration::minutes(5),
);
published.request_metadata = Some(json!({
"analytics_failure": {"origin": "upstream", "stage": "response", "reason": "private-diagnostic", "schema_version": 1},
}));
let hidden = sample_user_usage_audit(
"private-hidden-id",
"private-hidden-request",
"private-user-id",
"unpublished-model",
"private-provider-name",
"completed",
now - chrono::Duration::minutes(4),
);
let data = GatewayDataState::with_usage_reader_for_tests(Arc::new(InMemoryUsageReadRepository::seed(vec![published, hidden])))
.with_system_config_values_for_tests(vec![("health_publication_v1".into(), json!({
"enabled": true, "objects": [
{"public_id": "model-api", "kind": "model", "value": "internal-published-model", "display_name": "Model API"},
],
}))]);
let (gateway_url, gateway_handle) = start_server(build_router_with_state(
AppState::new().unwrap().with_data_state_for_tests(data),
))
.await;
let client = reqwest::Client::new();
for resource in ["summary", "objects", "objects/model-api"] {
let response = client
.get(format!(
"{gateway_url}/api/public/health/v2/{resource}?kind=model"
))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body: serde_json::Value = response.json().await.unwrap();
let metrics = match resource {
"summary" => &body["data"]["requests"],
"objects" => &body["data"]["items"][0],
_ => &body["data"],
};
assert_eq!(
metrics["request_count"], 1,
"{resource} includes only the published model"
);
if resource == "objects" {
assert_eq!(body["data"]["total"], 1);
assert!(!metrics["timeline"].as_array().unwrap().is_empty());
}
let encoded = body.to_string();
for forbidden in [
"private-",
"internal-published-model",
"unpublished-model",
"provider_id",
"api_key_id",
"source_value",
"attempts",
"error_message",
"analytics_failure",
] {
assert!(
!encoded.contains(forbidden),
"{resource} leaked {forbidden}"
);
}
}
let empty_scope = client
.get(format!(
"{gateway_url}/api/public/health/v2/summary?kind=api_format"
))
.send()
.await
.unwrap();
assert_eq!(empty_scope.status(), StatusCode::OK);
let empty: serde_json::Value = empty_scope.json().await.unwrap();
assert_eq!(empty["data"]["object_count"], 0);
assert_eq!(empty["data"]["requests"]["request_count"], 0);
gateway_handle.abort();
}
#[tokio::test]
async fn health_v2_authenticated_user_access_does_not_publish_anonymous_status() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".into(), json!(user.id)),
("role".into(), json!(user.role)),
(
"created_at".into(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".into(), json!("health-user-session")),
]),
now + chrono::Duration::hours(1),
);
let data = GatewayDataState::with_usage_reader_for_tests(Arc::new(
InMemoryUsageReadRepository::seed(vec![sample_user_usage_audit(
"health-user-row",
"health-user-request",
&user.id,
"model-health",
"internal-provider",
"completed",
now - chrono::Duration::minutes(5),
)]),
))
.with_user_reader(Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
user,
])))
.with_provider_catalog_reader(Arc::new(InMemoryProviderCatalogReadRepository::seed(
Vec::new(),
Vec::new(),
Vec::new(),
)))
.with_system_config_values_for_tests(vec![(
"health_publication_v1".into(),
json!({ "enabled": false, "objects": [] }),
)]);
let state = AppState::new()
.unwrap()
.with_data_state_for_tests(data)
.with_auth_session_for_tests(sample_auth_session(
"user-auth-1",
"health-user-session",
"health-user-device",
"refresh-token-placeholder",
now,
));
let (gateway_url, gateway_handle) = start_server(build_router_with_state(state)).await;
let client = reqwest::Client::new();
let url = format!("{gateway_url}/api/users/me/health/v2/objects?kind=api_format");
let anonymous = client.get(&url).send().await.unwrap();
assert_eq!(anonymous.status(), StatusCode::UNAUTHORIZED);
let authenticated = client
.get(&url)
.bearer_auth(&access_token)
.header("x-client-device-id", "health-user-device")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.unwrap();
assert_eq!(authenticated.status(), StatusCode::OK);
let body: serde_json::Value = authenticated.json().await.unwrap();
assert_eq!(body["meta"]["scope"]["kind"], "authenticated");
assert_eq!(body["data"]["total"], 1);
assert_eq!(body["data"]["items"][0]["request_count"], 1);
assert!(body["data"]["items"][0].get("attempts").is_none());
assert!(!body.to_string().contains("internal-provider"));
let provider = client
.get(format!(
"{gateway_url}/api/users/me/health/v2/objects?kind=provider"
))
.bearer_auth(&access_token)
.header("x-client-device-id", "health-user-device")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.unwrap();
assert_eq!(provider.status(), StatusCode::BAD_REQUEST);
let public = client
.get(format!("{gateway_url}/api/public/health/v2/objects"))
.send()
.await
.unwrap();
assert_eq!(public.status(), StatusCode::NOT_FOUND);
gateway_handle.abort();
}
#[tokio::test]
async fn gateway_handles_public_announcements_list_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let announcement_repository = Arc::new(InMemoryAnnouncementReadRepository::seed(vec![
StoredAnnouncement::new(
"announcement-pinned".to_string(),
"置顶维护".to_string(),
"维护窗口".to_string(),
"maintenance".to_string(),
5,
true,
true,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,
None,
1_711_000_100,
1_711_000_100,
)
.expect("announcement should build"),
StoredAnnouncement::new(
"announcement-normal".to_string(),
"普通公告".to_string(),
"普通内容".to_string(),
"info".to_string(),
3,
true,
false,
false,
Some("admin-2".to_string()),
Some("ops".to_string()),
None,
None,
1_711_000_000,
1_711_000_000,
)
.expect("announcement should build"),
StoredAnnouncement::new(
"announcement-inactive".to_string(),
"已停用".to_string(),
"不应返回".to_string(),
"warning".to_string(),
100,
false,
true,
false,
Some("admin-3".to_string()),
Some("root".to_string()),
None,
None,
1_711_000_200,
1_711_000_200,
)
.expect("announcement 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_announcement_reader_for_tests(
announcement_repository,
),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/announcements?active_only=false&limit=50&offset=0"
))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["total"], 2);
let items = payload["items"].as_array().expect("items should be array");
assert_eq!(items.len(), 2);
assert_eq!(items[0]["id"], "announcement-pinned");
assert_eq!(items[0]["is_active"], true);
assert_eq!(items[1]["id"], "announcement-normal");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_public_active_announcements_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let now = Utc::now().timestamp().max(0) as u64;
let announcement_repository = Arc::new(InMemoryAnnouncementReadRepository::seed(vec![
StoredAnnouncement::new(
"announcement-active".to_string(),
"有效公告".to_string(),
"当前生效".to_string(),
"important".to_string(),
50,
true,
false,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
Some((now.saturating_sub(60)) as i64),
Some((now.saturating_add(3600)) as i64),
now as i64,
now as i64,
)
.expect("announcement should build"),
StoredAnnouncement::new(
"announcement-future".to_string(),
"未来公告".to_string(),
"尚未生效".to_string(),
"info".to_string(),
10,
true,
false,
false,
Some("admin-2".to_string()),
Some("ops".to_string()),
Some((now.saturating_add(3600)) as i64),
Some((now.saturating_add(7200)) as i64),
now as i64,
now as i64,
)
.expect("announcement 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_announcement_reader_for_tests(
announcement_repository,
),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/announcements/active"))
.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"], "announcement-active");
assert_eq!(items[0]["type"], "important");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_public_announcement_detail_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let now = Utc::now().timestamp().max(0) as u64;
let announcement_repository = Arc::new(InMemoryAnnouncementReadRepository::seed(vec![
StoredAnnouncement::new(
"announcement-1".to_string(),
"系统维护".to_string(),
"今天晚些时候维护".to_string(),
"maintenance".to_string(),
10,
true,
true,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
Some(now.saturating_sub(60) as i64),
Some(now.saturating_add(3600) as i64),
1_711_000_000,
1_711_000_100,
)
.expect("announcement 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_announcement_reader_for_tests(
announcement_repository,
),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/announcements/announcement-1"))
.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"], "announcement-1");
assert_eq!(payload["title"], "系统维护");
assert_eq!(payload["type"], "maintenance");
assert_eq!(payload["author"]["id"], "admin-1");
assert_eq!(payload["author"]["username"], "admin");
assert_eq!(payload["created_at"], "2024-03-21T05:46:40Z");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_hides_non_public_announcement_details() {
let now = Utc::now().timestamp().max(0) as u64;
let announcements = [
("inactive", false, None, None),
("future", true, Some(now.saturating_add(3600) as i64), None),
("expired", true, None, Some(now.saturating_sub(1) as i64)),
]
.into_iter()
.map(|(id, is_active, starts_at, ends_at)| {
StoredAnnouncement::new(
format!("announcement-{id}"),
format!("{id} title"),
format!("{id} private content"),
"info".to_string(),
1,
is_active,
false,
false,
Some("admin-private".to_string()),
Some("admin".to_string()),
starts_at,
ends_at,
now as i64,
now as i64,
)
.expect("announcement should build")
})
.collect::<Vec<_>>();
let announcement_repository = Arc::new(InMemoryAnnouncementReadRepository::seed(announcements));
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_announcement_reader_for_tests(
announcement_repository,
),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let client = reqwest::Client::new();
for id in ["inactive", "future", "expired"] {
let response = client
.get(format!("{gateway_url}/api/announcements/announcement-{id}"))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{id}");
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "Announcement not found", "{id}");
}
gateway_handle.abort();
}
#[tokio::test]
async fn gateway_creates_announcement_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/announcements",
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 announcement_repository = Arc::new(InMemoryAnnouncementReadRepository::seed(vec![]));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_announcement_repository_for_tests(Arc::clone(
&announcement_repository,
)),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/announcements"))
.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!({
"title": "系统维护",
"content": "今晚维护",
"type": "maintenance",
"priority": 8,
"is_pinned": true,
"start_time": "2026-03-30T00:00:00Z",
"end_time": "2026-03-31T00:00:00Z"
}))
.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["title"], "系统维护");
assert_eq!(payload["message"], "公告创建成功");
let announcement_id = payload["id"]
.as_str()
.expect("created announcement id should be string");
let created = announcement_repository
.find_by_id(announcement_id)
.await
.expect("query should succeed")
.expect("announcement should exist");
assert_eq!(created.kind, "maintenance");
assert_eq!(created.priority, 8);
assert!(created.is_pinned);
assert_eq!(created.author_id.as_deref(), Some("admin-user-123"));
assert_eq!(created.start_time_unix_secs, Some(1_774_828_800));
assert_eq!(created.end_time_unix_secs, Some(1_774_915_200));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_updates_announcement_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/announcements/{announcement_id}",
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 announcement_repository = Arc::new(InMemoryAnnouncementReadRepository::seed(vec![
StoredAnnouncement::new(
"announcement-1".to_string(),
"系统维护".to_string(),
"今天维护".to_string(),
"maintenance".to_string(),
10,
true,
true,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,
None,
1_711_000_000,
1_711_000_100,
)
.expect("announcement 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_announcement_repository_for_tests(Arc::clone(
&announcement_repository,
)),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.put(format!("{gateway_url}/api/announcements/announcement-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!({
"title": "系统升级",
"content": "维护延长",
"type": "important",
"priority": 99,
"is_active": false,
"is_pinned": false,
"start_time": "2026-03-30T12:00:00Z",
"end_time": "2026-03-31T12:00:00Z"
}))
.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"], "公告更新成功");
let updated = announcement_repository
.find_by_id("announcement-1")
.await
.expect("query should succeed")
.expect("announcement should exist");
assert_eq!(updated.title, "系统升级");
assert_eq!(updated.content, "维护延长");
assert_eq!(updated.kind, "important");
assert_eq!(updated.priority, 99);
assert!(!updated.is_active);
assert!(!updated.is_pinned);
assert_eq!(updated.start_time_unix_secs, Some(1_774_872_000));
assert_eq!(updated.end_time_unix_secs, Some(1_774_958_400));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_deletes_announcement_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/announcements/{announcement_id}",
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 announcement_repository = Arc::new(InMemoryAnnouncementReadRepository::seed(vec![
StoredAnnouncement::new(
"announcement-1".to_string(),
"系统维护".to_string(),
"今天维护".to_string(),
"maintenance".to_string(),
10,
true,
true,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,
None,
1_711_000_000,
1_711_000_100,
)
.expect("announcement 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_announcement_repository_for_tests(Arc::clone(
&announcement_repository,
)),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.delete(format!("{gateway_url}/api/announcements/announcement-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"], "公告已删除");
let deleted = announcement_repository
.find_by_id("announcement-1")
.await
.expect("query should succeed");
assert!(deleted.is_none());
let page = announcement_repository
.list_announcements(&AnnouncementListQuery {
active_only: false,
offset: 0,
limit: 10,
now_unix_secs: None,
})
.await
.expect("list should succeed");
assert_eq!(page.total, 0);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_returns_service_unavailable_for_admin_announcement_writes_without_writer() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/announcements/{*path}",
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 announcement_repository = Arc::new(InMemoryAnnouncementReadRepository::seed(vec![
StoredAnnouncement::new(
"announcement-1".to_string(),
"系统维护".to_string(),
"今天维护".to_string(),
"maintenance".to_string(),
10,
true,
true,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,
None,
1_711_000_000,
1_711_000_100,
)
.expect("announcement should build"),
]));
let (_fallback_probe_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_announcement_reader_for_tests(
announcement_repository,
),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let client = reqwest::Client::new();
let create_response = client
.post(format!("{gateway_url}/api/announcements"))
.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!({
"title": "系统维护",
"content": "今晚维护",
"type": "maintenance"
}))
.send()
.await
.expect("request should succeed");
assert_eq!(create_response.status(), StatusCode::SERVICE_UNAVAILABLE);
let create_payload: serde_json::Value = create_response
.json()
.await
.expect("json body should parse");
assert_eq!(create_payload["detail"], "公告写入暂不可用");
let update_response = client
.put(format!("{gateway_url}/api/announcements/announcement-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!({
"title": "系统升级"
}))
.send()
.await
.expect("request should succeed");
assert_eq!(update_response.status(), StatusCode::SERVICE_UNAVAILABLE);
let update_payload: serde_json::Value = update_response
.json()
.await
.expect("json body should parse");
assert_eq!(update_payload["detail"], "公告写入暂不可用");
let delete_response = client
.delete(format!("{gateway_url}/api/announcements/announcement-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::SERVICE_UNAVAILABLE);
let delete_payload: serde_json::Value = delete_response
.json()
.await
.expect("json body should parse");
assert_eq!(delete_payload["detail"], "公告写入暂不可用");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_public_catalog_site_info_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
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::disabled().with_system_config_values_for_tests(
vec![
("site_name".to_string(), json!("Aether Local")),
("site_subtitle".to_string(), json!("Rust Only")),
],
),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/public/site-info"))
.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["site_name"], "Aether Local");
assert_eq!(payload["site_subtitle"], "Rust Only");
assert_eq!(payload.as_object().map(|object| object.len()), Some(2));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_public_catalog_providers_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let mut inactive_provider = sample_provider("provider-draft", "draft", 30);
inactive_provider.is_active = false;
let mut inactive_endpoint = sample_endpoint(
"endpoint-openai-disabled",
"provider-openai",
"openai:responses",
"https://internal-openai.example",
);
inactive_endpoint.is_active = false;
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider("provider-openai", "openai", 10),
sample_provider("provider-claude", "claude", 20),
inactive_provider,
],
vec![
sample_endpoint(
"endpoint-openai",
"provider-openai",
"openai:chat",
"https://api.openai.example",
),
sample_endpoint(
"endpoint-claude",
"provider-claude",
"claude:messages",
"https://api.anthropic.example",
),
sample_endpoint(
"endpoint-claude-cli",
"provider-claude",
"claude:messages",
"https://api.anthropic.example",
),
inactive_endpoint,
],
vec![],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog_repository,
),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/public/providers?is_active=false&limit=10"
))
.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 providers = payload.as_array().expect("providers should be an array");
assert_eq!(providers.len(), 2);
assert_eq!(providers[0]["id"], "provider-openai");
assert!(providers[0].get("name").is_none());
assert!(providers[0].get("description").is_none());
assert!(providers[0].get("website").is_none());
assert_eq!(providers[0]["provider_priority"], 10);
assert_eq!(providers[0]["endpoints_count"], 1);
assert_eq!(providers[0]["active_endpoints_count"], 1);
assert_eq!(providers[0]["models_count"], 0);
assert_eq!(providers[0]["active_models_count"], 0);
assert_eq!(providers[1]["id"], "provider-claude");
assert_eq!(providers[1]["endpoints_count"], 2);
assert_eq!(providers[1]["active_endpoints_count"], 2);
assert!(providers
.iter()
.all(|provider| provider["id"] != "provider-draft"));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_public_catalog_models_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let global_model_repository = Arc::new(
InMemoryGlobalModelReadRepository::seed(Vec::<StoredPublicGlobalModel>::new())
.with_public_catalog_models(vec![
sample_public_catalog_model(
"model-openai-gpt5",
"provider-openai",
"openai",
"gpt-5-preview",
"gpt-5",
"GPT 5",
),
sample_public_catalog_model(
"model-claude-sonnet",
"provider-claude",
"claude",
"claude-sonnet-4-5-20251001",
"claude-sonnet-4-5",
"Claude Sonnet 4.5",
),
]),
);
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_global_model_reader_for_tests(
global_model_repository,
),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/public/models?provider_id=provider-openai&limit=10"
))
.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 models = payload.as_array().expect("models should be an array");
assert_eq!(models.len(), 1);
assert_eq!(models[0]["id"], "model-openai-gpt5");
assert!(models[0].get("provider_id").is_none());
assert!(models[0].get("provider_name").is_none());
assert_eq!(models[0]["name"], "gpt-5");
assert_eq!(models[0]["display_name"], "GPT 5");
assert_eq!(models[0]["tags"], serde_json::Value::Null);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn public_catalog_excludes_unsupported_embedding_provider() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let mut active_embedding = sample_public_catalog_model(
"model-openai-embedding-small",
"provider-openai",
"openai",
"text-embedding-3-small",
"text-embedding-3-small",
"Text Embedding 3 Small",
);
active_embedding.supports_embedding = Some(true);
active_embedding.supports_streaming = Some(false);
let mut inactive_embedding = sample_public_catalog_model(
"model-openai-embedding-inactive",
"provider-openai",
"openai",
"text-embedding-3-large",
"text-embedding-3-large",
"Text Embedding 3 Large",
);
inactive_embedding.supports_embedding = Some(true);
inactive_embedding.is_active = false;
let mut unsupported_embedding = sample_public_catalog_model(
"model-unsupported-embedding",
"provider-openai",
"openai",
"legacy-embedding",
"legacy-embedding",
"Legacy Embedding",
);
unsupported_embedding.supports_embedding = Some(false);
unsupported_embedding.is_active = false;
let global_model_repository = Arc::new(
InMemoryGlobalModelReadRepository::seed(Vec::<StoredPublicGlobalModel>::new())
.with_public_catalog_models(vec![
active_embedding,
inactive_embedding,
unsupported_embedding,
]),
);
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_global_model_reader_for_tests(
global_model_repository,
),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/public/models?provider_id=provider-openai&limit=10"
))
.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 models = payload.as_array().expect("models should be an array");
assert_eq!(models.len(), 1);
assert_eq!(models[0]["id"], "model-openai-embedding-small");
assert_eq!(models[0]["supports_embedding"], true);
assert_eq!(models[0]["supports_streaming"], false);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_public_catalog_search_models_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let global_model_repository = Arc::new(
InMemoryGlobalModelReadRepository::seed(Vec::<StoredPublicGlobalModel>::new())
.with_public_catalog_models(vec![
sample_public_catalog_model(
"model-openai-gpt5",
"provider-openai",
"openai",
"gpt-5-preview",
"gpt-5",
"GPT 5",
),
sample_public_catalog_model(
"model-claude-sonnet",
"provider-claude",
"claude",
"claude-sonnet-4-5-20251001",
"claude-sonnet-4-5",
"Claude Sonnet 4.5",
),
]),
);
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_global_model_reader_for_tests(
global_model_repository,
),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/public/search/models?q=sonnet&limit=20"
))
.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 models = payload.as_array().expect("models should be an array");
assert_eq!(models.len(), 1);
assert!(models[0].get("provider_id").is_none());
assert!(models[0].get("provider_name").is_none());
assert_eq!(models[0]["name"], "claude-sonnet-4-5");
assert_eq!(models[0]["display_name"], "Claude Sonnet 4.5");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_public_catalog_stats_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider("provider-openai", "openai", 10),
sample_provider("provider-claude", "claude", 20),
],
vec![
sample_endpoint(
"endpoint-openai",
"provider-openai",
"openai:chat",
"https://api.openai.example",
),
sample_endpoint(
"endpoint-claude",
"provider-claude",
"claude:messages",
"https://api.anthropic.example",
),
],
vec![],
));
let candidate_repository =
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
sample_models_candidate_row("provider-openai", "openai", "openai:chat", "gpt-5", 10),
sample_models_candidate_row("provider-openai", "openai", "openai:chat", "gpt-4.1", 10),
sample_models_candidate_row(
"provider-claude",
"claude",
"claude:messages",
"claude-3-7-sonnet",
20,
),
]));
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_provider_catalog_and_minimal_candidate_selection_for_tests(
provider_catalog_repository,
candidate_repository,
),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/public/stats"))
.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_providers"], 2);
assert_eq!(payload["active_providers"], 2);
assert_eq!(payload["total_models"], 3);
assert_eq!(payload["active_models"], 3);
assert_eq!(
payload["supported_formats"],
json!(["claude:messages", "openai:chat"])
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_public_global_models_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let mut gpt_model = sample_public_global_model("gm-3", "gpt-5", "GPT 5", true);
gpt_model.config = Some(json!({
"description": "Public description",
"streaming": true,
"api_formats": ["openai:responses", {"secret": "capability-leak"}],
"billing": {
"video": {
"price_per_second_by_resolution": {
"720p": 0.12,
"internal": "video-billing-secret"
},
"private_key": "video-secret"
}
},
"client_secret": "config-secret",
"model_mappings": ["gpt-5-upstream"],
"provider_model_mappings": [{"name": "provider-gpt-5"}],
}));
gpt_model.supported_capabilities = Some(json!(["vision", {"secret": "capability-secret"}]));
gpt_model.default_tiered_pricing = Some(json!({
"tiers": [{
"up_to": null,
"input_price_per_1m": 3.0,
"output_price_per_1m": 15.0,
"internal_note": "tier-secret",
"cache_ttl_pricing": [{
"ttl_minutes": 60,
"cache_creation_price_per_1m": 4.0,
"secret": "ttl-secret"
}]
}],
"internal_pricing": {"secret": "pricing-secret"}
}));
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(vec![
sample_public_global_model("gm-1", "claude-sonnet-4-5", "Claude Sonnet 4.5", true),
sample_public_global_model("gm-2", "disabled-model", "Disabled Model", false),
gpt_model,
]));
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_global_model_reader_for_tests(
global_model_repository,
),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/public/global-models?is_active=false&search=gpt"
))
.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["models"][0]["name"], "gpt-5");
assert_eq!(payload["models"][0]["display_name"], "GPT 5");
assert_eq!(payload["models"][0]["usage_count"], 0);
assert_eq!(
payload["models"][0]["config"]["description"],
"Public description"
);
assert!(payload["models"][0]["config"]
.get("model_mappings")
.is_none());
assert!(payload["models"][0]["config"]
.get("provider_model_mappings")
.is_none());
assert!(payload["models"][0]["config"]
.get("client_secret")
.is_none());
assert_eq!(
payload["models"][0]["config"]["api_formats"],
json!(["openai:responses"])
);
assert_eq!(
payload["models"][0]["config"]["billing"]["video"]["price_per_second_by_resolution"],
json!({"720p": 0.12})
);
assert!(payload["models"][0]["config"]["billing"]["video"]
.get("private_key")
.is_none());
assert_eq!(
payload["models"][0]["supported_capabilities"],
json!(["vision"])
);
assert!(payload["models"][0]["default_tiered_pricing"]
.get("internal_pricing")
.is_none());
assert!(payload["models"][0]["default_tiered_pricing"]["tiers"][0]
.get("internal_note")
.is_none());
assert!(
payload["models"][0]["default_tiered_pricing"]["tiers"][0]["cache_ttl_pricing"][0]
.get("secret")
.is_none()
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_public_health_api_formats_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider("provider-openai", "openai", 10),
sample_provider("provider-claude", "claude", 20),
],
vec![
sample_endpoint(
"endpoint-openai",
"provider-openai",
"openai:chat",
"https://api.openai.example",
),
sample_endpoint(
"endpoint-claude",
"provider-claude",
"claude:messages",
"https://api.anthropic.example",
),
],
vec![],
));
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("current time should be after epoch")
.as_secs() as i64;
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::seed(vec![
sample_request_candidate(
"cand-openai-success",
"req-openai-success",
"endpoint-openai",
RequestCandidateStatus::Success,
now_unix_secs - 3_000,
Some(now_unix_secs - 2_980),
),
sample_request_candidate(
"cand-openai-failed",
"req-openai-failed",
"endpoint-openai",
RequestCandidateStatus::Failed,
now_unix_secs - 2_000,
Some(now_unix_secs - 1_980),
),
sample_request_candidate(
"cand-openai-skipped",
"req-openai-skipped",
"endpoint-openai",
RequestCandidateStatus::Skipped,
now_unix_secs - 1_000,
Some(now_unix_secs - 980),
),
sample_request_candidate(
"cand-openai-pending",
"req-openai-pending",
"endpoint-openai",
RequestCandidateStatus::Pending,
now_unix_secs - 500,
None,
),
sample_request_candidate(
"cand-claude-skipped",
"req-claude-skipped",
"endpoint-claude",
RequestCandidateStatus::Skipped,
now_unix_secs - 1_500,
Some(now_unix_secs - 1_480),
),
]));
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_provider_catalog_and_request_candidate_reader_for_tests(
provider_catalog_repository,
request_candidate_repository,
),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/public/health/api-formats?lookback_hours=6&per_format_limit=10"
))
.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 formats = payload["formats"]
.as_array()
.expect("formats should be an array");
assert_eq!(formats.len(), 2);
assert_eq!(formats[0]["api_format"], "claude:messages");
assert_eq!(formats[0]["api_path"], "/v1/messages");
assert_eq!(formats[0]["total_attempts"], 1);
assert_eq!(formats[0]["success_rate"], 1.0);
assert_eq!(formats[0]["events"].as_array().map(Vec::len), Some(1));
assert_eq!(formats[0]["timeline"].as_array().map(Vec::len), Some(60));
assert_eq!(formats[1]["api_format"], "openai:chat");
assert_eq!(formats[1]["api_path"], "/v1/chat/completions");
assert_eq!(formats[1]["total_attempts"], 3);
assert_eq!(formats[1]["success_count"], 1);
assert_eq!(formats[1]["failed_count"], 1);
assert_eq!(formats[1]["skipped_count"], 1);
assert_eq!(formats[1]["success_rate"], 0.5);
assert_eq!(formats[1]["events"].as_array().map(Vec::len), Some(3));
assert_eq!(formats[1]["timeline"].as_array().map(Vec::len), Some(60));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_public_auth_modules_status_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let auth_module_repository = Arc::new(InMemoryAuthModuleReadRepository::seed(
vec![StoredOAuthProviderModuleConfig::new(
"linuxdo".to_string(),
"Linux DO".to_string(),
"client-id".to_string(),
Some("encrypted-secret".to_string()),
"https://example.com/oauth/callback".to_string(),
)
.expect("oauth provider should build")],
Some(StoredLdapModuleConfig {
server_url: "ldaps://ldap.example.com".to_string(),
bind_dn: "cn=admin,dc=example,dc=com".to_string(),
bind_password_encrypted: Some("encrypted-password".to_string()),
base_dn: "dc=example,dc=com".to_string(),
user_search_filter: Some("(uid={username})".to_string()),
username_attr: Some("uid".to_string()),
email_attr: Some("mail".to_string()),
display_name_attr: Some("displayName".to_string()),
is_enabled: true,
is_exclusive: false,
use_starttls: true,
connect_timeout: Some(10),
}),
));
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_module_reader_for_tests(
auth_module_repository,
),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/modules/auth-status"))
.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,
json!([
{
"name": "oauth",
"display_name": "OAuth 登录",
"active": false
},
{
"name": "ldap",
"display_name": "LDAP 认证",
"active": false
}
])
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_public_capabilities_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router().expect("gateway should build");
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/capabilities"))
.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["capabilities"][0]["name"], "cache_1h");
assert_eq!(payload["capabilities"][1]["name"], "context_1m");
assert_eq!(payload["capabilities"][2]["name"], "gemini_files");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_auth_registration_settings_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let data_state = crate::data::GatewayDataState::with_auth_module_reader_for_tests(Arc::new(
InMemoryAuthModuleReadRepository::seed(Vec::new(), None),
))
.with_system_config_values_for_tests(vec![
("enable_registration".to_string(), json!(true)),
("require_email_verification".to_string(), json!(true)),
("smtp_host".to_string(), json!("smtp.example.com")),
("smtp_from_email".to_string(), json!("[email protected]")),
("password_policy_level".to_string(), json!("strong")),
("turnstile_enabled".to_string(), json!(true)),
("turnstile_site_key".to_string(), json!("site-public-key")),
(
"turnstile_secret_key".to_string(),
json!("secret-private-key"),
),
(
"registration_privacy_policy_enabled".to_string(),
json!(true),
),
(
"registration_privacy_policy_format".to_string(),
json!("html"),
),
(
"registration_privacy_policy_content".to_string(),
json!("<p>Policy</p>"),
),
(
"registration_privacy_policy_version".to_string(),
json!("2026-05-16"),
),
]);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/auth/registration-settings"))
.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,
json!({
"enable_registration": true,
"require_email_verification": true,
"email_configured": true,
"password_policy_level": "strong",
"turnstile_enabled": true,
"turnstile_site_key": "site-public-key",
"turnstile_required_actions": ["send_verification_code", "register"],
"privacy_policy": {
"enabled": true,
"format": "html",
"content": "<p>Policy</p>",
"version": "2026-05-16",
},
})
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_auth_settings_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let auth_module_repository = Arc::new(InMemoryAuthModuleReadRepository::seed(
Vec::new(),
Some(StoredLdapModuleConfig {
server_url: "ldaps://ldap.example.com".to_string(),
bind_dn: "cn=admin,dc=example,dc=com".to_string(),
bind_password_encrypted: Some("encrypted-password".to_string()),
base_dn: "dc=example,dc=com".to_string(),
user_search_filter: Some("(uid={username})".to_string()),
username_attr: Some("uid".to_string()),
email_attr: Some("mail".to_string()),
display_name_attr: Some("displayName".to_string()),
is_enabled: true,
is_exclusive: true,
use_starttls: true,
connect_timeout: Some(10),
}),
));
let data_state =
crate::data::GatewayDataState::with_auth_module_reader_for_tests(auth_module_repository)
.with_system_config_values_for_tests(vec![(
"module.ldap.enabled".to_string(),
json!(true),
)]);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/auth/settings"))
.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,
json!({
"local_enabled": false,
"ldap_enabled": true,
"ldap_exclusive": true,
})
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_public_user_configurable_capabilities_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router().expect("gateway should build");
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/capabilities/user-configurable"))
.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, json!({ "capabilities": [] }));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_public_model_capabilities_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(vec![
sample_public_global_model_with_capabilities(
"gm-1",
"gpt-5",
"GPT 5",
json!(["cache_1h", "missing_cap", "gemini_files"]),
),
]));
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_global_model_reader_for_tests(
global_model_repository,
),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/capabilities/model/gpt-5"))
.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["model"], "gpt-5");
assert_eq!(payload["global_model_name"], "gpt-5");
assert_eq!(
payload["supported_capabilities"],
json!(["cache_1h", "missing_cap", "gemini_files"])
);
assert_eq!(
payload["capability_details"].as_array().map(Vec::len),
Some(2)
);
assert_eq!(payload["capability_details"][0]["name"], "cache_1h");
assert_eq!(payload["capability_details"][1]["name"], "gemini_files");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_missing_public_model_capabilities_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router().expect("gateway should build");
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/capabilities/model/missing-model"
))
.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["model"], "missing-model");
assert_eq!(payload["supported_capabilities"], json!([]));
assert_eq!(payload["capability_details"], json!([]));
assert_eq!(payload["error"], "模型不存在");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_public_providers_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider("provider-2", "anthropic", 20),
sample_provider("provider-1", "openai", 10),
],
vec![],
vec![],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog_repository,
),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/v1/providers"))
.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!(payload["providers"][0].get("name").is_none());
assert_eq!(payload["providers"][0]["provider_priority"], 10);
assert!(payload["providers"][1].get("name").is_none());
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_public_provider_detail_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let mut inactive_provider = sample_provider("provider-disabled", "disabled", 20);
inactive_provider.is_active = false;
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider("provider-1", "openai", 10),
inactive_provider,
],
vec![],
vec![],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog_repository,
),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/v1/providers/provider-1"))
.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"], "provider-1");
assert!(payload.get("name").is_none());
assert_eq!(payload["provider_priority"], 10);
let response = reqwest::Client::new()
.get(format!("{gateway_url}/v1/providers/openai"))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "Provider not found");
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/v1/providers/provider-disabled?include_endpoints=true"
))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "Provider not found");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_public_providers_with_endpoints_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let mut inactive_endpoint = sample_endpoint(
"endpoint-disabled",
"provider-1",
"openai:responses",
"https://internal-openai.example",
);
inactive_endpoint.is_active = false;
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-1", "openai", 10)],
vec![
sample_endpoint(
"endpoint-1",
"provider-1",
"openai:chat",
"https://api.openai.example",
),
inactive_endpoint,
],
vec![],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog_repository,
),
),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/v1/providers?include_endpoints=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["providers"][0]["endpoints"][0]["id"], "endpoint-1");
assert!(payload["providers"][0]["endpoints"][0]
.get("base_url")
.is_none());
assert_eq!(
payload["providers"][0]["endpoints"][0]["api_format"],
"openai:chat"
);
assert_eq!(
payload["providers"][0]["endpoints"]
.as_array()
.map(Vec::len),
Some(1)
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_test_connection_alias_without_proxying_upstream() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router().expect("gateway should build");
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/test-connection"))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::GONE);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(
payload["detail"],
"Deprecated endpoint. Please use /v1/test-connection."
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_unauthenticated_test_connection_before_using_provider_credentials() {
let provider_hits = Arc::new(Mutex::new(0usize));
let provider_hits_clone = Arc::clone(&provider_hits);
let provider = Router::new().route(
"/{*path}",
any(move |_request: Request| {
let provider_hits_inner = Arc::clone(&provider_hits_clone);
async move {
*provider_hits_inner.lock().expect("mutex should lock") += 1;
Json(json!({"id": "must-not-run"})).into_response()
}
}),
);
let (provider_url, provider_handle) = start_server(provider).await;
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-auth-required", "openai", 10)],
vec![sample_endpoint(
"endpoint-auth-required",
"provider-auth-required",
"openai:chat",
&provider_url,
)],
vec![sample_key(
"key-auth-required",
"provider-auth-required",
"openai:chat",
"stored-provider-secret",
)],
));
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_provider_transport_reader_for_tests(
provider_catalog_repository,
DEVELOPMENT_ENCRYPTION_KEY,
)),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/v1/test-connection?provider=provider-auth-required&model=gpt-5&api_format=openai:chat"
))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "缺少用户凭证");
assert_eq!(*provider_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
provider_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_non_admin_test_connection_before_using_provider_credentials() {
let provider_hits = Arc::new(Mutex::new(0usize));
let provider_hits_clone = Arc::clone(&provider_hits);
let provider = Router::new().route(
"/{*path}",
any(move |_request: Request| {
let provider_hits_inner = Arc::clone(&provider_hits_clone);
async move {
*provider_hits_inner.lock().expect("mutex should lock") += 1;
Json(json!({"id": "must-not-run"})).into_response()
}
}),
);
let (provider_url, provider_handle) = start_server(provider).await;
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-admin-only", "openai", 10)],
vec![sample_endpoint(
"endpoint-admin-only",
"provider-admin-only",
"openai:chat",
&provider_url,
)],
vec![sample_key(
"key-admin-only",
"provider-admin-only",
"openai:chat",
"stored-provider-secret",
)],
));
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-test-connection-non-admin"),
),
]),
now + chrono::Duration::hours(1),
);
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_transport_reader_for_tests(
provider_catalog_repository,
DEVELOPMENT_ENCRYPTION_KEY,
)
.with_user_reader(user_repository),
)
.with_auth_session_for_tests(sample_auth_session(
"user-auth-1",
"session-test-connection-non-admin",
"device-test-connection-non-admin",
"refresh-token-placeholder",
now,
)),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/v1/test-connection?provider=provider-admin-only&model=gpt-5&api_format=openai:chat"
))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-test-connection-non-admin",
)
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "仅管理员可以测试供应商连接");
assert_eq!(*provider_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
provider_handle.abort();
}
#[tokio::test]
async fn gateway_handles_authenticated_test_connection_without_hitting_fallback_probe() {
let fallback_probe_hits = Arc::new(Mutex::new(0usize));
let fallback_probe_hits_clone = Arc::clone(&fallback_probe_hits);
let fallback_probe = Router::new().route(
"/{*path}",
any(move |_request: Request| {
let fallback_probe_hits_inner = Arc::clone(&fallback_probe_hits_clone);
async move {
*fallback_probe_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("proxied"))
}
}),
);
let provider_hits = Arc::new(Mutex::new(0usize));
let provider_hits_clone = Arc::clone(&provider_hits);
let provider = Router::new().route(
"/chat/completions",
any(move |request: Request| {
let provider_hits_inner = Arc::clone(&provider_hits_clone);
async move {
*provider_hits_inner.lock().expect("mutex should lock") += 1;
let headers = request.headers().clone();
let body = to_bytes(request.into_body(), usize::MAX)
.await
.expect("body should read");
let body_json: serde_json::Value =
serde_json::from_slice(&body).expect("json body should parse");
assert_eq!(
headers
.get("authorization")
.and_then(|value| value.to_str().ok()),
Some("Bearer sk-test-openai"),
);
assert_eq!(body_json["model"], "gpt-5");
assert_eq!(body_json["messages"][0]["content"], "Health check");
assert!(
body_json.get("max_tokens").is_none(),
"public OpenAI-compatible test connection must not force a tiny max_tokens value"
);
Json(json!({"id": "resp_local_test"})).into_response()
}
}),
);
let (provider_url, provider_handle) = start_server(provider).await;
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-1", "openai", 10)],
vec![sample_endpoint(
"endpoint-1",
"provider-1",
"openai:chat",
&provider_url,
)],
vec![sample_key(
"key-1",
"provider-1",
"openai:chat",
"sk-test-openai",
)],
));
let (_unused_fallback_probe_url, fallback_probe_handle) = start_server(fallback_probe).await;
let now = Utc::now();
let mut user = sample_auth_user(now);
user.role = "admin".to_string();
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-test-connection-openai"),
),
]),
now + chrono::Duration::hours(1),
);
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_provider_catalog_repository_for_tests(
provider_catalog_repository,
)
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY)
.with_user_reader(user_repository),
)
.with_auth_session_for_tests(sample_auth_session(
"user-auth-1",
"session-test-connection-openai",
"device-test-connection-openai",
"refresh-token-placeholder",
now,
)),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/v1/test-connection?provider=provider-1&model=gpt-5&api_format=openai:chat"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-test-connection-openai")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["status"], "success");
assert!(payload.get("provider").is_none());
assert_eq!(payload["provider_id"], "provider-1");
assert_eq!(payload["api_format"], "openai:chat");
assert_eq!(payload["response_id"], "resp_local_test");
assert_eq!(*provider_hits.lock().expect("mutex should lock"), 1);
assert_eq!(*fallback_probe_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
fallback_probe_handle.abort();
provider_handle.abort();
}
#[tokio::test]
async fn gateway_gemini_test_connection_does_not_force_low_max_output_tokens() {
let provider_hits = Arc::new(Mutex::new(0usize));
let provider_hits_clone = Arc::clone(&provider_hits);
let provider = Router::new().route(
"/{*path}",
any(move |request: Request| {
let provider_hits_inner = Arc::clone(&provider_hits_clone);
async move {
*provider_hits_inner.lock().expect("mutex should lock") += 1;
let body = to_bytes(request.into_body(), usize::MAX)
.await
.expect("body should read");
let body_json: serde_json::Value =
serde_json::from_slice(&body).expect("json body should parse");
assert_eq!(body_json["contents"][0]["parts"][0]["text"], "Health check");
assert!(
body_json
.get("generationConfig")
.and_then(|config| config.get("maxOutputTokens"))
.is_none(),
"Gemini test connection must not force a tiny maxOutputTokens value"
);
Json(json!({
"candidates": [{
"content": {
"role": "model",
"parts": [{"text": "ok"}]
},
"finishReason": "STOP"
}],
"responseId": "gemini_test_connection_ok"
}))
.into_response()
}
}),
);
let (provider_url, provider_handle) = start_server(provider).await;
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-gemini", "google", 10)],
vec![sample_endpoint(
"endpoint-gemini",
"provider-gemini",
"gemini:generate_content",
&provider_url,
)],
vec![sample_key(
"key-gemini",
"provider-gemini",
"gemini:generate_content",
"google-api-key",
)],
));
let now = Utc::now();
let mut user = sample_auth_user(now);
user.role = "admin".to_string();
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-test-connection-gemini"),
),
]),
now + chrono::Duration::hours(1),
);
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_repository_for_tests(
provider_catalog_repository,
)
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY)
.with_user_reader(user_repository),
)
.with_auth_session_for_tests(sample_auth_session(
"user-auth-1",
"session-test-connection-gemini",
"device-test-connection-gemini",
"refresh-token-placeholder",
now,
)),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/v1/test-connection?provider=provider-gemini&model=gemini-3-flash-preview&api_format=gemini:generate_content"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-test-connection-gemini")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["status"], "success");
assert_eq!(payload["provider_id"], "provider-gemini");
assert_eq!(payload["endpoint_id"], "endpoint-gemini");
assert_eq!(payload["api_format"], "gemini:generate_content");
assert_eq!(*provider_hits.lock().expect("mutex should lock"), 1);
gateway_handle.abort();
provider_handle.abort();
}
async fn assert_public_support_route_returns_local_503(
method: reqwest::Method,
path: &str,
body: Option<serde_json::Value>,
expected_detail: &str,
) {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router().expect("gateway should build");
let (gateway_url, gateway_handle) = start_server(gateway).await;
let client = reqwest::Client::new();
let url = format!("{gateway_url}{path}");
let request = client.request(method, url);
let request = if let Some(body) = body {
request.json(&body)
} else {
request
};
let response = request.send().await.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], expected_detail);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
async fn assert_public_support_route_returns_local_503_with_auth(
method: reqwest::Method,
path: &str,
body: Option<serde_json::Value>,
expected_detail: &str,
) {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-maintenance-1")),
]),
chrono::Utc::now() + chrono::Duration::hours(1),
);
let session = sample_auth_session(
"user-auth-1",
"session-maintenance-1",
"device-maintenance-1",
"refresh-maintenance-1",
now,
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(user, sample_auth_wallet("user-auth-1", now), [session])
.await;
let client = reqwest::Client::new();
let url = format!("{gateway_url}{path}");
let request = client
.request(method, url)
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-maintenance-1")
.header("user-agent", "AetherTest/1.0");
let request = if let Some(body) = body {
request.json(&body)
} else {
request
};
let response = request.send().await.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], expected_detail);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
async fn assert_local_route_not_found_response(response: reqwest::Response) {
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["error"]["type"], "http_error");
assert_eq!(payload["error"]["message"], "Route not found");
}
fn test_auth_secret() -> String {
std::env::var("JWT_SECRET_KEY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "aether-rust-test-jwt-secret-32-bytes-minimum".to_string())
}
fn test_base64url_encode(bytes: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
fn build_test_auth_token(
token_type: &str,
mut payload: serde_json::Map<String, serde_json::Value>,
expires_at: chrono::DateTime<chrono::Utc>,
) -> String {
use hmac::Mac;
let header = json!({ "alg": "HS256", "typ": "JWT" });
payload.insert("exp".to_string(), json!(expires_at.timestamp()));
payload.insert("type".to_string(), json!(token_type));
let header_segment = test_base64url_encode(
serde_json::to_vec(&header)
.expect("jwt header should serialize")
.as_slice(),
);
let payload_segment = test_base64url_encode(
serde_json::to_vec(&payload)
.expect("jwt payload should serialize")
.as_slice(),
);
let signing_input = format!("{header_segment}.{payload_segment}");
let mut mac = hmac::Hmac::<sha2::Sha256>::new_from_slice(test_auth_secret().as_bytes())
.expect("jwt secret should build");
mac.update(signing_input.as_bytes());
let signature = mac.finalize().into_bytes();
format!(
"{header_segment}.{payload_segment}.{}",
test_base64url_encode(signature.as_slice())
)
}
struct TestEnvVarGuard {
key: &'static str,
previous: Option<String>,
}
impl Drop for TestEnvVarGuard {
fn drop(&mut self) {
if let Some(previous) = self.previous.as_deref() {
std::env::set_var(self.key, previous);
} else {
std::env::remove_var(self.key);
}
}
}
fn set_test_env_var(key: &'static str, value: &str) -> TestEnvVarGuard {
let previous = std::env::var(key).ok();
std::env::set_var(key, value);
TestEnvVarGuard { key, previous }
}
#[cfg(test)]
fn payment_callback_env_lock() -> &'static tokio::sync::Mutex<()> {
static LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
&LOCK
}
const TEST_PAYMENT_CALLBACK_SECRET: &str = "test-callback-secret-0123456789abcdef";
fn canonicalize_test_json(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut entries = map.iter().collect::<Vec<_>>();
entries.sort_by(|left, right| left.0.cmp(right.0));
let mut object = serde_json::Map::new();
for (key, value) in entries {
object.insert(key.clone(), canonicalize_test_json(value));
}
serde_json::Value::Object(object)
}
serde_json::Value::Array(items) => {
serde_json::Value::Array(items.iter().map(canonicalize_test_json).collect())
}
_ => value.clone(),
}
}
fn build_test_payment_callback_signature(payload: &serde_json::Value, secret: &str) -> String {
use hmac::Mac;
let canonical = serde_json::to_string(&canonicalize_test_json(payload))
.expect("payment callback payload should serialize");
let mut mac = hmac::Hmac::<sha2::Sha256>::new_from_slice(secret.as_bytes())
.expect("payment callback secret should build");
mac.update(canonical.as_bytes());
let signature = mac.finalize().into_bytes();
signature.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn sample_auth_user(now: chrono::DateTime<chrono::Utc>) -> StoredUserAuthRecord {
StoredUserAuthRecord::new(
"user-auth-1".to_string(),
Some("[email protected]".to_string()),
true,
"alice".to_string(),
Some("$2y$10$.OBQfixAECpsb8V/VS3csOMf00x2E/jD/gnud20t6RG0yiQosyOZ2".to_string()),
"user".to_string(),
"local".to_string(),
Some(json!(["openai"])),
Some(json!(["openai:chat"])),
Some(json!(["gpt-5"])),
true,
false,
Some(now),
Some(now),
)
.expect("auth user should build")
}
fn sample_users_me_management_token(
token_id: &str,
user_id: &str,
username: &str,
is_active: bool,
) -> StoredManagementTokenWithUser {
let token = StoredManagementToken::new(
token_id.to_string(),
user_id.to_string(),
format!("{username}-token"),
)
.expect("management token should build")
.with_display_fields(
Some(format!("{username} token")),
Some("ae_test".to_string()),
Some(json!(["127.0.0.1"])),
)
.with_runtime_fields(
Some(4_102_444_800),
Some(1_711_000_000),
Some("127.0.0.1".to_string()),
7,
is_active,
)
.with_timestamps(Some(1_710_000_000), Some(1_711_000_100));
let user = StoredManagementTokenUserSummary::new(
user_id.to_string(),
Some(format!("{username}@example.com")),
username.to_string(),
"user".to_string(),
)
.expect("management token user should build");
StoredManagementTokenWithUser::new(token, user)
}
fn sample_auth_wallet(user_id: &str, now: chrono::DateTime<chrono::Utc>) -> StoredWalletSnapshot {
StoredWalletSnapshot::new(
"wallet-auth-1".to_string(),
Some(user_id.to_string()),
None,
12.5,
3.0,
"finite".to_string(),
"USD".to_string(),
"active".to_string(),
20.0,
4.5,
0.0,
0.0,
now.timestamp(),
)
.expect("wallet should build")
}
fn sample_standalone_auth_wallet(
api_key_id: &str,
now: chrono::DateTime<chrono::Utc>,
) -> StoredWalletSnapshot {
StoredWalletSnapshot::new(
"wallet-standalone-1".to_string(),
None,
Some(api_key_id.to_string()),
2.0,
0.5,
"finite".to_string(),
"USD".to_string(),
"active".to_string(),
5.0,
2.5,
0.0,
0.0,
now.timestamp(),
)
.expect("standalone wallet should build")
}
fn wallet_today_usage_test_time() -> chrono::DateTime<chrono::Utc> {
let offset =
chrono::FixedOffset::east_opt(8 * 3600).expect("Asia/Shanghai test offset should be valid");
let local_today = Utc::now().with_timezone(&offset).date_naive();
let local_noon = local_today
.and_hms_opt(12, 0, 0)
.expect("wallet today test noon should be valid");
offset
.from_local_datetime(&local_noon)
.single()
.expect("fixed offset local noon should be unambiguous")
.with_timezone(&Utc)
}
fn sample_auth_session(
user_id: &str,
session_id: &str,
client_device_id: &str,
refresh_token: &str,
now: chrono::DateTime<chrono::Utc>,
) -> crate::data::state::StoredUserSessionRecord {
crate::data::state::StoredUserSessionRecord::new(
session_id.to_string(),
user_id.to_string(),
client_device_id.to_string(),
None,
crate::data::state::StoredUserSessionRecord::hash_refresh_token(refresh_token),
None,
None,
Some(now),
Some(now + chrono::Duration::days(7)),
None,
None,
Some("127.0.0.1".to_string()),
Some("AetherTest/1.0".to_string()),
Some(now),
Some(now),
)
.expect("auth session should build")
}
fn sample_usage_auth_snapshot(
api_key_id: &str,
user_id: &str,
api_key_name: &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-5"])),
api_key_id.to_string(),
Some(api_key_name.to_string()),
true,
false,
false,
Some(60),
Some(5),
None,
Some(json!(["openai"])),
Some(json!(["openai:chat"])),
Some(json!(["gpt-5"])),
)
.expect("auth api key snapshot should build")
}
async fn start_auth_gateway_with_state(
user: StoredUserAuthRecord,
wallet: StoredWalletSnapshot,
sessions: impl IntoIterator<Item = crate::data::state::StoredUserSessionRecord>,
) -> (
String,
Arc<Mutex<usize>>,
tokio::task::JoinHandle<()>,
tokio::task::JoinHandle<()>,
) {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let wallet_repository = Arc::new(InMemoryWalletRepository::seed(vec![wallet]));
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_user_and_wallet_for_tests(
user_repository,
wallet_repository,
),
)
.with_auth_sessions_for_tests(sessions);
let gateway = build_router_with_state(state);
let (gateway_url, gateway_handle) = start_server(gateway).await;
(gateway_url, upstream_hits, gateway_handle, upstream_handle)
}
async fn start_auth_gateway_with_usage_state<T>(
user: StoredUserAuthRecord,
wallet: StoredWalletSnapshot,
sessions: impl IntoIterator<Item = crate::data::state::StoredUserSessionRecord>,
usage_repository: Arc<T>,
) -> (
String,
Arc<Mutex<usize>>,
tokio::task::JoinHandle<()>,
tokio::task::JoinHandle<()>,
)
where
T: UsageRepository + 'static,
{
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let wallet_repository = Arc::new(InMemoryWalletRepository::seed(vec![wallet]));
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_user_wallet_and_usage_for_tests(
user_repository,
wallet_repository,
usage_repository,
),
)
.with_auth_sessions_for_tests(sessions);
let gateway = build_router_with_state(state);
let (gateway_url, gateway_handle) = start_server(gateway).await;
(gateway_url, upstream_hits, gateway_handle, upstream_handle)
}
async fn start_auth_dashboard_gateway_with_state<TUsage, TCatalog>(
user: StoredUserAuthRecord,
wallet: StoredWalletSnapshot,
sessions: impl IntoIterator<Item = crate::data::state::StoredUserSessionRecord>,
usage_repository: Arc<TUsage>,
provider_catalog_repository: Arc<TCatalog>,
) -> (
String,
Arc<Mutex<usize>>,
tokio::task::JoinHandle<()>,
tokio::task::JoinHandle<()>,
)
where
TUsage: UsageRepository + 'static,
TCatalog: ProviderCatalogReadRepository + 'static,
{
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let wallet_repository = Arc::new(InMemoryWalletRepository::seed(vec![wallet]));
let data_state = crate::data::GatewayDataState::with_user_wallet_and_usage_for_tests(
user_repository,
wallet_repository,
usage_repository,
)
.with_provider_catalog_reader(provider_catalog_repository);
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests(sessions);
let gateway = build_router_with_state(state);
let (gateway_url, gateway_handle) = start_server(gateway).await;
(gateway_url, upstream_hits, gateway_handle, upstream_handle)
}
async fn start_auth_gateway_with_preferences_state(
user: StoredUserAuthRecord,
wallet: StoredWalletSnapshot,
sessions: impl IntoIterator<Item = crate::data::state::StoredUserSessionRecord>,
preferences: impl IntoIterator<Item = crate::data::state::StoredUserPreferenceRecord>,
) -> (
String,
Arc<Mutex<usize>>,
tokio::task::JoinHandle<()>,
tokio::task::JoinHandle<()>,
) {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let wallet_repository = Arc::new(InMemoryWalletRepository::seed(vec![wallet]));
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_user_and_wallet_for_tests(
user_repository,
wallet_repository,
)
.with_user_preferences_for_tests(preferences),
)
.with_auth_sessions_for_tests(sessions);
let gateway = build_router_with_state(state);
let (gateway_url, gateway_handle) = start_server(gateway).await;
(gateway_url, upstream_hits, gateway_handle, upstream_handle)
}
fn sample_user_usage_audit(
id: &str,
request_id: &str,
user_id: &str,
model: &str,
provider_name: &str,
status: &str,
created_at: chrono::DateTime<chrono::Utc>,
) -> StoredRequestUsageAudit {
let mut usage = StoredRequestUsageAudit::new(
id.to_string(),
request_id.to_string(),
Some(user_id.to_string()),
Some("api-key-user-1".to_string()),
Some("alice".to_string()),
Some("default".to_string()),
provider_name.to_string(),
model.to_string(),
Some(format!("{model}-target")),
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",
120,
30,
150,
1.25,
1.25,
Some(if status == "failed" { 500 } else { 200 }),
if status == "failed" {
Some("upstream failed".to_string())
} else {
None
},
None,
Some(420),
Some(120),
status.to_string(),
if matches!(status, "completed" | "failed" | "cancelled") {
"settled".to_string()
} else {
"pending".to_string()
},
created_at.timestamp(),
created_at.timestamp() + 1,
Some(created_at.timestamp() + 2),
)
.expect("usage should build");
usage.cache_creation_input_tokens = 10;
usage.cache_creation_ephemeral_5m_input_tokens = 4;
usage.cache_creation_ephemeral_1h_input_tokens = 6;
usage.cache_read_input_tokens = 15;
usage.cache_creation_cost_usd = 0.05;
usage.cache_read_cost_usd = 0.02;
usage
}
async fn start_auth_announcement_gateway_with_state<T>(
user: StoredUserAuthRecord,
wallet: StoredWalletSnapshot,
sessions: impl IntoIterator<Item = crate::data::state::StoredUserSessionRecord>,
announcement_repository: Arc<T>,
) -> (
String,
Arc<Mutex<usize>>,
tokio::task::JoinHandle<()>,
tokio::task::JoinHandle<()>,
)
where
T: AnnouncementReadRepository
+ aether_data::repository::announcements::AnnouncementWriteRepository
+ 'static,
{
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let wallet_repository = Arc::new(InMemoryWalletRepository::seed(vec![wallet]));
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_announcement_user_and_wallet_for_tests(
announcement_repository,
user_repository,
wallet_repository,
),
)
.with_auth_sessions_for_tests(sessions);
let gateway = build_router_with_state(state);
let (gateway_url, gateway_handle) = start_server(gateway).await;
(gateway_url, upstream_hits, gateway_handle, upstream_handle)
}
async fn start_auth_gateway_with_builder<F>(
build_state: F,
) -> (
String,
Arc<Mutex<usize>>,
tokio::task::JoinHandle<()>,
tokio::task::JoinHandle<()>,
)
where
F: FnOnce() -> AppState,
{
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let (_unused_fallback_probe_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(build_state());
let (gateway_url, gateway_handle) = start_server(gateway).await;
(gateway_url, upstream_hits, gateway_handle, upstream_handle)
}
#[tokio::test]
async fn gateway_handles_user_monitoring_audit_logs_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-monitoring-audit-logs"),
),
]),
now + chrono::Duration::hours(1),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-monitoring-audit-logs",
"device-monitoring-audit-logs",
"refresh-monitoring-audit-logs",
now,
)],
)
.await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/monitoring/my-audit-logs?days=30&limit=50&offset=0"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-monitoring-audit-logs")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["items"], json!([]));
assert_eq!(payload["meta"]["total"], 0);
assert_eq!(payload["meta"]["limit"], 50);
assert_eq!(payload["meta"]["offset"], 0);
assert_eq!(payload["meta"]["count"], 0);
assert_eq!(payload["filters"]["days"], 30);
assert_eq!(payload["filters"]["event_type"], serde_json::Value::Null);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_user_monitoring_rate_limit_status_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-monitoring-rate-limit-status"),
),
]),
now + chrono::Duration::hours(1),
);
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some("hash-monitoring-key-1".to_string()),
StoredAuthApiKeySnapshot::new(
"user-auth-1".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-5"])),
"api-key-monitoring-1".to_string(),
Some("monitoring-key".to_string()),
true,
false,
false,
Some(30),
Some(5),
None,
None,
None,
None,
)
.expect("auth api key snapshot should build")
.with_user_rate_limit(Some(80)),
)]));
let user_repository: Arc<dyn UserReadRepository> =
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
sample_auth_user(now),
]));
let group = user_repository
.create_user_group(UpsertUserGroupRecord {
name: "Monitoring Limits".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: Some(80),
rate_limit_mode: "custom".to_string(),
})
.await
.expect("group should create")
.expect("group should exist");
user_repository
.add_user_to_group(&group.id, "user-auth-1")
.await
.expect("group membership should create");
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = crate::data::GatewayDataState::with_auth_api_key_repository_for_tests(
auth_repository,
)
.with_user_reader(Arc::clone(&user_repository));
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-monitoring-rate-limit-status",
"device-monitoring-rate-limit-status",
"refresh-monitoring-rate-limit-status",
now,
)])
})
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/monitoring/rate-limit-status"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-monitoring-rate-limit-status")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["user_id"], "user-auth-1");
let api_keys = payload["api_keys"]
.as_array()
.expect("api_keys should be array");
assert_eq!(api_keys.len(), 1);
assert_eq!(api_keys[0]["api_key_name"], "monitoring-key");
assert_eq!(api_keys[0]["limit"], 30);
assert_eq!(api_keys[0]["remaining"], 30);
assert_eq!(api_keys[0]["scope"], "key");
assert_eq!(api_keys[0]["user_limit"], 80);
assert_eq!(api_keys[0]["user_remaining"], 80);
assert_eq!(api_keys[0]["key_limit"], 30);
assert_eq!(api_keys[0]["key_remaining"], 30);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_reads_announcement_unread_count_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-announcement-user-1"),
),
]),
now + chrono::Duration::hours(1),
);
let announcement_repository = Arc::new(InMemoryAnnouncementReadRepository::seed_with_reads(
vec![
StoredAnnouncement::new(
"announcement-unread".to_string(),
"未读公告".to_string(),
"仍然未读".to_string(),
"info".to_string(),
10,
true,
false,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,
None,
now.timestamp(),
now.timestamp(),
)
.expect("announcement should build"),
StoredAnnouncement::new(
"announcement-read".to_string(),
"已读公告".to_string(),
"已经读过".to_string(),
"maintenance".to_string(),
8,
true,
true,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,
None,
now.timestamp(),
now.timestamp(),
)
.expect("announcement should build"),
StoredAnnouncement::new(
"announcement-inactive".to_string(),
"停用公告".to_string(),
"不计入未读".to_string(),
"warning".to_string(),
6,
false,
false,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,
None,
now.timestamp(),
now.timestamp(),
)
.expect("announcement should build"),
],
[("user-auth-1".to_string(), "announcement-read".to_string())],
));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_announcement_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-announcement-user-1",
"device-announcement-user-1",
"refresh-token-placeholder",
now,
)],
announcement_repository,
)
.await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/announcements/users/me/unread-count"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-announcement-user-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["unread_count"], 1);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_lists_required_unread_announcements_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-announcement-required-1"),
),
]),
now + chrono::Duration::hours(1),
);
let announcement_repository = Arc::new(InMemoryAnnouncementReadRepository::seed_with_reads(
vec![
StoredAnnouncement::new(
"announcement-required".to_string(),
"必读公告".to_string(),
"需要确认".to_string(),
"important".to_string(),
20,
true,
false,
true,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,
None,
now.timestamp(),
now.timestamp(),
)
.expect("announcement should build"),
StoredAnnouncement::new(
"announcement-normal".to_string(),
"普通公告".to_string(),
"不需要弹窗".to_string(),
"info".to_string(),
10,
true,
false,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,
None,
now.timestamp(),
now.timestamp(),
)
.expect("announcement should build"),
StoredAnnouncement::new(
"announcement-read-required".to_string(),
"已读必读公告".to_string(),
"已经确认".to_string(),
"warning".to_string(),
8,
true,
false,
true,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,
None,
now.timestamp(),
now.timestamp(),
)
.expect("announcement should build"),
],
[(
"user-auth-1".to_string(),
"announcement-read-required".to_string(),
)],
));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_announcement_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-announcement-required-1",
"device-announcement-required-1",
"refresh-token-placeholder",
now,
)],
announcement_repository,
)
.await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/announcements/users/me/required-unread"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-announcement-required-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["total"], 1);
assert_eq!(payload["items"][0]["id"], "announcement-required");
assert_eq!(payload["items"][0]["requires_ack"], true);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_marks_announcement_read_status_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-announcement-user-2"),
),
]),
now + chrono::Duration::hours(1),
);
let announcement_repository = Arc::new(InMemoryAnnouncementReadRepository::seed(vec![
StoredAnnouncement::new(
"announcement-to-read".to_string(),
"待读公告".to_string(),
"读完就归零".to_string(),
"important".to_string(),
20,
true,
true,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,
None,
now.timestamp(),
now.timestamp(),
)
.expect("announcement should build"),
]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_announcement_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-announcement-user-2",
"device-announcement-user-2",
"refresh-token-placeholder",
now,
)],
announcement_repository,
)
.await;
let client = reqwest::Client::new();
let patch_response = client
.patch(format!(
"{gateway_url}/api/announcements/announcement-to-read/read-status"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-announcement-user-2")
.header("user-agent", "AetherTest/1.0")
.json(&json!({ "is_read": true }))
.send()
.await
.expect("patch request should succeed");
assert_eq!(patch_response.status(), StatusCode::OK);
let patch_payload: serde_json::Value =
patch_response.json().await.expect("json body should parse");
assert_eq!(patch_payload["message"], "公告已标记为已读");
let unread_response = client
.get(format!(
"{gateway_url}/api/announcements/users/me/unread-count"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-announcement-user-2")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("count request should succeed");
assert_eq!(unread_response.status(), StatusCode::OK);
let unread_payload: serde_json::Value = unread_response
.json()
.await
.expect("json body should parse");
assert_eq!(unread_payload["unread_count"], 0);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_marks_all_announcements_read_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-announcement-user-3"),
),
]),
now + chrono::Duration::hours(1),
);
let announcement_repository = Arc::new(InMemoryAnnouncementReadRepository::seed(vec![
StoredAnnouncement::new(
"announcement-a".to_string(),
"公告 A".to_string(),
"内容 A".to_string(),
"info".to_string(),
10,
true,
false,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,
None,
now.timestamp(),
now.timestamp(),
)
.expect("announcement should build"),
StoredAnnouncement::new(
"announcement-b".to_string(),
"公告 B".to_string(),
"内容 B".to_string(),
"warning".to_string(),
8,
false,
false,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,
None,
now.timestamp(),
now.timestamp(),
)
.expect("announcement should build"),
StoredAnnouncement::new(
"announcement-c".to_string(),
"公告 C".to_string(),
"内容 C".to_string(),
"important".to_string(),
6,
true,
true,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,
None,
now.timestamp(),
now.timestamp(),
)
.expect("announcement should build"),
]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_announcement_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-announcement-user-3",
"device-announcement-user-3",
"refresh-token-placeholder",
now,
)],
announcement_repository,
)
.await;
let client = reqwest::Client::new();
let mark_response = client
.post(format!("{gateway_url}/api/announcements/read-all"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-announcement-user-3")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("post request should succeed");
assert_eq!(mark_response.status(), StatusCode::OK);
let mark_payload: serde_json::Value =
mark_response.json().await.expect("json body should parse");
assert_eq!(mark_payload["message"], "已全部标记为已读");
let unread_response = client
.get(format!(
"{gateway_url}/api/announcements/users/me/unread-count"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-announcement-user-3")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("count request should succeed");
assert_eq!(unread_response.status(), StatusCode::OK);
let unread_payload: serde_json::Value = unread_response
.json()
.await
.expect("json body should parse");
assert_eq!(unread_payload["unread_count"], 0);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_announcement_user_routes_with_trailing_slash_locally() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-announcement-user-4"),
),
]),
now + chrono::Duration::hours(1),
);
let announcement_repository = Arc::new(InMemoryAnnouncementReadRepository::seed(vec![
StoredAnnouncement::new(
"announcement-trailing".to_string(),
"尾斜杠公告".to_string(),
"路径一致性".to_string(),
"info".to_string(),
10,
true,
false,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,
None,
now.timestamp(),
now.timestamp(),
)
.expect("announcement should build"),
]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_announcement_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-announcement-user-4",
"device-announcement-user-4",
"refresh-token-placeholder",
now,
)],
announcement_repository,
)
.await;
let client = reqwest::Client::new();
let unread_before = client
.get(format!(
"{gateway_url}/api/announcements/users/me/unread-count/"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-announcement-user-4")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(unread_before.status(), StatusCode::OK);
let unread_before_payload: serde_json::Value =
unread_before.json().await.expect("json body should parse");
assert_eq!(unread_before_payload["unread_count"], 1);
let patch_response = client
.patch(format!(
"{gateway_url}/api/announcements/announcement-trailing/read-status/"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-announcement-user-4")
.header("user-agent", "AetherTest/1.0")
.json(&json!({ "is_read": true }))
.send()
.await
.expect("patch request should succeed");
assert_eq!(patch_response.status(), StatusCode::OK);
let unread_after = client
.get(format!(
"{gateway_url}/api/announcements/users/me/unread-count/"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-announcement-user-4")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(unread_after.status(), StatusCode::OK);
let unread_after_payload: serde_json::Value =
unread_after.json().await.expect("json body should parse");
assert_eq!(unread_after_payload["unread_count"], 0);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_returns_not_found_for_missing_announcement_read_status_locally() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-announcement-user-3"),
),
]),
now + chrono::Duration::hours(1),
);
let announcement_repository = Arc::new(InMemoryAnnouncementReadRepository::seed(vec![]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_announcement_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-announcement-user-3",
"device-announcement-user-3",
"refresh-token-placeholder",
now,
)],
announcement_repository,
)
.await;
let response = reqwest::Client::new()
.patch(format!(
"{gateway_url}/api/announcements/missing-announcement/read-status"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-announcement-user-3")
.header("user-agent", "AetherTest/1.0")
.json(&json!({ "is_read": true }))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "Announcement not found");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_cannot_mark_future_required_announcement_as_read() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-announcement-future"),
),
]),
now + chrono::Duration::hours(1),
);
let starts_at = now.timestamp().max(0) as u64 + 3600;
let announcement_repository = Arc::new(InMemoryAnnouncementReadRepository::seed(vec![
StoredAnnouncement::new(
"announcement-future-required".to_string(),
"Future required notice".to_string(),
"Not public yet".to_string(),
"warning".to_string(),
10,
true,
false,
true,
Some("admin-1".to_string()),
Some("admin".to_string()),
Some(starts_at as i64),
None,
now.timestamp(),
now.timestamp(),
)
.expect("announcement should build"),
]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_announcement_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-announcement-future",
"device-announcement-future",
"refresh-token-placeholder",
now,
)],
Arc::clone(&announcement_repository),
)
.await;
let response = reqwest::Client::new()
.patch(format!(
"{gateway_url}/api/announcements/announcement-future-required/read-status"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-announcement-future")
.header("user-agent", "AetherTest/1.0")
.json(&json!({ "is_read": true }))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "Announcement not found");
assert_eq!(
announcement_repository
.count_unread_active_announcements("user-auth-1", starts_at)
.await
.expect("unread count should load"),
1
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_invalid_nested_announcement_paths_as_local_not_found_without_hitting_upstream(
) {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-announcement-user-invalid-nested"),
),
]),
now + chrono::Duration::hours(1),
);
let announcement_repository = Arc::new(InMemoryAnnouncementReadRepository::seed(vec![
StoredAnnouncement::new(
"announcement-nested".to_string(),
"嵌套路由公告".to_string(),
"用于校验无效 announcement 子路径".to_string(),
"info".to_string(),
10,
true,
false,
false,
Some("admin-1".to_string()),
Some("admin".to_string()),
None,
None,
now.timestamp(),
now.timestamp(),
)
.expect("announcement should build"),
]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_announcement_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-announcement-user-invalid-nested",
"device-announcement-user-invalid-nested",
"refresh-token-placeholder",
now,
)],
announcement_repository,
)
.await;
let client = reqwest::Client::new();
let detail_response = client
.get(format!(
"{gateway_url}/api/announcements/announcement-nested/history"
))
.send()
.await
.expect("request should succeed");
assert_local_route_not_found_response(detail_response).await;
let read_status_response = client
.patch(format!(
"{gateway_url}/api/announcements/announcement-nested/history/read-status"
))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-announcement-user-invalid-nested",
)
.header("user-agent", "AetherTest/1.0")
.json(&json!({ "is_read": true }))
.send()
.await
.expect("request should succeed");
assert_local_route_not_found_response(read_status_response).await;
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_redeems_wallet_code_locally() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id.clone())),
("role".to_string(), json!(user.role.clone())),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-wallet-redeem-1")),
]),
now + chrono::Duration::hours(1),
);
let wallet_repository = Arc::new(InMemoryWalletRepository::seed(vec![sample_auth_wallet(
"user-auth-1",
now,
)]));
let batch = wallet_repository
.create_admin_redeem_code_batch(
aether_data::repository::wallet::CreateAdminRedeemCodeBatchInput {
name: "测试兑换".to_string(),
amount_usd: 6.5,
currency: "USD".to_string(),
balance_bucket: "gift".to_string(),
total_count: 1,
expires_at_unix_secs: None,
description: Some("public support".to_string()),
created_by: Some("admin-user-1".to_string()),
},
)
.await
.expect("batch should create");
let redeem_code = batch.codes[0].code.clone();
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_user_and_wallet_for_tests(
user_repository,
wallet_repository,
),
)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-wallet-redeem-1",
"device-wallet-redeem-1",
"refresh-token-wallet-redeem-1",
now,
)]);
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/wallet/redeem"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-redeem-1")
.header("user-agent", "AetherTest/1.0")
.json(&json!({ "code": redeem_code }))
.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["batch_name"], "测试兑换");
assert_eq!(payload["amount_usd"], 6.5);
assert_eq!(payload["order"]["payment_method"], "gift_code");
assert_eq!(payload["order"]["refundable_amount_usd"], 0.0);
assert_eq!(payload["wallet"]["total_recharged"], 26.5);
assert_eq!(payload["wallet"]["recharge_balance"], 12.5);
assert_eq!(payload["wallet"]["gift_balance"], 9.5);
assert_eq!(payload["wallet"]["refundable_balance"], 12.5);
gateway_handle.abort();
}
#[tokio::test]
async fn gateway_creates_wallet_recharge_orders_locally_without_proxying_upstream() {
let now = Utc::now();
let user = StoredUserAuthRecord::new(
"user-wallet-recharge-create".to_string(),
Some("[email protected]".to_string()),
true,
"wallet_recharge_create_user".to_string(),
Some("$2y$10$.OBQfixAECpsb8V/VS3csOMf00x2E/jD/gnud20t6RG0yiQosyOZ2".to_string()),
"user".to_string(),
"local".to_string(),
Some(json!(["openai"])),
Some(json!(["openai:chat"])),
Some(json!(["gpt-5"])),
true,
false,
Some(now),
Some(now),
)
.expect("auth user should build");
let wallet = StoredWalletSnapshot::new(
"wallet-recharge-create".to_string(),
Some(user.id.clone()),
None,
12.5,
3.0,
"finite".to_string(),
"USD".to_string(),
"active".to_string(),
20.0,
4.5,
0.0,
0.0,
now.timestamp(),
)
.expect("wallet should build");
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id.clone())),
("role".to_string(), json!(user.role.clone())),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-wallet-recharge-create-1"),
),
]),
now + chrono::Duration::hours(1),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
wallet,
[sample_auth_session(
"user-wallet-recharge-create",
"session-wallet-recharge-create-1",
"device-wallet-recharge-create-1",
"refresh-token-placeholder",
now,
)],
)
.await;
let client = reqwest::Client::new();
let create_response = client
.post(format!("{gateway_url}/api/wallet/recharge"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-recharge-create-1")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"amount_usd": 10.0,
"payment_method": "manual",
"pay_amount": 72.5,
"pay_currency": "CNY",
"exchange_rate": 7.25,
}))
.send()
.await
.expect("request should succeed");
assert_eq!(create_response.status(), StatusCode::OK);
assert_eq!(
create_response
.headers()
.get("cache-control")
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let create_payload: serde_json::Value = create_response
.json()
.await
.expect("json body should parse");
let order_id = create_payload["order"]["id"]
.as_str()
.expect("order id should exist")
.to_string();
assert_eq!(
create_payload["order"]["wallet_id"],
"wallet-recharge-create"
);
assert_eq!(create_payload["order"]["amount_usd"], 10.0);
assert_eq!(create_payload["order"]["pay_amount"], 72.5);
assert_eq!(create_payload["order"]["pay_currency"], "CNY");
assert_eq!(create_payload["order"]["exchange_rate"], 7.25);
assert_eq!(create_payload["order"]["payment_method"], "manual");
assert_eq!(create_payload["order"]["status"], "pending");
assert_eq!(create_payload["payment_instructions"]["gateway"], "manual");
assert!(create_payload["payment_instructions"]["payment_url"].is_null());
assert!(create_payload["payment_instructions"]["qr_code"].is_null());
assert_eq!(
create_payload["payment_instructions"]["instructions"],
"请线下确认到账后由管理员处理"
);
let list_response = client
.get(format!(
"{gateway_url}/api/wallet/recharge?limit=20&offset=0"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-recharge-create-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(list_response.status(), StatusCode::OK);
let list_payload: serde_json::Value =
list_response.json().await.expect("json body should parse");
assert_eq!(list_payload["total"], 1);
assert_eq!(
list_payload["items"]
.as_array()
.and_then(|items| items.first())
.and_then(|item| item.get("id"))
.and_then(serde_json::Value::as_str),
Some(order_id.as_str())
);
let detail_response = client
.get(format!("{gateway_url}/api/wallet/recharge/{order_id}"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-recharge-create-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(detail_response.status(), StatusCode::OK);
assert_eq!(
detail_response
.headers()
.get("cache-control")
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let detail_payload: serde_json::Value = detail_response
.json()
.await
.expect("json body should parse");
assert_eq!(detail_payload["order"]["id"], order_id);
assert_eq!(
detail_payload["order"]["gateway_response"]["gateway"],
"manual"
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_reuses_pending_billing_plan_checkout_order_without_proxying_upstream() {
let now = Utc::now();
let user = StoredUserAuthRecord::new(
"user-billing-checkout-reuse".to_string(),
Some("[email protected]".to_string()),
true,
"billing_checkout_reuse_user".to_string(),
Some("$2y$10$.OBQfixAECpsb8V/VS3csOMf00x2E/jD/gnud20t6RG0yiQosyOZ2".to_string()),
"user".to_string(),
"local".to_string(),
Some(json!(["openai"])),
Some(json!(["openai:chat"])),
Some(json!(["gpt-5"])),
true,
false,
Some(now),
Some(now),
)
.expect("auth user should build");
let wallet = StoredWalletSnapshot::new(
"wallet-billing-checkout-reuse".to_string(),
Some(user.id.clone()),
None,
12.5,
3.0,
"finite".to_string(),
"USD".to_string(),
"active".to_string(),
20.0,
4.5,
0.0,
0.0,
now.timestamp(),
)
.expect("wallet should build");
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id.clone())),
("role".to_string(), json!(user.role.clone())),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-billing-checkout-reuse"),
),
]),
now + chrono::Duration::hours(1),
);
let billing_repository = Arc::new(InMemoryBillingReadRepository::seed(Vec::new()));
let encrypted_key = encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "epay-secret")
.expect("merchant key should encrypt");
let AdminBillingMutationOutcome::Applied(_) = billing_repository
.upsert_payment_gateway_config(&PaymentGatewayConfigWriteInput {
provider: "epay".to_string(),
enabled: true,
endpoint_url: "https://pay.example.com/".to_string(),
callback_base_url: Some("https://app.example.com".to_string()),
merchant_id: "merchant-1".to_string(),
merchant_key_encrypted: Some(encrypted_key),
preserve_existing_secret: false,
pay_currency: "CNY".to_string(),
usd_exchange_rate: 7.25,
min_recharge_usd: 1.0,
channels_json: json!([
{
"channel": "alipay",
"display_name": "支付宝"
}
]),
})
.await
.expect("gateway config should create")
else {
panic!("gateway config should apply");
};
let plan = match billing_repository
.create_billing_plan(&BillingPlanWriteInput {
title: "每日额度月卡".to_string(),
description: Some("测试套餐".to_string()),
price_amount: 100.0,
price_currency: "CNY".to_string(),
duration_unit: "month".to_string(),
duration_value: 1,
enabled: true,
sort_order: 1,
max_active_per_user: 1,
purchase_limit_scope: "active_period".to_string(),
entitlements_json: json!([
{
"type": "daily_quota",
"daily_quota_usd": 50.0,
"reset_timezone": "Asia/Shanghai",
"allow_wallet_overage": false
}
]),
})
.await
.expect("billing plan should create")
{
AdminBillingMutationOutcome::Applied(plan) => plan,
other => panic!("billing plan should apply, got {other:?}"),
};
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let (_upstream_url, upstream_handle) = start_server(upstream).await;
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let wallet_repository = Arc::new(InMemoryWalletRepository::seed(vec![wallet]));
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_user_billing_and_wallet_for_tests(
user_repository,
billing_repository,
wallet_repository,
))
.with_auth_sessions_for_tests([sample_auth_session(
"user-billing-checkout-reuse",
"session-billing-checkout-reuse",
"device-billing-checkout-reuse",
"refresh-token-placeholder",
now,
)]);
let gateway = build_router_with_state(state);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let client = reqwest::Client::new();
let checkout_body = json!({
"payment_provider": "epay",
"payment_method": "epay",
"payment_channel": "alipay",
});
let first_response = client
.post(format!(
"{gateway_url}/api/billing/plans/{}/checkout",
plan.id
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-billing-checkout-reuse")
.header("user-agent", "AetherTest/1.0")
.json(&checkout_body)
.send()
.await
.expect("first checkout request should succeed");
assert_eq!(first_response.status(), StatusCode::OK);
assert_eq!(
first_response
.headers()
.get("cache-control")
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let first_payload: serde_json::Value = first_response
.json()
.await
.expect("first checkout json should parse");
let first_order_id = first_payload["order"]["id"]
.as_str()
.expect("first order id should exist")
.to_string();
assert_eq!(first_payload["order"]["status"], "pending");
assert_eq!(first_payload["order"]["product_id"], plan.id);
assert_eq!(
first_payload["reused_pending_order"],
serde_json::Value::Null
);
let second_response = client
.post(format!(
"{gateway_url}/api/billing/plans/{}/checkout",
plan.id
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-billing-checkout-reuse")
.header("user-agent", "AetherTest/1.0")
.json(&checkout_body)
.send()
.await
.expect("second checkout request should succeed");
assert_eq!(second_response.status(), StatusCode::OK);
assert_eq!(
second_response
.headers()
.get("cache-control")
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let second_payload: serde_json::Value = second_response
.json()
.await
.expect("second checkout json should parse");
assert_eq!(second_payload["order"]["id"], first_order_id);
assert_eq!(second_payload["reused_pending_order"], true);
assert_eq!(
second_payload["payment_instructions"],
first_payload["payment_instructions"]
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_creates_wallet_refunds_locally_without_proxying_upstream() {
let now = Utc::now();
let user = StoredUserAuthRecord::new(
"user-wallet-refund-create".to_string(),
Some("[email protected]".to_string()),
true,
"refund_create_user".to_string(),
Some("$2y$10$.OBQfixAECpsb8V/VS3csOMf00x2E/jD/gnud20t6RG0yiQosyOZ2".to_string()),
"user".to_string(),
"local".to_string(),
Some(json!(["openai"])),
Some(json!(["openai:chat"])),
Some(json!(["gpt-5"])),
true,
false,
Some(now),
Some(now),
)
.expect("auth user should build");
let wallet = StoredWalletSnapshot::new(
"wallet-refund-create".to_string(),
Some(user.id.clone()),
None,
12.5,
3.0,
"finite".to_string(),
"USD".to_string(),
"active".to_string(),
20.0,
4.5,
0.0,
0.0,
now.timestamp(),
)
.expect("wallet should build");
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id.clone())),
("role".to_string(), json!(user.role.clone())),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-wallet-refund-create"),
),
]),
now + chrono::Duration::hours(1),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
wallet,
[sample_auth_session(
"user-wallet-refund-create",
"session-wallet-refund-create",
"device-wallet-refund-create",
"refresh-token-placeholder",
now,
)],
)
.await;
let client = reqwest::Client::new();
let create_response = client
.post(format!("{gateway_url}/api/wallet/refunds"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-refund-create")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"amount_usd": 5.0,
"reason": "need refund",
"idempotency_key": "wallet-refund-create-key",
}))
.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");
let refund_id = create_payload["id"]
.as_str()
.expect("refund id should exist")
.to_string();
assert_eq!(create_payload["status"], "pending_approval");
assert_eq!(create_payload["source_type"], "wallet_balance");
assert_eq!(create_payload["refund_mode"], "offline_payout");
assert_eq!(create_payload["amount_usd"], 5.0);
assert_eq!(create_payload["reason"], "need refund");
let idempotent_response = client
.post(format!("{gateway_url}/api/wallet/refunds"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-refund-create")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"amount_usd": 5.0,
"reason": "need refund",
"idempotency_key": "wallet-refund-create-key",
}))
.send()
.await
.expect("request should succeed");
assert_eq!(idempotent_response.status(), StatusCode::OK);
let idempotent_payload: serde_json::Value = idempotent_response
.json()
.await
.expect("json body should parse");
assert_eq!(idempotent_payload["id"], refund_id);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_wallet_refunds_that_exceed_available_balance_locally() {
let now = Utc::now();
let user = StoredUserAuthRecord::new(
"user-wallet-refund-overdraft".to_string(),
Some("[email protected]".to_string()),
true,
"refund_overdraft_user".to_string(),
Some("$2y$10$.OBQfixAECpsb8V/VS3csOMf00x2E/jD/gnud20t6RG0yiQosyOZ2".to_string()),
"user".to_string(),
"local".to_string(),
Some(json!(["openai"])),
Some(json!(["openai:chat"])),
Some(json!(["gpt-5"])),
true,
false,
Some(now),
Some(now),
)
.expect("auth user should build");
let wallet = StoredWalletSnapshot::new(
"wallet-refund-overdraft".to_string(),
Some(user.id.clone()),
None,
12.5,
3.0,
"finite".to_string(),
"USD".to_string(),
"active".to_string(),
20.0,
4.5,
0.0,
0.0,
now.timestamp(),
)
.expect("wallet should build");
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id.clone())),
("role".to_string(), json!(user.role.clone())),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-wallet-refund-overdraft"),
),
]),
now + chrono::Duration::hours(1),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
wallet,
[sample_auth_session(
"user-wallet-refund-overdraft",
"session-wallet-refund-overdraft",
"device-wallet-refund-overdraft",
"refresh-token-placeholder",
now,
)],
)
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/wallet/refunds"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-refund-overdraft")
.header("user-agent", "AetherTest/1.0")
.json(&json!({ "amount_usd": 20.0 }))
.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"],
"refund amount exceeds available refundable recharge balance"
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_wallet_read_routes_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-wallet-read-1")),
]),
now + chrono::Duration::hours(1),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-wallet-read-1",
"device-wallet-read-1",
"refresh-token-placeholder",
now,
)],
)
.await;
let client = reqwest::Client::new();
let transactions_response = client
.get(format!(
"{gateway_url}/api/wallet/transactions?limit=20&offset=0"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-read-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(transactions_response.status(), StatusCode::OK);
let transactions_payload: serde_json::Value = transactions_response
.json()
.await
.expect("json body should parse");
assert_eq!(transactions_payload["wallet"]["id"], "wallet-auth-1");
assert_eq!(transactions_payload["total"], 0);
assert_eq!(
transactions_payload["items"].as_array().map(Vec::len),
Some(0)
);
let flow_response = client
.get(format!("{gateway_url}/api/wallet/flow?limit=20&offset=0"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-read-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(flow_response.status(), StatusCode::OK);
let flow_payload: serde_json::Value =
flow_response.json().await.expect("json body should parse");
assert_eq!(flow_payload["wallet"]["id"], "wallet-auth-1");
assert_eq!(flow_payload["today_entry"]["timezone"], "Asia/Shanghai");
assert_eq!(flow_payload["today_entry"]["is_today"], true);
assert_eq!(flow_payload["total"], 0);
assert_eq!(flow_payload["items"].as_array().map(Vec::len), Some(0));
let refunds_response = client
.get(format!(
"{gateway_url}/api/wallet/refunds?limit=20&offset=0"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-read-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(refunds_response.status(), StatusCode::OK);
let refunds_payload: serde_json::Value = refunds_response
.json()
.await
.expect("json body should parse");
assert_eq!(refunds_payload["wallet"]["id"], "wallet-auth-1");
assert_eq!(refunds_payload["total"], 0);
assert_eq!(refunds_payload["items"].as_array().map(Vec::len), Some(0));
let refund_detail_response = client
.get(format!("{gateway_url}/api/wallet/refunds/refund-1"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-read-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(refund_detail_response.status(), StatusCode::NOT_FOUND);
let refund_detail_payload: serde_json::Value = refund_detail_response
.json()
.await
.expect("json body should parse");
assert_eq!(refund_detail_payload["detail"], "Refund request not found");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_wallet_recharge_read_routes_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-wallet-recharge-read-1"),
),
]),
now + chrono::Duration::hours(1),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-wallet-recharge-read-1",
"device-wallet-recharge-read-1",
"refresh-token-placeholder",
now,
)],
)
.await;
let client = reqwest::Client::new();
let list_response = client
.get(format!(
"{gateway_url}/api/wallet/recharge?limit=20&offset=0"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-recharge-read-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(list_response.status(), StatusCode::OK);
let list_payload: serde_json::Value =
list_response.json().await.expect("json body should parse");
assert_eq!(list_payload["wallet"]["id"], "wallet-auth-1");
assert_eq!(list_payload["total"], 0);
assert_eq!(list_payload["items"].as_array().map(Vec::len), Some(0));
let detail_response = client
.get(format!("{gateway_url}/api/wallet/recharge/order-1"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-recharge-read-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(detail_response.status(), StatusCode::NOT_FOUND);
let detail_payload: serde_json::Value = detail_response
.json()
.await
.expect("json body should parse");
assert_eq!(detail_payload["detail"], "Payment order not found");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_invalid_wallet_nested_detail_paths_as_local_not_found_without_hitting_upstream(
) {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-wallet-invalid-detail-1"),
),
]),
now + chrono::Duration::hours(1),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-wallet-invalid-detail-1",
"device-wallet-invalid-detail-1",
"refresh-token-placeholder",
now,
)],
)
.await;
let client = reqwest::Client::new();
let recharge_response = client
.get(format!("{gateway_url}/api/wallet/recharge/order-1/confirm"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-invalid-detail-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_local_route_not_found_response(recharge_response).await;
let refund_response = client
.get(format!("{gateway_url}/api/wallet/refunds/refund-1/status"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-invalid-detail-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_local_route_not_found_response(refund_response).await;
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_wallet_balance_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-wallet-balance-1")),
]),
now + chrono::Duration::hours(1),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-wallet-balance-1",
"device-wallet-balance-1",
"refresh-token-placeholder",
now,
)],
)
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/wallet/balance"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-balance-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["balance"], 15.5);
assert_eq!(payload["recharge_balance"], 12.5);
assert_eq!(payload["gift_balance"], 3.0);
assert_eq!(payload["refundable_balance"], 12.5);
assert_eq!(payload["currency"], "USD");
assert_eq!(payload["limit_mode"], "finite");
assert_eq!(payload["unlimited"], false);
assert_eq!(payload["pending_refund_count"], 0);
assert_eq!(payload["wallet"]["id"], "wallet-auth-1");
assert_eq!(payload["wallet"]["status"], "active");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_ccswitch_usage_with_api_key_without_proxying_upstream() {
let now = wallet_today_usage_test_time();
let user = sample_auth_user(now);
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
sample_user_usage_audit(
"usage-ccswitch-1",
"req-ccswitch-1",
"user-auth-1",
"gpt-5",
"Aether",
"completed",
now,
),
]));
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key("sk-ccswitch-usage")),
sample_usage_auth_snapshot("api-key-user-1", "user-auth-1", "ccswitch"),
)]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = GatewayDataState::with_user_wallet_and_usage_for_tests(
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
user.clone()
])),
Arc::new(InMemoryWalletRepository::seed(vec![sample_auth_wallet(
"user-auth-1",
now,
)])),
Arc::clone(&usage_repository),
)
.with_auth_api_key_reader(auth_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
})
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/ccswitch/usage"))
.header("authorization", "Bearer sk-ccswitch-usage")
.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["is_valid"], true);
assert_eq!(payload["plan_name"], "Aether");
assert_eq!(payload["remaining"], 15.5);
assert_eq!(payload["used"], 1.25);
assert_eq!(payload["unit"], "USD");
assert_eq!(payload["wallet"]["wallet_balance"], 15.5);
assert_eq!(payload["today"]["total_requests"], 1);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_ccswitch_usage_for_standalone_key_without_owner_usage() {
let now = wallet_today_usage_test_time();
let user = sample_auth_user(now);
let mut owner_usage = sample_user_usage_audit(
"usage-ccswitch-owner",
"req-ccswitch-owner",
"user-auth-1",
"gpt-5",
"Aether",
"completed",
now,
);
owner_usage.api_key_id = Some("api-key-user-1".to_string());
owner_usage.total_cost_usd = 1.25;
owner_usage.actual_total_cost_usd = 1.25;
let mut standalone_usage = sample_user_usage_audit(
"usage-ccswitch-standalone",
"req-ccswitch-standalone",
"user-auth-1",
"gpt-5",
"Aether",
"completed",
now,
);
standalone_usage.api_key_id = Some("api-key-standalone-1".to_string());
standalone_usage.api_key_name = Some("standalone".to_string());
standalone_usage.total_cost_usd = 0.5;
standalone_usage.actual_total_cost_usd = 0.5;
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
owner_usage,
standalone_usage,
]));
let mut snapshot =
sample_usage_auth_snapshot("api-key-standalone-1", "user-auth-1", "standalone");
snapshot.api_key_is_standalone = true;
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key("sk-ccswitch-standalone")),
snapshot,
)]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = GatewayDataState::with_user_wallet_and_usage_for_tests(
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
user.clone()
])),
Arc::new(InMemoryWalletRepository::seed(vec![
sample_auth_wallet("user-auth-1", now),
sample_standalone_auth_wallet("api-key-standalone-1", now),
])),
Arc::clone(&usage_repository),
)
.with_auth_api_key_reader(auth_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
})
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/ccswitch/usage"))
.header("authorization", "Bearer sk-ccswitch-standalone")
.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["is_valid"], true);
assert_eq!(payload["remaining"], 2.5);
assert_eq!(payload["used"], 0.5);
assert_eq!(payload["wallet"]["wallet"]["id"], "wallet-standalone-1");
assert_eq!(payload["today"]["total_requests"], 1);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_wallet_today_cost_locally_without_proxying_upstream() {
let auth_now = Utc::now();
let usage_now = wallet_today_usage_test_time();
let user = sample_auth_user(auth_now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-wallet-today-1")),
]),
auth_now + chrono::Duration::hours(1),
);
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
sample_user_usage_audit(
"usage-wallet-today-1",
"req-wallet-today-1",
"user-auth-1",
"gpt-4.1",
"OpenAI",
"completed",
usage_now - chrono::Duration::minutes(30),
),
sample_user_usage_audit(
"usage-wallet-old-1",
"req-wallet-old-1",
"user-auth-1",
"gpt-4.1",
"OpenAI",
"completed",
usage_now - chrono::Duration::days(1),
),
]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_usage_state(
user,
sample_auth_wallet("user-auth-1", auth_now),
[sample_auth_session(
"user-auth-1",
"session-wallet-today-1",
"device-wallet-today-1",
"refresh-token-placeholder",
auth_now,
)],
usage_repository,
)
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/wallet/today-cost"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-today-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["is_today"], true);
assert_eq!(payload["total_requests"], 1);
assert_eq!(payload["input_tokens"], 120);
assert_eq!(payload["cache_read_tokens"], 15);
assert_eq!(payload["total_cost"], 1.25);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_wallet_flow_today_entry_uses_live_settled_usage() {
let auth_now = Utc::now();
let usage_now = wallet_today_usage_test_time();
let user = sample_auth_user(auth_now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-wallet-flow-today")),
]),
auth_now + chrono::Duration::hours(1),
);
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
sample_user_usage_audit(
"usage-wallet-flow-today",
"req-wallet-flow-today",
"user-auth-1",
"gpt-4.1",
"OpenAI",
"completed",
usage_now,
),
]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_usage_state(
user,
sample_auth_wallet("user-auth-1", auth_now),
[sample_auth_session(
"user-auth-1",
"session-wallet-flow-today",
"device-wallet-flow-today",
"refresh-token-placeholder",
auth_now,
)],
usage_repository,
)
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/wallet/flow?limit=20&offset=0"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-flow-today")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["today_entry"]["is_today"], true);
assert_eq!(payload["today_entry"]["timezone"], "Asia/Shanghai");
assert_eq!(payload["today_entry"]["total_requests"], 1);
assert_eq!(payload["today_entry"]["input_tokens"], 120);
assert_eq!(payload["today_entry"]["cache_read_tokens"], 15);
assert_eq!(payload["today_entry"]["total_cost"], 1.25);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_returns_service_unavailable_for_wallet_today_cost_without_usage_reader() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-wallet-today-unavailable"),
),
]),
now + chrono::Duration::hours(1),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-wallet-today-unavailable",
"device-wallet-today-unavailable",
"refresh-token-placeholder",
now,
)],
)
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/wallet/today-cost"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-today-unavailable")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
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_updates_users_me_detail_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-update-detail"),
),
]),
now + chrono::Duration::hours(1),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-users-me-update-detail",
"device-users-me-update-detail",
"refresh-token-placeholder",
now,
)],
)
.await;
let client = reqwest::Client::new();
let unverified_response = client
.put(format!("{gateway_url}/api/users/me"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-update-detail")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"email": "[email protected]",
"username": "alice-updated"
}))
.send()
.await
.expect("request should succeed");
assert_eq!(unverified_response.status(), StatusCode::BAD_REQUEST);
let unverified_payload: serde_json::Value = unverified_response
.json()
.await
.expect("json body should parse");
assert_eq!(unverified_payload["detail"], "修改邮箱前请先完成新邮箱验证");
let update_response = client
.put(format!("{gateway_url}/api/users/me"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-update-detail")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"email": "[email protected]",
"username": "alice-updated",
"feature_settings": {
"chat_pii_redaction": {
"enabled": 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["message"], "个人信息更新成功");
let get_response = client
.get(format!("{gateway_url}/api/users/me"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-update-detail")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(get_response.status(), StatusCode::OK);
let get_payload: serde_json::Value = get_response.json().await.expect("json body should parse");
assert_eq!(get_payload["email"], "[email protected]");
assert_eq!(get_payload["username"], "alice-updated");
assert_eq!(get_payload["auth_source"], "local");
assert_eq!(get_payload["has_password"], true);
assert_eq!(
get_payload["feature_settings"]["chat_pii_redaction"]["enabled"],
true
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_returns_service_unavailable_for_users_me_detail_update_without_profile_storage() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-update-unavailable"),
),
]),
now + chrono::Duration::hours(1),
);
let user_repository =
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]).read_only());
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
crate::data::GatewayDataState::with_user_reader_for_tests(user_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.without_auth_user_store_for_tests()
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-update-unavailable",
"device-users-me-update-unavailable",
"refresh-token-placeholder",
now,
)])
})
.await;
let response = reqwest::Client::new()
.put(format!("{gateway_url}/api/users/me"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-update-unavailable")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"email": "[email protected]",
"username": "alice-updated",
}))
.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"], "修改邮箱前请先完成新邮箱验证");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_changes_users_me_password_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-password-current"),
),
]),
now + chrono::Duration::hours(1),
);
let current = sample_auth_session(
"user-auth-1",
"session-users-me-password-current",
"device-users-me-password-current",
"refresh-token-current",
now,
);
let other = sample_auth_session(
"user-auth-1",
"session-users-me-password-other",
"device-users-me-password-other",
"refresh-token-other",
now - chrono::Duration::hours(6),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[current, other],
)
.await;
let client = reqwest::Client::new();
let update_response = client
.patch(format!("{gateway_url}/api/users/me/password"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-password-current")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"current_password": "secret123",
"new_password": "Secret456!",
}))
.send()
.await
.expect("request should succeed");
assert_eq!(update_response.status(), StatusCode::OK);
let clear_cookie = update_response
.headers()
.get(http::header::SET_COOKIE)
.and_then(|value| value.to_str().ok())
.expect("password change should clear the refresh cookie")
.to_string();
let update_payload: serde_json::Value = update_response
.json()
.await
.expect("json body should parse");
assert_eq!(update_payload["message"], "密码修改成功");
assert!(clear_cookie.contains("aether_refresh_token="));
assert!(clear_cookie.contains("Max-Age=0"));
let sessions_response = client
.get(format!("{gateway_url}/api/users/me/sessions"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-password-current")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(sessions_response.status(), StatusCode::UNAUTHORIZED);
let sessions_payload: serde_json::Value = sessions_response
.json()
.await
.expect("json body should parse");
assert_eq!(sessions_payload["detail"], "登录会话已失效,请重新登录");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_returns_service_unavailable_for_users_me_password_change_without_credential_storage(
) {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-password-unavailable"),
),
]),
now + chrono::Duration::hours(1),
);
let user_repository =
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]).read_only());
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
crate::data::GatewayDataState::with_user_reader_for_tests(user_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.without_auth_user_store_for_tests()
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-password-unavailable",
"device-users-me-password-unavailable",
"refresh-token-placeholder",
now,
)])
})
.await;
let response = reqwest::Client::new()
.patch(format!("{gateway_url}/api/users/me/password"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-password-unavailable")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"current_password": "secret123",
"new_password": "Secret456!",
}))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
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_handles_users_me_endpoint_status_locally_without_proxying_upstream() {
let now = Utc::now();
let now_unix_secs = now.timestamp();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-user-endpoint-status-1"),
),
]),
now + chrono::Duration::hours(1),
);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider("provider-openai", "openai", 10),
sample_provider("provider-claude", "claude", 20),
],
vec![
sample_endpoint(
"endpoint-openai",
"provider-openai",
"openai:chat",
"https://api.openai.example",
),
sample_endpoint(
"endpoint-claude",
"provider-claude",
"claude:messages",
"https://api.anthropic.example",
),
],
vec![
sample_key(
"key-openai",
"provider-openai",
"openai:chat",
"sk-openai-endpoint-status",
),
sample_key(
"key-claude",
"provider-claude",
"claude:messages",
"sk-claude-endpoint-status",
),
],
));
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::seed(vec![
sample_request_candidate(
"cand-openai-success",
"req-openai-success",
"endpoint-openai",
RequestCandidateStatus::Success,
now_unix_secs - 3_000,
Some(now_unix_secs - 2_980),
),
sample_request_candidate(
"cand-openai-failed",
"req-openai-failed",
"endpoint-openai",
RequestCandidateStatus::Failed,
now_unix_secs - 2_000,
Some(now_unix_secs - 1_980),
),
sample_request_candidate(
"cand-claude-skipped",
"req-claude-skipped",
"endpoint-claude",
RequestCandidateStatus::Skipped,
now_unix_secs - 1_500,
Some(now_unix_secs - 1_480),
),
]));
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/{*path}",
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("proxied"))
}
}),
);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let wallet_repository = Arc::new(InMemoryWalletRepository::seed(vec![sample_auth_wallet(
"user-auth-1",
now,
)]));
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_user_and_wallet_for_tests(
user_repository,
wallet_repository,
)
.with_provider_catalog_reader(provider_catalog_repository)
.with_request_candidate_reader(request_candidate_repository),
)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-user-endpoint-status-1",
"device-user-endpoint-status-1",
"refresh-token-placeholder",
now,
)]),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/users/me/endpoint-status"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-user-endpoint-status-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
let items = payload.as_array().expect("payload should be an array");
assert_eq!(items.len(), 2);
assert_eq!(items[0]["api_format"], "claude:messages");
assert_eq!(items[0]["display_name"], "Claude Messages");
assert_eq!(items[0]["health_score"], 1.0);
assert_eq!(items[0]["timeline"].as_array().map(Vec::len), Some(60));
assert!(items[0].get("total_endpoints").is_none());
assert_eq!(items[1]["api_format"], "openai:chat");
assert_eq!(items[1]["display_name"], "OpenAI Chat");
assert_eq!(items[1]["health_score"], 0.5);
assert_eq!(items[1]["timeline"].as_array().map(Vec::len), Some(60));
assert!(items[1].get("total_keys").is_none());
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_returns_service_unavailable_for_users_me_endpoint_status_without_health_data() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-user-endpoint-status-unavailable"),
),
]),
now + chrono::Duration::hours(1),
);
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
crate::data::GatewayDataState::with_user_reader_for_tests(user_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-user-endpoint-status-unavailable",
"device-user-endpoint-status-unavailable",
"refresh-token-placeholder",
now,
)])
})
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/users/me/endpoint-status"))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-user-endpoint-status-unavailable",
)
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
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_handles_users_me_preferences_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-user-pref-1")),
]),
now + chrono::Duration::hours(1),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_preferences_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-user-pref-1",
"device-user-pref-1",
"refresh-token-placeholder",
now,
)],
{
let mut preference =
crate::data::state::StoredUserPreferenceRecord::default_for_user("user-auth-1");
preference.default_provider_id = Some("provider-openai".to_string());
preference.default_provider_name = Some("openai".to_string());
vec![preference]
},
)
.await;
let client = reqwest::Client::new();
let get_response = client
.get(format!("{gateway_url}/api/users/me/preferences"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-user-pref-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(get_response.status(), StatusCode::OK);
let get_payload: serde_json::Value = get_response.json().await.expect("json should parse");
assert_eq!(get_payload["theme"], "light");
assert_eq!(get_payload["language"], "zh-CN");
assert_eq!(get_payload["timezone"], "Asia/Shanghai");
assert_eq!(get_payload["notifications"]["email"], true);
assert_eq!(get_payload["default_provider_id"], "provider-openai");
assert!(get_payload.get("default_provider").is_none());
let put_response = client
.put(format!("{gateway_url}/api/users/me/preferences"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-user-pref-1")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"theme": "dark",
"language": "en-US",
"timezone": "UTC",
"bio": "hello",
"email_notifications": false,
"usage_alerts": false,
"announcement_notifications": true,
}))
.send()
.await
.expect("request should succeed");
assert_eq!(put_response.status(), StatusCode::OK);
let put_payload: serde_json::Value = put_response.json().await.expect("json should parse");
assert_eq!(put_payload["message"], "偏好设置更新成功");
let verify_response = client
.get(format!("{gateway_url}/api/users/me/preferences"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-user-pref-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(verify_response.status(), StatusCode::OK);
let verify_payload: serde_json::Value =
verify_response.json().await.expect("json should parse");
assert_eq!(verify_payload["theme"], "dark");
assert_eq!(verify_payload["language"], "en-US");
assert_eq!(verify_payload["timezone"], "UTC");
assert_eq!(verify_payload["bio"], "hello");
assert_eq!(verify_payload["notifications"]["email"], false);
assert_eq!(verify_payload["notifications"]["usage_alerts"], false);
assert_eq!(verify_payload["notifications"]["announcements"], true);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_returns_service_unavailable_for_users_me_preferences_update_without_storage() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-user-pref-unavailable"),
),
]),
now + chrono::Duration::hours(1),
);
let user_repository =
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]).read_only());
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
crate::data::GatewayDataState::with_user_reader_for_tests(user_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-user-pref-unavailable",
"device-user-pref-unavailable",
"refresh-token-placeholder",
now,
)])
})
.await;
let response = reqwest::Client::new()
.put(format!("{gateway_url}/api/users/me/preferences"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-user-pref-unavailable")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"theme": "dark",
"language": "en-US",
}))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let payload: serde_json::Value = response.json().await.expect("json should parse");
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_handles_users_me_usage_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-users-me-usage-1")),
]),
now + chrono::Duration::hours(1),
);
let mut streaming_usage = sample_user_usage_audit(
"usage-users-me-streaming-1",
"req-users-me-streaming-1",
"user-auth-1",
"gpt-4.1-mini",
"OpenAI",
"streaming",
now - chrono::Duration::minutes(5),
);
streaming_usage.candidate_index = Some(2);
streaming_usage.request_metadata = Some(json!({
"rate_multiplier": 0.5,
"input_price_per_1m": 3.0,
"output_price_per_1m": 9.0,
"cache_creation_price_per_1m": 3.75,
"cache_read_price_per_1m": 0.3,
}));
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
sample_user_usage_audit(
"usage-users-me-completed-1",
"req-users-me-completed-1",
"user-auth-1",
"gpt-4.1",
"OpenAI",
"completed",
now - chrono::Duration::minutes(20),
),
sample_user_usage_audit(
"usage-users-me-failed-1",
"req-users-me-failed-1",
"user-auth-1",
"claude-3.5-sonnet",
"Anthropic",
"failed",
now - chrono::Duration::minutes(10),
),
streaming_usage,
]));
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some("hash-api-key-user-1".to_string()),
sample_usage_auth_snapshot("api-key-user-1", "user-auth-1", "renamed-key"),
)]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = crate::data::GatewayDataState::with_user_wallet_and_usage_for_tests(
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
user.clone()
])),
Arc::new(InMemoryWalletRepository::seed(vec![sample_auth_wallet(
"user-auth-1",
now,
)])),
Arc::clone(&usage_repository),
)
.with_auth_api_key_reader(auth_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-usage-1",
"device-users-me-usage-1",
"refresh-token-placeholder",
now,
)])
})
.await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/users/me/usage?limit=10&offset=0&search=renamed-key"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-usage-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["total_requests"], 2);
assert_eq!(payload["total_input_tokens"], 240);
assert_eq!(payload["pagination"]["total"], 3);
assert_eq!(
payload["records"].as_array().expect("records array").len(),
3
);
assert_eq!(
payload["records"][0]["cache_creation_ephemeral_5m_input_tokens"],
4
);
assert_eq!(payload["records"][0]["effective_input_tokens"], 95);
assert_eq!(
payload["records"][0]["cache_creation_ephemeral_1h_input_tokens"],
6
);
assert_eq!(payload["records"][0]["input_price_per_1m"], 3.0);
assert_eq!(payload["records"][0]["output_price_per_1m"], 9.0);
assert_eq!(payload["records"][0]["cache_creation_price_per_1m"], 3.75);
assert_eq!(payload["records"][0]["cache_read_price_per_1m"], 0.3);
assert_eq!(payload["records"][0]["has_fallback"], true);
assert_eq!(payload["records"][0]["api_key"]["name"], "renamed-key");
assert_eq!(payload["records"][0]["api_key"]["display"], "renamed-key");
assert_eq!(
payload["summary_by_model"]
.as_array()
.expect("summary array")
.len(),
2
);
assert_eq!(
payload["summary_by_model"][0]["cache_creation_ephemeral_5m_tokens"],
4
);
assert_eq!(
payload["summary_by_model"][0]["cache_creation_ephemeral_1h_tokens"],
6
);
assert_eq!(payload["summary_by_model"][0]["effective_input_tokens"], 95);
assert_eq!(payload["summary_by_model"][0]["total_input_context"], 120);
assert!(payload.get("summary_by_provider").is_none());
assert_eq!(payload["billing"]["id"], "wallet-auth-1");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_users_me_usage_without_legacy_api_key_name_fallback_when_auth_reader_exists(
) {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-usage-no-legacy-fallback"),
),
]),
now + chrono::Duration::hours(1),
);
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
sample_user_usage_audit(
"usage-users-me-completed-no-legacy-fallback",
"req-users-me-completed-no-legacy-fallback",
"user-auth-1",
"gpt-4.1",
"OpenAI",
"completed",
now - chrono::Duration::minutes(20),
),
]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = crate::data::GatewayDataState::with_user_wallet_and_usage_for_tests(
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
user.clone()
])),
Arc::new(InMemoryWalletRepository::seed(vec![sample_auth_wallet(
"user-auth-1",
now,
)])),
Arc::clone(&usage_repository),
)
.with_auth_api_key_reader(Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![])));
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-usage-no-legacy-fallback",
"device-users-me-usage-no-legacy-fallback",
"refresh-token-users-me-usage-no-legacy-fallback",
now,
)])
})
.await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/users/me/usage?limit=10&offset=0&search=default"
))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-usage-no-legacy-fallback",
)
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["pagination"]["total"], 0);
assert_eq!(
payload["records"].as_array().expect("records array").len(),
0
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_users_me_usage_search_with_model_and_api_key_keywords() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-usage-multi-keyword-search"),
),
]),
now + chrono::Duration::hours(1),
);
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
sample_user_usage_audit(
"usage-users-me-completed-multi-keyword-search",
"req-users-me-completed-multi-keyword-search",
"user-auth-1",
"gpt-4.1",
"OpenAI",
"completed",
now - chrono::Duration::minutes(20),
),
sample_user_usage_audit(
"usage-users-me-failed-multi-keyword-search",
"req-users-me-failed-multi-keyword-search",
"user-auth-1",
"claude-3.5-sonnet",
"Anthropic",
"failed",
now - chrono::Duration::minutes(10),
),
sample_user_usage_audit(
"usage-users-me-streaming-multi-keyword-search",
"req-users-me-streaming-multi-keyword-search",
"user-auth-1",
"gpt-4.1-mini",
"OpenAI",
"streaming",
now - chrono::Duration::minutes(5),
),
]));
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some("hash-api-key-user-1".to_string()),
sample_usage_auth_snapshot("api-key-user-1", "user-auth-1", "renamed-key"),
)]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = crate::data::GatewayDataState::with_user_wallet_and_usage_for_tests(
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
user.clone()
])),
Arc::new(InMemoryWalletRepository::seed(vec![sample_auth_wallet(
"user-auth-1",
now,
)])),
Arc::clone(&usage_repository),
)
.with_auth_api_key_reader(auth_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-usage-multi-keyword-search",
"device-users-me-usage-multi-keyword-search",
"refresh-token-users-me-usage-multi-keyword-search",
now,
)])
})
.await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/users/me/usage?limit=10&offset=0&search=gpt%20renamed-key"
))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-usage-multi-keyword-search",
)
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["pagination"]["total"], 2);
assert_eq!(
payload["records"].as_array().expect("records array").len(),
2
);
assert_eq!(payload["records"][0]["model"], "gpt-4.1-mini");
assert_eq!(payload["records"][1]["model"], "gpt-4.1");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_users_me_usage_active_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-users-me-active-1")),
]),
now + chrono::Duration::hours(1),
);
let mut streaming_usage = sample_user_usage_audit(
"usage-users-me-streaming-1",
"req-users-me-streaming-1",
"user-auth-1",
"gpt-4.1-mini",
"OpenAI",
"streaming",
now - chrono::Duration::minutes(2),
);
streaming_usage.request_metadata = Some(json!({
"rate_multiplier": 0.5,
}));
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
sample_user_usage_audit(
"usage-users-me-pending-1",
"req-users-me-pending-1",
"user-auth-1",
"gpt-4.1",
"OpenAI",
"pending",
now - chrono::Duration::minutes(4),
),
streaming_usage,
sample_user_usage_audit(
"usage-users-me-completed-1",
"req-users-me-completed-1",
"user-auth-1",
"gpt-4.1",
"OpenAI",
"completed",
now - chrono::Duration::minutes(30),
),
]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_usage_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-users-me-active-1",
"device-users-me-active-1",
"refresh-token-placeholder",
now,
)],
usage_repository,
)
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/users/me/usage/active"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-active-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
let requests = payload["requests"].as_array().expect("requests array");
assert_eq!(requests.len(), 2);
assert_eq!(requests[0]["status"], "streaming");
assert_eq!(requests[0]["rate_multiplier"], 0.5);
assert_eq!(requests[0]["cache_creation_ephemeral_5m_input_tokens"], 4);
assert_eq!(requests[0]["cache_creation_ephemeral_1h_input_tokens"], 6);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_users_me_usage_interval_timeline_and_heatmap_locally_without_proxying_upstream(
) {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-usage-heatmap-1"),
),
]),
now + chrono::Duration::hours(1),
);
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
sample_user_usage_audit(
"usage-users-me-heatmap-1",
"req-users-me-heatmap-1",
"user-auth-1",
"gpt-4.1",
"OpenAI",
"completed",
now - chrono::Duration::minutes(25),
),
sample_user_usage_audit(
"usage-users-me-heatmap-2",
"req-users-me-heatmap-2",
"user-auth-1",
"gpt-4.1",
"OpenAI",
"completed",
now - chrono::Duration::minutes(15),
),
sample_user_usage_audit(
"usage-users-me-heatmap-3",
"req-users-me-heatmap-3",
"user-auth-1",
"gpt-4.1",
"OpenAI",
"completed",
now - chrono::Duration::days(1) - chrono::Duration::minutes(1),
),
]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_usage_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-users-me-usage-heatmap-1",
"device-users-me-usage-heatmap-1",
"refresh-token-placeholder",
now,
)],
usage_repository,
)
.await;
let client = reqwest::Client::new();
let timeline_response = client
.get(format!(
"{gateway_url}/api/users/me/usage/interval-timeline?hours=24&limit=100"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-usage-heatmap-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("timeline request should succeed");
assert_eq!(timeline_response.status(), StatusCode::OK);
let timeline_payload: serde_json::Value = timeline_response
.json()
.await
.expect("timeline json should parse");
assert_eq!(timeline_payload["analysis_period_hours"], 24);
assert_eq!(timeline_payload["total_points"], 1);
let heatmap_response = client
.get(format!("{gateway_url}/api/users/me/usage/heatmap"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-usage-heatmap-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("heatmap request should succeed");
assert_eq!(heatmap_response.status(), StatusCode::OK);
let heatmap_payload: serde_json::Value = heatmap_response
.json()
.await
.expect("heatmap json should parse");
assert!(
heatmap_payload["days"]
.as_array()
.expect("days array")
.len()
>= 2
);
assert!(heatmap_payload["max_requests"].as_u64().unwrap_or(0) >= 1);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_returns_service_unavailable_for_users_me_usage_routes_without_reader() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-usage-unavailable"),
),
]),
now + chrono::Duration::hours(1),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-users-me-usage-unavailable",
"device-users-me-usage-unavailable",
"refresh-token-placeholder",
now,
)],
)
.await;
let client = reqwest::Client::new();
for path in [
"/api/users/me/usage",
"/api/users/me/usage/active",
"/api/users/me/usage/interval-timeline?hours=24&limit=100",
"/api/users/me/usage/heatmap",
] {
let response = client
.get(format!("{gateway_url}{path}"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-usage-unavailable")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{path}");
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "服务暂不可用,请稍后重试", "{path}");
}
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_users_me_sessions_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-users-me-current")),
]),
now + chrono::Duration::hours(1),
);
let current = sample_auth_session(
"user-auth-1",
"session-users-me-current",
"device-users-me-current",
"refresh-token-current",
now,
);
let mut other = sample_auth_session(
"user-auth-1",
"session-users-me-other",
"device-users-me-other",
"refresh-token-other",
now - chrono::Duration::hours(6),
);
other.ip_address = Some("10.0.0.2".to_string());
let mut revoked = sample_auth_session(
"user-auth-1",
"session-users-me-revoked",
"device-users-me-revoked",
"refresh-token-revoked",
now - chrono::Duration::hours(12),
);
revoked.revoked_at = Some(now);
let mut expired = sample_auth_session(
"user-auth-1",
"session-users-me-expired",
"device-users-me-expired",
"refresh-token-expired",
now - chrono::Duration::hours(24),
);
expired.expires_at = Some(now - chrono::Duration::minutes(1));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[current, other, revoked, expired],
)
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/users/me/sessions"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-current")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
let sessions = payload.as_array().expect("sessions should be array");
assert_eq!(sessions.len(), 2);
assert_eq!(sessions[0]["id"], "session-users-me-current");
assert_eq!(sessions[0]["is_current"], true);
assert_eq!(sessions[0]["device_label"], "未知设备");
assert_eq!(sessions[1]["id"], "session-users-me-other");
assert_eq!(sessions[1]["is_current"], false);
assert_eq!(sessions[1]["ip_address"], "10.0.0.2");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_revokes_other_users_me_sessions_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-users-me-current")),
]),
now + chrono::Duration::hours(1),
);
let current = sample_auth_session(
"user-auth-1",
"session-users-me-current",
"device-users-me-current",
"refresh-token-current",
now,
);
let other = sample_auth_session(
"user-auth-1",
"session-users-me-other",
"device-users-me-other",
"refresh-token-other",
now - chrono::Duration::hours(6),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[current, other],
)
.await;
let client = reqwest::Client::new();
let delete_response = client
.delete(format!("{gateway_url}/api/users/me/sessions/others"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-current")
.header("user-agent", "AetherTest/1.0")
.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!(delete_payload["revoked_count"], 1);
let get_response = client
.get(format!("{gateway_url}/api/users/me/sessions"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-current")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(get_response.status(), StatusCode::OK);
let get_payload: serde_json::Value = get_response.json().await.expect("json body should parse");
let sessions = get_payload.as_array().expect("sessions should be array");
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0]["id"], "session-users-me-current");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_revokes_users_me_session_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-users-me-current")),
]),
now + chrono::Duration::hours(1),
);
let current = sample_auth_session(
"user-auth-1",
"session-users-me-current",
"device-users-me-current",
"refresh-token-current",
now,
);
let other = sample_auth_session(
"user-auth-1",
"session-users-me-other",
"device-users-me-other",
"refresh-token-other",
now - chrono::Duration::hours(6),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[current, other],
)
.await;
let client = reqwest::Client::new();
let delete_response = client
.delete(format!(
"{gateway_url}/api/users/me/sessions/session-users-me-other"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-current")
.header("user-agent", "AetherTest/1.0")
.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"], "设备已退出登录");
let get_response = client
.get(format!("{gateway_url}/api/users/me/sessions"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-current")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(get_response.status(), StatusCode::OK);
let get_payload: serde_json::Value = get_response.json().await.expect("json body should parse");
let sessions = get_payload.as_array().expect("sessions should be array");
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0]["id"], "session-users-me-current");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_updates_users_me_session_label_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-users-me-current")),
]),
now + chrono::Duration::hours(1),
);
let current = sample_auth_session(
"user-auth-1",
"session-users-me-current",
"device-users-me-current",
"refresh-token-current",
now,
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(user, sample_auth_wallet("user-auth-1", now), [current])
.await;
let response = reqwest::Client::new()
.patch(format!(
"{gateway_url}/api/users/me/sessions/session-users-me-current"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-current")
.header("user-agent", "AetherTest/1.0")
.json(&json!({ "device_label": "我的 MacBook" }))
.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"], "session-users-me-current");
assert_eq!(payload["device_label"], "我的 MacBook");
assert_eq!(payload["is_current"], true);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
let sessions_response = reqwest::Client::new()
.get(format!("{gateway_url}/api/users/me/sessions"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-current")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(sessions_response.status(), StatusCode::OK);
let sessions_payload: serde_json::Value = sessions_response
.json()
.await
.expect("json body should parse");
let sessions = sessions_payload
.as_array()
.expect("sessions should be array");
assert_eq!(sessions[0]["device_label"], "我的 MacBook");
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_cannot_update_or_revoke_another_users_session() {
let now = Utc::now();
let user = sample_auth_user(now);
let mut foreign_user = user.clone();
foreign_user.id = "user-auth-2".to_string();
foreign_user.email = Some("[email protected]".to_string());
foreign_user.username = "bob".to_string();
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-owner-current"),
),
]),
now + chrono::Duration::hours(1),
);
let foreign_access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(foreign_user.id)),
("role".to_string(), json!(foreign_user.role)),
(
"created_at".to_string(),
json!(foreign_user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-users-me-foreign")),
]),
now + chrono::Duration::hours(1),
);
let owner_session = sample_auth_session(
"user-auth-1",
"session-users-me-owner-current",
"device-users-me-owner-current",
"refresh-token-owner-current",
now,
);
let mut foreign_session = sample_auth_session(
"user-auth-2",
"session-users-me-foreign",
"device-users-me-foreign",
"refresh-token-foreign",
now,
);
foreign_session.device_label = Some("Bob phone".to_string());
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
user,
foreign_user,
]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_user_reader_for_tests(
user_repository,
))
.with_auth_sessions_for_tests([owner_session, foreign_session])
})
.await;
let client = reqwest::Client::new();
let update_response = client
.patch(format!(
"{gateway_url}/api/users/me/sessions/session-users-me-foreign"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-owner-current")
.header("user-agent", "AetherTest/1.0")
.json(&json!({ "device_label": "Compromised" }))
.send()
.await
.expect("cross-user update request should complete");
assert_eq!(update_response.status(), StatusCode::NOT_FOUND);
let revoke_response = client
.delete(format!(
"{gateway_url}/api/users/me/sessions/session-users-me-foreign"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-owner-current")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("cross-user revoke request should complete");
assert_eq!(revoke_response.status(), StatusCode::NOT_FOUND);
let foreign_sessions_response = client
.get(format!("{gateway_url}/api/users/me/sessions"))
.header("authorization", format!("Bearer {foreign_access_token}"))
.header("x-client-device-id", "device-users-me-foreign")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("foreign owner session request should complete");
assert_eq!(foreign_sessions_response.status(), StatusCode::OK);
let foreign_sessions: serde_json::Value = foreign_sessions_response
.json()
.await
.expect("json body should parse");
let foreign_sessions = foreign_sessions
.as_array()
.expect("sessions should be an array");
assert_eq!(foreign_sessions.len(), 1);
assert_eq!(foreign_sessions[0]["id"], "session-users-me-foreign");
assert_eq!(foreign_sessions[0]["device_label"], "Bob phone");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_invalid_users_me_session_delete_path_as_local_not_found_without_hitting_upstream(
) {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-users-me-current")),
]),
now + chrono::Duration::hours(1),
);
let current = sample_auth_session(
"user-auth-1",
"session-users-me-current",
"device-users-me-current",
"refresh-token-current",
now,
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(user, sample_auth_wallet("user-auth-1", now), [current])
.await;
let response = reqwest::Client::new()
.delete(format!("{gateway_url}/api/users/me/sessions/"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-current")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_local_route_not_found_response(response).await;
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_invalid_users_me_session_update_path_as_local_not_found_without_hitting_upstream(
) {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-users-me-current")),
]),
now + chrono::Duration::hours(1),
);
let current = sample_auth_session(
"user-auth-1",
"session-users-me-current",
"device-users-me-current",
"refresh-token-current",
now,
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(user, sample_auth_wallet("user-auth-1", now), [current])
.await;
let response = reqwest::Client::new()
.patch(format!("{gateway_url}/api/users/me/sessions/"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-current")
.header("user-agent", "AetherTest/1.0")
.json(&json!({ "device_label": "我的 MacBook" }))
.send()
.await
.expect("request should succeed");
assert_local_route_not_found_response(response).await;
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_users_me_api_keys_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-users-me-api-keys")),
]),
now + chrono::Duration::hours(1),
);
let encrypted = encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "sk-user-live-1")
.expect("ciphertext should build");
let auth_repository = Arc::new(
InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some("87e376524de9f1d78f2b7d95d20bcc3faed619d572ccc9a1d9a343ab83e33834".to_string()),
StoredAuthApiKeySnapshot::new(
"user-auth-1".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-5"])),
"user-key-1".to_string(),
Some("primary".to_string()),
true,
false,
false,
Some(60),
Some(5),
Some(300),
Some(json!(["openai"])),
Some(json!(["openai:chat"])),
Some(json!(["gpt-5"])),
)
.expect("auth api key snapshot should build"),
)])
.with_export_records(vec![StoredAuthApiKeyExportRecord::new(
"user-auth-1".to_string(),
"user-key-1".to_string(),
"87e376524de9f1d78f2b7d95d20bcc3faed619d572ccc9a1d9a343ab83e33834".to_string(),
Some(encrypted),
Some("primary".to_string()),
Some(json!(["openai"])),
Some(json!(["openai:chat"])),
Some(json!(["gpt-5"])),
Some(60),
Some(5),
Some(json!({"cache_1h": true})),
true,
Some(300),
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![user]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
crate::data::GatewayDataState::with_user_reader_for_tests(user_repository)
.attach_auth_api_key_repository_for_tests(auth_repository)
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-api-keys",
"device-users-me-api-keys",
"refresh-token-users-me-api-keys",
now,
)])
})
.await;
let client = reqwest::Client::new();
let list_response = client
.get(format!("{gateway_url}/api/users/me/api-keys"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-api-keys")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(list_response.status(), StatusCode::OK);
let list_payload: serde_json::Value =
list_response.json().await.expect("json body should parse");
let api_keys = list_payload.as_array().expect("api keys should be array");
assert_eq!(api_keys.len(), 1);
assert_eq!(api_keys[0]["id"], "user-key-1");
assert_eq!(api_keys[0]["name"], "primary");
assert_eq!(api_keys[0]["key_display"], "sk-u...e-1");
assert_eq!(api_keys[0]["total_requests"], 9);
assert_eq!(api_keys[0]["total_cost_usd"], 1.5);
assert_eq!(api_keys[0]["created_at"], "2024-03-21T05:48:20+00:00");
assert_eq!(api_keys[0]["last_used_at"], "2024-03-21T05:48:22+00:00");
let detail_response = client
.get(format!(
"{gateway_url}/api/users/me/api-keys/user-key-1?include_key=true"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-api-keys")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(detail_response.status(), StatusCode::OK);
assert_eq!(
detail_response
.headers()
.get(http::header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let detail_payload: serde_json::Value = detail_response
.json()
.await
.expect("json body should parse");
assert_eq!(detail_payload["key"], "sk-user-live-1");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_users_me_client_config_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let _public_base_url_guard =
set_test_env_var("AETHER_PUBLIC_BASE_URL", "https://aether.example.com/");
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-client-config"),
),
]),
now + chrono::Duration::hours(1),
);
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
crate::data::GatewayDataState::with_user_reader_for_tests(user_repository)
.with_system_config_values_for_tests(vec![(
"site_name".to_string(),
json!("Aether Local"),
)]);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-client-config",
"device-users-me-client-config",
"refresh-token-users-me-client-config",
now,
)])
})
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/users/me/client-config"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-client-config")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["base_url"], "https://aether.example.com");
assert_eq!(payload["site_name"], "Aether Local");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_invalid_users_me_api_key_detail_path_as_local_not_found_without_hitting_upstream(
) {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-users-me-api-keys")),
]),
now + chrono::Duration::hours(1),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-users-me-api-keys",
"device-users-me-api-keys",
"refresh-token-users-me-api-keys",
now,
)],
)
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/users/me/api-keys/"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-api-keys")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_local_route_not_found_response(response).await;
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_invalid_users_me_api_key_patch_path_as_local_not_found_without_hitting_upstream(
) {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-api-key-writes"),
),
]),
now + chrono::Duration::hours(1),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = crate::data::GatewayDataState::with_auth_api_key_repository_for_tests(
Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![])),
)
.with_user_reader(Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
user,
])));
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-api-key-writes",
"device-users-me-api-key-writes",
"refresh-token-users-me-api-key-writes",
now,
)])
})
.await;
let response = reqwest::Client::new()
.patch(format!("{gateway_url}/api/users/me/api-keys/"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-api-key-writes")
.header("user-agent", "AetherTest/1.0")
.json(&json!({ "is_active": false }))
.send()
.await
.expect("request should succeed");
assert_local_route_not_found_response(response).await;
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_users_me_api_key_nested_get_path_as_local_not_found_without_hitting_upstream(
) {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-users-me-api-keys")),
]),
now + chrono::Duration::hours(1),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-users-me-api-keys",
"device-users-me-api-keys",
"refresh-token-users-me-api-keys",
now,
)],
)
.await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/users/me/api-keys/key-123/providers"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-api-keys")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(
response
.headers()
.get(CONTROL_ROUTE_FAMILY_HEADER)
.and_then(|value| value.to_str().ok()),
None
);
assert_eq!(
response
.headers()
.get(CONTROL_ROUTE_KIND_HEADER)
.and_then(|value| value.to_str().ok()),
None
);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["error"]["type"], "http_error");
assert_eq!(payload["error"]["message"], "Route not found");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_users_me_api_key_writes_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-api-key-writes"),
),
]),
now + chrono::Duration::hours(1),
);
let encrypted =
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "sk-user-existing-1")
.expect("ciphertext should build");
let auth_repository = Arc::new(
InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some("hash-user-existing-1".to_string()),
StoredAuthApiKeySnapshot::new(
"user-auth-1".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-5"])),
"user-existing-1".to_string(),
Some("existing".to_string()),
true,
false,
false,
Some(60),
Some(5),
Some(300),
Some(json!(["openai"])),
Some(json!(["openai:chat"])),
Some(json!(["gpt-5"])),
)
.expect("auth api key snapshot should build"),
)])
.with_export_records(vec![StoredAuthApiKeyExportRecord::new(
"user-auth-1".to_string(),
"user-existing-1".to_string(),
"hash-user-existing-1".to_string(),
Some(encrypted),
Some("existing".to_string()),
Some(json!(["openai"])),
Some(json!(["openai:chat"])),
Some(json!(["gpt-5"])),
Some(60),
Some(5),
None,
true,
Some(300),
false,
3,
0,
0.5,
false,
)
.expect("export record should build")]),
);
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![sample_endpoint(
"endpoint-openai-1",
"provider-openai",
"openai:chat",
"https://api.openai.example",
)],
vec![],
));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = crate::data::GatewayDataState::with_auth_api_key_repository_for_tests(
auth_repository,
)
.with_user_reader(user_repository)
.with_provider_catalog_reader(provider_catalog_repository)
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-api-key-writes",
"device-users-me-api-key-writes",
"refresh-token-users-me-api-key-writes",
now,
)])
})
.await;
let client = reqwest::Client::new();
let create_response = client
.post(format!("{gateway_url}/api/users/me/api-keys"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-api-key-writes")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"name": "writer-key",
"rate_limit": 120,
"feature_settings": {
"chat_pii_redaction": {
"enabled": true
}
}
}))
.send()
.await
.expect("request should succeed");
assert_eq!(create_response.status(), StatusCode::OK);
assert_eq!(
create_response
.headers()
.get(http::header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let create_payload: serde_json::Value = create_response
.json()
.await
.expect("json body should parse");
let created_id = create_payload["id"]
.as_str()
.expect("created id should be string")
.to_string();
assert_eq!(create_payload["name"], "writer-key");
assert!(create_payload.get("credential_kind").is_none());
assert_eq!(create_payload["rate_limit"], 120);
assert_eq!(create_payload["concurrent_limit"], serde_json::Value::Null);
assert_eq!(
create_payload["feature_settings"]["chat_pii_redaction"]["enabled"],
true
);
assert_eq!(create_payload["message"], "API密钥创建成功");
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["key"]
.as_str()
.unwrap_or_default()
.starts_with("sk-"));
let update_response = client
.put(format!("{gateway_url}/api/users/me/api-keys/{created_id}"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-api-key-writes")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"name": "writer-key-renamed",
"rate_limit": 30,
"concurrent_limit": 4,
"feature_settings": {
"chat_pii_redaction": {
"enabled": false
}
}
}))
.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["name"], "writer-key-renamed");
assert!(update_payload.get("credential_kind").is_none());
assert_eq!(update_payload["rate_limit"], 30);
assert_eq!(update_payload["concurrent_limit"], 4);
assert_eq!(
update_payload["feature_settings"]["chat_pii_redaction"]["enabled"],
false
);
assert_eq!(update_payload["message"], "API密钥已更新");
let toggle_response = client
.patch(format!("{gateway_url}/api/users/me/api-keys/{created_id}"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-api-key-writes")
.header("user-agent", "AetherTest/1.0")
.json(&json!({ "is_active": false }))
.send()
.await
.expect("request should succeed");
assert_eq!(toggle_response.status(), StatusCode::OK);
let toggle_payload: serde_json::Value = toggle_response
.json()
.await
.expect("json body should parse");
assert_eq!(toggle_payload["is_active"], false);
let providers_response = client
.put(format!(
"{gateway_url}/api/users/me/api-keys/{created_id}/providers"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-api-key-writes")
.header("user-agent", "AetherTest/1.0")
.json(&json!({ "providers": ["openai"] }))
.send()
.await
.expect("request should succeed");
assert_eq!(providers_response.status(), StatusCode::OK);
let providers_payload: serde_json::Value = providers_response
.json()
.await
.expect("json body should parse");
assert_eq!(providers_payload["message"], "API密钥可用提供商已更新");
assert_eq!(
providers_payload["allowed_providers"],
json!(["provider-openai"])
);
let capabilities_response = client
.put(format!(
"{gateway_url}/api/users/me/api-keys/{created_id}/capabilities"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-api-key-writes")
.header("user-agent", "AetherTest/1.0")
.json(&json!({ "force_capabilities": {} }))
.send()
.await
.expect("request should succeed");
assert_eq!(capabilities_response.status(), StatusCode::OK);
let capabilities_payload: serde_json::Value = capabilities_response
.json()
.await
.expect("json body should parse");
assert_eq!(capabilities_payload["force_capabilities"], json!({}));
let detail_response = client
.get(format!("{gateway_url}/api/users/me/api-keys/{created_id}"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-api-key-writes")
.header("user-agent", "AetherTest/1.0")
.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["id"], created_id);
assert_eq!(detail_payload["is_active"], false);
assert_eq!(
detail_payload["allowed_providers"],
json!(["provider-openai"])
);
assert_eq!(detail_payload["concurrent_limit"], 4);
assert_eq!(detail_payload["force_capabilities"], json!({}));
assert_eq!(detail_payload["created_at"], created_at);
assert_eq!(
detail_payload["feature_settings"]["chat_pii_redaction"]["enabled"],
false
);
let delete_response = client
.delete(format!("{gateway_url}/api/users/me/api-keys/{created_id}"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-api-key-writes")
.header("user-agent", "AetherTest/1.0")
.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密钥已删除");
let missing_response = client
.get(format!("{gateway_url}/api/users/me/api-keys/{created_id}"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-api-key-writes")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(missing_response.status(), StatusCode::NOT_FOUND);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_returns_service_unavailable_for_users_me_api_key_writes_without_writer() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-api-key-readonly"),
),
]),
now + chrono::Duration::hours(1),
);
let encrypted =
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "sk-user-existing-1")
.expect("ciphertext should build");
let auth_repository = Arc::new(
InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some("hash-user-existing-1".to_string()),
StoredAuthApiKeySnapshot::new(
"user-auth-1".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-5"])),
"user-existing-1".to_string(),
Some("existing".to_string()),
true,
false,
false,
Some(60),
Some(5),
Some(300),
Some(json!(["openai"])),
Some(json!(["openai:chat"])),
Some(json!(["gpt-5"])),
)
.expect("auth api key snapshot should build"),
)])
.with_export_records(vec![StoredAuthApiKeyExportRecord::new(
"user-auth-1".to_string(),
"user-existing-1".to_string(),
"hash-user-existing-1".to_string(),
Some(encrypted),
Some("existing".to_string()),
Some(json!(["openai"])),
Some(json!(["openai:chat"])),
Some(json!(["gpt-5"])),
Some(60),
Some(5),
None,
true,
Some(300),
false,
3,
0,
0.5,
false,
)
.expect("export record should build")]),
);
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![sample_endpoint(
"endpoint-openai-1",
"provider-openai",
"openai:chat",
"https://api.openai.example",
)],
vec![],
));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
crate::data::GatewayDataState::with_auth_api_key_reader_for_tests(auth_repository)
.with_user_reader(user_repository)
.with_provider_catalog_reader(provider_catalog_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-api-key-readonly",
"device-users-me-api-key-readonly",
"refresh-token-users-me-api-key-readonly",
now,
)])
})
.await;
let client = reqwest::Client::new();
let create_response = client
.post(format!("{gateway_url}/api/users/me/api-keys"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-api-key-readonly")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"name": "readonly-key",
"rate_limit": 120
}))
.send()
.await
.expect("request should succeed");
assert_eq!(create_response.status(), StatusCode::SERVICE_UNAVAILABLE);
let create_payload: serde_json::Value = create_response
.json()
.await
.expect("json body should parse");
assert_eq!(create_payload["detail"], "服务暂不可用,请稍后重试");
let update_response = client
.put(format!(
"{gateway_url}/api/users/me/api-keys/user-existing-1"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-api-key-readonly")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"name": "writer-key-renamed",
"rate_limit": 30
}))
.send()
.await
.expect("request should succeed");
assert_eq!(update_response.status(), StatusCode::SERVICE_UNAVAILABLE);
let update_payload: serde_json::Value = update_response
.json()
.await
.expect("json body should parse");
assert_eq!(update_payload["detail"], "服务暂不可用,请稍后重试");
let toggle_response = client
.patch(format!(
"{gateway_url}/api/users/me/api-keys/user-existing-1"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-api-key-readonly")
.header("user-agent", "AetherTest/1.0")
.json(&json!({ "is_active": false }))
.send()
.await
.expect("request should succeed");
assert_eq!(toggle_response.status(), StatusCode::SERVICE_UNAVAILABLE);
let toggle_payload: serde_json::Value = toggle_response
.json()
.await
.expect("json body should parse");
assert_eq!(toggle_payload["detail"], "服务暂不可用,请稍后重试");
let providers_response = client
.put(format!(
"{gateway_url}/api/users/me/api-keys/user-existing-1/providers"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-api-key-readonly")
.header("user-agent", "AetherTest/1.0")
.json(&json!({ "providers": ["openai"] }))
.send()
.await
.expect("request should succeed");
assert_eq!(providers_response.status(), StatusCode::SERVICE_UNAVAILABLE);
let providers_payload: serde_json::Value = providers_response
.json()
.await
.expect("json body should parse");
assert_eq!(providers_payload["detail"], "服务暂不可用,请稍后重试");
let capabilities_response = client
.put(format!(
"{gateway_url}/api/users/me/api-keys/user-existing-1/capabilities"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-api-key-readonly")
.header("user-agent", "AetherTest/1.0")
.json(&json!({ "force_capabilities": {} }))
.send()
.await
.expect("request should succeed");
assert_eq!(
capabilities_response.status(),
StatusCode::SERVICE_UNAVAILABLE
);
let capabilities_payload: serde_json::Value = capabilities_response
.json()
.await
.expect("json body should parse");
assert_eq!(capabilities_payload["detail"], "服务暂不可用,请稍后重试");
let delete_response = client
.delete(format!(
"{gateway_url}/api/users/me/api-keys/user-existing-1"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-api-key-readonly")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(delete_response.status(), StatusCode::SERVICE_UNAVAILABLE);
let delete_payload: serde_json::Value = delete_response
.json()
.await
.expect("json body should parse");
assert_eq!(delete_payload["detail"], "服务暂不可用,请稍后重试");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_users_me_management_token_reads_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-management-token-reads"),
),
]),
now + chrono::Duration::hours(1),
);
let repository = Arc::new(InMemoryManagementTokenRepository::seed(vec![
sample_users_me_management_token("mt-user-1", "user-auth-1", "alice", true),
sample_users_me_management_token("mt-user-2", "user-auth-2", "bob", true),
]));
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
crate::data::GatewayDataState::with_management_token_repository_for_tests(
Arc::clone(&repository),
)
.with_user_reader(user_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-management-token-reads",
"device-users-me-management-token-reads",
"refresh-token-users-me-management-token-reads",
now,
)])
})
.await;
let client = reqwest::Client::new();
let list_response = client
.get(format!(
"{gateway_url}/api/me/management-tokens?is_active=true&skip=0&limit=50"
))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-reads",
)
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(list_response.status(), StatusCode::OK);
let list_payload: serde_json::Value =
list_response.json().await.expect("json body should parse");
assert_eq!(list_payload["total"], 1);
assert_eq!(list_payload["quota"]["used"], 1);
assert_eq!(list_payload["quota"]["max"], 20);
let items = list_payload["items"]
.as_array()
.expect("items should be array");
assert_eq!(items.len(), 1);
assert_eq!(items[0]["id"], "mt-user-1");
assert!(items[0].get("user").is_none());
let detail_response = client
.get(format!("{gateway_url}/api/me/management-tokens/mt-user-1"))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-reads",
)
.header("user-agent", "AetherTest/1.0")
.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["id"], "mt-user-1");
assert_eq!(detail_payload["token_display"], "ae_test...****");
let foreign_response = client
.get(format!("{gateway_url}/api/me/management-tokens/mt-user-2"))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-reads",
)
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(foreign_response.status(), StatusCode::NOT_FOUND);
let foreign_before = repository
.get_management_token_with_user("mt-user-2")
.await
.expect("foreign token lookup should succeed")
.expect("foreign token should exist");
let update_response = client
.put(format!("{gateway_url}/api/me/management-tokens/mt-user-2"))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-reads",
)
.header("user-agent", "AetherTest/1.0")
.json(&json!({ "name": "cross-user-update" }))
.send()
.await
.expect("cross-user token update should complete");
assert_eq!(update_response.status(), StatusCode::FORBIDDEN);
let toggle_response = client
.patch(format!(
"{gateway_url}/api/me/management-tokens/mt-user-2/status"
))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-reads",
)
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("cross-user token toggle should complete");
assert_eq!(toggle_response.status(), StatusCode::FORBIDDEN);
let regenerate_response = client
.post(format!(
"{gateway_url}/api/me/management-tokens/mt-user-2/regenerate"
))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-reads",
)
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("cross-user token regenerate should complete");
assert_eq!(regenerate_response.status(), StatusCode::FORBIDDEN);
let delete_response = client
.delete(format!("{gateway_url}/api/me/management-tokens/mt-user-2"))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-reads",
)
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("cross-user token delete should complete");
assert_eq!(delete_response.status(), StatusCode::FORBIDDEN);
let foreign_after = repository
.get_management_token_with_user("mt-user-2")
.await
.expect("foreign token lookup should succeed")
.expect("foreign token should still exist");
assert_eq!(foreign_after, foreign_before);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_users_me_management_token_nested_get_path_as_local_not_found_without_hitting_upstream(
) {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-management-token-reads"),
),
]),
now + chrono::Duration::hours(1),
);
let repository = Arc::new(InMemoryManagementTokenRepository::seed(vec![
sample_users_me_management_token("mt-user-1", "user-auth-1", "alice", true),
]));
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
crate::data::GatewayDataState::with_management_token_repository_for_tests(
repository,
)
.with_user_reader(user_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-management-token-reads",
"device-users-me-management-token-reads",
"refresh-token-users-me-management-token-reads",
now,
)])
})
.await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/me/management-tokens/mt-user-1/status"
))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-reads",
)
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_local_route_not_found_response(response).await;
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_users_me_management_token_writes_locally_without_proxying_upstream() {
let now = Utc::now();
let mut user = sample_auth_user(now);
user.role = "admin".to_string();
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-management-token-writes"),
),
]),
now + chrono::Duration::hours(1),
);
let repository = Arc::new(InMemoryManagementTokenRepository::seed(vec![
sample_users_me_management_token("mt-existing-1", "user-auth-1", "alice", true),
]));
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
crate::data::GatewayDataState::with_management_token_repository_for_tests(
repository,
)
.with_user_reader(user_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-management-token-writes",
"device-users-me-management-token-writes",
"refresh-token-users-me-management-token-writes",
now,
)])
})
.await;
let client = reqwest::Client::new();
let create_response = client
.post(format!("{gateway_url}/api/me/management-tokens"))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-writes",
)
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"name": "writer-token",
"description": "writer token",
"allowed_ips": ["127.0.0.1"],
"permissions": ["admin:usage:read"],
"expires_at": (now + chrono::Duration::days(1)).to_rfc3339(),
}))
.send()
.await
.expect("request should succeed");
let create_status = create_response.status();
let create_body = create_response.text().await.expect("body should read");
assert_eq!(create_status, StatusCode::CREATED, "{create_body}");
let create_payload: serde_json::Value =
serde_json::from_str(&create_body).expect("json body should parse");
let created_id = create_payload["data"]["id"]
.as_str()
.expect("created id should exist")
.to_string();
assert_eq!(create_payload["message"], "Management Token 创建成功");
assert_eq!(create_payload["data"]["name"], "writer-token");
assert_eq!(
create_payload["data"]["permissions"],
json!(["admin:usage:read"])
);
let created_token = create_payload["token"].as_str().unwrap_or_default();
let created_token_random = created_token.strip_prefix("ae-").unwrap_or_default();
assert_eq!(created_token_random.len(), 32);
assert!(created_token_random
.chars()
.all(|ch| ch.is_ascii_alphanumeric()));
let update_response = client
.put(format!(
"{gateway_url}/api/me/management-tokens/{created_id}"
))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-writes",
)
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"name": "writer-token-renamed",
"description": null,
"allowed_ips": ["10.0.0.1"],
"permissions": ["admin:usage:read", "admin:pool:write"],
"expires_at": (now + chrono::Duration::days(2)).to_rfc3339(),
}))
.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["message"], "更新成功");
assert_eq!(update_payload["data"]["name"], "writer-token-renamed");
assert_eq!(
update_payload["data"]["description"],
serde_json::Value::Null
);
assert_eq!(
update_payload["data"]["permissions"],
json!(["admin:pool:write", "admin:usage:read"])
);
let toggle_response = client
.patch(format!(
"{gateway_url}/api/me/management-tokens/{created_id}/status"
))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-writes",
)
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(toggle_response.status(), StatusCode::OK);
let toggle_payload: serde_json::Value = toggle_response
.json()
.await
.expect("json body should parse");
assert_eq!(toggle_payload["message"], "Token 已禁用");
assert_eq!(toggle_payload["data"]["is_active"], false);
let regenerate_response = client
.post(format!(
"{gateway_url}/api/me/management-tokens/{created_id}/regenerate"
))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-writes",
)
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(regenerate_response.status(), StatusCode::OK);
let regenerate_payload: serde_json::Value = regenerate_response
.json()
.await
.expect("json body should parse");
assert_eq!(regenerate_payload["message"], "Token 已重新生成");
let regenerated_token = regenerate_payload["token"].as_str().unwrap_or_default();
let regenerated_token_random = regenerated_token.strip_prefix("ae-").unwrap_or_default();
assert_eq!(regenerated_token_random.len(), 32);
assert!(regenerated_token_random
.chars()
.all(|ch| ch.is_ascii_alphanumeric()));
let delete_response = client
.delete(format!(
"{gateway_url}/api/me/management-tokens/{created_id}"
))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-writes",
)
.header("user-agent", "AetherTest/1.0")
.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"], "删除成功");
let missing_response = client
.get(format!(
"{gateway_url}/api/me/management-tokens/{created_id}"
))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-writes",
)
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(missing_response.status(), StatusCode::NOT_FOUND);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_users_me_management_token_writes_for_non_admin_user_without_proxying_upstream(
) {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-management-token-create-denied"),
),
]),
now + chrono::Duration::hours(1),
);
let repository = Arc::new(InMemoryManagementTokenRepository::default());
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
crate::data::GatewayDataState::with_management_token_repository_for_tests(
repository,
)
.with_user_reader(user_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-management-token-create-denied",
"device-users-me-management-token-create-denied",
"refresh-token-users-me-management-token-create-denied",
now,
)])
})
.await;
let create_response = reqwest::Client::new()
.post(format!("{gateway_url}/api/me/management-tokens"))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-create-denied",
)
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"name": "ordinary-user-token",
"description": "ordinary user token",
}))
.send()
.await
.expect("request should succeed");
assert_eq!(create_response.status(), StatusCode::FORBIDDEN);
let create_payload: serde_json::Value = create_response
.json()
.await
.expect("json body should parse");
assert_eq!(
create_payload["detail"],
json!("仅管理员可以创建 Management Token")
);
let client = reqwest::Client::new();
for (method, path) in [
(reqwest::Method::PUT, "/api/me/management-tokens/mt-owned"),
(
reqwest::Method::PATCH,
"/api/me/management-tokens/mt-owned/status",
),
(
reqwest::Method::POST,
"/api/me/management-tokens/mt-owned/regenerate",
),
(
reqwest::Method::DELETE,
"/api/me/management-tokens/mt-owned",
),
] {
let response = client
.request(method, format!("{gateway_url}{path}"))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-create-denied",
)
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(
payload["detail"],
json!("仅管理员可以管理 Management Token")
);
}
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_returns_service_unavailable_for_users_me_management_token_reads_without_reader() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-management-token-read-unavailable"),
),
]),
now + chrono::Duration::hours(1),
);
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
crate::data::GatewayDataState::with_user_reader_for_tests(user_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-management-token-read-unavailable",
"device-users-me-management-token-read-unavailable",
"refresh-token-users-me-management-token-read-unavailable",
now,
)])
})
.await;
let client = reqwest::Client::new();
let list_response = client
.get(format!("{gateway_url}/api/me/management-tokens"))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-read-unavailable",
)
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(list_response.status(), StatusCode::SERVICE_UNAVAILABLE);
let list_payload: serde_json::Value =
list_response.json().await.expect("json body should parse");
assert_eq!(list_payload["detail"], "服务暂不可用,请稍后重试");
let detail_response = client
.get(format!("{gateway_url}/api/me/management-tokens/mt-missing"))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-read-unavailable",
)
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(detail_response.status(), StatusCode::SERVICE_UNAVAILABLE);
let detail_payload: serde_json::Value = detail_response
.json()
.await
.expect("json body should parse");
assert_eq!(detail_payload["detail"], "服务暂不可用,请稍后重试");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_returns_service_unavailable_for_users_me_management_token_writes_without_writer() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-management-token-write-unavailable"),
),
]),
now + chrono::Duration::hours(1),
);
let repository = Arc::new(InMemoryManagementTokenRepository::seed(vec![
sample_users_me_management_token("mt-existing-1", "user-auth-1", "alice", true),
]));
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
crate::data::GatewayDataState::with_management_token_reader_for_tests(repository)
.with_user_reader(user_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-management-token-write-unavailable",
"device-users-me-management-token-write-unavailable",
"refresh-token-users-me-management-token-write-unavailable",
now,
)])
})
.await;
let client = reqwest::Client::new();
let create_response = client
.post(format!("{gateway_url}/api/me/management-tokens"))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-write-unavailable",
)
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"name": "readonly-token",
"description": "readonly token",
"allowed_ips": ["127.0.0.1"],
"expires_at": (now + chrono::Duration::days(1)).to_rfc3339(),
}))
.send()
.await
.expect("request should succeed");
assert_eq!(create_response.status(), StatusCode::SERVICE_UNAVAILABLE);
let create_payload: serde_json::Value = create_response
.json()
.await
.expect("json body should parse");
assert_eq!(create_payload["detail"], "服务暂不可用,请稍后重试");
let update_response = client
.put(format!(
"{gateway_url}/api/me/management-tokens/mt-existing-1"
))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-write-unavailable",
)
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"name": "readonly-token-renamed",
}))
.send()
.await
.expect("request should succeed");
assert_eq!(update_response.status(), StatusCode::SERVICE_UNAVAILABLE);
let update_payload: serde_json::Value = update_response
.json()
.await
.expect("json body should parse");
assert_eq!(update_payload["detail"], "服务暂不可用,请稍后重试");
let toggle_response = client
.patch(format!(
"{gateway_url}/api/me/management-tokens/mt-existing-1/status"
))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-write-unavailable",
)
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(toggle_response.status(), StatusCode::SERVICE_UNAVAILABLE);
let toggle_payload: serde_json::Value = toggle_response
.json()
.await
.expect("json body should parse");
assert_eq!(toggle_payload["detail"], "服务暂不可用,请稍后重试");
let regenerate_response = client
.post(format!(
"{gateway_url}/api/me/management-tokens/mt-existing-1/regenerate"
))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-write-unavailable",
)
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(
regenerate_response.status(),
StatusCode::SERVICE_UNAVAILABLE
);
let regenerate_payload: serde_json::Value = regenerate_response
.json()
.await
.expect("json body should parse");
assert_eq!(regenerate_payload["detail"], "服务暂不可用,请稍后重试");
let delete_response = client
.delete(format!(
"{gateway_url}/api/me/management-tokens/mt-existing-1"
))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-write-unavailable",
)
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(delete_response.status(), StatusCode::SERVICE_UNAVAILABLE);
let delete_payload: serde_json::Value = delete_response
.json()
.await
.expect("json body should parse");
assert_eq!(delete_payload["detail"], "服务暂不可用,请稍后重试");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_users_me_providers_locally_without_proxying_upstream() {
let now = Utc::now();
let mut user = sample_auth_user(now);
user.allowed_providers = Some(vec!["claude".to_string()]);
user.allowed_providers_mode = "specific".to_string();
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-providers"),
),
]),
now + chrono::Duration::hours(1),
);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider("provider-openai", "openai", 10),
sample_provider("provider-claude", "claude", 20),
],
vec![
sample_endpoint(
"endpoint-openai-1",
"provider-openai",
"openai:chat",
"https://api.openai.example",
),
sample_endpoint(
"endpoint-claude-1",
"provider-claude",
"claude:messages",
"https://api.claude.example",
),
],
vec![],
));
let global_model_repository = Arc::new(
InMemoryGlobalModelReadRepository::seed(Vec::<StoredPublicGlobalModel>::new())
.with_public_catalog_models(vec![
sample_public_catalog_model(
"model-openai-gpt5",
"provider-openai",
"openai",
"gpt-5-preview",
"gpt-5",
"GPT 5",
),
sample_public_catalog_model(
"model-claude-sonnet",
"provider-claude",
"claude",
"claude-sonnet-4-5-20251001",
"claude-sonnet-4-5",
"Claude Sonnet 4.5",
),
]),
);
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let group = user_repository
.create_user_group(UpsertUserGroupRecord {
name: "OpenAI only".to_string(),
description: None,
priority: 0,
allowed_providers: Some(vec!["openai".to_string()]),
allowed_providers_mode: "specific".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("group should create")
.expect("group should exist");
user_repository
.add_user_to_group(&group.id, "user-auth-1")
.await
.expect("group membership should create");
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = crate::data::GatewayDataState::with_global_model_reader_for_tests(
global_model_repository,
)
.with_provider_catalog_reader(provider_catalog_repository)
.with_user_reader(user_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-providers",
"device-users-me-providers",
"refresh-token-users-me-providers",
now,
)])
})
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/users/me/providers"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-providers")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
let providers = payload.as_array().expect("providers should be array");
assert_eq!(providers.len(), 1);
assert_eq!(providers[0]["id"], "provider-openai");
assert!(providers[0].get("name").is_none());
assert!(providers[0].get("description").is_none());
assert_eq!(providers[0]["endpoints"][0]["id"], "endpoint-openai-1");
assert!(providers[0]["endpoints"][0].get("base_url").is_none());
assert_eq!(providers[0]["models"][0]["name"], "gpt-5");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_users_me_detail_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-users-me-detail-1")),
]),
now + chrono::Duration::hours(1),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-users-me-detail-1",
"device-users-me-detail-1",
"refresh-token-placeholder",
now,
)],
)
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/users/me"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-detail-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["id"], "user-auth-1");
assert_eq!(payload["email"], "[email protected]");
assert_eq!(payload["username"], "alice");
assert_eq!(payload["auth_source"], "local");
assert_eq!(payload["has_password"], true);
assert_eq!(payload["billing"]["id"], "wallet-auth-1");
assert_eq!(payload["billing"]["balance"], 15.5);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_auth_login_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(user, sample_auth_wallet("user-auth-1", now), []).await;
let client = reqwest::Client::new();
let response = client
.post(format!("{gateway_url}/api/auth/login"))
.header("x-client-device-id", "device-auth-login")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"email": "[email protected]",
"password": "secret123",
"auth_type": "local",
}))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let set_cookie = response
.headers()
.get(reqwest::header::SET_COOKIE)
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned)
.expect("set-cookie should exist");
assert!(set_cookie.contains("aether_refresh_token="));
let payload: serde_json::Value = response.json().await.expect("json body should parse");
let access_token = payload["access_token"]
.as_str()
.expect("access token should exist")
.to_string();
assert_eq!(payload["user_id"], "user-auth-1");
assert_eq!(payload["email"], "[email protected]");
assert_eq!(payload["username"], "alice");
assert_eq!(payload["role"], "user");
let me_response = client
.get(format!("{gateway_url}/api/auth/me"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-auth-login")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("me request should succeed");
assert_eq!(me_response.status(), StatusCode::OK);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_auth_login_rejects_query_only_device_binding_without_creating_session() {
let now = Utc::now();
let user = sample_auth_user(now);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(user, sample_auth_wallet("user-auth-1", now), []).await;
let client = reqwest::Client::new();
let rejected = client
.post(format!(
"{gateway_url}/api/auth/login?client_device_id=device-auth-login-csrf"
))
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"email": "[email protected]",
"password": "secret123",
"auth_type": "local",
}))
.send()
.await
.expect("query-only login request should complete");
assert_eq!(rejected.status(), StatusCode::BAD_REQUEST);
assert!(!rejected.headers().contains_key(http::header::SET_COOKIE));
let legitimate = client
.post(format!("{gateway_url}/api/auth/login"))
.header("x-client-device-id", "device-auth-login-csrf")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"email": "[email protected]",
"password": "secret123",
"auth_type": "local",
}))
.send()
.await
.expect("legitimate login should complete");
assert_eq!(legitimate.status(), StatusCode::OK);
assert!(legitimate.headers().contains_key(http::header::SET_COOKIE));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rate_limits_repeated_auth_login_attempts_by_identifier() {
let now = Utc::now();
let user = sample_auth_user(now);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(user, sample_auth_wallet("user-auth-1", now), []).await;
let client = reqwest::Client::new();
for attempt in 1..=10 {
let response = client
.post(format!("{gateway_url}/api/auth/login"))
.header("x-client-device-id", "device-auth-login-rate-limit")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"email": "[email protected]",
"password": "incorrect-password",
"auth_type": "local",
}))
.send()
.await
.expect("login request should succeed");
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"attempt {attempt} should reach authentication"
);
}
let rejected = client
.post(format!("{gateway_url}/api/auth/login"))
.header("x-client-device-id", "device-auth-login-rate-limit")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"email": "[email protected]",
"password": "incorrect-password",
"auth_type": "local",
}))
.send()
.await
.expect("rate-limited login request should succeed");
assert_eq!(rejected.status(), StatusCode::TOO_MANY_REQUESTS);
let retry_after = rejected
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok())
.expect("Retry-After should be a positive integer");
assert!(retry_after > 0 && retry_after <= 60);
let payload: serde_json::Value = rejected.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "请求过于频繁,请稍后重试");
assert_eq!(payload["retry_after"], retry_after);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_auth_login_identifier_rate_limit_is_atomic_under_concurrency() {
let now = Utc::now();
let user = sample_auth_user(now);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(user, sample_auth_wallet("user-auth-1", now), []).await;
let client = reqwest::Client::new();
let mut tasks = Vec::new();
for _ in 0..12 {
let client = client.clone();
let gateway_url = gateway_url.clone();
tasks.push(tokio::spawn(async move {
client
.post(format!("{gateway_url}/api/auth/login"))
.header("x-client-device-id", "device-auth-login-concurrent")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"email": "[email protected]",
"password": "incorrect-password",
"auth_type": "local",
}))
.send()
.await
.expect("concurrent login request should succeed")
.status()
}));
}
let mut unauthorized = 0;
let mut rate_limited = 0;
for task in tasks {
match task.await.expect("login task should complete") {
StatusCode::UNAUTHORIZED => unauthorized += 1,
StatusCode::TOO_MANY_REQUESTS => rate_limited += 1,
status => panic!("unexpected concurrent login status: {status}"),
}
}
assert_eq!(unauthorized, 10);
assert_eq!(rate_limited, 2);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_unsupported_auth_login_type_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(user, sample_auth_wallet("user-auth-1", now), []).await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/login"))
.header("x-client-device-id", "device-auth-login-unsupported")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"email": "[email protected]",
"password": "secret123",
"auth_type": "oauth",
}))
.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"], "不支持的认证类型");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_auth_ldap_login_locally_without_proxying_upstream() {
let encrypted_bind_password =
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "bind-secret")
.expect("bind password should encrypt");
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let auth_module_repository = Arc::new(InMemoryAuthModuleReadRepository::seed(
Vec::<StoredOAuthProviderModuleConfig>::new(),
Some(StoredLdapModuleConfig {
server_url: "mockldap://ldap.example.com".to_string(),
bind_dn: "cn=admin,dc=example,dc=com".to_string(),
bind_password_encrypted: Some(encrypted_bind_password),
base_dn: "dc=example,dc=com".to_string(),
user_search_filter: Some("(uid={username})".to_string()),
username_attr: Some("uid".to_string()),
email_attr: Some("mail".to_string()),
display_name_attr: Some("displayName".to_string()),
is_enabled: true,
is_exclusive: false,
use_starttls: false,
connect_timeout: Some(5),
}),
));
let data_state = crate::data::GatewayDataState::disabled()
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY)
.attach_auth_module_repository_for_tests(auth_module_repository)
.with_system_config_values_for_tests(vec![
("module.ldap.enabled".to_string(), json!(true)),
("default_user_initial_gift_usd".to_string(), json!(9.5)),
]);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
})
.await;
let client = reqwest::Client::new();
let response = client
.post(format!("{gateway_url}/api/auth/login"))
.header("x-client-device-id", "device-auth-ldap")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"email": "alice",
"password": "secret123",
"auth_type": "ldap",
}))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let set_cookie = response
.headers()
.get(reqwest::header::SET_COOKIE)
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned)
.expect("set-cookie should exist");
assert!(set_cookie.contains("aether_refresh_token="));
let payload: serde_json::Value = response.json().await.expect("json body should parse");
let access_token = payload["access_token"]
.as_str()
.expect("access token should exist")
.to_string();
let user_id = payload["user_id"]
.as_str()
.expect("user id should exist")
.to_string();
assert_eq!(payload["email"], "[email protected]");
assert_eq!(payload["username"], "alice");
assert_eq!(payload["role"], "user");
let me_response = client
.get(format!("{gateway_url}/api/auth/me"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-auth-ldap")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("me request should succeed");
assert_eq!(me_response.status(), StatusCode::OK);
let me_payload: serde_json::Value = me_response.json().await.expect("json body should parse");
assert_eq!(me_payload["id"], user_id);
assert_eq!(me_payload["email"], "[email protected]");
assert_eq!(me_payload["username"], "alice");
assert_eq!(me_payload["billing"]["balance"], 9.5);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_auth_register_locally_without_proxying_upstream() {
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = crate::data::GatewayDataState::disabled()
.with_system_config_values_for_tests(vec![
("enable_registration".to_string(), json!(true)),
("require_email_verification".to_string(), json!(true)),
("smtp_host".to_string(), json!("smtp.example.com")),
("smtp_from_email".to_string(), json!("[email protected]")),
("default_user_initial_gift_usd".to_string(), json!(12.5)),
]);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_email_verified_for_tests(
"[email protected]",
TEST_EMAIL_VERIFICATION_TOKEN,
)
})
.await;
let client = reqwest::Client::new();
let register_response = client
.post(format!("{gateway_url}/api/auth/register"))
.json(&json!({
"email": "[email protected]",
"username": "alice",
"password": "secret123",
"email_verification_token": TEST_EMAIL_VERIFICATION_TOKEN,
}))
.send()
.await
.expect("register request should succeed");
assert_eq!(register_response.status(), StatusCode::OK);
let register_payload: serde_json::Value = register_response
.json()
.await
.expect("json body should parse");
assert_eq!(register_payload["email"], "[email protected]");
assert_eq!(register_payload["username"], "alice");
assert_eq!(register_payload["message"], "注册成功");
let login_response = client
.post(format!("{gateway_url}/api/auth/login"))
.header("x-client-device-id", "device-auth-register")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"email": "[email protected]",
"password": "secret123",
"auth_type": "local",
}))
.send()
.await
.expect("login request should succeed");
assert_eq!(login_response.status(), StatusCode::OK);
let login_payload: serde_json::Value =
login_response.json().await.expect("json body should parse");
let access_token = login_payload["access_token"]
.as_str()
.expect("access token should exist")
.to_string();
let me_response = client
.get(format!("{gateway_url}/api/auth/me"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-auth-register")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("me request should succeed");
assert_eq!(me_response.status(), StatusCode::OK);
let me_payload: serde_json::Value = me_response.json().await.expect("json body should parse");
assert_eq!(me_payload["email"], "[email protected]");
assert_eq!(me_payload["username"], "alice");
assert_eq!(me_payload["billing"]["gift_balance"], 12.5);
assert_eq!(me_payload["billing"]["total_adjusted"], 12.5);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_auth_register_without_current_privacy_policy_acceptance() {
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = crate::data::GatewayDataState::disabled()
.with_system_config_values_for_tests(vec![
("enable_registration".to_string(), json!(true)),
("require_email_verification".to_string(), json!(true)),
("smtp_host".to_string(), json!("smtp.example.com")),
("smtp_from_email".to_string(), json!("[email protected]")),
(
"registration_privacy_policy_enabled".to_string(),
json!(true),
),
(
"registration_privacy_policy_version".to_string(),
json!("2026-05-16"),
),
]);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_email_verified_for_tests(
"[email protected]",
TEST_EMAIL_VERIFICATION_TOKEN,
)
})
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/register"))
.json(&json!({
"email": "[email protected]",
"username": "alice",
"password": "secret123",
"privacy_policy_accepted": true,
"privacy_policy_version": "old-version",
}))
.send()
.await
.expect("register request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "请先阅读并同意当前版本的隐私政策");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
async fn start_turnstile_siteverify_server(
response_payload: serde_json::Value,
status: StatusCode,
) -> (
String,
Arc<Mutex<Vec<std::collections::HashMap<String, String>>>>,
tokio::task::JoinHandle<()>,
) {
start_turnstile_siteverify_server_with_delay(response_payload, status, None).await
}
async fn start_turnstile_siteverify_server_with_delay(
response_payload: serde_json::Value,
status: StatusCode,
delay: Option<Duration>,
) -> (
String,
Arc<Mutex<Vec<std::collections::HashMap<String, String>>>>,
tokio::task::JoinHandle<()>,
) {
let requests = Arc::new(Mutex::new(Vec::new()));
let requests_clone = Arc::clone(&requests);
let upstream = Router::new().route(
"/turnstile/siteverify",
any(
move |axum::extract::Form(form): axum::extract::Form<
std::collections::HashMap<String, String>,
>| {
let requests_inner = Arc::clone(&requests_clone);
let response_payload = response_payload.clone();
async move {
if let Some(delay) = delay {
tokio::time::sleep(delay).await;
}
requests_inner
.lock()
.expect("turnstile requests should lock")
.push(form);
(status, Json(response_payload))
}
},
),
);
let (url, handle) = start_server(upstream).await;
(format!("{url}/turnstile/siteverify"), requests, handle)
}
fn turnstile_enabled_data_state() -> crate::data::GatewayDataState {
crate::data::GatewayDataState::disabled().with_system_config_values_for_tests(vec![
("enable_registration".to_string(), json!(true)),
("require_email_verification".to_string(), json!(true)),
("smtp_host".to_string(), json!("smtp.example.com")),
("smtp_from_email".to_string(), json!("[email protected]")),
("default_user_initial_gift_usd".to_string(), json!(12.5)),
("turnstile_enabled".to_string(), json!(true)),
("turnstile_site_key".to_string(), json!("site-public-key")),
(
"turnstile_secret_key".to_string(),
json!("secret-private-key"),
),
(
"turnstile_allowed_hostnames".to_string(),
json!(["gateway.example.com"]),
),
])
}
#[tokio::test]
async fn gateway_rejects_auth_register_without_turnstile_token_when_enabled() {
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(turnstile_enabled_data_state())
.with_auth_email_verified_for_tests(
"[email protected]",
TEST_EMAIL_VERIFICATION_TOKEN,
)
})
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/register"))
.json(&json!({
"email": "[email protected]",
"username": "alice",
"password": "secret123",
"email_verification_token": TEST_EMAIL_VERIFICATION_TOKEN,
}))
.send()
.await
.expect("register request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "请先完成人机验证");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_auth_send_verification_code_without_turnstile_token_when_enabled() {
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(turnstile_enabled_data_state())
})
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/send-verification-code"))
.json(&json!({ "email": "[email protected]" }))
.send()
.await
.expect("send verification request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "请先完成人机验证");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_auth_register_with_oversized_turnstile_token() {
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(turnstile_enabled_data_state())
.with_auth_email_verified_for_tests(
"[email protected]",
TEST_EMAIL_VERIFICATION_TOKEN,
)
})
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/register"))
.json(&json!({
"email": "[email protected]",
"username": "alice",
"password": "secret123",
"turnstile_token": "x".repeat(2049),
}))
.send()
.await
.expect("register request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "人机验证失败,请重试");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_returns_service_unavailable_when_turnstile_keys_are_incomplete() {
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
turnstile_enabled_data_state().with_system_config_values_for_tests(vec![
("enable_registration".to_string(), json!(true)),
("require_email_verification".to_string(), json!(true)),
("smtp_host".to_string(), json!("smtp.example.com")),
("smtp_from_email".to_string(), json!("[email protected]")),
("turnstile_enabled".to_string(), json!(true)),
("turnstile_site_key".to_string(), json!("site-public-key")),
("turnstile_secret_key".to_string(), serde_json::Value::Null),
]);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_email_verified_for_tests(
"[email protected]",
TEST_EMAIL_VERIFICATION_TOKEN,
)
})
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/register"))
.json(&json!({
"email": "[email protected]",
"username": "alice",
"password": "secret123",
"turnstile_token": "valid-token",
}))
.send()
.await
.expect("register request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
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_ignores_spoofed_cf_ip_during_successful_turnstile_verification() {
let (siteverify_url, turnstile_requests, turnstile_handle) = start_turnstile_siteverify_server(
json!({
"success": true,
"action": "register",
"hostname": "gateway.example.com",
}),
StatusCode::OK,
)
.await;
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder({
let siteverify_url = siteverify_url.clone();
move || {
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(turnstile_enabled_data_state())
.with_auth_email_verified_for_tests(
"[email protected]",
TEST_EMAIL_VERIFICATION_TOKEN,
)
.with_turnstile_siteverify_url_for_tests(&siteverify_url)
}
})
.await;
let register_response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/register"))
.header("cf-connecting-ip", "203.0.113.10")
.json(&json!({
"email": "[email protected]",
"username": "alice",
"password": "secret123",
"turnstile_token": "valid-token",
"email_verification_token": TEST_EMAIL_VERIFICATION_TOKEN,
}))
.send()
.await
.expect("register request should succeed");
assert_eq!(register_response.status(), StatusCode::OK);
let register_payload: serde_json::Value = register_response
.json()
.await
.expect("json body should parse");
assert_eq!(register_payload["message"], "注册成功");
let requests = turnstile_requests
.lock()
.expect("turnstile requests should lock");
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0].get("secret").map(String::as_str),
Some("secret-private-key")
);
assert_eq!(
requests[0].get("response").map(String::as_str),
Some("valid-token")
);
assert_eq!(
requests[0].get("remoteip").map(String::as_str),
Some("127.0.0.1")
);
assert!(requests[0].contains_key("idempotency_key"));
drop(requests);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
turnstile_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_auth_register_when_turnstile_action_mismatches() {
let (siteverify_url, _turnstile_requests, turnstile_handle) =
start_turnstile_siteverify_server(
json!({
"success": true,
"action": "send_verification_code",
"hostname": "gateway.example.com",
}),
StatusCode::OK,
)
.await;
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder({
let siteverify_url = siteverify_url.clone();
move || {
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(turnstile_enabled_data_state())
.with_auth_email_verified_for_tests(
"[email protected]",
TEST_EMAIL_VERIFICATION_TOKEN,
)
.with_turnstile_siteverify_url_for_tests(&siteverify_url)
}
})
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/register"))
.json(&json!({
"email": "[email protected]",
"username": "alice",
"password": "secret123",
"turnstile_token": "valid-token",
}))
.send()
.await
.expect("register request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "人机验证失败,请重试");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
turnstile_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_auth_register_when_turnstile_siteverify_rejects_token() {
let (siteverify_url, _turnstile_requests, turnstile_handle) =
start_turnstile_siteverify_server(
json!({
"success": false,
"error-codes": ["invalid-input-response"],
}),
StatusCode::OK,
)
.await;
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder({
let siteverify_url = siteverify_url.clone();
move || {
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(turnstile_enabled_data_state())
.with_auth_email_verified_for_tests(
"[email protected]",
TEST_EMAIL_VERIFICATION_TOKEN,
)
.with_turnstile_siteverify_url_for_tests(&siteverify_url)
}
})
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/register"))
.json(&json!({
"email": "[email protected]",
"username": "alice",
"password": "secret123",
"turnstile_token": "invalid-token",
}))
.send()
.await
.expect("register request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "人机验证失败,请重试");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
turnstile_handle.abort();
}
#[tokio::test]
async fn gateway_returns_service_unavailable_when_turnstile_siteverify_reports_secret_error() {
let (siteverify_url, _turnstile_requests, turnstile_handle) =
start_turnstile_siteverify_server(
json!({
"success": false,
"error-codes": ["invalid-input-secret"],
}),
StatusCode::OK,
)
.await;
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder({
let siteverify_url = siteverify_url.clone();
move || {
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(turnstile_enabled_data_state())
.with_auth_email_verified_for_tests(
"[email protected]",
TEST_EMAIL_VERIFICATION_TOKEN,
)
.with_turnstile_siteverify_url_for_tests(&siteverify_url)
}
})
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/register"))
.json(&json!({
"email": "[email protected]",
"username": "alice",
"password": "secret123",
"turnstile_token": "valid-token",
}))
.send()
.await
.expect("register request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "服务暂不可用,请稍后重试");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
turnstile_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_auth_register_when_turnstile_hostname_mismatches() {
let (siteverify_url, _turnstile_requests, turnstile_handle) =
start_turnstile_siteverify_server(
json!({
"success": true,
"action": "register",
"hostname": "evil.example.com",
}),
StatusCode::OK,
)
.await;
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder({
let siteverify_url = siteverify_url.clone();
move || {
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(turnstile_enabled_data_state())
.with_auth_email_verified_for_tests(
"[email protected]",
TEST_EMAIL_VERIFICATION_TOKEN,
)
.with_turnstile_siteverify_url_for_tests(&siteverify_url)
}
})
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/register"))
.json(&json!({
"email": "[email protected]",
"username": "alice",
"password": "secret123",
"turnstile_token": "valid-token",
}))
.send()
.await
.expect("register request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "人机验证失败,请重试");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
turnstile_handle.abort();
}
#[tokio::test]
async fn gateway_returns_service_unavailable_when_turnstile_siteverify_fails() {
let (siteverify_url, _turnstile_requests, turnstile_handle) =
start_turnstile_siteverify_server(
json!({ "error": "unavailable" }),
StatusCode::BAD_GATEWAY,
)
.await;
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder({
let siteverify_url = siteverify_url.clone();
move || {
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(turnstile_enabled_data_state())
.with_auth_email_verified_for_tests(
"[email protected]",
TEST_EMAIL_VERIFICATION_TOKEN,
)
.with_turnstile_siteverify_url_for_tests(&siteverify_url)
}
})
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/register"))
.json(&json!({
"email": "[email protected]",
"username": "alice",
"password": "secret123",
"turnstile_token": "valid-token",
}))
.send()
.await
.expect("register request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "服务暂不可用,请稍后重试");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
turnstile_handle.abort();
}
#[tokio::test]
async fn gateway_returns_service_unavailable_when_turnstile_siteverify_times_out() {
let (siteverify_url, _turnstile_requests, turnstile_handle) =
start_turnstile_siteverify_server_with_delay(
json!({
"success": true,
"action": "register",
"hostname": "gateway.example.com",
}),
StatusCode::OK,
Some(Duration::from_millis(250)),
)
.await;
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder({
let siteverify_url = siteverify_url.clone();
move || {
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(turnstile_enabled_data_state())
.with_auth_email_verified_for_tests(
"[email protected]",
TEST_EMAIL_VERIFICATION_TOKEN,
)
.with_turnstile_siteverify_url_for_tests(&siteverify_url)
.with_turnstile_siteverify_timeout_for_tests(Duration::from_millis(20))
}
})
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/register"))
.json(&json!({
"email": "[email protected]",
"username": "alice",
"password": "secret123",
"turnstile_token": "valid-token",
}))
.send()
.await
.expect("register request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "服务暂不可用,请稍后重试");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
turnstile_handle.abort();
}
#[tokio::test]
async fn gateway_returns_service_unavailable_for_auth_register_without_storage() {
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = crate::data::GatewayDataState::disabled()
.with_system_config_values_for_tests(vec![
("enable_registration".to_string(), json!(true)),
("require_email_verification".to_string(), json!(true)),
("smtp_host".to_string(), json!("smtp.example.com")),
("smtp_from_email".to_string(), json!("[email protected]")),
("default_user_initial_gift_usd".to_string(), json!(12.5)),
]);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_email_verified_for_tests(
"[email protected]",
TEST_EMAIL_VERIFICATION_TOKEN,
)
.without_auth_user_store_for_tests()
})
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/register"))
.json(&json!({
"email": "[email protected]",
"username": "alice",
"password": "secret123",
"email_verification_token": TEST_EMAIL_VERIFICATION_TOKEN,
}))
.send()
.await
.expect("register request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
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_handles_auth_send_verification_code_locally_without_proxying_upstream() {
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::disabled().with_system_config_values_for_tests(
vec![
("smtp_host".to_string(), json!("smtp.example.com")),
("smtp_from_email".to_string(), json!("[email protected]")),
("smtp_from_name".to_string(), json!("Aether Mail")),
],
),
)
})
.await;
let client = reqwest::Client::new();
let send_response = client
.post(format!("{gateway_url}/api/auth/send-verification-code"))
.json(&json!({ "email": "[email protected]" }))
.send()
.await
.expect("request should succeed");
assert_eq!(send_response.status(), StatusCode::OK);
assert_eq!(
send_response
.headers()
.get(http::header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let send_payload: serde_json::Value =
send_response.json().await.expect("json body should parse");
assert_eq!(send_payload["success"], true);
assert_eq!(send_payload["expire_minutes"], 5);
let verification_token = send_payload["verification_token"]
.as_str()
.expect("verification token should exist")
.to_string();
let status_response = client
.post(format!("{gateway_url}/api/auth/verification-status"))
.json(&json!({
"email": "[email protected]",
"verification_token": verification_token,
}))
.send()
.await
.expect("status request should succeed");
assert_eq!(status_response.status(), StatusCode::OK);
let status_payload: serde_json::Value = status_response
.json()
.await
.expect("json body should parse");
assert_eq!(status_payload["has_pending_code"], true);
assert_eq!(status_payload["is_verified"], false);
assert!(
status_payload["cooldown_remaining"]
.as_i64()
.unwrap_or_default()
> 0
);
let resend_response = client
.post(format!("{gateway_url}/api/auth/send-verification-code"))
.json(&json!({ "email": "[email protected]" }))
.send()
.await
.expect("second request should succeed");
assert_eq!(resend_response.status(), StatusCode::BAD_REQUEST);
let resend_payload: serde_json::Value = resend_response
.json()
.await
.expect("json body should parse");
assert!(resend_payload["detail"]
.as_str()
.unwrap_or_default()
.contains("请在"));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_requires_turnstile_token_before_auth_send_verification_code() {
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::disabled().with_system_config_values_for_tests(
vec![
("smtp_host".to_string(), json!("smtp.example.com")),
("smtp_from_email".to_string(), json!("[email protected]")),
("turnstile_enabled".to_string(), json!(true)),
("turnstile_site_key".to_string(), json!("site-key-123")),
("turnstile_secret_key".to_string(), json!("secret-key-123")),
],
),
)
})
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/send-verification-code"))
.json(&json!({ "email": "[email protected]" }))
.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"], "请先完成人机验证");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_verifies_turnstile_token_before_auth_send_verification_code() {
let (siteverify_url, turnstile_requests, siteverify_handle) =
start_turnstile_siteverify_server(
json!({
"success": true,
"action": "send_verification_code",
"hostname": "localhost"
}),
StatusCode::OK,
)
.await;
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder({
let siteverify_url = siteverify_url.clone();
move || {
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::disabled()
.with_system_config_values_for_tests(vec![
("smtp_host".to_string(), json!("smtp.example.com")),
("smtp_from_email".to_string(), json!("[email protected]")),
("smtp_from_name".to_string(), json!("Aether Mail")),
("turnstile_enabled".to_string(), json!(true)),
("turnstile_site_key".to_string(), json!("site-key-123")),
("turnstile_secret_key".to_string(), json!("secret-key-123")),
(
"turnstile_allowed_hostnames".to_string(),
json!(["localhost"]),
),
]),
)
.with_turnstile_siteverify_url_for_tests(&siteverify_url)
}
})
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/send-verification-code"))
.json(&json!({
"email": "[email protected]",
"turnstile_token": "turnstile-token-123"
}))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["success"], true);
let requests = turnstile_requests
.lock()
.expect("turnstile requests should lock");
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0].get("secret").map(String::as_str),
Some("secret-key-123")
);
assert_eq!(
requests[0].get("response").map(String::as_str),
Some("turnstile-token-123")
);
assert!(requests[0].contains_key("idempotency_key"));
drop(requests);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
siteverify_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_auth_verification_status_locally_without_proxying_upstream() {
let now = Utc::now() - chrono::Duration::seconds(10);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
AppState::new()
.expect("gateway should build")
.with_auth_email_verification_pending_for_tests(
"[email protected]",
"123456",
TEST_EMAIL_VERIFICATION_TOKEN,
now,
)
})
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/verification-status"))
.json(&json!({
"email": "[email protected]",
"verification_token": TEST_EMAIL_VERIFICATION_TOKEN,
}))
.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["email"], "[email protected]");
assert_eq!(payload["has_pending_code"], true);
assert_eq!(payload["is_verified"], false);
assert!(payload["cooldown_remaining"].as_i64().unwrap_or_default() > 0);
assert!(payload["code_expires_in"].as_i64().unwrap_or_default() > 0);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_auth_verify_email_locally_without_proxying_upstream() {
let now = Utc::now();
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
AppState::new()
.expect("gateway should build")
.with_auth_email_verification_pending_for_tests(
"[email protected]",
"123456",
TEST_EMAIL_VERIFICATION_TOKEN,
now,
)
})
.await;
let client = reqwest::Client::new();
let verify_response = client
.post(format!("{gateway_url}/api/auth/verify-email"))
.json(&json!({
"email": "[email protected]",
"code": "123456",
"verification_token": TEST_EMAIL_VERIFICATION_TOKEN,
}))
.send()
.await
.expect("request should succeed");
assert_eq!(verify_response.status(), StatusCode::OK);
let verify_payload: serde_json::Value = verify_response
.json()
.await
.expect("json body should parse");
assert_eq!(verify_payload["success"], true);
let status_response = client
.post(format!("{gateway_url}/api/auth/verification-status"))
.json(&json!({
"email": "[email protected]",
"verification_token": TEST_EMAIL_VERIFICATION_TOKEN,
}))
.send()
.await
.expect("status request should succeed");
assert_eq!(status_response.status(), StatusCode::OK);
let status_payload: serde_json::Value = status_response
.json()
.await
.expect("json body should parse");
assert_eq!(status_payload["has_pending_code"], false);
assert_eq!(status_payload["is_verified"], true);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_invalidates_email_verification_after_five_incorrect_codes() {
let now = Utc::now();
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
AppState::new()
.expect("gateway should build")
.with_auth_email_verification_pending_for_tests(
"[email protected]",
"123456",
TEST_EMAIL_VERIFICATION_TOKEN,
now,
)
})
.await;
let client = reqwest::Client::new();
for attempt in 1..=4 {
let response = client
.post(format!("{gateway_url}/api/auth/verify-email"))
.json(&json!({
"email": "[email protected]",
"code": "000000",
"verification_token": TEST_EMAIL_VERIFICATION_TOKEN,
}))
.send()
.await
.expect("incorrect verification request should succeed");
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"attempt {attempt} should report an incorrect code"
);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["detail"], "验证码错误");
}
let exhausted = client
.post(format!("{gateway_url}/api/auth/verify-email"))
.json(&json!({
"email": "[email protected]",
"code": "000000",
"verification_token": TEST_EMAIL_VERIFICATION_TOKEN,
}))
.send()
.await
.expect("exhausted verification request should succeed");
assert_eq!(exhausted.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(exhausted
.headers()
.contains_key(reqwest::header::RETRY_AFTER));
let exhausted_payload: serde_json::Value =
exhausted.json().await.expect("json body should parse");
assert_eq!(
exhausted_payload["detail"],
"验证码尝试次数过多,请重新获取"
);
let correct_after_exhaustion = client
.post(format!("{gateway_url}/api/auth/verify-email"))
.json(&json!({
"email": "[email protected]",
"code": "123456",
"verification_token": TEST_EMAIL_VERIFICATION_TOKEN,
}))
.send()
.await
.expect("verification request after exhaustion should succeed");
assert_eq!(correct_after_exhaustion.status(), StatusCode::BAD_REQUEST);
let payload: serde_json::Value = correct_after_exhaustion
.json()
.await
.expect("json body should parse");
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_handles_auth_me_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-auth-1")),
]),
now + chrono::Duration::hours(1),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-auth-1",
"device-auth-1",
"refresh-token-placeholder",
now,
)],
)
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/auth/me"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-auth-1")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["id"], "user-auth-1");
assert_eq!(payload["email"], "[email protected]");
assert_eq!(payload["username"], "alice");
assert_eq!(payload["billing"]["id"], "wallet-auth-1");
assert_eq!(payload["billing"]["balance"], 15.5);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_access_and_refresh_tokens_from_a_stale_security_version() {
let now = Utc::now();
let user = sample_auth_user(now)
.with_security_version(1)
.expect("auth user security version should update");
let refresh_token = build_test_auth_token(
"refresh",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-auth-stale")),
("jti".to_string(), json!("jti-auth-stale")),
]),
now + chrono::Duration::days(7),
);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-auth-stale")),
]),
now + chrono::Duration::hours(1),
);
let stale_session = sample_auth_session(
"user-auth-1",
"session-auth-stale",
"device-auth-stale",
&refresh_token,
now,
);
assert_eq!(stale_session.security_version, 0);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[stale_session],
)
.await;
let client = reqwest::Client::new();
let me_response = client
.get(format!("{gateway_url}/api/auth/me"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-auth-stale")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("stale access request should complete");
assert_eq!(me_response.status(), StatusCode::UNAUTHORIZED);
let me_payload: serde_json::Value = me_response.json().await.expect("json body should parse");
assert_eq!(me_payload["detail"], "登录会话已失效,请重新登录");
let refresh_response = client
.post(format!("{gateway_url}/api/auth/refresh"))
.header("cookie", format!("aether_refresh_token={refresh_token}"))
.header("x-client-device-id", "device-auth-stale")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("stale refresh request should complete");
assert_eq!(refresh_response.status(), StatusCode::UNAUTHORIZED);
let refresh_payload: serde_json::Value = refresh_response
.json()
.await
.expect("json body should parse");
assert_eq!(refresh_payload["detail"], "登录会话已失效,请重新登录");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_users_me_available_models_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-available-models"),
),
]),
now + chrono::Duration::hours(1),
);
let global_model_repository = Arc::new(
InMemoryGlobalModelReadRepository::seed(vec![
sample_public_global_model("gm-1", "gpt-5", "GPT 5", true),
sample_public_global_model("gm-2", "claude-sonnet-4-5", "Claude Sonnet 4.5", true),
sample_public_global_model("gm-3", "disabled-model", "Disabled Model", false),
])
.with_active_global_model_refs(vec![
StoredProviderActiveGlobalModel::new("provider-openai".to_string(), "gm-1".to_string())
.expect("active global model ref should build"),
StoredProviderActiveGlobalModel::new("provider-claude".to_string(), "gm-2".to_string())
.expect("active global model ref should build"),
]),
);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider("provider-openai", "openai", 10),
sample_provider("provider-claude", "claude", 20),
],
vec![],
vec![],
));
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = crate::data::GatewayDataState::with_global_model_reader_for_tests(
global_model_repository,
)
.with_provider_catalog_reader(provider_catalog_repository)
.with_user_reader(user_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-available-models",
"device-users-me-available-models",
"refresh-token-placeholder",
now,
)])
})
.await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/users/me/available-models?search=gpt&skip=0&limit=10"
))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-available-models")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
let models = payload["models"]
.as_array()
.expect("models should be an array");
assert_eq!(payload["total"], 1);
assert_eq!(models.len(), 1);
assert_eq!(models[0]["id"], "gm-1");
assert_eq!(models[0]["name"], "gpt-5");
assert_eq!(models[0]["display_name"], "GPT 5");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_refreshes_users_me_available_models_after_group_assignment() {
let now = Utc::now();
let mut user = sample_auth_user(now);
user.allowed_providers = None;
user.allowed_providers_mode = "unrestricted".to_string();
// Keep the legacy gpt-5 personal policy from sample_auth_user. Group policies are the
// authority now, so this stale field must not narrow the user catalog.
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-group-models"),
),
]),
now + chrono::Duration::hours(1),
);
let mut allowed_model =
sample_public_global_model("gm-2", "claude-sonnet-4-5", "Claude Sonnet 4.5", true);
allowed_model.config = Some(json!({
"description": "Claude detail",
"model_mappings": ["claude-upstream"]
}));
let mut blocked_model = sample_public_global_model("gm-1", "gpt-5", "GPT 5", true);
blocked_model.config = Some(json!({
"description": "GPT detail",
"model_mappings": ["gpt-upstream"]
}));
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(vec![
blocked_model,
allowed_model,
]));
let user_repository: Arc<dyn UserReadRepository> =
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let group = user_repository
.create_user_group(UpsertUserGroupRecord {
name: "Claude only".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: Some(vec!["claude-sonnet-4-5".to_string()]),
allowed_models_mode: "specific".to_string(),
rate_limit: None,
rate_limit_mode: "system".to_string(),
})
.await
.expect("group should create")
.expect("group should exist");
let data_state =
crate::data::GatewayDataState::with_global_model_reader_for_tests(global_model_repository)
.with_user_reader(Arc::clone(&user_repository));
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-group-models",
"device-users-me-group-models",
"refresh-token-placeholder",
now,
)]);
let mutation_state = state.clone();
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| state).await;
let client = reqwest::Client::new();
let response = client
.get(format!("{gateway_url}/api/users/me/available-models"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-group-models")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["total"], 2);
mutation_state
.replace_user_groups_for_user("user-auth-1", std::slice::from_ref(&group.id))
.await
.expect("group membership should create");
let response = client
.get(format!("{gateway_url}/api/users/me/available-models"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-group-models")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed after group assignment");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
let models = payload["models"]
.as_array()
.expect("models should be an array");
assert_eq!(payload["total"], 1);
assert_eq!(models.len(), 1);
assert_eq!(models[0]["name"], "claude-sonnet-4-5");
assert_eq!(models[0]["config"]["description"], "Claude detail");
assert!(models[0]["config"].get("model_mappings").is_none());
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_returns_no_users_me_available_models_when_group_denies_all_models() {
let now = Utc::now();
let mut user = sample_auth_user(now);
user.allowed_providers = None;
user.allowed_providers_mode = "unrestricted".to_string();
user.allowed_models = None;
user.allowed_models_mode = "unrestricted".to_string();
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-deny-all-models"),
),
]),
now + chrono::Duration::hours(1),
);
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(vec![
sample_public_global_model("gm-1", "gpt-5", "GPT 5", true),
sample_public_global_model("gm-2", "claude-sonnet-4-5", "Claude Sonnet 4.5", true),
]));
let user_repository: Arc<dyn UserReadRepository> =
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let group = user_repository
.create_user_group(UpsertUserGroupRecord {
name: "No models".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: "deny_all".to_string(),
rate_limit: None,
rate_limit_mode: "system".to_string(),
})
.await
.expect("group should create")
.expect("group should exist");
user_repository
.add_user_to_group(&group.id, "user-auth-1")
.await
.expect("group membership should create");
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = crate::data::GatewayDataState::with_global_model_reader_for_tests(
global_model_repository,
)
.with_user_reader(Arc::clone(&user_repository));
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-deny-all-models",
"device-users-me-deny-all-models",
"refresh-token-placeholder",
now,
)])
})
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/users/me/available-models"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-deny-all-models")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["total"], 0);
assert_eq!(payload["models"].as_array().map(Vec::len), Some(0));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_returns_service_unavailable_for_users_me_available_models_without_provider_catalog(
) {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-available-models-unavailable"),
),
]),
now + chrono::Duration::hours(1),
);
let global_model_repository = Arc::new(
InMemoryGlobalModelReadRepository::seed(vec![
sample_public_global_model("gm-1", "gpt-5", "GPT 5", true),
sample_public_global_model("gm-2", "claude-sonnet-4-5", "Claude Sonnet 4.5", true),
])
.with_active_global_model_refs(vec![StoredProviderActiveGlobalModel::new(
"provider-openai".to_string(),
"gm-1".to_string(),
)
.expect("active global model ref should build")]),
);
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let group = user_repository
.create_user_group(UpsertUserGroupRecord {
name: "OpenAI provider only".to_string(),
description: None,
priority: 0,
allowed_providers: Some(vec!["openai".to_string()]),
allowed_providers_mode: "specific".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("group should create")
.expect("group should exist");
user_repository
.add_user_to_group(&group.id, "user-auth-1")
.await
.expect("group membership should create");
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state = crate::data::GatewayDataState::with_global_model_reader_for_tests(
global_model_repository,
)
.with_user_reader(user_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-available-models-unavailable",
"device-users-me-available-models-unavailable",
"refresh-token-placeholder",
now,
)])
})
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/users/me/available-models"))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-available-models-unavailable",
)
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
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_handles_users_me_model_capabilities_get_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-model-cap-get"),
),
]),
now + chrono::Duration::hours(1),
);
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
crate::data::GatewayDataState::with_user_reader_for_tests(user_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-model-cap-get",
"device-users-me-model-cap-get",
"refresh-token-placeholder",
now,
)])
.with_auth_user_model_capability_settings_for_tests(
"user-auth-1",
json!({"gpt-5": {"cache_1h": true}}),
)
})
.await;
let response = reqwest::Client::new()
.get(format!("{gateway_url}/api/users/me/model-capabilities"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-model-cap-get")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(
payload["model_capability_settings"],
json!({"gpt-5": {"cache_1h": true}})
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_updates_users_me_model_capabilities_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-model-cap-put"),
),
]),
now + chrono::Duration::hours(1),
);
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
crate::data::GatewayDataState::with_user_reader_for_tests(user_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-model-cap-put",
"device-users-me-model-cap-put",
"refresh-token-placeholder",
now,
)])
.with_auth_user_model_capability_settings_for_tests(
"user-auth-1",
json!({"disabled-model": {"disabled_flag": true}}),
)
})
.await;
let client = reqwest::Client::new();
let put_response = client
.put(format!("{gateway_url}/api/users/me/model-capabilities"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-model-cap-put")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"model_capability_settings": {}
}))
.send()
.await
.expect("request should succeed");
assert_eq!(put_response.status(), StatusCode::OK);
let put_payload: serde_json::Value = put_response.json().await.expect("json body should parse");
assert_eq!(put_payload["message"], "模型能力配置已更新");
assert_eq!(put_payload["model_capability_settings"], json!({}));
let get_response = client
.get(format!("{gateway_url}/api/users/me/model-capabilities"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-users-me-model-cap-put")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(get_response.status(), StatusCode::OK);
let get_payload: serde_json::Value = get_response.json().await.expect("json body should parse");
assert_eq!(get_payload["model_capability_settings"], json!({}));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_returns_service_unavailable_for_users_me_model_capabilities_update_without_storage(
) {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-model-cap-unavailable"),
),
]),
now + chrono::Duration::hours(1),
);
let user_repository =
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]).read_only());
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
crate::data::GatewayDataState::with_user_reader_for_tests(user_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-model-cap-unavailable",
"device-users-me-model-cap-unavailable",
"refresh-token-placeholder",
now,
)])
.without_auth_user_model_capability_store_for_tests()
})
.await;
let response = reqwest::Client::new()
.put(format!("{gateway_url}/api/users/me/model-capabilities"))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-model-cap-unavailable",
)
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"model_capability_settings": {}
}))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
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_handles_auth_refresh_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let refresh_token = build_test_auth_token(
"refresh",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-auth-2")),
("jti".to_string(), json!("jti-auth-1")),
]),
now + chrono::Duration::days(7),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-auth-2",
"device-auth-2",
&refresh_token,
now,
)],
)
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/refresh"))
.header("cookie", format!("aether_refresh_token={refresh_token}"))
.header("x-client-device-id", "device-auth-2")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let set_cookie = response
.headers()
.get(http::header::SET_COOKIE)
.and_then(|value| value.to_str().ok())
.expect("set-cookie should exist")
.to_string();
let payload: serde_json::Value = response.json().await.expect("json body should parse");
let access_token = payload["access_token"]
.as_str()
.expect("access token should be string");
assert_eq!(payload["token_type"], "bearer");
assert!(payload["expires_in"].as_i64().unwrap_or_default() > 0);
assert_eq!(access_token.split('.').count(), 3);
assert!(set_cookie.contains("aether_refresh_token="));
assert!(set_cookie.contains("HttpOnly"));
assert!(set_cookie.contains("Path=/api/auth"));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_auth_refresh_rejects_query_only_device_binding_without_rotating_session() {
let now = Utc::now();
let user = sample_auth_user(now);
let refresh_token = build_test_auth_token(
"refresh",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-auth-refresh-csrf")),
("jti".to_string(), json!("jti-auth-refresh-csrf")),
]),
now + chrono::Duration::days(7),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-auth-refresh-csrf",
"device-auth-refresh-csrf",
&refresh_token,
now,
)],
)
.await;
let client = reqwest::Client::new();
let rejected = client
.post(format!(
"{gateway_url}/api/auth/refresh?client_device_id=device-auth-refresh-csrf"
))
.header("cookie", format!("aether_refresh_token={refresh_token}"))
.send()
.await
.expect("query-only refresh request should complete");
assert_eq!(rejected.status(), StatusCode::BAD_REQUEST);
assert!(!rejected.headers().contains_key(http::header::SET_COOKIE));
let legitimate = client
.post(format!("{gateway_url}/api/auth/refresh"))
.header("cookie", format!("aether_refresh_token={refresh_token}"))
.header("x-client-device-id", "device-auth-refresh-csrf")
.send()
.await
.expect("legitimate refresh should complete");
assert_eq!(legitimate.status(), StatusCode::OK);
assert!(legitimate.headers().contains_key(http::header::SET_COOKIE));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_allows_only_one_concurrent_refresh_rotation() {
let now = Utc::now();
let user = sample_auth_user(now);
let refresh_token = build_test_auth_token(
"refresh",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-auth-refresh-race")),
("jti".to_string(), json!("jti-auth-refresh-race")),
]),
now + chrono::Duration::days(7),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-auth-refresh-race",
"device-auth-refresh-race",
&refresh_token,
now,
)],
)
.await;
let client = reqwest::Client::new();
let build_request = || {
client
.post(format!("{gateway_url}/api/auth/refresh"))
.header("cookie", format!("aether_refresh_token={refresh_token}"))
.header("x-client-device-id", "device-auth-refresh-race")
.header("user-agent", "AetherTest/1.0")
.send()
};
let (first, second) = tokio::join!(build_request(), build_request());
let responses = [
first.expect("first refresh request should complete"),
second.expect("second refresh request should complete"),
];
let mut success_count = 0;
let mut conflict_count = 0;
for response in responses {
let status = response.status();
let has_set_cookie = response.headers().contains_key(http::header::SET_COOKIE);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
match status {
StatusCode::OK => {
success_count += 1;
assert!(has_set_cookie);
assert!(payload["access_token"].as_str().is_some());
}
StatusCode::CONFLICT => {
conflict_count += 1;
assert!(!has_set_cookie);
assert!(payload.get("access_token").is_none());
}
status => panic!("unexpected concurrent refresh status: {status}"),
}
}
assert_eq!(success_count, 1);
assert_eq!(conflict_count, 1);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_auth_logout_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-auth-3")),
]),
now + chrono::Duration::hours(1),
);
let refresh_token = build_test_auth_token(
"refresh",
serde_json::Map::from_iter([
("user_id".to_string(), json!("user-auth-1")),
("created_at".to_string(), json!(Some(now.to_rfc3339()))),
("session_id".to_string(), json!("session-auth-3")),
("jti".to_string(), json!("jti-auth-3")),
]),
now + chrono::Duration::days(7),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-auth-3",
"device-auth-3",
&refresh_token,
now,
)],
)
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/logout"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-auth-3")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let set_cookie = response
.headers()
.get(http::header::SET_COOKIE)
.and_then(|value| value.to_str().ok())
.expect("set-cookie should exist")
.to_string();
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["success"], true);
assert_eq!(payload["message"], "登出成功");
assert!(set_cookie.contains("aether_refresh_token="));
assert!(set_cookie.contains("Max-Age=0"));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_logout_rejects_tokens_bound_to_a_replaced_user_identity() {
let now = Utc::now();
let mut user = sample_auth_user(now);
let original_created_at = user
.created_at
.expect("test user should have creation time");
let replacement_created_at = original_created_at + chrono::Duration::milliseconds(1);
user.created_at = Some(replacement_created_at);
let build_access_token = |created_at: chrono::DateTime<Utc>| {
build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
("created_at".to_string(), json!(created_at.to_rfc3339())),
(
"session_id".to_string(),
json!("session-auth-replaced-user"),
),
]),
now + chrono::Duration::hours(1),
)
};
let stale_access_token = build_access_token(original_created_at);
let current_access_token = build_access_token(replacement_created_at);
let stale_refresh_token = build_test_auth_token(
"refresh",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
(
"created_at".to_string(),
json!(original_created_at.to_rfc3339()),
),
(
"session_id".to_string(),
json!("session-auth-replaced-user"),
),
("jti".to_string(), json!("jti-auth-replaced-user")),
]),
now + chrono::Duration::days(7),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-auth-replaced-user",
"device-auth-replaced-user",
&stale_refresh_token,
now,
)],
)
.await;
let client = reqwest::Client::new();
let stale_response = client
.post(format!("{gateway_url}/api/auth/logout"))
.header("authorization", format!("Bearer {stale_access_token}"))
.header(
"cookie",
format!("aether_refresh_token={stale_refresh_token}"),
)
.header("x-client-device-id", "device-auth-replaced-user")
.send()
.await
.expect("stale logout request should complete");
assert_eq!(stale_response.status(), StatusCode::UNAUTHORIZED);
let current_response = client
.post(format!("{gateway_url}/api/auth/logout"))
.header("authorization", format!("Bearer {current_access_token}"))
.header("x-client-device-id", "device-auth-replaced-user")
.send()
.await
.expect("current logout request should complete");
assert_eq!(current_response.status(), StatusCode::OK);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_cookie_logout_rejects_query_only_device_binding_without_revoking_session() {
let now = Utc::now();
let user = sample_auth_user(now);
let refresh_token = build_test_auth_token(
"refresh",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-auth-logout-csrf")),
("jti".to_string(), json!("jti-auth-logout-csrf")),
]),
now + chrono::Duration::days(7),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
sample_auth_wallet("user-auth-1", now),
[sample_auth_session(
"user-auth-1",
"session-auth-logout-csrf",
"device-auth-logout-csrf",
&refresh_token,
now,
)],
)
.await;
let client = reqwest::Client::new();
let rejected = client
.post(format!(
"{gateway_url}/api/auth/logout?client_device_id=device-auth-logout-csrf"
))
.header("cookie", format!("aether_refresh_token={refresh_token}"))
.send()
.await
.expect("query-only logout request should complete");
assert_eq!(rejected.status(), StatusCode::BAD_REQUEST);
assert!(!rejected.headers().contains_key(http::header::SET_COOKIE));
let legitimate = client
.post(format!("{gateway_url}/api/auth/logout"))
.header("cookie", format!("aether_refresh_token={refresh_token}"))
.header("x-client-device-id", "device-auth-logout-csrf")
.send()
.await
.expect("legitimate logout should complete");
assert_eq!(legitimate.status(), StatusCode::OK);
let clear_cookie = legitimate
.headers()
.get(http::header::SET_COOKIE)
.and_then(|value| value.to_str().ok())
.expect("legitimate logout should clear refresh cookie");
assert!(clear_cookie.contains("Max-Age=0"));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_does_not_report_logout_success_when_session_revoke_is_rejected() {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-auth-read-only")),
]),
now + chrono::Duration::hours(1),
);
let session = sample_auth_session(
"user-auth-1",
"session-auth-read-only",
"device-auth-read-only",
"refresh-token-read-only",
now,
);
let repository = Arc::new(
InMemoryUserReadRepository::seed_auth_users(vec![user])
.with_user_sessions([session])
.read_only(),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_user_reader_for_tests(repository))
.without_auth_session_store_for_tests()
})
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/logout"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-auth-read-only")
.header("user-agent", "AetherTest/1.0")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
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_handles_payment_callback_route_locally_without_proxying_upstream() {
let _env_lock = payment_callback_env_lock().lock().await;
let _secret_guard = set_test_env_var("PAYMENT_CALLBACK_SECRET", TEST_PAYMENT_CALLBACK_SECRET);
let now = Utc::now();
let user = StoredUserAuthRecord::new(
"user-wallet-callback".to_string(),
Some("[email protected]".to_string()),
true,
"wallet_callback_user".to_string(),
Some("$2y$10$.OBQfixAECpsb8V/VS3csOMf00x2E/jD/gnud20t6RG0yiQosyOZ2".to_string()),
"user".to_string(),
"local".to_string(),
Some(json!(["openai"])),
Some(json!(["openai:chat"])),
Some(json!(["gpt-5"])),
true,
false,
Some(now),
Some(now),
)
.expect("auth user should build");
let wallet = StoredWalletSnapshot::new(
"wallet-callback".to_string(),
Some(user.id.clone()),
None,
0.0,
0.0,
"finite".to_string(),
"USD".to_string(),
"active".to_string(),
0.0,
0.0,
0.0,
0.0,
now.timestamp(),
)
.expect("wallet should build");
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id.clone())),
("role".to_string(), json!(user.role.clone())),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
("session_id".to_string(), json!("session-wallet-callback-1")),
]),
now + chrono::Duration::hours(1),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
wallet,
[sample_auth_session(
"user-wallet-callback",
"session-wallet-callback-1",
"device-wallet-callback-1",
"refresh-token-placeholder",
now,
)],
)
.await;
let client = reqwest::Client::new();
let create_response = client
.post(format!("{gateway_url}/api/wallet/recharge"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-callback-1")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"amount_usd": 10.0,
"payment_method": "manual",
"pay_amount": 72.5,
"pay_currency": "CNY",
"exchange_rate": 7.25,
}))
.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");
let order_id = create_payload["order"]["id"]
.as_str()
.expect("order id should exist")
.to_string();
let order_no = create_payload["order"]["order_no"]
.as_str()
.expect("order no should exist")
.to_string();
let callback_body = json!({
"callback_key": "callback-key-1",
"order_no": order_no,
"gateway_order_id": create_payload["order"]["gateway_order_id"],
"amount_usd": 10.0,
"pay_amount": 72.5,
"pay_currency": "CNY",
"exchange_rate": 7.25,
"payload": serde_json::Value::Null,
});
let callback_signature =
build_test_payment_callback_signature(&callback_body, TEST_PAYMENT_CALLBACK_SECRET);
let callback_response = client
.post(format!("{gateway_url}/api/payment/callback/manual"))
.header("x-payment-callback-token", TEST_PAYMENT_CALLBACK_SECRET)
.header("x-payment-callback-signature", callback_signature)
.json(&callback_body)
.send()
.await
.expect("request should succeed");
assert_eq!(callback_response.status(), StatusCode::OK);
let callback_payload: serde_json::Value = callback_response
.json()
.await
.expect("json body should parse");
assert_eq!(callback_payload, json!({ "ok": true }));
let detail_response = client
.get(format!("{gateway_url}/api/wallet/recharge/{order_id}"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-callback-1")
.header("user-agent", "AetherTest/1.0")
.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["order"]["status"], "credited");
assert_eq!(detail_payload["order"]["refundable_amount_usd"], 10.0);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_payment_callback_with_mismatched_payment_method_locally() {
let _env_lock = payment_callback_env_lock().lock().await;
let _secret_guard = set_test_env_var("PAYMENT_CALLBACK_SECRET", TEST_PAYMENT_CALLBACK_SECRET);
let now = Utc::now();
let user = StoredUserAuthRecord::new(
"user-wallet-callback-mismatch".to_string(),
Some("[email protected]".to_string()),
true,
"wallet_callback_mismatch_user".to_string(),
Some("$2y$10$.OBQfixAECpsb8V/VS3csOMf00x2E/jD/gnud20t6RG0yiQosyOZ2".to_string()),
"user".to_string(),
"local".to_string(),
Some(json!(["openai"])),
Some(json!(["openai:chat"])),
Some(json!(["gpt-5"])),
true,
false,
Some(now),
Some(now),
)
.expect("auth user should build");
let wallet = StoredWalletSnapshot::new(
"wallet-callback-mismatch".to_string(),
Some(user.id.clone()),
None,
0.0,
0.0,
"finite".to_string(),
"USD".to_string(),
"active".to_string(),
0.0,
0.0,
0.0,
0.0,
now.timestamp(),
)
.expect("wallet should build");
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id.clone())),
("role".to_string(), json!(user.role.clone())),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-wallet-callback-mismatch-1"),
),
]),
now + chrono::Duration::hours(1),
);
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_state(
user,
wallet,
[sample_auth_session(
"user-wallet-callback-mismatch",
"session-wallet-callback-mismatch-1",
"device-wallet-callback-mismatch-1",
"refresh-token-placeholder",
now,
)],
)
.await;
let client = reqwest::Client::new();
let create_response = client
.post(format!("{gateway_url}/api/wallet/recharge"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-callback-mismatch-1")
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"amount_usd": 10.0,
"payment_method": "alipay",
"pay_amount": 72.5,
"pay_currency": "CNY",
"exchange_rate": 7.25,
}))
.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");
let order_id = create_payload["order"]["id"]
.as_str()
.expect("order id should exist")
.to_string();
let order_no = create_payload["order"]["order_no"]
.as_str()
.expect("order no should exist")
.to_string();
let callback_body = json!({
"callback_key": "callback-key-mismatch-1",
"order_no": order_no,
"gateway_order_id": create_payload["order"]["gateway_order_id"],
"amount_usd": 10.0,
"pay_amount": 72.5,
"pay_currency": "CNY",
"exchange_rate": 7.25,
"payload": serde_json::Value::Null,
});
let callback_signature =
build_test_payment_callback_signature(&callback_body, TEST_PAYMENT_CALLBACK_SECRET);
let callback_response = client
.post(format!("{gateway_url}/api/payment/callback/wechat"))
.header("x-payment-callback-token", TEST_PAYMENT_CALLBACK_SECRET)
.header("x-payment-callback-signature", callback_signature)
.json(&callback_body)
.send()
.await
.expect("request should succeed");
assert_eq!(callback_response.status(), StatusCode::OK);
let callback_payload: serde_json::Value = callback_response
.json()
.await
.expect("json body should parse");
assert_eq!(callback_payload["ok"], false);
assert_eq!(callback_payload["error"], "payment callback rejected");
assert_eq!(
callback_payload.as_object().map(|value| value.len()),
Some(2)
);
let detail_response = client
.get(format!("{gateway_url}/api/wallet/recharge/{order_id}"))
.header("authorization", format!("Bearer {access_token}"))
.header("x-client-device-id", "device-wallet-callback-mismatch-1")
.header("user-agent", "AetherTest/1.0")
.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["order"]["status"], "pending");
assert_eq!(detail_payload["order"]["refundable_amount_usd"], 0.0);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}