package main // 本文件钉住搜索提供商动作面的**危险/边界结局**(切片 L1/PLG-2):损坏配置不许被覆盖、 // 装配期要如实点名「声明了却没实现」的动作。它们与主用例集分开成文件的理由只有一条: // `wire_plugin_actions_test.go` 已接近「文件 ≤500 行」的上限(AGENTS §10)。 import ( "bytes" "context" "net/http" "os" "path/filepath" "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/logger" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" ) // TestWebSearchActionConfigUnreadableIsReported 锁定「文件存在但不合法」的口径: // 结构化失败(500 config_unreadable),**不覆盖**原文件 —— 损坏的配置可以人工救回。 func TestWebSearchActionConfigUnreadableIsReported(t *testing.T) { env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) const broken = `{"version":1,"providers":[{"provider":"bocha"},{"provider":"BOCHA"}]}` wsWriteRawPanel(t, env.path, broken) _, err := env.dispatch(t, search.ActionListProviders, map[string]any{}) ae, ok := pluginmgr.AsActionError(err) if !ok || ae.Code != actionCodeConfigUnreadable || ae.Status != http.StatusInternalServerError { t.Fatalf("err = %#v, want 500 config_unreadable", err) } if _, err := env.dispatch(t, search.ActionSaveProvider, map[string]any{ "values": map[string]any{"provider": search.NameBocha, "apiKey": "sk-x"}, }); err == nil { t.Fatal("配置不合法时必须拒绝保存(否则会静默盖掉可救回的原文件)") } raw, rerr := os.ReadFile(env.path) if rerr != nil { t.Fatalf("read: %v", rerr) } if string(raw) != broken { t.Fatalf("原文件被改动了:%q", raw) } } // TestWarnImpliedButMissingNamesGaps 锁定装配期的**核对**:声明里有、登记里没有的动作 // 必须在日志里点名(否则用户只会看到一个点了回 501 的按钮)。这里直接覆盖核对函数本身。 func TestWarnImpliedButMissingNamesGaps(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()) var buf bytes.Buffer log := logger.New(logger.Options{Out: &buf, Level: "debug"}) wirePanelHub(&app{cfg: cfg, log: log, bus: b, plugins: mgr}) hub := mgr.Panels() // 模拟「插件声明新增了一个动作,但宿主忘了给它接线」:补一条声明(Declare 是整体 // 替换语义 ⇒ 原有四个动作 + 候选来源仍在),只多一个没人实现的名字。 extra := sdk.Panel{ ID: "extra", Title: "额外页", Sections: []sdk.Section{{ Type: sdk.SectionTable, ID: "t", Title: "额外表", Source: &sdk.Source{Action: "plugin.notWired"}, Columns: []sdk.Column{{Key: "k", Label: "K"}}, }}, } if _, err := hub.Declare(search.PanelPluginID, append(search.AdminPanels(), extra)); err != nil { t.Fatalf("Declare: %v", err) } warnImpliedButMissing(hub, log) out := buf.String() if !strings.Contains(out, "plugin.notWired") || !strings.Contains(out, "no handler") { t.Fatalf("声明了却没实现的动作必须被点名(否则只剩一个回 501 的按钮): %q", out) } // 已登记的动作不得被误报。 if idx := strings.Index(out, "action=plugin.listProviders"); idx >= 0 && strings.Contains(out[idx:], "no handler") { t.Fatalf("已登记的动作被误报成缺处理器: %q", out) } }