package runtimeconfig import ( "bytes" "log/slog" "os" "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) // 本文件钉住启动补齐的**边界**:显式清空与 omitempty 键的面板优先、组集合与顺序 // 由面板全权决定、解析失败/补齐后不合法时绝不覆盖原文件、以及日志可溯源且不泄凭据; // 并把 `LLM_MULTIMODAL` 的既有语义在补齐之后原样钉住。 // TestMaterializeMultimodalRegression 是 `LLM_MULTIMODAL`(4adfc9a)的回归:补齐落地 // 之后,它在**两条路径**下的语义与既有断言(multimodal_env_test.go)逐条一致—— // // 路径 A(面板配置缺失/为空 ⇒ env 播种):本键由 GroupFromEnv 带进合成组,落盘可见; // 路径 B(面板配置存在 ⇒ 面板优先):本键**不在补齐表内**,面板未声明就是未声明, // `.env` 的 LLM_MULTIMODAL=true 不得替面板作答(否则面板的勾选会被 .env 顶掉)。 func TestMaterializeMultimodalRegression(t *testing.T) { t.Parallel() env := materializeEnv() env.Multimodal = true t.Run("面板缺失时由 env 播种出 multimodal", func(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "models.json") res, err := Materialize(path, env, discardLog()) if err != nil { t.Fatalf("Materialize: %v", err) } if !res.Groups[0].Multimodal { t.Fatal("播种路径:Multimodal = false, want true(GroupFromEnv 既有语义)") } if !strings.Contains(readRaw(t, path), `"multimodal": true`) { t.Fatalf("播种落盘文件缺 multimodal: %s", readRaw(t, path)) } }) t.Run("面板存在时 env 不得补 multimodal", func(t *testing.T) { t.Parallel() path := writeModelsFile(t, `[ {"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"sk-p","model":"panel-model"} ]`) before := readRaw(t, path) res, err := Materialize(path, env, discardLog()) if err != nil { t.Fatalf("Materialize: %v", err) } if len(res.Filled) != 0 { t.Fatalf("Filled = %v, want 空(multimodal 不在补齐表内)", fillStrings(res.Filled)) } groups, warn := LoadModels(path, env) if warn != nil { t.Fatalf("LoadModels: warn = %v", warn) } if groups[0].Multimodal { t.Fatal("env 的 LLM_MULTIMODAL=true 替面板作了答, want 组文件优先") } if res.Written || readRaw(t, path) != before { t.Fatal("文件不得因 env 的 multimodal 声明而改动") } }) t.Run("面板声明 true 时保持 true", func(t *testing.T) { t.Parallel() path := writeModelsFile(t, `[ {"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"sk-p","model":"panel-model","multimodal":true} ]`) off := materializeEnv() res, err := Materialize(path, off, discardLog()) if err != nil { t.Fatalf("Materialize: %v", err) } if !res.Groups[0].Multimodal { t.Fatal("面板显式 true 被 env 的未声明顶掉了, want 面板优先") } if res.Written { t.Fatal("面板已有该键且无需补齐时不得改写文件") } }) } // TestMaterializeMultimodalFromProcessEnv 走一遍真实读路径的播种:进程 env → // config.Load → cfg.LLM.Multimodal → Materialize 播种出的组。用 t.Setenv,故不与 // 并行测试同跑(config 包同口径)。 func TestMaterializeMultimodalFromProcessEnv(t *testing.T) { t.Setenv("JWT_SECRET", strings.Repeat("s", 40)) t.Setenv("LLM_MULTIMODAL", "true") t.Setenv("LLM_BASE_URL", "https://env.example/v1") t.Setenv("LLM_API_KEY", "sk-env-key") t.Setenv("LLM_MODEL", "env-model") cfg, err := config.Load("") if err != nil { t.Fatalf("config.Load: %v", err) } path := filepath.Join(t.TempDir(), "models.json") res, err := Materialize(path, cfg.LLM, discardLog()) if err != nil { t.Fatalf("Materialize: %v", err) } if !res.Seeded || !res.Groups[0].Multimodal { t.Fatalf("Seeded=%v Multimodal=%v, want 播种且收图能力为 true", res.Seeded, res.Groups[0].Multimodal) } if !strings.Contains(readRaw(t, path), `"multimodal": true`) { t.Fatalf("落盘文件缺 multimodal: %s", readRaw(t, path)) } } // TestMaterializeKeepsPanelExplicitEmpty 回答裁决里那条存疑口径:**面板显式清空某键 // ⇒ 面板优先、env 不补**。`apiKey` 两处取证——空串与 null 都是「显式声明为空」,都在 // 原始键集合里,因此都不得被 env 的值填回去。 func TestMaterializeKeepsPanelExplicitEmpty(t *testing.T) { t.Parallel() env := materializeEnv() for _, tc := range []struct { name string raw string }{ {name: "空串", raw: `[{"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"","model":"panel-model"}]`}, {name: "null", raw: `[{"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":null,"model":"panel-model"}]`}, } { path := writeModelsFile(t, tc.raw) before := readRaw(t, path) res, err := Materialize(path, env, discardLog()) if err != nil { t.Fatalf("%s: Materialize: %v", tc.name, err) } if res.Groups[0].APIKey != "" { t.Fatalf("%s: APIKey = %q, want 保持面板的显式空值", tc.name, res.Groups[0].APIKey) } if len(res.Filled) != 0 || res.Written || readRaw(t, path) != before { t.Fatalf("%s: Filled=%v Written=%v, want 面板优先且不动文件", tc.name, fillStrings(res.Filled), res.Written) } if got := fillStrings(res.PanelWins); !equalStrings(got, []string{"openai.provider", "openai.baseUrl", "openai.apiKey", "openai.model"}) { t.Fatalf("%s: PanelWins = %v, want 面板四个键全部保留(含显式空的 apiKey)", tc.name, got) } } } // TestMaterializeLeavesOmitemptyKeysToPanel 钉住补齐表的**刻意留白**:`fallback` / // `multimodal` / `reasoning.kind` 带 omitempty/omitzero,在 models.json 里「面板清空 // 该键」与「从未写过该键」是同一个字节形态(键直接不写)。若把它们纳入补齐,面板里 // 清掉后重启就会被 `.env` 静默补回——正是裁决禁止的「重启打回面板」。故它们一律 // 留给面板:面板配置缺失/无效时仍由 GroupFromEnv 兜底(见 multimodal 回归那组)。 func TestMaterializeLeavesOmitemptyKeysToPanel(t *testing.T) { t.Parallel() env := materializeEnv() env.Multimodal = true env.ReasoningKind = ReasoningKindVLLM path := writeModelsFile(t, `[ {"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"sk-p","model":"panel-model"} ]`) before := readRaw(t, path) res, err := Materialize(path, env, discardLog()) if err != nil { t.Fatalf("Materialize: %v", err) } if len(res.Filled) != 0 || res.Written || readRaw(t, path) != before { t.Fatalf("Filled=%v Written=%v, want 三个 omitempty 键都不补、文件不动", fillStrings(res.Filled), res.Written) } g := res.Groups[0] if g.Fallback != "" || g.Multimodal || g.Reasoning.Kind != "" { t.Fatalf("组 = %+v, want fallback/multimodal/reasoning.kind 全部保持面板的「未声明」", g) } } // TestMaterializeKeepsPanelGroupSetAndOrder:组集合与顺序完全由面板决定,`.env` 既不 // 新增(哪怕 env.Provider 指向另一种协议)、也不复活、更不重排;补齐时按**组自己的** // provider 选 env 族,而不是按 env.Provider。 func TestMaterializeKeepsPanelGroupSetAndOrder(t *testing.T) { t.Parallel() env := materializeEnv() env.Provider = ProviderAnthropic // 播种会合成 anthropic 组,补齐不应受它影响 path := writeModelsFile(t, `[ {"id":"a","provider":"openai","baseUrl":"https://panel-a/v1","apiKey":"sk-a","model":"model-a"}, {"id":"b","provider":"openai","baseUrl":"https://panel-b/v1","apiKey":"sk-b","model":"model-b"} ]`) before := readRaw(t, path) res, err := Materialize(path, env, discardLog()) if err != nil { t.Fatalf("Materialize: %v", err) } if len(res.Groups) != 2 || res.Groups[0].ID != "a" || res.Groups[1].ID != "b" { t.Fatalf("组集合 = %+v, want 面板的两个组原序保留", res.Groups) } if res.Seeded || res.Written || readRaw(t, path) != before { t.Fatal("面板非空且无键可补时不得播种也不得改文件") } // openai 组缺 apiKey ⇒ 取**扁平块**的值(env.Provider 是 anthropic 也不改族)。 fillPath := writeModelsFile(t, `[{"id":"a","provider":"openai","baseUrl":"https://panel-a/v1","model":"model-a"}]`) res, err = Materialize(fillPath, env, discardLog()) if err != nil { t.Fatalf("Materialize: %v", err) } if got := res.Groups[0].APIKey; got != "sk-env-key" { t.Fatalf("APIKey = %q, want 扁平块的 sk-env-key(按组自己的 provider 选族)", got) } } // TestMaterializeDoesNotOverwriteBadPanel:解析失败或补齐后仍不合法的面板配置一律 // **不落盘**——损坏的配置可以人工救回,用 `.env` 盖掉它会连人写的组一起丢失。结论与 // LoadModels 一致(回退 env 合成组),只多一条可诊断的 Warn。 func TestMaterializeDoesNotOverwriteBadPanel(t *testing.T) { t.Parallel() env := materializeEnv() for _, tc := range []struct { name string raw string }{ {name: "不是 JSON", raw: `{ not json`}, {name: "顶层不是数组", raw: `{"id":"g"}`}, {name: "字段类型不对", raw: `[{"id":"g","provider":"openai","baseUrl":"https://p","model":"m","maxTokens":"abc"}]`}, {name: "补齐后仍不合法", raw: `[{"id":"g","provider":"gemini","model":"m"}]`}, } { path := writeModelsFile(t, tc.raw) before := readRaw(t, path) res, err := Materialize(path, env, discardLog()) if err != nil { t.Fatalf("%s: Materialize 不该返回错(写盘并未尝试): %v", tc.name, err) } if res.Warn == nil { t.Fatalf("%s: Warn = nil, want 一条可诊断告警", tc.name) } if res.Written || readRaw(t, path) != before { t.Fatalf("%s: 原文件被改写了", tc.name) } if len(res.Groups) != 1 || res.Groups[0].APIKey != env.APIKey { t.Fatalf("%s: 兜底组 = %+v, want env 合成组(与 LoadModels 口径一致)", tc.name, res.Groups) } } } // TestMaterializeLogsProvenanceWithoutCredentials:补齐动作必须可诊断——日志带 // source=(config/runtimeconfig)、说清补了哪些键(组 id + 键名);且**凭据绝不进日志**。 func TestMaterializeLogsProvenanceWithoutCredentials(t *testing.T) { t.Parallel() const secret = "sk-env-secret-key" env := materializeEnv() env.APIKey = secret var buf bytes.Buffer log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) path := writeModelsFile(t, `[{"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","model":"panel-model"}]`) if _, err := Materialize(path, env, log); err != nil { t.Fatalf("Materialize: %v", err) } out := buf.String() if !strings.Contains(out, `"source":"config/runtimeconfig"`) { t.Fatalf("日志缺来源标注: %s", out) } if !strings.Contains(out, "openai.apiKey") || !strings.Contains(out, `"count":1`) { t.Fatalf("日志没说清补了哪个键: %s", out) } if strings.Contains(out, secret) { t.Fatalf("日志泄漏了凭据: %s", out) } // 播种路径同样:只报组数,不打印任何组内取值。 buf.Reset() seedPath := filepath.Join(t.TempDir(), "models.json") if _, err := Materialize(seedPath, env, log); err != nil { t.Fatalf("Materialize: %v", err) } seedOut := buf.String() if !strings.Contains(seedOut, `"source":"config/runtimeconfig"`) || strings.Contains(seedOut, secret) { t.Fatalf("播种日志异常: %s", seedOut) } if _, err := os.Stat(seedPath); err != nil { t.Fatalf("播种应落盘: %v", err) } }