From 14f96c9fa01f30784216d79717a5bab2bc778321 Mon Sep 17 00:00:00 2001 From: elky Date: Mon, 7 Sep 2026 08:53:41 +0800 Subject: [PATCH] fix(providers): preserve health in redacted key summaries --- apps/aether-gateway/src/state/catalog.rs | 6 +- .../src/state/catalog_credentials.rs | 44 +++++++++++++ .../src/tests/control/admin/providers.rs | 62 +++++++++++++++++++ docs/api/provider-health-summary.md | 9 +++ 4 files changed, 117 insertions(+), 4 deletions(-) diff --git a/apps/aether-gateway/src/state/catalog.rs b/apps/aether-gateway/src/state/catalog.rs index 025329c9b..6349545b0 100644 --- a/apps/aether-gateway/src/state/catalog.rs +++ b/apps/aether-gateway/src/state/catalog.rs @@ -533,12 +533,10 @@ impl AppState { &self, provider_ids: &[String], ) -> Result, GatewayError> { - let keys = self - .data + self.data .list_provider_catalog_key_summaries_by_provider_ids(provider_ids) .await - .map_err(|err| GatewayError::Internal(err.to_string()))?; - self.open_provider_catalog_keys(keys).await + .map_err(|err| GatewayError::Internal(err.to_string())) } pub(crate) async fn list_provider_catalog_key_maintenance_summaries_by_provider_ids( diff --git a/apps/aether-gateway/src/state/catalog_credentials.rs b/apps/aether-gateway/src/state/catalog_credentials.rs index 462d901a5..9d240c3ad 100644 --- a/apps/aether-gateway/src/state/catalog_credentials.rs +++ b/apps/aether-gateway/src/state/catalog_credentials.rs @@ -272,6 +272,50 @@ mod tests { ) } + #[tokio::test] + async fn app_state_reads_redacted_key_summaries_without_opening_credentials() { + for (api_key, auth_config) in [ + (Some("summary"), None), + (None, Some("{}")), + (Some("summary"), Some("{}")), + ] { + let health = serde_json::json!({"openai:chat": {"health_score": 0.75}}); + let key = sample_key( + "key-1", + "provider-1", + api_key.map(ToOwned::to_owned), + auth_config.map(ToOwned::to_owned), + ) + .with_health_fields(Some(health.clone()), None); + let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed( + vec![sample_provider("provider-1")], + Vec::new(), + vec![key], + )); + let state = AppState::new() + .expect("test state should build") + .with_data_state_for_tests( + GatewayDataState::with_provider_catalog_reader_for_tests(repository) + .with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY), + ); + let provider_ids = ["provider-1".to_string()]; + + let summaries = state + .list_provider_catalog_key_summaries_by_provider_ids(&provider_ids) + .await + .expect("redacted summaries should not require credential authentication"); + + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].health_by_format.as_ref(), Some(&health)); + assert_eq!(summaries[0].encrypted_api_key.as_deref(), api_key); + assert_eq!(summaries[0].encrypted_auth_config.as_deref(), auth_config); + assert!(state + .list_provider_catalog_keys_by_provider_ids(&provider_ids) + .await + .is_err()); + } + } + #[tokio::test] async fn app_state_migrates_both_legacy_fields_with_one_exact_cas() { let legacy_api = diff --git a/apps/aether-gateway/src/tests/control/admin/providers.rs b/apps/aether-gateway/src/tests/control/admin/providers.rs index c63ed351d..417d006ec 100644 --- a/apps/aether-gateway/src/tests/control/admin/providers.rs +++ b/apps/aether-gateway/src/tests/control/admin/providers.rs @@ -70,6 +70,68 @@ async fn provider_health_summary( payload["items"][0].clone() } +#[tokio::test] +async fn admin_provider_summary_health_preserves_redacted_key_summaries() { + let endpoint = sample_endpoint( + "endpoint-chat", + "provider-openai", + "openai:chat", + "https://api.openai.example", + ); + let keys = [ + ("key-api", "api_key", None, 0.25), + ("key-oauth", "oauth", Some("{}"), 0.75), + ] + .into_iter() + .map(|(key_id, auth_type, auth_config, score)| { + let mut key = sample_key(key_id, "provider-openai", "openai:chat", "test") + .with_health_fields( + Some(json!({"openai:chat": {"health_score": score}})), + None, + ); + key.auth_type = auth_type.to_string(); + key.encrypted_api_key = Some("summary".to_string()); + key.encrypted_auth_config = auth_config.map(ToOwned::to_owned); + key + }) + .collect(); + let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed( + vec![sample_provider("provider-openai", "openai", 10)], + vec![endpoint], + keys, + )); + let state = AppState::new() + .expect("gateway should build") + .with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests( + repository, + )); + + for uri in [ + "/api/admin/providers/summary", + "/api/admin/providers/provider-openai/summary", + ] { + let response = local_admin_providers_response(&state, http::Method::GET, uri, None).await; + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("summary body should read"); + let payload: serde_json::Value = serde_json::from_slice(&body).expect("summary should parse"); + let summary = if uri == "/api/admin/providers/summary" { + &payload["items"][0] + } else { + &payload + }; + + assert_eq!(summary["total_keys"], 2); + assert_eq!(summary["active_keys"], 2); + assert_eq!(summary["endpoint_health_details"][0]["total_keys"], 2); + assert_eq!(summary["endpoint_health_details"][0]["active_keys"], 2); + assert_eq!(summary["endpoint_health_details"][0]["health_score"], 0.5); + assert_eq!(summary["avg_health_score"], 0.5); + assert_eq!(summary["unhealthy_endpoints"], 0); + } +} + #[tokio::test] async fn admin_provider_summary_health_ignores_disabled_keys() { let endpoint = sample_endpoint( diff --git a/docs/api/provider-health-summary.md b/docs/api/provider-health-summary.md index 1c103c099..9c104a624 100644 --- a/docs/api/provider-health-summary.md +++ b/docs/api/provider-health-summary.md @@ -17,6 +17,15 @@ `total_keys` 和 `active_keys` 仍反映密钥配置数量,不因缺少健康数据而减少。 +## 数据读取 + +摘要从密钥的轻量投影读取 API 格式、启用状态和 `health_by_format`。 +PostgreSQL 投影中的凭据字段使用 `summary` / `{}` 等脱敏占位值,并非真实密文, +因此摘要读取不执行凭据解密、认证或迁移。完整密钥读取仍保留原有的凭据安全校验。 + +若将这些占位值送入凭据校验,读取会失败,并被摘要聚合当作空密钥列表, +导致已配置密钥的端点也被错误显示为灰色;不能通过给缺失分数默认填 `100%` 来修复。 + ## 页面展示 桌面表格和手机卡片使用相同规则: