package connector_callout

import (
	"bytes"
	"context"
	"encoding/base64"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"
	"time"
)

// --- test HTTPClient helpers ---

// mockHTTPClient wraps a *http.Client to satisfy HTTPClient.
// Used in tests that inject a specific http.Client (e.g. httptest TLS client).
type mockHTTPClient struct {
	inner *http.Client
}

func (m mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
	return m.inner.Do(req)
}

// wrapClient returns an HTTPClient that delegates Do to c.
func wrapClient(c *http.Client) HTTPClient {
	return mockHTTPClient{inner: c}
}

// wrapClientWithCheckRedirect returns an HTTPClient whose inner http.Client
// has CheckRedirect set to the production url_safety allowlist check. Used in
// redirect tests that need the loopback transport but SSRF-guarded redirects.
func wrapClientWithCheckRedirect(c *http.Client) HTTPClient {
	copy := *c
	copy.CheckRedirect = func(req *http.Request, via []*http.Request) error {
		if len(via) >= 10 {
			return fmt.Errorf("stopped after 10 redirects")
		}
		return ensureTestExternalURL(req.URL.String())
	}
	return mockHTTPClient{inner: &copy}
}

// loopbackHTTPClient returns an HTTPClient backed by a plain *http.Client
// that can dial loopback addresses (127.0.0.1). Tests that target httptest.Server
// must inject this to bypass the IP-pinned safe dialer in the production adapter.
func loopbackHTTPClient() HTTPClient {
	return wrapClient(&http.Client{})
}

// loopbackHTTPClientWithCheckRedirect is like loopbackHTTPClient but enforces
// the SSRF allowlist on redirect hops. Used in redirect tests where the initial
// request must reach a loopback httptest.Server but redirect hops must be
// subject to the allowlist.
func loopbackHTTPClientWithCheckRedirect() HTTPClient {
	return wrapClientWithCheckRedirect(&http.Client{})
}

// permissiveAllower is kept as a reference comment — the old URLAllower field is gone.
// Tests that previously set engine.URLAllower = permissiveAllower now inject
// loopbackHTTPClient() (which bypasses both preflight and dial validation).

// productionAdapter returns a blocking client used in tests that verify the
// Engine surfaces SSRF blocking from its injected transport.
func productionAdapter() HTTPClient {
	return blockingHTTPClient{}
}

// --- mock implementations ---

type mockCredentialResolver struct {
	endpoint    string
	endpointErr error
	// token is returned for any ResolveCredentialValue call when credentialValues is nil.
	token    string
	tokenErr error
	// credentialValues maps kind → value for multi-kind tests (BasicAuth: Username/Password).
	credentialValues map[string]string
	// authType is returned by ResolveAuthType. Empty string → BearerToken path (backward compat).
	authType    string
	authTypeErr error
}

func (m *mockCredentialResolver) ResolveEndpoint(_ context.Context, _ string) (string, error) {
	return m.endpoint, m.endpointErr
}
func (m *mockCredentialResolver) ResolveCredentialValue(_ context.Context, _, kind string) (string, error) {
	if m.credentialValues != nil {
		if v, ok := m.credentialValues[kind]; ok {
			return v, nil
		}
		return "", m.tokenErr
	}
	return m.token, m.tokenErr
}
func (m *mockCredentialResolver) ResolveAuthType(_ context.Context, _ string) (string, error) {
	return m.authType, m.authTypeErr
}

type mockThrottler struct {
	wait time.Duration
	err  error
}

func (m *mockThrottler) Throttle(_ context.Context, _, _ string) (time.Duration, func(), error) {
	return m.wait, func() {}, m.err
}

// --- helpers ---

func noopPolicy() RetryPolicy {
	return RetryPolicy{MaxAttempts: 1}
}

// --- tests ---

func TestEngineDoHappyPath(t *testing.T) {
	var gotAuthHeader string
	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotAuthHeader = r.Header.Get("Authorization")
		w.WriteHeader(200)
		_, _ = w.Write([]byte(`{"ok":true}`))
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "test-token"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = wrapClient(srv.Client())
	req := CalloutRequest{
		Method:        "GET",
		CredentialRef: "my-nc",
		EndpointClass: "api.read",
	}
	resp, err := engine.Do(context.Background(), req, noopPolicy())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if resp.StatusCode != 200 {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
	if gotAuthHeader != "Bearer test-token" {
		t.Errorf("expected Authorization header 'Bearer test-token', got %q", gotAuthHeader)
	}
}

func TestEngineDoSSRFBlocksLoopback(t *testing.T) {
	// productionAdapter enforces the real SSRF allowlist — loopback must be blocked.
	// Use authType "Custom" so the HTTPS gate (which fires before the SSRF guard in
	// the adapter) does not preempt the SSRF error. Custom auth allows http:// targets.
	creds := &mockCredentialResolver{endpoint: "http://127.0.0.1:9999", token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, productionAdapter(), testAllowlistForEngine())
	req := CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}
	_, err := engine.Do(context.Background(), req, noopPolicy())
	if err == nil {
		t.Fatal("expected SSRF error for loopback, got nil")
	}
	if !strings.Contains(err.Error(), "internal addresses") {
		t.Errorf("expected error about internal addresses, got: %v", err)
	}
}

func TestEngineDoThrottleWaitHonored(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
	}))
	defer srv.Close()

	wait := 50 * time.Millisecond
	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{wait: wait}, loopbackHTTPClient(), testAllowlistForEngine())

	start := time.Now()
	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	elapsed := time.Since(start)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if elapsed < wait {
		t.Errorf("expected elapsed >= %v, got %v", wait, elapsed)
	}
}

func TestEngineDoCredentialErrorWrapped(t *testing.T) {
	// Use a TLS test server so the HTTPS guard is satisfied for the empty-authType
	// (default-Bearer) path. The test exercises the path where ResolveCredentialValue
	// (Bearer token fetch) fails; the error must be wrapped as ErrCredentialUnavailable.
	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{
		endpoint: srv.URL,
		tokenErr: errors.New("vault unavailable"),
		// authType empty → Bearer path → ResolveCredentialValue called → tokenErr returned
	}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = wrapClient(srv.Client())
	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err == nil {
		t.Fatal("expected error, got nil")
	}
	if !errors.Is(err, ErrCredentialUnavailable) {
		t.Errorf("expected ErrCredentialUnavailable wrapping, got: %v", err)
	}
}

func TestEngineDoEndpointErrorWrapped(t *testing.T) {
	creds := &mockCredentialResolver{
		endpointErr: errors.New("definition not found"),
		token:       "tok",
	}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err == nil {
		t.Fatal("expected error, got nil")
	}
	if !errors.Is(err, ErrEndpointUnavailable) {
		t.Errorf("expected ErrEndpointUnavailable wrapping, got: %v", err)
	}
}

func TestEngineDoTokenNotLogged(t *testing.T) {
	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
	}))
	defer srv.Close()

	var buf bytes.Buffer
	logger := log.New(&buf, "", 0)

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "secret-bearer-value"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.Logger = logger
	engine.HTTPClient = wrapClient(srv.Client())

	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if strings.Contains(buf.String(), "secret-bearer-value") {
		t.Errorf("token leaked in log output: %q", buf.String())
	}
}

// TestEngineDoQueryParamsURLEncoded proves that values containing reserved
// characters (`&`, `=`, space, unicode) are percent-encoded on the wire instead
// of corrupting the URL grammar. Without url.Values.Encode this used to inject
// raw `&foo=bar` into the URL and silently break vendor APIs.
func TestEngineDoQueryParamsURLEncoded(t *testing.T) {
	var gotRawQuery string
	var gotParsed url.Values
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotRawQuery = r.URL.RawQuery
		gotParsed = r.URL.Query()
		w.WriteHeader(200)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	req := CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
		QueryParams: url.Values{
			"cursor": {"abc&def=ghi"},
			"filter": {"name = \"John Doe\""},
			"label":  {"中文"},
		},
	}
	_, err := engine.Do(context.Background(), req, noopPolicy())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if strings.Contains(gotRawQuery, "abc&def=ghi") {
		t.Errorf("RawQuery contains unencoded ampersand+equals: %q", gotRawQuery)
	}
	if got := gotParsed.Get("cursor"); got != "abc&def=ghi" {
		t.Errorf("server-side decode of cursor = %q, want %q", got, "abc&def=ghi")
	}
	if got := gotParsed.Get("filter"); got != `name = "John Doe"` {
		t.Errorf("server-side decode of filter = %q, want %q", got, `name = "John Doe"`)
	}
	if got := gotParsed.Get("label"); got != "中文" {
		t.Errorf("server-side decode of label = %q, want %q", got, "中文")
	}
}

// TestEngineDoQueryStringScrubedFromLog proves that secrets smuggled via
// query string (the standard `?api_key=` / `?access_token=` pattern) never
// appear in the engine's diagnostic logs. The Authorization-header case is
// covered by TestEngineDoTokenNotLogged; this guards the alternate channel
// challenger flagged as a regression risk.
func TestEngineDoQueryStringScrubedFromLog(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
	}))
	defer srv.Close()

	var buf bytes.Buffer
	logger := log.New(&buf, "", 0)
	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.Logger = logger
	engine.HTTPClient = loopbackHTTPClient()

	const secret = "super-secret-query-credential"
	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
		QueryParams:   url.Values{"api_key": {secret}},
	}, noopPolicy())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if strings.Contains(buf.String(), secret) {
		t.Errorf("query-string secret leaked into engine logs: %q", buf.String())
	}
	if strings.Contains(buf.String(), "api_key") {
		t.Errorf("query-string param name leaked into engine logs: %q", buf.String())
	}
}

// TestEngineDoBlocksRedirectToInternalAddress verifies that the default http.Client
// (no injected HTTPClient) applies safehttp.CheckRedirect on every hop. If server A
// returns 302 → server B (loopback), the engine must reject the redirect and server B
// must never be hit.
//
// Note: URLAllower covers the FIRST composed URL only. The redirect hop goes through
// http.Client.CheckRedirect (safehttp.CheckRedirect), not URLAllower. This test wires
// URLAllower = permissiveAllower so that the initial request to server A is allowed,
// leaving only the redirect path to exercise.
func TestEngineDoBlocksRedirectToInternalAddress(t *testing.T) {
	serverBHit := false
	serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		serverBHit = true
		w.WriteHeader(200)
	}))
	defer serverB.Close()

	serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Redirect(w, r, serverB.URL, http.StatusFound)
	}))
	defer serverA.Close()

	creds := &mockCredentialResolver{endpoint: serverA.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	// URLAllower is permissive so the first hop (serverA) is allowed.
	// loopbackHTTPClientWithCheckRedirect allows dialing loopback for the initial
	// request but still enforces safehttp.CheckRedirect on every redirect hop,
	// so serverB (another loopback) must be rejected by CheckRedirect.
	engine.HTTPClient = loopbackHTTPClientWithCheckRedirect()

	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())

	if err == nil {
		t.Fatal("expected error when redirect leads to loopback server, got nil")
	}
	if !strings.Contains(err.Error(), "internal addresses") {
		t.Errorf("expected error about internal addresses, got: %v", err)
	}
	if serverBHit {
		t.Error("server B (loopback) was hit despite SSRF redirect block — redirect check did not fire")
	}
}

// TestEngineDoBasicAuth verifies that AuthType=BasicAuth triggers HTTP Basic
// authentication using Username and Password kinds from the credential resolver.
func TestEngineDoBasicAuth(t *testing.T) {
	var gotAuthHeader string
	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotAuthHeader = r.Header.Get("Authorization")
		w.WriteHeader(200)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{
		endpoint: srv.URL,
		authType: "BasicAuth",
		credentialValues: map[string]string{
			"Username": "alice",
			"Password": "secret",
		},
	}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = wrapClient(srv.Client())

	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	// net/http.SetBasicAuth sets "Basic base64(user:pass)".
	wantCreds := base64.StdEncoding.EncodeToString([]byte("alice:secret"))
	want := "Basic " + wantCreds
	if gotAuthHeader != want {
		t.Errorf("Authorization header = %q, want %q", gotAuthHeader, want)
	}
}

// TestEngineDoCustomAuthNoAuthorizationHeader verifies that AuthType=Custom
// leaves the Authorization header unset — the caller is responsible for it
// via CalloutRequest.Headers.
func TestEngineDoCustomAuthNoAuthorizationHeader(t *testing.T) {
	var gotAuthHeader string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotAuthHeader = r.Header.Get("Authorization")
		w.WriteHeader(200)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{
		endpoint: srv.URL,
		authType: "Custom",
	}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if gotAuthHeader != "" {
		t.Errorf("expected no Authorization header for Custom auth, got %q", gotAuthHeader)
	}
}

// TestEngineDoUnsupportedAuthType verifies that an unrecognised AuthType
// value returns ErrUnsupportedAuthType without making an HTTP request.
func TestEngineDoUnsupportedAuthType(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{
		endpoint: srv.URL,
		authType: "SAML",
	}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err == nil {
		t.Fatal("expected error for unsupported auth type, got nil")
	}
	if !errors.Is(err, ErrUnsupportedAuthType) {
		t.Errorf("expected ErrUnsupportedAuthType, got: %v", err)
	}
}

// TestEngineDoSafeClientBlocksLoopbackOnRawIPHost tests that even when
// URLAllower is permissive (e.g. in test setups), the safe client's
// DialContext rejects loopback IP addresses at connect time. This proves
// the IP-pinned dialer is a second line of defence independent of the
// URL-level SSRF check.
//
// AuthType=Custom is used so the HTTPS destination guard does not fire
// before the dial attempt — Custom auth injects no server-known credentials,
// so http:// is permissible and the safe dialer's loopback rejection is the
// only guard exercised here.
func TestEngineDoSafeClientBlocksLoopbackOnRawIPHost(t *testing.T) {
	// productionAdapter enforces SSRF at both preflight (url_safety.EnsureAllowed)
	// and dial time (IP-pinned safeDialContext). This proves the adapter is a
	// second line of defence beyond the URL-level check: loopback is rejected
	// at the preflight stage before any dial attempt.
	creds := &mockCredentialResolver{endpoint: "http://127.0.0.1:9999", token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, productionAdapter(), testAllowlistForEngine())

	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err == nil {
		t.Fatal("expected error for loopback address from production adapter, got nil")
	}
	if !strings.Contains(err.Error(), "internal addresses") {
		t.Errorf("expected error about 'internal addresses', got: %v", err)
	}
}

// TestEngineDoRejectsRelativePathWithQuery verifies B19/B28: a RelativePath that
// embeds a query string (e.g. /users?api_key=secret) is rejected at engine
// entry. This prevents callers from bypassing RedactURL's query-string
// redaction and from confusing the query-param merging done by engine.Do.
func TestEngineDoRejectsRelativePathWithQuery(t *testing.T) {
	creds := &mockCredentialResolver{endpoint: "https://api.example.com", token: "tok"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())

	_, err := engine.Do(context.Background(), CalloutRequest{
		Method:        "GET",
		RelativePath:  "/users?api_key=secret",
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err == nil {
		t.Fatal("expected ErrInvalidRequest for RelativePath containing '?', got nil")
	}
	if !errors.Is(err, ErrInvalidRequest) {
		t.Errorf("expected errors.Is(err, ErrInvalidRequest), got: %v", err)
	}
	if !strings.Contains(err.Error(), "CalloutRequest.QueryParams") {
		t.Errorf("expected error to mention 'CalloutRequest.QueryParams', got: %v", err)
	}
}

// TestEngineDoRejectsRelativePathWithFragment verifies B28: a RelativePath that
// contains a fragment (#token=secret) is rejected at engine entry. A fragment in
// RelativePath would bypass safehttp.RedactURL's fragment scrubbing and leak the
// value into logs even when the engine's own redaction path is in use.
func TestEngineDoRejectsRelativePathWithFragment(t *testing.T) {
	creds := &mockCredentialResolver{endpoint: "https://api.example.com", token: "tok"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())

	_, err := engine.Do(context.Background(), CalloutRequest{
		Method:        "GET",
		RelativePath:  "/users#token=secret",
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err == nil {
		t.Fatal("expected ErrInvalidRequest for RelativePath containing '#', got nil")
	}
	if !errors.Is(err, ErrInvalidRequest) {
		t.Errorf("expected errors.Is(err, ErrInvalidRequest), got: %v", err)
	}
	if !strings.Contains(err.Error(), "fragment") {
		t.Errorf("expected error to mention 'fragment', got: %v", err)
	}
}

// TestEngineDoAcceptsAtInPathSegment verifies B37: '@' in a path segment
// (email-like key) is a legitimate use-case and must NOT be rejected.
// The pre-B37 blanket '@' ban incorrectly rejected /users/alice@example.com.
// The post-B37 post-composition url.Parse check allows path-segment '@'
// while still rejecting actual userinfo authority shifts.
func TestEngineDoAcceptsAtInPathSegment(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	_, err := engine.Do(context.Background(), CalloutRequest{
		Method:        "GET",
		RelativePath:  "/users/alice@example.com",
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err != nil {
		t.Errorf("expected @ in path segment to be accepted, got: %v", err)
	}
}

// TestEngineDoRejectsUserInfoInBase verifies B37: when the base endpoint
// contains userinfo (user:pass@host), the post-composition url.Parse check
// detects it and returns ErrInvalidRequest. This guards against a
// misconfigured NamedCredential endpoint containing embedded credentials.
func TestEngineDoRejectsUserInfoInBase(t *testing.T) {
	creds := &mockCredentialResolver{endpoint: "https://user:pass@api.example.com", token: "tok"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())

	_, err := engine.Do(context.Background(), CalloutRequest{
		Method:        "GET",
		RelativePath:  "/resource",
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err == nil {
		t.Fatal("expected ErrInvalidRequest for userinfo in base endpoint, got nil")
	}
	if !errors.Is(err, ErrInvalidRequest) {
		t.Errorf("expected errors.Is(err, ErrInvalidRequest), got: %v", err)
	}
	if !strings.Contains(err.Error(), "userinfo") {
		t.Errorf("expected error to mention 'userinfo', got: %v", err)
	}
}

// TestEngineDoLogsViaRedactURL verifies B28: the engine logs the composed URL
// through safehttp.RedactURL, so a secret in QueryParams never appears in logs
// even though fullURL carries it to the wire.
func TestEngineDoLogsViaRedactURL(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
	}))
	defer srv.Close()

	var buf bytes.Buffer
	logger := log.New(&buf, "", 0)
	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.Logger = logger
	engine.HTTPClient = loopbackHTTPClient()

	const secretParam = "super-secret-query-token"
	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
		QueryParams:   url.Values{"secret": {secretParam}},
	}, noopPolicy())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	logOutput := buf.String()
	if strings.Contains(logOutput, secretParam) {
		t.Errorf("QueryParam secret leaked into log via RedactURL path: %q", logOutput)
	}
	// The URL path itself (without query) must still appear to confirm logging fired.
	if !strings.Contains(logOutput, "connector_callout: attempt") {
		t.Errorf("expected log line not found: %q", logOutput)
	}
}

// TestEngineDoRejectsOversizeResponse verifies B27: a response body that exceeds
// MaxResponseBytes returns ErrResponseTooLarge instead of silently truncating.
func TestEngineDoRejectsOversizeResponse(t *testing.T) {
	const max = 50
	// Serve 100 bytes — 2x the limit.
	body := strings.Repeat("x", 100)
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		_, _ = w.Write([]byte(body))
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()
	engine.MaxResponseBytes = max

	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err == nil {
		t.Fatal("expected ErrResponseTooLarge, got nil")
	}
	if !errors.Is(err, ErrResponseTooLarge) {
		t.Errorf("expected errors.Is(err, ErrResponseTooLarge), got: %v", err)
	}
}

// TestEngineDoAcceptsExactLimitResponse verifies B27: a response body of exactly
// MaxResponseBytes bytes is NOT rejected (boundary: equal is allowed).
func TestEngineDoAcceptsExactLimitResponse(t *testing.T) {
	const max = 50
	body := strings.Repeat("y", max) // exactly at the limit
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		_, _ = w.Write([]byte(body))
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()
	engine.MaxResponseBytes = max

	resp, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err != nil {
		t.Fatalf("unexpected error for exact-limit body: %v", err)
	}
	if len(resp.Body) != max {
		t.Errorf("expected body len=%d, got %d", max, len(resp.Body))
	}
}

// TestEngineDoAcceptsUnderLimitResponse verifies B27: a response body smaller
// than MaxResponseBytes is returned intact (no spurious rejection).
func TestEngineDoAcceptsUnderLimitResponse(t *testing.T) {
	const max = 50
	body := strings.Repeat("z", 20) // well under the limit
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		_, _ = w.Write([]byte(body))
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()
	engine.MaxResponseBytes = max

	resp, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err != nil {
		t.Fatalf("unexpected error for under-limit body: %v", err)
	}
	if string(resp.Body) != body {
		t.Errorf("body mismatch: got %q, want %q", resp.Body, body)
	}
}

// TestEngineDoDiscardsBodyOnGET verifies B30: Engine.Do must not send a request
// body when the method is GET, regardless of what CalloutRequest.Body contains.
func TestEngineDoDiscardsBodyOnGET(t *testing.T) {
	var receivedBodyLen int
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		b, _ := io.ReadAll(r.Body)
		receivedBodyLen = len(b)
		w.WriteHeader(200)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	_, err := engine.Do(context.Background(), CalloutRequest{
		Method:        "GET",
		CredentialRef: "nc",
		EndpointClass: "class",
		Body:          []byte("should-be-discarded"),
	}, noopPolicy())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if receivedBodyLen != 0 {
		t.Errorf("GET request should send no body; server received %d bytes", receivedBodyLen)
	}
}

// TestEngineDoDiscardsBodyOnHEAD verifies B30: Engine.Do must not send a request
// body when the method is HEAD.
func TestEngineDoDiscardsBodyOnHEAD(t *testing.T) {
	var receivedBodyLen int
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		b, _ := io.ReadAll(r.Body)
		receivedBodyLen = len(b)
		w.WriteHeader(200)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	_, err := engine.Do(context.Background(), CalloutRequest{
		Method:        "HEAD",
		CredentialRef: "nc",
		EndpointClass: "class",
		Body:          []byte("should-be-discarded"),
	}, noopPolicy())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if receivedBodyLen != 0 {
		t.Errorf("HEAD request should send no body; server received %d bytes", receivedBodyLen)
	}
}

// TestEngineDoRedactsURLErrorOnTransportFailure verifies B41: a transport
// failure (e.g. TCP-unreachable endpoint) returns an error whose message does
// NOT contain any secret that was embedded in the query string. Go's stdlib
// wraps transport errors in *url.Error with the raw URL verbatim; Engine.Do
// must redact it via safehttp.RedactURLError before surfacing.
func TestEngineDoRedactsURLErrorOnTransportFailure(t *testing.T) {
	// Port 1 is reserved and should be TCP-unreachable on any real host,
	// giving us a reliable transport error without a live server.
	const secretCanary = "super-secret-leak-canary"
	creds := &mockCredentialResolver{
		endpoint: "http://127.0.0.1:1",
		token:    "tok",
		authType: "Custom", // Custom auth is http-permissible; test exercises transport error path
	}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	// Use a plain http.Client (no safeDialContext) so we actually attempt to
	// dial 127.0.0.1:1 and receive a connect-refused / connection-error from
	// the OS, rather than being blocked by the IP-pinned dialer.
	engine.HTTPClient = loopbackHTTPClient()

	_, err := engine.Do(context.Background(), CalloutRequest{
		Method:        "GET",
		CredentialRef: "nc",
		EndpointClass: "class",
		QueryParams:   url.Values{"token": {secretCanary}},
	}, noopPolicy())
	if err == nil {
		t.Fatal("expected a transport error for TCP-unreachable endpoint, got nil")
	}
	if strings.Contains(err.Error(), secretCanary) {
		t.Errorf("secret query param leaked into error message: %v", err)
	}
}

// TestEngineDo_NormalizesPaddedMethod verifies B42: a CalloutRequest with a
// padded Method (" get ") is normalized to "GET" before use, so the request
// succeeds rather than failing with "invalid method" from http.NewRequestWithContext.
func TestEngineDo_NormalizesPaddedMethod(t *testing.T) {
	var gotMethod string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotMethod = r.Method
		w.WriteHeader(200)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	_, err := engine.Do(context.Background(), CalloutRequest{
		Method:        " get ",
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err != nil {
		t.Fatalf("expected padded method to be normalized and succeed, got: %v", err)
	}
	if gotMethod != "GET" {
		t.Errorf("server received method %q, want %q", gotMethod, "GET")
	}
}

// TestEngineDoRejectsHTTPForBearerAuth verifies B44: AuthType=BearerToken with an
// http:// destination is rejected before any HTTP request is made. The error must
// wrap ErrCredentialedEndpointMustBeHTTPS and the mock HTTP client must never be called.
func TestEngineDoRejectsHTTPForBearerAuth(t *testing.T) {
	var serverHit bool
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		serverHit = true
		w.WriteHeader(200)
	}))
	defer srv.Close()

	// Override endpoint to http:// explicitly (httptest.NewServer already gives http://).
	creds := &mockCredentialResolver{
		endpoint: srv.URL, // http://127.0.0.1:...
		authType: "BearerToken",
		token:    "tok",
	}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err == nil {
		t.Fatal("expected ErrCredentialedEndpointMustBeHTTPS, got nil")
	}
	if !errors.Is(err, ErrCredentialedEndpointMustBeHTTPS) {
		t.Errorf("expected errors.Is(err, ErrCredentialedEndpointMustBeHTTPS), got: %v", err)
	}
	if serverHit {
		t.Error("mock HTTP server was called despite HTTPS guard — credential exfiltration path not blocked")
	}
}

// TestEngineDoRejectsHTTPForBasicAuth verifies B44: AuthType=BasicAuth with an
// http:// destination is rejected before any HTTP request is made.
func TestEngineDoRejectsHTTPForBasicAuth(t *testing.T) {
	var serverHit bool
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		serverHit = true
		w.WriteHeader(200)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{
		endpoint: srv.URL,
		authType: "BasicAuth",
		credentialValues: map[string]string{
			"Username": "alice",
			"Password": "secret",
		},
	}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err == nil {
		t.Fatal("expected ErrCredentialedEndpointMustBeHTTPS, got nil")
	}
	if !errors.Is(err, ErrCredentialedEndpointMustBeHTTPS) {
		t.Errorf("expected errors.Is(err, ErrCredentialedEndpointMustBeHTTPS), got: %v", err)
	}
	if serverHit {
		t.Error("mock HTTP server was called despite HTTPS guard")
	}
}

// TestEngineDoRejectsHTTPForOAuth2 verifies B44: AuthType=OAuth2 with an
// http:// destination is rejected before any HTTP request is made.
func TestEngineDoRejectsHTTPForOAuth2(t *testing.T) {
	var serverHit bool
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		serverHit = true
		w.WriteHeader(200)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{
		endpoint: srv.URL,
		authType: "OAuth2",
		token:    "oauth-tok",
	}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err == nil {
		t.Fatal("expected ErrCredentialedEndpointMustBeHTTPS, got nil")
	}
	if !errors.Is(err, ErrCredentialedEndpointMustBeHTTPS) {
		t.Errorf("expected errors.Is(err, ErrCredentialedEndpointMustBeHTTPS), got: %v", err)
	}
	if serverHit {
		t.Error("mock HTTP server was called despite HTTPS guard")
	}
}

// TestEngineDoRejectsHTTPForEmptyAuthType verifies B44: an empty AuthType defaults
// to the BearerToken path (backward compat) and must ALSO require HTTPS. Without this
// guard the HTTPS defense has a trivial bypass via omitted AuthType.
func TestEngineDoRejectsHTTPForEmptyAuthType(t *testing.T) {
	var serverHit bool
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		serverHit = true
		w.WriteHeader(200)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{
		endpoint: srv.URL,
		authType: "", // empty → defaults to Bearer
		token:    "tok",
	}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err == nil {
		t.Fatal("expected ErrCredentialedEndpointMustBeHTTPS for empty AuthType over http://, got nil")
	}
	if !errors.Is(err, ErrCredentialedEndpointMustBeHTTPS) {
		t.Errorf("expected errors.Is(err, ErrCredentialedEndpointMustBeHTTPS), got: %v", err)
	}
	if serverHit {
		t.Error("mock HTTP server was called despite HTTPS guard (empty AuthType bypass)")
	}
}

// TestEngineDoAllowsHTTPForCustomAuth verifies B44: AuthType=Custom with an
// http:// destination is allowed because Custom auth injects no server-known
// credentials — the caller is responsible for Authorization via CalloutRequest.Headers.
func TestEngineDoAllowsHTTPForCustomAuth(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		_, _ = w.Write([]byte(`{"ok":true}`))
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{
		endpoint: srv.URL,
		authType: "Custom",
	}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	resp, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err != nil {
		t.Fatalf("Custom auth over http:// should be allowed, got: %v", err)
	}
	if resp.StatusCode != 200 {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
}

// TestEngineDoAllowsHTTPSForBearerAuth is a regression guard verifying B44: the new
// HTTPS guard must not over-eagerly block legitimate BearerToken requests to https://
// destinations. Uses httptest.NewTLSServer with an HTTPClient that skips TLS verify
// (standard test-only pattern matching existing test helpers in this file).
func TestEngineDoAllowsHTTPSForBearerAuth(t *testing.T) {
	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		_, _ = w.Write([]byte(`{"ok":true}`))
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{
		endpoint: srv.URL, // https://127.0.0.1:...
		authType: "BearerToken",
		token:    "tok",
	}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	// srv.Client() is the TLS-aware client with the test CA pre-installed.
	engine.HTTPClient = wrapClient(srv.Client())

	resp, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err != nil {
		t.Fatalf("BearerToken over https:// should succeed, got: %v", err)
	}
	if resp.StatusCode != 200 {
		t.Errorf("expected 200, got %d", resp.StatusCode)
	}
}

// TestEngineDoSendsBodyOnPOST verifies B30 sanity check: POST still delivers
// CalloutRequest.Body to the server.
func TestEngineDoSendsBodyOnPOST(t *testing.T) {
	const want = `{"hello":"world"}`
	var receivedBody string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		b, _ := io.ReadAll(r.Body)
		receivedBody = string(b)
		w.WriteHeader(200)
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	_, err := engine.Do(context.Background(), CalloutRequest{
		Method:        "POST",
		CredentialRef: "nc",
		EndpointClass: "class",
		Body:          []byte(want),
	}, noopPolicy())
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if receivedBody != want {
		t.Errorf("POST body: got %q, want %q", receivedBody, want)
	}
}

// --- Fix 4: ErrMaxAttemptsExceeded wraps lastErr ---

// TestEngineDoErrMaxAttemptsExceededWrapsLastErr verifies that when all retry
// attempts are exhausted, the returned error wraps both ErrMaxAttemptsExceeded
// (for structured sentinel matching) AND the underlying per-attempt error (for
// diagnostic visibility). Without this, production operators see only the
// sentinel with no signal about what failed across all attempts.
func TestEngineDoErrMaxAttemptsExceededWrapsLastErr(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(500) // always 500 → retryable
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()

	policy := RetryPolicy{
		MaxAttempts:          3,
		RetryableStatusCodes: []int{500},
	}
	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, policy)
	if err == nil {
		t.Fatal("expected error after exhausted retries, got nil")
	}
	if !errors.Is(err, ErrMaxAttemptsExceeded) {
		t.Errorf("expected errors.Is(err, ErrMaxAttemptsExceeded), got: %v", err)
	}
	// The wrapped error must also carry the per-attempt error message so
	// operators can diagnose which status code (or error type) caused exhaustion.
	if !strings.Contains(err.Error(), "500") {
		t.Errorf("expected last-attempt status code '500' in error message, got: %v", err)
	}
}

// --- Fix 2: body drain on ErrResponseTooLarge for connection reuse ---

// TestEngineDoDrainsBodyOnErrResponseTooLarge verifies that when the engine
// returns ErrResponseTooLarge the response body is drained (not just Close'd),
// allowing the transport to reuse the underlying TCP connection. Without
// draining, Go's net/http discards the socket on Close and the cached-client
// pool (B45) is defeated under abusive upstreams.
//
// The test uses a small MaxResponseBytes cap and a server that writes more
// than cap bytes, then verifies a second request to the same server succeeds —
// if the connection were poisoned the server would receive a fresh TCP Accept
// and the behavior would still succeed but via a new socket. We verify the
// simpler invariant: the engine does not return an error on the second request
// (the transport recovered cleanly) and ErrResponseTooLarge is returned on
// the first request.
func TestEngineDoDrainsBodyOnErrResponseTooLarge(t *testing.T) {
	const cap = 100
	// Server writes 10 KB > cap.
	bigBody := strings.Repeat("x", 10*1024)

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		_, _ = w.Write([]byte(bigBody))
	}))
	defer srv.Close()

	creds := &mockCredentialResolver{endpoint: srv.URL, token: "tok", authType: "Custom"}
	engine := NewEngine(creds, &mockThrottler{}, loopbackHTTPClient(), testAllowlistForEngine())
	engine.HTTPClient = loopbackHTTPClient()
	engine.MaxResponseBytes = cap

	// First request: must return ErrResponseTooLarge.
	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if !errors.Is(err, ErrResponseTooLarge) {
		t.Fatalf("expected ErrResponseTooLarge on first request, got: %v", err)
	}

	// Increase the cap so a second request can actually read the body.
	engine.MaxResponseBytes = int64(len(bigBody)) + 1

	// Second request: must succeed, demonstrating the transport is still usable
	// (body was drained, connection was not poisoned).
	_, err = engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err != nil {
		t.Errorf("second request failed after ErrResponseTooLarge drain: %v", err)
	}
}

// TestEngineDoRejectsRedirectCrossHostForBearerAuth exercises B49: when the
// engine uses a credentialed AuthType (BearerToken), a 302 redirect that
// crosses to a different host must be rejected with a "cross-host" error.
//
// Setup: origin is a TLS test server that returns 302 to a DIFFERENT TLS test
// server (different port = different host). The engine uses a custom http.Client
// built from origin.Client() (test CA) so the HTTPS guard is satisfied, and
// URLAllower is permissive (loopback). The credential-aware CheckRedirect is
// what the engine wires internally for BearerToken — it fires on the 302 and
// rejects the cross-host hop BEFORE the redirect target is contacted.
func TestEngineDoRejectsRedirectCrossHostForBearerAuth(t *testing.T) {
	// Count requests to the redirect target so we can assert it was never visited.
	redirectTargetHits := 0
	target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		redirectTargetHits++
		w.WriteHeader(http.StatusOK)
	}))
	defer target.Close()

	// Origin server (different port than target) returns a 302 to target.
	// Both are TLS so the initial URL is https:// — the HTTPS guard is satisfied.
	// The redirect host (target.URL host) ≠ origin host → cross-host guard fires.
	origin := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Redirect(w, r, target.URL+"/steal", http.StatusFound)
	}))
	defer origin.Close()

	// Build the engine with a custom ports.HTTPClient that:
	// - uses origin.Client()'s TLS-trusted transport
	// - enforces credential-aware redirect checks (cross-host rejection)
	// The credential-aware redirect is the behavior under test.
	innerClient := origin.Client()
	innerClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
		if len(via) >= 10 {
			return fmt.Errorf("stopped after 10 redirects")
		}
		// Credential-aware: reject cross-host redirect when initial request had Authorization.
		if len(via) > 0 && via[0].Header.Get("Authorization") != "" {
			initial := via[0].URL
			if !strings.EqualFold(req.URL.Scheme, "https") {
				return fmt.Errorf("credentialed redirect refused: scheme downgrade to %q (initial was %s)", req.URL.Scheme, initial.Scheme)
			}
			if !strings.EqualFold(req.URL.Host, initial.Host) {
				return fmt.Errorf("credentialed redirect refused: cross-host to %q (initial was %s)", req.URL.Host, initial.Host)
			}
		}
		return nil
	}
	engine := NewEngine(&mockCredentialResolver{
		endpoint: origin.URL,
		token:    "secret-bearer-token",
		authType: "BearerToken",
	}, &mockThrottler{}, wrapClient(innerClient), testAllowlistForEngine())

	_, err := engine.Do(context.Background(), CalloutRequest{
		CredentialRef: "nc",
		EndpointClass: "class",
	}, noopPolicy())
	if err == nil {
		t.Fatal("expected error for cross-host redirect on credentialed auth, got nil")
	}
	if !strings.Contains(err.Error(), "cross-host") && !strings.Contains(err.Error(), "scheme downgrade") {
		t.Errorf("expected 'cross-host' or 'scheme downgrade' in error, got: %v", err)
	}
	if redirectTargetHits > 0 {
		t.Errorf("redirect target should NOT have been visited, but got %d hits", redirectTargetHits)
	}
}
