317 lines
11 KiB
Go
317 lines
11 KiB
Go
package httpc
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestJSONHeadersConcurrentSnapshot(t *testing.T) {
|
|
var leaked atomic.Bool
|
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Authorization") != "Bearer "+r.URL.Query().Get("caller") {
|
|
leaked.Store(true)
|
|
}
|
|
if r.Header.Get("X-Shared") != "original" || len(r.Header.Values("X-Tag")) != 2 {
|
|
leaked.Store(true)
|
|
}
|
|
if r.Header.Get("Content-Type") != "application/vnd.example+json" {
|
|
leaked.Store(true)
|
|
}
|
|
io.WriteString(w, `{"ok":true}`)
|
|
}))
|
|
defer s.Close()
|
|
c := testClient(t, Config{})
|
|
headers := http.Header{"X-Shared": []string{"original"}, "X-Tag": []string{"one", "two"}}
|
|
snapshot := WithHeaders(headers)
|
|
headers.Set("X-Shared", "changed")
|
|
headers["X-Tag"][0] = "changed"
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 24; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
id := fmt.Sprint(i)
|
|
var output struct {
|
|
OK bool `json:"ok"`
|
|
}
|
|
if err := c.JSON(context.Background(), "POST", s.URL+"?caller="+id, map[string]bool{"hello": true}, &output, snapshot, WithHeader("Authorization", "Bearer "+id), WithHeader("content-type", "application/vnd.example+json")); err != nil || !output.OK {
|
|
t.Errorf("JSON failed: %v", err)
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
if leaked.Load() {
|
|
t.Fatal("headers leaked across requests or changed with source map")
|
|
}
|
|
}
|
|
func TestRequestOptionsValidationBeforeNetwork(t *testing.T) {
|
|
var calls atomic.Int32
|
|
c := testClient(t, Config{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
|
calls.Add(1)
|
|
return benchmarkTransport{`{}`, 200}.RoundTrip(r)
|
|
})})
|
|
invalid := []RequestOption{nil, WithHeader("bad name", "private-token"), WithHeader("Authorization", "private-token\r\nInjected: yes"), WithHeader("Host", "example.invalid"), WithHeader("Content-Length", "1"), WithHeader("Transfer-Encoding", "chunked"), WithHeader("Trailer", "X-Signature"), WithHeaders(http.Header{"X-Name": []string{"one"}, "x-name": []string{"two"}}), WithMaxResponseBytes(0), WithMaxResponseBytes(maxResponseBytes + 1)}
|
|
for _, option := range invalid {
|
|
err := c.JSON(context.Background(), "GET", "http://example.invalid", nil, nil, option)
|
|
if err == nil || strings.Contains(err.Error(), "private-token") {
|
|
t.Fatal("missing or unsafe error", err)
|
|
}
|
|
}
|
|
if calls.Load() != 0 {
|
|
t.Fatal("invalid options reached network")
|
|
}
|
|
}
|
|
func TestJSONResponseLimitsAndStreaming(t *testing.T) {
|
|
var calls atomic.Int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls.Add(1)
|
|
switch r.URL.Path {
|
|
case "/empty":
|
|
w.WriteHeader(204)
|
|
case "/chunked":
|
|
w.(http.Flusher).Flush()
|
|
io.WriteString(w, strings.Repeat("x", 17))
|
|
case "/status-stream":
|
|
w.WriteHeader(401)
|
|
w.(http.Flusher).Flush()
|
|
<-r.Context().Done()
|
|
case "/multiple":
|
|
io.WriteString(w, `{} {}`)
|
|
default:
|
|
io.WriteString(w, `{"ok":true}`)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
c := testClient(t, Config{MaxResponseBytes: 8, Timeout: time.Second})
|
|
var out struct {
|
|
OK bool `json:"ok"`
|
|
}
|
|
if err := c.JSON(context.Background(), "GET", server.URL, nil, &out, WithMaxResponseBytes(32)); err != nil || !out.OK {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.JSON(context.Background(), "GET", server.URL, nil, nil); !errors.Is(err, ErrResponseTooLarge) {
|
|
t.Fatal("limit changed permanently", err)
|
|
}
|
|
for _, target := range []any{nil, &out} {
|
|
if err := c.JSON(context.Background(), "GET", server.URL+"/chunked", nil, target, WithMaxResponseBytes(16)); !errors.Is(err, ErrResponseTooLarge) {
|
|
t.Fatal("unbounded chunked body", err)
|
|
}
|
|
}
|
|
if err := c.JSON(context.Background(), "GET", server.URL+"/empty", nil, &out); err != nil || !out.OK {
|
|
t.Fatal("empty success changed output", err)
|
|
}
|
|
if err := c.JSON(context.Background(), "GET", server.URL+"/multiple", nil, &out); err == nil {
|
|
t.Fatal("multiple JSON documents accepted")
|
|
}
|
|
start := time.Now()
|
|
err := c.JSON(context.Background(), "GET", server.URL+"/status-stream", nil, nil)
|
|
var status *StatusError
|
|
if !errors.As(err, &status) || status.StatusCode != 401 || time.Since(start) > 500*time.Millisecond {
|
|
t.Fatal("waited for unknown status body", err)
|
|
}
|
|
if calls.Load() != 7 {
|
|
t.Fatal(calls.Load())
|
|
}
|
|
}
|
|
func TestErrorBodyPoolReuseAndDrainBound(t *testing.T) {
|
|
var connections atomic.Int32
|
|
s := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(401)
|
|
io.WriteString(w, "private-error-body")
|
|
}))
|
|
s.Config.ConnState = func(_ net.Conn, state http.ConnState) {
|
|
if state == http.StateNew {
|
|
connections.Add(1)
|
|
}
|
|
}
|
|
s.Start()
|
|
defer s.Close()
|
|
c := testClient(t, Config{})
|
|
for i := 0; i < 10; i++ {
|
|
err := c.JSON(context.Background(), "GET", s.URL, nil, nil)
|
|
var status *StatusError
|
|
if !errors.As(err, &status) || strings.Contains(err.Error(), "private-error-body") {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if connections.Load() != 1 {
|
|
t.Fatal("small status response prevented reuse", connections.Load())
|
|
}
|
|
body := &countedBody{Reader: strings.NewReader(strings.Repeat("x", 1<<20))}
|
|
custom := testClient(t, Config{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: 401, ContentLength: 1, Body: body, Header: make(http.Header)}, nil
|
|
})})
|
|
err := custom.JSON(context.Background(), "GET", "http://example.invalid", nil, nil)
|
|
var status *StatusError
|
|
if !errors.As(err, &status) || !body.closed || body.read > (32<<10)+1 {
|
|
t.Fatal("unbounded/missing drain", body.read, body.closed, err)
|
|
}
|
|
}
|
|
|
|
type countedBody struct {
|
|
io.Reader
|
|
read int
|
|
closed bool
|
|
}
|
|
|
|
func (b *countedBody) Read(p []byte) (int, error) {
|
|
n, err := b.Reader.Read(p)
|
|
b.read += n
|
|
return n, err
|
|
}
|
|
func (b *countedBody) Close() error { b.closed = true; return nil }
|
|
|
|
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
|
|
|
type borrowedTransport struct{ closed atomic.Bool }
|
|
|
|
func (t *borrowedTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
return benchmarkTransport{`{}`, 200}.RoundTrip(r)
|
|
}
|
|
func (t *borrowedTransport) CloseIdleConnections() { t.closed.Store(true) }
|
|
func TestBorrowedTransportAndHeaderRetry(t *testing.T) {
|
|
owned := &borrowedTransport{}
|
|
c := testClient(t, Config{Transport: owned})
|
|
c.Close()
|
|
if owned.closed.Load() {
|
|
t.Fatal("closed caller's transport")
|
|
}
|
|
var attempts atomic.Int32
|
|
retry := testClient(t, Config{MaxRetries: 1, Backoff: time.Millisecond, Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
|
if r.Header.Get("Authorization") != "Bearer test" {
|
|
t.Error("retry lost header")
|
|
}
|
|
status := 200
|
|
if attempts.Add(1) == 1 {
|
|
status = 503
|
|
}
|
|
return benchmarkTransport{`{}`, status}.RoundTrip(r)
|
|
})})
|
|
if err := retry.JSON(context.Background(), "GET", "http://example.invalid", nil, nil, WithHeader("Authorization", "Bearer test")); err != nil || attempts.Load() != 2 {
|
|
t.Fatal(err, attempts.Load())
|
|
}
|
|
}
|
|
func FuzzRequestHeaderOptions(f *testing.F) {
|
|
f.Add("Authorization", "Bearer example")
|
|
f.Add("X-Name", "one\r\nX-Test: bad")
|
|
f.Fuzz(func(t *testing.T, name, value string) {
|
|
if len(name)+len(value) > 8192 {
|
|
t.Skip()
|
|
}
|
|
option := WithHeader(name, value)
|
|
var settings requestOptions
|
|
if err := option(&settings); err != nil {
|
|
return
|
|
}
|
|
req, err := http.NewRequest("GET", "http://example.invalid", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header = settings.headers
|
|
// The standard Request writer must be able to serialize accepted headers.
|
|
if err = req.Write(io.Discard); err != nil {
|
|
t.Fatal("accepted malformed header", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestHTTPLogDoesNotContainCredentials(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger, err := logx.New(logx.Config{Writer: &buffer})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer logger.Close()
|
|
c, err := New(Config{Transport: benchmarkTransport{"private-response", 401}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer c.Close()
|
|
ctx := logx.WithLogger(context.Background(), logger)
|
|
err = c.JSON(ctx, "GET", "http://private-user:private-password@example.invalid/private-path?token=private-query", nil, nil, WithHeader("Authorization", "Bearer private-token"))
|
|
if err == nil {
|
|
t.Fatal("expected status error")
|
|
}
|
|
for _, secret := range []string{"private-user", "private-password", "private-path", "private-query", "private-token", "private-response"} {
|
|
if strings.Contains(buffer.String(), secret) || strings.Contains(err.Error(), secret) {
|
|
t.Fatal("sensitive field leaked")
|
|
}
|
|
}
|
|
if !strings.Contains(buffer.String(), "http.client") {
|
|
t.Fatal("missing access log")
|
|
}
|
|
}
|
|
func TestCloseAllowsInflightCallToFinish(t *testing.T) {
|
|
entered := make(chan struct{})
|
|
release := make(chan struct{})
|
|
var once sync.Once
|
|
releaseNow := func() { once.Do(func() { close(release) }) }
|
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(entered); <-release; io.WriteString(w, `{}`) }))
|
|
defer s.Close()
|
|
c := testClient(t, Config{Timeout: time.Second})
|
|
done := make(chan error, 1)
|
|
go func() { done <- c.JSON(context.Background(), "GET", s.URL, nil, nil) }()
|
|
defer releaseNow()
|
|
select {
|
|
case <-entered:
|
|
case err := <-done:
|
|
t.Fatal("request failed before handler", err)
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("handler not entered")
|
|
}
|
|
c.Close()
|
|
releaseNow()
|
|
if err := <-done; err != nil {
|
|
t.Fatal("Close interrupted in-flight call", err)
|
|
}
|
|
if err := c.JSON(context.Background(), "GET", s.URL, nil, nil); !errors.Is(err, ErrClosed) {
|
|
t.Fatal("accepted new call after Close", err)
|
|
}
|
|
}
|
|
|
|
func TestSlowKnownErrorBodyHasShortDrainBudget(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Length", "100")
|
|
w.WriteHeader(503)
|
|
w.(http.Flusher).Flush()
|
|
<-r.Context().Done()
|
|
}))
|
|
defer server.Close()
|
|
c := testClient(t, Config{Timeout: 2 * time.Second})
|
|
start := time.Now()
|
|
err := c.JSON(context.Background(), "GET", server.URL, nil, nil)
|
|
var status *StatusError
|
|
if !errors.As(err, &status) || status.StatusCode != 503 || time.Since(start) > 500*time.Millisecond {
|
|
t.Fatal("slow error body delayed status", err, time.Since(start))
|
|
}
|
|
}
|
|
|
|
func TestJSONLimitAppliesAfterGzipExpansion(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Encoding", "gzip")
|
|
writer := gzip.NewWriter(w)
|
|
_, _ = io.WriteString(writer, strings.Repeat("x", 4096))
|
|
_ = writer.Close()
|
|
}))
|
|
defer server.Close()
|
|
c := testClient(t, Config{MaxResponseBytes: 64})
|
|
if err := c.JSON(context.Background(), "GET", server.URL, nil, nil); !errors.Is(err, ErrResponseTooLarge) {
|
|
t.Fatal("gzip bypassed response limit", err)
|
|
}
|
|
}
|