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 的相关注册留给主会话。
This commit is contained in:
1 parent
2d21aa553e
commit
120d926cf8
11 files changed
+1811
-41
No files matched your search
@@ -0,0 +1,263 @@
|
||||
// websearch.go 是 web-search 插件的**可变源**:`$DATA_DIR/config/plugins/web-search.json`
|
||||
// (design/13 §2.1「插件配置数据一律进 config/plugins/<插件 id>.json,文件名 = 插件 id」;
|
||||
// design/09 §5.7 E 的首个用例 = 搜索 provider 配置页 —— 让多个第三方搜索 provider
|
||||
// 在面板里配,不再靠 `.env`)。
|
||||
//
|
||||
// 三件事划清边界:
|
||||
//
|
||||
// 1. 本文件只管**文件形状 + 读写 + 结构校验**:它不认识 `WEB_SEARCH_*` 键、不认识
|
||||
// `config.WebSearchConfig`,也不认识任何一家 provider 的名字。「把 `.env` 播种成
|
||||
// 这份形状」与「把这份形状读回工具要的配置」是**组合根**的事
|
||||
// (`cmd/cyrene/wire_io_tools.go`)—— 那里本来就只有一份 provider 名字表,
|
||||
// 多一份就是第二份真源。
|
||||
// 2. **清单 vs 数据分离**(design/13 §2.1):`config/plugins.json` 只有插件管理器自己的
|
||||
// 清单;插件落下来的配置数据一个插件一个文件。
|
||||
// 3. 凭据只落盘、**绝不进日志/响应**:读侧出口只有 `MaskedWebSearchFile`。
|
||||
//
|
||||
// 读写口径与 `plugins.json` / `models.json` 同源:JSON、0600、temp+rename(并发读者永远
|
||||
// 看不到半个文件)。文件缺失/为空 = 「还没配过」,由调用方用 `.env` 播种,**不是错误**。
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 文件形状的常量。
|
||||
const (
|
||||
// webSearchPluginID 是 web-search 插件的 id。文件名 = 插件 id(design/13 §2.1 的硬约定,
|
||||
// 它让落地文件能人工对上插件;插件声明侧的同名常量在 plugins/tools/io/search,
|
||||
// 两边由 search 包的测试钉住同源)。
|
||||
webSearchPluginID = "web-search"
|
||||
// webSearchConfigSubdir / webSearchPluginsDir 是落点的两段目录
|
||||
// (`$DATA_DIR/config/plugins/<插件 id>.json`)。
|
||||
webSearchConfigSubdir = "config"
|
||||
webSearchPluginsDir = "plugins"
|
||||
// WebSearchVersion 是当前形状版本。读盘时接受 0(人工手写没写版本)与它,拒绝更高版本
|
||||
// —— 未来版本的文件用今天的语义去解释,只会读出一份**看起来正常**的错配置。
|
||||
WebSearchVersion = 1
|
||||
// webSearchFileMode 是落盘权限:文件会带 API Key。
|
||||
webSearchFileMode = 0o600
|
||||
// webSearchDirMode 是落盘目录权限。
|
||||
webSearchDirMode = 0o755
|
||||
)
|
||||
|
||||
// webSearchProviderRe 是 provider 名的字符集。本仓内置名全是小写字母 + 下划线
|
||||
// (searxng / zhipu / aliyun_iqs / bocha / volcengine / baidu_qianfan),限定字符集是为了
|
||||
// 让名字能直接当端点段、文件名与日志字段用。
|
||||
//
|
||||
// 它**不**判定「这个名字是否已登记」—— 那是 `plugins/tools/io/search` 的事实,本包不去
|
||||
// 反向依赖插件包(组合根负责核对并记 WARN)。
|
||||
var webSearchProviderRe = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
||||
|
||||
// ErrInvalidWebSearch 是 web-search.json 结构不合法时的分类错误(errors.Is 可判类)。
|
||||
var ErrInvalidWebSearch = errors.New("runtimeconfig: invalid web-search config")
|
||||
|
||||
// WebSearchConfigPath 返回 web-search 面板配置的落点:
|
||||
// `$DATA_DIR/config/plugins/web-search.json`。
|
||||
func WebSearchConfigPath(dataDir string) string {
|
||||
return filepath.Join(dataDir, webSearchConfigSubdir, webSearchPluginsDir, webSearchPluginID+".json")
|
||||
}
|
||||
|
||||
// EnableFlag 返回 ProviderEntry.Enabled 的三态指针(`&true` / `&false` 的简写)。
|
||||
func EnableFlag(v bool) *bool { return &v }
|
||||
|
||||
// ProviderEntry 是一条 provider 配置 —— 「多实例」= providers 数组里的多条,
|
||||
// 每家**独立** key / 基地址 / 启用态,互不影响(design/27 §1.2)。
|
||||
type ProviderEntry struct {
|
||||
// Provider 是 provider 名。它在文件内**唯一**:注册表、主 provider 与降级链都以名字
|
||||
// 为键,同名两条在语义上无法区分(校验期直接拒掉,而不是「后者覆盖前者」这种静默取一)。
|
||||
Provider string `json:"provider"`
|
||||
// APIKey 是上游凭据。**绝不进日志/响应**(读侧经 MaskedWebSearchFile)。
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
// BaseURL 是上游基地址(searxng 的基地址就是原有 `WEB_SEARCH_ENDPOINT` 的端点)。
|
||||
BaseURL string `json:"baseUrl,omitempty"`
|
||||
// Enabled 是启用态。三态:缺省(nil)与 true 都算**启用**,显式 false 才是停用 ——
|
||||
// 用指针而不是 bool,是为了让「手写文件只填了 provider 与 apiKey」这种常见情形不被
|
||||
// 误判成停用(与 sdk.When.Enabled 同一取舍)。
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// IsEnabled 报告该条是否启用(缺省视为启用)。
|
||||
func (e ProviderEntry) IsEnabled() bool { return e.Enabled == nil || *e.Enabled }
|
||||
|
||||
// WebSearchFile 是 `web-search.json` 的形状。
|
||||
type WebSearchFile struct {
|
||||
// Version 是形状版本(0 = 人工手写没写版本,按当前版本读)。
|
||||
Version int `json:"version"`
|
||||
// Provider 是主 provider,取值语义与 `WEB_SEARCH_PROVIDER` **完全一致**:空串 / off /
|
||||
// none(大小写与空白不敏感)是「显式关闭」的哨兵,由组合根按同一套裁决处理
|
||||
// —— 面板与 `.env` 不许有第二套关闭语义。
|
||||
Provider string `json:"provider"`
|
||||
// Fallback 是降级链,从左到右试;空 = 只用手选的那一家。
|
||||
Fallback []string `json:"fallback,omitempty"`
|
||||
// MaxResults 是单次检索的条数上限;0 = 未声明,由组合根回落到代码默认值。
|
||||
MaxResults int `json:"maxResults,omitempty"`
|
||||
// Timeout 是单次上游尝试的超时(Go duration 字符串,如 "20s");空 = 未声明。
|
||||
// 用字符串而不是纳秒整数,是因为这份文件是给人看、给人改的。
|
||||
Timeout string `json:"timeout,omitempty"`
|
||||
// Providers 是各家的接线。允许为空数组(= 一家都没配 ⇒ 组合根不注册 web-search)。
|
||||
Providers []ProviderEntry `json:"providers"`
|
||||
}
|
||||
|
||||
// TimeoutDuration 解析 Timeout:未声明(空/全空白)⇒ 0(= 沿用代码默认值)。
|
||||
// 写法非法或非正数 ⇒ ErrInvalidWebSearch(非正数的超时是笔误,不是「关掉超时」)。
|
||||
func (f WebSearchFile) TimeoutDuration() (time.Duration, error) {
|
||||
s := strings.TrimSpace(f.Timeout)
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%w: timeout %q 不是合法的 Go duration: %w", ErrInvalidWebSearch, s, err)
|
||||
}
|
||||
if d <= 0 {
|
||||
return 0, fmt.Errorf("%w: timeout 必须为正(得到 %q)", ErrInvalidWebSearch, s)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// ValidateWebSearch 校验文件的结构。
|
||||
//
|
||||
// 刻意**不**校验「provider 名是否已登记」:那是插件自己的事实(search.Known),
|
||||
// 本包反向依赖插件包会把配置层与插件层绑死。未登记的名字由组合根记一条 WARN 并忽略
|
||||
// (它不可能被选中,因为选中的家由 io 工具集在启动期校验)。
|
||||
func ValidateWebSearch(f WebSearchFile) error {
|
||||
if f.Version < 0 || f.Version > WebSearchVersion {
|
||||
return fmt.Errorf("%w: version %d 超出本仓支持的 0..%d",
|
||||
ErrInvalidWebSearch, f.Version, WebSearchVersion)
|
||||
}
|
||||
if f.MaxResults < 0 {
|
||||
return fmt.Errorf("%w: maxResults 不得为负(得到 %d)", ErrInvalidWebSearch, f.MaxResults)
|
||||
}
|
||||
if _, err := f.TimeoutDuration(); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := make(map[string]bool, len(f.Providers))
|
||||
for i, e := range f.Providers {
|
||||
name := strings.ToLower(strings.TrimSpace(e.Provider))
|
||||
if name == "" {
|
||||
return fmt.Errorf("%w: providers[%d].provider 必填", ErrInvalidWebSearch, i)
|
||||
}
|
||||
if !webSearchProviderRe.MatchString(name) {
|
||||
return fmt.Errorf("%w: providers[%d].provider %q 非法:只允许小写字母、数字与下划线",
|
||||
ErrInvalidWebSearch, i, e.Provider)
|
||||
}
|
||||
if seen[name] {
|
||||
return fmt.Errorf("%w: provider %q 重复(同名两条无法区分:注册表与降级链都以名字为键)",
|
||||
ErrInvalidWebSearch, name)
|
||||
}
|
||||
seen[name] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadWebSearch 读取 web-search.json。
|
||||
//
|
||||
// found 报告「文件存在且非空」:缺失或全空白 = 还没配过,由调用方用 `.env` 播种
|
||||
// (design/09 §5.7 E 的「先读可变源、缺失回落 `.env` 播种」),**不是错误**。
|
||||
//
|
||||
// 存在但不合法(解不开 / 结构不合法)⇒ 返回错误:调用方应记 WARN 并回落 `.env`,
|
||||
// 且**不得覆盖**原文件 —— 损坏的配置可以人工救回,用 `.env` 盖掉它会把人工写的条目
|
||||
// 一起丢掉(与 models.json 的同一处置)。
|
||||
func LoadWebSearch(path string) (f WebSearchFile, found bool, err error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return WebSearchFile{}, false, nil
|
||||
}
|
||||
return WebSearchFile{}, false, fmt.Errorf("runtimeconfig: read %s: %w", path, err)
|
||||
}
|
||||
if strings.TrimSpace(string(raw)) == "" {
|
||||
return WebSearchFile{}, false, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return WebSearchFile{}, false, fmt.Errorf("%w: decode %s: %w", ErrInvalidWebSearch, path, err)
|
||||
}
|
||||
if err := ValidateWebSearch(f); err != nil {
|
||||
return WebSearchFile{}, false, err
|
||||
}
|
||||
if f.Providers == nil {
|
||||
f.Providers = []ProviderEntry{}
|
||||
}
|
||||
return f, true, nil
|
||||
}
|
||||
|
||||
// SaveWebSearch 把 f 原子写回 path:校验结构 → 建目录 → 写临时文件(0600)→ rename。
|
||||
//
|
||||
// temp+rename 的理由与 plugins.json 一致:并发读者要么看到旧文件、要么看到新文件,
|
||||
// 不会读到半个 JSON。
|
||||
func SaveWebSearch(path string, f WebSearchFile) error {
|
||||
if err := ValidateWebSearch(f); err != nil {
|
||||
return fmt.Errorf("runtimeconfig: save %s: %w", path, err)
|
||||
}
|
||||
if f.Providers == nil {
|
||||
f.Providers = []ProviderEntry{}
|
||||
}
|
||||
raw, err := json.MarshalIndent(f, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("runtimeconfig: marshal: %w", err)
|
||||
}
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, webSearchDirMode); err != nil {
|
||||
return fmt.Errorf("runtimeconfig: mkdir %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, raw, webSearchFileMode); err != nil {
|
||||
return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("runtimeconfig: rename %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertProviderEntry 返回把 e 插入或**就位替换**后的文件(其余条目的顺序不变)。
|
||||
//
|
||||
// 匹配键是 provider 名(大小写与首尾空白不敏感,写回时归一成小写):这样「面板里改一条
|
||||
// 已存在的家」不会因为大小写不同而多出一条,也不会把顺序打乱(顺序 = 面板里的稳定顺序)。
|
||||
func UpsertProviderEntry(f WebSearchFile, e ProviderEntry) WebSearchFile {
|
||||
e.Provider = strings.ToLower(strings.TrimSpace(e.Provider))
|
||||
out := make([]ProviderEntry, 0, len(f.Providers)+1)
|
||||
replaced := false
|
||||
for _, cur := range f.Providers {
|
||||
if strings.EqualFold(strings.TrimSpace(cur.Provider), e.Provider) {
|
||||
out = append(out, e)
|
||||
replaced = true
|
||||
continue
|
||||
}
|
||||
out = append(out, cur)
|
||||
}
|
||||
if !replaced {
|
||||
out = append(out, e)
|
||||
}
|
||||
f.Providers = out
|
||||
return f
|
||||
}
|
||||
|
||||
// MaskedWebSearchFile 返回读侧出口用的副本:每条 APIKey 只留尾 4 位(≤4 位则全掩),
|
||||
// 其余字段原样。落盘的 `web-search.json` 不受影响。
|
||||
//
|
||||
// 这是本包唯一的「回给客户端」的出口:凭据读侧**永不回显明文**(design/09 §5.4.2)。
|
||||
// 掩码后的值**不是**可直接回写的凭据 —— 「未改动」必须由写侧的显式哨兵表达。
|
||||
func MaskedWebSearchFile(f WebSearchFile) WebSearchFile {
|
||||
out := f
|
||||
if f.Fallback != nil {
|
||||
out.Fallback = append([]string(nil), f.Fallback...)
|
||||
}
|
||||
out.Providers = make([]ProviderEntry, 0, len(f.Providers))
|
||||
for _, e := range f.Providers {
|
||||
if strings.TrimSpace(e.APIKey) != "" {
|
||||
e.APIKey = maskToken(strings.TrimSpace(e.APIKey))
|
||||
}
|
||||
out.Providers = append(out.Providers, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user