Files
Cyrene_v2/internal/services/gateway/admin_config_facts_test.go
T
AskaEth 236b202fda feat(backthink): 静默时段心跳降频(A)—— THINK_QUIET_INTERVAL_SEC 默认 3600
主人裁定「先做 A(静默降频)再转向 C」;背景实测:静默期**思考频率不变**(恒定 900s 心跳),只拦打扰 ⇒ 成本照付。
- 新键 THINK_QUIET_INTERVAL_SEC(默认 3600):窗口内用该间隔、窗外仍用 THINK_INTERVAL_SEC;**≤0 = 关闭降频**(不许出现「半夜完全不思考」暗坑);窗口关闭/钟点非法 ⇒ 不降频
- 心跳**每拍按时刻选间隔**(放到循环外就永远不生效——变异实测能打红)
- 🔴 不变量:**事件驱动不被降频影响**(TestBackthink_EventTrigger_NotSlowedByQuietInterval 钉住)
- 事实卡 9→12 条(+思考总开关/普通间隔/静默间隔,标「改需重启」);契约形状未变、未升版
- ⚠️ 如实登记:ENABLE_BACKGROUND_THINKING 是**死配置**(全仓无消费者),已在 env/面板标签/契约三处标注,**是否接线待主人拍**
- 变异 5 条全红;C 的缺口清单(唤醒面 7 处)已回给主会话排期
2026-10-05 22:38:17 +08:00

348 lines
14 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_BackthinkHeartbeatKeys 钉住 design/12 §N A4 新增的三条
// 后台思考心跳事实(`ENABLE_BACKGROUND_THINKING` / `THINK_INTERVAL_SEC` /
// `THINK_QUIET_INTERVAL_SEC`):值取**进程生效值**、总条数确实是 12。键名与
// `.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) != 12 {
t.Fatalf("facts = %d, want 12(记忆整理 7 + 后台思考心跳 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": "20",
"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: 20,
},
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)
}
}
// TestAdminConfigFacts_KeysMatchEnvExample 是判据 ④:键名单的权威来源是
// `.env.example` —— 12 个键必须逐字出现在模板里(键名写错一个字母,用户照面板
// 抄去改就是静默不生效,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)
}
}