package main import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "path/filepath" "sort" "strings" "testing" "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/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" ) // 本文件钉住切片 L1/PLG-2 的**执行面接线**:内置插件 web-search 的四个动作 + // 下拉候选来源真的被登记到插件管理器上,并且真的能读写可变源、真发一次探针请求。 // // 为什么在组合根测:处理器与可变源的**接线事实**(哪个文件、哪份默认值、哪个客户端) // 只在这里成立;插件管理器的单测只能证明「登记面会路由」,证明不了「搜索页点得动」。 // actionTestEnv 是一个接好面板中枢 + 动作处理器的组合根夹具。 type actionTestEnv struct { app *app mgr *pluginmgr.Manager hub *pluginmgr.Panels // path 是可变源落点($DATA_DIR/config/plugins/web-search.json)。 path string } // newActionTestEnv 按组合根的**同一条路**接线:wirePanelHub(声明 + 动作处理器登记)。 // extra 是额外的 .env 行,用来控制代码默认值(如 WEB_SEARCH_PROVIDER)。 func newActionTestEnv(t *testing.T, extra ...string) *actionTestEnv { t.Helper() cfg := wsLoadConfig(t, extra...) b := bus.New() t.Cleanup(func() { _ = b.Close() }) mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog()) t.Cleanup(func() { mgr.Close(context.Background()) }) a := &app{cfg: cfg, log: testLog(), bus: b, plugins: mgr} wirePanelHub(a) hub := mgr.Panels() if hub == nil { t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上") } return &actionTestEnv{app: a, mgr: mgr, hub: hub, path: runtimeconfig.WebSearchConfigPath(cfg.DataDir)} } // dispatch 调一次动作(走生产同一条 Dispatch:声明 → 启用 → 处理器)。 func (e *actionTestEnv) dispatch(t *testing.T, action string, payload any) (any, error) { t.Helper() return e.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{ PluginID: search.PanelPluginID, Action: action, Payload: payload, }) } // mustDispatch 调一次动作并要求成功。 func (e *actionTestEnv) mustDispatch(t *testing.T, action string, payload any) any { t.Helper() out, err := e.dispatch(t, action, payload) if err != nil { t.Fatalf("dispatch %s: %v", action, err) } return out } // saveProvider 走保存动作(表单形状 `{values:{...}}`)。 func (e *actionTestEnv) saveProvider(t *testing.T, values map[string]any) any { t.Helper() return e.mustDispatch(t, search.ActionSaveProvider, map[string]any{"values": values}) } // rowsOf 把列表动作的结果解成面板行。 func (e *actionTestEnv) rowsOf(t *testing.T) []search.ProviderRow { t.Helper() out := e.mustDispatch(t, search.ActionListProviders, map[string]any{}) m, ok := out.(map[string]any) if !ok { t.Fatalf("列表结果类型 = %T", out) } rows, ok := m["rows"].([]search.ProviderRow) if !ok { t.Fatalf("列表结果缺 rows: %#v", m) } return rows } // rowOf 取某一家在表格里的那一行(找不到则失败)。 func (e *actionTestEnv) rowOf(t *testing.T, provider string) search.ProviderRow { t.Helper() for _, r := range e.rowsOf(t) { if r.Provider == provider { return r } } t.Fatalf("表格里没有 %q 这一行(rows = %+v)", provider, e.rowsOf(t)) return search.ProviderRow{} } // loadFile 读盘上的可变源(断言「真的落盘了」时用)。 func (e *actionTestEnv) loadFile(t *testing.T) runtimeconfig.WebSearchFile { t.Helper() f, found, err := runtimeconfig.LoadWebSearch(e.path) if err != nil { t.Fatalf("LoadWebSearch(%s): %v", e.path, err) } if !found { t.Fatalf("可变源不在盘上: %s", e.path) } return f } // entryOf 取盘上某家的条目。 func (e *actionTestEnv) entryOf(t *testing.T, provider string) runtimeconfig.ProviderEntry { t.Helper() for _, p := range e.loadFile(t).Providers { if p.Provider == provider { return p } } t.Fatalf("盘上没有 %q 条目", provider) return runtimeconfig.ProviderEntry{} } // TestWirePanelHubRegistersBuiltinActions 是接线的主判据:内置插件的四个动作与 // 下拉候选来源都进了登记面,且每一个都能真的被 Dispatch 执行(不再回 501)。 func TestWirePanelHubRegistersBuiltinActions(t *testing.T) { env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) want := append([]string(nil), search.ActionNames()...) want = append(want, pluginmgr.OptionsActionName("plugin:providers")) registered := env.hub.ActionHandlerNames(search.PanelPluginID) sort.Strings(want) if strings.Join(registered, ",") != strings.Join(want, ",") { t.Fatalf("已登记的处理器 = %v, want %v", registered, want) } // 每个已登记的动作都必须**已声明**(否则登记它等于登记一个永远调不到的名字)。 for _, name := range registered { if _, ok := env.hub.Action(search.PanelPluginID, name); !ok { t.Errorf("动作 %q 已登记但没声明(永远不可达)", name) } } // 声明里的每一个动作都必须有处理器 —— 否则面板上会有一个点了回 501 的按钮。 for _, name := range env.hub.DeclaredActions(search.PanelPluginID) { if _, err := env.dispatch(t, name, nil); errors.Is(err, pluginmgr.ErrActionUnavailable) { t.Errorf("声明过的动作 %q 没有处理器(会回 501)", name) } } // 列表动作也必须真的执行(此时可变源还没配过 ⇒ 空表不是错误)。 if rows := env.rowsOf(t); len(rows) != 0 { t.Fatalf("没配过任何一家时列表应为空,实际 %+v", rows) } } // TestWebSearchListProvidersShowsStoredEntries 锁定列表动作读的是**可变源** // (而不是种子/默认值):写进盘里的条目与状态都如实出现,且不含任何凭据。 func TestWebSearchListProvidersShowsStoredEntries(t *testing.T) { env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) env.saveProvider(t, map[string]any{ "provider": search.NameBocha, "apiKey": "sk-bocha-secret", "baseUrl": "https://api.bocha.cn", "enabled": true, }) rows := env.rowsOf(t) byName := make(map[string]search.ProviderRow, len(rows)) for _, r := range rows { byName[r.Provider] = r } if got := byName[search.NameBocha]; got.State != search.StateOK || !got.Enabled { t.Fatalf("bocha 行 = %+v, want state=ok 且启用", got) } raw, err := json.Marshal(rows) if err != nil { t.Fatalf("marshal rows: %v", err) } if strings.Contains(string(raw), "sk-bocha-secret") { t.Fatalf("列表动作泄漏了凭据: %s", raw) } } // TestWebSearchSaveProviderHonoursSecretSentinel 锁定写侧的三态哨兵语义 // (§5.4.2:空字符串 ≠ 不改动): // - apiKey 缺位 ⇒ 原值保留(面板只持有掩码,留空必须真的不改); // - apiKey 显式空串 ⇒ 清空; // - enabled / baseUrl 缺位 ⇒ 保留。 func TestWebSearchSaveProviderHonoursSecretSentinel(t *testing.T) { env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) env.saveProvider(t, map[string]any{ "provider": search.NameBocha, "apiKey": "sk-first", "baseUrl": "https://api.bocha.cn", "enabled": true, }) // 只改地址(apiKey / enabled 缺位)⇒ 凭据与启用态一个都不许动。 env.saveProvider(t, map[string]any{"provider": search.NameBocha, "baseUrl": "https://mirror.bocha.cn"}) got := env.entryOf(t, search.NameBocha) if got.APIKey != "sk-first" { t.Fatalf("apiKey 缺位时被改动了: %q(哨兵语义失效 = 一次保存静默清空凭据)", got.APIKey) } if got.BaseURL != "https://mirror.bocha.cn" || !got.IsEnabled() { t.Fatalf("条目 = %+v, want 新地址 + 仍启用", got) } // 显式停用 + 显式清空凭据(前端只有勾了清除才会送出空串)。 env.saveProvider(t, map[string]any{"provider": search.NameBocha, "apiKey": "", "enabled": false}) got = env.entryOf(t, search.NameBocha) if got.APIKey != "" { t.Fatalf("显式空串必须清空凭据,得到 %q", got.APIKey) } if got.IsEnabled() { t.Fatal("显式 enabled=false 必须落盘(停用态)") } if state := env.rowOf(t, search.NameBocha).State; state != search.StateDisabled { t.Fatalf("停用后 state = %q, want %q", state, search.StateDisabled) } } // TestWebSearchReauthProviderReturnsMaskedValues 锁定 reauth「把该条带回表单」: // 返回显式 values(前端 formValuesOfResult 只认 values/formValues), // 且 apiKey 只给**掩码** —— 读侧永不回明文(§5.4.2)。 func TestWebSearchReauthProviderReturnsMaskedValues(t *testing.T) { env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) env.saveProvider(t, map[string]any{ "provider": search.NameBocha, "apiKey": "sk-abcdefgh", "baseUrl": "https://api.bocha.cn", "enabled": true, }) out := env.mustDispatch(t, search.ActionReauthProvider, map[string]any{ "row": map[string]any{"provider": search.NameBocha, "state": search.StateOK}, }) m, ok := out.(map[string]any) if !ok { t.Fatalf("reauth 结果类型 = %T", out) } values, ok := m["values"].(map[string]any) if !ok { t.Fatalf("reauth 结果缺 values: %#v", m) } if values["provider"] != search.NameBocha || values["baseUrl"] != "https://api.bocha.cn" { t.Fatalf("回填的字段 = %#v", values) } if values["enabled"] != true { t.Fatalf("enabled 回填 = %#v, want true", values["enabled"]) } masked, _ := values["apiKey"].(string) if masked == "" || masked == "sk-abcdefgh" { t.Fatalf("apiKey 回填 = %q,必须是掩码(读侧无明文)", masked) } if !strings.HasSuffix(masked, "efgh") { t.Fatalf("掩码口径应与宿主的 maskToken 同源(尾 4 位),得到 %q", masked) } } // TestWebSearchTestProviderHitsRealHTTP 是探针的判据:**真发一次请求**到盘上那条 // 配置指的地址,用真应答区分「通了」与「没通」。 func TestWebSearchTestProviderHitsRealHTTP(t *testing.T) { hits := make(chan *http.Request, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits <- r w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"results":[{"title":"A","url":"http://a.example","content":"alpha"}]}`)) })) defer srv.Close() env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) env.saveProvider(t, map[string]any{ "provider": search.NameSearXNG, "baseUrl": srv.URL + "/search", "enabled": true, }) out := env.mustDispatch(t, search.ActionTestProvider, map[string]any{ "row": map[string]any{"provider": search.NameSearXNG}, }) res, ok := out.(search.ProbeResult) if !ok { t.Fatalf("探针结果类型 = %T", out) } if !res.OK || res.Count != 1 { t.Fatalf("探针结论 = %+v, want ok 且 1 条", res) } select { case r := <-hits: if r.URL.Path != "/search" || !strings.Contains(r.URL.RawQuery, "q=cyrene") { t.Fatalf("上游收到的请求 = %s?%s(探针必须真的搜一次)", r.URL.Path, r.URL.RawQuery) } default: t.Fatal("上游一个请求都没收到 —— 探针没真发请求") } } // TestWebSearchTestProviderReportsFailureHonestly 锁定失败口径:上游拒了凭据 ⇒ // 结构化非 2xx(码 = 探针码,文案固定不含上游原文),绝不回一句假的「已完成」。 func TestWebSearchTestProviderReportsFailureHonestly(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"bad key sk-should-not-leak"}`)) })) defer srv.Close() env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) env.saveProvider(t, map[string]any{ "provider": search.NameSearXNG, "baseUrl": srv.URL, "enabled": true, }) _, err := env.dispatch(t, search.ActionTestProvider, map[string]any{ "row": map[string]any{"provider": search.NameSearXNG}, }) ae, ok := pluginmgr.AsActionError(err) if !ok { t.Fatalf("探针失败 err = %v,期望结构化 ActionError", err) } if ae.Code != search.ProbeUnauthorized { t.Fatalf("失败码 = %q, want %q", ae.Code, search.ProbeUnauthorized) } if ae.Status != http.StatusBadGateway { t.Fatalf("失败状态 = %d, want 502(上游失败)", ae.Status) } if strings.Contains(ae.Message, "sk-should-not-leak") || ae.Message == "" { t.Fatalf("失败文案 = %q(不得含上游原文,也不得为空)", ae.Message) } } // TestWebSearchTestProviderNotConfiguredIsClientError 锁定「没配齐」归 400: // 这是调用方/配置的问题,不是上游不可用(前端据此区分该改配置还是该重试)。 func TestWebSearchTestProviderNotConfiguredIsClientError(t *testing.T) { env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) // 存一条**没配齐**的(显式清空 key):探针必须在发请求**之前**就报 not_configured, // 绝不拿一个没有凭据的配置去戳上游。 env.saveProvider(t, map[string]any{"provider": search.NameBocha, "apiKey": ""}) _, err := env.dispatch(t, search.ActionTestProvider, map[string]any{ "row": map[string]any{"provider": search.NameBocha}, }) ae, ok := pluginmgr.AsActionError(err) if !ok || ae.Code != search.ProbeNotConfigured || ae.Status != http.StatusBadRequest { t.Fatalf("err = %#v, want 400 not_configured", err) } } // TestWebSearchActionsRejectUnknownProviderAndBadPayload 锁定动作的两类调用方错误 // 都被**显式拒绝**(不静默存一条永远选不中的配置,也不把类型错当成空值)。 func TestWebSearchActionsRejectUnknownProviderAndBadPayload(t *testing.T) { env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) cases := []struct { name string action string payload any wantCode string }{ {"保存未登记的家", search.ActionSaveProvider, map[string]any{"values": map[string]any{"provider": "tavily", "apiKey": "sk-x"}}, actionCodeUnknownProvider}, {"保存缺 provider", search.ActionSaveProvider, map[string]any{"values": map[string]any{"apiKey": "sk-x"}}, pluginmgr.ActionCodeInvalidPayload}, {"保存缺 values 对象", search.ActionSaveProvider, map[string]any{"apiKey": "sk-x"}, pluginmgr.ActionCodeInvalidPayload}, {"保存 enabled 类型错", search.ActionSaveProvider, map[string]any{"values": map[string]any{"provider": search.NameBocha, "enabled": "yes"}}, pluginmgr.ActionCodeInvalidPayload}, {"探针未登记的家", search.ActionTestProvider, map[string]any{"row": map[string]any{"provider": "tavily"}}, actionCodeUnknownProvider}, {"探针缺 provider", search.ActionTestProvider, map[string]any{}, pluginmgr.ActionCodeInvalidPayload}, {"reauth 未登记的家", search.ActionReauthProvider, map[string]any{"row": map[string]any{"provider": "tavily"}}, actionCodeUnknownProvider}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { _, err := env.dispatch(t, tc.action, tc.payload) ae, ok := pluginmgr.AsActionError(err) if !ok { t.Fatalf("err = %v,期望结构化 ActionError", err) } if ae.Code != tc.wantCode || ae.Status != http.StatusBadRequest { t.Fatalf("err = %+v, want code=%q status=400", ae, tc.wantCode) } }) } // 被拒的保存不得在盘上留下痕迹(没写成功时文件可能根本不存在 —— 那也是「没留下」)。 if f, found, err := runtimeconfig.LoadWebSearch(env.path); err == nil && found { for _, p := range f.Providers { if p.Provider == "tavily" { t.Fatal("未登记的家被写进了可变源") } } } } // TestWebSearchOptionsActionFeedsDeclaredOptionsFrom 锁死「声明的下拉一定拉得到候选」: // 声明里每个 optionsFrom 映射出的动作名都**已登记且已声明**(否则前端只能降级成文本框)。 // // 候选的形状是 `{value,label}`(切片 LBL-1):这里连**显示名非空**一起钉住 —— 只有值的 // 候选会让面板下拉与表格里全是英文 id(主人 2026-10-06 报障的那个形状)。 func TestWebSearchOptionsActionFeedsDeclaredOptionsFrom(t *testing.T) { env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) sources := declaredOptionsSources(search.AdminPanels()) if len(sources) == 0 { t.Fatal("声明里一个 optionsFrom 都没有:这条判据失去了意义") } for _, src := range sources { name := pluginmgr.OptionsActionName(src) if name == "" { t.Fatalf("optionsFrom %q 映射不出动作名", src) } if _, ok := env.hub.Action(search.PanelPluginID, name); !ok { t.Errorf("候选来源 %q(动作 %s)没进声明索引", src, name) } out, err := env.dispatch(t, name, map[string]any{}) if err != nil { t.Errorf("候选来源 %q 调不动: %v", src, err) continue } options, _ := out.(map[string]any)["options"].([]search.ProviderOption) if len(options) == 0 { t.Errorf("候选来源 %q 回了空候选(表单下拉会空着)", src) } got, want := optionValues(options), optionValues(search.ProviderOptions()) if strings.Join(got, ",") != strings.Join(want, ",") { t.Errorf("候选值 = %v, want %v(value 逐字等于 KnownNames())", got, want) } for _, o := range options { if strings.TrimSpace(o.Label) == "" { t.Errorf("候选来源 %q 的 %q 没有显示名(下拉里会是空白项)", src, o.Value) } } } } // optionValues 取出结构化候选的**值**(落盘真源),丢掉显示名。 func optionValues(opts []search.ProviderOption) []string { out := make([]string, 0, len(opts)) for _, o := range opts { out = append(out, o.Value) } return out } // TestWebSearchActionsUnwiredAnswers501 锁定降级口径:**没有配置**(测试夹具/装配未完成) // 时不登记任何处理器 ⇒ 已声明的动作诚实回 501(ErrActionUnavailable), // 绝不假装成功地回一份空列表。 func TestWebSearchActionsUnwiredAnswers501(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()) // cfg 为 nil ⇒ 组合根拿不到数据目录,动作登记被跳过(只告警)。 wirePanelHub(&app{log: testLog(), bus: b, plugins: mgr}) hub := mgr.Panels() if got := hub.ActionHandlerNames(search.PanelPluginID); len(got) != 0 { t.Fatalf("没有配置时不该登记任何处理器,实际 %v", got) } _, err := hub.Dispatch(context.Background(), pluginmgr.ActionRequest{ PluginID: search.PanelPluginID, Action: search.ActionListProviders, }) if !errors.Is(err, pluginmgr.ErrActionUnavailable) { t.Fatalf("err = %v, want ErrActionUnavailable(未登记处理器必须诚实回 501)", err) } }