package io import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // 本文件是切片 WS1-A 的 io 侧契约:**拿不到可用搜索端点时,web-search 不进注册面**, // 以及「配置里给定的端点确实被用上」——后者包含本机兜底端点(回环地址)必须真能 // 走通 SSRF 守卫,否则「端点配了却仍然用不了」换个形态又回来了。 // hasTool 报告工具表里有没有某个名字的工具。 func hasTool(set []sdk.Tool, name string) bool { for _, tl := range set { if tl.Name() == name { return true } } return false } // searxngFallbackURL 是本机兜底 SearXNG 的地址(与 config 的 WEB_SEARCH_ENDPOINT // 默认值同源;那个默认值由 cmd/cyrene 的接线测试钉住)。它只用于真机 e2e。 const searxngFallbackURL = "http://127.0.0.1:38471" // TestNew_WithoutSearchOmitsTheTool 钉住「关闭态 = 模型看不见这把锤子」: // WithoutSearch 之后注册面里没有 web-search,其余六个工具一个不少、顺序不变。 func TestNew_WithoutSearchOmitsTheTool(t *testing.T) { t.Parallel() set := buildTools(t, &config.Config{}, WithoutSearch()) if hasTool(set, "web-search") { t.Fatal("WithoutSearch 之后注册面里仍有 web-search —— 模型会看见一把必报错的锤子") } for _, name := range []string{"file", "image-crop", "http", "web-fetch", "knowledge", "document-read"} { if !hasTool(set, name) { t.Errorf("WithoutSearch 不得影响其它工具,但 %q 不见了", name) } } if len(set) != 6 { t.Fatalf("工具数 = %d, want 6", len(set)) } // 对照:不给该选项时仍然注册(既有调用方行为不变)。 if plain := buildTools(t, &config.Config{}); !hasTool(plain, "web-search") { t.Fatal("未传 WithoutSearch 时 web-search 必须照旧注册(后向兼容)") } } // TestNew_WithoutSearchWinsOverEndpoint 钉住两个选项同时出现时的裁决: // 关闭优先 —— 否则「端点为空 + 关闭」的组合会退化成既注册又报错。 func TestNew_WithoutSearchWinsOverEndpoint(t *testing.T) { t.Parallel() set := buildTools(t, &config.Config{}, WithSearchEndpoint("http://127.0.0.1:38471"), WithoutSearch()) if hasTool(set, "web-search") { t.Fatal("WithoutSearch 必须压过 WithSearchEndpoint:注册面里不得出现 web-search") } } // TestWebSearch_ConfiguredEndpointIsUsed 是「配置值真被透传」的 io 侧断言: // 断言的不是「没报错」,而是**请求真的打到了配置里那个地址**(含 q / format=json)。 func TestWebSearch_ConfiguredEndpointIsUsed(t *testing.T) { t.Parallel() const query = "昔涟" got := make(chan *http.Request, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got <- r w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"results":[{"title":"A","url":"http://a.example","content":"alpha"}]}`)) })) defer srv.Close() ws := webSearchToolForTest(t, WithSearchEndpoint(srv.URL+"/search"), WithHTTPClient(srv.Client()), ) if ws.endpoint != srv.URL+"/search" { t.Fatalf("工具持有的端点 = %q, want %q(配置值没被透传)", ws.endpoint, srv.URL+"/search") } res, err := ws.Execute(context.Background(), map[string]any{"query": query}) if err != nil { t.Fatalf("Execute: %v", err) } select { case r := <-got: if r.URL.Path != "/search" { t.Errorf("请求路径 = %q, want /search", r.URL.Path) } if r.URL.Query().Get("q") != query { t.Errorf("q = %q, want %q", r.URL.Query().Get("q"), query) } if r.URL.Query().Get("format") != "json" { t.Errorf("format = %q, want json", r.URL.Query().Get("format")) } case <-time.After(2 * time.Second): t.Fatal("配置里的端点一个请求都没收到 —— 端点没被真正使用") } if m := res.(map[string]any); m["count"].(int) != 1 { t.Fatalf("count = %v, want 1", m["count"]) } } // TestWebSearch_LoopbackEndpointNeedsNoAllowlist 钉住本机兜底端点(回环地址)必须 // **开箱可用**:不额外传 WithAllowedHosts 也能查询成功。少了这条,默认端点 // http://127.0.0.1:38471 会被 SSRF 守卫拒掉,等于换个姿势又给了把用不了的锤子。 func TestWebSearch_LoopbackEndpointNeedsNoAllowlist(t *testing.T) { t.Parallel() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"results":[{"title":"A","url":"http://a.example","content":"alpha"}]}`)) })) defer srv.Close() ws := webSearchToolForTest(t, WithSearchEndpoint(srv.URL), WithHTTPClient(srv.Client()), ) res, err := ws.Execute(context.Background(), map[string]any{"query": "cyrene"}) if err != nil { t.Fatalf("回环端点必须开箱可用,却报错: %v", err) } if m := res.(map[string]any); m["count"].(int) != 1 { t.Fatalf("count = %v, want 1", m["count"]) } if !isHostAllowed(serverHost(srv), ws.allowedHosts) { t.Fatalf("端点主机不在本工具的 SSRF 白名单里: %v", ws.allowedHosts) } } // TestWebSearch_DoesNotWidenSharedAllowlist 钉住上一条的**边界**:给 web-search 放行 // 它自己的端点主机,绝不许顺带把回环地址塞进 http / web-fetch 共用的那份白名单 // —— 那会让模型伸手去摸本机服务(含后端自己的 :10942)。 func TestWebSearch_DoesNotWidenSharedAllowlist(t *testing.T) { t.Parallel() set := buildTools(t, &config.Config{}, WithSearchEndpoint(searxngFallbackURL)) ht := findTool(t, set, "http").(*httpTool) if isHostAllowed("127.0.0.1", ht.allowedHosts) { t.Fatalf("http 工具的 SSRF 白名单被搜索端点污染了: %v", ht.allowedHosts) } _, err := ht.Execute(context.Background(), map[string]any{"url": "http://127.0.0.1:10942/api/v1/health"}) if !errors.Is(err, ErrSSRF) { t.Fatalf("http 工具仍必须拒绝回环地址,err = %v", err) } } // TestWebSearch_Redirects 用本机兜底 SearXNG 的真实形状(308 → /search)钉住重定向: // // - 跳到**端点自己**的相对路径必须被跟随(SearXNG 收到 "/?q=…" 就是 308 到 // "/search?q=…";少了这条,默认端点仍是「配了却用不了」); // - 跳到**别的**受保护地址(私网/回环)必须仍被拦下 —— 放行的是「配置里那一个 // 端点主机」,不是「所有重定向」。 func TestWebSearch_Redirects(t *testing.T) { t.Parallel() t.Run("跟随端点自身的相对跳转", func(t *testing.T) { t.Parallel() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/search" { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"results":[{"title":"A","url":"http://a.example","content":"alpha"}]}`)) return } w.Header().Set("Location", "/search?"+r.URL.RawQuery) w.WriteHeader(http.StatusPermanentRedirect) })) defer srv.Close() ws := webSearchToolForTest(t, WithSearchEndpoint(srv.URL)) res, err := ws.Execute(context.Background(), map[string]any{"query": "cyrene"}) if err != nil { t.Fatalf("相对重定向必须被跟随: %v", err) } if m := res.(map[string]any); m["count"].(int) != 1 { t.Fatalf("count = %v, want 1", m["count"]) } }) t.Run("拒绝跳到私网地址", func(t *testing.T) { t.Parallel() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Location", "http://10.255.255.1/search") w.WriteHeader(http.StatusPermanentRedirect) })) defer srv.Close() ws := webSearchToolForTest(t, WithSearchEndpoint(srv.URL)) if _, err := ws.Execute(context.Background(), map[string]any{"query": "cyrene"}); !errors.Is(err, ErrSSRF) { t.Fatalf("跳到私网地址必须被 SSRF 守卫拦下,err = %v", err) } }) } // TestWebSearch_TimeoutIsPerCallAndLocal 钉住超时旋钮是**每次搜索自己**的预算: // 它生效(慢端点被掐断),且**不改**任何客户端自身的超时(http / web-fetch 共用 // 的那个客户端因此不受影响)。 func TestWebSearch_TimeoutIsPerCallAndLocal(t *testing.T) { t.Parallel() release := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { select { case <-release: case <-r.Context().Done(): } _, _ = w.Write([]byte(`{"results":[]}`)) })) defer srv.Close() defer close(release) set := buildTools(t, &config.Config{}, WithSearchEndpoint(srv.URL), WithSearchTimeout(50*time.Millisecond), ) ws := findTool(t, set, "web-search").(*webSearchTool) if ws.timeout != 50*time.Millisecond { t.Fatalf("web-search 超时 = %v, want 50ms", ws.timeout) } if _, err := ws.Execute(context.Background(), map[string]any{"query": "cyrene"}); err == nil { t.Fatal("50ms 预算下慢端点必须被掐断") } // 两个客户端自身的超时都还是 io 包的既有默认值:搜索超时是「按次预算」,不是 // 客户端超时——否则改 WEB_SEARCH_TIMEOUT 会顺手改掉 http/web-fetch 的行为。 ht := findTool(t, set, "http").(*httpTool) if ht.client.Timeout != defaultHTTPTimeout { t.Fatalf("http 客户端超时 = %v, want %v(WEB_SEARCH_TIMEOUT 不得改到别的工具)", ht.client.Timeout, defaultHTTPTimeout) } if ws.client.Timeout != defaultHTTPTimeout { t.Fatalf("web-search 客户端超时 = %v, want %v(超时应是每次调用的预算)", ws.client.Timeout, defaultHTTPTimeout) } } // TestSearchEndpointHost 覆盖端点主机抽取的边角:空、非法、带端口、IPv6、已命中白名单。 func TestSearchEndpointHost(t *testing.T) { t.Parallel() cases := []struct { name string endpoint string want string }{ {"带端口", "http://127.0.0.1:38471", "127.0.0.1"}, {"带路径", "https://searx.example.com/search?format=json", "searx.example.com"}, {"IPv6", "http://[::1]:8080", "::1"}, {"空串", "", ""}, {"无主机", "http:///search", ""}, {"非法", "http://%zz", ""}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := searchEndpointHost(tc.endpoint); got != tc.want { t.Fatalf("searchEndpointHost(%q) = %q, want %q", tc.endpoint, got, tc.want) } }) } // 已命中白名单时不重复添加;空主机时原样返回(不改动调用方的切片)。 base := []string{"example.com"} if out := withSearchEndpointHost(base, "https://example.com"); len(out) != 1 { t.Fatalf("已在白名单里的主机不得重复添加: %v", out) } if out := withSearchEndpointHost(base, "not a url"); len(out) != 1 || out[0] != "example.com" { t.Fatalf("解析不出主机时必须原样返回: %v", out) } out := withSearchEndpointHost(base, "http://127.0.0.1:38471") if len(out) != 2 || out[0] != "example.com" || out[1] != "127.0.0.1" { t.Fatalf("应返回追加主机后的新切片: %v", out) } if len(base) != 1 { t.Fatalf("不得就地修改入参切片: %v", base) } } // searxngResultCount 用**裸 HTTP**(与工具实现无关)问兜底服务同一个查询,返回它此刻 // 真能产出的结果条数;服务不可达/应答不可解析时返回 -1。 // // 它是真机 e2e 的**前置探针**:上层引擎被限流或 CAPTCHA 时兜底服务会「200 但 0 条」, // 那时 0 条结果不是本仓的缺陷(2026-10-05 实测:sogou 被 Suspended: CAPTCHA、bing // 静默返回空)—— 探针让这种外部降级表现为 skip 而不是误报红,同时保住断言强度: // 探针报 >0 而工具报 0,就是本仓的错。 func searxngResultCount(t *testing.T, endpoint, query string) int { t.Helper() client := &http.Client{Timeout: 25 * time.Second} resp, err := client.Get(endpoint + "/search?q=" + url.QueryEscape(query) + "&format=json") if err != nil { return -1 } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return -1 } var payload struct { Results []searchResult `json:"results"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return -1 } return len(payload.Results) } // TestWebSearch_RealSearXNG_EndToEnd 是**真机**端到端:直接调工具实现打本机兜底 // SearXNG(scripts/searxng/settings.yml 的配套实例)。它只读外部服务,不碰后端进程、 // 不碰仓库根产物。兜底服务没起、或它的上游引擎被限流到产不出结果时 **skip** // (外部降级不该判成代码红;探针与工具的查询逐字相同,探针有结果而工具没有 = 真红)。 func TestWebSearch_RealSearXNG_EndToEnd(t *testing.T) { t.Parallel() const query = "昔涟 崩坏星穹铁道" upstream := searxngResultCount(t, searxngFallbackURL, query) if upstream < 0 { t.Skipf("本机兜底 SearXNG 不可达(%s),跳过真机 e2e", searxngFallbackURL) } if upstream == 0 { t.Skipf("兜底 SearXNG 当前产不出结果(上游引擎被限流/CAPTCHA),跳过真机 e2e") } ws := webSearchToolForTest(t, WithSearchEndpoint(searxngFallbackURL)) res, err := ws.Execute(context.Background(), map[string]any{"query": query}) if err != nil { t.Fatalf("真机查询失败: %v", err) } m := res.(map[string]any) results := m["results"].([]searchResult) if len(results) == 0 { t.Fatalf("裸 HTTP 探针拿到 %d 条、工具却拿到 0 条 —— 这不再是外部降级: %v", upstream, m) } t.Logf("真机 e2e:端点 %s 返回 %d 条结果,首条标题=%q url=%q", searxngFallbackURL, len(results), results[0].Title, results[0].URL) if strings.TrimSpace(results[0].URL) == "" { t.Fatalf("首条结果没有 url: %+v", results[0]) } }