- 新增 $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 的相关注册留给主会话。
217 lines
8.1 KiB
Go
217 lines
8.1 KiB
Go
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)
|
||
}
|
||
}
|