package mediafetch import ( "context" "errors" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" ) // hitServer 是一个「记录命中次数」的假资产服务端:所有断言「0 次命中」的反证用例 // 都靠它的计数器说话(不是靠日志或猜测)。 type hitServer struct { srv *httptest.Server hits atomic.Int32 body []byte delay time.Duration code int next string // 非空时回 302 到该地址 } func newHitServer(t *testing.T, body []byte) *hitServer { t.Helper() hs := &hitServer{body: body} hs.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hs.hits.Add(1) if hs.delay > 0 { time.Sleep(hs.delay) } if hs.next != "" { http.Redirect(w, r, hs.next, http.StatusFound) return } if hs.code != 0 { w.WriteHeader(hs.code) return } w.Header().Set("Content-Type", "text/plain") _, _ = w.Write(hs.body) })) t.Cleanup(hs.srv.Close) return hs } func (h *hitServer) count() int { return int(h.hits.Load()) } // TestAllowlistMatchSemantics 钉住白名单的判定口径:deny-by-default、精确来源匹配、 // `*.suffix` 覆盖整棵子树但**不**覆盖裸域名。 func TestAllowlistMatchSemantics(t *testing.T) { t.Parallel() cases := []struct { name string entries []string url string want bool }{ {"空名单一律拒", nil, "https://cdn.example.com/a.txt", false}, {"精确命中", []string{"https://cdn.example.com"}, "https://cdn.example.com/a.txt", true}, {"主机大小写归一", []string{"https://cdn.example.com"}, "https://CDN.Example.COM/a.txt", true}, {"协议不同即拒", []string{"https://cdn.example.com"}, "http://cdn.example.com/a.txt", false}, {"端口不同即拒", []string{"https://cdn.example.com"}, "https://cdn.example.com:8443/a.txt", false}, {"默认端口等价", []string{"https://cdn.example.com"}, "https://cdn.example.com:443/a.txt", true}, {"通配覆盖子树", []string{"https://*.qq.com"}, "https://multimedia.nt.qq.com.cn/x", false}, {"通配覆盖子域", []string{"https://*.qq.com"}, "https://a.qq.com/x", true}, {"通配覆盖多级子域", []string{"https://*.qq.com"}, "https://a.b.qq.com/x", true}, {"通配不覆盖裸域", []string{"https://*.qq.com"}, "https://qq.com/x", false}, {"畸形条目不匹配任何东西", []string{"cdn.example.com"}, "https://cdn.example.com/a.txt", false}, {"非 http 引用一律拒", []string{"https://cdn.example.com"}, "file:///etc/passwd", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := NewAllowlist(tc.entries...).Allowed(tc.url); got != tc.want { t.Fatalf("Allowed(%q) = %v, want %v", tc.url, got, tc.want) } }) } } // TestFetchRejectsOffAllowlistWithoutRequest 是本包最要紧的反证:来源不在白名单里时 // **一个请求都不发出去**(断言服务端 0 次命中)。 func TestFetchRejectsOffAllowlistWithoutRequest(t *testing.T) { t.Parallel() hs := newHitServer(t, []byte("secret")) // 白名单里放的是别的主机 —— 与真实资产主机同网段、同形态,只差来源不匹配。 allow := NewAllowlist("https://assets.example.com") _, err := Fetch(context.Background(), Request{URL: hs.srv.URL + "/a.txt", Allow: allow, Client: hs.srv.Client()}) if !errors.Is(err, ErrOriginNotAllowed) { t.Fatalf("err = %v, want ErrOriginNotAllowed", err) } if hs.count() != 0 { t.Fatalf("白名单外的来源被请求了 %d 次,want 0", hs.count()) } } // TestFetchReturnsBytes 是正向用例:来源在白名单里 ⇒ 原样取回字节。 func TestFetchReturnsBytes(t *testing.T) { t.Parallel() hs := newHitServer(t, []byte("正文内容\n")) allow := NewAllowlist(originOf(t, hs.srv.URL)) got, err := Fetch(context.Background(), Request{URL: hs.srv.URL + "/a.txt", Allow: allow, Client: hs.srv.Client()}) if err != nil { t.Fatalf("Fetch: %v", err) } if string(got) != "正文内容\n" { t.Fatalf("bytes = %q", got) } if hs.count() != 1 { t.Fatalf("命中次数 = %d, want 1", hs.count()) } } // TestFetchRejectsOversizeWhileReading 钉住「边读边限」:服务端超过上限时放弃, // 返回 ErrTooLarge(绝不把整份超大响应读满内存)。 func TestFetchRejectsOversizeWhileReading(t *testing.T) { t.Parallel() hs := newHitServer(t, []byte(strings.Repeat("x", 4096))) allow := NewAllowlist(originOf(t, hs.srv.URL)) _, err := Fetch(context.Background(), Request{ URL: hs.srv.URL + "/big.txt", Allow: allow, Client: hs.srv.Client(), MaxBytes: 64, }) if !errors.Is(err, ErrTooLarge) { t.Fatalf("err = %v, want ErrTooLarge", err) } } // TestFetchTimeoutHonorsContext 钉住超时:单份下载有具名超时,超时后放弃并保留 // ctx 哨兵(调用方据此分类,不必解析错误文本)。 func TestFetchTimeoutHonorsContext(t *testing.T) { t.Parallel() hs := newHitServer(t, []byte("slow")) hs.delay = 500 * time.Millisecond allow := NewAllowlist(originOf(t, hs.srv.URL)) start := time.Now() _, err := Fetch(context.Background(), Request{ URL: hs.srv.URL + "/slow.txt", Allow: allow, Client: hs.srv.Client(), Timeout: 50 * time.Millisecond, }) if err == nil { t.Fatal("err = nil, want timeout") } if elapsed := time.Since(start); elapsed > time.Second { t.Fatalf("取回耗时 %v,超时没有生效", elapsed) } if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("err = %v, want context.DeadlineExceeded(哨兵链必须保留)", err) } } // TestFetchBadStatus 钉住非 2xx 一律失败(绝不把错误页正文当附件收下)。 func TestFetchBadStatus(t *testing.T) { t.Parallel() hs := newHitServer(t, nil) hs.code = http.StatusNotFound allow := NewAllowlist(originOf(t, hs.srv.URL)) _, err := Fetch(context.Background(), Request{URL: hs.srv.URL + "/missing.txt", Allow: allow, Client: hs.srv.Client()}) if !errors.Is(err, ErrBadStatus) { t.Fatalf("err = %v, want ErrBadStatus", err) } } // TestFetchBlocksRedirectOffAllowlist 钉住重定向逐跳校验:302 到白名单外的主机时 // 拒绝,且**目标主机一次都没被请求**。 func TestFetchBlocksRedirectOffAllowlist(t *testing.T) { t.Parallel() outside := newHitServer(t, []byte("evil")) inside := newHitServer(t, []byte("ok")) inside.next = outside.srv.URL + "/evil.txt" allow := NewAllowlist(originOf(t, inside.srv.URL)) _, err := Fetch(context.Background(), Request{ URL: inside.srv.URL + "/a.txt", Allow: allow, Client: inside.srv.Client(), }) if !errors.Is(err, ErrOriginNotAllowed) { t.Fatalf("err = %v, want ErrOriginNotAllowed(重定向跨到白名单外)", err) } if outside.count() != 0 { t.Fatalf("白名单外的重定向目标被请求了 %d 次,want 0", outside.count()) } } // TestFetchErrorNeverCarriesFullURL 钉住「错误可安全进日志」:平台资产地址可能带签名 // 凭据(如 CDN 的 rkey),错误串里**绝不能**出现它。 func TestFetchErrorNeverCarriesFullURL(t *testing.T) { t.Parallel() // 127.0.0.1:1 上不会有人监听;白名单放行它,于是请求真的会发出去但连不上。 const secret = "rkey=SUPER_SECRET_VALUE" allow := NewAllowlist("http://127.0.0.1:1") _, err := Fetch(context.Background(), Request{ URL: "http://127.0.0.1:1/download?" + secret, Allow: allow, Timeout: 200 * time.Millisecond, }) if err == nil { t.Fatal("err = nil, want dial failure") } if strings.Contains(err.Error(), secret) || strings.Contains(err.Error(), "/download") { t.Fatalf("错误串里出现了 URL 或凭据:%v", err) } if !strings.Contains(err.Error(), "http://127.0.0.1:1") { t.Fatalf("错误串里应保留来源(便于定位是哪个平台主机):%v", err) } } // TestFetchRejectsEmptyAndNonHTTP 钉住输入侧的基本拒绝面。 func TestFetchRejectsEmptyAndNonHTTP(t *testing.T) { t.Parallel() allow := NewAllowlist("https://cdn.example.com") if _, err := Fetch(context.Background(), Request{URL: " ", Allow: allow}); !errors.Is(err, ErrEmptyURL) { t.Fatalf("空引用 err = %v, want ErrEmptyURL", err) } if _, err := Fetch(context.Background(), Request{URL: "ftp://cdn.example.com/a", Allow: allow}); !errors.Is(err, ErrBadScheme) { t.Fatalf("非 http err = %v, want ErrBadScheme", err) } // HostOf 只回来源,绝不回完整 URL。 if got := HostOf("https://cdn.example.com/a?token=SECRET"); strings.Contains(got, "SECRET") { t.Fatalf("HostOf 泄露了查询串:%q", got) } } // originOf 返回测试服务端的来源串。 func originOf(t *testing.T, rawURL string) string { t.Helper() origin, err := OriginOf(rawURL) if err != nil { t.Fatalf("OriginOf(%q): %v", rawURL, err) } return origin }