/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
:                                                                               :
:   █▀ █ █▀▀   ·   Blazing-fast pentesting suite                                :
:   ▄█ █ █▀    ·   BSD 3-Clause License                                         :
:                                                                               :
:   (c) 2022-2026 vmfunc, xyzeva,                                               :
:                 lunchcat alumni & contributors                                :
:                                                                               :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/

package httpx

import (
	"context"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"
)

// resetConfig clears the package-level transport so each test starts clean.
func resetConfig(t *testing.T) {
	t.Helper()
	mu.Lock()
	configured = nil
	mu.Unlock()
}

// captureServer records the headers of the last request it served.
func captureServer(t *testing.T, seen *http.Header) *httptest.Server {
	t.Helper()
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		*seen = r.Header.Clone()
		w.WriteHeader(http.StatusOK)
	}))
	t.Cleanup(srv.Close)
	return srv
}

func get(t *testing.T, client *http.Client, url string) {
	t.Helper()
	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, http.NoBody)
	if err != nil {
		t.Fatalf("new request: %v", err)
	}
	resp, err := client.Do(req)
	if err != nil {
		t.Fatalf("do request: %v", err)
	}
	resp.Body.Close()
}

func TestClientBeforeConfigure(t *testing.T) {
	resetConfig(t)

	var seen http.Header
	srv := captureServer(t, &seen)

	// a client must work with no Configure call so existing code is unaffected.
	get(t, Client(5*time.Second), srv.URL)

	if seen == nil {
		t.Fatal("request never reached the server")
	}
}

func TestConfigureHeadersAndCookie(t *testing.T) {
	tests := []struct {
		name      string
		opts      Options
		wantKey   string
		wantValue string
	}{
		{
			name:      "custom header injected",
			opts:      Options{Headers: []string{"X-Test: sif"}},
			wantKey:   "X-Test",
			wantValue: "sif",
		},
		{
			name:      "cookie injected",
			opts:      Options{Cookie: "session=abc"},
			wantKey:   "Cookie",
			wantValue: "session=abc",
		},
		{
			name:      "user agent injected",
			opts:      Options{UserAgent: "sif-scanner"},
			wantKey:   "User-Agent",
			wantValue: "sif-scanner",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			resetConfig(t)

			if err := Configure(tt.opts); err != nil {
				t.Fatalf("Configure: %v", err)
			}

			var seen http.Header
			srv := captureServer(t, &seen)
			get(t, Client(5*time.Second), srv.URL)

			if got := seen.Get(tt.wantKey); got != tt.wantValue {
				t.Errorf("header %q = %q, want %q", tt.wantKey, got, tt.wantValue)
			}
		})
	}
}

func TestConfigureHeaderDoesNotOverride(t *testing.T) {
	resetConfig(t)

	if err := Configure(Options{Headers: []string{"X-Test: global"}}); err != nil {
		t.Fatalf("Configure: %v", err)
	}

	var seen http.Header
	srv := captureServer(t, &seen)

	// a caller that sets the header explicitly must win over the global default.
	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
	if err != nil {
		t.Fatalf("new request: %v", err)
	}
	req.Header.Set("X-Test", "caller")
	resp, err := Client(5 * time.Second).Do(req)
	if err != nil {
		t.Fatalf("do request: %v", err)
	}
	resp.Body.Close()

	if got := seen.Get("X-Test"); got != "caller" {
		t.Errorf("X-Test = %q, want caller (caller value must not be overridden)", got)
	}
}

func TestConfigureInvalidHeader(t *testing.T) {
	resetConfig(t)

	// a header without ": " should fail loud rather than silently dropping.
	if err := Configure(Options{Headers: []string{"missing-separator"}}); err == nil {
		t.Fatal("expected error for malformed header, got nil")
	}
}

func TestConfigureInvalidProxy(t *testing.T) {
	tests := []struct {
		name  string
		proxy string
	}{
		{name: "unsupported scheme", proxy: "ftp://localhost:1080"},
		{name: "malformed url", proxy: "://nope"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			resetConfig(t)
			if err := Configure(Options{Proxy: tt.proxy}); err == nil {
				t.Errorf("expected error for proxy %q, got nil", tt.proxy)
			}
		})
	}
}

func TestRateLimit(t *testing.T) {
	resetConfig(t)

	const ratePerSec = 5
	if err := Configure(Options{RateLimit: ratePerSec}); err != nil {
		t.Fatalf("Configure: %v", err)
	}

	var seen http.Header
	srv := captureServer(t, &seen)
	client := Client(5 * time.Second)

	// at 5 req/s the limiter starts with a full burst, so the first batch is
	// immediate and the next request must wait roughly one tick. fire burst+1
	// requests and assert the extra one forced a measurable delay.
	const requests = ratePerSec + 1
	start := time.Now()
	for i := 0; i < requests; i++ {
		get(t, client, srv.URL)
	}
	elapsed := time.Since(start)

	// one request beyond the burst should cost about 1/rate; allow slack but
	// require a non-trivial delay so an unthrottled client fails this.
	minDelay := time.Second / ratePerSec / 2
	if elapsed < minDelay {
		t.Errorf("expected rate limiting to add >= %v of delay, got %v", minDelay, elapsed)
	}
}

func TestRateLimitUnlimited(t *testing.T) {
	resetConfig(t)

	// RateLimit 0 means no limiter is installed; requests should fly through.
	if err := Configure(Options{RateLimit: 0}); err != nil {
		t.Fatalf("Configure: %v", err)
	}

	mu.RLock()
	rt, ok := configured.(*roundTripper)
	mu.RUnlock()
	if !ok {
		t.Fatal("configured transport is not *roundTripper")
	}
	if rt.limiter != nil {
		t.Error("expected no limiter when RateLimit is 0")
	}
}

func TestIdlePerHost(t *testing.T) {
	tests := []struct {
		name    string
		threads int
		want    int
	}{
		{name: "below floor clamps up", threads: 1, want: minIdleConnsPerHost},
		{name: "zero clamps up", threads: 0, want: minIdleConnsPerHost},
		{name: "at floor", threads: minIdleConnsPerHost, want: minIdleConnsPerHost},
		{name: "above floor passes through", threads: 64, want: 64},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := idlePerHost(tt.threads); got != tt.want {
				t.Errorf("idlePerHost(%d) = %d, want %d", tt.threads, got, tt.want)
			}
		})
	}
}

func TestBuildTransportTuning(t *testing.T) {
	const threads = 32
	tr, err := buildTransport("", threads)
	if err != nil {
		t.Fatalf("buildTransport: %v", err)
	}

	if tr.MaxIdleConns != maxIdleConns {
		t.Errorf("MaxIdleConns = %d, want %d", tr.MaxIdleConns, maxIdleConns)
	}
	if tr.MaxIdleConnsPerHost != threads {
		t.Errorf("MaxIdleConnsPerHost = %d, want %d", tr.MaxIdleConnsPerHost, threads)
	}
	if tr.MaxConnsPerHost != 0 {
		t.Errorf("MaxConnsPerHost = %d, want 0 (unbounded)", tr.MaxConnsPerHost)
	}
	if tr.IdleConnTimeout != idleConnTimeout {
		t.Errorf("IdleConnTimeout = %v, want %v", tr.IdleConnTimeout, idleConnTimeout)
	}
	if !tr.ForceAttemptHTTP2 {
		t.Error("ForceAttemptHTTP2 = false, want true")
	}
}

func TestDrainClose(t *testing.T) {
	resetConfig(t)

	// serve a body the caller never reads; DrainClose must drain it so the conn
	// is eligible for reuse rather than abandoned mid-stream.
	const body = "sif response body that the caller never reads"
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
		io.WriteString(w, body)
	}))
	t.Cleanup(srv.Close)

	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
	if err != nil {
		t.Fatalf("new request: %v", err)
	}
	resp, err := Client(5 * time.Second).Do(req)
	if err != nil {
		t.Fatalf("do request: %v", err)
	}

	DrainClose(resp)

	// after DrainClose the body is closed; a further read must fail.
	if _, err := resp.Body.Read(make([]byte, 1)); err == nil {
		t.Error("expected read after DrainClose to fail on a closed body")
	}
}

func TestDrainCloseNil(t *testing.T) {
	// a nil response (e.g. an errored request) must not panic.
	DrainClose(nil)
	DrainClose(&http.Response{})
}

// countConns wraps a test server with a ConnState hook that tallies how many
// distinct tcp conns the server saw. distinct conns == failed reuse.
func countConns(t *testing.T) (*httptest.Server, func() int) {
	t.Helper()

	var (
		mu    sync.Mutex
		conns = make(map[net.Conn]struct{})
	)
	srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		// always write a body so reuse depends on the caller draining it.
		io.WriteString(w, "ok")
	}))
	srv.Config.ConnState = func(c net.Conn, state http.ConnState) {
		if state != http.StateNew {
			return
		}
		mu.Lock()
		conns[c] = struct{}{}
		mu.Unlock()
	}
	srv.Start()
	t.Cleanup(srv.Close)

	return srv, func() int {
		mu.Lock()
		defer mu.Unlock()
		return len(conns)
	}
}

func TestTransportReusesConnections(t *testing.T) {
	resetConfig(t)

	const (
		threads  = 8
		requests = 30
	)
	if err := Configure(Options{Threads: threads}); err != nil {
		t.Fatalf("Configure: %v", err)
	}

	srv, distinct := countConns(t)

	// fire N sequential requests through the tuned client, draining each body so
	// the conn returns to the pool. a working pool serves all of them on one conn.
	client := Client(5 * time.Second)
	for i := 0; i < requests; i++ {
		req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
		if err != nil {
			t.Fatalf("new request %d: %v", i, err)
		}
		resp, err := client.Do(req)
		if err != nil {
			t.Fatalf("do request %d: %v", i, err)
		}
		DrainClose(resp)
	}

	// sequential reuse should land on exactly one conn; allow a tiny margin for
	// the rare race where a conn is reaped between requests.
	const maxReuseConns = 2
	if got := distinct(); got > maxReuseConns {
		t.Errorf("tuned client opened %d conns for %d requests, want <= %d (pool not reusing)",
			got, requests, maxReuseConns)
	}
}

func TestBareClientDoesNotReuse(t *testing.T) {
	srv, distinct := countConns(t)

	// the control: a bare DefaultTransport client whose caller closes but never
	// drains the body. go can't reuse a half-read conn, so each request dials
	// fresh - this is exactly the pre-tuning behavior we're fixing.
	client := &http.Client{
		Timeout:   5 * time.Second,
		Transport: http.DefaultTransport.(*http.Transport).Clone(),
	}

	const requests = 30
	for i := 0; i < requests; i++ {
		req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
		if err != nil {
			t.Fatalf("new request %d: %v", i, err)
		}
		resp, err := client.Do(req)
		if err != nil {
			t.Fatalf("do request %d: %v", i, err)
		}
		// close without draining - the leak that kills reuse.
		resp.Body.Close()
	}

	// most requests should have dialed a fresh conn. don't demand exactly N (the
	// scheduler occasionally reuses one), just that it's clearly not pooling.
	const minDistinct = requests / 2
	if got := distinct(); got < minDistinct {
		t.Errorf("bare client opened only %d conns for %d requests, want >= %d "+
			"(expected near-zero reuse without draining)", got, requests, minDistinct)
	}
}

// BenchmarkConnReuse contrasts the tuned, draining client against a bare client
// that closes without draining. the reported conns/op metric is the distinct
// tcp conns one pass of `requests` opened - tuned≈1, bare≈requests - so the
// README can quote real before/after reuse numbers. the conn map is reset per
// iteration so the metric stays a per-pass count and the bare path doesn't
// accumulate b.N*requests live sockets and exhaust the ephemeral port range.
//
// run the bare sub-bench with a bounded -benchtime (e.g. -benchtime 5x): its
// whole point is that it can't reuse, so a large b.N floods the local port
// space with TIME_WAIT sockets. the tuned sub-bench reuses and runs unbounded.
func BenchmarkConnReuse(b *testing.B) {
	const requests = 50

	run := func(b *testing.B, drain bool, client *http.Client) {
		b.Helper()
		var (
			mu    sync.Mutex
			conns = make(map[net.Conn]struct{})
		)
		srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
			io.WriteString(w, strings.Repeat("x", 256))
		}))
		srv.Config.ConnState = func(c net.Conn, state http.ConnState) {
			if state != http.StateNew {
				return
			}
			mu.Lock()
			conns[c] = struct{}{}
			mu.Unlock()
		}
		srv.Start()
		defer srv.Close()

		var lastPass int
		b.ResetTimer()
		for n := 0; n < b.N; n++ {
			mu.Lock()
			conns = make(map[net.Conn]struct{})
			mu.Unlock()
			for i := 0; i < requests; i++ {
				req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
				resp, err := client.Do(req)
				if err != nil {
					b.Fatalf("do: %v", err)
				}
				if drain {
					DrainClose(resp)
				} else {
					resp.Body.Close()
				}
			}
			// close idle conns between passes so the bare client's per-pass
			// sockets land in TIME_WAIT and free up before the next pass.
			client.CloseIdleConnections()
			mu.Lock()
			lastPass = len(conns)
			mu.Unlock()
		}
		b.StopTimer()

		// distinct conns for a single pass of `requests`.
		b.ReportMetric(float64(lastPass), "conns/op")
	}

	b.Run("tuned-drain", func(b *testing.B) {
		resetBench()
		tr, err := buildTransport("", 8)
		if err != nil {
			b.Fatalf("buildTransport: %v", err)
		}
		run(b, true, &http.Client{Timeout: 5 * time.Second, Transport: tr})
	})

	b.Run("bare-noDrain", func(b *testing.B) {
		run(b, false, &http.Client{
			Timeout:   5 * time.Second,
			Transport: http.DefaultTransport.(*http.Transport).Clone(),
		})
	})
}

// resetBench clears the package transport without a *testing.T for benchmarks.
func resetBench() {
	mu.Lock()
	configured = nil
	mu.Unlock()
}

// retrySequenceServer serves codes[n] on the n-th hit, repeating the last once
// the slice runs out; retryable codes carry Retry-After: 0 to keep tests fast.
func retrySequenceServer(t *testing.T, hits *atomic.Int64, codes ...int) *httptest.Server {
	t.Helper()
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		n := int(hits.Add(1)) - 1
		code := codes[len(codes)-1]
		if n < len(codes) {
			code = codes[n]
		}
		if code == http.StatusTooManyRequests || code == http.StatusServiceUnavailable {
			w.Header().Set("Retry-After", "0")
		}
		w.WriteHeader(code)
	}))
	t.Cleanup(srv.Close)
	return srv
}

// getStatus performs a GET and returns the final status code the caller sees.
func getStatus(t *testing.T, client *http.Client, url string) int {
	t.Helper()
	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, http.NoBody)
	if err != nil {
		t.Fatalf("new request: %v", err)
	}
	resp, err := client.Do(req)
	if err != nil {
		t.Fatalf("do request: %v", err)
	}
	resp.Body.Close()
	return resp.StatusCode
}

func TestRetryRecoversAfter429(t *testing.T) {
	resetConfig(t)

	var hits atomic.Int64
	srv := retrySequenceServer(t, &hits, http.StatusTooManyRequests, http.StatusOK)
	if err := Configure(Options{MaxRetries: 2}); err != nil {
		t.Fatalf("Configure: %v", err)
	}

	if code := getStatus(t, Client(5*time.Second), srv.URL); code != http.StatusOK {
		t.Errorf("status = %d, want 200 after retry", code)
	}
	if got := hits.Load(); got != 2 {
		t.Errorf("server hits = %d, want 2 (initial + one retry)", got)
	}
}

func TestRetryRecoversAfter503(t *testing.T) {
	resetConfig(t)

	var hits atomic.Int64
	srv := retrySequenceServer(t, &hits, http.StatusServiceUnavailable, http.StatusOK)
	if err := Configure(Options{MaxRetries: 2}); err != nil {
		t.Fatalf("Configure: %v", err)
	}

	if code := getStatus(t, Client(5*time.Second), srv.URL); code != http.StatusOK {
		t.Errorf("status = %d, want 200 after retry", code)
	}
	if got := hits.Load(); got != 2 {
		t.Errorf("server hits = %d, want 2", got)
	}
}

func TestRetryDisabled(t *testing.T) {
	resetConfig(t)

	var hits atomic.Int64
	srv := retrySequenceServer(t, &hits, http.StatusTooManyRequests, http.StatusOK)
	if err := Configure(Options{MaxRetries: 0}); err != nil {
		t.Fatalf("Configure: %v", err)
	}

	if code := getStatus(t, Client(5*time.Second), srv.URL); code != http.StatusTooManyRequests {
		t.Errorf("status = %d, want 429 with retries off", code)
	}
	if got := hits.Load(); got != 1 {
		t.Errorf("server hits = %d, want 1 (no retry)", got)
	}
}

func TestRetryExhausted(t *testing.T) {
	resetConfig(t)

	var hits atomic.Int64
	srv := retrySequenceServer(t, &hits, http.StatusTooManyRequests) // always 429
	if err := Configure(Options{MaxRetries: 2}); err != nil {
		t.Fatalf("Configure: %v", err)
	}

	if code := getStatus(t, Client(5*time.Second), srv.URL); code != http.StatusTooManyRequests {
		t.Errorf("status = %d, want 429 after exhausting retries", code)
	}
	if got := hits.Load(); got != 3 {
		t.Errorf("server hits = %d, want 3 (initial + 2 retries)", got)
	}
}

func TestRetryIgnoresNonRetryableStatus(t *testing.T) {
	resetConfig(t)

	var hits atomic.Int64
	srv := retrySequenceServer(t, &hits, http.StatusInternalServerError, http.StatusOK)
	if err := Configure(Options{MaxRetries: 2}); err != nil {
		t.Fatalf("Configure: %v", err)
	}

	if code := getStatus(t, Client(5*time.Second), srv.URL); code != http.StatusInternalServerError {
		t.Errorf("status = %d, want 500 (not retried)", code)
	}
	if got := hits.Load(); got != 1 {
		t.Errorf("server hits = %d, want 1 (500 not retried)", got)
	}
}

func TestRetryReplaysRequestBody(t *testing.T) {
	resetConfig(t)

	var hits atomic.Int64
	var bmu sync.Mutex
	var bodies []string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		body, _ := io.ReadAll(r.Body)
		bmu.Lock()
		bodies = append(bodies, string(body))
		bmu.Unlock()
		if hits.Add(1) == 1 {
			w.Header().Set("Retry-After", "0")
			w.WriteHeader(http.StatusTooManyRequests)
			return
		}
		w.WriteHeader(http.StatusOK)
	}))
	t.Cleanup(srv.Close)

	if err := Configure(Options{MaxRetries: 2}); err != nil {
		t.Fatalf("Configure: %v", err)
	}

	req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, srv.URL, strings.NewReader("payload"))
	if err != nil {
		t.Fatalf("new request: %v", err)
	}
	resp, err := Client(5 * time.Second).Do(req)
	if err != nil {
		t.Fatalf("do request: %v", err)
	}
	resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200 after body replay", resp.StatusCode)
	}

	bmu.Lock()
	defer bmu.Unlock()
	if len(bodies) != 2 {
		t.Fatalf("server saw %d requests, want 2", len(bodies))
	}
	for i, body := range bodies {
		if body != "payload" {
			t.Errorf("body[%d] = %q, want %q (rewind dropped the body)", i, body, "payload")
		}
	}
}

func TestRetryAfterHeader(t *testing.T) {
	noHeader := &http.Response{Header: http.Header{}}
	if got := retryAfter(noHeader, 0); got != retryBackoffBase {
		t.Errorf("missing header: attempt 0 = %v, want %v", got, retryBackoffBase)
	}
	if got := retryAfter(noHeader, 1); got != 2*retryBackoffBase {
		t.Errorf("missing header: attempt 1 = %v, want %v", got, 2*retryBackoffBase)
	}
	if got := retryAfter(noHeader, 1000); got != retryAfterCap {
		t.Errorf("missing header: attempt 1000 = %v, want cap %v", got, retryAfterCap)
	}

	withSeconds := func(v string) *http.Response {
		return &http.Response{Header: http.Header{"Retry-After": {v}}}
	}
	if got := retryAfter(withSeconds("3"), 0); got != 3*time.Second {
		t.Errorf("Retry-After 3 = %v, want 3s", got)
	}
	if got := retryAfter(withSeconds("0"), 5); got != 0 {
		t.Errorf("Retry-After 0 = %v, want 0", got)
	}
	if got := retryAfter(withSeconds("9999"), 0); got != retryAfterCap {
		t.Errorf("Retry-After 9999 = %v, want cap %v", got, retryAfterCap)
	}
	if got := retryAfter(withSeconds("soon"), 0); got != retryBackoffBase {
		t.Errorf("Retry-After junk = %v, want backoff %v", got, retryBackoffBase)
	}

	future := time.Now().Add(5 * time.Second).UTC().Format(http.TimeFormat)
	if got := retryAfter(withSeconds(future), 0); got <= 0 || got > 5*time.Second {
		t.Errorf("Retry-After http-date = %v, want (0, 5s]", got)
	}
}

func TestCapDuration(t *testing.T) {
	cases := []struct{ in, want time.Duration }{
		{-time.Second, 0},
		{0, 0},
		{5 * time.Second, 5 * time.Second},
		{retryAfterCap, retryAfterCap},
		{retryAfterCap + time.Second, retryAfterCap},
	}
	for _, c := range cases {
		if got := capDuration(c.in); got != c.want {
			t.Errorf("capDuration(%v) = %v, want %v", c.in, got, c.want)
		}
	}
}

func TestSleepCtxCancelled(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	if err := sleepCtx(ctx, time.Hour); err == nil {
		t.Error("sleepCtx on a cancelled context should return its error, not block")
	}
}

// hostMappingTransport clones the default transport but resolves the given
// hostnames to the supplied local listener addresses, so a test can force a
// redirect across two genuinely distinct hostnames (not just distinct ports
// on 127.0.0.1, which net/http's redirect header stripping treats as the
// same host and never strips).
func hostMappingTransport(t *testing.T, hostToAddr map[string]string) *http.Transport {
	t.Helper()
	dialer := &net.Dialer{}
	base := http.DefaultTransport.(*http.Transport).Clone()
	base.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
		host, _, err := net.SplitHostPort(addr)
		if err != nil {
			return nil, err
		}
		if mapped, ok := hostToAddr[host]; ok {
			addr = mapped
		}
		return dialer.DialContext(ctx, network, addr)
	}
	return base
}

// exercises the crossHostRedirect fix in roundTripper.RoundTrip (httpx.go).
func TestRedirectStripsSensitiveHeadersCrossHost(t *testing.T) {
	var attackerSeen, targetSeen http.Header

	attacker := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
		attackerSeen = r.Header.Clone()
	}))
	defer attacker.Close()

	target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		targetSeen = r.Header.Clone()
		if r.URL.Path == "/redirect" {
			w.Header().Set("Location", "http://attacker.evil/loot")
			w.WriteHeader(http.StatusFound)
		}
	}))
	defer target.Close()

	base := hostMappingTransport(t, map[string]string{
		"target.test":   target.Listener.Addr().String(),
		"attacker.evil": attacker.Listener.Addr().String(),
	})

	rt := &roundTripper{
		base:      base,
		cookie:    "session=secret",
		headers:   map[string]string{"Authorization": "Bearer topsecret"},
		userAgent: "sif-scanner",
	}
	client := &http.Client{Transport: rt}

	resp, err := client.Get("http://target.test/redirect")
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	DrainClose(resp)

	if targetSeen == nil {
		t.Fatal("target never received the initial request")
	}
	if targetSeen.Get("Cookie") != "session=secret" {
		t.Errorf("target Cookie = %q, want it injected on the first hop", targetSeen.Get("Cookie"))
	}
	if targetSeen.Get("Authorization") != "Bearer topsecret" {
		t.Errorf("target Authorization = %q, want it injected on the first hop", targetSeen.Get("Authorization"))
	}

	if attackerSeen == nil {
		t.Fatal("redirect never reached the attacker host")
	}
	if got := attackerSeen.Get("Cookie"); got != "" {
		t.Errorf("attacker Cookie = %q, want empty (cross-host redirect must not leak it)", got)
	}
	if got := attackerSeen.Get("Authorization"); got != "" {
		t.Errorf("attacker Authorization = %q, want empty (cross-host redirect must not leak it)", got)
	}
	if got := attackerSeen.Get("User-Agent"); got != "sif-scanner" {
		t.Errorf("attacker User-Agent = %q, want sif-scanner (non-sensitive headers still inject)", got)
	}
}

// TestRedirectPreservesSensitiveHeadersSameHost is the control: a same-host
// redirect must keep behaving exactly as before the fix.
func TestRedirectPreservesSensitiveHeadersSameHost(t *testing.T) {
	var finalSeen http.Header

	target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/redirect":
			w.Header().Set("Location", "http://target.test/dest")
			w.WriteHeader(http.StatusFound)
		case "/dest":
			finalSeen = r.Header.Clone()
		}
	}))
	defer target.Close()

	base := hostMappingTransport(t, map[string]string{
		"target.test": target.Listener.Addr().String(),
	})

	rt := &roundTripper{
		base:      base,
		cookie:    "session=secret",
		headers:   map[string]string{"Authorization": "Bearer topsecret"},
		userAgent: "sif-scanner",
	}
	client := &http.Client{Transport: rt}

	resp, err := client.Get("http://target.test/redirect")
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	DrainClose(resp)

	if finalSeen == nil {
		t.Fatal("redirect never reached /dest")
	}
	if finalSeen.Get("Cookie") != "session=secret" {
		t.Errorf("same-host Cookie = %q, want session=secret (same-host redirects keep it)", finalSeen.Get("Cookie"))
	}
	if finalSeen.Get("Authorization") != "Bearer topsecret" {
		t.Errorf("same-host Authorization = %q, want Bearer topsecret (same-host redirects keep it)", finalSeen.Get("Authorization"))
	}
}
