Files
Cyrene_v2/plugins/tools/io/bodyfetch_test.go
T
AskaEth c2e413ec38
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(io): web-fetch 直连→浏览器渲染降级档 + fetchedBy 如实标注(Z5 机制)
- 降级链:直连失败 / 判定为动态页 ⇒ 浏览器渲染取正文;返回体带 fetchedBy=direct|browser,
  两条档的失败各自留痕(directError/browserError/browserSource/dynamicSuspect),**绝不静默替换**
- 被 SSRF 拒绝的目标**不换路**(红线);渲染器缺位时如实报双失败
- 3 个变异自检(去掉动态页判据 / 去掉 SSRF 短路 / 掩盖双失败)
⚠️ **生产接不通**:internal/capability/browser 没有「取正文」入口(不在本片文件面)⇒
   开关默认关、不假装可用;解封形状已在报告里给出(加 RenderText ≈40 行)
2026-10-07 09:34:47 +08:00

312 lines
12 KiB
Go
Raw 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 io
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fakeBodyRenderer 是 BodyRenderer 的测试替身:它**记录**自己被调了几次、拿到的
// URL 是什么 —— 「直连成功时绝不碰浏览器」「被拒的目标不换路」这类要求只能靠
// calls 计数钉住,光看返回体看不出来。
type fakeBodyRenderer struct {
body RenderedBody
err error
calls int
urls []string
}
func (f *fakeBodyRenderer) RenderBody(_ context.Context, rawURL string) (RenderedBody, error) {
f.calls++
f.urls = append(f.urls, rawURL)
if f.err != nil {
return RenderedBody{}, f.err
}
return f.body, nil
}
// serveHTML 起一个恒返回这段 HTML 的测试服务(Content-Type 为 text/html)。
func serveHTML(t *testing.T, doc string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, doc)
}))
t.Cleanup(srv.Close)
return srv
}
// serveStatus 起一个恒返回指定状态码的测试服务(用于造「直连失败」)。
func serveStatus(t *testing.T, code int) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "boom", code)
}))
t.Cleanup(srv.Close)
return srv
}
// normalDoc 是一篇「正文够长、不像动态页」的普通页面(> dynamicTextMinChars 字)。
func normalDoc() string {
return `<html><body><h1>Hello</h1><p>` + strings.Repeat("正文句子。", 60) + `</p></body></html>`
}
// skeletonDoc 是一个只有前端框架挂载点、没有任何可见正文的**骨架页**。
func skeletonDoc() string {
return `<html><head><script>window.__NUXT__={}</script></head>` +
`<body><div id="app"></div><script src="/x.js"></script></body></html>`
}
// rendererTool 造一把带渲染器的 web-fetch(可选注入),并把服务主机放进白名单。
func rendererTool(t *testing.T, srv *httptest.Server, r BodyRenderer) *webFetchTool {
t.Helper()
opts := []Option{WithAllowedHosts(serverHost(srv)), WithHTTPClient(srv.Client())}
if r != nil {
opts = append(opts, WithBrowserFallback(r))
}
return webFetchToolForTest(t, opts...)
}
// ① 直连成功 + 不像动态页 ⇒ **绝不**走浏览器(calls 必为 0),且标注 direct。
func TestFetchBodyChain_DirectSuccessNeverTouchesBrowser(t *testing.T) {
t.Parallel()
srv := serveHTML(t, normalDoc())
r := &fakeBodyRenderer{body: RenderedBody{Text: "不该被用到", Source: "fake"}}
wf := rendererTool(t, srv, r)
got, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
if err != nil {
t.Fatalf("Execute: %v", err)
}
m := got.(map[string]any)
if m["fetchedBy"] != FetchedByDirect {
t.Errorf("fetchedBy = %v, want %q", m["fetchedBy"], FetchedByDirect)
}
if r.calls != 0 {
t.Errorf("直连成功却起了浏览器:calls = %d, urls = %v", r.calls, r.urls)
}
if !strings.Contains(m["text"].(string), "Hello") {
t.Errorf("text 不是直连的正文: %q", m["text"])
}
// 没有发生过的事,不许出现在结果里(空字段一律不写)。
for _, k := range []string{"directError", "browserSource", "browserError", "dynamicSuspect"} {
if _, exists := m[k]; exists {
t.Errorf("结果里不该有 %q(本次没有发生)", k)
}
}
}
// ② 直连失败 + 浏览器可用 ⇒ 走浏览器、标注 browser,并把直连的失败照实留痕。
func TestFetchBodyChain_BrowserRendersAfterDirectFailure(t *testing.T) {
t.Parallel()
srv := serveStatus(t, http.StatusInternalServerError)
r := &fakeBodyRenderer{body: RenderedBody{Text: "浏览器渲染出来的正文", Source: "本机有头 Chrome"}}
wf := rendererTool(t, srv, r)
got, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
if err != nil {
t.Fatalf("Execute: %v", err)
}
m := got.(map[string]any)
if m["fetchedBy"] != FetchedByBrowser {
t.Errorf("fetchedBy = %v, want %q", m["fetchedBy"], FetchedByBrowser)
}
if m["text"] != "浏览器渲染出来的正文" {
t.Errorf("text = %q", m["text"])
}
if m["browserSource"] != "本机有头 Chrome" {
t.Errorf("browserSource = %v(来源必须如实带出)", m["browserSource"])
}
if de, _ := m["directError"].(string); !strings.Contains(de, "status 500") {
t.Errorf("directError = %q,必须留下直连为什么没成", m["directError"])
}
// 渲染器拿到的必须是**原始 URL**(它自己要再过一遍 SSRF 判定)。
if r.calls != 1 || len(r.urls) != 1 || r.urls[0] != srv.URL {
t.Errorf("渲染器入参错: calls = %d, urls = %v, want [%s]", r.calls, r.urls, srv.URL)
}
}
// ③ 浏览器不可用(这一档没打开)+ 直连失败 ⇒ 两条都照实报,绝不只报一条、绝不冒充成功。
func TestFetchBodyChain_BrowserUnavailableReportsBothFailures(t *testing.T) {
t.Parallel()
srv := serveStatus(t, http.StatusInternalServerError)
wf := rendererTool(t, srv, nil) // 不注入渲染器 = 默认关
_, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
if err == nil {
t.Fatal("两条档都没成,必须有错误,不许冒充成功")
}
if !strings.Contains(err.Error(), "status 500") {
t.Errorf("错误里丢了直连的真实失败: %v", err)
}
if !strings.Contains(err.Error(), reasonNoBrowserRenderer) {
t.Errorf("错误里没说清浏览器这一档为什么没走: %v", err)
}
}
// ④ 浏览器这一档走了但失败 + 直连也失败 ⇒ 两条失败都出现在错误里(谁也别替谁背锅)。
func TestFetchBodyChain_BrowserRenderFailureKeepsBothFailures(t *testing.T) {
t.Parallel()
srv := serveStatus(t, http.StatusInternalServerError)
r := &fakeBodyRenderer{err: errors.New("CDP 连不上")}
wf := rendererTool(t, srv, r)
_, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
if err == nil {
t.Fatal("两条档都没成,必须有错误")
}
for _, want := range []string{"status 500", "CDP 连不上"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("错误里缺 %q: %v", want, err)
}
}
}
// ⑤ 直连成功但**像动态页** + 浏览器关着 ⇒ 交出直连正文,但把「页面可疑 + 浏览器
// 这一档为什么没走」如实标出来(不掩盖「本可以核一遍」)。
func TestFetchBodyChain_DynamicPageWithoutRendererIsHonest(t *testing.T) {
t.Parallel()
srv := serveHTML(t, skeletonDoc())
wf := rendererTool(t, srv, nil) // 不注入渲染器 = 这一档关着
got, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
if err != nil {
t.Fatalf("Execute: %v", err)
}
m := got.(map[string]any)
if m["fetchedBy"] != FetchedByDirect {
t.Errorf("fetchedBy = %v, want %q(浏览器没走成,来源仍是直连)", m["fetchedBy"], FetchedByDirect)
}
if m["dynamicSuspect"] != true {
t.Errorf("dynamicSuspect = %v,骨架页必须被判成可疑", m["dynamicSuspect"])
}
if m["browserError"] != reasonNoBrowserRenderer {
t.Errorf("browserError = %v,必须说明这一档为什么没走", m["browserError"])
}
}
// ⑥ 直连成功但像动态页 + 浏览器可用 ⇒ 改用浏览器渲染,并同时标注「页面可疑」。
func TestFetchBodyChain_DynamicPageUsesBrowser(t *testing.T) {
t.Parallel()
srv := serveHTML(t, skeletonDoc())
r := &fakeBodyRenderer{body: RenderedBody{Text: "渲染后的正文", Source: "本机有头 Chrome"}}
wf := rendererTool(t, srv, r)
got, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
if err != nil {
t.Fatalf("Execute: %v", err)
}
m := got.(map[string]any)
if m["fetchedBy"] != FetchedByBrowser {
t.Errorf("fetchedBy = %v, want %q", m["fetchedBy"], FetchedByBrowser)
}
if m["text"] != "渲染后的正文" {
t.Errorf("text = %q", m["text"])
}
if m["dynamicSuspect"] != true {
t.Errorf("dynamicSuspect = %v,判定为动态页这件事必须留痕", m["dynamicSuspect"])
}
if _, exists := m["directError"]; exists {
t.Errorf("直连其实成功了,不该有 directError: %v", m["directError"])
}
if r.calls != 1 {
t.Errorf("渲染器调用次数 = %d, want 1", r.calls)
}
}
// ⑦ 被 SSRF 判定拒绝的目标**不换路**:降级档绝不能变成绕过判定的通道。
func TestFetchBodyChain_SSRFDeniedNeverFallsBackToBrowser(t *testing.T) {
t.Parallel()
r := &fakeBodyRenderer{body: RenderedBody{Text: "不该被用到", Source: "fake"}}
wf := webFetchToolForTest(t, WithBrowserFallback(r))
_, err := wf.Execute(context.Background(), map[string]any{"url": "http://localhost:9/"})
if !errors.Is(err, ErrSSRF) {
t.Fatalf("期望 ErrSSRF,实得 %v", err)
}
if r.calls != 0 {
t.Errorf("被拒的目标居然起了浏览器(calls = %d)—— 那是绕过判定,不是降级", r.calls)
}
}
// ⑧ 渲染成功但一个字都没取到 ⇒ 按失败处理(空正文不许冒充「取到了」)。
func TestFetchBodyChain_EmptyRenderedBodyIsFailure(t *testing.T) {
t.Parallel()
srv := serveStatus(t, http.StatusInternalServerError)
r := &fakeBodyRenderer{body: RenderedBody{Text: " \n "}}
wf := rendererTool(t, srv, r)
_, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
if !errors.Is(err, ErrEmptyRenderedBody) {
t.Fatalf("期望 ErrEmptyRenderedBody,实得 %v", err)
}
if !strings.Contains(err.Error(), "status 500") {
t.Errorf("错误里还得有直连的真实失败: %v", err)
}
}
// ⑨ 渲染出来的正文同样受字节上限约束,且截断不切断 UTF-8 字符。
func TestFetchBodyChain_RenderedTextRespectsMaxBytes(t *testing.T) {
t.Parallel()
srv := serveStatus(t, http.StatusInternalServerError)
r := &fakeBodyRenderer{body: RenderedBody{Text: strings.Repeat("正", 50), Source: "fake"}}
wf := rendererTool(t, srv, r)
got, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL, "max_bytes": 10})
if err != nil {
t.Fatalf("Execute: %v", err)
}
m := got.(map[string]any)
if len(m["text"].(string)) != 9 {
t.Errorf("按 10 字节截断后应为 3 个汉字 = 9 字节,实得 %d", len(m["text"].(string)))
}
if m["truncated"] != true {
t.Errorf("truncated = %v, want true", m["truncated"])
}
}
// ⑩ 开关默认关:不传选项、或显式传 nil,渲染器都是 nil(= 这一档关着)。
func TestWithBrowserFallback_DefaultsAndNilAreOff(t *testing.T) {
t.Parallel()
srv := serveStatus(t, http.StatusInternalServerError)
if wf := webFetchToolForTest(t); wf.renderer != nil {
t.Error("默认(不传选项)必须是关:renderer 应为 nil")
}
wf := webFetchToolForTest(t, WithAllowedHosts(serverHost(srv)), WithHTTPClient(srv.Client()),
WithBrowserFallback(nil))
if wf.renderer != nil {
t.Fatal("WithBrowserFallback(nil) 必须是关")
}
_, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
if err == nil || !strings.Contains(err.Error(), reasonNoBrowserRenderer) {
t.Fatalf("关着的时候直连失败必须如实报「这一档没打开」: %v", err)
}
}
// ⑪ 动态页判定的三条边界:非 HTML 不算、正文够长不算、没有框架标记不算。
func TestLooksDynamic(t *testing.T) {
t.Parallel()
cases := []struct {
name string
mt string
text string
rawHTML string
want bool
}{
{"骨架页 + 框架标记", "text/html", "", skeletonDoc(), true},
{"骨架页但没有任何框架标记", "text/html", "", `<html><body></body></html>`, false},
{"正文够长", "text/html", strings.Repeat("字", dynamicTextMinChars), skeletonDoc(), false},
{"非 HTML(纯文本)", "text/plain", "", `id="app"`, false},
{"非 HTML(JSON)", "application/json", "", `{"mount":"id=\"app\"","x":"id=\"root\""}`, false},
}
for _, tc := range cases {
if got := looksDynamic(tc.mt, tc.text, tc.rawHTML); got != tc.want {
t.Errorf("%s: looksDynamic = %v, want %v", tc.name, got, tc.want)
}
}
}