Files
Cyrene_v2/cmd/cyrene/websearch_selection_test.go
T
AskaEth aa0bf8b5a0
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(websearch): 面板「填 key + 同一次选它当主 provider」不再被自己的校验回滚
真机报障(2026-10-07 09:50):面板上给 zhipu 填了 API Key,保存却一直回
400 primary_not_configured;盘上配置文件 mtime 一动不动 —— 连刚填的 key
一起被回滚,界面表现为「怎么填都存不进去」。

根因(顺序错误):saveProvider 先 applySelectionPatch(内含「主 provider 必须
已配齐」的校验),后 UpsertProviderEntry。校验看的是**盘上的旧文件**,而这次
提交里的 key 还没落盘 ⇒ 校验判它没配齐,整笔拒绝。

修法:把校验从 applySelectionPatch 里摘出来,改名 mergeSelectionPatch(只合并
文件级字段 + 回报有没有给 primary),由 saveProvider 在 UpsertProviderEntry
**之后**对最终文件校验,再写盘。判据本身一字未改 —— 「真的没配齐的主家照样拒」。

验证:cmd/cyrene 整包测试绿;新增回归 TestWebSearchSaveProviderAndPrimaryInOneSubmit
(同一次提交填 key + 选主家 ⇒ 200 且盘上 key 与 primary 都在;反向:真没配齐仍 400
且文件字节不变);用 -overlay 把校验改回「拿旧文件校验」⇒ 该用例变红且报错文案
与主人看到的逐字相同。
2026-10-07 09:56:37 +08:00

329 lines
15 KiB
Go
Raw 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` 裁决 #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)
}
}
// TestWebSearchSaveProviderAndPrimaryInOneSubmit 是 2026-10-07 真机报障的回归:
// 面板上第一次给某家填 key、又在**同一次提交**里把它选为主 provider,必须**一次成功**。
//
// 缺陷原形:主 provider 的可用性校验拿的是**盘上的旧文件**(那条 provider 还没 key)
// ⇒ 整笔(**连同刚填的 key**)被 400 primary_not_configured 回滚,界面上表现为
// 「怎么填都存不进去」,而盘上文件 mtime 一动不动。修法:校验移到「本次条目已并进
// 文件之后」(websearch_action_data.go 的 saveProvider 先 UpsertProviderEntry 再 check)。
func TestWebSearchSaveProviderAndPrimaryInOneSubmit(t *testing.T) {
env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
// 前置:盘上那条 zhipu 没有 key —— 面板第一次配置它时正是这个状态。
if e, ok := findProviderEntry(env.loadFile(t), search.NameZhipu); !ok || strings.TrimSpace(e.APIKey) != "" {
t.Fatalf("前置不成立:盘上 zhipu 应存在且尚无 key(found=%v, key=%q)", ok, e.APIKey)
}
if _, err := env.save(map[string]any{
"provider": search.NameZhipu, "apiKey": "test-key-001", "enabled": true,
"primary": search.NameZhipu,
}); err != nil {
t.Fatalf("同一次提交里填 key + 选它当主家被拒(键与选择必须能一起落盘): %v", err)
}
after := env.loadFile(t)
if got := strings.ToLower(strings.TrimSpace(after.Provider)); got != search.NameZhipu {
t.Fatalf("盘上 primary = %q, want %q", got, search.NameZhipu)
}
entry, ok := findProviderEntry(after, search.NameZhipu)
if !ok || entry.APIKey != "test-key-001" {
t.Fatalf("盘上 zhipu 条目 = %+v, want 带本次填的 key", entry)
}
// 反向护栏:**真的没配齐**的主家仍必须被拒,且盘上一个字节都不动。
// (本次修复只改「校验看哪一份文件」,不改判据。)
beforeReject := env.rawFile(t)
_, err := env.save(map[string]any{
"provider": search.NameBocha, "enabled": true, "primary": search.NameBocha,
})
ae, ok := pluginmgr.AsActionError(err)
if !ok || ae.Code != actionCodePrimaryNotConfigured {
t.Fatalf("err = %#v, want 400 %s(bocha 这次没填 key)", err, actionCodePrimaryNotConfigured)
}
if got := env.rawFile(t); !bytes.Equal(got, beforeReject) {
t.Fatalf("被拒的保存动了盘上的文件:\nbefore=%s\nafter =%s", beforeReject, got)
}
}