Files
Cyrene_v2/cmd/cyrene/websearch_reload_test.go
AskaEth a30f4a49ff feat(search): provider 热重载 + primary/fallback(W3)
- 保存被包成事务:快照 → 落盘 → 按新配置重建工具 → 原子替换注册表条目(registry.ReplaceTool
  窄方法,保留运行期 Policy);下一轮即生效,不要求重启
- 若新配置会让后端下次起不来(主家未配齐 fail-fast)⇒ 文件按字节回滚 + 409 config_unstartable
- 声明面补 primary + fallback[](可配可生效);否决「另开 form」方案(会破坏既有 reauth 回填)
- 主 provider 解析口径核查结论:没被 PLG-4 改坏(顺手修了一处日志缺陷)
- 4 个变异自检(丢 Policy/不换条目/回滚 no-op/忽略 fallback)
2026-10-06 15:59:12 +08:00

407 lines
16 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 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
}