package gateway import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) // 本文件锁定记忆整理「面板调优」读写端点(`GET|PUT /api/v1/admin/memories/tidy/tuning`) // 的判据: // ① 读侧如实回显**盘上值**(键缺位 ⇒ 该键不出现)+ 取值规则 specs + `.env` 兜底值, // 且不回显同文件里不受管的键; // ② 写侧**三态**:键缺位 ⇒ 逐字节不动(含「不重写文件」);合法值 ⇒ 采用且保留 // 同文件其它键;非法值 ⇒ `400 invalid_payload` 且**盘上一个字节都不动**; // ③ 盘上文件解不开 ⇒ 读/写都 `409 tuning_corrupt` 且绝不覆盖; // ④ `null` 是清除哨兵(删键 ⇒ 回到下一档兜底); // ⑤ RBAC 走 writeAuth(owner|admin)、写落审计 `memory.tidy.tuning`; // ⑥ 热生效**如实**:接线了 ⇒ hot=true 且真把值交给运行期;没接线/失败 ⇒ hot=false + // hotError,绝不把「落盘了」说成「生效了」。 // tuneEnvT 是一个注入了数据目录(= 面板配置文件落点所在)的网关测试环境。 type tuneEnvT struct { srv *Server ts *httptest.Server dir string jwt *auth.JWTService } // newTuneEnv 构造环境。`cfg.MemoryTidy` 被显式填上,用来验证读侧下发的 // 「缺位时 `.env` 那一档」确实取自进程生效配置(而不是网关自己编的数)。 func newTuneEnv(t *testing.T, deps Deps) *tuneEnvT { t.Helper() dir := t.TempDir() cfg := cfgDefault() cfg.MemoryTidy = config.MemoryTidyConfig{IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7} deps.DataDir = dir srv, ts, jwt := buildEnv(t, cfg, deps) return &tuneEnvT{srv: srv, ts: ts, dir: dir, jwt: jwt} } func (e *tuneEnvT) url() string { return e.ts.URL + "/api/v1/admin/memories/tidy/tuning" } func (e *tuneEnvT) path() string { return filepath.Join(e.dir, filepath.FromSlash(runtimeconfig.MemoryTidyConfigRelPath)) } func (e *tuneEnvT) token(t *testing.T, role auth.Role) string { t.Helper() return signAny(t, e.jwt, "acct-"+string(role), role) } // seed 写入一份面板配置并返回写后的字节(后续逐字节比对用)。 func (e *tuneEnvT) seed(t *testing.T, body string) []byte { t.Helper() if err := os.MkdirAll(filepath.Dir(e.path()), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(e.path(), []byte(body), 0o600); err != nil { t.Fatalf("seed: %v", err) } return e.bytes(t) } // bytes 读回盘上的原始字节:「非法值不落盘」的证据就是它与之前相等。 func (e *tuneEnvT) bytes(t *testing.T) []byte { t.Helper() raw, err := os.ReadFile(e.path()) if err != nil { t.Fatalf("read tuning file: %v", err) } return raw } func (e *tuneEnvT) put(t *testing.T, token, raw string) (*http.Response, []byte) { t.Helper() return doRaw(t, http.MethodPut, e.url(), raw, token) } func (e *tuneEnvT) get(t *testing.T, token string) (*http.Response, []byte) { t.Helper() return doReq(t, http.MethodGet, e.url(), nil, token) } // view 解一次成功响应的 data。 func (e *tuneEnvT) view(t *testing.T, body []byte) AdminMemoryTidyTuningView { t.Helper() var view AdminMemoryTidyTuningView if err := json.Unmarshal(decodeBody(t, body)["data"], &view); err != nil { t.Fatalf("decode view: %v (body=%s)", err, body) } return view } // assertErrorCode 断言响应是错误信封且 code 匹配。 func assertErrorCode(t *testing.T, body []byte, want string) { t.Helper() var eobj struct { Code string `json:"code"` } if err := json.Unmarshal(decodeBody(t, body)["error"], &eobj); err != nil { t.Fatalf("decode error: %v (body=%s)", err, body) } if eobj.Code != want { t.Fatalf("error code = %q, want %q (body=%s)", eobj.Code, want, body) } } // diskDoc 解出盘上的文档(键 → 原样 JSON 值)。 func (e *tuneEnvT) diskDoc(t *testing.T) map[string]json.RawMessage { t.Helper() doc := map[string]json.RawMessage{} if err := json.Unmarshal(e.bytes(t), &doc); err != nil { t.Fatalf("decode disk: %v", err) } return doc } // TestAdminMemoryTidyTuningRead_EchoesDiskValues 读侧:回显盘上 7 键(缺位不下发)、 // 下发取值规则与 `.env` 兜底值,且**不回显**同文件里不受管的键。 func TestAdminMemoryTidyTuningRead_EchoesDiskValues(t *testing.T) { t.Parallel() env := newTuneEnv(t, Deps{}) env.seed(t, `{ "intervalSec": 3600, "quietStartHour": 23, "quietEndHour": 7, "ingestGate": false, "t1PerWindow": 120 }`) resp, body := env.get(t, env.token(t, auth.RoleOwner)) if resp.StatusCode != http.StatusOK { t.Fatalf("read status = %d, want 200 (body=%s)", resp.StatusCode, body) } view := env.view(t, body) if view.Path != runtimeconfig.MemoryTidyConfigRelPath || !view.Present { t.Fatalf("view path/present = %q/%v", view.Path, view.Present) } if got := view.Values["intervalSec"]; got != float64(3600) { t.Fatalf("values.intervalSec = %#v, want 3600", got) } if got := view.Values["ingestGate"]; got != false { t.Fatalf("values.ingestGate = %#v, want false", got) } // 键缺位 ⇒ 不出现(「没配」与「配成 0」必须分得开)。 for _, absent := range []string{"quietIntervalSec", "autoApply", "tempTtlSec"} { if _, ok := view.Values[absent]; ok { t.Fatalf("values 不该出现 %s(盘上没这个键):%#v", absent, view.Values) } } // 不受管的键(t1PerWindow)不进 values:这张卡改不了它,混进来会误导。 if _, ok := view.Values["t1PerWindow"]; ok { t.Fatalf("values 不该出现不受管的 t1PerWindow:%#v", view.Values) } if len(view.Specs) != len(memoryTidyTuningManagedKeys()) { t.Fatalf("specs = %d 条, want %d", len(view.Specs), len(memoryTidyTuningManagedKeys())) } if view.EnvValues["intervalSec"] != 86400 || view.EnvValues["quietStartHour"] != 23 { t.Fatalf("envValues = %#v, want intervalSec=86400 / quietStartHour=23", view.EnvValues) } if view.Hot { t.Fatal("只读响应里 hot 必须为 false(读不改变任何东西)") } } // TestAdminMemoryTidyTuningRead_MissingFileIsNotAnError 文件还没播种时:正常回 200、 // present=false、values 为空 —— 「还没配过」不是错误(与 runtimeconfig 的读取口径一致)。 func TestAdminMemoryTidyTuningRead_MissingFileIsNotAnError(t *testing.T) { t.Parallel() env := newTuneEnv(t, Deps{}) resp, body := env.get(t, env.token(t, auth.RoleOwner)) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } view := env.view(t, body) if view.Present || len(view.Values) != 0 { t.Fatalf("view = %+v, want present=false / 无值", view) } } // TestAdminMemoryTidyTuningRead_ViewerForbidden RBAC:viewer 403、匿名 401。 func TestAdminMemoryTidyTuningRead_ViewerForbidden(t *testing.T) { t.Parallel() env := newTuneEnv(t, Deps{}) env.seed(t, `{"intervalSec": 3600}`) resp, body := env.get(t, env.token(t, auth.RoleViewer)) if resp.StatusCode != http.StatusForbidden { t.Fatalf("viewer read status = %d, want 403 (body=%s)", resp.StatusCode, body) } resp, _ = env.get(t, "") if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("anonymous read status = %d, want 401", resp.StatusCode) } } // TestAdminMemoryTidyTuningWrite_AdoptsValidValuesAndKeepsOtherKeys 写侧「合法值 ⇒ 采用」: // 7 键都能落盘,同文件里不受管的键**原样保留**(一次面板保存绝不抹掉别的旋钮)。 func TestAdminMemoryTidyTuningWrite_AdoptsValidValuesAndKeepsOtherKeys(t *testing.T) { t.Parallel() env := newTuneEnv(t, Deps{}) env.seed(t, `{"t1Judge": false, "t1PerWindow": 120}`) // 用 admin 角色也走同一道门(writeAuth = owner|admin)。 resp, body := env.put(t, env.token(t, auth.RoleAdmin), `{ "intervalSec": 10800, "quietIntervalSec": 3600, "quietStartHour": 23, "quietEndHour": 7, "ingestGate": true, "autoApply": true, "tempTtlSec": 604800 }`) if resp.StatusCode != http.StatusOK { t.Fatalf("write status = %d, want 200 (body=%s)", resp.StatusCode, body) } view := env.view(t, body) for key, want := range map[string]any{ "intervalSec": float64(10800), "quietIntervalSec": float64(3600), "quietStartHour": float64(23), "quietEndHour": float64(7), "ingestGate": true, "autoApply": true, "tempTtlSec": float64(604800), } { if got := view.Values[key]; got != want { t.Fatalf("回显 values[%s] = %#v, want %#v", key, got, want) } } doc := env.diskDoc(t) if string(doc["intervalSec"]) != "10800" || string(doc["t1PerWindow"]) != "120" { t.Fatalf("disk = %s", string(env.bytes(t))) } if _, ok := doc["t1Judge"]; !ok { t.Fatalf("不受管的 t1Judge 被抹掉了:%s", string(env.bytes(t))) } // 未接运行期 ⇒ 如实说「已落盘、重启后生效」。 if view.Hot || view.HotError == "" { t.Fatalf("未接热生效时 hot/hotError = %v/%q, want false/非空", view.Hot, view.HotError) } } // TestAdminMemoryTidyTuningWrite_AbsentKeyLeavesBytesUntouched 三态之一:键**缺位** ⇒ // 不改动。空补丁连文件都不重写(mtime 都不动);只提交一个键时另一个键逐字节不动。 func TestAdminMemoryTidyTuningWrite_AbsentKeyLeavesBytesUntouched(t *testing.T) { t.Parallel() env := newTuneEnv(t, Deps{}) before := env.seed(t, `{"intervalSec": 86400, "quietStartHour": 23}`) past := time.Now().Add(-time.Hour) if err := os.Chtimes(env.path(), past, past); err != nil { t.Fatalf("chtimes: %v", err) } tok := env.token(t, auth.RoleOwner) resp, body := env.put(t, tok, `{}`) if resp.StatusCode != http.StatusOK { t.Fatalf("empty patch status = %d, want 200 (body=%s)", resp.StatusCode, body) } if after := env.bytes(t); string(after) != string(before) { t.Fatalf("空补丁改动了盘上内容:\nbefore=%s\nafter=%s", before, after) } info, err := os.Stat(env.path()) if err != nil { t.Fatalf("stat: %v", err) } if !info.ModTime().Equal(past) { t.Fatalf("空补丁重写了文件(mtime %v ⇒ %v)", past, info.ModTime()) } if resp, body := env.put(t, tok, `{"quietStartHour": 22}`); resp.StatusCode != http.StatusOK { t.Fatalf("partial patch status = %d (body=%s)", resp.StatusCode, body) } doc := env.diskDoc(t) if string(doc["intervalSec"]) != "86400" || string(doc["quietStartHour"]) != "22" { t.Fatalf("partial patch 结果 = %s", string(env.bytes(t))) } }