Files
Cyrene_v2/cmd/cyrene/wire_plugin_actions_test.go
AskaEth c46053e276
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 搜索提供商下拉/多选/表格显示中文名(值仍是 ASCII id)
主人报障:「为什么中文搜索接口……下拉菜单里全给的英文名?你甚至不愿意带个括号
把中文名写上去」。根因不是漏写文案,而是整条链路没有「显示名」的位置:字段
声明只有 optionsFrom,候选动作回的就是 KnownNames() 的纯 id 数组。

- 后端:候选改为对象数组 {value,label};label 表与接入位表同源(键用
  NameSearXNG 等常量),未知 id 原样回。value 逐字等于 KnownNames()。
- 前端:下拉/多选「显示 label、写 value」;表格单元格按 value→label 索引显示,
  查不到回落显示原值。多选从裸输入框改成「候选开关 + 落盘值输入框」。
  解码器本就兼容裸字符串/{value,label}/{id} 三形状 ⇒ 其他插件零影响。

中文名逐字:SearXNG 本地兜底(searxng)/ 智谱 BigModel(zhipu)/
阿里云 IQS 信息查询(aliyun_iqs)/ 博查 Bocha(bocha)/
火山引擎·豆包搜索(volcengine)/ 百度千帆 AI 搜索(baidu_qianfan)。

验收(主会话复跑 + 亲自变异):go build ./... + vet 绿;search 包测试绿;
tsc --noEmit 0 错;新渲染测试 26 checks + 既有 plugin panel 55 / data 90 /
schema values 40 / channel config 70 全绿。我用 sed 把 values.ts 的
option.value 全换成 option.label ⇒ 渲染测试红(「点选把候选项的 id 追加到
降级链末尾」got="bocha, 智谱 BigModel(zhipu)")—— 这正是会把配置文件写坏的
错法;还原后 md5 一致且绿。

⚠️ 契约面变更(我在 Docs/api-client.md 登记):plugin.providers 候选响应形状
字符串数组 → 对象数组;label 仅供显示、value 才是落盘值。
⚠️ 已知边界:viewer 角色看不到 form 区块 ⇒ 表格对 viewer 仍显示裸 id(未做)。
⚠️ 真机浏览器点击未跑(项目无 jsdom)⇒ 建议刷新管理页点一次确认观感。
2026-10-07 10:08:19 +08:00

467 lines
19 KiB
Go
Raw Permalink 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
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)
}
}