- 保存被包成事务:快照 → 落盘 → 按新配置重建工具 → 原子替换注册表条目(registry.ReplaceTool 窄方法,保留运行期 Policy);下一轮即生效,不要求重启 - 若新配置会让后端下次起不来(主家未配齐 fail-fast)⇒ 文件按字节回滚 + 409 config_unstartable - 声明面补 primary + fallback[](可配可生效);否决「另开 form」方案(会破坏既有 reauth 回填) - 主 provider 解析口径核查结论:没被 PLG-4 改坏(顺手修了一处日志缺陷) - 4 个变异自检(丢 Policy/不换条目/回滚 no-op/忽略 fallback)
283 lines
12 KiB
Go
283 lines
12 KiB
Go
package main
|
||
|
||
// 本文件钉住切片 W3 的另外三条判据(`DECISIONS-2026-10-06` 裁决 #2 / 点 ③ / 点 ④):
|
||
//
|
||
// - **主 provider / 降级链可配**:两个文件级字段(`primary` / `fallback`)经表单落到
|
||
// 可变源,回填(reauth)也带着它们;没配齐的主家**写盘之前**就拒掉;
|
||
// - **主 provider 解析口径**(配了可变源 / 只配 env / 都没配)各选谁;
|
||
// - **探针失败如实**:非 2xx + 结构化码,不存在「200 带 ok:false」这条路径。
|
||
//
|
||
// 夹具与假上游在 websearch_testenv_test.go;热重载本体在 websearch_reload_test.go。
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"strings"
|
||
"testing"
|
||
|
||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||
)
|
||
|
||
// --- ① 面板校验:选一个没配齐的主家当场拒掉 ---------------------------------
|
||
|
||
// TestWebSearchSelectionRejectsUnusablePrimary 钉住「写盘之前就拦」:主 provider 选了
|
||
// 一家没配齐的(缺 key)必须回 400 primary_not_configured,且**盘上一个字节都没变**
|
||
// (这不是「存下来再说」,那份配置会让后端下次起不来)。
|
||
func TestWebSearchSelectionRejectsUnusablePrimary(t *testing.T) {
|
||
env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||
before := env.rawFile(t)
|
||
|
||
_, err := env.save(map[string]any{
|
||
"provider": search.NameSearXNG, "enabled": true, "primary": search.NameZhipu,
|
||
})
|
||
ae, ok := pluginmgr.AsActionError(err)
|
||
if !ok || ae.Code != actionCodePrimaryNotConfigured || ae.Status != http.StatusBadRequest {
|
||
t.Fatalf("err = %#v, want 400 %s", err, actionCodePrimaryNotConfigured)
|
||
}
|
||
if got := env.rawFile(t); !bytes.Equal(got, before) {
|
||
t.Fatalf("被拒的保存动了盘上的文件:\nbefore=%s\nafter =%s", before, got)
|
||
}
|
||
|
||
// 未登记的名字:与条目级保存同一个码同一句口径。
|
||
_, err = env.save(map[string]any{
|
||
"provider": search.NameSearXNG, "enabled": true, "primary": "tavily",
|
||
})
|
||
if ae, ok := pluginmgr.AsActionError(err); !ok || ae.Code != actionCodeUnknownProvider {
|
||
t.Fatalf("err = %#v, want %s", err, actionCodeUnknownProvider)
|
||
}
|
||
// 降级链里的未登记名字同样拒(它永远不会被选中,存下去就是假话)。
|
||
_, err = env.save(map[string]any{
|
||
"provider": search.NameSearXNG, "enabled": true, "fallback": []any{"tavily"},
|
||
})
|
||
if ae, ok := pluginmgr.AsActionError(err); !ok || ae.Code != actionCodeUnknownProvider {
|
||
t.Fatalf("err = %#v, want %s", err, actionCodeUnknownProvider)
|
||
}
|
||
// 形状错(不是数组)⇒ 400,绝不静默当空数组清空降级链。
|
||
_, err = env.save(map[string]any{
|
||
"provider": search.NameSearXNG, "enabled": true, "fallback": 42,
|
||
})
|
||
if ae, ok := pluginmgr.AsActionError(err); !ok || ae.Code != pluginmgr.ActionCodeInvalidPayload {
|
||
t.Fatalf("err = %#v, want %s", err, pluginmgr.ActionCodeInvalidPayload)
|
||
}
|
||
}
|
||
|
||
// TestWebSearchSelectionAcceptsConfiguredCloudFallback 钉住「降级链允许放还没配齐的家」:
|
||
// 未配置的家在运行期会被跳过(不是错误),所以面板存得下去;而主家必须是配齐的。
|
||
// 同时钉住归一化:去空白、去重、丢空项。
|
||
func TestWebSearchSelectionAcceptsConfiguredCloudFallback(t *testing.T) {
|
||
up := newWSFakeSearxng(t, "a")
|
||
env := newReloadTestEnv(t,
|
||
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||
"WEB_SEARCH_ENDPOINT="+up.url,
|
||
)
|
||
env.mustSave(t, map[string]any{
|
||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||
"fallback": []any{search.NameZhipu, " bocha ", search.NameZhipu, ""},
|
||
})
|
||
if got := env.loadFile(t).Fallback; strings.Join(got, ",") != search.NameZhipu+","+search.NameBocha {
|
||
t.Fatalf("降级链落盘 = %v, want [zhipu bocha]", got)
|
||
}
|
||
}
|
||
|
||
// TestWebSearchReauthEchoesSelection:表单回填必须带上两个文件级字段,否则用户
|
||
// 「清空降级链」在前端的三态写回里会落成「缺位 = 不改动」。
|
||
func TestWebSearchReauthEchoesSelection(t *testing.T) {
|
||
up := newWSFakeSearxng(t, "a")
|
||
env := newReloadTestEnv(t,
|
||
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||
"WEB_SEARCH_ENDPOINT="+up.url,
|
||
)
|
||
env.mustSave(t, map[string]any{
|
||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||
"primary": search.NameSearXNG, "fallback": []any{search.NameBocha},
|
||
})
|
||
out, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||
PluginID: search.PanelPluginID, Action: search.ActionReauthProvider,
|
||
Payload: map[string]any{"row": map[string]any{"provider": search.NameSearXNG}},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("reauth: %v", err)
|
||
}
|
||
values, _ := out.(map[string]any)["values"].(map[string]any)
|
||
if values == nil {
|
||
t.Fatalf("reauth 结果缺 values: %#v", out)
|
||
}
|
||
if values[search.FieldPrimary] != search.NameSearXNG {
|
||
t.Fatalf("回填的 primary = %v, want %q", values[search.FieldPrimary], search.NameSearXNG)
|
||
}
|
||
fb, ok := values[search.FieldFallback].([]string)
|
||
if !ok || strings.Join(fb, ",") != search.NameBocha {
|
||
t.Fatalf("回填的 fallback = %#v, want [%s]", values[search.FieldFallback], search.NameBocha)
|
||
}
|
||
}
|
||
|
||
// --- ② 主 provider 解析口径(三情形 + 清空)---------------------------------
|
||
|
||
// TestWebSearchPrimaryResolutionFromEnvOnly:**只配 `.env`**(可变源文件缺失)⇒ 选 `.env`
|
||
// 那家,来源标 `env`,且播种真的落了盘。
|
||
func TestWebSearchPrimaryResolutionFromEnvOnly(t *testing.T) {
|
||
var out bytes.Buffer
|
||
cfg := wsLoadConfig(t,
|
||
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||
"WEB_SEARCH_BOCHA_API_KEY=sk-env",
|
||
)
|
||
wsSurfaceApp(t, cfg, &out)
|
||
if got := wsLogField(t, out.String(), "primary"); got != search.NameBocha {
|
||
t.Fatalf("primary = %q, want %q(.env 是唯一来源)", got, search.NameBocha)
|
||
}
|
||
if got := wsLogField(t, out.String(), "configSource"); got != webSearchSourceEnv {
|
||
t.Fatalf("configSource = %q, want %q", got, webSearchSourceEnv)
|
||
}
|
||
if _, found, err := runtimeconfig.LoadWebSearch(wsPanelPath(cfg)); err != nil || !found {
|
||
t.Fatalf("文件缺失时必须按 .env 播种落盘: found=%v err=%v", found, err)
|
||
}
|
||
}
|
||
|
||
// TestWebSearchPrimaryResolutionPanelWins:**配了可变源** ⇒ 选文件里那家(`.env` 说的
|
||
// 另一家不作数),来源标 panel。
|
||
func TestWebSearchPrimaryResolutionPanelWins(t *testing.T) {
|
||
var out bytes.Buffer
|
||
cfg := wsLoadConfig(t,
|
||
"WEB_SEARCH_PROVIDER="+search.NameZhipu,
|
||
"WEB_SEARCH_ZHIPU_API_KEY=sk-env-zhipu",
|
||
)
|
||
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
|
||
Version: runtimeconfig.WebSearchVersion,
|
||
Provider: search.NameBocha,
|
||
Providers: []runtimeconfig.ProviderEntry{
|
||
{Provider: search.NameBocha, APIKey: "sk-panel", Enabled: runtimeconfig.EnableFlag(true)},
|
||
},
|
||
}); err != nil {
|
||
t.Fatalf("写面板配置: %v", err)
|
||
}
|
||
a := wsSurfaceApp(t, cfg, &out)
|
||
if !wsHasTool(a, webSearchToolName) {
|
||
t.Fatalf("面板里 bocha 已配齐,工具应在注册面里: %v", toolNames(a))
|
||
}
|
||
if got := wsLogField(t, out.String(), "primary"); got != search.NameBocha {
|
||
t.Fatalf("primary = %q, want %q(面板文件是唯一来源,.env 说的 zhipu 不作数)", got, search.NameBocha)
|
||
}
|
||
if got := wsLogField(t, out.String(), "configSource"); got != webSearchSourcePanel {
|
||
t.Fatalf("configSource = %q, want %q", got, webSearchSourcePanel)
|
||
}
|
||
}
|
||
|
||
// TestWebSearchPrimaryResolutionDefaultsToSearxng:**都没配** ⇒ 代码默认 searxng
|
||
// (本机兜底端点),工具照常在注册面里。
|
||
func TestWebSearchPrimaryResolutionDefaultsToSearxng(t *testing.T) {
|
||
var out bytes.Buffer
|
||
cfg := wsLoadConfig(t)
|
||
a := wsSurfaceApp(t, cfg, &out)
|
||
if !wsHasTool(a, webSearchToolName) {
|
||
t.Fatalf("都没配时代码默认(searxng 本机兜底)应当生效: %v", toolNames(a))
|
||
}
|
||
if got := wsLogField(t, out.String(), "primary"); got != search.NameSearXNG {
|
||
t.Fatalf("primary = %q, want %q(代码默认值)", got, search.NameSearXNG)
|
||
}
|
||
}
|
||
|
||
// TestWebSearchPrimaryResolutionClearedFileMeansOff:文件里把 provider 清空 ⇒ **关闭**
|
||
// (不是回落 `.env`)——「清空 = 关」是文件形状里写死的语义,面板与 `.env` 不许有两套。
|
||
func TestWebSearchPrimaryResolutionClearedFileMeansOff(t *testing.T) {
|
||
var out bytes.Buffer
|
||
cfg := wsLoadConfig(t,
|
||
"WEB_SEARCH_PROVIDER="+search.NameBocha,
|
||
"WEB_SEARCH_BOCHA_API_KEY=sk-env",
|
||
)
|
||
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{
|
||
Version: runtimeconfig.WebSearchVersion,
|
||
// 显式清空主 provider:哨兵语义 = 关掉整把工具。
|
||
Provider: "off",
|
||
Providers: []runtimeconfig.ProviderEntry{
|
||
{Provider: search.NameBocha, APIKey: "sk-panel", Enabled: runtimeconfig.EnableFlag(true)},
|
||
},
|
||
}); err != nil {
|
||
t.Fatalf("写面板配置: %v", err)
|
||
}
|
||
a := wsSurfaceApp(t, cfg, &out)
|
||
if wsHasTool(a, webSearchToolName) {
|
||
t.Fatalf("文件里显式关闭(off)⇒ 工具不得进注册面: %v", toolNames(a))
|
||
}
|
||
if out := out.String(); !strings.Contains(out, "web-search tool not registered") {
|
||
t.Fatalf("关闭必须留下可诊断的 WARN: %q", out)
|
||
}
|
||
}
|
||
|
||
// --- ③ 探针失败如实:非 2xx + 结构化码,绝不「200 带 ok:false」----------------
|
||
|
||
// TestWebSearchProbeFailuresAreStructuredNon2xx 钉住裁决 #4(点 ④):每一次探针失败都必须是
|
||
// **结构化非 2xx**。前端行内动作只在失败时显示文案,把「探不通」放进 200 的 data 里
|
||
// 等于让界面报一句假成功。
|
||
//
|
||
// 探针的成功/失败分类由 search.Probe 给出(那里已有单测),这里查的是**动作面**有没有
|
||
// 把失败原样交出去:码稳定、状态码落在 4xx/5xx、文案不带上游原文。
|
||
func TestWebSearchProbeFailuresAreStructuredNon2xx(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
status int
|
||
body string
|
||
wantCode string
|
||
want int
|
||
}{
|
||
{"凭据被拒", http.StatusUnauthorized, `{"error":"bad key sk-leak"}`, search.ProbeUnauthorized, http.StatusBadGateway},
|
||
{"被限流", http.StatusTooManyRequests, `{}`, search.ProbeRateLimited, http.StatusBadGateway},
|
||
{"上游 5xx", http.StatusInternalServerError, `{}`, search.ProbeUpstream, http.StatusBadGateway},
|
||
{"应答不是预期结构", http.StatusOK, `not json`, search.ProbeBadResponse, http.StatusBadGateway},
|
||
{"连通但零结果", http.StatusOK, `{"results":[]}`, search.ProbeNoResults, http.StatusBadGateway},
|
||
}
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
up := newWSFakeUpstream(t, tc.status, tc.body)
|
||
env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||
env.mustSave(t, map[string]any{
|
||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||
})
|
||
out, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||
PluginID: search.PanelPluginID, Action: search.ActionTestProvider,
|
||
Payload: map[string]any{"row": map[string]any{"provider": search.NameSearXNG}},
|
||
})
|
||
if err == nil {
|
||
// 「200 带 ok:false」正是这条判据要堵的路:要么成功(ok=true 的结果),
|
||
// 要么结构化失败,没有第三种。
|
||
if res, ok := out.(search.ProbeResult); ok && !res.OK {
|
||
t.Fatalf("探针失败却回了成功响应(200 + ok:false):%+v", res)
|
||
}
|
||
t.Fatalf("探针失败却回了 200(结果 = %#v)", out)
|
||
}
|
||
ae, ok := pluginmgr.AsActionError(err)
|
||
if !ok {
|
||
t.Fatalf("err = %v,期望结构化 ActionError", err)
|
||
}
|
||
if ae.Code != tc.wantCode {
|
||
t.Fatalf("失败码 = %q, want %q", ae.Code, tc.wantCode)
|
||
}
|
||
if ae.Status != tc.want {
|
||
t.Fatalf("失败状态 = %d, want %d(非 2xx 才算如实)", ae.Status, tc.want)
|
||
}
|
||
raw, _ := json.Marshal(map[string]any{"code": ae.Code, "message": ae.Message})
|
||
if strings.Contains(string(raw), "sk-leak") {
|
||
t.Fatalf("失败文案泄漏了上游原文: %s", raw)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestWebSearchProbeNotConfiguredIs400:没配齐是**调用方/配置**的问题(400),
|
||
// 不是上游不可用(502)—— 前端据此区分「该改配置」还是「该重试」。
|
||
func TestWebSearchProbeNotConfiguredIs400(t *testing.T) {
|
||
env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||
_, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||
PluginID: search.PanelPluginID, Action: search.ActionTestProvider,
|
||
Payload: map[string]any{"row": map[string]any{"provider": search.NameZhipu}},
|
||
})
|
||
ae, ok := pluginmgr.AsActionError(err)
|
||
if !ok || ae.Code != search.ProbeNotConfigured || ae.Status != http.StatusBadRequest {
|
||
t.Fatalf("err = %#v, want 400 %s", err, search.ProbeNotConfigured)
|
||
}
|
||
}
|