package gateway import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "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" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件钉住「重置为 env 默认」动作(切片 R1,实现见 admin_write_llm_reset.go)的 // **语义**侧:只覆盖 `.env` 声明过的键、env 未声明的键原样保留、组集合不增减、幂等。 // 护栏(confirm / RBAC / 审计)与失败路径在 admin_write_llm_reset_guard_test.go。 // // 用例全部走**临时目录 + 临时 `.env` 文件 + 临时 models.json**:仓库根的 `.env` 与 // `data/` 一个字节都不碰。 const ( // resetEnvKey / resetPanelKey 是两个必须**永不回显**的凭据明文。 resetEnvKey = "sk-env-reset-plaintext" resetPanelKey = "sk-panel-reset-plaintext" ) // resetPanelJSON 是面板(models.json)的初始状态:openai 组与 anthropic 组的面板值 // 全都与临时 `.env` 的声明不同,且带几把 env 里**没有出处**的键(fallback / // multimodal / maxTokens / reasoning / personaModels)——它们必须原样活下来。 const resetPanelJSON = `[ {"id":"panel-openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"` + resetPanelKey + `","model":"panel-model", "fallback":"panel-fallback","multimodal":true,"maxTokens":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","fallback":"panel-ant-fallback"} ]` // llmResetFixture 是一次重置用例的运行环境。 type llmResetFixture struct { modelsPath string envPath string runtime *model.RuntimeLLM ts *httptest.Server jwt *auth.JWTService before string } // newLlmResetFixture 写面板文件 + 临时 `.env`,接上可热重载的 LLM 栈并起测试服务。 // envPath 留空 = 用临时 `.env`;「env 源不可读」的用例自己传一个目录路径进来。 func newLlmResetFixture(t *testing.T, envPath, panel string, deps Deps) *llmResetFixture { t.Helper() modelsPath, before := writeResetPanelFile(t, panel) if envPath == "" { envPath = writeResetEnvFile(t) } runtime := newLlmRuntimeForGateway(t, modelsPath, llmEnvForTest()) deps.LLM = runtime deps.EnvPath = envPath _, ts, jwt := buildEnv(t, cfgDefault(), deps) return &llmResetFixture{modelsPath: modelsPath, envPath: envPath, runtime: runtime, ts: ts, jwt: jwt, before: before} } // post 发一次重置请求(token 为空 = 匿名)。 func (f *llmResetFixture) post(t *testing.T, tok string, body map[string]any) (*http.Response, []byte) { t.Helper() return doReq(t, http.MethodPost, f.ts.URL+"/api/v1/admin/llm/reset-from-env", body, tok) } // ownerToken 签发 owner token(每个用例自带一个,避免共享状态)。 func (f *llmResetFixture) ownerToken(t *testing.T) string { t.Helper() return signAny(t, f.jwt, "acct", auth.RoleOwner) } // writeResetEnvFile 写一份临时 `.env` 默认层并返回其路径(`LLM_PROVIDER` 缺省按 // 标签默认 openai;两族都声明,好让用例分辨「按组自己的 provider 选族」)。 func writeResetEnvFile(t *testing.T) string { t.Helper() content := strings.Join([]string{ "# 重置用例的临时 env 默认层(绝不写到仓库根 .env)", "LLM_BASE_URL=https://env.example/v1", "LLM_API_KEY=" + resetEnvKey, "LLM_MODEL=env-model", "LLM_CONTEXT_WINDOW=131072", "ANTHROPIC_BASE_URL=https://env-anthropic.example", "ANTHROPIC_API_KEY=sk-env-anthropic-plaintext", "ANTHROPIC_MODEL=env-claude", "", }, "\n") path := filepath.Join(t.TempDir(), "reset.env") if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatalf("写临时 .env: %v", err) } return path } // writeResetPanelFile 写一份面板 models.json,返回路径与文件原文。 func writeResetPanelFile(t *testing.T, panel string) (string, string) { t.Helper() path := filepath.Join(t.TempDir(), "models.json") if err := os.WriteFile(path, []byte(panel), 0o600); err != nil { t.Fatalf("写面板 models.json: %v", err) } return path, readResetPanel(t, path) } // readResetPanel 读回面板文件原文(幂等/不动的断言必须比字节,不能只看解出来的组)。 func readResetPanel(t *testing.T, path string) string { t.Helper() raw, err := os.ReadFile(path) if err != nil { t.Fatalf("读回 %s: %v", path, err) } return string(raw) } // llmResetResponse 是 POST /api/v1/admin/llm/reset-from-env 的响应体:字段名逐字 // 对齐网关 DTO(llmResetGroup / llmResetSkip)。 type llmResetResponse struct { Reset []struct { Group string `json:"group"` Keys []string `json:"keys"` } `json:"reset"` Skipped []struct { Group string `json:"group"` Reason string `json:"reason"` } `json:"skipped"` Path string `json:"path"` } // decodeResetResponse 解响应体的 data 段。 func decodeResetResponse(t *testing.T, body []byte) llmResetResponse { t.Helper() var res llmResetResponse if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil { t.Fatalf("decode reset response: %v (body=%s)", err, body) } return res } // resetKeys 取某个组被改回的键名。 func resetKeys(t *testing.T, res llmResetResponse, group string) []string { t.Helper() for _, g := range res.Reset { if g.Group == group { return g.Keys } } t.Fatalf("组 %q 不在 reset 列表里: %+v", group, res.Reset) return nil } // diskGroups 从磁盘读回组(真实落盘断言)。 func diskGroups(t *testing.T, path string) []runtimeconfig.Group { t.Helper() groups, warn := runtimeconfig.LoadModels(path, config.LLMConfig{}) if warn != nil { t.Fatalf("LoadModels: warn = %v", warn) } return groups } func TestAdminResetLlmFromEnvOnlyResetsEnvDeclaredKeys(t *testing.T) { t.Parallel() f := newLlmResetFixture(t, "", resetPanelJSON, Deps{}) tok := f.ownerToken(t) resp, body := f.post(t, tok, map[string]any{"confirm": true}) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } res := decodeResetResponse(t, body) if res.Path != f.modelsPath { t.Fatalf("path = %q, want %q", res.Path, f.modelsPath) } // 只回**真的被改回**的键(provider 在 env 默认层里也有声明,但面板值已一致 ⇒ 不算改回)。 wantKeys := []string{"baseUrl", "apiKey", "model", "contextWindow"} if got := resetKeys(t, res, "panel-openai"); !equalStringSlices(got, wantKeys) { t.Fatalf("panel-openai 改回的键 = %v, want %v", got, wantKeys) } if got := resetKeys(t, res, "panel-anthropic"); !equalStringSlices(got, wantKeys) { t.Fatalf("panel-anthropic 改回的键 = %v, want %v(anthropic 族按组自己的 provider 选族)", got, wantKeys) } if len(res.Skipped) != 0 { t.Fatalf("skipped = %+v, want 空(两组都有键被改回)", res.Skipped) } // 磁盘真值:env 值覆盖,env 未声明的键原样保留。 groups := diskGroups(t, f.modelsPath) if len(groups) != 2 || groups[0].ID != "panel-openai" || groups[1].ID != "panel-anthropic" { t.Fatalf("组集合/顺序 = %+v, want 原样的两组", groups) } g := groups[0] if g.BaseURL != "https://env.example/v1" || g.APIKey != resetEnvKey || g.Model != "env-model" || g.ContextWindow != 131072 { 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("env 未声明的键被动了: %+v", g) } ant := groups[1] if ant.BaseURL != "https://env-anthropic.example" || ant.APIKey != "sk-env-anthropic-plaintext" || ant.Model != "env-claude" || ant.Fallback != "panel-ant-fallback" { t.Fatalf("panel-anthropic = %+v, want anthropic 族的 env 值 + 保留 fallback", ant) } // 运行中的栈真的换了配置(热重载,无需重启):生效组(下标 0)已退回 env 基线。 if f.runtime.Group().ID != "panel-openai" || f.runtime.Model() != "env-model" { t.Fatalf("runtime group=%q model=%q, want 热重载到 env 基线", f.runtime.Group().ID, f.runtime.Model()) } // 凭据纪律:两个 key 的明文都不在响应体里(只回键名)。 for _, secret := range []string{resetEnvKey, resetPanelKey, "sk-env-anthropic-plaintext", "sk-panel-ant"} { if strings.Contains(string(body), secret) { t.Fatalf("响应体回显了凭据 %q: %s", secret, body) } } } // equalStringSlices 比对两份字符串切片(顺序敏感:键顺序即 groupFillSpecs 顺序)。 func equalStringSlices(got, want []string) bool { if len(got) != len(want) { return false } for i := range got { if got[i] != want[i] { return false } } return true } // TestAdminResetLlmFromEnvKeepsGroupSet 钉住「组集合不增减」:面板删掉的 anthropic 组 // 不因 `.env` 仍声明它而复活,面板新增的组也不被删除(顺序一并保持)。 func TestAdminResetLlmFromEnvKeepsGroupSet(t *testing.T) { t.Parallel() panel := `[ {"id":"panel-extra","provider":"openai","baseUrl":"https://panel-extra.example/v1","apiKey":"` + resetPanelKey + `","model":"panel-extra-model"} ]` f := newLlmResetFixture(t, "", panel, Deps{}) tok := f.ownerToken(t) resp, body := f.post(t, tok, map[string]any{"confirm": true}) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } if got := resetKeys(t, decodeResetResponse(t, body), "panel-extra"); !equalStringSlices(got, []string{"baseUrl", "apiKey", "model", "contextWindow"}) { t.Fatalf("panel-extra 改回的键 = %v, want [baseUrl apiKey model contextWindow]", got) } groups := diskGroups(t, f.modelsPath) if len(groups) != 1 || groups[0].ID != "panel-extra" { t.Fatalf("组集合 = %+v, want 只有面板那一组(不复活被删的 anthropic 组)", groups) } if _, ok := runtimeconfig.Find(groups, runtimeconfig.ProviderAnthropic); ok { t.Fatal("`.env` 声明的 anthropic 组被复活了, want 不新增组") } } // TestAdminResetLlmFromEnvIdempotent 钉住幂等:第二次调用没有任何键要改,于是不写盘、 // 两组都报「已与 env 一致」。 func TestAdminResetLlmFromEnvIdempotent(t *testing.T) { t.Parallel() f := newLlmResetFixture(t, "", resetPanelJSON, Deps{}) tok := f.ownerToken(t) if resp, body := f.post(t, tok, map[string]any{"confirm": true}); resp.StatusCode != http.StatusOK { t.Fatalf("第一次 status = %d, want 200 (body=%s)", resp.StatusCode, body) } afterFirst := readResetPanel(t, f.modelsPath) resp, body := f.post(t, tok, map[string]any{"confirm": true}) if resp.StatusCode != http.StatusOK { t.Fatalf("第二次 status = %d, want 200 (body=%s)", resp.StatusCode, body) } res := decodeResetResponse(t, body) if len(res.Reset) != 0 { t.Fatalf("reset = %+v, want 空(面板值已与 env 一致)", res.Reset) } if len(res.Skipped) != 2 { t.Fatalf("skipped = %+v, want 两组都报「已一致」", res.Skipped) } for _, s := range res.Skipped { if s.Reason != runtimeconfig.SkipReasonAlreadyDefault { t.Fatalf("跳过原因 = %q, want %q", s.Reason, runtimeconfig.SkipReasonAlreadyDefault) } } if readResetPanel(t, f.modelsPath) != afterFirst { t.Fatal("无键可改时文件被改动了, want 逐字节不变") } } // TestAdminResetLlmFromEnvSkipsFamilyEnvDidNotDeclare 钉住面板上那条跳过原因的诚实性: // `.env` 只配了扁平 openai 块时,anthropic 族没有可改回的键,跳过原因必须是 // 「`.env` 未声明该组的默认键」而不是误导性的「面板值已一致」。 func TestAdminResetLlmFromEnvSkipsFamilyEnvDidNotDeclare(t *testing.T) { t.Parallel() envPath := filepath.Join(t.TempDir(), "openai-only.env") content := "LLM_BASE_URL=https://env.example/v1\nLLM_API_KEY=" + resetEnvKey + "\nLLM_MODEL=env-model\n" if err := os.WriteFile(envPath, []byte(content), 0o600); err != nil { t.Fatalf("写临时 .env: %v", err) } f := newLlmResetFixture(t, envPath, resetPanelJSON, Deps{}) tok := f.ownerToken(t) resp, body := f.post(t, tok, map[string]any{"confirm": true}) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } res := decodeResetResponse(t, body) if got := resetKeys(t, res, "panel-openai"); !equalStringSlices(got, []string{"baseUrl", "apiKey", "model"}) { t.Fatalf("panel-openai 改回的键 = %v, want [baseUrl apiKey model](扁平块声明的就是这三个)", got) } reason := "" for _, s := range res.Skipped { if s.Group == "panel-anthropic" { reason = s.Reason } } if reason != runtimeconfig.SkipReasonNoDefaults { t.Fatalf("panel-anthropic 的跳过原因 = %q, want %q", reason, runtimeconfig.SkipReasonNoDefaults) } }