Files
Cyrene_v2/internal/config/runtimeconfig/reset_test.go
T
AskaEth c1fa1e9b9a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 面板「重置为 env 默认」动作(materialize 的反方向)+ 契约 v0.62
- 语义:只把 .env **确实声明过**的键覆盖回 env 值;env 未声明的键(fallback/multimodal/maxTokens/reasoning/personaModels)原样保留;**组集合不增减**(不复活被面板删的组、不删面板新增的组);无键可改时不写盘。成功即热重载 LLM 栈。返回 {reset:[{group,keys}], skipped:[{group,reason}], path},**只回键名、不回显凭据值**。

- 护栏:必须 confirm:true(缺失/false/非法 body ⇒ 400,且**先于任何读写**);**owner-only**(与同页 POST /admin/llm、/admin/llm/test 同门 —— 这是破坏性写,门不比同族更宽);审计动作 llm.resetFromEnv(camelCase,全仓多词动作的既有约定)。失败路径明确报错(env_defaults_unreadable / models_unreadable / llm_unavailable),不假成功。

- 组合根注入 Deps.EnvPath(网关不 fopen .env):请求时按既有解析入口重读,语义 =「退回 .env 现在声明的默认值」;.env 层里的 provider 是同义反复,不计入「env 为该组声明了什么」的判定(有专门用例)。

- 前端:LLM 页「重置为 env 默认」卡片(中文二次确认 → 提交 → 按 reset/skipped 渲染 → 成功后重拉配置),标签 owner 专属(与 System.tsx 同款)。

- 契约 v0.62:api-client(权限矩阵改回 12+10+38=60、端点表 9b、重置契约小节、写操作契约、changelog、banner)与 api-reference(端点表 #70、§5.13a 详情含 curl、§8.2/§8.3/§8.4)同步;字段名三方逐字对齐。

- 主会话独立验收:隔离 worktree(R1 的 19 文件)build/vet 绿、gateway+config 三包绿、R1 用例 10 PASS、reset_from_env 残留 0;**变异亲自复现**(去掉 confirm 校验 ⇒ RequiresConfirm 红:status = 200, want 400,且响应体显示面板配置真的被改)。

- 子代理自报并已处理:测试文件触 500 行上限 ⇒ 拆成语义侧 + 护栏侧两份;门改回 owner-only 后前端标签会误导 admin ⇒ 改「owner 专属」并重建 bundle。任务书本身仍是旧口径(owner|admin / snake_case)—— 已由主会话登记修正说明,避免压缩后有人按任务书回退。

- 未做(诚实边界):不支持按组重置;不纳入 channels.json/plugins.json;不自动重启(contextWindow 的预算推导仍只在启动时算一次)。新端点需下一次 build+restart 才在 :10942 生效。
2026-10-04 21:33:11 +08:00

281 lines
11 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 runtimeconfig
import (
"bytes"
"errors"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// 本文件钉住「重置为 env 默认」这个**显式**动作的语义(切片 R1,实现见 reset.go):
// 只改 `.env` 声明过的键、env 未声明的键原样保留、组集合与顺序不增减、面板值已与
// env 一致时不写盘(幂等),且失败一个字节都不落盘 —— 与 materialize 的补齐方向相反、
// 键表同源。
// resetPanelFile 写一份含三组的面板配置:openai 组(面板值全都与 env 不同)、
// anthropic 组(env 侧另有一块 anthropic 声明)、以及 env 里完全没有对应声明的
// 第三组(它的面板值只可能被保留)。返回文件路径。
func resetPanelFile(t *testing.T) string {
t.Helper()
return writeModelsFile(t, `[
{"id":"panel-openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"sk-panel-key","model":"panel-model",
"fallback":"panel-fallback","multimodal":true,"maxTokens":4096,"contextWindow":4096,
"reasoning":{"kind":"vllm"},"personaModels":{"cyrene":"panel-model"}},
{"id":"panel-anthropic","provider":"anthropic","baseUrl":"https://panel-anthropic.example","apiKey":"sk-panel-ant","model":"panel-claude"},
{"id":"panel-extra","provider":"openai","baseUrl":"https://panel-extra.example/v1","apiKey":"sk-panel-extra","model":"panel-extra-model"}
]`)
}
// TestResetFromEnvOnlyTouchesEnvDeclaredKeys 是核心语义:`.env` 声明过的键被改回
// env 值,env 未声明的键(fallback / multimodal / maxTokens / reasoning /
// personaModels)**原样保留**;`provider` 虽在 env 默认层里被声明,但面板值已与
// env 一致,因此不算「被改回」。
func TestResetFromEnvOnlyTouchesEnvDeclaredKeys(t *testing.T) {
t.Parallel()
env := materializeEnv()
env.ContextWindow = 131072
path := resetPanelFile(t)
res, err := ResetFromEnv(path, env, discardLog())
if err != nil {
t.Fatalf("ResetFromEnv: %v", err)
}
if !res.Written {
t.Fatal("Written = false, want true(有键被改回就该落盘)")
}
// 三组都在:openai 组被改回,anthropic 组被改回,extra 组也一样(同属 openai 族)。
got := map[string][]string{}
for _, g := range res.Reset {
got[g.GroupID] = g.Keys
}
wantOpenAI := []string{"baseUrl", "apiKey", "model", "contextWindow"}
if !equalStrings(got["panel-openai"], wantOpenAI) {
t.Fatalf("panel-openai 改回的键 = %v, want %v(provider 已一致,不算改回)", got["panel-openai"], wantOpenAI)
}
wantAnthropic := []string{"baseUrl", "apiKey", "model", "contextWindow"}
if !equalStrings(got["panel-anthropic"], wantAnthropic) {
t.Fatalf("panel-anthropic 改回的键 = %v, want %v(anthropic 族按组自己的 provider 选族;LLM_CONTEXT_WINDOW 对两族都播种)",
got["panel-anthropic"], wantAnthropic)
}
if len(res.Skipped) != 0 {
t.Fatalf("Skipped = %+v, want 空(每一组都有键被改回)", res.Skipped)
}
// 磁盘上的真值:env 值覆盖,未声明的键原样保留。
groups, warn := LoadModels(path, env)
if warn != nil {
t.Fatalf("LoadModels: warn = %v", warn)
}
g := mustFindGroup(t, groups, "panel-openai")
if g.BaseURL != env.BaseURL || g.APIKey != env.APIKey || g.Model != env.Model || g.ContextWindow != env.ContextWindow {
t.Fatalf("panel-openai = %+v, want env 的 baseUrl/apiKey/model/contextWindow", g)
}
if g.Fallback != "panel-fallback" || !g.Multimodal || g.MaxTokens != 4096 ||
g.Reasoning.Kind != "vllm" || g.PersonaModels["cyrene"] != "panel-model" {
t.Fatalf("panel-openai = %+v, want env 未声明的键原样保留", g)
}
ant := mustFindGroup(t, groups, "panel-anthropic")
if ant.BaseURL != env.AnthropicBaseURL || ant.APIKey != env.AnthropicAPIKey || ant.Model != env.AnthropicModel {
t.Fatalf("panel-anthropic = %+v, want anthropic 族的 env 值", ant)
}
// 组集合与顺序不变(panel-extra 是面板新增的组,env 里没有它的 id)。
if len(groups) != 3 || groups[0].ID != "panel-openai" || groups[1].ID != "panel-anthropic" || groups[2].ID != "panel-extra" {
t.Fatalf("组集合/顺序 = %+v, want 原样的三组", groups)
}
}
// mustFindGroup 按 id 取组,取不到直接失败。
func mustFindGroup(t *testing.T, groups []Group, id string) Group {
t.Helper()
g, ok := Find(groups, id)
if !ok {
t.Fatalf("组 %q 不在磁盘上: %+v", id, groups)
}
return g
}
// TestResetFromEnvKeepsGroupsEnvWantsBack 钉住「组集合不因 env 而增减」:env 里
// 有 anthropic 块,但面板把 anthropic 组删了 —— 重置**不复活**它;面板新增的组
// 也**不被删除**(组数与顺序都不动)。
func TestResetFromEnvKeepsGroupsEnvWantsBack(t *testing.T) {
t.Parallel()
env := materializeEnv()
path := writeModelsFile(t, `[
{"id":"panel-extra","provider":"openai","baseUrl":"https://panel-extra.example/v1","apiKey":"sk-panel-extra","model":"panel-extra-model"}
]`)
before := readRaw(t, path)
res, err := ResetFromEnv(path, env, discardLog())
if err != nil {
t.Fatalf("ResetFromEnv: %v", err)
}
if !res.Written {
t.Fatal("Written = false, want true(openai 组确实被改回)")
}
groups, warn := LoadModels(path, env)
if warn != nil {
t.Fatalf("LoadModels: warn = %v", warn)
}
if len(groups) != 1 || groups[0].ID != "panel-extra" {
t.Fatalf("组集合 = %+v, want 只有面板那一组(envelope 不复活被删的组)", groups)
}
if _, ok := Find(groups, ProviderAnthropic); ok {
t.Fatal("env 声明的 anthropic 组被复活了, want 不新增组")
}
if readRaw(t, path) == before {
t.Fatal("openai 组的键应被改回(文件该变)")
}
}
// TestResetFromEnvIdempotentNoWrite 钉住幂等:第二次调用时面板值已与 env 一致,
// 因此**一个字节都不动**,并且报出「已一致」的跳过原因。
func TestResetFromEnvIdempotentNoWrite(t *testing.T) {
t.Parallel()
env := materializeEnv()
path := resetPanelFile(t)
if _, err := ResetFromEnv(path, env, discardLog()); err != nil {
t.Fatalf("第一次 ResetFromEnv: %v", err)
}
before := readRaw(t, path)
res, err := ResetFromEnv(path, env, discardLog())
if err != nil {
t.Fatalf("第二次 ResetFromEnv: %v", err)
}
if res.Written {
t.Fatal("Written = true, want false(无键可改时不写盘)")
}
if len(res.Reset) != 0 {
t.Fatalf("Reset = %+v, want 空", res.Reset)
}
if len(res.Skipped) != 3 {
t.Fatalf("Skipped = %+v, want 三组都报「已一致」", res.Skipped)
}
for _, s := range res.Skipped {
if s.Reason != SkipReasonAlreadyDefault {
t.Fatalf("跳过原因 = %q, want %q", s.Reason, SkipReasonAlreadyDefault)
}
}
if readRaw(t, path) != before {
t.Fatal("无键可改时文件被改动了, want 逐字节不变")
}
}
// TestResetFromEnvUnreadablePanelFails 钉住失败路径:面板文件不存在/不可解析时
// 明确报错(ErrPanelUnreadable),绝不假装成功、也绝不创建文件。
func TestResetFromEnvUnreadablePanelFails(t *testing.T) {
t.Parallel()
env := materializeEnv()
t.Run("文件不存在", func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "models.json")
if _, err := ResetFromEnv(path, env, discardLog()); !errors.Is(err, ErrPanelUnreadable) {
t.Fatalf("err = %v, want ErrPanelUnreadable", err)
}
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
t.Fatal("重置失败却创建了文件, want 什么都不做")
}
})
t.Run("内容不可解析", func(t *testing.T) {
t.Parallel()
path := writeModelsFile(t, `{"not":"an array"}`)
before := readRaw(t, path)
if _, err := ResetFromEnv(path, env, discardLog()); !errors.Is(err, ErrPanelUnreadable) {
t.Fatalf("err = %v, want ErrPanelUnreadable", err)
}
if readRaw(t, path) != before {
t.Fatal("解析失败的文件被改写了, want 一个字节都不动")
}
})
}
// TestResetFromEnvEnvDeclaresNothing 钉住「env 什么都没声明」这一侧:组的
// baseUrl/model 等一个都不动,且**每一组**都报「env 未声明该组的默认键」——这一条
// 之所以能成立,靠的是把 `provider`(同义反复,恒等于组里已有的值)排除在
// 「env 声明了什么」的判定之外,否则面板上会显示成误导性的「面板值已一致」。
func TestResetFromEnvEnvDeclaresNothing(t *testing.T) {
t.Parallel()
env := config.LLMConfig{Provider: ProviderOpenAI}
path := resetPanelFile(t)
before := readRaw(t, path)
res, err := ResetFromEnv(path, env, discardLog())
if err != nil {
t.Fatalf("ResetFromEnv: %v", err)
}
if res.Written || len(res.Reset) != 0 {
t.Fatalf("res = %+v, want 无改动(env 未声明任何键)", res)
}
if len(res.Skipped) != 3 {
t.Fatalf("Skipped = %+v, want 三组都被跳过", res.Skipped)
}
for _, s := range res.Skipped {
if s.Reason != SkipReasonNoDefaults {
t.Fatalf("组 %s 的跳过原因 = %q, want %q", s.GroupID, s.Reason, SkipReasonNoDefaults)
}
}
if readRaw(t, path) != before {
t.Fatal("env 未声明任何键时文件被改动了, want 逐字节不变")
}
}
// TestResetFromEnvSkipsFamilyWithoutDeclaration 钉住「按组自己的 provider 选族」的
// 另一面:`.env` 只配了扁平 openai 块时,anthropic 组没有任何可改回的键,且原因必须
// 是「`.env` 未声明」而不是「已一致」。
func TestResetFromEnvSkipsFamilyWithoutDeclaration(t *testing.T) {
t.Parallel()
env := materializeEnv() // 含 anthropic 块
env.AnthropicBaseURL, env.AnthropicAPIKey, env.AnthropicModel, env.AnthropicFallback = "", "", "", ""
path := resetPanelFile(t)
res, err := ResetFromEnv(path, env, discardLog())
if err != nil {
t.Fatalf("ResetFromEnv: %v", err)
}
var anthropic *ResetSkip
for i := range res.Skipped {
if res.Skipped[i].GroupID == "panel-anthropic" {
anthropic = &res.Skipped[i]
}
}
if anthropic == nil {
t.Fatalf("anthropic 组不在 skipped 里: %+v", res.Skipped)
}
if anthropic.Reason != SkipReasonNoDefaults {
t.Fatalf("anthropic 组跳过原因 = %q, want %q", anthropic.Reason, SkipReasonNoDefaults)
}
}
// TestResetFromEnvNeverLogsCredentialValues 钉住凭据纪律:日志里只有组 id 与
// 键名,env 与面板的 apiKey 值都不出现。
func TestResetFromEnvNeverLogsCredentialValues(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
env := materializeEnv()
path := resetPanelFile(t)
if _, err := ResetFromEnv(path, env, log); err != nil {
t.Fatalf("ResetFromEnv: %v", err)
}
out := buf.String()
for _, secret := range []string{env.APIKey, env.AnthropicAPIKey, "sk-panel-key", "sk-panel-ant"} {
if strings.Contains(out, secret) {
t.Fatalf("日志里出现了凭据 %q: %s", secret, out)
}
}
if !strings.Contains(out, `"source":"config/runtimeconfig"`) {
t.Fatalf("日志缺来源标注: %s", out)
}
if !strings.Contains(out, "panel-openai.baseUrl") {
t.Fatalf("日志未报告被改回的键(可审计): %s", out)
}
}