主人报的故障:她调 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 条全红
130 lines
4.9 KiB
Go
130 lines
4.9 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|
||
}
|