Files
djk/internal/app/network_browser_test.go

250 lines
9.7 KiB
Go

package app
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
fhttp "github.com/bogdanfinn/fhttp"
)
type browserFixtureClient struct {
do func(*fhttp.Request) (*fhttp.Response, error)
closed atomic.Int32
}
func (client *browserFixtureClient) Do(request *fhttp.Request) (*fhttp.Response, error) {
return client.do(request)
}
func (client *browserFixtureClient) CloseIdleConnections() { client.closed.Add(1) }
func TestHuangguoBrowserHTTP2CookiesAndNativeIsolation(t *testing.T) {
var requests atomic.Int32
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count := requests.Add(1)
if r.ProtoMajor != 2 || !strings.Contains(r.UserAgent(), "Chrome/150.0.0.0") || r.Header.Get("Sec-Ch-Ua-Platform") != `"macOS"` {
t.Errorf("browser fingerprint headers or HTTP/2 missing: %s %s", r.Proto, r.UserAgent())
}
_, err := r.Cookie("fixture_session")
if (count == 2) != (err == nil) {
t.Errorf("cookie session reuse/reset failed at request %d", count)
}
http.SetCookie(w, &http.Cookie{Name: "fixture_session", Value: "local-test-only", Path: "/", Secure: true})
io.WriteString(w, "fixture browser page")
}))
server.EnableHTTP2 = true
server.StartTLS()
defer server.Close()
cfg := defaultConfig()
cfg.dataDir, cfg.OutputDir, cfg.ProxyURL, cfg.Retries = t.TempDir(), t.TempDir(), "direct", 1
cfg.HuangguoVideoURL, cfg.InsecureTLS = server.URL, true
downloader := NewDownloader(cfg)
defer downloader.client.CloseIdleConnections()
downloader.limiter = newRequestLimiter(2, time.Nanosecond)
transport := downloader.client.Transport.(*imageTransport).base.(*huangguoBrowserTransport)
var nativeCalls atomic.Int32
transport.base = rankingTransport(func(r *http.Request) (*http.Response, error) {
nativeCalls.Add(1)
return rankingHTTPResponse(r, 200, "native fixture"), nil
})
for index := 0; index < 3; index++ {
if index == 2 {
downloader.client.CloseIdleConnections()
}
body, err := downloader.fetchProviderText(context.Background(), "https://huangguo.video/videos", "https://huangguo.video/")
if err != nil || body != "fixture browser page" {
t.Fatal(body, err)
}
}
if nativeCalls.Load() != 0 {
t.Fatal("browser request first used the native transport")
}
request, _ := http.NewRequest(http.MethodGet, "https://hongguoduanju.com/", nil)
response, err := downloader.client.Do(request)
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if nativeCalls.Load() != 1 {
t.Fatal("unrelated source did not keep native transport")
}
}
func TestHuangguoBrowserRespectsCertificatesAndCancellation(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "fixture") }))
defer server.Close()
cfg := defaultConfig()
cfg.dataDir, cfg.OutputDir, cfg.ProxyURL, cfg.Retries = t.TempDir(), t.TempDir(), "direct", 1
cfg.HuangguoVideoURL = server.URL
downloader := NewDownloader(cfg)
defer downloader.client.CloseIdleConnections()
if _, err := downloader.fetchProviderText(context.Background(), "https://huangguo.video/videos", ""); err == nil {
t.Fatal("untrusted certificate was accepted by default")
}
blocking := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.(http.Flusher).Flush()
<-r.Context().Done()
}))
defer blocking.Close()
cfg.HuangguoVideoURL = blocking.URL
downloader = NewDownloader(cfg)
defer downloader.client.CloseIdleConnections()
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
if _, err := downloader.fetchProviderText(ctx, "https://huangguo.video/videos", ""); err == nil {
t.Fatal("canceled browser body read succeeded")
}
downloader.limiter.mu.Lock()
active := downloader.limiter.active
downloader.limiter.mu.Unlock()
if active != 0 {
t.Fatal("browser timeout leaked a limiter permit")
}
}
func TestHuangguoBrowserProxyResetAndBackoff(t *testing.T) {
cfg := defaultConfig()
cfg.dataDir, cfg.OutputDir, cfg.ProxyURL, cfg.Retries = t.TempDir(), t.TempDir(), "http://proxy-one.invalid:8080", 3
downloader := NewDownloader(cfg)
defer downloader.client.CloseIdleConnections()
downloader.limiter = newRequestLimiter(2, time.Nanosecond)
transport := downloader.client.Transport.(*imageTransport).base.(*huangguoBrowserTransport)
var routes []string
var clients []*browserFixtureClient
calls := 0
transport.newClient = func(proxy string) (browserHTTPClient, error) {
routes = append(routes, proxy)
client := &browserFixtureClient{do: func(r *fhttp.Request) (*fhttp.Response, error) {
calls++
status, body := 403, "Cloudflare: Sorry, you have been blocked"
if len(routes) > 1 {
status, body = 200, "fixture success"
}
return &fhttp.Response{StatusCode: status, Header: fhttp.Header{"Cf-Ray": {"fixture-ray"}}, Body: io.NopCloser(strings.NewReader(body))}, nil
}}
clients = append(clients, client)
return client, nil
}
for index := 0; index < 2; index++ {
_, err := downloader.fetchProviderText(context.Background(), "https://huangguo.video/videos", "")
if err == nil || !strings.Contains(err.Error(), "Cloudflare 拒绝了当前请求") {
t.Fatal("missing accurate block cause", err)
}
}
if calls != 1 || len(routes) != 1 {
t.Fatal("backoff did not prevent repeated requests", calls, len(routes))
}
downloader.proxyRouter.configure("socks5://proxy-two.invalid:1080")
downloader.client.CloseIdleConnections()
if clients[0].closed.Load() == 0 {
t.Fatal("old proxy session not closed")
}
_, err := downloader.fetchProviderText(context.Background(), "https://huangguo.video/videos", "")
if err == nil || calls != 1 {
t.Fatal("proxy switch bypassed the existing backoff")
}
downloader.limiter.mu.Lock()
delete(downloader.limiter.backoffs, "huangguo.video")
downloader.limiter.mu.Unlock()
body, err := downloader.fetchProviderText(context.Background(), "https://huangguo.video/videos", "")
if err != nil || body != "fixture success" || len(routes) != 2 || routes[1] != "socks5://proxy-two.invalid:1080" {
t.Fatal("updated proxy was not used", routes, err)
}
}
func TestHuangguoBrowserRedirectUsesNativePolicy(t *testing.T) {
cfg := defaultConfig()
cfg.dataDir, cfg.OutputDir, cfg.ProxyURL = t.TempDir(), t.TempDir(), "direct"
downloader := NewDownloader(cfg)
defer downloader.client.CloseIdleConnections()
transport := downloader.client.Transport.(*imageTransport).base.(*huangguoBrowserTransport)
transport.newClient = func(string) (browserHTTPClient, error) {
return &browserFixtureClient{do: func(r *fhttp.Request) (*fhttp.Response, error) {
return &fhttp.Response{StatusCode: 302, Header: fhttp.Header{"Location": {"https://redirect.invalid/fixture"}, "Set-Cookie": {"fixture_session=private; Path=/"}}, Body: io.NopCloser(strings.NewReader(""))}, nil
}}, nil
}
transport.base = rankingTransport(func(r *http.Request) (*http.Response, error) {
if r.URL.Host != "redirect.invalid" || r.Header.Get("Cookie") != "" || r.Header.Get("Sec-Ch-Ua") != "" {
t.Error("redirect leaked fingerprint session headers")
}
return rankingHTTPResponse(r, 200, "redirected fixture"), nil
})
body, err := downloader.fetchProviderText(context.Background(), "https://huangguo.video/videos", "")
if err != nil || body != "redirected fixture" {
t.Fatal(body, err)
}
}
func TestHuangguoBrowserLivePages(t *testing.T) {
if os.Getenv("JUKU_HUANGGUO_BROWSER_PROBE") != "1" {
t.Skip("text-only live probe is opt-in")
}
cfg := defaultConfig()
cfg.dataDir, cfg.OutputDir, cfg.ProxyURL, cfg.Retries = t.TempDir(), t.TempDir(), firstNonEmpty(os.Getenv("JUKU_HUANGGUO_BROWSER_PROXY"), "direct"), 1
cfg.RequestIntervalMS = 2000
downloader := NewDownloader(cfg)
defer downloader.client.CloseIdleConnections()
var events []diagnosticEvent
transport := downloader.client.Transport.(*imageTransport).base.(*huangguoBrowserTransport)
transport.record = func(event diagnosticEvent) { events = append(events, event) }
ctx, cancel := context.WithTimeout(context.Background(), 70*time.Second)
defer cancel()
urls := []string{"https://huangguo.video/videos", "https://huangguo.video/videos?category=1"}
counts := []int{}
detailURL := ""
for _, address := range urls {
body, err := downloader.fetchProviderText(ctx, address, "https://huangguo.video/")
if err != nil {
t.Fatal(err)
}
dramas := parseHuangguoVideoCards(body, address)
if len(dramas) == 0 {
t.Fatal("200 page contained no business records")
}
counts = append(counts, len(dramas))
if detailURL == "" {
for _, drama := range dramas {
_, id, ok := splitProviderDramaID(drama.ID)
if ok && strings.HasPrefix(id, "series/") {
detailURL = "https://huangguo.video/" + id
break
}
}
}
}
if detailURL == "" {
t.Fatal("missing linked detail")
}
body, err := downloader.fetchProviderText(ctx, detailURL, "https://huangguo.video/")
if err != nil {
t.Fatal(err)
}
episodes := parseHuangguoVideoEpisodes(body, detailURL)
if len(episodes) == 0 {
t.Fatal("detail contained no episode links")
}
counts = append(counts, len(episodes))
evidence := map[string]any{"time": time.Now().UTC(), "client": "tls-client v1.16.0 / Chrome 150", "counts": counts, "requests": events, "route": map[bool]string{true: "direct", false: "configured-proxy"}[cfg.ProxyURL == "direct"]}
if output := os.Getenv("JUKU_HUANGGUO_BROWSER_EVIDENCE"); output != "" {
data, _ := json.MarshalIndent(evidence, "", " ")
if err := os.WriteFile(filepath.Clean(output), data, 0600); err != nil {
t.Fatal(err)
}
}
for _, event := range events {
if event.HTTPStatus != 200 || event.Protocol != "HTTP/2.0" {
t.Fatal("unexpected live response", event.HTTPStatus, event.Protocol)
}
}
t.Logf("text-only list/category/detail counts: %v; responses=%d", counts, len(events))
}