Files
Cyrene_v2/internal/services/gateway/admin_config_facts_test.go
T
AskaEth 34cef95e17 feat(gateway): 配置只读事实面 + 后台思考行「档位/穿透」(契约 v0.68)
- 新增 GET /api/v1/admin/config-facts:记忆整理 7 键 + 安静时段 2 键的**进程生效值** + 声明默认值(反射读 default 标签,不读 .env)
- 面板:记忆整理卡 + 安静时段卡(Miuix 只读),文案按新语义「只服务主动搭话,不静默回复」
- 后台思考行加 urgency(**只能是下限**,存档不可分 high/critical)+ quietHoursBypassed(与既有 bypassed 是两件事)
- 红线实测:往响应里塞 JWT ⇒ TestAdminConfigFacts_NeverLeaksSecretValues 打红(连断言消息都把整个响应打出来了)
- 前端:configFacts 24 checks / ConfigFactsCards 29 / BackthinkUrgencyTags 19 全过
- 契约 v0.68(纯增量:B12 新端点 + B8 两字段 + §4.10 形状与语义 + §7.4-17 下限限定)
2026-10-05 13:54:56 +08:00

313 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// 本文件是 P3「配置只读事实面」的测试。四条被钉住的判据:
// ① 安静时段的**值** = 进程生效值(不是常量、不是模板默认);
// ② 「未配置/默认」如实呈现(value + default + isDefault 三者合起来不伪造事实);
// ③ 响应里**不含任何密钥/令牌值**(红线,用哨兵值钉死);
// ④ 键名单 = `.env.example`(键名单的权威来源,协同 E6 的固定动作)。
// getConfigFacts 调用管理端点并返回状态码、原始响应体与解码结果。
func getConfigFacts(t *testing.T, cfg *config.Config, tok string) (int, []byte, AdminConfigFacts) {
t.Helper()
_, ts, jwt := buildEnv(t, cfg, Deps{})
token := tok
if token == "" {
token = signAny(t, jwt, "acct", auth.RoleOwner)
}
resp, raw := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/config-facts", nil, token)
var out struct {
Data AdminConfigFacts `json:"data"`
}
if resp.StatusCode == http.StatusOK {
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
}
return resp.StatusCode, raw, out.Data
}
// factByKey 取出某键的事实(不存在 ⇒ 测试失败)。
func factByKey(t *testing.T, facts []AdminConfigFact, key string) AdminConfigFact {
t.Helper()
for _, f := range facts {
if f.Key == key {
return f
}
}
t.Fatalf("fact %s missing from %+v", key, facts)
return AdminConfigFact{}
}
// quietHoursCfg 构造一个「安静时段 = 01:00-05:00、记忆整理阈值被改过」的生效配置:
// 值全部来自配置字段,因此它模拟的正是 `config.Load` 解析后的样子。
func quietHoursCfg() *config.Config {
cfg := cfgDefault()
cfg.Background.QuietStartHour = 1
cfg.Background.QuietEndHour = 5
cfg.MemoryTidy = config.MemoryTidyConfig{
Enabled: false,
IntervalSec: 3600,
QuietStartHour: 2,
QuietEndHour: 6,
RecallThreshold: 0.7,
ScanLimit: 10,
CandidateLimit: 3,
}
return cfg
}
// TestAdminConfigFacts_QuietHoursAreProcessEffective 是判据 ①:端点回的是
// **进程生效值**(config 字段),而不是模板默认值、更不是写死的常量。
func TestAdminConfigFacts_QuietHoursAreProcessEffective(t *testing.T) {
t.Parallel()
status, _, facts := getConfigFacts(t, quietHoursCfg(), "")
if status != http.StatusOK {
t.Fatalf("status = %d", status)
}
if facts.QuietHours.StartHour != 1 || facts.QuietHours.EndHour != 5 {
t.Fatalf("quietHours = %+v, want 1..5(进程生效值)", facts.QuietHours)
}
if facts.QuietHours.Disabled {
t.Fatalf("quietHours 1..5 不应被判定为关闭:%+v", facts.QuietHours)
}
if facts.QuietHours.Label != "01:00-05:00" {
t.Fatalf("label = %q, want 01:00-05:00", facts.QuietHours.Label)
}
for key, want := range map[string]string{
"QUIET_START_HOUR": "1",
"QUIET_END_HOUR": "5",
"MEMORY_TIDY_QUIET_START_HOUR": "2",
"MEMORY_TIDY_QUIET_END_HOUR": "6",
"MEMORY_TIDY_ENABLED": "false",
"MEMORY_TIDY_INTERVAL_SEC": "3600",
"MEMORY_TIDY_RECALL_THRESHOLD": "0.7",
"MEMORY_TIDY_SCAN_LIMIT": "10",
"MEMORY_TIDY_CANDIDATE_LIMIT": "3",
} {
if got := factByKey(t, facts.Facts, key).Value; got != want {
t.Fatalf("%s value = %q, want %q", key, got, want)
}
}
// 被改过的键必须标成「非默认」,面板才知道它不是默认值。
if factByKey(t, facts.Facts, "QUIET_START_HOUR").IsDefault {
t.Fatal("QUIET_START_HOUR=1 与声明默认 23 不同,isDefault 必须为 false")
}
}
// TestAdminConfigFacts_DeclaredDefaultsAndDefaultFlag 是判据 ②:每条事实都带
// **声明默认值**(default 标签,非空),且当生效值等于它时 isDefault=true ——
// 面板因此能如实显示「默认」,而不是把默认值当成「用户配过的值」。
func TestAdminConfigFacts_DeclaredDefaultsAndDefaultFlag(t *testing.T) {
t.Parallel()
defaults := map[string]string{
"MEMORY_TIDY_ENABLED": "true",
"MEMORY_TIDY_INTERVAL_SEC": "86400",
"MEMORY_TIDY_QUIET_START_HOUR": "23",
"MEMORY_TIDY_QUIET_END_HOUR": "7",
"MEMORY_TIDY_RECALL_THRESHOLD": "0.8",
"MEMORY_TIDY_SCAN_LIMIT": "5000",
"MEMORY_TIDY_CANDIDATE_LIMIT": "20",
"QUIET_START_HOUR": "23",
"QUIET_END_HOUR": "7",
}
defaultCfg := &config.Config{
MemoryTidy: config.MemoryTidyConfig{
Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7,
RecallThreshold: 0.8, ScanLimit: 5000, CandidateLimit: 20,
},
Background: config.BackgroundConfig{QuietStartHour: 23, QuietEndHour: 7},
}
status, _, facts := getConfigFacts(t, defaultCfg, "")
if status != http.StatusOK {
t.Fatalf("status = %d", status)
}
if len(facts.Facts) != len(defaults) {
t.Fatalf("facts = %d, want %d", len(facts.Facts), len(defaults))
}
for key, want := range defaults {
got := factByKey(t, facts.Facts, key)
if got.Default == "" {
t.Fatalf("%s 没有声明默认值:default 标签与事实表漂移了", key)
}
if got.Default != want {
t.Fatalf("%s default = %q, want %q(字面量钉桩)", key, got.Default, want)
}
if !got.IsDefault || got.Value != want {
t.Fatalf("%s 生效值等于默认值时必须 isDefault=true 且 value=%q:%+v", key, want, got)
}
}
// 「已改」的呈现:改一个键,只有它掉出默认。
changed := defaultCfg
changed.MemoryTidy.RecallThreshold = 0.55
_, _, after := getConfigFacts(t, changed, "")
got := factByKey(t, after.Facts, "MEMORY_TIDY_RECALL_THRESHOLD")
if got.Value != "0.55" || got.IsDefault {
t.Fatalf("改过的键必须如实报 value=0.55 且 isDefault=false:%+v", got)
}
if !factByKey(t, after.Facts, "QUIET_END_HOUR").IsDefault {
t.Fatal("未改的键不得被标成「已改」")
}
}
// TestAdminConfigFacts_QuietHoursWindowCanBeDisabled 钉住窗口关闭态的如实呈现
// (两个相等的**非 0** 钟点):面板必须能显示「已关闭」而不是一个 23:00-23:00 的假窗口。
func TestAdminConfigFacts_QuietHoursWindowCanBeDisabled(t *testing.T) {
t.Parallel()
cfg := cfgDefault()
cfg.Background.QuietStartHour = 23
cfg.Background.QuietEndHour = 23
_, _, facts := getConfigFacts(t, cfg, "")
if !facts.QuietHours.Disabled || facts.QuietHours.Label != quietHoursDisabledLabel {
t.Fatalf("两端相等的窗口必须报「已关闭」:%+v", facts.QuietHours)
}
}
// TestAdminConfigFacts_ZeroZeroQuietHoursFallsBackToDefault 钉住那个容易谎报的边角:
// 两端都写 0 **不是**「关闭窗口」,而是「未填」—— backthink(cfg.go 的 normalize)与
// 记忆整理都会回落到设计默认 23:00-07:00。事实面必须报**运行时真正生效**的窗口,否则
// 会把「未填」谎报成「已关闭」,进而漏报真实发生的穿透。
func TestAdminConfigFacts_ZeroZeroQuietHoursFallsBackToDefault(t *testing.T) {
t.Parallel()
cfg := cfgDefault()
cfg.Background.QuietStartHour = 0
cfg.Background.QuietEndHour = 0
_, _, facts := getConfigFacts(t, cfg, "")
if facts.QuietHours.Disabled {
t.Fatalf("0/0 是「未填」而不是「关闭」:%+v", facts.QuietHours)
}
if !facts.QuietHours.FallbackToDefault {
t.Fatalf("0/0 必须如实报告回落:%+v", facts.QuietHours)
}
if facts.QuietHours.StartHour != 23 || facts.QuietHours.EndHour != 7 || facts.QuietHours.Label != "23:00-07:00" {
t.Fatalf("回落后的生效窗口应为 23:00-07:00:%+v", facts.QuietHours)
}
// 事实行本身仍如实报「这个键解析出来的值」(0),不替用户改写配置。
if got := factByKey(t, facts.Facts, "QUIET_START_HOUR"); got.Value != "0" {
t.Fatalf("事实行的 value 必须是该键实际解析出的值:%+v", got)
}
}
// TestEffectiveQuietHours_MatchesBackthinkNormalize 钉住「生效窗口」是同一口径:
// 非 0 值原样、0/0 才回落。
func TestEffectiveQuietHours_MatchesBackthinkNormalize(t *testing.T) {
t.Parallel()
defaults := declaredEnvDefaults(&config.Config{})
cases := []struct {
name string
start, end int
wantS int
wantE int
wantFell bool
}{
{"常规窗口原样", 1, 5, 1, 5, false},
{"跨午夜原样", 23, 7, 23, 7, false},
{"相等非 0(关闭)原样", 23, 23, 23, 23, false},
{"只有起点为 0 仍是真实窗口", 0, 7, 0, 7, false},
{"0/0 视为未填 ⇒ 回落", 0, 0, 23, 7, true},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
cfg := &config.Config{Background: config.BackgroundConfig{QuietStartHour: tc.start, QuietEndHour: tc.end}}
gotS, gotE, fell := effectiveQuietHours(cfg, defaults)
if gotS != tc.wantS || gotE != tc.wantE || fell != tc.wantFell {
t.Fatalf("effectiveQuietHours(%d,%d) = (%d,%d,%v), want (%d,%d,%v)",
tc.start, tc.end, gotS, gotE, fell, tc.wantS, tc.wantE, tc.wantFell)
}
})
}
// nil 配置不 panic、不编窗口。
if s, e, fell := effectiveQuietHours(nil, nil); s != 0 || e != 0 || fell {
t.Fatalf("nil 配置必须如实报没有窗口:(%d,%d,%v)", s, e, fell)
}
}
// TestAdminConfigFacts_NeverLeaksSecretValues 是判据 ③(红线):响应体里绝不许
// 出现任何密钥/令牌值。哨兵值刻意取得足够独特,任何形式的泄漏都会被逮到。
func TestAdminConfigFacts_NeverLeaksSecretValues(t *testing.T) {
t.Parallel()
secrets := []string{
"jwt-secret-sentinel-0123456789abcdef",
"sk-llm-api-key-sentinel",
"local-llm-key-sentinel",
"embedding-key-sentinel",
"postgres://user:pgpassword-sentinel@localhost:5432/cyrene",
}
cfg := cfgDefault()
cfg.Auth.JWTSecret = secrets[0]
cfg.Auth.AdminPass = "admin-pass-sentinel"
cfg.LLM.APIKey = secrets[1]
cfg.LLM.Local.APIKey = secrets[2]
cfg.Embedding.APIKey = secrets[3]
cfg.DB.PGURL = secrets[4]
cfg.Channels = config.ChannelsConfig{
AccountsJSON: `{"telegram":[{"id":"conf0","config":{"bot_token":"channel-token-sentinel"}}]}`,
}
status, raw, _ := getConfigFacts(t, cfg, "")
if status != http.StatusOK {
t.Fatalf("status = %d, body = %s", status, raw)
}
body := string(raw)
for _, secret := range append(secrets, "admin-pass-sentinel", "channel-token-sentinel") {
if strings.Contains(body, secret) {
t.Fatalf("响应里出现了凭据值 %q —— 这是红线:%s", secret, body)
}
}
// 正对照:非敏感的事实确实在响应里(否则上面那条断言可能只是空响应混过关)。
if !strings.Contains(body, `"QUIET_START_HOUR"`) || !strings.Contains(body, `"quietHours"`) {
t.Fatalf("响应缺少配置事实本身:%s", body)
}
}
// TestAdminConfigFacts_KeysMatchEnvExample 是判据 ④:键名单的权威来源是
// `.env.example` —— 9 个键必须逐字出现在模板里(键名写错一个字母,用户照面板
// 抄去改就是静默不生效,AGENTS §10 的 E6 固定动作)。
func TestAdminConfigFacts_KeysMatchEnvExample(t *testing.T) {
t.Parallel()
raw, err := os.ReadFile(filepath.Join("..", "..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
template := string(raw)
for _, spec := range configFactSpecs() {
if !strings.Contains(template, spec.key+"=") {
t.Fatalf(".env.example 里没有 %s= —— 事实表与模板漂移", spec.key)
}
}
}
// TestAdminConfigFacts_RequiresOwnerOrAdmin 钉住 RBAC 门:可读的是 owner/admin,
// viewer 拿不到配置事实面(它不是运行时监控投影)。
func TestAdminConfigFacts_RequiresOwnerOrAdmin(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
respViewer, _ := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/config-facts", nil, signAny(t, jwt, "acct", auth.RoleViewer))
if respViewer.StatusCode != http.StatusForbidden {
t.Fatalf("viewer = %d, want 403", respViewer.StatusCode)
}
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin} {
resp, _ := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/config-facts", nil, signAny(t, jwt, "acct", role))
if resp.StatusCode != http.StatusOK {
t.Fatalf("role %s = %d, want 200", role, resp.StatusCode)
}
}
resp, _ := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/config-facts", nil, "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("匿名 = %d, want 401", resp.StatusCode)
}
}