package scraper import ( "crypto/tls" "crypto/x509" "net/http" "net/http/httptest" "testing" "time" ) func TestAdvertisesH3(t *testing.T) { cases := []struct { hdr http.Header want bool }{ {http.Header{"Alt-Svc": []string{`h3=":443";ma=2592000`}}, true}, {http.Header{"Alt-Svc": []string{`h2=":443";ma=2592000`}}, false}, {http.Header{}, false}, {nil, false}, } for i, c := range cases { if got := advertisesH3(c.hdr); got != c.want { t.Errorf("case %d: advertisesH3(%v) = %v, want %v", i, c.hdr, got, c.want) } } } func TestH3CapabilityCache(t *testing.T) { tr := newScraperTransport() host := "example.test" // Unknown host: try h3 first (newest-available-first). if !tr.shouldTryH3(host) { t.Errorf("unknown host should attempt h3 first") } // Positive capability keeps h3 enabled. tr.recordH3(host, true) if !tr.shouldTryH3(host) { t.Errorf("capable host should attempt h3") } // Negative capability gates h3 off. tr.recordH3(host, false) if tr.shouldTryH3(host) { t.Errorf("negative capability should disable h3") } // A forced host tries h3 even during a negative cool-down. tr.recordH3(host, false) tr.forceH3(host) if !tr.shouldTryH3(host) { t.Errorf("forced host should attempt h3") } } func TestH3NegativeExpiry(t *testing.T) { tr := newScraperTransport() host := "expiry.test" // A negative capability older than its TTL must re-enable h3. tr.mu.Lock() tr.cached[host] = h3Capability{capable: false, expireAt: time.Now().Add(-time.Second)} tr.mu.Unlock() if !tr.shouldTryH3(host) { t.Errorf("expired negative capability should attempt h3 again") } } func TestObserveAltSvcLearnsCapability(t *testing.T) { tr := newScraperTransport() host := "advertises.test" // Alt-Svc advertisement records a positive capability. tr.observeAltSvc(&http.Response{Header: http.Header{"Alt-Svc": []string{`h3=":443"`}}}, host) tr.mu.Lock() c := tr.cached[host] tr.mu.Unlock() if !c.capable { t.Errorf("alt-svc advertisement should record a positive h3 capability") } // A response without alt-svc must not clear an existing capability. tr.observeAltSvc(&http.Response{Header: http.Header{}}, host) tr.mu.Lock() c = tr.cached[host] tr.mu.Unlock() if !c.capable { t.Errorf("no alt-svc must not clear an existing capability") } } // TestRoundTripFallsBackToH12 verifies that when HTTP/3 is not available (the // httptest server speaks only HTTP/1.1 over TLS — no QUIC), the transport // falls back to the stdlib (h1/h2) transport and still returns a response. func TestRoundTripFallsBackToH12(t *testing.T) { srv := newTLSServer(t) cfg := srv.TLS cfg.NextProtos = []string{"http/1.1"} // keep it HTTP/1.1 so result is deterministic tr := newScraperTransport() // Make the h3 handshake fail fast instead of waiting the 5s const. quicCfg := tr.h3Transport().QUICConfig quicCfg.HandshakeIdleTimeout = 200 * time.Millisecond // Trust the httptest server's self-signed cert in both sub-transports. pool := x509.NewCertPool() pool.AddCert(srv.Certificate()) cerr := &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: pool} tr.h12.TLSClientConfig = cerr tr.h3.TLSClientConfig = cerr addr := srv.Listener.Addr().String() req, err := http.NewRequest("GET", "https://"+addr+"/", nil) if err != nil { t.Fatalf("new request: %v", err) } resp, err := tr.RoundTrip(req) if err != nil { t.Fatalf("RoundTrip: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Errorf("status = %d, want 200", resp.StatusCode) } // It must have worked over h12: HTTP/1.x (we pinned ALPN) and definitely // not HTTP/3. if resp.ProtoMajor >= 3 { t.Errorf("unexpected proto %s (should have fallen back to h1/h2)", resp.Proto) } } func newTLSServer(t *testing.T) *httptest.Server { t.Helper() s := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) })) s.StartTLS() t.Cleanup(s.Close) return s }