Files
AskaEth b7fb4dd1c7 fix(gateway): 配置事实↔模板的守卫改为「在模板里 / 显式豁免带理由」
由来:主人 2026-10-06 红线把 `.env` 冻结为「只保证最小化功能可跑」的引导面 ⇒
调优旋钮不再进模板(我已把 MEMORY_TIDY_COMPARE_LIMIT 撤出),但既有测试仍要求
「每个事实键必须逐字出现在 .env.example」⇒ HEAD 上红一条。

改法(收窄不是放弃守卫):新增**封闭**的 envExampleExemptKeys(键→理由),
每个事实键满足「在模板里」或「在清单里且理由非空」;并加**反向断言**:
清单里不许有非事实键、也不许有其实已在模板里的键(防清单变垃圾桶)。

我的变异:往清单塞 NOT_A_FACT_KEY ⇒ TestAdminConfigFacts_KeysMatchEnvExample FAIL;
还原后复绿。
2026-10-06 09:03:32 +08:00

381 lines
15 KiB
Go
Raw Permalink 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_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)
}
}