真机报障(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 把校验改回「拿旧文件校验」⇒ 该用例变红且报错文案 与主人看到的逐字相同。
329 lines
15 KiB
Go
329 lines
15 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)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|