package main // 本文件钉住切片 W3 的**热重载**(`DECISIONS-2026-10-06` 裁决 #1): // // - 面板保存 ⇒ 重建 web-search 工具 ⇒ 替换注册表条目 ⇒ **下一轮即生效** // (design/09 §5.5 的端到端判据:不改 `.env`、不重启,下一次搜索真的走新配置); // - 主 provider / 降级链经同一个表单落到文件级字段后**立刻接上工具**; // - 保存结果会让后端**下次起不来**时:非 2xx + 结构化码 + **按字节回滚**; // - 用户真正拿到的那一面(管理端点 HTTP)也逐条核对。 // // 夹具与假上游在 websearch_testenv_test.go;面板选择字段与解析口径在 // websearch_selection_test.go。 import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway" "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" ) // --- ① 保存后热重载:下一次搜索真的走新配置 ------------------------------- // TestWebSearchHotReloadPointsAtSavedEndpoint 是热重载的主判据(design/09 §5.5): // 面板把 searxng 的地址改成另一个上游 ⇒ **不重启** ⇒ 下一轮搜索必须打到新那家, // 且旧那家不再收到请求。同时钉住「注册表里的值是真的换了一把」(不是改了旧对象)。 func TestWebSearchHotReloadPointsAtSavedEndpoint(t *testing.T) { old := newWSFakeSearxng(t, "old") fresh := newWSFakeSearxng(t, "fresh") env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG, "WEB_SEARCH_ENDPOINT="+old.url, ) if _, err := env.search(t, "cyrene"); err != nil { t.Fatalf("启动期搜索失败: %v", err) } if len(old.hits) == 0 { t.Fatal("启动期没打到配置里的上游 —— 接线本身有问题") } before := env.toolValue(t) old.drain() outcome := env.mustSave(t, map[string]any{ "provider": search.NameSearXNG, "baseUrl": fresh.url, "enabled": true, }) if !outcome.Applied || !outcome.Registered { t.Fatalf("hotReload = %+v, want applied 且 registered", outcome) } if env.toolValue(t) == before { t.Fatal("注册表里的 web-search 还是启动期那一把(没被替换)") } got, err := env.search(t, "cyrene") if err != nil { t.Fatalf("保存后搜索失败: %v", err) } if len(fresh.hits) == 0 { t.Fatal("保存后的搜索没打到新上游 —— 改动没生效(还得重启)") } old.noHit(t) if got["provider"] != search.NameSearXNG { t.Fatalf("provider = %v, want %q", got["provider"], search.NameSearXNG) } } // TestWebSearchHotReloadKeepsCapabilityPolicy:热替换必须**保留**能力面策略 // (分组/陌生人可达标记是运行期配置,不属于工具实现)。丢了它 = 一次面板保存让某个群的 // 工具面悄悄缩水。 func TestWebSearchHotReloadKeepsCapabilityPolicy(t *testing.T) { up := newWSFakeSearxng(t, "a") env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG, "WEB_SEARCH_ENDPOINT="+up.url, ) if err := env.app.caps.SetAllowInStranger(webSearchToolName, true); err != nil { t.Fatalf("SetAllowInStranger: %v", err) } env.mustSave(t, map[string]any{ "provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, }) pol, err := env.app.caps.Policy(webSearchToolName) if err != nil { t.Fatalf("Policy: %v", err) } if !pol.AllowInStranger { t.Fatal("热替换丢掉了 AllowInStranger(策略是运行期配置,必须沿用)") } } // --- ② 主 provider / 降级链:面板可配且立刻生效 ----------------------------- // TestWebSearchHotReloadSwitchesPrimaryAndFallback 钉住裁决 #2 的端到端语义: // 面板选中谁当主家,下一次搜索就走谁;主家不可用时按降级链前进并**如实标注**实际应答的家。 func TestWebSearchHotReloadSwitchesPrimaryAndFallback(t *testing.T) { searxng := newWSFakeUpstream(t, http.StatusInternalServerError, `{"error":"boom"}`) bocha := newWSFakeUpstream(t, http.StatusOK, `{"webPages":{"value":[{"name":"博查结果","url":"https://b.example/1","summary":"正文"}]}}`) env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG, "WEB_SEARCH_ENDPOINT="+searxng.url, "WEB_SEARCH_BOCHA_API_KEY=sk-bocha", "WEB_SEARCH_BOCHA_BASE_URL="+bocha.url, ) // 选 bocha 当主家(文件级 primary),降级链留 searxng:两者都在盘上、都已配齐。 outcome := env.mustSave(t, map[string]any{ "provider": search.NameBocha, "apiKey": "sk-bocha", "baseUrl": bocha.url, "enabled": true, "primary": search.NameBocha, "fallback": []any{search.NameSearXNG}, }) if !outcome.Applied { t.Fatalf("hotReload = %+v, want applied", outcome) } got, err := env.search(t, "cyrene") if err != nil { t.Fatalf("切换主家后搜索失败: %v", err) } if got["provider"] != search.NameBocha { t.Fatalf("provider = %v, want %q(面板选的主家必须立刻生效)", got["provider"], search.NameBocha) } if len(bocha.hits) == 0 { t.Fatal("选中的新主家一个请求都没收到") } // 把主家换回 searxng(它 500)⇒ 必须**降级到 bocha** 并如实标注是谁答的。 bocha.drain() searxng.drain() env.mustSave(t, map[string]any{ "provider": search.NameSearXNG, "baseUrl": searxng.url, "enabled": true, "primary": search.NameSearXNG, "fallback": []any{search.NameBocha}, }) got, err = env.search(t, "cyrene") if err != nil { t.Fatalf("降级链搜索失败: %v", err) } if got["provider"] != search.NameBocha { t.Fatalf("provider = %v, want %q(主力 500 时必须前进一格并如实标注)", got["provider"], search.NameBocha) } if len(searxng.hits) == 0 { t.Fatal("降级链没先试主力 searxng") } } // TestWebSearchHotReloadTogglesToolByPrimary 钉住「关掉就整把消失、再开就回来」: // 主 provider 写成 off 哨兵 ⇒ 模型看不见这把工具(与启动期语义一致);改回一家可用的 // ⇒ 同一进程内它又回到注册面。 func TestWebSearchHotReloadTogglesToolByPrimary(t *testing.T) { up := newWSFakeSearxng(t, "a") env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG, "WEB_SEARCH_ENDPOINT="+up.url, ) off := env.mustSave(t, map[string]any{ "provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, "primary": "off", }) if !off.Applied || off.Registered { t.Fatalf("hotReload = %+v, want applied=true registered=false", off) } if wsHasTool(env.app, webSearchToolName) { t.Fatalf("主 provider 写成 off 后 web-search 不得留在注册面里: %v", toolNames(env.app)) } back := env.mustSave(t, map[string]any{ "provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, "primary": search.NameSearXNG, }) if !back.Applied || !back.Registered { t.Fatalf("hotReload = %+v, want applied=true registered=true", back) } if _, err := env.search(t, "cyrene"); err != nil { t.Fatalf("改回可用主家后搜索应当成功: %v", err) } } // --- ③ 回滚:不让面板存出一份「下次起不来」的配置 --------------------------- // TestWebSearchHotReloadRollsBackUnstartableSave 是安全网的判据:面板把**当前主家**的 // 凭据清空后,这份配置会让后端下次启动直接失败(design/27 §3 的 fail-fast)⇒ 保存必须 // 诚实失败、把文件**按字节**回滚,并且运行中的工具必须仍是原来那一把。 func TestWebSearchHotReloadRollsBackUnstartableSave(t *testing.T) { bocha := newWSFakeUpstream(t, http.StatusOK, `{"webPages":{"value":[{"name":"博查","url":"https://b.example/1","summary":"正文"}]}}`) env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameBocha, "WEB_SEARCH_BOCHA_API_KEY=sk-keep", "WEB_SEARCH_BOCHA_BASE_URL="+bocha.url, ) before := env.rawFile(t) toolBefore := env.toolValue(t) _, err := env.save(map[string]any{"provider": search.NameBocha, "apiKey": ""}) ae, ok := pluginmgr.AsActionError(err) if !ok { t.Fatalf("err = %v,期望结构化 ActionError(清空主家凭据会让后端起不来)", err) } if ae.Code != actionCodeConfigUnstartable { t.Fatalf("失败码 = %q, want %q", ae.Code, actionCodeConfigUnstartable) } if ae.Status < 400 || ae.Status > 599 { t.Fatalf("失败状态 = %d,必须是非 2xx 的结构化失败", ae.Status) } if strings.Contains(ae.Message, "sk-keep") { t.Fatalf("失败文案泄漏了凭据: %q", ae.Message) } if got := env.rawFile(t); !bytes.Equal(got, before) { t.Fatalf("回滚后文件与保存前不逐字节相同:\nbefore=%s\nafter =%s", before, got) } if env.toolValue(t) != toolBefore { t.Fatal("被回滚的保存却把注册表里的工具换掉了(盘上与内存不一致)") } got, err := env.search(t, "cyrene") if err != nil { t.Fatalf("回滚后搜索应当照常成功: %v", err) } if got["provider"] != search.NameBocha { t.Fatalf("回滚后 provider = %v, want %q", got["provider"], search.NameBocha) } if out := env.out.String(); !strings.Contains(out, "rolled back") { t.Fatalf("回滚必须留下可诊断的日志: %q", out) } } // TestWebSearchReloadWithoutRegistryIsHonest:没有能力注册表(夹具 / 装配未完成)时, // 热重载**如实报告 applied=false**,绝不假装换过了 —— 否则响应里的 registered 会是一句假话。 func TestWebSearchReloadWithoutRegistryIsHonest(t *testing.T) { cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) b := bus.New() defer b.Close() mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog()) defer mgr.Close(context.Background()) // 故意不设 caps:这正是既有动作夹具(actionTestEnv)的形状。 a := &app{cfg: cfg, log: testLog(), bus: b, plugins: mgr} wirePanelHub(a) res, err := mgr.Panels().Dispatch(context.Background(), pluginmgr.ActionRequest{ PluginID: search.PanelPluginID, Action: search.ActionSaveProvider, Payload: map[string]any{"values": map[string]any{ "provider": search.NameSearXNG, "baseUrl": "http://127.0.0.1:38471", "enabled": true, }}, }) if err != nil { t.Fatalf("保存失败: %v", err) } outcome, ok := res.(map[string]any)["hotReload"].(webSearchReloadOutcome) if !ok { t.Fatalf("响应里没有 hotReload: %#v", res) } if outcome.Applied || outcome.Registered { t.Fatalf("没有注册表可换时必须如实报 applied=false: %+v", outcome) } } // --- ④ 用户真正拿到的那一面:管理端点(HTTP)--------------------------------- // adminActionHTTP 把组合根装配出来的动作端点架起来,返回 base URL 与 owner 令牌。 // // 网关这一段(鉴权 → 声明 → 分发 → 状态码映射)不是本片改的,但用户拿到的是它: // 只在动作中枢上验,没法证明「契约里那个 409 真的到得了客户端」。 func adminActionHTTP(t *testing.T, env *reloadTestEnv) (string, string) { t.Helper() jwtSvc := auth.New(config.AuthConfig{JWTSecret: obsTestSecret}) gw, err := gateway.New(&config.Config{ Auth: config.AuthConfig{JWTSecret: obsTestSecret}, }, gateway.Deps{Log: testLog(), Auth: jwtSvc, Plugins: env.app.plugins}) if err != nil { t.Fatalf("gateway.New: %v", err) } t.Cleanup(gw.Close) ts := httptest.NewServer(gw.Handler()) t.Cleanup(ts.Close) tok, err := jwtSvc.Sign("acct", auth.RoleOwner, time.Hour) if err != nil { t.Fatalf("sign token: %v", err) } return ts.URL + "/api/v1/admin/plugins/" + search.PanelPluginID + "/actions/" + search.ActionSaveProvider, tok } // TestWebSearchHotReloadOverAdminHTTP 核对成功路径的 HTTP 契约:200 信封里的 // `result.hotReload` 必须如实,而且保存后下一次搜索**真的**打到新上游。 func TestWebSearchHotReloadOverAdminHTTP(t *testing.T) { cloud := newWSFakeUpstream(t, http.StatusOK, `{"webPages":{"value":[{"name":"博查","url":"https://b.example/1","summary":"正文"}]}}`) env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameBocha, "WEB_SEARCH_BOCHA_API_KEY=sk-keep", "WEB_SEARCH_BOCHA_BASE_URL="+cloud.url, ) url, tok := adminActionHTTP(t, env) status, raw := wsPostAction(t, url, tok, map[string]any{"values": map[string]any{ "provider": search.NameSearXNG, "baseUrl": cloud.url, "enabled": true, "primary": search.NameBocha, }}) if status != http.StatusOK { t.Fatalf("保存状态 = %d, want 200(body=%s)", status, raw) } var body struct { Data struct { Action string `json:"action"` Kind string `json:"kind"` Result struct { Saved bool `json:"saved"` HotReload struct { Applied bool `json:"applied"` Registered bool `json:"registered"` } `json:"hotReload"` } `json:"result"` } `json:"data"` } if err := json.Unmarshal(raw, &body); err != nil { t.Fatalf("decode: %v (body=%s)", err, raw) } if body.Data.Kind != string(pluginmgr.ActionWrite) { t.Errorf("kind = %q, want %q(写动作必须按写面鉴权)", body.Data.Kind, pluginmgr.ActionWrite) } if !body.Data.Result.Saved || !body.Data.Result.HotReload.Applied || !body.Data.Result.HotReload.Registered { t.Fatalf("响应 envelope 里的结果不如实: %s", raw) } got, err := env.search(t, "cyrene") if err != nil { t.Fatalf("HTTP 保存后搜索失败: %v", err) } if got["provider"] != search.NameBocha { t.Fatalf("provider = %v, want %q(HTTP 保存也必须热重载)", got["provider"], search.NameBocha) } } // TestWebSearchSaveOverAdminHTTPRollsBackUnstartable 核对失败路径的 HTTP 契约: // **非 2xx** + `{error:{code,message}}`,且盘上文件真的回到了保存前。 func TestWebSearchSaveOverAdminHTTPRollsBackUnstartable(t *testing.T) { cloud := newWSFakeUpstream(t, http.StatusOK, `{"webPages":{"value":[{"name":"博查","url":"https://b.example/1","summary":"正文"}]}}`) env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameBocha, "WEB_SEARCH_BOCHA_API_KEY=sk-keep", "WEB_SEARCH_BOCHA_BASE_URL="+cloud.url, ) url, tok := adminActionHTTP(t, env) before := env.rawFile(t) status, raw := wsPostAction(t, url, tok, map[string]any{"values": map[string]any{ "provider": search.NameBocha, "apiKey": "", }}) if status != http.StatusConflict { t.Fatalf("状态 = %d, want 409(body=%s)", status, raw) } var body struct { Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } if err := json.Unmarshal(raw, &body); err != nil { t.Fatalf("decode error body: %v (body=%s)", err, raw) } if body.Error.Code != actionCodeConfigUnstartable { t.Fatalf("错误码 = %q, want %q(body=%s)", body.Error.Code, actionCodeConfigUnstartable, raw) } if strings.Contains(body.Error.Message, "sk-keep") || body.Error.Message == "" { t.Fatalf("错误文案 = %q(不得为空、不得含凭据)", body.Error.Message) } if got := env.rawFile(t); !bytes.Equal(got, before) { t.Fatalf("HTTP 层的回滚不彻底:\nbefore=%s\nafter =%s", before, got) } } // wsPostAction 向管理动作端点发一次 POST(Bearer + JSON 体),返回状态码与响应体。 func wsPostAction(t *testing.T, url, token string, payload any) (int, []byte) { t.Helper() raw, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal payload: %v", err) } req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(raw)) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("POST %s: %v", url, err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read %s: %v", url, err) } return resp.StatusCode, body }