mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-17 00:17:46 +08:00
fix: harden OAuth identity and cookies and correct quota and JSON display
This commit is contained in:
+5
-1
@@ -31,7 +31,11 @@ RUST_LOG=aether_gateway=info
|
|||||||
# 示例: http://localhost:5173,https://app.example.com
|
# 示例: http://localhost:5173,https://app.example.com
|
||||||
# CORS_ORIGINS=http://localhost:5173
|
# CORS_ORIGINS=http://localhost:5173
|
||||||
# CORS_ALLOW_CREDENTIALS=true
|
# 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_SAMESITE=None
|
||||||
# AUTH_REFRESH_COOKIE_SECURE=true
|
# AUTH_REFRESH_COOKIE_SECURE=true
|
||||||
|
|
||||||
|
|||||||
@@ -1463,7 +1463,7 @@ mod tests {
|
|||||||
&auth_config,
|
&auth_config,
|
||||||
Some(0),
|
Some(0),
|
||||||
),
|
),
|
||||||
"antigravity_anti@example.com"
|
"anti@example.com"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::super::helpers::admin_provider_oauth_key_name_from_auth_config;
|
||||||
use super::super::kiro::{
|
use super::super::kiro::{
|
||||||
admin_provider_oauth_kiro_refresh_base_url_override, fetch_admin_provider_oauth_kiro_email,
|
admin_provider_oauth_kiro_refresh_base_url_override, fetch_admin_provider_oauth_kiro_email,
|
||||||
refresh_admin_provider_oauth_kiro_auth_config,
|
refresh_admin_provider_oauth_kiro_auth_config,
|
||||||
@@ -79,7 +80,7 @@ fn kiro_social_key_name(
|
|||||||
.collect::<String>()
|
.collect::<String>()
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
format!("kiro_{fallback} ({provider})")
|
format!("账号_{fallback} ({provider})")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn kiro_social_poll_error_response(error: impl Into<String>) -> Response<Body> {
|
fn kiro_social_poll_error_response(error: impl Into<String>) -> Response<Body> {
|
||||||
@@ -1004,10 +1005,11 @@ async fn handle_admin_provider_oauth_windsurf_browser_device_poll(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let key_name = email
|
let key_name = admin_provider_oauth_key_name_from_auth_config(
|
||||||
.as_deref()
|
&provider.provider_type,
|
||||||
.map(|email| format!("windsurf_{email}"))
|
&auth_config,
|
||||||
.unwrap_or_else(|| format!("windsurf_{}", current_unix_secs()));
|
None,
|
||||||
|
);
|
||||||
match state
|
match state
|
||||||
.create_provider_oauth_catalog_key(
|
.create_provider_oauth_catalog_key(
|
||||||
&provider.id,
|
&provider.id,
|
||||||
@@ -1356,6 +1358,32 @@ mod tests {
|
|||||||
use crate::control::GatewayAdminPrincipalContext;
|
use crate::control::GatewayAdminPrincipalContext;
|
||||||
use aether_data::repository::provider_oauth::StoredAdminProviderOAuthDeviceSession;
|
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 {
|
fn device_session() -> StoredAdminProviderOAuthDeviceSession {
|
||||||
StoredAdminProviderOAuthDeviceSession {
|
StoredAdminProviderOAuthDeviceSession {
|
||||||
session_id: "device-session-1".to_string(),
|
session_id: "device-session-1".to_string(),
|
||||||
|
|||||||
@@ -52,13 +52,12 @@ pub(super) fn admin_provider_oauth_key_name_from_auth_config(
|
|||||||
auth_config: &Map<String, Value>,
|
auth_config: &Map<String, Value>,
|
||||||
batch_index: Option<usize>,
|
batch_index: Option<usize>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let provider_type = provider_type.trim();
|
|
||||||
if let Some(email) = trimmed_auth_config_string(auth_config, "email") {
|
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") {
|
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())
|
.map(|duration| duration.as_secs())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
match batch_index {
|
match batch_index {
|
||||||
Some(index) => format!("{provider_type}_{timestamp}_{index}"),
|
Some(index) => format!("账号_{timestamp}_{index}"),
|
||||||
None => format!("账号_{timestamp}"),
|
None => format!("账号_{timestamp}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,6 +86,106 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::{json, Map};
|
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::<u64>().is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn grok_default_key_name_uses_full_user_id() {
|
fn grok_default_key_name_uses_full_user_id() {
|
||||||
let mut auth_config = Map::new();
|
let mut auth_config = Map::new();
|
||||||
@@ -95,10 +194,18 @@ mod tests {
|
|||||||
json!("1619039a-0191-4e0a-a490-8f4ad21262c9"),
|
json!("1619039a-0191-4e0a-a490-8f4ad21262c9"),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(
|
for provider_type in ["grok", " Grok "] {
|
||||||
admin_provider_oauth_key_name_from_auth_config("grok", &auth_config, None),
|
for batch_index in [None, Some(3)] {
|
||||||
"grok_1619039a-0191-4e0a-a490-8f4ad21262c9"
|
assert_eq!(
|
||||||
);
|
admin_provider_oauth_key_name_from_auth_config(
|
||||||
|
provider_type,
|
||||||
|
&auth_config,
|
||||||
|
batch_index,
|
||||||
|
),
|
||||||
|
"1619039a-0191-4e0a-a490-8f4ad21262c9"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -109,17 +216,22 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
admin_provider_oauth_key_name_from_auth_config("grok", &auth_config, None),
|
admin_provider_oauth_key_name_from_auth_config("grok", &auth_config, None),
|
||||||
"grok_grok@example.com"
|
"grok@example.com"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 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!(name.ends_with("_3"));
|
||||||
|
assert!(other_name.starts_with("账号_"));
|
||||||
|
assert!(other_name.ends_with("_4"));
|
||||||
|
assert_ne!(name, other_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use super::shared::{
|
|||||||
quota_key_auto_removed, quota_refresh_success_invalid_state,
|
quota_key_auto_removed, quota_refresh_success_invalid_state,
|
||||||
resolve_provider_quota_execution_timeouts, ProviderQuotaExecutionOutcome,
|
resolve_provider_quota_execution_timeouts, ProviderQuotaExecutionOutcome,
|
||||||
};
|
};
|
||||||
use crate::handlers::admin::provider::shared::payloads::AdminImportProviderModelsRequest;
|
|
||||||
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
|
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
|
||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
use aether_admin::provider::quota::{
|
use aether_admin::provider::quota::{
|
||||||
@@ -24,63 +23,6 @@ use std::collections::BTreeMap;
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
fn antigravity_discovered_model_ids(metadata_update: Option<&serde_json::Value>) -> Vec<String> {
|
|
||||||
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(
|
async fn execute_antigravity_quota_plan(
|
||||||
state: &AdminAppState<'_>,
|
state: &AdminAppState<'_>,
|
||||||
transport: &AdminGatewayProviderTransportSnapshot,
|
transport: &AdminGatewayProviderTransportSnapshot,
|
||||||
@@ -380,10 +322,6 @@ pub(crate) async fn refresh_antigravity_provider_quota_locally(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if status == "success" {
|
|
||||||
sync_antigravity_discovered_models(state, &provider.id, metadata_update.as_ref()).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
if status == "success" {
|
if status == "success" {
|
||||||
success_count += 1;
|
success_count += 1;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
mod support_announcements;
|
mod support_announcements;
|
||||||
#[path = "support/auth.rs"]
|
#[path = "support/auth.rs"]
|
||||||
mod support_auth;
|
mod support_auth;
|
||||||
|
#[path = "support/auth_cookie_policy.rs"]
|
||||||
|
mod support_auth_cookie_policy;
|
||||||
#[path = "support/billing.rs"]
|
#[path = "support/billing.rs"]
|
||||||
mod support_billing;
|
mod support_billing;
|
||||||
#[path = "support/ccswitch.rs"]
|
#[path = "support/ccswitch.rs"]
|
||||||
@@ -133,6 +135,31 @@ pub(crate) async fn maybe_build_local_public_support_response(
|
|||||||
remote_addr: &std::net::SocketAddr,
|
remote_addr: &std::net::SocketAddr,
|
||||||
client_ip: std::net::IpAddr,
|
client_ip: std::net::IpAddr,
|
||||||
request_body: Option<&Bytes>,
|
request_body: Option<&Bytes>,
|
||||||
|
) -> Option<Response<Body>> {
|
||||||
|
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<Response<Body>> {
|
) -> Option<Response<Body>> {
|
||||||
let decision = request_context.control_decision.as_ref()?;
|
let decision = request_context.control_decision.as_ref()?;
|
||||||
if decision.route_class.as_deref() != Some("public_support") {
|
if decision.route_class.as_deref() != Some("public_support") {
|
||||||
|
|||||||
@@ -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<Body>,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
host_header: Option<&str>,
|
||||||
|
remote_addr: &SocketAddr,
|
||||||
|
) -> Response<Body> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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<Url> {
|
||||||
|
let url = parse_http_url(value)?;
|
||||||
|
(url.path() == "/").then_some(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_http_url(value: &str) -> Option<Url> {
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -248,7 +248,7 @@ pub(super) fn auth_verification_send_cooldown_seconds() -> i64 {
|
|||||||
.unwrap_or(60)
|
.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")
|
std::env::var("AUTH_REFRESH_COOKIE_NAME")
|
||||||
.ok()
|
.ok()
|
||||||
.map(|value| value.trim().to_string())
|
.map(|value| value.trim().to_string())
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use aether_data::repository::global_models::InMemoryGlobalModelReadRepository;
|
|||||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||||
use aether_data::repository::proxy_nodes::InMemoryProxyNodeRepository;
|
use aether_data::repository::proxy_nodes::InMemoryProxyNodeRepository;
|
||||||
use aether_data_contracts::repository::global_models::{
|
use aether_data_contracts::repository::global_models::{
|
||||||
AdminProviderModelListQuery, GlobalModelReadRepository,
|
AdminGlobalModelListQuery, AdminProviderModelListQuery, GlobalModelReadRepository,
|
||||||
};
|
};
|
||||||
use aether_data_contracts::repository::provider_catalog::{
|
use aether_data_contracts::repository::provider_catalog::{
|
||||||
ProviderCatalogReadRepository, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
ProviderCatalogReadRepository, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||||
@@ -20,8 +20,9 @@ use http::StatusCode;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use super::super::super::{
|
use super::super::super::{
|
||||||
build_router_with_state, build_state_with_execution_runtime_override, sample_bound_auth_config,
|
build_router_with_state, build_state_with_execution_runtime_override, sample_admin_global_model,
|
||||||
sample_bound_key, sample_endpoint, sample_key, sample_proxy_node, start_server, AppState,
|
sample_bound_auth_config, sample_bound_key, sample_endpoint, sample_key, sample_proxy_node,
|
||||||
|
start_server, AppState,
|
||||||
};
|
};
|
||||||
use crate::constants::{
|
use crate::constants::{
|
||||||
GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
|
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],
|
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 (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).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")),
|
.and_then(|value| value.get("remaining_fraction")),
|
||||||
Some(&json!(0.25))
|
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 {
|
.list_admin_provider_models(&AdminProviderModelListQuery {
|
||||||
provider_id: "provider-antigravity".to_string(),
|
provider_id: "provider-antigravity".to_string(),
|
||||||
is_active: None,
|
is_active: None,
|
||||||
@@ -2541,15 +2559,8 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_antigravity_with_tru
|
|||||||
limit: 100,
|
limit: 100,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("imported Antigravity provider models should read");
|
.expect("Antigravity provider models should read after quota refresh");
|
||||||
let imported_model_names = imported_provider_models
|
assert!(provider_models.is_empty());
|
||||||
.iter()
|
|
||||||
.map(|model| model.provider_model_name.as_str())
|
|
||||||
.collect::<std::collections::BTreeSet<_>>();
|
|
||||||
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"));
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reloaded[0]
|
reloaded[0]
|
||||||
.upstream_metadata
|
.upstream_metadata
|
||||||
|
|||||||
@@ -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() {
|
fn gateway_names_new_antigravity_oauth_account_from_google_userinfo_email() {
|
||||||
run_admin_oauth_test(
|
run_admin_oauth_test(
|
||||||
"gateway_names_new_antigravity_oauth_account_from_google_userinfo_email",
|
"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 = Arc::new(Mutex::new(0usize));
|
||||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||||
let upstream = Router::new().fallback(any(move |_request: Request| {
|
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()
|
let response = reqwest::Client::new()
|
||||||
.post(format!(
|
.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(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||||
.json(&json!({
|
.json(&request_body)
|
||||||
"callback_url": "http://localhost:51121/oauth2callback?code=antigravity-code-123&state=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
|
||||||
}))
|
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("request should succeed");
|
.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 status = response.status();
|
||||||
let payload: Value = response.json().await.expect("json body should parse");
|
let payload: Value = response.json().await.expect("json body should parse");
|
||||||
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
||||||
assert_eq!(payload["provider_type"], "antigravity");
|
let account_result = if operation == "batch-import" {
|
||||||
assert_eq!(payload["email"], "new-antigravity@example.com");
|
assert_eq!(payload["total"], 1);
|
||||||
assert_eq!(payload["replaced"], false);
|
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!(*token_hits.lock().expect("mutex should lock"), 1);
|
||||||
assert_eq!(*user_info_hits.lock().expect("mutex should lock"), 1);
|
assert_eq!(*user_info_hits.lock().expect("mutex should lock"), 1);
|
||||||
assert_eq!(
|
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);
|
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()
|
.as_str()
|
||||||
.expect("created key id should be returned")
|
.expect("created key id should be returned")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ use chrono::{TimeZone, Utc};
|
|||||||
const TEST_EMAIL_VERIFICATION_TOKEN: &str =
|
const TEST_EMAIL_VERIFICATION_TOKEN: &str =
|
||||||
"test-email-verification-token-00000000000000000000000000000000";
|
"test-email-verification-token-00000000000000000000000000000000";
|
||||||
|
|
||||||
|
#[path = "public_support/auth_cookie.rs"]
|
||||||
|
mod auth_cookie;
|
||||||
#[path = "public_support/dashboard.rs"]
|
#[path = "public_support/dashboard.rs"]
|
||||||
mod dashboard;
|
mod dashboard;
|
||||||
#[path = "public_support/vscodex.rs"]
|
#[path = "public_support/vscodex.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ pub fn build_kiro_batch_import_key_name(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|byte| format!("{byte:02x}"))
|
.map(|byte| format!("{byte:02x}"))
|
||||||
.collect::<String>();
|
.collect::<String>();
|
||||||
format!("kiro_{}", &hex[..6])
|
format!("账号_{}", &hex[..6])
|
||||||
});
|
});
|
||||||
format!("{base} ({method})")
|
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 }))
|
.map(|refresh_token| json!({ "refreshToken": refresh_token }))
|
||||||
.collect()
|
.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)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -405,18 +405,40 @@ pub fn build_kiro_device_key_name(email: Option<&str>, refresh_token: Option<&st
|
|||||||
.collect::<String>()
|
.collect::<String>()
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
format!("kiro_{fallback} (idc)")
|
format!("账号_{fallback} (idc)")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
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,
|
parse_provider_oauth_callback_params, MAX_UNVERIFIED_JWT_CLAIMS_BYTES,
|
||||||
};
|
};
|
||||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||||
use serde_json::json;
|
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 {
|
fn sample_unsigned_jwt(payload: serde_json::Value) -> String {
|
||||||
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
|
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
|
||||||
let payload = URL_SAFE_NO_PAD.encode(payload.to_string());
|
let payload = URL_SAFE_NO_PAD.encode(payload.to_string());
|
||||||
|
|||||||
@@ -179,7 +179,8 @@ impl ProviderOAuthAdapter for AntigravityProviderOAuthAdapter {
|
|||||||
ctx: &crate::provider::ProviderOAuthTransportContext,
|
ctx: &crate::provider::ProviderOAuthTransportContext,
|
||||||
input: crate::provider::ProviderOAuthImportInput,
|
input: crate::provider::ProviderOAuthImportInput,
|
||||||
) -> Result<crate::provider::ProviderOAuthTokenSet, crate::core::OAuthError> {
|
) -> Result<crate::provider::ProviderOAuthTokenSet, crate::core::OAuthError> {
|
||||||
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(
|
async fn refresh(
|
||||||
@@ -223,10 +224,11 @@ mod tests {
|
|||||||
use super::{AntigravityProviderOAuthAdapter, ANTIGRAVITY_USER_INFO_URL};
|
use super::{AntigravityProviderOAuthAdapter, ANTIGRAVITY_USER_INFO_URL};
|
||||||
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse};
|
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse};
|
||||||
use crate::provider::{
|
use crate::provider::{
|
||||||
ProviderOAuthAccount, ProviderOAuthAdapter, ProviderOAuthTransportContext,
|
ProviderOAuthAccount, ProviderOAuthAdapter, ProviderOAuthImportInput,
|
||||||
|
ProviderOAuthTransportContext,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::json;
|
use serde_json::{json, Value};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
@@ -235,6 +237,8 @@ mod tests {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct GoogleOAuthExecutor {
|
struct GoogleOAuthExecutor {
|
||||||
requests: Mutex<Vec<OAuthHttpRequest>>,
|
requests: Mutex<Vec<OAuthHttpRequest>>,
|
||||||
|
token_payload: Option<Value>,
|
||||||
|
user_info_response: Option<OAuthHttpResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn transport_context() -> ProviderOAuthTransportContext {
|
fn transport_context() -> ProviderOAuthTransportContext {
|
||||||
@@ -275,26 +279,36 @@ mod tests {
|
|||||||
.expect("requests should lock")
|
.expect("requests should lock")
|
||||||
.push(request);
|
.push(request);
|
||||||
match request_id.as_str() {
|
match request_id.as_str() {
|
||||||
"provider-oauth:exchange-code" => Ok(OAuthHttpResponse {
|
"provider-oauth:exchange-code" | "provider-oauth:refresh-token" => {
|
||||||
status_code: 200,
|
Ok(OAuthHttpResponse {
|
||||||
body_text: json!({
|
status_code: 200,
|
||||||
"access_token": "google-access-token",
|
body_text: self
|
||||||
"refresh_token": "google-refresh-token",
|
.token_payload
|
||||||
"token_type": "Bearer",
|
.clone()
|
||||||
"expires_in": 3600
|
.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(self
|
||||||
}),
|
.user_info_response
|
||||||
"provider-oauth:antigravity-user-info" => Ok(OAuthHttpResponse {
|
.clone()
|
||||||
status_code: 200,
|
.unwrap_or_else(|| OAuthHttpResponse {
|
||||||
body_text: json!({
|
status_code: 200,
|
||||||
"email": "antigravity@example.com",
|
body_text: json!({
|
||||||
"verified_email": true
|
"email": "antigravity@example.com",
|
||||||
})
|
"verified_email": true
|
||||||
.to_string(),
|
})
|
||||||
json_body: None,
|
.to_string(),
|
||||||
}),
|
json_body: None,
|
||||||
|
})),
|
||||||
other => panic!("unexpected OAuth request: {other}"),
|
other => panic!("unexpected OAuth request: {other}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -365,6 +379,133 @@ mod tests {
|
|||||||
assert_eq!(requests[1].network, ctx.network);
|
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]
|
#[tokio::test]
|
||||||
async fn antigravity_probe_marks_forbidden_metadata_invalid() {
|
async fn antigravity_probe_marks_forbidden_metadata_invalid() {
|
||||||
let adapter = AntigravityProviderOAuthAdapter::default();
|
let adapter = AntigravityProviderOAuthAdapter::default();
|
||||||
|
|||||||
@@ -169,11 +169,6 @@ function getDisplayHtml(line: JsonDisplayLine): string {
|
|||||||
|
|
||||||
function toggleFold(line: JsonDisplayLine, index: number) {
|
function toggleFold(line: JsonDisplayLine, index: number) {
|
||||||
const overrides = new Map(foldOverrides.value)
|
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)
|
overrides.set(line.id, !line.collapsed)
|
||||||
foldOverrides.value = overrides
|
foldOverrides.value = overrides
|
||||||
localReader = undefined
|
localReader = undefined
|
||||||
|
|||||||
@@ -10,23 +10,46 @@ const apps: Array<{ app: App, root: HTMLElement }> = []
|
|||||||
afterEach(() => { for (const { app, root } of apps.splice(0)) { app.unmount(); root.remove() } })
|
afterEach(() => { for (const { app, root } of apps.splice(0)) { app.unmount(); root.remove() } })
|
||||||
|
|
||||||
function mountBody(value: unknown) {
|
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 json = vi.fn(async (options: BodyJsonOptions) => engine.json(options))
|
||||||
const bodyDocument = shallowRef({ json } as unknown as BodyDocument)
|
const bodyDocument = shallowRef({ json } as unknown as BodyDocument)
|
||||||
|
const expandDepth = shallowRef(999)
|
||||||
const errors: unknown[] = []
|
const errors: unknown[] = []
|
||||||
const root = document.createElement('div')
|
const root = document.createElement('div')
|
||||||
document.body.appendChild(root)
|
document.body.appendChild(root)
|
||||||
const app = createApp(defineComponent({ setup: () => () => h(JsonContent, {
|
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),
|
onLoadError: (error: unknown) => errors.push(error),
|
||||||
}) }))
|
}) }))
|
||||||
app.mount(root)
|
app.mount(root)
|
||||||
apps.push({ app, 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))! }
|
function button(root: HTMLElement, label: string) { return [...root.querySelectorAll('button')].find(button => button.textContent?.includes(label))! }
|
||||||
|
|
||||||
describe('worker-backed body views', () => {
|
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<HTMLButtonElement>('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 () => {
|
it('automatically reads worker chunks while scrolling and preserves the complete copy', async () => {
|
||||||
const values = Array.from({ length: 1000 }, (_value, index) => `value-${index}`)
|
const values = Array.from({ length: 1000 }, (_value, index) => `value-${index}`)
|
||||||
const { root, json, engine } = mountBody(values)
|
const { root, json, engine } = mountBody(values)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { createApp, defineComponent, h, shallowRef, type App } from 'vue'
|
import { createApp, defineComponent, h, shallowRef, type App } from 'vue'
|
||||||
import JsonContent from '../RequestDetailDrawer/JsonContent.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'
|
import { JSON_PAGE_SIZE, JSON_SCROLL_CHUNK_SIZE, JSON_TEXT_CHUNK_SIZE } from '../../utils/json-viewer'
|
||||||
|
|
||||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||||
@@ -48,15 +49,57 @@ describe('JsonContent lazy rendering', () => {
|
|||||||
expect(root.querySelector<HTMLElement>('.virtual-body-scroll')?.scrollTop).toBe(0)
|
expect(root.querySelector<HTMLElement>('.virtual-body-scroll')?.scrollTop).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens the complete subtree with one bracket click', async () => {
|
it('opens one layer per bracket click after collapsing all descendants', async () => {
|
||||||
const { root } = mountJson({ messages: [{ content: 'hidden content' }] }, 0)
|
const { root } = mountJson({ messages: [{ content: { text: 'hidden content' } }] }, 0)
|
||||||
await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(3))
|
await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(3))
|
||||||
expect(root.textContent).not.toContain('hidden content')
|
for (const lineCount of [5, 7, 9]) {
|
||||||
root.querySelector<HTMLButtonElement>('button[aria-label="展开节点"]')!.click()
|
expect(root.textContent).not.toContain('hidden content')
|
||||||
|
root.querySelector<HTMLElement>('.line-content.clickable-collapsed')!.click()
|
||||||
|
await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(lineCount))
|
||||||
|
}
|
||||||
await vi.waitFor(() => expect(root.textContent).toContain('hidden content'))
|
await vi.waitFor(() => expect(root.textContent).toContain('hidden content'))
|
||||||
expect(root.querySelector('button[aria-label="展开节点"]')).toBeNull()
|
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<HTMLButtonElement>('button[title="展开全部"]')!.click()
|
||||||
|
await vi.waitFor(() => expect(root.textContent).toContain('deep content'))
|
||||||
|
expect(root.querySelector('button[aria-label="展开节点"]')).toBeNull()
|
||||||
|
root.querySelector<HTMLButtonElement>('button[title="收缩全部"]')!.click()
|
||||||
|
await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(3))
|
||||||
|
for (const lineCount of [5, 7]) {
|
||||||
|
root.querySelector<HTMLButtonElement>('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<HTMLButtonElement>('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 () => {
|
it('shows complete long strings without an extra expansion button', async () => {
|
||||||
const content = `${'x'.repeat(JSON_TEXT_CHUNK_SIZE * 10) }末尾🙂`
|
const content = `${'x'.repeat(JSON_TEXT_CHUNK_SIZE * 10) }末尾🙂`
|
||||||
const { root, data } = mountJson({ content })
|
const { root, data } = mountJson({ content })
|
||||||
|
|||||||
@@ -65,11 +65,34 @@ describe('lazy JSON pages', () => {
|
|||||||
expect(reader.read(21).lines).toEqual([])
|
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 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<string, boolean>()
|
||||||
|
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)
|
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)
|
expect(folded.lines.some(line => line.value === 'complete content')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ function* splitJsonLine(line: JsonTreeLine, chunkSize: number): Generator<JsonTr
|
|||||||
function* walkJsonLines(data: unknown, options: JsonPageOptions): Generator<JsonDisplayLine> {
|
function* walkJsonLines(data: unknown, options: JsonPageOptions): Generator<JsonDisplayLine> {
|
||||||
const depth = options.expandDepth === 0 || options.expandDepth == null ? 1 : options.expandDepth
|
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<JsonTreeLine> {
|
function* walk(value: unknown, path: string, indent: number, comma: string, key?: string): Generator<JsonTreeLine> {
|
||||||
if (value == null || typeof value !== 'object') {
|
if (value == null || typeof value !== 'object') {
|
||||||
yield { id: path, indent, key, value, comma, canFold: false, collapsed: false }
|
yield { id: path, indent, key, value, comma, canFold: false, collapsed: false }
|
||||||
return
|
return
|
||||||
@@ -140,8 +140,7 @@ function* walkJsonLines(data: unknown, options: JsonPageOptions): Generator<Json
|
|||||||
const keys = isArray ? [] : Object.keys(value)
|
const keys = isArray ? [] : Object.keys(value)
|
||||||
const childCount = isArray ? value.length : keys.length
|
const childCount = isArray ? value.length : keys.length
|
||||||
const override = options.foldOverrides?.get(path)
|
const override = options.foldOverrides?.get(path)
|
||||||
const expandedSubtree = expandedParent || override === false
|
const collapsed = childCount === 0 || (override ?? (indent >= depth))
|
||||||
const collapsed = childCount === 0 || (override ?? (!expandedSubtree && indent >= depth))
|
|
||||||
const bracket = isArray ? '[' : '{'
|
const bracket = isArray ? '[' : '{'
|
||||||
const closingBracket = isArray ? ']' : '}'
|
const closingBracket = isArray ? ']' : '}'
|
||||||
yield { id: path, indent, key, comma, bracket, closingBracket, childCount, isArray, collapsed, canFold: childCount > 0 }
|
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<Json
|
|||||||
const childKey = isArray ? String(index) : keys[index]
|
const childKey = isArray ? String(index) : keys[index]
|
||||||
const childPath = `${path}/${options.indexPaths ? index : childKey.replace(/~/g, '~0').replace(/\//g, '~1')}`
|
const childPath = `${path}/${options.indexPaths ? index : childKey.replace(/~/g, '~0').replace(/\//g, '~1')}`
|
||||||
const childValue = isArray ? value[index] : (value as Record<string, unknown>)[childKey]
|
const childValue = isArray ? value[index] : (value as Record<string, unknown>)[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 }
|
yield { id: `close:${path}`, indent, bracket: closingBracket, comma, canFold: false, collapsed: false }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,85 @@ describe('getCodexQuotaWindowPresentation', () => {
|
|||||||
expect(windows.sort((a, b) => a.sortOrder - b.sortOrder).map(item => item.label)).toEqual(['5H', '周'])
|
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', () => {
|
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: 10_080 })).toBe('周限额')
|
||||||
expect(getCodexQuotaWindowLimitLabel({ code: 'weekly', window_minutes: 43_800 })).toBe('月限额')
|
expect(getCodexQuotaWindowLimitLabel({ code: 'weekly', window_minutes: 43_800 })).toBe('月限额')
|
||||||
|
|||||||
@@ -71,6 +71,37 @@ describe('providerKeyQuota', () => {
|
|||||||
}, 'codex')).toBe('月剩余 86.0%')
|
}, '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', () => {
|
it('formats Grok account quota from structured quota windows', () => {
|
||||||
expect(getQuotaDisplayText({
|
expect(getQuotaDisplayText({
|
||||||
status_snapshot: {
|
status_snapshot: {
|
||||||
|
|||||||
@@ -41,7 +41,13 @@ export function getCodexQuotaWindowPresentation(
|
|||||||
const code = String(window.code || '').trim().toLowerCase()
|
const code = String(window.code || '').trim().toLowerCase()
|
||||||
const isSpark = code.startsWith('spark_')
|
const isSpark = code.startsWith('spark_')
|
||||||
const baseCode = isSpark ? code.slice('spark_'.length) : code
|
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 hasExplicitWindowMinutes = window.window_minutes != null
|
||||||
const windowMinutes = Number(window.window_minutes)
|
const windowMinutes = Number(window.window_minutes)
|
||||||
|
|
||||||
@@ -51,12 +57,19 @@ export function getCodexQuotaWindowPresentation(
|
|||||||
|
|
||||||
const period = hasExplicitWindowMinutes
|
const period = hasExplicitWindowMinutes
|
||||||
? formatCodexQuotaPeriod(windowMinutes)
|
? formatCodexQuotaPeriod(windowMinutes)
|
||||||
: getLegacyCodexQuotaPeriod(baseCode, rawLabel)
|
: getLegacyCodexQuotaPeriod(baseCode, legacyLabel)
|
||||||
if (!period) return null
|
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
|
const fallbackOrder = baseCode === '5h' ? 300 : baseCode === 'weekly' ? 10_080 : 1_000_000
|
||||||
return {
|
return {
|
||||||
label: isSpark ? `Spark${period}` : period,
|
label,
|
||||||
sortOrder: (isSpark ? 10_000_000 : 0) + (hasExplicitWindowMinutes ? windowMinutes : fallbackOrder),
|
sortOrder: (isSpark ? 10_000_000 : 0) + (hasExplicitWindowMinutes ? windowMinutes : fallbackOrder),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -768,6 +768,60 @@ describe('PoolManagement Codex cycle stats mode', () => {
|
|||||||
expect(root.querySelector('[data-testid="pool-stats-cycle-single-marker"]')).toBeNull()
|
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 () => {
|
it('opens only one score popover across desktop and mobile layouts', async () => {
|
||||||
const scoredKey = createPoolKey('codex', {
|
const scoredKey = createPoolKey('codex', {
|
||||||
pool_score: {
|
pool_score: {
|
||||||
|
|||||||
Reference in New Issue
Block a user