diff --git a/.env.example b/.env.example index 171e8aa32..0c77476c2 100644 --- a/.env.example +++ b/.env.example @@ -31,7 +31,11 @@ RUST_LOG=aether_gateway=info # 示例: http://localhost:5173,https://app.example.com # CORS_ORIGINS=http://localhost:5173 # CORS_ALLOW_CREDENTIALS=true -# 如果前后端跨站并依赖登录刷新 Cookie,还要配合: +# 登录刷新 Cookie 对同源浏览器请求和可信反代自动适配 HTTP/HTTPS。 +# HTTP 自动使用兼容的 SameSite=Lax(显式 Strict 保留);HTTPS 保留原有 SameSite 配置。 +# 无法确认访问协议时保留安全默认值;HTTPS 反代请正确传递 X-Forwarded-Proto。 +# AUTH_REFRESH_COOKIE_SECURE 可显式覆盖自动判断,公网部署仍建议使用 HTTPS。 +# 如果前后端跨站并依赖登录刷新 Cookie,必须使用 HTTPS,并配合: # AUTH_REFRESH_COOKIE_SAMESITE=None # AUTH_REFRESH_COOKIE_SECURE=true diff --git a/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/parse.rs b/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/parse.rs index 7d05e18f0..60cff9220 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/parse.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/parse.rs @@ -1463,7 +1463,7 @@ mod tests { &auth_config, Some(0), ), - "antigravity_anti@example.com" + "anti@example.com" ); } diff --git a/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/device/poll.rs b/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/device/poll.rs index d30f52598..8074a0024 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/device/poll.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/device/poll.rs @@ -1,3 +1,4 @@ +use super::super::helpers::admin_provider_oauth_key_name_from_auth_config; use super::super::kiro::{ admin_provider_oauth_kiro_refresh_base_url_override, fetch_admin_provider_oauth_kiro_email, refresh_admin_provider_oauth_kiro_auth_config, @@ -79,7 +80,7 @@ fn kiro_social_key_name( .collect::() }) .unwrap_or_else(|| "unknown".to_string()); - format!("kiro_{fallback} ({provider})") + format!("账号_{fallback} ({provider})") } fn kiro_social_poll_error_response(error: impl Into) -> Response { @@ -1004,10 +1005,11 @@ async fn handle_admin_provider_oauth_windsurf_browser_device_poll( } } } else { - let key_name = email - .as_deref() - .map(|email| format!("windsurf_{email}")) - .unwrap_or_else(|| format!("windsurf_{}", current_unix_secs())); + let key_name = admin_provider_oauth_key_name_from_auth_config( + &provider.provider_type, + &auth_config, + None, + ); match state .create_provider_oauth_catalog_key( &provider.id, @@ -1356,6 +1358,32 @@ mod tests { use crate::control::GatewayAdminPrincipalContext; use aether_data::repository::provider_oauth::StoredAdminProviderOAuthDeviceSession; + #[test] + fn kiro_social_key_name_preserves_email_and_auth_method() { + assert_eq!( + super::kiro_social_key_name( + Some(" kiro_user@example.com "), + Some("Github"), + Some("refresh-token-1"), + ), + "kiro_user@example.com (Github)" + ); + } + + #[test] + fn kiro_social_key_name_without_email_uses_generic_account_prefix() { + for email in [None, Some(""), Some(" ")] { + assert_eq!( + super::kiro_social_key_name(email, Some("Google"), Some("refresh-token-1")), + "账号_154f43 (Google)" + ); + assert_eq!( + super::kiro_social_key_name(email, None, None), + "账号_unknown (social)" + ); + } + } + fn device_session() -> StoredAdminProviderOAuthDeviceSession { StoredAdminProviderOAuthDeviceSession { session_id: "device-session-1".to_string(), diff --git a/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/helpers.rs b/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/helpers.rs index 91b2f68f8..76f896c6b 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/helpers.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/helpers.rs @@ -52,13 +52,12 @@ pub(super) fn admin_provider_oauth_key_name_from_auth_config( auth_config: &Map, batch_index: Option, ) -> String { - let provider_type = provider_type.trim(); if let Some(email) = trimmed_auth_config_string(auth_config, "email") { - return format!("{provider_type}_{email}"); + return email; } - if provider_type.eq_ignore_ascii_case("grok") { + if provider_type.trim().eq_ignore_ascii_case("grok") { if let Some(user_id) = trimmed_auth_config_string(auth_config, "user_id") { - return format!("grok_{user_id}"); + return user_id; } } @@ -68,7 +67,7 @@ pub(super) fn admin_provider_oauth_key_name_from_auth_config( .map(|duration| duration.as_secs()) .unwrap_or(0); match batch_index { - Some(index) => format!("{provider_type}_{timestamp}_{index}"), + Some(index) => format!("账号_{timestamp}_{index}"), None => format!("账号_{timestamp}"), } } @@ -87,6 +86,106 @@ mod tests { use super::*; use serde_json::{json, Map}; + const PROVIDER_TYPES: &[&str] = &[ + "codex", + " Codex ", + "claude_code", + "chatgpt_web", + "gemini_cli", + "antigravity", + "grok", + " Grok ", + "kiro", + "windsurf", + ]; + + #[test] + fn default_key_name_uses_email_without_provider_prefix() { + let mut auth_config = Map::new(); + auth_config.insert("email".to_string(), json!(" user@example.com ")); + + for provider_type in PROVIDER_TYPES { + for batch_index in [None, Some(3)] { + assert_eq!( + admin_provider_oauth_key_name_from_auth_config( + provider_type, + &auth_config, + batch_index, + ), + "user@example.com" + ); + } + } + } + + #[test] + fn antigravity_default_key_name_uses_email_without_provider_prefix() { + for email in [" user@example.com ", "antigravity_user@example.com"] { + let mut auth_config = Map::new(); + auth_config.insert("email".to_string(), json!(email)); + + for provider_type in ["antigravity", " Antigravity "] { + for batch_index in [None, Some(3)] { + assert_eq!( + admin_provider_oauth_key_name_from_auth_config( + provider_type, + &auth_config, + batch_index, + ), + email.trim() + ); + } + } + } + } + + #[test] + fn default_key_name_preserves_email_with_provider_prefix() { + for provider_type in PROVIDER_TYPES { + let email = format!("{}_user@example.com", provider_type.trim()); + let mut auth_config = Map::new(); + auth_config.insert("email".to_string(), json!(email)); + + for batch_index in [None, Some(3)] { + assert_eq!( + admin_provider_oauth_key_name_from_auth_config( + provider_type, + &auth_config, + batch_index, + ), + email + ); + } + } + } + + #[test] + fn default_key_name_without_email_uses_generic_account_name() { + for email in [None, Some(""), Some(" ")] { + let mut auth_config = Map::new(); + if let Some(email) = email { + auth_config.insert("email".to_string(), json!(email)); + } + + for provider_type in PROVIDER_TYPES { + for batch_index in [None, Some(3)] { + let name = admin_provider_oauth_key_name_from_auth_config( + provider_type, + &auth_config, + batch_index, + ); + let suffix = name.strip_prefix("账号_").expect("generic account prefix"); + let timestamp = if batch_index.is_some() { + suffix.strip_suffix("_3").expect("batch index suffix") + } else { + suffix + }; + assert!(timestamp.parse::().is_ok()); + } + } + } + } + #[test] fn grok_default_key_name_uses_full_user_id() { let mut auth_config = Map::new(); @@ -95,10 +194,18 @@ mod tests { json!("1619039a-0191-4e0a-a490-8f4ad21262c9"), ); - assert_eq!( - admin_provider_oauth_key_name_from_auth_config("grok", &auth_config, None), - "grok_1619039a-0191-4e0a-a490-8f4ad21262c9" - ); + for provider_type in ["grok", " Grok "] { + for batch_index in [None, Some(3)] { + assert_eq!( + admin_provider_oauth_key_name_from_auth_config( + provider_type, + &auth_config, + batch_index, + ), + "1619039a-0191-4e0a-a490-8f4ad21262c9" + ); + } + } } #[test] @@ -109,17 +216,22 @@ mod tests { assert_eq!( admin_provider_oauth_key_name_from_auth_config("grok", &auth_config, None), - "grok_grok@example.com" + "grok@example.com" ); } #[test] - fn batch_default_key_name_keeps_existing_timestamp_shape() { + fn batch_default_key_name_keeps_distinct_indexes_without_provider_prefix() { let auth_config = Map::new(); - let name = admin_provider_oauth_key_name_from_auth_config("codex", &auth_config, Some(3)); + let name = admin_provider_oauth_key_name_from_auth_config("grok", &auth_config, Some(3)); + let other_name = + admin_provider_oauth_key_name_from_auth_config("grok", &auth_config, Some(4)); - assert!(name.starts_with("codex_")); + assert!(name.starts_with("账号_")); assert!(name.ends_with("_3")); + assert!(other_name.starts_with("账号_")); + assert!(other_name.ends_with("_4")); + assert_ne!(name, other_name); } #[test] diff --git a/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/antigravity.rs b/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/antigravity.rs index ee24a7161..0f4bb2e7c 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/antigravity.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/antigravity.rs @@ -5,7 +5,6 @@ use super::shared::{ quota_key_auto_removed, quota_refresh_success_invalid_state, resolve_provider_quota_execution_timeouts, ProviderQuotaExecutionOutcome, }; -use crate::handlers::admin::provider::shared::payloads::AdminImportProviderModelsRequest; use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot}; use crate::GatewayError; use aether_admin::provider::quota::{ @@ -24,63 +23,6 @@ use std::collections::BTreeMap; use std::time::{SystemTime, UNIX_EPOCH}; use tracing::warn; -fn antigravity_discovered_model_ids(metadata_update: Option<&serde_json::Value>) -> Vec { - metadata_update - .and_then(|value| value.pointer("/antigravity/quota_by_model")) - .and_then(serde_json::Value::as_object) - .into_iter() - .flat_map(|models| models.keys()) - .map(String::as_str) - .filter(|model_id| aether_model_fetch::antigravity_model_id_is_routable(model_id)) - .map(ToOwned::to_owned) - .collect() -} - -async fn sync_antigravity_discovered_models( - state: &AdminAppState<'_>, - provider_id: &str, - metadata_update: Option<&serde_json::Value>, -) { - if !state.has_global_model_data_reader() || !state.has_global_model_data_writer() { - return; - } - let model_ids = antigravity_discovered_model_ids(metadata_update); - if model_ids.is_empty() { - return; - } - - let result = state - .build_admin_import_provider_models_payload( - provider_id, - AdminImportProviderModelsRequest { - model_ids, - tiered_pricing: None, - price_per_request: None, - }, - ) - .await; - match result { - Ok(payload) => { - let errors = payload - .get("errors") - .and_then(serde_json::Value::as_array) - .map(Vec::len) - .unwrap_or(0); - if errors > 0 { - warn!( - provider_id, - errors, "Antigravity discovered-model catalog sync completed with item errors" - ); - } - } - Err(error) => warn!( - provider_id, - error = %error, - "Antigravity discovered-model catalog sync failed" - ), - } -} - async fn execute_antigravity_quota_plan( state: &AdminAppState<'_>, transport: &AdminGatewayProviderTransportSnapshot, @@ -380,10 +322,6 @@ pub(crate) async fn refresh_antigravity_provider_quota_locally( continue; } - if status == "success" { - sync_antigravity_discovered_models(state, &provider.id, metadata_update.as_ref()).await; - } - if status == "success" { success_count += 1; } else { diff --git a/apps/aether-gateway/src/handlers/public/support.rs b/apps/aether-gateway/src/handlers/public/support.rs index ac38a5040..19eef8c76 100644 --- a/apps/aether-gateway/src/handlers/public/support.rs +++ b/apps/aether-gateway/src/handlers/public/support.rs @@ -30,6 +30,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; mod support_announcements; #[path = "support/auth.rs"] mod support_auth; +#[path = "support/auth_cookie_policy.rs"] +mod support_auth_cookie_policy; #[path = "support/billing.rs"] mod support_billing; #[path = "support/ccswitch.rs"] @@ -133,6 +135,31 @@ pub(crate) async fn maybe_build_local_public_support_response( remote_addr: &std::net::SocketAddr, client_ip: std::net::IpAddr, request_body: Option<&Bytes>, +) -> Option> { + let response = build_local_public_support_response( + state, + request_context, + headers, + remote_addr, + client_ip, + request_body, + ) + .await?; + Some(support_auth_cookie_policy::finalize_refresh_cookie( + response, + headers, + request_context.host_header.as_deref(), + remote_addr, + )) +} + +async fn build_local_public_support_response( + state: &AppState, + request_context: &GatewayPublicRequestContext, + headers: &http::HeaderMap, + remote_addr: &std::net::SocketAddr, + client_ip: std::net::IpAddr, + request_body: Option<&Bytes>, ) -> Option> { let decision = request_context.control_decision.as_ref()?; if decision.route_class.as_deref() != Some("public_support") { diff --git a/apps/aether-gateway/src/handlers/public/support/auth_cookie_policy.rs b/apps/aether-gateway/src/handlers/public/support/auth_cookie_policy.rs new file mode 100644 index 000000000..a46a55b17 --- /dev/null +++ b/apps/aether-gateway/src/handlers/public/support/auth_cookie_policy.rs @@ -0,0 +1,424 @@ +use super::support_auth::{auth_refresh_cookie_name, auth_refresh_cookie_secure}; +use axum::body::Body; +use axum::http::{header, HeaderMap, HeaderValue, Response}; +use std::net::SocketAddr; +use url::Url; + +pub(super) fn finalize_refresh_cookie( + mut response: Response, + headers: &HeaderMap, + host_header: Option<&str>, + remote_addr: &SocketAddr, +) -> Response { + if !response.headers().contains_key(header::SET_COOKIE) { + return response; + } + let cookie_name = auth_refresh_cookie_name(); + let explicit_secure = std::env::var("AUTH_REFRESH_COOKIE_SECURE").ok(); + let public_base_url = std::env::var("AETHER_PUBLIC_BASE_URL") + .ok() + .or_else(|| std::env::var("PUBLIC_BASE_URL").ok()); + let secure = refresh_cookie_secure_for_request( + headers, + host_header, + crate::headers::trusted_proxy_ip(remote_addr.ip()), + explicit_secure.as_deref(), + public_base_url.as_deref(), + auth_refresh_cookie_secure(), + ); + let cookies = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|cookie| rewrite_refresh_cookie(cookie, &cookie_name, secure)) + .collect::>(); + response.headers_mut().remove(header::SET_COOKIE); + for cookie in cookies { + response.headers_mut().append(header::SET_COOKIE, cookie); + } + response +} + +fn refresh_cookie_secure_for_request( + headers: &HeaderMap, + host_header: Option<&str>, + trusted_proxy: bool, + explicit_secure: Option<&str>, + public_base_url: Option<&str>, + fallback_secure: bool, +) -> bool { + if let Some(value) = explicit_secure { + return !value.trim().eq_ignore_ascii_case("false"); + } + + let origin = single_header(headers, header::ORIGIN.as_str()).and_then(parse_origin); + let public_url = public_base_url.and_then(parse_http_url); + let forwarded_proto = trusted_proxy.then(|| forwarded_proto(headers)).flatten(); + if origin.as_ref().is_some_and(|url| url.scheme() == "https") + || public_url + .as_ref() + .is_some_and(|url| url.scheme() == "https") + || forwarded_proto == Some("https") + { + return true; + } + if trusted_proxy && headers.contains_key("x-forwarded-proto") { + return forwarded_proto != Some("http"); + } + if public_url + .as_ref() + .is_some_and(|url| url.scheme() == "http") + { + return false; + } + if let (Some(origin), Some(host)) = (origin, host_header) { + let request_origin = parse_origin(&format!("{}://{host}", origin.scheme())); + if request_origin.is_some_and(|url| url.origin() == origin.origin()) { + return false; + } + } + fallback_secure +} + +fn single_header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + let mut values = headers.get_all(name).iter(); + let value = values.next()?.to_str().ok()?.trim(); + (values.next().is_none() && !value.is_empty()).then_some(value) +} + +fn parse_origin(value: &str) -> Option { + let url = parse_http_url(value)?; + (url.path() == "/").then_some(url) +} + +fn parse_http_url(value: &str) -> Option { + let url = Url::parse(value.trim()).ok()?; + (matches!(url.scheme(), "http" | "https") + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none()) + .then_some(url) +} + +fn forwarded_proto(headers: &HeaderMap) -> Option<&'static str> { + let value = headers + .get_all("x-forwarded-proto") + .iter() + .last()? + .to_str() + .ok()? + .rsplit(',') + .next()? + .trim(); + if value.eq_ignore_ascii_case("https") { + Some("https") + } else if value.eq_ignore_ascii_case("http") { + Some("http") + } else { + None + } +} + +fn rewrite_refresh_cookie(cookie: &HeaderValue, cookie_name: &str, secure: bool) -> HeaderValue { + let Ok(value) = cookie.to_str() else { + return cookie.clone(); + }; + let mut attributes = value.split(';').map(str::trim); + let Some(pair) = attributes.next() else { + return cookie.clone(); + }; + if pair.split_once('=').map(|(name, _)| name) != Some(cookie_name) { + return cookie.clone(); + } + let secure = + secure || cookie_name.starts_with("__Secure-") || cookie_name.starts_with("__Host-"); + let mut parts = vec![pair.to_string()]; + for attribute in attributes { + if attribute.eq_ignore_ascii_case("Secure") { + continue; + } + if !secure + && attribute.split_once('=').is_some_and(|(name, value)| { + name.trim().eq_ignore_ascii_case("SameSite") + && value.trim().eq_ignore_ascii_case("None") + }) + { + parts.push("SameSite=Lax".to_string()); + } else { + parts.push(attribute.to_string()); + } + } + if secure { + parts.push("Secure".to_string()); + } + let Ok(mut rewritten) = HeaderValue::from_str(&parts.join("; ")) else { + return cookie.clone(); + }; + rewritten.set_sensitive(cookie.is_sensitive()); + rewritten +} + +#[cfg(test)] +mod tests { + use super::{refresh_cookie_secure_for_request, rewrite_refresh_cookie}; + use axum::http::{header, HeaderMap, HeaderValue}; + + fn headers(origin: Option<&str>, forwarded_proto: Option<&str>) -> HeaderMap { + let mut headers = HeaderMap::new(); + if let Some(origin) = origin { + headers.insert(header::ORIGIN, HeaderValue::from_str(origin).unwrap()); + } + if let Some(proto) = forwarded_proto { + headers.insert("x-forwarded-proto", HeaderValue::from_str(proto).unwrap()); + } + headers + } + + #[test] + fn refresh_cookie_auto_detects_same_origin_http_and_https() { + for (origin, host, secure) in [ + ("http://aether.test:8084", "aether.test:8084", false), + ("http://aether.test", "aether.test:80", false), + ("http://[2001:db8::1]:8084", "[2001:db8::1]:8084", false), + ("https://aether.test", "aether.test", true), + ] { + assert_eq!( + refresh_cookie_secure_for_request( + &headers(Some(origin), None), + Some(host), + false, + None, + None, + true, + ), + secure, + "{origin}", + ); + } + assert!(refresh_cookie_secure_for_request( + &headers(Some("https://aether.test"), None), + Some("aether.test"), + false, + None, + None, + false, + )); + } + + #[test] + fn refresh_cookie_does_not_infer_http_from_other_or_invalid_origins() { + for origin in [ + "http://other.test", + "http://aether.test:8085", + "null", + "http://user@aether.test:8084", + "http://aether.test:8084/path", + "http://aether.test:8084?query", + "http://aether.test:8084#fragment", + "http://aether.test:8084, https://aether.test:8084", + "file:///tmp/test", + ] { + assert!( + refresh_cookie_secure_for_request( + &headers(Some(origin), None), + Some("aether.test:8084"), + false, + None, + None, + true, + ), + "{origin}" + ); + } + let mut duplicate = headers(Some("http://aether.test:8084"), None); + duplicate.append( + header::ORIGIN, + HeaderValue::from_static("https://aether.test:8084"), + ); + assert!(refresh_cookie_secure_for_request( + &duplicate, + Some("aether.test:8084"), + false, + None, + None, + true, + )); + } + + #[test] + fn refresh_cookie_only_trusts_forwarded_protocol_from_trusted_peers() { + for (proto, trusted, secure) in [ + ("http", true, false), + ("https", true, true), + ("http", false, true), + ("https", false, true), + ("https, http", true, false), + ("http, https", true, true), + ("ftp", true, true), + ("http,", true, true), + ] { + assert_eq!( + refresh_cookie_secure_for_request( + &headers(None, Some(proto)), + Some("aether.test"), + trusted, + None, + None, + true, + ), + secure, + "{proto}, trusted={trusted}" + ); + } + let mut chained = headers(None, Some("http, http")); + chained.append("x-forwarded-proto", HeaderValue::from_static("https")); + assert!(refresh_cookie_secure_for_request( + &chained, + Some("aether.test"), + true, + None, + None, + true, + )); + } + + #[test] + fn refresh_cookie_https_evidence_prevents_automatic_downgrade() { + for (origin, proto, public_url) in [ + ("https://aether.test", "http", None), + ("http://aether.test", "https", None), + ("http://aether.test", "http", Some("https://aether.test")), + ] { + assert!(refresh_cookie_secure_for_request( + &headers(Some(origin), Some(proto)), + Some("aether.test"), + true, + None, + public_url, + true, + )); + } + } + + #[test] + fn refresh_cookie_preserves_explicit_overrides_and_unknown_defaults() { + for (explicit, secure) in [ + ("true", true), + ("FALSE", false), + ("invalid", true), + ("", true), + ] { + assert_eq!( + refresh_cookie_secure_for_request( + &headers(Some("http://aether.test"), None), + Some("aether.test"), + false, + Some(explicit), + None, + true, + ), + secure + ); + } + assert!(!refresh_cookie_secure_for_request( + &headers(Some("https://aether.test"), None), + Some("aether.test"), + false, + Some("false"), + None, + true, + )); + for fallback in [false, true] { + assert_eq!( + refresh_cookie_secure_for_request( + &HeaderMap::new(), + Some("aether.test"), + false, + None, + None, + fallback, + ), + fallback + ); + } + } + + #[test] + fn refresh_cookie_accepts_an_explicit_public_http_origin() { + assert!(!refresh_cookie_secure_for_request( + &HeaderMap::new(), + Some("internal:8084"), + false, + None, + Some("http://aether.test"), + true, + )); + assert!(refresh_cookie_secure_for_request( + &headers(Some("https://aether.test"), None), + Some("internal:8084"), + false, + None, + Some("http://aether.test"), + true, + )); + } + + #[test] + fn refresh_cookie_rewrite_preserves_secret_path_expiry_and_httponly() { + let mut cookie = HeaderValue::from_static( + "aether_refresh_token=secret; Path=/api/auth; HttpOnly; SameSite=None; Max-Age=604800; Secure", + ); + cookie.set_sensitive(true); + let rewritten = rewrite_refresh_cookie(&cookie, "aether_refresh_token", false); + assert_eq!( + rewritten.to_str().unwrap(), + "aether_refresh_token=secret; Path=/api/auth; HttpOnly; SameSite=Lax; Max-Age=604800" + ); + assert!(rewritten.is_sensitive()); + assert_eq!( + rewrite_refresh_cookie(&cookie, "aether_refresh_token", true), + cookie + ); + } + + #[test] + fn refresh_cookie_rewrite_also_clears_http_cookies() { + let cookie = HeaderValue::from_static( + "aether_refresh_token=; Path=/api/auth; HttpOnly; SameSite=None; Max-Age=0; Secure", + ); + assert_eq!( + rewrite_refresh_cookie(&cookie, "aether_refresh_token", false) + .to_str() + .unwrap(), + "aether_refresh_token=; Path=/api/auth; HttpOnly; SameSite=Lax; Max-Age=0" + ); + } + + #[test] + fn refresh_cookie_rewrite_preserves_other_cookies_and_strict_policy() { + let unrelated = HeaderValue::from_static("oauth_binding=secret; Path=/; Secure; HttpOnly"); + assert_eq!( + rewrite_refresh_cookie(&unrelated, "aether_refresh_token", false), + unrelated + ); + let strict = HeaderValue::from_static( + "custom_refresh=secret; Path=/api/auth; HttpOnly; SameSite=Strict", + ); + assert_eq!( + rewrite_refresh_cookie(&strict, "custom_refresh", false), + strict + ); + assert!(rewrite_refresh_cookie(&strict, "custom_refresh", true) + .to_str() + .unwrap() + .ends_with("; Secure")); + let prefixed = + HeaderValue::from_static("__Secure-refresh=secret; HttpOnly; SameSite=None; Secure"); + assert_eq!( + rewrite_refresh_cookie(&prefixed, "__Secure-refresh", false), + prefixed + ); + } +} diff --git a/apps/aether-gateway/src/handlers/public/support/auth_helpers.rs b/apps/aether-gateway/src/handlers/public/support/auth_helpers.rs index ce46b0f3d..8b271254d 100644 --- a/apps/aether-gateway/src/handlers/public/support/auth_helpers.rs +++ b/apps/aether-gateway/src/handlers/public/support/auth_helpers.rs @@ -248,7 +248,7 @@ pub(super) fn auth_verification_send_cooldown_seconds() -> i64 { .unwrap_or(60) } -pub(super) fn auth_refresh_cookie_name() -> String { +pub(crate) fn auth_refresh_cookie_name() -> String { std::env::var("AUTH_REFRESH_COOKIE_NAME") .ok() .map(|value| value.trim().to_string()) diff --git a/apps/aether-gateway/src/tests/control/admin/endpoints/quota.rs b/apps/aether-gateway/src/tests/control/admin/endpoints/quota.rs index c266a944a..42d4ab107 100644 --- a/apps/aether-gateway/src/tests/control/admin/endpoints/quota.rs +++ b/apps/aether-gateway/src/tests/control/admin/endpoints/quota.rs @@ -8,7 +8,7 @@ use aether_data::repository::global_models::InMemoryGlobalModelReadRepository; use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository; use aether_data::repository::proxy_nodes::InMemoryProxyNodeRepository; use aether_data_contracts::repository::global_models::{ - AdminProviderModelListQuery, GlobalModelReadRepository, + AdminGlobalModelListQuery, AdminProviderModelListQuery, GlobalModelReadRepository, }; use aether_data_contracts::repository::provider_catalog::{ ProviderCatalogReadRepository, StoredProviderCatalogKey, StoredProviderCatalogProvider, @@ -20,8 +20,9 @@ use http::StatusCode; use serde_json::json; use super::super::super::{ - build_router_with_state, build_state_with_execution_runtime_override, sample_bound_auth_config, - sample_bound_key, sample_endpoint, sample_key, sample_proxy_node, start_server, AppState, + build_router_with_state, build_state_with_execution_runtime_override, sample_admin_global_model, + sample_bound_auth_config, sample_bound_key, sample_endpoint, sample_key, sample_proxy_node, + start_server, AppState, }; use crate::constants::{ GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER, @@ -2421,7 +2422,15 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_antigravity_with_tru )], vec![key], )); - let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::default()); + let existing_global_model = sample_admin_global_model( + "global-claude-sonnet-4", + "claude-sonnet-4", + "Claude Sonnet 4", + ); + let global_model_repository = Arc::new( + InMemoryGlobalModelReadRepository::default() + .with_admin_global_models(vec![existing_global_model.clone()]), + ); let (upstream_url, upstream_handle) = start_server(upstream).await; let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await; @@ -2533,7 +2542,16 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_antigravity_with_tru .and_then(|value| value.get("remaining_fraction")), Some(&json!(0.25)) ); - let imported_provider_models = global_model_repository + let global_models = global_model_repository + .list_admin_global_models(&AdminGlobalModelListQuery { + limit: 100, + ..Default::default() + }) + .await + .expect("global models should read after quota refresh"); + assert_eq!(global_models.total, 1); + assert_eq!(global_models.items, vec![existing_global_model]); + let provider_models = global_model_repository .list_admin_provider_models(&AdminProviderModelListQuery { provider_id: "provider-antigravity".to_string(), is_active: None, @@ -2541,15 +2559,8 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_antigravity_with_tru limit: 100, }) .await - .expect("imported Antigravity provider models should read"); - let imported_model_names = imported_provider_models - .iter() - .map(|model| model.provider_model_name.as_str()) - .collect::>(); - assert!(imported_model_names.contains("claude-sonnet-4")); - assert!(imported_model_names.contains("gemini-2.5-pro")); - assert!(imported_model_names.contains("gemini-3.7-flash-tiered")); - assert!(!imported_model_names.contains("chat_23310")); + .expect("Antigravity provider models should read after quota refresh"); + assert!(provider_models.is_empty()); assert_eq!( reloaded[0] .upstream_metadata diff --git a/apps/aether-gateway/src/tests/control/admin/oauth.rs b/apps/aether-gateway/src/tests/control/admin/oauth.rs index b15aef987..13553b7da 100644 --- a/apps/aether-gateway/src/tests/control/admin/oauth.rs +++ b/apps/aether-gateway/src/tests/control/admin/oauth.rs @@ -3916,11 +3916,47 @@ async fn gateway_completes_admin_provider_oauth_provider_locally_with_trusted_ad fn gateway_names_new_antigravity_oauth_account_from_google_userinfo_email() { run_admin_oauth_test( "gateway_names_new_antigravity_oauth_account_from_google_userinfo_email", - gateway_names_new_antigravity_oauth_account_from_google_userinfo_email_impl, + || { + assert_antigravity_oauth_account_uses_google_userinfo_email( + "complete", + json!({ + "callback_url": "http://localhost:51121/oauth2callback?code=antigravity-code-123&state=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }), + ) + }, ); } -async fn gateway_names_new_antigravity_oauth_account_from_google_userinfo_email_impl() { +#[test] +fn gateway_names_imported_antigravity_oauth_account_from_google_userinfo_email() { + run_admin_oauth_test( + "gateway_names_imported_antigravity_oauth_account_from_google_userinfo_email", + || { + assert_antigravity_oauth_account_uses_google_userinfo_email( + "import-refresh-token", + json!({"refresh_token": "antigravity-import-refresh-token"}), + ) + }, + ); +} + +#[test] +fn gateway_names_batch_imported_antigravity_oauth_account_from_google_userinfo_email() { + run_admin_oauth_test( + "gateway_names_batch_imported_antigravity_oauth_account_from_google_userinfo_email", + || { + assert_antigravity_oauth_account_uses_google_userinfo_email( + "batch-import", + json!({"credentials": "antigravity-import-refresh-token"}), + ) + }, + ); +} + +async fn assert_antigravity_oauth_account_uses_google_userinfo_email( + operation: &str, + request_body: Value, +) { let upstream_hits = Arc::new(Mutex::new(0usize)); let upstream_hits_clone = Arc::clone(&upstream_hits); let upstream = Router::new().fallback(any(move |_request: Request| { @@ -4025,15 +4061,13 @@ async fn gateway_names_new_antigravity_oauth_account_from_google_userinfo_email_ let response = reqwest::Client::new() .post(format!( - "{gateway_url}/api/admin/provider-oauth/providers/provider-antigravity/complete" + "{gateway_url}/api/admin/provider-oauth/providers/provider-antigravity/{operation}" )) .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!({ - "callback_url": "http://localhost:51121/oauth2callback?code=antigravity-code-123&state=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" - })) + .json(&request_body) .send() .await .expect("request should succeed"); @@ -4041,9 +4075,22 @@ async fn gateway_names_new_antigravity_oauth_account_from_google_userinfo_email_ let status = response.status(); let payload: Value = response.json().await.expect("json body should parse"); assert_eq!(status, StatusCode::OK, "payload={payload}"); - assert_eq!(payload["provider_type"], "antigravity"); - assert_eq!(payload["email"], "new-antigravity@example.com"); - assert_eq!(payload["replaced"], false); + let account_result = if operation == "batch-import" { + assert_eq!(payload["total"], 1); + assert_eq!(payload["success"], 1, "payload={payload}"); + assert_eq!(payload["failed"], 0); + assert_eq!(payload["results"][0]["status"], "success"); + assert_eq!( + payload["results"][0]["key_name"], + "new-antigravity@example.com" + ); + &payload["results"][0] + } else { + assert_eq!(payload["provider_type"], "antigravity"); + assert_eq!(payload["email"], "new-antigravity@example.com"); + &payload + }; + assert_eq!(account_result["replaced"], false); assert_eq!(*token_hits.lock().expect("mutex should lock"), 1); assert_eq!(*user_info_hits.lock().expect("mutex should lock"), 1); assert_eq!( @@ -4055,7 +4102,7 @@ async fn gateway_names_new_antigravity_oauth_account_from_google_userinfo_email_ ); assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); - let key_id = payload["key_id"] + let key_id = account_result["key_id"] .as_str() .expect("created key id should be returned") .to_string(); diff --git a/apps/aether-gateway/src/tests/frontdoor/public_support.rs b/apps/aether-gateway/src/tests/frontdoor/public_support.rs index 465ec9451..96331273c 100644 --- a/apps/aether-gateway/src/tests/frontdoor/public_support.rs +++ b/apps/aether-gateway/src/tests/frontdoor/public_support.rs @@ -50,6 +50,8 @@ 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/dashboard.rs"] mod dashboard; #[path = "public_support/vscodex.rs"] diff --git a/apps/aether-gateway/src/tests/frontdoor/public_support/auth_cookie.rs b/apps/aether-gateway/src/tests/frontdoor/public_support/auth_cookie.rs new file mode 100644 index 000000000..472a02bb1 --- /dev/null +++ b/apps/aether-gateway/src/tests/frontdoor/public_support/auth_cookie.rs @@ -0,0 +1,124 @@ +use super::{sample_auth_user, sample_auth_wallet, start_auth_gateway_with_state}; +use axum::http::{header, StatusCode}; +use chrono::Utc; +use serde_json::json; + +fn refresh_cookie(response: &reqwest::Response, secure: bool) -> String { + let cookie = response + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap(); + assert!(cookie.starts_with("aether_refresh_token=")); + assert!(cookie.contains("HttpOnly")); + assert!(cookie.contains("Path=/api/auth")); + assert_eq!( + cookie + .split(';') + .any(|attribute| attribute.trim() == "Secure"), + secure + ); + if !secure { + assert!(!cookie.contains("SameSite=None")); + assert!(cookie.contains("SameSite=Lax")); + } + assert_eq!( + response.headers().get(header::CACHE_CONTROL).unwrap(), + "no-store" + ); + cookie.to_string() +} + +#[tokio::test] +async fn gateway_auth_refresh_cookie_roundtrip_adapts_to_http_and_https() { + for (origin_scheme, forwarded_proto, secure) in [ + ("http", None, false), + ("https", None, true), + ("https", Some("https"), true), + ("http", Some("https"), true), + ] { + let now = Utc::now(); + let (gateway_url, upstream_hits, gateway_handle, upstream_handle) = + start_auth_gateway_with_state( + sample_auth_user(now), + sample_auth_wallet("user-auth-1", now), + [], + ) + .await; + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + header::ORIGIN, + gateway_url + .replacen("http:", &format!("{origin_scheme}:"), 1) + .parse() + .unwrap(), + ); + headers.insert( + "x-client-device-id", + "cookie-roundtrip-device".parse().unwrap(), + ); + if let Some(proto) = forwarded_proto { + headers.insert("x-forwarded-proto", proto.parse().unwrap()); + } + let client = reqwest::Client::builder() + .default_headers(headers) + .build() + .unwrap(); + let login = client.post(format!("{gateway_url}/api/auth/login")) + .json(&json!({ "email": "alice@example.com", "password": "secret123", "auth_type": "local" })) + .send().await.unwrap(); + assert_eq!(login.status(), StatusCode::OK); + let mut cookie = refresh_cookie(&login, secure); + + for _ in 0..3 { + let refreshed = client + .post(format!("{gateway_url}/api/auth/refresh")) + .header(header::COOKIE, cookie.split(';').next().unwrap()) + .send() + .await + .unwrap(); + assert_eq!(refreshed.status(), StatusCode::OK); + let rotated = refresh_cookie(&refreshed, secure); + assert_ne!(rotated, cookie); + cookie = rotated; + let payload: serde_json::Value = refreshed.json().await.unwrap(); + let current_user = client + .get(format!("{gateway_url}/api/auth/me")) + .bearer_auth(payload["access_token"].as_str().unwrap()) + .send() + .await + .unwrap(); + assert_eq!(current_user.status(), StatusCode::OK); + } + + let logout = client + .post(format!("{gateway_url}/api/auth/logout")) + .header(header::COOKIE, cookie.split(';').next().unwrap()) + .send() + .await + .unwrap(); + assert_eq!(logout.status(), StatusCode::OK); + assert!(refresh_cookie(&logout, secure).contains("Max-Age=0")); + + let revoked = client + .post(format!("{gateway_url}/api/auth/refresh")) + .header(header::COOKIE, cookie.split(';').next().unwrap()) + .send() + .await + .unwrap(); + assert_eq!(revoked.status(), StatusCode::UNAUTHORIZED); + assert!(refresh_cookie(&revoked, secure).contains("Max-Age=0")); + + let missing = client + .post(format!("{gateway_url}/api/auth/refresh")) + .send() + .await + .unwrap(); + assert_eq!(missing.status(), StatusCode::UNAUTHORIZED); + assert!(refresh_cookie(&missing, secure).contains("Max-Age=0")); + assert_eq!(*upstream_hits.lock().unwrap(), 0); + gateway_handle.abort(); + upstream_handle.abort(); + } +} diff --git a/crates/aether-admin/src/provider/oauth.rs b/crates/aether-admin/src/provider/oauth.rs index a25268f18..65312248e 100644 --- a/crates/aether-admin/src/provider/oauth.rs +++ b/crates/aether-admin/src/provider/oauth.rs @@ -20,7 +20,7 @@ pub fn build_kiro_batch_import_key_name( .iter() .map(|byte| format!("{byte:02x}")) .collect::(); - format!("kiro_{}", &hex[..6]) + format!("账号_{}", &hex[..6]) }); format!("{base} ({method})") } @@ -130,3 +130,36 @@ pub fn parse_admin_provider_oauth_kiro_batch_import_entries(raw_credentials: &st .map(|refresh_token| json!({ "refreshToken": refresh_token })) .collect() } + +#[cfg(test)] +mod tests { + use super::build_kiro_batch_import_key_name; + + #[test] + fn kiro_batch_import_key_name_preserves_email_and_auth_method() { + for method in ["social", "idc"] { + assert_eq!( + build_kiro_batch_import_key_name( + Some(" kiro_user@example.com "), + Some(method), + Some("refresh-token-1"), + ), + format!("kiro_user@example.com ({method})") + ); + } + } + + #[test] + fn kiro_batch_import_key_name_without_email_uses_generic_account_prefix() { + for email in [None, Some(""), Some(" ")] { + assert_eq!( + build_kiro_batch_import_key_name(email, None, Some("refresh-token-1")), + "账号_154f43 (social)" + ); + assert_eq!( + build_kiro_batch_import_key_name(email, Some("idc"), Some("refresh-token-1")), + "账号_154f43 (idc)" + ); + } + } +} diff --git a/crates/aether-admin/src/provider/state.rs b/crates/aether-admin/src/provider/state.rs index ade0fd45f..1d4a40bf4 100644 --- a/crates/aether-admin/src/provider/state.rs +++ b/crates/aether-admin/src/provider/state.rs @@ -405,18 +405,40 @@ pub fn build_kiro_device_key_name(email: Option<&str>, refresh_token: Option<&st .collect::() }) .unwrap_or_else(|| "unknown".to_string()); - format!("kiro_{fallback} (idc)") + format!("账号_{fallback} (idc)") } #[cfg(test)] mod tests { use super::{ - decode_jwt_claims, enrich_admin_provider_oauth_auth_config, + build_kiro_device_key_name, decode_jwt_claims, enrich_admin_provider_oauth_auth_config, parse_provider_oauth_callback_params, MAX_UNVERIFIED_JWT_CLAIMS_BYTES, }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use serde_json::json; + #[test] + fn kiro_device_key_name_preserves_email_and_auth_method() { + assert_eq!( + build_kiro_device_key_name(Some(" kiro_user@example.com "), Some("refresh-token-1")), + "kiro_user@example.com (idc)" + ); + } + + #[test] + fn kiro_device_key_name_without_email_uses_generic_account_prefix() { + for email in [None, Some(""), Some(" ")] { + assert_eq!( + build_kiro_device_key_name(email, Some("refresh-token-1")), + "账号_154f43 (idc)" + ); + assert_eq!( + build_kiro_device_key_name(email, None), + "账号_unknown (idc)" + ); + } + } + fn sample_unsigned_jwt(payload: serde_json::Value) -> String { let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#); let payload = URL_SAFE_NO_PAD.encode(payload.to_string()); diff --git a/crates/aether-oauth/src/provider/providers/antigravity.rs b/crates/aether-oauth/src/provider/providers/antigravity.rs index b11cac216..a54021d34 100644 --- a/crates/aether-oauth/src/provider/providers/antigravity.rs +++ b/crates/aether-oauth/src/provider/providers/antigravity.rs @@ -179,7 +179,8 @@ impl ProviderOAuthAdapter for AntigravityProviderOAuthAdapter { ctx: &crate::provider::ProviderOAuthTransportContext, input: crate::provider::ProviderOAuthImportInput, ) -> Result { - self.inner.import_credentials(executor, ctx, input).await + let result = self.inner.import_credentials(executor, ctx, input).await?; + self.enrich_google_identity(executor, ctx, result).await } async fn refresh( @@ -223,10 +224,11 @@ mod tests { use super::{AntigravityProviderOAuthAdapter, ANTIGRAVITY_USER_INFO_URL}; use crate::network::{OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse}; use crate::provider::{ - ProviderOAuthAccount, ProviderOAuthAdapter, ProviderOAuthTransportContext, + ProviderOAuthAccount, ProviderOAuthAdapter, ProviderOAuthImportInput, + ProviderOAuthTransportContext, }; use async_trait::async_trait; - use serde_json::json; + use serde_json::{json, Value}; use std::collections::BTreeMap; use std::sync::Mutex; @@ -235,6 +237,8 @@ mod tests { #[derive(Default)] struct GoogleOAuthExecutor { requests: Mutex>, + token_payload: Option, + user_info_response: Option, } fn transport_context() -> ProviderOAuthTransportContext { @@ -275,26 +279,36 @@ mod tests { .expect("requests should lock") .push(request); match request_id.as_str() { - "provider-oauth:exchange-code" => Ok(OAuthHttpResponse { - status_code: 200, - body_text: json!({ - "access_token": "google-access-token", - "refresh_token": "google-refresh-token", - "token_type": "Bearer", - "expires_in": 3600 + "provider-oauth:exchange-code" | "provider-oauth:refresh-token" => { + Ok(OAuthHttpResponse { + status_code: 200, + body_text: self + .token_payload + .clone() + .unwrap_or_else(|| { + json!({ + "access_token": "google-access-token", + "refresh_token": "google-refresh-token", + "token_type": "Bearer", + "expires_in": 3600 + }) + }) + .to_string(), + json_body: None, }) - .to_string(), - json_body: None, - }), - "provider-oauth:antigravity-user-info" => Ok(OAuthHttpResponse { - status_code: 200, - body_text: json!({ - "email": "antigravity@example.com", - "verified_email": true - }) - .to_string(), - json_body: None, - }), + } + "provider-oauth:antigravity-user-info" => Ok(self + .user_info_response + .clone() + .unwrap_or_else(|| OAuthHttpResponse { + status_code: 200, + body_text: json!({ + "email": "antigravity@example.com", + "verified_email": true + }) + .to_string(), + json_body: None, + })), other => panic!("unexpected OAuth request: {other}"), } } @@ -365,6 +379,133 @@ mod tests { assert_eq!(requests[1].network, ctx.network); } + #[tokio::test] + async fn antigravity_import_fetches_google_email_for_account_identity() { + let adapter = AntigravityProviderOAuthAdapter::default() + .with_oauth_credentials_for_tests("test-client-id", "test-client-secret"); + let ctx = transport_context(); + let executor = GoogleOAuthExecutor::default(); + + let result = adapter + .import_credentials( + &executor, + &ctx, + ProviderOAuthImportInput { + provider_type: "antigravity".to_string(), + name: None, + refresh_token: Some("google-refresh-token".to_string()), + raw_credentials: None, + network: ctx.network.clone(), + }, + ) + .await + .expect("Antigravity OAuth import should succeed"); + + assert_eq!(result.auth_config["email"], "antigravity@example.com"); + assert_eq!( + result + .token_set + .raw_payload + .as_ref() + .and_then(|payload| payload.get("email")), + Some(&json!("antigravity@example.com")) + ); + let requests = executor.requests.lock().expect("requests should lock"); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].request_id, "provider-oauth:refresh-token"); + assert_eq!(requests[1].url, ANTIGRAVITY_USER_INFO_URL); + assert_eq!(requests[1].method, reqwest::Method::GET); + assert_eq!( + requests[1].headers.get("authorization").map(String::as_str), + Some("Bearer google-access-token") + ); + assert_eq!(requests[1].network, ctx.network); + } + + #[tokio::test] + async fn antigravity_import_skips_userinfo_when_token_payload_has_email() { + let adapter = AntigravityProviderOAuthAdapter::default() + .with_oauth_credentials_for_tests("test-client-id", "test-client-secret"); + let ctx = transport_context(); + let executor = GoogleOAuthExecutor { + token_payload: Some(json!({ + "access_token": "google-access-token", + "email": "token-email@example.com" + })), + ..Default::default() + }; + + let result = adapter + .import_credentials( + &executor, + &ctx, + ProviderOAuthImportInput { + provider_type: "antigravity".to_string(), + name: None, + refresh_token: Some("google-refresh-token".to_string()), + raw_credentials: None, + network: ctx.network.clone(), + }, + ) + .await + .expect("Antigravity OAuth import should succeed"); + + assert_eq!(result.auth_config["email"], "token-email@example.com"); + assert_eq!( + executor + .requests + .lock() + .expect("requests should lock") + .len(), + 1 + ); + } + + #[tokio::test] + async fn antigravity_import_rejects_unavailable_or_invalid_google_identity() { + let adapter = AntigravityProviderOAuthAdapter::default() + .with_oauth_credentials_for_tests("test-client-id", "test-client-secret"); + let ctx = transport_context(); + + for (status_code, profile) in [ + (401, json!({"error": "unauthorized"})), + (200, json!({})), + (200, json!({"email": " "})), + ( + 200, + json!({"email": "unverified@example.com", "verified_email": false}), + ), + ] { + let executor = GoogleOAuthExecutor { + user_info_response: Some(OAuthHttpResponse { + status_code, + body_text: profile.to_string(), + json_body: None, + }), + ..Default::default() + }; + + let result = adapter + .import_credentials( + &executor, + &ctx, + ProviderOAuthImportInput { + provider_type: "antigravity".to_string(), + name: None, + refresh_token: Some("google-refresh-token".to_string()), + raw_credentials: None, + network: ctx.network.clone(), + }, + ) + .await; + + assert!( + result.is_err(), + "invalid Google identity should reject import: {profile}" + ); + } + } + #[tokio::test] async fn antigravity_probe_marks_forbidden_metadata_invalid() { let adapter = AntigravityProviderOAuthAdapter::default(); diff --git a/frontend/src/features/usage/components/RequestDetailDrawer/JsonContent.vue b/frontend/src/features/usage/components/RequestDetailDrawer/JsonContent.vue index 78915ce8b..4e0312303 100644 --- a/frontend/src/features/usage/components/RequestDetailDrawer/JsonContent.vue +++ b/frontend/src/features/usage/components/RequestDetailDrawer/JsonContent.vue @@ -169,11 +169,6 @@ function getDisplayHtml(line: JsonDisplayLine): string { function toggleFold(line: JsonDisplayLine, index: number) { const overrides = new Map(foldOverrides.value) - if (line.collapsed) { - for (const path of overrides.keys()) { - if (path.startsWith(`${line.id}/`)) overrides.delete(path) - } - } overrides.set(line.id, !line.collapsed) foldOverrides.value = overrides localReader = undefined diff --git a/frontend/src/features/usage/components/__tests__/BodyContent.worker.spec.ts b/frontend/src/features/usage/components/__tests__/BodyContent.worker.spec.ts index 3f94a75a6..583bab345 100644 --- a/frontend/src/features/usage/components/__tests__/BodyContent.worker.spec.ts +++ b/frontend/src/features/usage/components/__tests__/BodyContent.worker.spec.ts @@ -10,23 +10,46 @@ const apps: Array<{ app: App, root: HTMLElement }> = [] afterEach(() => { for (const { app, root } of apps.splice(0)) { app.unmount(); root.remove() } }) function mountBody(value: unknown) { - const engine = new BodyDocumentEngine(value) + const engine = new BodyDocumentEngine(value) const json = vi.fn(async (options: BodyJsonOptions) => engine.json(options)) const bodyDocument = shallowRef({ json } as unknown as BodyDocument) + const expandDepth = shallowRef(999) const errors: unknown[] = [] const root = document.createElement('div') document.body.appendChild(root) const app = createApp(defineComponent({ setup: () => () => h(JsonContent, { - data: null, bodyDocument: bodyDocument.value, expandDepth: 999, viewMode: 'formatted', isDark: false, emptyMessage: '无数据', + data: null, bodyDocument: bodyDocument.value, expandDepth: expandDepth.value, viewMode: 'formatted', isDark: false, emptyMessage: '无数据', onLoadError: (error: unknown) => errors.push(error), }) })) app.mount(root) apps.push({ app, root }) - return { root, json, bodyDocument, engine, errors } + return { root, json, bodyDocument, expandDepth, engine, errors } } function button(root: HTMLElement, label: string) { return [...root.querySelectorAll('button')].find(button => button.textContent?.includes(label))! } describe('worker-backed body views', () => { + it('resets every layer on collapse-all and reopens worker-backed nodes one layer at a time', async () => { + const value = { messages: [{ content: { text: 'deep worker content' } }] } + const { root, json, expandDepth, engine } = mountBody(value) + await vi.waitFor(() => expect(root.textContent).toContain('deep worker content')) + for (let cycle = 0; cycle < 2; cycle += 1) { + expandDepth.value = 0 + await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(3)) + expect(json).toHaveBeenLastCalledWith(expect.objectContaining({ expandDepth: 0, foldOverrides: new Map() })) + for (const lineCount of [5, 7, 9]) { + expect(root.textContent).not.toContain('deep worker content') + root.querySelector('button[aria-label="展开节点"]')!.click() + await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(lineCount)) + } + expect(root.textContent).toContain('deep worker content') + expandDepth.value = 999 + await vi.waitFor(() => expect(json).toHaveBeenLastCalledWith(expect.objectContaining({ expandDepth: 999, foldOverrides: new Map() }))) + await vi.waitFor(() => expect(root.textContent).toContain('deep worker content')) + expect(root.querySelector('button[aria-label="展开节点"]')).toBeNull() + } + expect(JSON.parse(engine.copy())).toEqual(value) + }) + it('automatically reads worker chunks while scrolling and preserves the complete copy', async () => { const values = Array.from({ length: 1000 }, (_value, index) => `value-${index}`) const { root, json, engine } = mountBody(values) diff --git a/frontend/src/features/usage/components/__tests__/JsonContent.lazy.spec.ts b/frontend/src/features/usage/components/__tests__/JsonContent.lazy.spec.ts index c81659530..df12aa349 100644 --- a/frontend/src/features/usage/components/__tests__/JsonContent.lazy.spec.ts +++ b/frontend/src/features/usage/components/__tests__/JsonContent.lazy.spec.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createApp, defineComponent, h, shallowRef, type App } from 'vue' import JsonContent from '../RequestDetailDrawer/JsonContent.vue' +import JsonContentPanel from '../JsonContentPanel.vue' import { JSON_PAGE_SIZE, JSON_SCROLL_CHUNK_SIZE, JSON_TEXT_CHUNK_SIZE } from '../../utils/json-viewer' const mountedApps: Array<{ app: App, root: HTMLElement }> = [] @@ -48,15 +49,57 @@ describe('JsonContent lazy rendering', () => { expect(root.querySelector('.virtual-body-scroll')?.scrollTop).toBe(0) }) - it('opens the complete subtree with one bracket click', async () => { - const { root } = mountJson({ messages: [{ content: 'hidden content' }] }, 0) + it('opens one layer per bracket click after collapsing all descendants', async () => { + const { root } = mountJson({ messages: [{ content: { text: 'hidden content' } }] }, 0) await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(3)) - expect(root.textContent).not.toContain('hidden content') - root.querySelector('button[aria-label="展开节点"]')!.click() + for (const lineCount of [5, 7, 9]) { + expect(root.textContent).not.toContain('hidden content') + root.querySelector('.line-content.clickable-collapsed')!.click() + await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(lineCount)) + } await vi.waitFor(() => expect(root.textContent).toContain('hidden content')) expect(root.querySelector('button[aria-label="展开节点"]')).toBeNull() }) + it('collapses every layer through the toolbar and keeps expand-all working', async () => { + const root = document.createElement('div') + document.body.appendChild(root) + const app = createApp(defineComponent({ setup: () => () => h(JsonContentPanel, { + data: { messages: [{ content: { text: 'deep content' } }] }, isDark: false, + }) })) + app.mount(root) + mountedApps.push({ app, root }) + await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(3)) + for (let cycle = 0; cycle < 2; cycle += 1) { + root.querySelector('button[title="展开全部"]')!.click() + await vi.waitFor(() => expect(root.textContent).toContain('deep content')) + expect(root.querySelector('button[aria-label="展开节点"]')).toBeNull() + root.querySelector('button[title="收缩全部"]')!.click() + await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(3)) + for (const lineCount of [5, 7]) { + root.querySelector('button[aria-label="展开节点"]')!.click() + await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(lineCount)) + expect(root.textContent).not.toContain('deep content') + } + } + }) + + it('preserves an explicitly folded child when its parent is reopened', async () => { + const { root } = mountJson({ messages: [{ content: { text: 'hidden content' } }] }) + const nodeButton = (key: string) => [...root.querySelectorAll('.json-line')] + .find(line => line.querySelector('.token-key')?.textContent === JSON.stringify(key))! + .querySelector('button')! + await vi.waitFor(() => expect(root.textContent).toContain('hidden content')) + nodeButton('content').click() + await vi.waitFor(() => expect(nodeButton('content').getAttribute('aria-expanded')).toBe('false')) + nodeButton('messages').click() + await vi.waitFor(() => expect(nodeButton('messages').getAttribute('aria-expanded')).toBe('false')) + nodeButton('messages').click() + await vi.waitFor(() => expect(nodeButton('messages').getAttribute('aria-expanded')).toBe('true')) + expect(nodeButton('content').getAttribute('aria-expanded')).toBe('false') + expect(root.textContent).not.toContain('hidden content') + }) + it('shows complete long strings without an extra expansion button', async () => { const content = `${'x'.repeat(JSON_TEXT_CHUNK_SIZE * 10) }末尾🙂` const { root, data } = mountJson({ content }) diff --git a/frontend/src/features/usage/utils/__tests__/json-viewer.spec.ts b/frontend/src/features/usage/utils/__tests__/json-viewer.spec.ts index 8697276b5..e96eece79 100644 --- a/frontend/src/features/usage/utils/__tests__/json-viewer.spec.ts +++ b/frontend/src/features/usage/utils/__tests__/json-viewer.spec.ts @@ -65,11 +65,34 @@ describe('lazy JSON pages', () => { expect(reader.read(21).lines).toEqual([]) }) - it('expands all descendants of an opened node while honoring an explicitly closed child', () => { + it.each([false, true])('opens collapsed descendants one layer at a time with indexPaths=%s', indexPaths => { const data = { messages: [{ content: { text: 'complete content' } }] } - const opened = getJsonPage(data, { expandDepth: 1, foldOverrides: new Map([['$/messages', false]]) }) + const paths = indexPaths ? ['$/0', '$/0/0', '$/0/0/0'] : ['$/messages', '$/messages/0', '$/messages/0/content'] + const foldOverrides = new Map() + for (const path of paths) { + const folded = getJsonPage(data, { expandDepth: 0, foldOverrides, indexPaths }) + expect(folded.lines.find(line => line.id === path)).toMatchObject({ collapsed: true }) + expect(folded.lines.some(line => line.value === 'complete content')).toBe(false) + foldOverrides.set(path, false) + } + const opened = getJsonPage(data, { expandDepth: 0, foldOverrides, indexPaths }) expect(opened.lines.some(line => line.value === 'complete content')).toBe(true) - const folded = getJsonPage(data, { expandDepth: 1, foldOverrides: new Map([['$/messages', false], ['$/messages/0/content', true]]) }) + }) + + it('does not read unopened descendants after opening their parent', () => { + const readContent = vi.fn(() => { throw new Error('unopened content must not be read') }) + const message = Object.defineProperty({}, 'content', { enumerable: true, get: readContent }) + const opened = getJsonPage({ messages: [message] }, { expandDepth: 0, foldOverrides: new Map([['$/messages', false]]) }) + expect(opened.lines.find(line => line.id === '$/messages/0')).toMatchObject({ collapsed: true }) + expect(readContent).not.toHaveBeenCalled() + }) + + it('still expands every layer in expand-all mode while honoring explicitly closed children', () => { + const data = { messages: [{ content: { text: 'complete content' } }] } + const expanded = getJsonPage(data, { expandDepth: 999 }) + expect(expanded.lines.some(line => line.value === 'complete content')).toBe(true) + expect(expanded.lines.filter(line => line.canFold).every(line => !line.collapsed)).toBe(true) + const folded = getJsonPage(data, { expandDepth: 999, foldOverrides: new Map([['$/messages', false], ['$/messages/0/content', true]]) }) expect(folded.lines.some(line => line.value === 'complete content')).toBe(false) }) diff --git a/frontend/src/features/usage/utils/json-viewer.ts b/frontend/src/features/usage/utils/json-viewer.ts index daba0a219..648391533 100644 --- a/frontend/src/features/usage/utils/json-viewer.ts +++ b/frontend/src/features/usage/utils/json-viewer.ts @@ -130,7 +130,7 @@ function* splitJsonLine(line: JsonTreeLine, chunkSize: number): Generator { const depth = options.expandDepth === 0 || options.expandDepth == null ? 1 : options.expandDepth - function* walk(value: unknown, path: string, indent: number, comma: string, key?: string, expandedParent = false): Generator { + function* walk(value: unknown, path: string, indent: number, comma: string, key?: string): Generator { if (value == null || typeof value !== 'object') { yield { id: path, indent, key, value, comma, canFold: false, collapsed: false } return @@ -140,8 +140,7 @@ function* walkJsonLines(data: unknown, options: JsonPageOptions): Generator= depth)) + const collapsed = childCount === 0 || (override ?? (indent >= depth)) const bracket = isArray ? '[' : '{' const closingBracket = isArray ? ']' : '}' yield { id: path, indent, key, comma, bracket, closingBracket, childCount, isArray, collapsed, canFold: childCount > 0 } @@ -151,7 +150,7 @@ function* walkJsonLines(data: unknown, options: JsonPageOptions): Generator)[childKey] - yield* walk(childValue, childPath, indent + 1, index === childCount - 1 ? '' : ',', isArray ? undefined : childKey, expandedSubtree) + yield* walk(childValue, childPath, indent + 1, index === childCount - 1 ? '' : ',', isArray ? undefined : childKey) } yield { id: `close:${path}`, indent, bracket: closingBracket, comma, canFold: false, collapsed: false } } diff --git a/frontend/src/utils/__tests__/codexQuotaWindow.spec.ts b/frontend/src/utils/__tests__/codexQuotaWindow.spec.ts index ae4204c4f..e6652acde 100644 --- a/frontend/src/utils/__tests__/codexQuotaWindow.spec.ts +++ b/frontend/src/utils/__tests__/codexQuotaWindow.spec.ts @@ -29,6 +29,85 @@ describe('getCodexQuotaWindowPresentation', () => { expect(windows.sort((a, b) => a.sortOrder - b.sortOrder).map(item => item.label)).toEqual(['5H', '周']) }) + it('distinguishes account and model quotas with the same duration', () => { + const windows = [ + { code: 'weekly', label: '周', scope: 'account', window_minutes: 10_080 }, + { code: 'additional_0_primary', label: 'gpt-reserve', scope: 'model', window_minutes: 10_080 }, + ] + + expect(windows.map(window => getCodexQuotaWindowPresentation(window)?.label)) + .toEqual(['周', 'gpt-reserve 周']) + expect(getCodexQuotaWindowLimitLabel(windows[1])).toBe('gpt-reserve 周限额') + }) + + it.each([ + [300, 'gpt-reserve 5H'], + [10_080, 'gpt-reserve 周'], + [43_800, 'gpt-reserve 月'], + ])('includes the model name for a %i-minute window', (windowMinutes, expectedLabel) => { + expect(getCodexQuotaWindowPresentation({ + code: 'additional_0_primary', + label: ' gpt-reserve ', + scope: ' Model ', + window_minutes: windowMinutes, + })?.label).toBe(expectedLabel) + }) + + it.each(['model', 'quota_group_label', 'quota_group'] as const)( + 'falls back to %s when a model quota label is blank', + (field) => { + expect(getCodexQuotaWindowPresentation({ + code: 'additional_0_primary', + label: ' ', + scope: 'model', + [field]: 'gpt-reserve', + window_minutes: 10_080, + })?.label).toBe('gpt-reserve 周') + }, + ) + + it('keeps model identities when legacy snapshots have no window duration', () => { + expect(getCodexQuotaWindowPresentation({ + code: 'additional_0_primary', + label: 'gpt-reserve', + scope: 'model', + })?.label).toBe('gpt-reserve') + expect(getCodexQuotaWindowPresentation({ + code: 'additional_0_primary', + model: 'gpt-reserve', + scope: 'model', + })?.label).toBe('gpt-reserve') + expect(getCodexQuotaWindowPresentation({ + code: 'weekly', + label: 'gpt-reserve', + scope: 'model', + })?.label).toBe('gpt-reserve 周') + }) + + it('uses the code when a model quota has no other identity', () => { + expect(getCodexQuotaWindowPresentation({ + code: 'model-reserve', + scope: 'model', + window_minutes: 10_080, + })?.label).toBe('model-reserve 周') + }) + + it('preserves Spark formatting without stripping other model names', () => { + expect(getCodexQuotaWindowPresentation({ + code: 'spark_weekly', + label: 'Spark 周', + window_minutes: 10_080, + })).toEqual({ label: 'Spark周', sortOrder: 10_010_080 }) + expect(getCodexQuotaWindowPresentation({ code: 'spark_5h', label: 'Spark 5H' })?.label) + .toBe('Spark5H') + expect(getCodexQuotaWindowPresentation({ + code: 'additional_0_primary', + label: 'Spark Reserve', + scope: 'model', + window_minutes: 10_080, + })?.label).toBe('Spark Reserve 周') + }) + it('builds the provider limit label from the actual window duration', () => { expect(getCodexQuotaWindowLimitLabel({ code: 'weekly', window_minutes: 10_080 })).toBe('周限额') expect(getCodexQuotaWindowLimitLabel({ code: 'weekly', window_minutes: 43_800 })).toBe('月限额') diff --git a/frontend/src/utils/__tests__/providerKeyQuota.spec.ts b/frontend/src/utils/__tests__/providerKeyQuota.spec.ts index e20e9f17c..e1737d9df 100644 --- a/frontend/src/utils/__tests__/providerKeyQuota.spec.ts +++ b/frontend/src/utils/__tests__/providerKeyQuota.spec.ts @@ -71,6 +71,37 @@ describe('providerKeyQuota', () => { }, 'codex')).toBe('月剩余 86.0%') }) + it('keeps account and model Codex weekly quotas distinct in display text', () => { + expect(getQuotaDisplayText({ + status_snapshot: { + oauth: { code: 'valid' }, + account: { code: 'ok', blocked: false }, + quota: { + provider_type: 'codex', + code: 'ok', + exhausted: false, + windows: [ + { + code: 'weekly', + label: '周', + scope: 'account', + window_minutes: 10_080, + remaining_ratio: 0.9, + }, + { + code: 'additional_0_primary', + label: 'gpt-reserve', + scope: 'model', + model: 'gpt-reserve', + window_minutes: 10_080, + remaining_ratio: 0.4, + }, + ], + }, + }, + }, 'codex')).toBe('周剩余 90.0% | gpt-reserve 周剩余 40.0%') + }) + it('formats Grok account quota from structured quota windows', () => { expect(getQuotaDisplayText({ status_snapshot: { diff --git a/frontend/src/utils/codexQuotaWindow.ts b/frontend/src/utils/codexQuotaWindow.ts index f699c4c3f..1a87be97b 100644 --- a/frontend/src/utils/codexQuotaWindow.ts +++ b/frontend/src/utils/codexQuotaWindow.ts @@ -41,7 +41,13 @@ export function getCodexQuotaWindowPresentation( const code = String(window.code || '').trim().toLowerCase() const isSpark = code.startsWith('spark_') const baseCode = isSpark ? code.slice('spark_'.length) : code - const rawLabel = String(window.label || '').trim().replace(/^Spark\s*/i, '') + const rawLabel = String(window.label || '').trim() + const modelLabel = String(window.scope || '').trim().toLowerCase() === 'model' && !isSpark + ? [rawLabel, window.model, window.quota_group_label, window.quota_group, window.code] + .map(value => String(value || '').trim()) + .find(Boolean) + : undefined + const legacyLabel = isSpark ? rawLabel.replace(/^Spark\s*/i, '') : rawLabel || modelLabel || '' const hasExplicitWindowMinutes = window.window_minutes != null const windowMinutes = Number(window.window_minutes) @@ -51,12 +57,19 @@ export function getCodexQuotaWindowPresentation( const period = hasExplicitWindowMinutes ? formatCodexQuotaPeriod(windowMinutes) - : getLegacyCodexQuotaPeriod(baseCode, rawLabel) + : getLegacyCodexQuotaPeriod(baseCode, legacyLabel) if (!period) return null + let label = period + if (isSpark) { + label = `Spark${period}` + } else if (modelLabel && modelLabel !== period) { + label = `${modelLabel} ${period}` + } + const fallbackOrder = baseCode === '5h' ? 300 : baseCode === 'weekly' ? 10_080 : 1_000_000 return { - label: isSpark ? `Spark${period}` : period, + label, sortOrder: (isSpark ? 10_000_000 : 0) + (hasExplicitWindowMinutes ? windowMinutes : fallbackOrder), } } diff --git a/frontend/src/views/admin/__tests__/PoolManagement.codex-cycle-stats.spec.ts b/frontend/src/views/admin/__tests__/PoolManagement.codex-cycle-stats.spec.ts index ee932daad..392c70b44 100644 --- a/frontend/src/views/admin/__tests__/PoolManagement.codex-cycle-stats.spec.ts +++ b/frontend/src/views/admin/__tests__/PoolManagement.codex-cycle-stats.spec.ts @@ -768,6 +768,60 @@ describe('PoolManagement Codex cycle stats mode', () => { expect(root.querySelector('[data-testid="pool-stats-cycle-single-marker"]')).toBeNull() }) + it('renders separate account and model weekly quotas in desktop and mobile layouts', async () => { + const codexKey = createPoolKey('codex', { + status_snapshot: { + oauth: { code: 'valid' }, + account: { code: 'ok', blocked: false }, + quota: { + code: 'ok', + exhausted: false, + provider_type: 'codex', + windows: [ + { + code: 'weekly', + label: '周', + scope: 'account', + remaining_ratio: 0.9, + window_minutes: 10_080, + usage: { request_count: 12 }, + }, + { + code: 'additional_0_primary', + label: 'gpt-reserve', + scope: 'model', + model: 'gpt-reserve', + remaining_ratio: 0.4, + window_minutes: 10_080, + usage: { request_count: 99 }, + }, + ], + }, + }, + }) + endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] }) + endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(codexKey)) + endpointMocks.getProvider.mockResolvedValue(createProvider('codex')) + + const root = mountPoolManagement() + await settle() + + const panels = root.querySelectorAll('[data-testid="pool-quota-rows"]') + expect(panels).toHaveLength(2) + for (const panel of panels) { + const rows = Array.from(panel.children).map(row => ({ + label: row.querySelector('[data-testid="pool-quota-period-label"]')?.textContent?.trim(), + meter: row.querySelector('[data-testid="pool-quota-meter-text"]')?.textContent?.trim(), + })) + expect(rows).toHaveLength(2) + expect(rows).toEqual(expect.arrayContaining([ + { label: '周', meter: '90.0%' }, + { label: 'gpt-reserve 周', meter: '40.0%' }, + ])) + } + expect(root.querySelector('[data-testid="pool-stats-cycle-request_count"]')?.textContent?.trim()).toBe('-/12') + }) + it('opens only one score popover across desktop and mobile layouts', async () => { const scoredKey = createPoolKey('codex', { pool_score: {