Files
AskaEth 8e6c77ad10
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(search): web-search 接线本机 SearXNG 兜底 + 显式开关(她不再拿一把用不了的锤子)
主人报的故障:她调 web-search 必失败(no search endpoint configured)。根因两条:组合根从未注入 WithSearchEndpoint + 配置层根本没有搜索配置项。
- internal/config/websearch.go:WEB_SEARCH_ENDPOINT(默认本机兜底 http://127.0.0.1:38471)+ WEB_SEARCH_TIMEOUT;ResolveEndpoint() 三态裁决(可用/显式关闭/非法),非法值启动即报错
- cmd/cyrene/wire_io_tools.go:组合根唯一的 io 选项装配点;端点不可用 ⇒ WithoutSearch() + 带 source 的 WARN(只记键名与原因,不打印 URL 值)
- plugins/tools/io:新增 WithoutSearch()(**根本不进注册面** ⇒ 模型看不见)+ WithSearchTimeout();协议解析逻辑一字未动
- 隐蔽坑(额外查出并修):SearXNG 对 /?q= 回 308 → /search,重定向那一跳走**共享 SSRF 白名单** ⇒ 只注入端点照样被拦。改为**只给 web-search 一件**加宽自己的客户端与白名单,共享白名单与 http/web-fetch 客户端一字未动(有测试钉住它仍拒绝本机网关地址)
- .env.example 新增 2 键 + 注释说明「空串/off/none = 不注册」
- 真机 e2e 两次绿(SearXNG 返回 9 条);变异:不注入端点 / 关闭态仍注册 / 默认值改错 / 不并入端点主机 / 重定向用共享白名单 ⇒ 5 条全红
2026-10-05 20:29:21 +08:00

130 lines
4.9 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 config
import (
"strings"
"testing"
"time"
)
// 本文件钉住切片 WS1-A 的两个配置键:`WEB_SEARCH_ENDPOINT`(默认 = 本机兜底)与
// `WEB_SEARCH_TIMEOUT`(可选,未配置 = 沿用 io 包既有超时)。
//
// 关闭语义(空串 / off / none)由 ResolveEndpoint 定死:组合根据此**不注册**
// web-search 工具,因此这三种写法必须与「可用端点」区分得干干净净。
// TestWebSearch_DefaultEndpointIsLocalFallback 钉住默认值字面量:模板与代码承诺的
// 就是本机兜底地址(scripts/searxng/settings.yml 的配套实例)。
func TestWebSearch_DefaultEndpointIsLocalFallback(t *testing.T) {
t.Parallel()
cfg, err := Load(writeEnvFile(t, "JWT_SECRET="+strings.Repeat("s", 40)+"\n"))
if err != nil {
t.Fatalf("Load: %v", err)
}
const want = "http://127.0.0.1:38471"
if cfg.WebSearch.Endpoint != want {
t.Fatalf("WEB_SEARCH_ENDPOINT 默认值 = %q, want %q", cfg.WebSearch.Endpoint, want)
}
decision := cfg.WebSearch.ResolveEndpoint()
if !decision.Enabled || decision.URL != want {
t.Fatalf("默认端点必须可用: %+v", decision)
}
// 未配置超时 = 零值 ⇒ 沿用 io 包既有超时(本层不得另造默认值)。
if cfg.WebSearch.Timeout != 0 {
t.Fatalf("WEB_SEARCH_TIMEOUT 未配置时应为零值,got %v", cfg.WebSearch.Timeout)
}
}
// TestWebSearch_EnvOverridesEndpointAndTimeout 钉住两个键都能被 .env 覆盖。
func TestWebSearch_EnvOverridesEndpointAndTimeout(t *testing.T) {
t.Parallel()
cfg, err := Load(writeEnvFile(t, strings.Join([]string{
"JWT_SECRET=" + strings.Repeat("s", 40),
"WEB_SEARCH_ENDPOINT=https://searx.example.com/search",
"WEB_SEARCH_TIMEOUT=25s",
"",
}, "\n")))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.WebSearch.Endpoint != "https://searx.example.com/search" {
t.Fatalf("Endpoint = %q", cfg.WebSearch.Endpoint)
}
if cfg.WebSearch.Timeout != 25*time.Second {
t.Fatalf("Timeout = %v, want 25s", cfg.WebSearch.Timeout)
}
}
// TestWebSearch_ExplicitEmptyMeansOff 钉住「写了键但留空 = 关闭」,它与「没写这个键
// (⇒ 默认兜底)」是两件事:前者必须让工具从注册面消失,后者必须开箱可用。
func TestWebSearch_ExplicitEmptyMeansOff(t *testing.T) {
t.Parallel()
cfg, err := Load(writeEnvFile(t, strings.Join([]string{
"JWT_SECRET=" + strings.Repeat("s", 40),
"WEB_SEARCH_ENDPOINT=",
"",
}, "\n")))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.WebSearch.Endpoint != "" {
t.Fatalf("显式留空后 Endpoint = %q, want \"\"", cfg.WebSearch.Endpoint)
}
decision := cfg.WebSearch.ResolveEndpoint()
if decision.Enabled || decision.Reason != WebSearchReasonOff {
t.Fatalf("空串必须是「显式关闭」: %+v", decision)
}
}
// TestWebSearch_InvalidTimeoutFailsLoad 钉住非法 duration 的明确行为:启动即报错
// (fail fast),绝不静默当成 0 或别的值。
func TestWebSearch_InvalidTimeoutFailsLoad(t *testing.T) {
t.Parallel()
_, err := Load(writeEnvFile(t, strings.Join([]string{
"JWT_SECRET=" + strings.Repeat("s", 40),
"WEB_SEARCH_TIMEOUT=25",
"",
}, "\n")))
if err == nil {
t.Fatal("WEB_SEARCH_TIMEOUT=25 不是合法 duration,Load 必须报错而不是静默取值")
}
if !strings.Contains(err.Error(), "WEB_SEARCH_TIMEOUT") {
t.Fatalf("错误信息必须点出是哪个键: %v", err)
}
}
// TestWebSearchConfig_ResolveEndpoint 是关闭 / 非法 / 可用三种去处的表驱动裁决。
func TestWebSearchConfig_ResolveEndpoint(t *testing.T) {
t.Parallel()
cases := []struct {
name string
endpoint string
wantEnabled bool
wantURL string
wantReason string
}{
{"空串 = 关闭", "", false, "", WebSearchReasonOff},
{"off", "off", false, "", WebSearchReasonOff},
{"none", "none", false, "", WebSearchReasonOff},
{"大写 OFF", "OFF", false, "", WebSearchReasonOff},
{"带空白 None", " None ", false, "", WebSearchReasonOff},
{"http 可用", "http://127.0.0.1:38471", true, "http://127.0.0.1:38471", ""},
{"https 带路径", "https://searx.example.com/search", true, "https://searx.example.com/search", ""},
{"周围空白被裁掉", " http://searx.example.com ", true, "http://searx.example.com", ""},
{"scheme 非 http(s)", "ftp://searx.example.com", false, "", WebSearchReasonInvalid},
{"缺主机", "http:///search", false, "", WebSearchReasonInvalid},
{"裸 host:port", "127.0.0.1:38471", false, "", WebSearchReasonInvalid},
{"纯文字", "searx", false, "", WebSearchReasonInvalid},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := WebSearchConfig{Endpoint: tc.endpoint}.ResolveEndpoint()
if got.Enabled != tc.wantEnabled || got.URL != tc.wantURL || got.Reason != tc.wantReason {
t.Fatalf("ResolveEndpoint(%q) = %+v, want {Enabled:%v URL:%q Reason:%q}",
tc.endpoint, got, tc.wantEnabled, tc.wantURL, tc.wantReason)
}
})
}
}