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