Files
Cyrene_v2/internal/config/runtimeconfig/websearch_test.go
T
AskaEth 120d926cf8 feat(search): 搜索 provider 可变源 + 插件声明面板(PLG-4)
- 新增 $DATA_DIR/config/plugins/web-search.json(0600 + temp+rename,文件名 = 插件 id)
- 组合根改「先读可变源、缺失回落 .env 播种」;**不新增任何 env 键**(.env 已冻结)
- 多实例:每家 provider 独立 key/baseURL/启用态 + 「测试连通」探针
- 用 PLG-1 的声明库给出该面板的 adminPanels 声明;动作名交给 PLG-2 的通用动作端点分发
  (本片自己不开路由)

⚠️ 本片自认越界:改了 cmd/cyrene/wire_web_search_test.go(既有测试,为适配 DataDir);
  另有 design/28 的相关注册留给主会话。
2026-10-06 09:04:04 +08:00

217 lines
8.1 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 runtimeconfig
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// 本文件钉住 web-search 可变源(PLG-4 / design/13 §2.1)的形状与读写语义:
// 路径 = `$DATA_DIR/config/plugins/web-search.json`(文件名 = 插件 id)、
// 0600 + temp+rename、缺失/为空不是错误、非法文件报可分类的错、读侧掩码。
// wsPath 是本用例自己的落点(落临时目录,绝不碰仓库工作目录)。
func wsPath(t *testing.T) string {
t.Helper()
return WebSearchConfigPath(t.TempDir())
}
// wsWriteFile 落一份手写的文件(建好父目录 —— 真实部署里 `$DATA_DIR/config/plugins/`
// 由播种或面板保存建出来,人工救回时也得自己建)。
func wsWriteFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
}
// 判据 ①:文件名 = 插件 id,落点在 `config/plugins/` 下(design/13 §2.1 的硬约定)。
func TestWebSearchConfigPathIsPluginIdNamed(t *testing.T) {
t.Parallel()
got := filepath.ToSlash(WebSearchConfigPath("/data"))
if want := "/data/config/plugins/web-search.json"; got != want {
t.Fatalf("路径 = %q, want %q", got, want)
}
}
// 判据 ②:文件缺失或全空白 = 「还没配过」,不是错误(组合根据此用 .env 播种)。
func TestLoadWebSearchMissingOrBlankIsNotAnError(t *testing.T) {
t.Parallel()
path := wsPath(t)
if _, found, err := LoadWebSearch(path); err != nil || found {
t.Fatalf("缺失文件:found=%v err=%v,want false/nil", found, err)
}
wsWriteFile(t, path, " \n\t")
if _, found, err := LoadWebSearch(path); err != nil || found {
t.Fatalf("空白文件:found=%v err=%v,want false/nil", found, err)
}
}
// 判据 ③:写盘 → 读回逐字一致(含三态 enabled:缺省 = 启用、显式 false = 停用)。
func TestSaveLoadWebSearchRoundTrip(t *testing.T) {
t.Parallel()
path := wsPath(t)
in := WebSearchFile{
Version: WebSearchVersion,
Provider: "bocha",
Fallback: []string{"searxng", "zhipu"},
MaxResults: 5,
Timeout: "20s",
Providers: []ProviderEntry{
{Provider: "searxng", BaseURL: "http://127.0.0.1:38471", Enabled: EnableFlag(true)},
{Provider: "bocha", APIKey: "sk-x", BaseURL: "https://api.bocha.cn", Enabled: EnableFlag(false)},
{Provider: "zhipu"},
},
}
if err := SaveWebSearch(path, in); err != nil {
t.Fatalf("SaveWebSearch: %v", err)
}
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
t.Fatalf("temp+rename 之后不得留下 .tmp(err=%v)", err)
}
out, found, err := LoadWebSearch(path)
if err != nil || !found {
t.Fatalf("LoadWebSearch: found=%v err=%v", found, err)
}
if out.Provider != in.Provider || out.MaxResults != in.MaxResults || out.Timeout != in.Timeout {
t.Fatalf("标量不往返:%+v", out)
}
if strings.Join(out.Fallback, ",") != strings.Join(in.Fallback, ",") {
t.Fatalf("fallback = %v, want %v", out.Fallback, in.Fallback)
}
if len(out.Providers) != len(in.Providers) {
t.Fatalf("providers 条数 = %d, want %d", len(out.Providers), len(in.Providers))
}
// 三态:显式 false 必须仍是 false;缺省(没写 enabled)读回来必须是**启用**。
if out.Providers[1].IsEnabled() {
t.Fatal("显式 enabled=false 读回来变成了启用")
}
if !out.Providers[2].IsEnabled() {
t.Fatal("没写 enabled 的条目必须是启用(缺省 = 启用)")
}
if got, err := out.TimeoutDuration(); err != nil || got != 20*time.Second {
t.Fatalf("TimeoutDuration = %v (err=%v), want 20s", got, err)
}
}
// 判据 ③b:落盘权限 0600(文件可能带 API Key)。Windows 的 os.WriteFile 不表达 POSIX
// 权限位,故只在非 Windows 上断言 —— 不断言也不代表口径可以放宽,落盘调用本身就写了 0600。
func TestSaveWebSearchFileModeIs0600(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Windows 不表达 POSIX 权限位")
}
path := wsPath(t)
if err := SaveWebSearch(path, WebSearchFile{Version: WebSearchVersion, Providers: []ProviderEntry{}}); err != nil {
t.Fatalf("SaveWebSearch: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("权限 = %o, want 600", perm)
}
}
// 判据 ④:结构非法一律拒绝,且错误可按 errors.Is 判类(调用方据此记 WARN 而不是崩)。
func TestSaveWebSearchRejectsInvalidShapes(t *testing.T) {
t.Parallel()
cases := []struct {
name string
file WebSearchFile
}{
{"版本过高", WebSearchFile{Version: WebSearchVersion + 1}},
{"条数为负", WebSearchFile{Version: WebSearchVersion, MaxResults: -1}},
{"超时不可解析", WebSearchFile{Version: WebSearchVersion, Timeout: "20"}},
{"超时非正", WebSearchFile{Version: WebSearchVersion, Timeout: "-5s"}},
{"provider 空", WebSearchFile{Version: WebSearchVersion, Providers: []ProviderEntry{{}}}},
{"provider 含大写/横线", WebSearchFile{Version: WebSearchVersion, Providers: []ProviderEntry{{Provider: "Baidu-Qianfan"}}}},
{"provider 重复", WebSearchFile{Version: WebSearchVersion, Providers: []ProviderEntry{{Provider: "bocha"}, {Provider: "BOCHA"}}}},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if err := SaveWebSearch(wsPath(t), tc.file); !errors.Is(err, ErrInvalidWebSearch) {
t.Fatalf("err = %v, want errors.Is(ErrInvalidWebSearch)", err)
}
})
}
}
// 判据 ⑤:损坏的文件**报错但不被改写**(Load 只读;调用方据此回落 .env 并保留现场)。
func TestLoadWebSearchCorruptFileKeepsContents(t *testing.T) {
t.Parallel()
path := wsPath(t)
const broken = `{"version":1,"providers":[{"provider":"bocha"},{"provider":"BOCHA"}]}`
wsWriteFile(t, path, broken)
if _, found, err := LoadWebSearch(path); err == nil || found {
t.Fatalf("非法文件必须报错:found=%v err=%v", found, err)
} else if !errors.Is(err, ErrInvalidWebSearch) {
t.Fatalf("err = %v, want errors.Is(ErrInvalidWebSearch)", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(raw) != broken {
t.Fatalf("读侧不得改写原文件:%q", raw)
}
}
// 判据 ⑥:upsert 按 provider 名就位替换(不打乱顺序、不因大小写多出一条)。
func TestUpsertProviderEntryReplacesInPlaceAndAppends(t *testing.T) {
t.Parallel()
f := WebSearchFile{Providers: []ProviderEntry{{Provider: "searxng"}, {Provider: "bocha"}}}
f = UpsertProviderEntry(f, ProviderEntry{Provider: " BOCHA ", APIKey: "sk-new"})
if len(f.Providers) != 2 {
t.Fatalf("条数 = %d, want 2(同名必须就位替换而不是追加)", len(f.Providers))
}
if f.Providers[1].Provider != "bocha" || f.Providers[1].APIKey != "sk-new" {
t.Fatalf("替换结果 = %+v(名字要归一化、顺序要保住)", f.Providers[1])
}
f = UpsertProviderEntry(f, ProviderEntry{Provider: "zhipu"})
if len(f.Providers) != 3 || f.Providers[2].Provider != "zhipu" {
t.Fatalf("新增必须追加到末尾:%+v", f.Providers)
}
}
// 判据 ⑦:读侧出口掩码凭据(只留尾 4 位),且落盘内容不受影响。
func TestMaskedWebSearchFileHidesCredentials(t *testing.T) {
t.Parallel()
in := WebSearchFile{
Providers: []ProviderEntry{
{Provider: "bocha", APIKey: "sk-1234567890"},
{Provider: "zhipu", APIKey: "abc"},
{Provider: "searxng", BaseURL: "http://127.0.0.1:38471"},
},
}
out := MaskedWebSearchFile(in)
if out.Providers[0].APIKey != "****7890" {
t.Fatalf("掩码 = %q, want ****7890", out.Providers[0].APIKey)
}
if out.Providers[1].APIKey != "****" {
t.Fatalf("短凭据必须全掩,得到 %q", out.Providers[1].APIKey)
}
if in.Providers[0].APIKey != "sk-1234567890" {
t.Fatal("掩码不得改到原值(落盘内容必须原样)")
}
// 掩码后的值绝不是可直接回写的凭据,但仍要能看出「这家已配置」。
raw, err := json.Marshal(out)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(raw), "sk-1234567890") {
t.Fatalf("读侧出口泄漏了明文:%s", raw)
}
}