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_BackthinkHeartbeatKeys 钉住 design/12 §N A4 新增的三条 // 后台思考心跳事实(`ENABLE_BACKGROUND_THINKING` / `THINK_INTERVAL_SEC` / // `THINK_QUIET_INTERVAL_SEC`):值取**进程生效值**、总条数确实是 13。键名与 // `.env.example` 逐字一致那一半由 TestAdminConfigFacts_KeysMatchEnvExample 钉。 func TestAdminConfigFacts_BackthinkHeartbeatKeys(t *testing.T) { t.Parallel() cfg := cfgDefault() cfg.Background.Enabled = false cfg.Background.IntervalSec = 7200 cfg.Background.QuietIntervalSec = 0 // ≤0 = 关闭降频;事实面必须如实报 0 _, _, facts := getConfigFacts(t, cfg, "") if len(facts.Facts) != 13 { t.Fatalf("facts = %d, want 13(记忆整理 8 + 后台思考心跳 3 + 安静时段 2)", len(facts.Facts)) } for key, want := range map[string]string{ "ENABLE_BACKGROUND_THINKING": "false", "THINK_INTERVAL_SEC": "7200", "THINK_QUIET_INTERVAL_SEC": "0", } { got := factByKey(t, facts.Facts, key) if got.Value != want { t.Fatalf("%s value = %q, want %q(进程生效值)", key, got.Value, want) } if got.IsDefault { t.Fatalf("%s 被改过,isDefault 必须为 false:%+v", key, got) } } } // 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": "200", "MEMORY_TIDY_COMPARE_LIMIT": "2000", "ENABLE_BACKGROUND_THINKING": "true", "THINK_INTERVAL_SEC": "900", "THINK_QUIET_INTERVAL_SEC": "3600", "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: 200, CompareLimit: 2000, }, Background: config.BackgroundConfig{ Enabled: true, IntervalSec: 900, QuietIntervalSec: 3600, 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) } } // envExampleExemptKeys 是**允许不出现在 `.env.example`** 的配置事实键,值 = 豁免理由。 // // 依据(主人 2026-10-06 红线):`.env` 冻结为「只保证最小化功能可跑」的**引导面**, // **不再当作配置面** —— 调优/门槛/上限类旋钮**不进模板**(只有代码默认值;将来由 // `$DATA_DIR/config/**` + 面板提供,见 `design/13 §2.1`)。因此「键必须在模板里」 // 这条旧口径收窄为:「**在模板里,或在这里带理由显式豁免**」。 // // 🔴 这份清单是**封闭的**:新键要么进模板(确属最小化引导必需、且经主人批准), // 要么必须在这里显式登记并写清理由 —— 否则本用例红。它守的东西没变: // **不许有键悄悄漂移出去**(写错一个字母 ⇒ 用户照面板改就是静默不生效)。 var envExampleExemptKeys = map[string]string{ "MEMORY_TIDY_COMPARE_LIMIT": "调优旋钮(每分区两两比对上限);按冻结口径走 config/ 而不进 .env(design/13 §2.1)", } // TestAdminConfigFacts_KeysMatchEnvExample 是判据 ④:键名单的权威来源是 `.env.example`。 // 每个配置事实键必须**逐字出现在模板里**,或**在 envExampleExemptKeys 里带理由登记豁免** // (口径 2026-10-06 由「必须在模板里」收窄为「在模板里 / 显式豁免」,原因见上)。 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+"=") { continue } if why, ok := envExampleExemptKeys[spec.key]; ok && strings.TrimSpace(why) != "" { continue } t.Fatalf(".env.example 里没有 %s= 且未登记豁免 —— 事实表与模板漂移", spec.key) } // 反向:豁免清单里不许留着「其实模板里有」或「根本不是事实键」的条目 // (否则清单会变成垃圾桶,守卫随之失效)。 keys := map[string]bool{} for _, spec := range configFactSpecs() { keys[spec.key] = true } for key := range envExampleExemptKeys { if !keys[key] { t.Fatalf("豁免清单里的 %q 不是配置事实键 —— 请删掉(清单只放事实键)", key) } if strings.Contains(template, key+"=") { t.Fatalf("豁免清单里的 %q 其实已经在 .env.example 里 —— 请把它从清单删掉", 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) } }