Files
Cyrene_v2/internal/services/gateway/admin_write_llm_reset_test.go
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

327 lines
13 KiB
Go
Raw Permalink 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 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)
}
}