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) } }