- 新增 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 下限限定)
313 lines
12 KiB
Go
313 lines
12 KiB
Go
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)
|
||
}
|
||
}
|