fix(public): 修复用户可见性、额度、验证与 Codex 探测

This commit is contained in:
Entropy.Xu
2026-05-16 00:51:44 +08:00
parent 8eb4c029b2
commit bbd3c30b0e
50 changed files with 2321 additions and 347 deletions

View File

@@ -669,6 +669,7 @@ async fn gateway_handles_public_catalog_site_info_without_proxying_upstream() {
vec![
("site_name".to_string(), json!("Aether Local")),
("site_subtitle".to_string(), json!("Rust Only")),
("show_github_link".to_string(), json!(false)),
],
),
),
@@ -685,6 +686,7 @@ async fn gateway_handles_public_catalog_site_info_without_proxying_upstream() {
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["show_github_link"], false);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
@@ -757,7 +759,9 @@ async fn gateway_handles_public_catalog_providers_without_proxying_upstream() {
let providers = payload.as_array().expect("providers should be an array");
assert_eq!(providers.len(), 2);
assert_eq!(providers[0]["id"], "provider-openai");
assert_eq!(providers[0]["name"], "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);
@@ -834,7 +838,8 @@ async fn gateway_handles_public_catalog_models_without_proxying_upstream() {
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_eq!(models[0]["provider_name"], "openai");
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);
@@ -993,7 +998,8 @@ async fn gateway_handles_public_catalog_search_models_without_proxying_upstream(
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]["provider_name"], "claude");
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);
@@ -1101,10 +1107,16 @@ async fn gateway_handles_public_global_models_without_proxying_upstream() {
}),
);
let mut gpt_model = sample_public_global_model("gm-3", "gpt-5", "GPT 5", true);
gpt_model.config = Some(json!({
"description": "Public description",
"model_mappings": ["gpt-5-upstream"],
"provider_model_mappings": [{"name": "provider-gpt-5"}],
}));
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),
sample_public_global_model("gm-3", "gpt-5", "GPT 5", true),
gpt_model,
]));
let (upstream_url, upstream_handle) = start_server(upstream).await;
@@ -1131,6 +1143,16 @@ async fn gateway_handles_public_global_models_without_proxying_upstream() {
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_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
@@ -1409,6 +1431,8 @@ async fn gateway_handles_auth_registration_settings_without_proxying_upstream()
("smtp_host".to_string(), json!("smtp.example.com")),
("smtp_from_email".to_string(), json!("noreply@example.com")),
("password_policy_level".to_string(), json!("strong")),
("turnstile_enabled".to_string(), json!(true)),
("turnstile_site_key".to_string(), json!("site-key-123")),
]);
let (upstream_url, upstream_handle) = start_server(upstream).await;
@@ -1434,6 +1458,8 @@ async fn gateway_handles_auth_registration_settings_without_proxying_upstream()
"require_email_verification": true,
"email_configured": true,
"password_policy_level": "strong",
"turnstile_enabled": true,
"turnstile_site_key": "site-key-123",
})
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
@@ -1689,9 +1715,9 @@ async fn gateway_handles_public_providers_without_proxying_upstream() {
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["providers"][0]["name"], "openai");
assert!(payload["providers"][0].get("name").is_none());
assert_eq!(payload["providers"][0]["provider_priority"], 10);
assert_eq!(payload["providers"][1]["name"], "anthropic");
assert!(payload["providers"][1].get("name").is_none());
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
@@ -1739,7 +1765,7 @@ async fn gateway_handles_public_provider_detail_without_proxying_upstream() {
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_eq!(payload["name"], "openai");
assert!(payload.get("name").is_none());
assert_eq!(payload["provider_priority"], 10);
let response = reqwest::Client::new()
@@ -1748,11 +1774,9 @@ async fn gateway_handles_public_provider_detail_without_proxying_upstream() {
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["id"], "provider-1");
assert_eq!(payload["name"], "openai");
assert_eq!(payload["provider_priority"], 10);
assert_eq!(payload["detail"], "Provider not found");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
@@ -1805,6 +1829,9 @@ async fn gateway_handles_public_providers_with_endpoints_without_proxying_upstre
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"
@@ -1926,7 +1953,7 @@ async fn gateway_handles_public_test_connection_without_hitting_fallback_probe()
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/v1/test-connection?provider=openai&model=gpt-5&api_format=openai:chat"
"{gateway_url}/v1/test-connection?provider=provider-1&model=gpt-5&api_format=openai:chat"
))
.send()
.await
@@ -1935,7 +1962,8 @@ async fn gateway_handles_public_test_connection_without_hitting_fallback_probe()
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"], "openai");
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);
@@ -4771,7 +4799,13 @@ async fn gateway_handles_users_me_preferences_locally_without_proxying_upstream(
"refresh-token-placeholder",
now,
)],
std::iter::empty::<crate::data::state::StoredUserPreferenceRecord>(),
{
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;
@@ -4791,6 +4825,8 @@ async fn gateway_handles_users_me_preferences_locally_without_proxying_upstream(
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"))
@@ -5041,6 +5077,7 @@ async fn gateway_handles_users_me_usage_locally_without_proxying_upstream() {
105
);
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);
@@ -7552,8 +7589,10 @@ async fn gateway_handles_users_me_providers_locally_without_proxying_upstream()
let providers = payload.as_array().expect("providers should be array");
assert_eq!(providers.len(), 1);
assert_eq!(providers[0]["id"], "provider-openai");
assert_eq!(providers[0]["name"], "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);
@@ -7990,6 +8029,127 @@ async fn gateway_handles_auth_send_verification_code_locally_without_proxying_up
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!("noreply@example.com")),
("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": "alice@example.com" }))
.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 seen_siteverify_body = Arc::new(Mutex::new(None::<String>));
let seen_siteverify_body_clone = Arc::clone(&seen_siteverify_body);
let siteverify = Router::new().route(
"/turnstile/siteverify",
any(move |request: Request| {
let seen_siteverify_body_inner = Arc::clone(&seen_siteverify_body_clone);
async move {
let body = String::from_utf8(
to_bytes(request.into_body(), usize::MAX)
.await
.expect("body should read")
.to_vec(),
)
.expect("siteverify body should be utf8");
*seen_siteverify_body_inner
.lock()
.expect("mutex should lock") = Some(body);
(
StatusCode::OK,
Json(json!({
"success": true,
"hostname": "localhost"
})),
)
}
}),
);
let (siteverify_url, siteverify_handle) = start_server(siteverify).await;
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!("noreply@example.com")),
("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_siteverify_url".to_string(),
json!(format!("{siteverify_url}/turnstile/siteverify")),
),
(
"turnstile_allowed_hostnames".to_string(),
json!(["localhost"]),
),
],
),
)
})
.await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/auth/send-verification-code"))
.json(&json!({
"email": "alice@example.com",
"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 siteverify_body = seen_siteverify_body
.lock()
.expect("mutex should lock")
.clone()
.expect("siteverify should be called");
assert!(siteverify_body.contains("secret=secret-key-123"));
assert!(siteverify_body.contains("response=turnstile-token-123"));
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);
@@ -8209,6 +8369,203 @@ async fn gateway_handles_users_me_available_models_locally_without_proxying_upst
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_filters_users_me_available_models_by_group_policy_and_hides_model_mappings() {
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-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");
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-group-models",
"device-users-me-group-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-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");
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(
) {