Files
Cyrene_v2/internal/services/gateway/admin_config_facts_test.go
T
AskaEth 6e28b38b08
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 护栏抬高+时间预算闸+试算加性异步 与 D3 入库前审查(耦合两片一起落)
【MT-A:护栏 + 诚实截断 + 试算异步】
- maxTidyCompareItems 400(写死)→2000 并开放为 MEMORY_TIDY_COMPARE_LIMIT;
  MEMORY_TIDY_CANDIDATE_LIMIT 默认 20→200(语义不变,仍每分区)
- 依据是首跑实测:459 条的分区被 400 截断 ⇒ compared(705) < scanned(784),
  候选数(77)由上限而不是规则决定;三处上限的 WARN 都补齐了总数/上限/分区名
- 新增「上限 + 时间预算」双闸(实测 2000 条/分区 ≈234s > 3 分钟墙钟):
  整分区跳过 / 两两比对中途收手,一律如实记 (budget) + WARN
- ?async=1 ⇒ 202 {runId,status};GET …/{runId} 轮询(writeAuth 同档);
  done 的 result 与同步 data 逐字节同形;并发=复用既有 runId、TTL 30min 自完成起算、
  重启即如实 404 tidy_run_not_found;不带 async 时同步路径逐字不变

【MT-B:D3 入库前审查(design/23 §15.1)】
- 新记忆经 Store.Add(仓库里唯一 INSERT INTO memory_items)落库后,由独立记忆管理
  会话(sess-memorytidy)在有界后台队列异步判定 skip/merge/modify/create
- 软合并回写(status + merged_into/merged_from/merge_count,可 revert、绝不改 content)
- 隔离来源(群聊/陌生人)**概不审查**、召回/超时失败**零改写**(fail-safe 结构性成立)
- 写入侧仅 +62.7µs/条(非阻塞入队);默认 ENABLED=true(主人裁定:入库前先审查)

【耦合说明(重要)】两片同包且互相依赖:MT-B 的 preinsert_recall.go 用 MT-A 的
collectTidyPairs 新签名 + tidyBudget ⇒ **各自单独都 build 不过**,故一并落地。
MT-A 为让整包编译,最小幅度适配了 MT-B 的 preinsert_recall.go / preinsert_test.go
(零值预算=行为不变;符号重名改名)。

【我的验收】隔离 worktree(37 文件)build/vet/linux/darwin 全过,切片包全绿;
我亲手打红两个变异:① 去掉 (compare) 截断标记 ⇒ TestTidyCompareLimit_HitIsLoud 红;
② 中和隔离来源守卫 ⇒ TestReviewRestrictedSourceIsNeverTouched 红。
唯一红是 internal/runtime/tool 的两条超时用例(该包无人改)⇒ 主仓库 -count=8 全绿,
判定为负载敏感 flake(当时两个整树验收 + 子代理并发),已记入开发习惯与约定 §7。

.env.example 补 MEMORY_REVIEW_* 四键(MT-B 无该文件权限,由我按其代码语义补写)。
2026-10-06 08:04:12 +08:00

349 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`):值取**进程生效值**、总条数确实是 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)
}
}
// TestAdminConfigFacts_KeysMatchEnvExample 是判据 ④:键名单的权威来源是
// `.env.example` —— 13 个键必须逐字出现在模板里(键名写错一个字母,用户照面板
// 抄去改就是静默不生效,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)
}
}