package browser import ( "context" "errors" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg" ) // renderTestManager 造一个「导航目标已过白名单」的管理器:白名单里有 example.com, // 于是 RenderText 的 SSRF 判定走白名单短路(不依赖外网 DNS,离线可跑)。 func renderTestManager(t *testing.T, avail browsercfg.Availability) *Manager { t.Helper() m := NewManager(Options{ Availability: avail, Config: browsercfg.Defaults(t.TempDir()), DataDir: t.TempDir(), AllowedHosts: []string{"example.com"}, }) t.Cleanup(func() { _ = m.Close() }) return m } // okBrowserCDP 是「导航得通、正文取得回」的假 CDP:它按方法名应答,并把页面自报的 // state(url/title/readyState)与正文 text 分别交给 pageState 与 innerText 两次求值。 // 两个求值靠表达式区分(一个读 readyState,一个读 body.innerText)。 func okBrowserCDP(t *testing.T, text string) *fakeCDP { t.Helper() return newFakeCDP(t, func(method string, params map[string]any) (any, *cdpError) { switch method { case "Page.enable", "Page.navigate": return map[string]any{}, nil case "Page.getFrameTree": return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil case "Page.createIsolatedWorld": return map[string]any{"executionContextId": 1}, nil case "Runtime.evaluate": expr, _ := params["expression"].(string) if strings.Contains(expr, "readyState") { return map[string]any{"result": map[string]any{"value": map[string]any{ "url": "https://example.com/final", "title": "示例页", "readyState": "complete", }}}, nil } if !strings.Contains(expr, "innerText") { return nil, &cdpError{Code: -32601, Message: "unexpected expression " + expr} } return map[string]any{"result": map[string]any{"value": text}}, nil } return nil, &cdpError{Code: -32601, Message: "unexpected " + method} }) } // cdpEndpointAvail 把假 CDP 的地址包装成「已配 CDP 端点」的探测结论(走 remoteCDP, // 不起任何本机进程)。 func cdpEndpointAvail(f *fakeCDP) browsercfg.Availability { return browsercfg.Availability{ Usable: true, Source: browsercfg.SourceCDP, Endpoint: f.base, Detail: "假 CDP 端点 " + f.base, } } // ① 渲染成功 ⇒ 取回渲染后的可见正文(innerText,不是 HTML 源码),并如实带出来源与 // 页面自报的地址/标题。 func TestManager_RenderTextReturnsVisibleText(t *testing.T) { t.Parallel() f := okBrowserCDP(t, "渲染后的正文:Hello 世界") m := renderTestManager(t, cdpEndpointAvail(f)) got, err := m.RenderText(context.Background(), "https://example.com/") if err != nil { t.Fatalf("RenderText: %v", err) } if got.Text != "渲染后的正文:Hello 世界" { t.Errorf("Text = %q", got.Text) } if got.URL != "https://example.com/final" || got.Title != "示例页" { t.Errorf("URL/Title 应取页面自报值,实得 %q / %q", got.URL, got.Title) } if !strings.Contains(got.Source, "假 CDP 端点") { t.Errorf("Source = %q,必须如实标注来源", got.Source) } } // ② 渲染**成功但正文为空** ⇒ 空串 + nil error:它是读数不是失败,由上层按自己的口径 // 判(io 的降级档把空正文按 ErrEmptyRenderedBody 处理)。这里钉住本层不许把它变成 error, // 也不许拿空串冒充「取到了失败」。 func TestManager_RenderTextEmptyBodyIsNotAnError(t *testing.T) { t.Parallel() f := okBrowserCDP(t, " \n ") m := renderTestManager(t, cdpEndpointAvail(f)) got, err := m.RenderText(context.Background(), "https://example.com/") if err != nil { t.Fatalf("正文为空不该是错误(它是读数),实得 %v", err) } if strings.TrimSpace(got.Text) != "" { t.Errorf("Text = %q, want 空/空白", got.Text) } } // ③ 导航失败(CDP 的 errorText)⇒ 如实报错,**不返回空串冒充成功**:判据是 // errors.Is(err, ErrNavigate) 且正文那一次求值根本没发生过。 func TestManager_RenderTextNavigateFailureIsHonestError(t *testing.T) { t.Parallel() f := newFakeCDP(t, func(method string, _ map[string]any) (any, *cdpError) { switch method { case "Page.enable": return map[string]any{}, nil case "Page.navigate": return map[string]any{"errorText": "net::ERR_NAME_NOT_RESOLVED"}, nil } return nil, &cdpError{Code: -32601, Message: "导航失败后不该再发 " + method} }) m := renderTestManager(t, cdpEndpointAvail(f)) got, err := m.RenderText(context.Background(), "https://example.com/") if err == nil { t.Fatal("导航失败必须报错,不许返回空正文冒充成功") } if !errors.Is(err, ErrNavigate) { t.Errorf("err = %v, want ErrNavigate", err) } if !strings.Contains(err.Error(), "ERR_NAME_NOT_RESOLVED") { t.Errorf("错误里应带上 CDP 的原始原因: %v", err) } if got.Text != "" || got.Source != "" { t.Errorf("失败时不该交出半个结论: %+v", got) } } // ④ 页面求值异常(正文那一次)⇒ 归类为 ErrEvaluate,同样不许冒充「取到了空正文」。 func TestManager_RenderTextEvaluateFailureIsHonestError(t *testing.T) { t.Parallel() f := newFakeCDP(t, func(method string, params map[string]any) (any, *cdpError) { switch method { case "Page.enable", "Page.navigate": return map[string]any{}, nil case "Page.getFrameTree": return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil case "Page.createIsolatedWorld": return map[string]any{"executionContextId": 1}, nil case "Runtime.evaluate": expr, _ := params["expression"].(string) if strings.Contains(expr, "readyState") { return map[string]any{"result": map[string]any{"value": map[string]any{ "url": "https://example.com/", "title": "t", "readyState": "complete", }}}, nil } return map[string]any{"exceptionDetails": map[string]any{"text": "TypeError: body is gone"}}, nil } return nil, &cdpError{Code: -32601, Message: "unexpected " + method} }) m := renderTestManager(t, cdpEndpointAvail(f)) if _, err := m.RenderText(context.Background(), "https://example.com/"); !errors.Is(err, ErrEvaluate) { t.Fatalf("err = %v, want ErrEvaluate", err) } } // ⑤ SSRF 守卫在**起浏览器之前**跑:云元数据地址被拒,且没有任何 CDP 请求发生 // (假 CDP 只被 /json/version 碰过的话就不是这条路径 —— 这里直接用不可达的可执行文件, // 若顺序颠倒会拿到启动错误而不是守卫错误)。 func TestManager_RenderTextBlockedURLRejectedBeforeAnyProcess(t *testing.T) { t.Parallel() m := renderTestManager(t, browsercfg.Availability{ Usable: true, Source: browsercfg.SourceLocalChrome, Executable: "definitely-not-a-browser.exe", }) _, err := m.RenderText(context.Background(), "http://169.254.169.254/latest/meta-data/") if !errors.Is(err, ErrBlockedURL) { t.Fatalf("err = %v, want ErrBlockedURL", err) } if !strings.Contains(err.Error(), "安全守卫") { t.Errorf("错误应来自 URL 守卫: %v", err) } } // ⑥ 没有可用来源时不假装可用:ErrUnavailable(组合根据此**不挂**这条降级档)。 func TestManager_RenderTextUnavailableDoesNotPretend(t *testing.T) { t.Parallel() m := renderTestManager(t, browsercfg.Availability{Usable: false, Detail: "本机没有浏览器"}) if _, err := m.RenderText(context.Background(), "https://example.com/"); !errors.Is(err, ErrUnavailable) { t.Fatalf("err = %v, want ErrUnavailable", err) } }