mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
c650aabbef
My agent added `//nolint:testpackage` to a test file on one of my PRs. Again. This PR cleans it up across the entire repo and updates the in-repo conventions so future agents stop doing it. The repo already has a precedent for white-box tests that need to touch unexported symbols: `*_internal_test.go` (145+ existing files). The `testpackage` linter's default `skip-regexp` exempts that filename suffix, so the `//nolint:testpackage` directive is unnecessary in every case where someone reached for it. This PR renames 51 such files to `*_internal_test.go` via `git mv` so blame and history follow, and strips the dead directive from 2 files that were already correctly named (`coderd/oauth2provider/authorize_internal_test.go`, `coderd/x/chatd/advisor_internal_test.go`). `.claude/docs/TESTING.md` now documents the rule explicitly under *Test Package Naming*, which is imported into the root `AGENTS.md` via `@.claude/docs/TESTING.md`. The rule: prefer `package foo_test`; if you need internal access, rename the file to `*_internal_test.go` rather than adding a nolint directive.
1729 lines
55 KiB
Go
1729 lines
55 KiB
Go
package chatdebug
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
func newTestSinkContext(t *testing.T) (context.Context, *attemptSink) {
|
|
t.Helper()
|
|
|
|
sink := &attemptSink{}
|
|
return withAttemptSink(context.Background(), sink), sink
|
|
}
|
|
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return f(req)
|
|
}
|
|
|
|
type scriptedReadCloser struct {
|
|
chunks [][]byte
|
|
index int
|
|
offset int // byte offset within current chunk
|
|
}
|
|
|
|
func (r *scriptedReadCloser) Read(p []byte) (int, error) {
|
|
if r.index >= len(r.chunks) {
|
|
return 0, io.EOF
|
|
}
|
|
chunk := r.chunks[r.index]
|
|
remaining := chunk[r.offset:]
|
|
n := copy(p, remaining)
|
|
r.offset += n
|
|
if r.offset >= len(chunk) {
|
|
r.index++
|
|
r.offset = 0
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func (*scriptedReadCloser) Close() error {
|
|
return nil
|
|
}
|
|
|
|
type closeTrackingReadCloser struct {
|
|
*bytes.Reader
|
|
closed bool
|
|
closeErr error
|
|
}
|
|
|
|
func (c *closeTrackingReadCloser) Close() error {
|
|
c.closed = true
|
|
return c.closeErr
|
|
}
|
|
|
|
func TestRecordingTransport_NoSink(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
gotMethod := make(chan string, 1)
|
|
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
gotMethod <- req.Method
|
|
_, _ = rw.Write([]byte("ok"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{Base: server.Client().Transport},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
require.Equal(t, "ok", string(body))
|
|
require.Equal(t, http.MethodGet, <-gotMethod)
|
|
}
|
|
|
|
func TestRecordingTransport_CaptureRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const requestBody = `{"message":"hello","api_key":"super-secret"}`
|
|
|
|
type receivedRequest struct {
|
|
authorization string
|
|
body []byte
|
|
}
|
|
gotRequest := make(chan receivedRequest, 1)
|
|
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
body, err := io.ReadAll(req.Body)
|
|
require.NoError(t, err)
|
|
gotRequest <- receivedRequest{
|
|
authorization: req.Header.Get("Authorization"),
|
|
body: body,
|
|
}
|
|
_, _ = rw.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{Base: server.Client().Transport},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodPost,
|
|
server.URL,
|
|
strings.NewReader(requestBody),
|
|
)
|
|
require.NoError(t, err)
|
|
req.Header.Set("Authorization", "Bearer top-secret")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
_, err = io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
require.Equal(t, 1, attempts[0].Number)
|
|
require.Equal(t, RedactedValue, attempts[0].RequestHeaders["Authorization"])
|
|
require.Equal(t, "application/json", attempts[0].RequestHeaders["Content-Type"])
|
|
require.JSONEq(t, `{"message":"hello","api_key":"[REDACTED]"}`, string(attempts[0].RequestBody))
|
|
|
|
received := <-gotRequest
|
|
require.JSONEq(t, requestBody, string(received.body))
|
|
require.Equal(t, "Bearer top-secret", received.authorization)
|
|
}
|
|
|
|
func TestRecordingTransport_CaptureRequestRestoresSharedGetBody(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const requestBody = `{"message":"hello","api_key":"super-secret"}`
|
|
|
|
gotRequest := make(chan []byte, 1)
|
|
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
body, err := io.ReadAll(req.Body)
|
|
require.NoError(t, err)
|
|
gotRequest <- body
|
|
_, _ = rw.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{Base: server.Client().Transport},
|
|
}
|
|
|
|
reader := bytes.NewReader([]byte(requestBody))
|
|
originalBody := &closeTrackingReadCloser{Reader: reader}
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodPost,
|
|
server.URL,
|
|
originalBody,
|
|
)
|
|
require.NoError(t, err)
|
|
req.ContentLength = int64(len(requestBody))
|
|
req.GetBody = func() (io.ReadCloser, error) {
|
|
_, err := reader.Seek(0, io.SeekStart)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return io.NopCloser(reader), nil
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
_, err = io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
require.JSONEq(t, requestBody, string(<-gotRequest))
|
|
require.True(t, originalBody.closed)
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
require.JSONEq(t, `{"message":"hello","api_key":"[REDACTED]"}`, string(attempts[0].RequestBody))
|
|
}
|
|
|
|
func TestRecordingTransport_CaptureRequestResetFailureFailsRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const requestBody = `{"message":"hello"}`
|
|
|
|
gotRequest := make(chan struct{}, 1)
|
|
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
gotRequest <- struct{}{}
|
|
_, _ = rw.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{Base: server.Client().Transport},
|
|
}
|
|
|
|
reader := bytes.NewReader([]byte(requestBody))
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodPost,
|
|
server.URL,
|
|
io.NopCloser(reader),
|
|
)
|
|
require.NoError(t, err)
|
|
req.ContentLength = int64(len(requestBody))
|
|
getBodyCalls := 0
|
|
req.GetBody = func() (io.ReadCloser, error) {
|
|
getBodyCalls++
|
|
if getBodyCalls == 2 {
|
|
return nil, xerrors.New("reset failed")
|
|
}
|
|
_, err := reader.Seek(0, io.SeekStart)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return io.NopCloser(reader), nil
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if resp != nil {
|
|
require.NoError(t, resp.Body.Close())
|
|
}
|
|
require.ErrorContains(t, err, "chatdebug: reset request body: reset failed")
|
|
require.Nil(t, resp)
|
|
require.Empty(t, sink.snapshot())
|
|
select {
|
|
case <-gotRequest:
|
|
t.Fatal("request should not be sent with a drained body")
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestRecordingTransport_CaptureRequestBodyCloseFailureFailsRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const requestBody = `{"message":"hello"}`
|
|
|
|
gotRequest := make(chan struct{}, 1)
|
|
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
gotRequest <- struct{}{}
|
|
_, _ = rw.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{Base: server.Client().Transport},
|
|
}
|
|
|
|
reader := bytes.NewReader([]byte(requestBody))
|
|
originalBody := &closeTrackingReadCloser{
|
|
Reader: reader,
|
|
closeErr: xerrors.New("close failed"),
|
|
}
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodPost,
|
|
server.URL,
|
|
originalBody,
|
|
)
|
|
require.NoError(t, err)
|
|
req.ContentLength = int64(len(requestBody))
|
|
req.GetBody = func() (io.ReadCloser, error) {
|
|
_, err := reader.Seek(0, io.SeekStart)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return io.NopCloser(reader), nil
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if resp != nil {
|
|
require.NoError(t, resp.Body.Close())
|
|
}
|
|
require.ErrorContains(t, err, "chatdebug: reset request body: close failed")
|
|
require.Nil(t, resp)
|
|
require.True(t, originalBody.closed)
|
|
require.Empty(t, sink.snapshot())
|
|
select {
|
|
case <-gotRequest:
|
|
t.Fatal("request should not be sent when the captured body cannot be closed")
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestRecordingTransport_RedactsSensitiveQueryParameters(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
_, _ = rw.Write([]byte(`ok`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{Transport: &RecordingTransport{Base: server.Client().Transport}}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+`?api_key=secret&safe=ok`, nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
_, err = io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
require.Contains(t, attempts[0].URL, "api_key=%5BREDACTED%5D")
|
|
require.Contains(t, attempts[0].URL, "safe=ok")
|
|
}
|
|
|
|
func TestRecordingTransport_TruncatesLargeRequestBodies(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
_, _ = io.Copy(io.Discard, req.Body)
|
|
_, _ = rw.Write([]byte(`ok`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{Transport: &RecordingTransport{Base: server.Client().Transport}}
|
|
|
|
large := strings.Repeat("x", maxRecordedRequestBodyBytes+1024)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, strings.NewReader(large))
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
_, err = io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
require.Equal(t, []byte("[TRUNCATED]"), attempts[0].RequestBody)
|
|
}
|
|
|
|
func TestRecordingTransport_StripsURLUserinfo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
_, _ = rw.Write([]byte(`ok`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{Transport: &RecordingTransport{Base: server.Client().Transport}}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.Replace(server.URL, "http://", "http://user:secret@", 1)+`?api_key=secret`, nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
_, err = io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
require.NotContains(t, attempts[0].URL, "user:secret")
|
|
require.Contains(t, attempts[0].URL, "api_key=%5BREDACTED%5D")
|
|
}
|
|
|
|
func TestRecordingTransport_SkipsNonReplayableRequestBodyCapture(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const requestBody = `{"message":"hello"}`
|
|
gotRequest := make(chan []byte, 1)
|
|
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
body, err := io.ReadAll(req.Body)
|
|
require.NoError(t, err)
|
|
gotRequest <- body
|
|
_, _ = rw.Write([]byte(`ok`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{Transport: &RecordingTransport{Base: server.Client().Transport}}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, io.NopCloser(strings.NewReader(requestBody)))
|
|
require.NoError(t, err)
|
|
req.GetBody = nil
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
_, err = io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
require.JSONEq(t, requestBody, string(<-gotRequest))
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
require.Nil(t, attempts[0].RequestBody)
|
|
}
|
|
|
|
func TestRecordingTransport_CaptureResponse(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
rw.Header().Set("X-API-Key", "response-secret")
|
|
rw.Header().Set("X-Trace-ID", "trace-123")
|
|
rw.WriteHeader(http.StatusCreated)
|
|
_, _ = rw.Write([]byte(`{"token":"response-secret","safe":"ok"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{Base: server.Client().Transport},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
require.JSONEq(t, `{"token":"response-secret","safe":"ok"}`, string(body))
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
require.Equal(t, http.StatusCreated, attempts[0].ResponseStatus)
|
|
require.Equal(t, "application/json", attempts[0].ResponseHeaders["Content-Type"])
|
|
require.Equal(t, RedactedValue, attempts[0].ResponseHeaders["X-Api-Key"])
|
|
require.Equal(t, "trace-123", attempts[0].ResponseHeaders["X-Trace-Id"])
|
|
require.JSONEq(t, `{"token":"[REDACTED]","safe":"ok"}`, string(attempts[0].ResponseBody))
|
|
}
|
|
|
|
// TestRecordingTransport_CaptureResponseRecordsOnClose verifies that
|
|
// EOF recording is deferred to Close() rather than firing in Read().
|
|
// This ensures Close()'s validation logic (JSON integrity, content-
|
|
// length checks) always runs.
|
|
func TestRecordingTransport_CaptureResponseRecordsOnClose(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
rw.Header().Set("X-API-Key", "response-secret")
|
|
rw.WriteHeader(http.StatusAccepted)
|
|
_, _ = rw.Write([]byte(`{"token":"response-secret","safe":"ok"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{Base: server.Client().Transport},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.JSONEq(t, `{"token":"response-secret","safe":"ok"}`, string(body))
|
|
|
|
// Before Close(), the attempt should not yet be recorded
|
|
// because EOF recording is deferred to Close().
|
|
require.Empty(t, sink.snapshot(), "attempt should not be recorded before Close()")
|
|
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, http.StatusAccepted, attempts[0].ResponseStatus)
|
|
require.Equal(t, "application/json", attempts[0].ResponseHeaders["Content-Type"])
|
|
require.Equal(t, RedactedValue, attempts[0].ResponseHeaders["X-Api-Key"])
|
|
require.JSONEq(t, `{"token":"[REDACTED]","safe":"ok"}`, string(attempts[0].ResponseBody))
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
}
|
|
|
|
func TestRecordingTransport_StreamingBody(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
flusher, ok := rw.(http.Flusher)
|
|
require.True(t, ok)
|
|
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
_, _ = rw.Write([]byte(`{"safe":"stream",`))
|
|
flusher.Flush()
|
|
_, _ = rw.Write([]byte(`"token":"chunk-secret"}`))
|
|
flusher.Flush()
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{Base: server.Client().Transport},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
buf := make([]byte, 5)
|
|
var body strings.Builder
|
|
for {
|
|
n, readErr := resp.Body.Read(buf)
|
|
if n > 0 {
|
|
_, writeErr := body.Write(buf[:n])
|
|
require.NoError(t, writeErr)
|
|
}
|
|
if errors.Is(readErr, io.EOF) {
|
|
break
|
|
}
|
|
require.NoError(t, readErr)
|
|
}
|
|
require.NoError(t, resp.Body.Close())
|
|
require.JSONEq(t, `{"safe":"stream","token":"chunk-secret"}`, body.String())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
require.JSONEq(t, `{"safe":"stream","token":"[REDACTED]"}`, string(attempts[0].ResponseBody))
|
|
}
|
|
|
|
func TestRecordingTransport_CloseAfterDecoderConsumesContentLengthSucceeds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
_, _ = rw.Write([]byte(`{"token":"response-secret","safe":"ok"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{Transport: &RecordingTransport{Base: server.Client().Transport}}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
var decoded map[string]string
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&decoded))
|
|
require.Equal(t, "ok", decoded["safe"])
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
require.Empty(t, attempts[0].Error)
|
|
}
|
|
|
|
func TestRecordingTransport_CloseAfterDecoderConsumesUnknownLengthJSONSucceeds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test response exercises unknown-length close semantics.
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: &scriptedReadCloser{chunks: [][]byte{[]byte(`{"token":"response-secret","safe":"ok"}`)}},
|
|
ContentLength: -1,
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
var decoded map[string]string
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&decoded))
|
|
require.Equal(t, "ok", decoded["safe"])
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
require.Empty(t, attempts[0].Error)
|
|
}
|
|
|
|
func TestRecordingTransport_CloseAfterDecoderConsumesUnknownLengthJSONWithTrailingDocumentMarksFailed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test response exercises unknown-length close semantics.
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: &scriptedReadCloser{chunks: [][]byte{[]byte("{\"token\":\"response-secret\",\"safe\":\"ok\"}{\"token\":\"second\"}")}},
|
|
ContentLength: -1,
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
var decoded map[string]string
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&decoded))
|
|
require.Equal(t, "ok", decoded["safe"])
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusFailed, attempts[0].Status)
|
|
require.Equal(t, io.ErrUnexpectedEOF.Error(), attempts[0].Error)
|
|
}
|
|
|
|
func TestRecordingTransport_CloseAfterDecoderConsumesUnknownLengthNDJSONMarksFailed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test response exercises unknown-length close semantics.
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/x-ndjson"}},
|
|
Body: &scriptedReadCloser{chunks: [][]byte{[]byte("{\"token\":\"response-secret\",\"safe\":\"ok\"}\n{\"token\":\"second\"}\n")}},
|
|
ContentLength: -1,
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
var decoded map[string]string
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&decoded))
|
|
require.Equal(t, "ok", decoded["safe"])
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusFailed, attempts[0].Status)
|
|
require.Equal(t, io.ErrUnexpectedEOF.Error(), attempts[0].Error)
|
|
}
|
|
|
|
func TestRecordingTransport_CloseAfterDecoderDrainsUnknownLengthSucceeds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test response exercises unknown-length close semantics.
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: &scriptedReadCloser{chunks: [][]byte{[]byte(`{"token":"response-secret","safe":"ok"}`)}},
|
|
ContentLength: -1,
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
var decoded map[string]string
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&decoded))
|
|
require.Equal(t, "ok", decoded["safe"])
|
|
_, err = io.Copy(io.Discard, resp.Body)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
require.Empty(t, attempts[0].Error)
|
|
}
|
|
|
|
func TestRecordingTransport_CloseWithoutReadingHeadResponseSucceeds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test response exercises no-body close semantics.
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: &scriptedReadCloser{chunks: [][]byte{[]byte(`{"ignored":true}`)}},
|
|
ContentLength: 13,
|
|
Request: req,
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodHead, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
require.Empty(t, attempts[0].Error)
|
|
}
|
|
|
|
func TestRecordingTransport_CloseWithoutReadingUnknownLengthMarksFailed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test response exercises unknown-length close semantics.
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: &scriptedReadCloser{chunks: [][]byte{[]byte(`{"token":"response-secret","safe":"ok"}`)}},
|
|
ContentLength: -1,
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusFailed, attempts[0].Status)
|
|
require.Equal(t, io.ErrUnexpectedEOF.Error(), attempts[0].Error)
|
|
}
|
|
|
|
func TestRecordingTransport_PrematureCloseUnknownLengthMarksFailed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test response exercises unknown-length close semantics.
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: &scriptedReadCloser{chunks: [][]byte{[]byte(`{"token":"response-secret","safe":"ok"}`)}},
|
|
ContentLength: -1,
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
buf := make([]byte, 5)
|
|
_, err = resp.Body.Read(buf)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusFailed, attempts[0].Status)
|
|
require.Equal(t, io.ErrUnexpectedEOF.Error(), attempts[0].Error)
|
|
}
|
|
|
|
func TestRecordingTransport_PrematureCloseMarksFailed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
_, _ = rw.Write([]byte(`{"token":"response-secret","safe":"ok"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{Transport: &RecordingTransport{Base: server.Client().Transport}}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
buf := make([]byte, 5)
|
|
_, err = resp.Body.Read(buf)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusFailed, attempts[0].Status)
|
|
require.NotEmpty(t, attempts[0].Error, "failure-path attempt should record an Error")
|
|
}
|
|
|
|
func TestRecordingTransport_TruncatesLargeResponses(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
_, _ = rw.Write([]byte(strings.Repeat("x", maxRecordedResponseBodyBytes+1024)))
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{Transport: &RecordingTransport{Base: server.Client().Transport}}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
_, err = io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
require.Equal(t, []byte("[TRUNCATED]"), attempts[0].ResponseBody)
|
|
}
|
|
|
|
func TestRecordingTransport_TransportError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return nil, xerrors.New("transport exploded")
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodPost,
|
|
"http://example.invalid",
|
|
strings.NewReader(`{"password":"secret","safe":"ok"}`),
|
|
)
|
|
require.NoError(t, err)
|
|
req.Header.Set("Authorization", "Bearer top-secret")
|
|
|
|
resp, err := client.Do(req)
|
|
if resp != nil {
|
|
defer resp.Body.Close()
|
|
}
|
|
require.Nil(t, resp)
|
|
require.EqualError(t, err, "Post \"http://example.invalid\": transport exploded")
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusFailed, attempts[0].Status)
|
|
require.Equal(t, 1, attempts[0].Number)
|
|
require.Equal(t, RedactedValue, attempts[0].RequestHeaders["Authorization"])
|
|
require.JSONEq(t, `{"password":"[REDACTED]","safe":"ok"}`, string(attempts[0].RequestBody))
|
|
require.Zero(t, attempts[0].ResponseStatus)
|
|
require.Equal(t, "transport exploded", attempts[0].Error)
|
|
require.GreaterOrEqual(t, attempts[0].DurationMs, int64(0))
|
|
}
|
|
|
|
func TestRecordingTransport_TransportErrorSanitizesURLCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return nil, xerrors.New("connection to http://admin:s3cret@api.example.com/v1?api_key=sk-1234 refused")
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
if resp != nil {
|
|
defer resp.Body.Close()
|
|
}
|
|
require.Error(t, err)
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusFailed, attempts[0].Status)
|
|
require.NotContains(t, attempts[0].Error, "s3cret")
|
|
require.NotContains(t, attempts[0].Error, "sk-1234")
|
|
require.Contains(t, attempts[0].Error, "api_key=%5BREDACTED%5D")
|
|
}
|
|
|
|
func TestRecordingTransport_NilBase(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
_, _ = rw.Write([]byte("ok"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := &http.Client{Transport: &RecordingTransport{}}
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "ok", string(body))
|
|
}
|
|
|
|
func TestRecordingTransport_SSEReadToEOFMarksCompleted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
ssePayload := "data: {\"token\":\"secret\"}\n\ndata: [DONE]\n\n"
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test SSE content type.
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
|
Body: io.NopCloser(strings.NewReader(ssePayload)),
|
|
ContentLength: -1,
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
require.Equal(t, ssePayload, string(body))
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
require.Empty(t, attempts[0].Error)
|
|
// SSE bodies should be preserved as-is, not replaced with
|
|
// a redaction diagnostic.
|
|
require.Equal(t, ssePayload, string(attempts[0].ResponseBody))
|
|
}
|
|
|
|
// TestRecordingTransport_SSEReadToEOFWithoutCloseStillRecords verifies
|
|
// that SSE consumers that reach EOF and abandon the response without
|
|
// calling Close() (the pattern fantasy's Anthropic SSE adapter follows)
|
|
// still populate the attempt sink. Close()-only recording would leave
|
|
// the chat_turn step's attempts field permanently empty.
|
|
func TestRecordingTransport_SSEReadToEOFWithoutCloseStillRecords(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
ssePayload := "data: {\"token\":\"secret\"}\n\ndata: [DONE]\n\n"
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test SSE content type.
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
|
Body: io.NopCloser(strings.NewReader(ssePayload)),
|
|
ContentLength: -1,
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req) //nolint:bodyclose // Intentionally skip Close() to verify EOF-only recording.
|
|
require.NoError(t, err)
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.Equal(t, ssePayload, string(body))
|
|
// Deliberately do NOT call resp.Body.Close(). The attempt must be
|
|
// recorded on EOF alone.
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
require.Empty(t, attempts[0].Error)
|
|
require.Equal(t, ssePayload, string(attempts[0].ResponseBody))
|
|
}
|
|
|
|
// TestRecordingTransport_SSEEmptyBodyRecordsOnEOF verifies that an SSE
|
|
// response with zero bytes (immediate EOF on the first Read) still
|
|
// records a completed attempt. This covers the n == 0 && err == io.EOF
|
|
// branch in accumulateReadLocked where the buffer path is skipped but
|
|
// sawEOF must still fire the Read-path recording.
|
|
func TestRecordingTransport_SSEEmptyBodyRecordsOnEOF(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test SSE content type.
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
|
Body: io.NopCloser(strings.NewReader("")),
|
|
ContentLength: -1,
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req) //nolint:bodyclose // Intentionally skip Close() to verify EOF-only recording.
|
|
require.NoError(t, err)
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.Empty(t, body)
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
require.Empty(t, attempts[0].Error)
|
|
require.Empty(t, attempts[0].ResponseBody)
|
|
}
|
|
|
|
// TestRecordingTransport_SSEReadToEOFWithCloseErrorUpgrades verifies
|
|
// that when an SSE consumer reads to EOF (which eagerly records the
|
|
// attempt as completed) and then Close() fails because inner.Close()
|
|
// returns an error, the recorded attempt is upgraded to failed with
|
|
// the close error rather than silently remaining completed.
|
|
func TestRecordingTransport_SSEReadToEOFWithCloseErrorUpgrades(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
ssePayload := "data: {\"token\":\"secret\"}\n\ndata: [DONE]\n\n"
|
|
closeErr := xerrors.New("boom: connection reset")
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test SSE content type.
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
|
Body: &failingCloseReader{
|
|
inner: strings.NewReader(ssePayload),
|
|
closeErr: closeErr,
|
|
},
|
|
ContentLength: -1,
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.Equal(t, ssePayload, string(body))
|
|
|
|
// Close must surface the inner close error to the caller...
|
|
gotCloseErr := resp.Body.Close()
|
|
require.ErrorIs(t, gotCloseErr, closeErr)
|
|
|
|
// ...and the recorded attempt must reflect that failure instead of
|
|
// the provisional completed entry written on EOF.
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusFailed, attempts[0].Status)
|
|
require.Contains(t, attempts[0].Error, "boom: connection reset")
|
|
require.Equal(t, ssePayload, string(attempts[0].ResponseBody))
|
|
}
|
|
|
|
// TestRecordingBody_SSEConcurrentReadCloseNoDeadlock exercises the
|
|
// lock-ordering contract between record() and recordProvisional()
|
|
// under concurrent Read/Close on an SSE body. An earlier revision
|
|
// where record() entered recordOnce.Do before acquiring r.mu (while
|
|
// recordProvisional() acquired r.mu first) deadlocked when one
|
|
// goroutine won the Once but then blocked on r.mu while the other
|
|
// held r.mu and blocked on the Once.
|
|
func TestRecordingBody_SSEConcurrentReadCloseNoDeadlock(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const iterations = 200
|
|
ssePayload := []byte("data: ping\n\n")
|
|
|
|
for i := range iterations {
|
|
sink := &attemptSink{}
|
|
body := &recordingBody{
|
|
inner: io.NopCloser(strings.NewReader(string(ssePayload))),
|
|
contentLength: -1,
|
|
contentType: "text/event-stream",
|
|
sink: sink,
|
|
startedAt: time.Now(),
|
|
base: Attempt{Number: sink.nextAttemptNumber()},
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
buf := make([]byte, 64)
|
|
for {
|
|
if _, err := body.Read(buf); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
_ = body.Close()
|
|
}()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
wg.Wait()
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(testutil.WaitShort):
|
|
t.Fatalf("deadlock detected on iteration %d", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRecordingTransport_SSEClosedEarlyMarksFailed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
ssePayload := "data: {\"token\":\"secret\"}\n\ndata: [DONE]\n\n"
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test SSE content type.
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
|
Body: &scriptedReadCloser{chunks: [][]byte{[]byte(ssePayload)}},
|
|
ContentLength: -1,
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
// Read only a few bytes then close early.
|
|
buf := make([]byte, 5)
|
|
_, err = resp.Body.Read(buf)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusFailed, attempts[0].Status)
|
|
require.Equal(t, io.ErrUnexpectedEOF.Error(), attempts[0].Error)
|
|
}
|
|
|
|
func TestRecordingTransport_TextPlainPreservedNotRedacted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
textPayload := "This is plain text, not JSON."
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test text/plain content type.
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"text/plain"}},
|
|
Body: io.NopCloser(strings.NewReader(textPayload)),
|
|
ContentLength: int64(len(textPayload)),
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
_, err = io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
// Non-JSON bodies should be preserved as-is, not replaced
|
|
// with a redaction diagnostic.
|
|
require.Equal(t, textPayload, string(attempts[0].ResponseBody))
|
|
}
|
|
|
|
// TestRecordingTransport_NDJSONRedacted verifies that NDJSON response
|
|
// bodies have secrets redacted on a per-line basis rather than being
|
|
// treated as non-JSON and preserved raw.
|
|
func TestRecordingTransport_NDJSONRedacted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
ndjsonPayload := "{\"api_key\":\"sk-123\",\"safe\":\"ok\"}\n{\"token\":\"tok-456\",\"data\":\"value\"}\n"
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test NDJSON content type.
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/x-ndjson"}},
|
|
Body: io.NopCloser(strings.NewReader(ndjsonPayload)),
|
|
ContentLength: int64(len(ndjsonPayload)),
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
// Caller sees original unredacted payload.
|
|
require.Equal(t, ndjsonPayload, string(body))
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
// Recorded body should have secrets redacted per-line.
|
|
lines := strings.Split(string(attempts[0].ResponseBody), "\n")
|
|
require.JSONEq(t, `{"api_key":"[REDACTED]","safe":"ok"}`, lines[0])
|
|
require.JSONEq(t, `{"token":"[REDACTED]","data":"value"}`, lines[1])
|
|
}
|
|
|
|
// TestRecordingTransport_PlusJSONSuffixRedacted verifies that
|
|
// content types with a +json suffix (e.g. application/vnd.api+json)
|
|
// are treated as JSON-like and have secrets redacted in recorded
|
|
// response bodies.
|
|
func TestRecordingTransport_PlusJSONSuffixRedacted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
jsonPayload := `{"token":"secret","safe":"ok"}`
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test +json suffix content type.
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/vnd.api+json"}},
|
|
Body: io.NopCloser(strings.NewReader(jsonPayload)),
|
|
ContentLength: int64(len(jsonPayload)),
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
// Caller sees original unredacted payload.
|
|
require.Equal(t, jsonPayload, string(body))
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
// Token must be redacted in the recorded body.
|
|
require.JSONEq(t, `{"token":"[REDACTED]","safe":"ok"}`, string(attempts[0].ResponseBody))
|
|
}
|
|
|
|
// TestRecordingTransport_UnrecognizedContentTypeDefaultsToJSONRedaction
|
|
// verifies that an unrecognized content-type header (e.g. non-canonical
|
|
// lowercase key not found by http.Header.Get) defaults to JSON
|
|
// redaction rather than falling into the raw-body preservation path.
|
|
func TestRecordingTransport_UnrecognizedContentTypeDefaultsToJSONRedaction(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
// Use lowercase header key to simulate non-canonical transport.
|
|
return &http.Response{ //nolint:exhaustruct // Test lowercase content-type.
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"content-type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader(`{"token":"secret","safe":"ok"}`)),
|
|
ContentLength: int64(len(`{"token":"secret","safe":"ok"}`)),
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
_, err = io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
// The token should be redacted, not preserved raw or replaced
|
|
// with the fail-closed diagnostic.
|
|
require.JSONEq(t, `{"token":"[REDACTED]","safe":"ok"}`, string(attempts[0].ResponseBody))
|
|
}
|
|
|
|
// TestRecordingTransport_NonJSONBodyFailClosedRedaction verifies that
|
|
// when the Content-Type is empty (or JSON-like) but the response body
|
|
// is not valid JSON, RedactJSONSecrets' fail-closed behavior replaces
|
|
// the body with a diagnostic message rather than preserving the raw
|
|
// content which could contain credentials.
|
|
func TestRecordingTransport_NonJSONBodyFailClosedRedaction(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
htmlBody := `<html><body>502 Bad Gateway</body></html>`
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
// Empty Content-Type triggers the JSON-or-unknown
|
|
// branch in record(), which calls RedactJSONSecrets.
|
|
return &http.Response{ //nolint:exhaustruct // Test fail-closed redaction.
|
|
StatusCode: http.StatusBadGateway,
|
|
Header: http.Header{},
|
|
Body: io.NopCloser(strings.NewReader(htmlBody)),
|
|
ContentLength: int64(len(htmlBody)),
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
// The caller sees the original body.
|
|
require.Equal(t, htmlBody, string(body))
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
// The recorded body must be the fail-closed diagnostic, not the
|
|
// raw HTML which could contain tokens or session data.
|
|
require.JSONEq(t,
|
|
`{"error":"chatdebug: body is not valid JSON, redacted for safety"}`,
|
|
string(attempts[0].ResponseBody))
|
|
}
|
|
|
|
// TestRecordingTransport_TruncatedUnknownLengthMarksCompleted verifies
|
|
// that an unknown-length (chunked) response that exceeds the recording
|
|
// buffer is marked as completed, not failed. The caller consumed the
|
|
// body successfully; we just couldn't buffer all of it.
|
|
func TestRecordingTransport_TruncatedUnknownLengthMarksCompleted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
largeBody := strings.Repeat("x", maxRecordedResponseBodyBytes+1024)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test unknown-length body.
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
|
Body: io.NopCloser(strings.NewReader(largeBody)),
|
|
ContentLength: -1,
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.Len(t, body, maxRecordedResponseBodyBytes+1024)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
require.Empty(t, attempts[0].Error)
|
|
require.Equal(t, []byte("[TRUNCATED]"), attempts[0].ResponseBody)
|
|
}
|
|
|
|
// errorAfterReadCloser returns data for the first N reads, then an error.
|
|
type errorAfterReadCloser struct {
|
|
data []byte
|
|
offset int
|
|
errAt int // byte offset at which to return the error
|
|
err error
|
|
}
|
|
|
|
func (r *errorAfterReadCloser) Read(p []byte) (int, error) {
|
|
if r.offset >= r.errAt {
|
|
return 0, r.err
|
|
}
|
|
remaining := r.data[r.offset:]
|
|
if len(remaining) > len(p) {
|
|
remaining = remaining[:len(p)]
|
|
}
|
|
if r.offset+len(remaining) > r.errAt {
|
|
remaining = remaining[:r.errAt-r.offset]
|
|
}
|
|
n := copy(p, remaining)
|
|
r.offset += n
|
|
if r.offset >= r.errAt {
|
|
return n, r.err
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func (*errorAfterReadCloser) Close() error {
|
|
return nil
|
|
}
|
|
|
|
// TestRecordingTransport_MidStreamReadError verifies that a non-EOF
|
|
// read error during body consumption is recorded immediately with
|
|
// "failed" status and the correct error message.
|
|
func TestRecordingTransport_MidStreamReadError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test mid-stream error.
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: &errorAfterReadCloser{data: []byte(`{"key":"value"}`), errAt: 10, err: io.ErrUnexpectedEOF},
|
|
ContentLength: -1,
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
_, err = io.ReadAll(resp.Body)
|
|
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusFailed, attempts[0].Status)
|
|
require.Equal(t, io.ErrUnexpectedEOF.Error(), attempts[0].Error)
|
|
}
|
|
|
|
// trackingReadCloser wraps a reader and counts total bytes delivered
|
|
// via Read. Close always succeeds.
|
|
type trackingReadCloser struct {
|
|
inner io.Reader
|
|
bytesRead int64
|
|
closed bool
|
|
}
|
|
|
|
func (r *trackingReadCloser) Read(p []byte) (int, error) {
|
|
n, err := r.inner.Read(p)
|
|
r.bytesRead += int64(n)
|
|
return n, err
|
|
}
|
|
|
|
func (r *trackingReadCloser) Close() error {
|
|
r.closed = true
|
|
return nil
|
|
}
|
|
|
|
// failingCloseReader reads normally but returns an error on Close.
|
|
type failingCloseReader struct {
|
|
inner io.Reader
|
|
closeErr error
|
|
}
|
|
|
|
func (r *failingCloseReader) Read(p []byte) (int, error) {
|
|
return r.inner.Read(p)
|
|
}
|
|
|
|
func (r *failingCloseReader) Close() error {
|
|
return r.closeErr
|
|
}
|
|
|
|
// TestRecordingTransport_MaxDrainBytesRespected verifies that
|
|
// drainToEOF stops after maxDrainBytes, preventing unbounded reads.
|
|
// The test uses a tracking reader to assert the byte cap.
|
|
func TestRecordingTransport_MaxDrainBytesRespected(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
|
|
// Build a body where json.Decoder consumes the first JSON document
|
|
// but leaves trailing whitespace larger than maxDrainBytes. The
|
|
// drain path should stop after maxDrainBytes, not read everything.
|
|
jsonDoc := `{"safe":"ok"}`
|
|
// Trailing whitespace much larger than maxDrainBytes. The drain
|
|
// should consume at most maxDrainBytes of it.
|
|
trailing := strings.Repeat(" ", maxDrainBytes*2)
|
|
fullBody := jsonDoc + trailing
|
|
|
|
tracker := &trackingReadCloser{inner: strings.NewReader(fullBody)}
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test maxDrainBytes.
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: tracker,
|
|
ContentLength: -1,
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
var decoded map[string]string
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&decoded))
|
|
require.Equal(t, "ok", decoded["safe"])
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
|
|
// The key assertion: total bytes read through the tracker should
|
|
// be bounded. The json.Decoder reads the JSON doc (~13 bytes),
|
|
// then drainToEOF reads at most maxDrainBytes more. Without the
|
|
// cap, the full body (maxDrainBytes*2 + 13) would be consumed.
|
|
maxExpected := int64(len(jsonDoc)) + int64(maxDrainBytes) + 4096 // small buffer overhead
|
|
require.Less(t, tracker.bytesRead, int64(len(fullBody)),
|
|
"drain should NOT have consumed the entire body")
|
|
require.LessOrEqual(t, tracker.bytesRead, maxExpected,
|
|
"total bytes read should be bounded by maxDrainBytes")
|
|
require.True(t, tracker.closed, "inner body should be closed")
|
|
}
|
|
|
|
// TestRecordingTransport_InnerCloseError verifies that an error from
|
|
// the inner body's Close() is recorded as a failed attempt and
|
|
// returned to the caller.
|
|
func TestRecordingTransport_InnerCloseError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
closeErr := xerrors.New("connection reset by peer")
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test close error.
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: &failingCloseReader{inner: strings.NewReader(`{"ok":true}`), closeErr: closeErr},
|
|
ContentLength: -1,
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
_, err = io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
err = resp.Body.Close()
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "connection reset by peer")
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusFailed, attempts[0].Status)
|
|
require.Contains(t, attempts[0].Error, "connection reset by peer")
|
|
}
|
|
|
|
// TestRecordingTransport_204NoContentSucceeds verifies that a 204 No
|
|
// Content response is marked completed when closed without reading.
|
|
func TestRecordingTransport_204NoContentSucceeds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test 204 no-body.
|
|
StatusCode: http.StatusNoContent,
|
|
Header: http.Header{},
|
|
Body: io.NopCloser(strings.NewReader("")),
|
|
ContentLength: 0,
|
|
Request: req,
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, "http://example.invalid/resource", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
require.Empty(t, attempts[0].Error)
|
|
}
|
|
|
|
// TestRecordingTransport_304NotModifiedSucceeds verifies that a 304
|
|
// Not Modified response is marked completed when closed without
|
|
// reading, even when Content-Length is non-zero.
|
|
func TestRecordingTransport_304NotModifiedSucceeds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, sink := newTestSinkContext(t)
|
|
client := &http.Client{
|
|
Transport: &RecordingTransport{
|
|
Base: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{ //nolint:exhaustruct // Test 304 no-body.
|
|
StatusCode: http.StatusNotModified,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader("")),
|
|
ContentLength: 42,
|
|
Request: req,
|
|
}, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.invalid/resource", nil)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
attempts := sink.snapshot()
|
|
require.Len(t, attempts, 1)
|
|
require.Equal(t, attemptStatusCompleted, attempts[0].Status)
|
|
require.Empty(t, attempts[0].Error)
|
|
}
|