package browser import ( "context" "errors" "strconv" "strings" "testing" "unicode/utf8" ) // navigateErrorCDP 造一个「导航必失败、errorText 是给定文本」的假 CDP:只应答 Page.enable // 与 Page.navigate,导航之后一律拒绝(失败之后不该再有求值)。 func navigateErrorCDP(t *testing.T, errorText string) *fakeCDP { t.Helper() return 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": errorText}, nil } return nil, &cdpError{Code: -32601, Message: "导航失败后不该再发 " + method} }) } // evaluateExceptionCDP 造一个「导航与就绪都正常、取正文那一次求值抛异常」的假 CDP: // 异常文本由调用方给(模拟页面 JS 往 exceptionDetails.text 里塞任何东西)。 func evaluateExceptionCDP(t *testing.T, exceptionText 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/", "title": "t", "readyState": "complete", }}}, nil } return map[string]any{"exceptionDetails": map[string]any{"text": exceptionText}}, nil } return nil, &cdpError{Code: -32601, Message: "unexpected " + method} }) } // 出口 A(`Page.navigate` 的 errorText):散文形状的值一个字符都不许出镜 —— 代理与网关 // 会把它回显成任意文本,包括它刚收到的那条带 token 的 URL。判据是「原文本里每个有辨识度 // 的片段都不在错误串里」,且出镜的是**字节数**(够判断对端说了句话,不带那句话)。 // // 删掉 navigateCause 的调用(退回 `fmt.Errorf("%w: %s", ErrNavigate, text)`)时本用例必红。 func TestManager_RenderTextNavigateErrorTextNeverEchoesFreeText(t *testing.T) { t.Parallel() const raw = "Failed loading https://example.com/?access_token=SUPERSECRET123 (via proxy)" m := renderTestManager(t, cdpEndpointAvail(navigateErrorCDP(t, raw))) _, err := m.RenderText(context.Background(), "https://example.com/") if !errors.Is(err, ErrNavigate) { t.Fatalf("err = %v, want ErrNavigate", err) } msg := err.Error() for _, leak := range []string{"SUPERSECRET123", "access_token", "https://example.com/?", "Failed loading", "proxy"} { if strings.Contains(msg, leak) { t.Errorf("错误串泄露了 errorText 的自由文本片段 %q: %q", leak, msg) } } if !strings.Contains(msg, "已隐去") { t.Errorf("应点明文本已隐去(否则读起来像对端没说话): %q", msg) } if !strings.Contains(msg, strconv.Itoa(len(raw))) { t.Errorf("应带出被隐去文本的字节数 %d(唯一的排查线索): %q", len(raw), msg) } } // 出口 A 的正向:合法的 net error 枚举**必须原样保留** —— 它是调用方与工具分类失败的 // 唯一依据(render_test ③ 也在断言它)。这条同时钉住「net:: 前缀要先剥掉再交给 // ErrorToken」这个实现前提:整串直接送进 ErrorToken 会因 `:` 被判空,正向就丢了。 func TestManager_RenderTextNavigateErrorTextKeepsNetErrorCode(t *testing.T) { t.Parallel() for _, code := range []string{ "net::ERR_NAME_NOT_RESOLVED", "net::ERR_CONNECTION_REFUSED", "net::ERR_TIMED_OUT", "ERR_ABORTED", } { m := renderTestManager(t, cdpEndpointAvail(navigateErrorCDP(t, code))) _, err := m.RenderText(context.Background(), "https://example.com/") if !errors.Is(err, ErrNavigate) { t.Fatalf("%s: err = %v, want ErrNavigate", code, err) } if !strings.Contains(err.Error(), code) { t.Errorf("%s 是合法的网络错误码,必须原样保留,实得 %q", code, err.Error()) } } } // 出口 B(`Runtime.evaluate` 的 exceptionDetails.text):页面 JS 可以 throw 任何东西, // 包括它读到的凭据。判据是「凭据形状被打码、消息本体仍可排障」—— 不是整段丢弃(自由 // 文本的处置口径是「带上限地带」,见 errtext.go)。 // // 删掉 evaluateCause 的调用(退回原样直吐)时本用例必红。 func TestManager_RenderTextExceptionTextRedactsCredentials(t *testing.T) { t.Parallel() const raw = "TypeError: fetch failed https://api.example.com/v1?api_key=sk-live-ABCDEFGH12345" m := renderTestManager(t, cdpEndpointAvail(evaluateExceptionCDP(t, raw))) _, err := m.RenderText(context.Background(), "https://example.com/") if !errors.Is(err, ErrEvaluate) { t.Fatalf("err = %v, want ErrEvaluate", err) } msg := err.Error() for _, leak := range []string{"ABCDEFGH12345", "sk-live"} { if strings.Contains(msg, leak) { t.Errorf("错误串泄露了凭据 %q: %q", leak, msg) } } if !strings.Contains(msg, "[redacted]") { t.Errorf("凭据形状应被打成 [redacted](看得见的打码,不是静默删除): %q", msg) } if !strings.Contains(msg, "TypeError") { t.Errorf("异常消息本体是唯一的定位线索,不该被整段丢掉: %q", msg) } } // 出口 B 的上限:页面可以把 4KB 正文塞进异常文本,而这条错误串同时进日志与模型可见视图。 // 判据:超限时截断看得见(`…`),且超出上限的尾部**确实没出镜**;换行被压平(一个换行 // 就能在按行聚合的日志里伪造出另一条记录)。 func TestManager_RenderTextExceptionTextIsBoundedAndFlattened(t *testing.T) { t.Parallel() const tail = "TAILMARKER" raw := strings.Repeat("A", maxExceptionTextRunes) + "\n" + tail m := renderTestManager(t, cdpEndpointAvail(evaluateExceptionCDP(t, raw))) _, err := m.RenderText(context.Background(), "https://example.com/") if !errors.Is(err, ErrEvaluate) { t.Fatalf("err = %v, want ErrEvaluate", err) } msg := err.Error() if strings.Contains(msg, tail) { t.Errorf("超出上限的尾部不该出镜: %q", msg) } if !strings.HasSuffix(msg, "…") { t.Errorf("截断必须看得见(尾部补省略号),不许让半截文本读起来像全文: %q", msg) } if strings.ContainsAny(msg, "\r\n") { t.Errorf("异常文本里的换行必须压平(否则可在按行聚合的日志里伪造记录): %q", msg) } } // pageExceptionText 的档位就是 maxExceptionTextRunes:超限出 maxExceptionTextRunes 个 // rune 加一个省略号,一个不多。 func TestPageExceptionTextLimitsRunes(t *testing.T) { t.Parallel() got := pageExceptionText(strings.Repeat("长", maxExceptionTextRunes+50)) if n, want := utf8.RuneCountInString(got), maxExceptionTextRunes+1; n != want { t.Errorf("rune 数 = %d, want %d(%d + 省略号)", n, want, maxExceptionTextRunes) } if !strings.HasSuffix(got, "…") { t.Errorf("截断应补省略号: %q", got) } // 未超限时原样返回,不补省略号(截断标记只表示「确实被截了」)。 short := pageExceptionText("TypeError: body is gone") if short != "TypeError: body is gone" { t.Errorf("未超限不该改动文本: %q", short) } // 只给空白 ⇒ 空串,由 evaluateCause 如实说「对端未给文本」。 if got := evaluateCause(" \n\t "); got != "页面异常(对端未给文本)" { t.Errorf("空白异常文本的处理 = %q", got) } } // netErrorCode 只放行「像 net error 枚举」的值:形状判定仍由 trace.ErrorToken 做,本 // 函数只补「net:: 命名空间可剥 + 必须以 ERR_ 开头」这两个前提 —— 于是「恰好是单个令牌 // 的自由文本」(`hunter2`)不会被误当成错误码保留。 func TestNetErrorCodeOnlyAcceptsEnumerations(t *testing.T) { t.Parallel() cases := []struct { in string want string }{ {"net::ERR_NAME_NOT_RESOLVED", "net::ERR_NAME_NOT_RESOLVED"}, {" net::ERR_ABORTED ", "net::ERR_ABORTED"}, {"ERR_FAILED", "ERR_FAILED"}, {"hunter2", ""}, {"Failed loading https://example.com/", ""}, {"net::ERR_NOT_A_CODE because spaces", ""}, {"net::hunter2", ""}, {"", ""}, } for _, c := range cases { if got := netErrorCode(c.in); got != c.want { t.Errorf("netErrorCode(%q) = %q, want %q", c.in, got, c.want) } } }