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
|
||||
# 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
|
||||
|
||||
|
||||
@@ -1463,7 +1463,7 @@ mod tests {
|
||||
&auth_config,
|
||||
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::{
|
||||
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::<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> {
|
||||
@@ -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(),
|
||||
|
||||
@@ -52,13 +52,12 @@ pub(super) fn admin_provider_oauth_key_name_from_auth_config(
|
||||
auth_config: &Map<String, Value>,
|
||||
batch_index: Option<usize>,
|
||||
) -> 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::<u64>().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]
|
||||
|
||||
@@ -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<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(
|
||||
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 {
|
||||
|
||||
@@ -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<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>> {
|
||||
let decision = request_context.control_decision.as_ref()?;
|
||||
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)
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
@@ -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::<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"));
|
||||
.expect("Antigravity provider models should read after quota refresh");
|
||||
assert!(provider_models.is_empty());
|
||||
assert_eq!(
|
||||
reloaded[0]
|
||||
.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() {
|
||||
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();
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,18 +405,40 @@ pub fn build_kiro_device_key_name(email: Option<&str>, refresh_token: Option<&st
|
||||
.collect::<String>()
|
||||
})
|
||||
.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());
|
||||
|
||||
@@ -179,7 +179,8 @@ impl ProviderOAuthAdapter for AntigravityProviderOAuthAdapter {
|
||||
ctx: &crate::provider::ProviderOAuthTransportContext,
|
||||
input: crate::provider::ProviderOAuthImportInput,
|
||||
) -> 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(
|
||||
@@ -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<Vec<OAuthHttpRequest>>,
|
||||
token_payload: Option<Value>,
|
||||
user_info_response: Option<OAuthHttpResponse>,
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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 () => {
|
||||
const values = Array.from({ length: 1000 }, (_value, index) => `value-${index}`)
|
||||
const { root, json, engine } = mountBody(values)
|
||||
|
||||
@@ -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<HTMLElement>('.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<HTMLButtonElement>('button[aria-label="展开节点"]')!.click()
|
||||
for (const lineCount of [5, 7, 9]) {
|
||||
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'))
|
||||
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 () => {
|
||||
const content = `${'x'.repeat(JSON_TEXT_CHUNK_SIZE * 10) }末尾🙂`
|
||||
const { root, data } = mountJson({ content })
|
||||
|
||||
@@ -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<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)
|
||||
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)
|
||||
})
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ function* splitJsonLine(line: JsonTreeLine, chunkSize: number): Generator<JsonTr
|
||||
function* walkJsonLines(data: unknown, options: JsonPageOptions): Generator<JsonDisplayLine> {
|
||||
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') {
|
||||
yield { id: path, indent, key, value, comma, canFold: false, collapsed: false }
|
||||
return
|
||||
@@ -140,8 +140,7 @@ function* walkJsonLines(data: unknown, options: JsonPageOptions): Generator<Json
|
||||
const keys = isArray ? [] : Object.keys(value)
|
||||
const childCount = isArray ? value.length : keys.length
|
||||
const override = options.foldOverrides?.get(path)
|
||||
const expandedSubtree = expandedParent || override === false
|
||||
const collapsed = childCount === 0 || (override ?? (!expandedSubtree && indent >= 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<Json
|
||||
const childKey = isArray ? String(index) : keys[index]
|
||||
const childPath = `${path}/${options.indexPaths ? index : childKey.replace(/~/g, '~0').replace(/\//g, '~1')}`
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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('月限额')
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user