mirror of
https://github.com/caddyserver/caddy.git
synced 2026-06-23 01:20:32 +00:00
This is a shared encode_test harness with HTML/JSON/JS/CSS payloads taken from caddyserver.com Benchmarks: - BenchmarkStandardEncodingPayloads: raw encoder NewEncoder/Write/Close path - BenchmarkEncodeHandlerCorpus: full Encode.ServeHTTP middleware path - Grid: 4 payloads × gzip levels 1/5/9 × zstd fastest/default/best - Each subtest runs with 4 parallel workers; compare runs on MB/s and allocs/op Conformance tests: - Encoder contract: Reset, Flush, Close, and pool-style Reset-after-Close reuse - Corpus HTTP encoding: Content-Encoding, Vary, ETag suffix, header stripping - Response semantics: minimum_length, 304, HEAD, range, WebSocket bypass, If-None-Match rewrite, Cache-Control no-transform, content-type matcher rejection
400 lines
13 KiB
Go
400 lines
13 KiB
Go
package encode_test
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
|
"github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
|
|
)
|
|
|
|
const conformanceContentType = "text/plain"
|
|
|
|
// TestStandardEncoderContract verifies Reset, Flush, Close, and Reset-after-Close
|
|
// reuse for each encoder using the same HTML/JSON/JS/CSS payloads as the benchmark suite.
|
|
func TestStandardEncoderContract(t *testing.T) {
|
|
for _, encCase := range standardEncoderCases(t) {
|
|
t.Run(encCase.name, func(t *testing.T) {
|
|
for _, corpus := range benchmarkCorpora(t) {
|
|
t.Run(corpus.name, func(t *testing.T) {
|
|
encoder := encCase.encoding.NewEncoder()
|
|
original := corpus.data
|
|
|
|
encodeAndVerifyRoundTrip(t, encCase, encoder, original)
|
|
|
|
// Simulate writer-pool reuse: Close → Reset(nil) → Reset(writer).
|
|
encoder.Reset(nil)
|
|
encodeAndVerifyRoundTrip(t, encCase, encoder, original)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEncodeCorpusResponse verifies encoded-response semantics (Content-Encoding, Vary,
|
|
// ETag suffix, header stripping) for each benchmark corpus and encoder.
|
|
func TestEncodeCorpusResponse(t *testing.T) {
|
|
for _, encCase := range standardEncoderCases(t) {
|
|
t.Run(encCase.name, func(t *testing.T) {
|
|
for _, corpus := range benchmarkCorpora(t) {
|
|
t.Run(corpus.name, func(t *testing.T) {
|
|
enc := newEncodeHandler(t, encCase, 1)
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set("Accept-Encoding", encCase.encoding.AcceptEncoding())
|
|
w := httptest.NewRecorder()
|
|
|
|
next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
w.Header().Set("Content-Type", corpus.contentType)
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(corpus.data)))
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
w.Header().Set("Etag", `"response"`)
|
|
_, err := w.Write(corpus.data)
|
|
return err
|
|
})
|
|
|
|
if err := enc.ServeHTTP(w, r, next); err != nil {
|
|
t.Fatalf("ServeHTTP() error = %v", err)
|
|
}
|
|
checkEncodedCorpusResponse(t, w, encCase, corpus)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type encodeScenario struct {
|
|
name string
|
|
method string
|
|
minLength int
|
|
reqHeaders func(encoderCase) http.Header
|
|
checkRequest func(*testing.T, *http.Request)
|
|
next func(encoderCase) caddyhttp.Handler
|
|
checkResponse func(*testing.T, *httptest.ResponseRecorder, encoderCase)
|
|
}
|
|
|
|
var encodeScenarios = []encodeScenario{
|
|
{
|
|
name: "minimum length prevents encoding",
|
|
method: http.MethodGet,
|
|
minLength: 1024,
|
|
next: func(encoderCase) caddyhttp.Handler {
|
|
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
w.Header().Set("Content-Type", conformanceContentType)
|
|
_, err := w.Write([]byte("short"))
|
|
return err
|
|
})
|
|
},
|
|
checkResponse: checkMinLengthPreventsEncoding,
|
|
},
|
|
{
|
|
name: "not modified adds vary without encoding",
|
|
method: http.MethodGet,
|
|
minLength: 1,
|
|
next: func(encoderCase) caddyhttp.Handler {
|
|
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return nil
|
|
})
|
|
},
|
|
checkResponse: checkNotModifiedVary,
|
|
},
|
|
{
|
|
name: "head response headers can be encoded without body",
|
|
method: http.MethodHead,
|
|
minLength: 1,
|
|
next: func(encoderCase) caddyhttp.Handler {
|
|
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
w.Header().Set("Content-Type", conformanceContentType)
|
|
w.Header().Set("Content-Length", "128")
|
|
return nil
|
|
})
|
|
},
|
|
checkResponse: checkHeadEncodedHeaders,
|
|
},
|
|
{
|
|
name: "range response bypasses encoding",
|
|
method: http.MethodGet,
|
|
minLength: 1,
|
|
reqHeaders: func(encoderCase) http.Header {
|
|
return http.Header{"Range": {"bytes=0-15"}}
|
|
},
|
|
next: func(encoderCase) caddyhttp.Handler {
|
|
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
w.Header().Set("Content-Type", conformanceContentType)
|
|
w.Header().Set("Content-Range", "bytes 0-15/128")
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
w.WriteHeader(http.StatusPartialContent)
|
|
_, err := w.Write([]byte("0123456789abcdef"))
|
|
return err
|
|
})
|
|
},
|
|
checkResponse: checkRangeResponseBypassesEncoding,
|
|
},
|
|
{
|
|
name: "websocket handshake bypasses encoding",
|
|
method: http.MethodGet,
|
|
minLength: 1,
|
|
reqHeaders: func(encoderCase) http.Header {
|
|
return http.Header{
|
|
"Connection": {"Upgrade"},
|
|
"Sec-WebSocket-Key": {"dGhlIHNhbXBsZSBub25jZQ=="},
|
|
"Upgrade": {"websocket"},
|
|
}
|
|
},
|
|
next: func(encoderCase) caddyhttp.Handler {
|
|
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
w.WriteHeader(http.StatusSwitchingProtocols)
|
|
return nil
|
|
})
|
|
},
|
|
checkResponse: checkWebSocketBypass,
|
|
},
|
|
{
|
|
name: "strips encoded etag suffix before next handler",
|
|
method: http.MethodGet,
|
|
minLength: 1,
|
|
reqHeaders: func(encCase encoderCase) http.Header {
|
|
return http.Header{
|
|
"If-None-Match": {fmt.Sprintf(`"response-%s"`, encCase.encoding.AcceptEncoding())},
|
|
}
|
|
},
|
|
checkRequest: func(t *testing.T, r *http.Request) {
|
|
if got := r.Header.Get("If-None-Match"); got != `"response"` {
|
|
t.Fatalf("If-None-Match = %q, want %q", got, `"response"`)
|
|
}
|
|
},
|
|
next: func(encoderCase) caddyhttp.Handler {
|
|
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return nil
|
|
})
|
|
},
|
|
checkResponse: checkStripsEncodedETagSuffix,
|
|
},
|
|
{
|
|
name: "request cache-control no-transform prevents encoding",
|
|
method: http.MethodGet,
|
|
minLength: 1,
|
|
reqHeaders: func(encoderCase) http.Header {
|
|
return http.Header{"Cache-Control": {"no-cache, no-transform"}}
|
|
},
|
|
next: func(encoderCase) caddyhttp.Handler {
|
|
return conformanceLargeBodyHandler(conformanceContentType)
|
|
},
|
|
checkResponse: checkBypassesEncoding,
|
|
},
|
|
{
|
|
name: "response cache-control no-transform prevents encoding",
|
|
method: http.MethodGet,
|
|
minLength: 1,
|
|
next: func(encoderCase) caddyhttp.Handler {
|
|
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
w.Header().Set("Content-Type", conformanceContentType)
|
|
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
|
_, err := w.Write(conformanceLargeBody())
|
|
return err
|
|
})
|
|
},
|
|
checkResponse: checkBypassesEncoding,
|
|
},
|
|
{
|
|
name: "content type matcher rejection prevents encoding",
|
|
method: http.MethodGet,
|
|
minLength: 1,
|
|
next: func(encoderCase) caddyhttp.Handler {
|
|
return conformanceLargeBodyHandler("image/png")
|
|
},
|
|
checkResponse: checkBypassesEncoding,
|
|
},
|
|
}
|
|
|
|
// TestEncodeResponseSemantics verifies HTTP edge cases (304, HEAD, range, WebSocket,
|
|
// minimum_length, ETag request rewriting, no-transform, matcher rejection) independent
|
|
// of the benchmark corpora.
|
|
func TestEncodeResponseSemantics(t *testing.T) {
|
|
for _, encCase := range standardEncoderCases(t) {
|
|
t.Run(encCase.name, func(t *testing.T) {
|
|
for _, sc := range encodeScenarios {
|
|
t.Run(sc.name, func(t *testing.T) {
|
|
runEncodeScenario(t, encCase, sc)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func runEncodeScenario(t *testing.T, encCase encoderCase, sc encodeScenario) {
|
|
t.Helper()
|
|
|
|
enc := newEncodeHandler(t, encCase, sc.minLength)
|
|
r := httptest.NewRequest(sc.method, "/", nil)
|
|
r.Header.Set("Accept-Encoding", encCase.encoding.AcceptEncoding())
|
|
if sc.reqHeaders != nil {
|
|
for name, values := range sc.reqHeaders(encCase) {
|
|
r.Header.Del(name)
|
|
for _, value := range values {
|
|
r.Header.Add(name, value)
|
|
}
|
|
}
|
|
}
|
|
w := httptest.NewRecorder()
|
|
|
|
var rw http.ResponseWriter = w
|
|
if sc.method == http.MethodHead {
|
|
// httptest.ResponseRecorder still stores body writes on HEAD; discard them
|
|
// so Close() path matches real clients that must not receive a body.
|
|
rw = noBodyResponseWriter{ResponseRecorder: w}
|
|
}
|
|
|
|
next := sc.next(encCase)
|
|
if sc.checkRequest != nil {
|
|
inner := next
|
|
next = caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
sc.checkRequest(t, r)
|
|
return inner.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
if err := enc.ServeHTTP(rw, r, next); err != nil {
|
|
t.Fatalf("%s: ServeHTTP() error = %v", sc.name, err)
|
|
}
|
|
sc.checkResponse(t, w, encCase)
|
|
}
|
|
|
|
// noBodyResponseWriter discards Write data while still allowing the encode
|
|
// middleware to observe writes for Content-Length / min-length decisions.
|
|
type noBodyResponseWriter struct {
|
|
*httptest.ResponseRecorder
|
|
}
|
|
|
|
func (w noBodyResponseWriter) Write(p []byte) (int, error) {
|
|
return len(p), nil
|
|
}
|
|
|
|
func checkEncodedCorpusResponse(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase, corpus benchmarkCorpus) {
|
|
t.Helper()
|
|
|
|
encName := encCase.encoding.AcceptEncoding()
|
|
if got := w.Header().Get("Content-Encoding"); got != encName {
|
|
t.Fatalf("Content-Encoding = %q, want %q", got, encName)
|
|
}
|
|
if !encode.HasVaryValue(w.Header(), "Accept-Encoding") {
|
|
t.Fatalf("Vary = %q, want Accept-Encoding", w.Header().Values("Vary"))
|
|
}
|
|
if got := w.Header().Get("Content-Length"); got != "" {
|
|
t.Fatalf("Content-Length = %q, want empty", got)
|
|
}
|
|
if got := w.Header().Get("Accept-Ranges"); got != "" {
|
|
t.Fatalf("Accept-Ranges = %q, want empty", got)
|
|
}
|
|
wantETag := fmt.Sprintf(`"response-%s"`, encName)
|
|
if got := w.Header().Get("Etag"); got != wantETag {
|
|
t.Fatalf("Etag = %q, want %q", got, wantETag)
|
|
}
|
|
assertDecompresses(t, encCase, w.Body.Bytes(), corpus.data)
|
|
}
|
|
|
|
func checkMinLengthPreventsEncoding(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
|
|
t.Helper()
|
|
|
|
if got := w.Header().Get("Content-Encoding"); got != "" {
|
|
t.Fatalf("Content-Encoding = %q, want empty", got)
|
|
}
|
|
if got := w.Body.String(); got != "short" {
|
|
t.Fatalf("body = %q, want short", got)
|
|
}
|
|
}
|
|
|
|
func checkNotModifiedVary(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
|
|
t.Helper()
|
|
|
|
if got := w.Code; got != http.StatusNotModified {
|
|
t.Fatalf("status = %d, want %d", got, http.StatusNotModified)
|
|
}
|
|
if got := w.Header().Get("Content-Encoding"); got != "" {
|
|
t.Fatalf("Content-Encoding = %q, want empty", got)
|
|
}
|
|
if !encode.HasVaryValue(w.Header(), "Accept-Encoding") {
|
|
t.Fatalf("Vary = %q, want Accept-Encoding", w.Header().Values("Vary"))
|
|
}
|
|
if got := w.Body.Len(); got != 0 {
|
|
t.Fatalf("body length = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func checkHeadEncodedHeaders(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
|
|
t.Helper()
|
|
|
|
if got := w.Header().Get("Content-Encoding"); got != encCase.encoding.AcceptEncoding() {
|
|
t.Fatalf("Content-Encoding = %q, want %q", got, encCase.encoding.AcceptEncoding())
|
|
}
|
|
if got := w.Body.Len(); got != 0 {
|
|
t.Fatalf("body length = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func checkRangeResponseBypassesEncoding(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
|
|
t.Helper()
|
|
|
|
if got := w.Code; got != http.StatusPartialContent {
|
|
t.Fatalf("status = %d, want %d", got, http.StatusPartialContent)
|
|
}
|
|
if got := w.Header().Get("Content-Encoding"); got != "" {
|
|
t.Fatalf("Content-Encoding = %q, want empty", got)
|
|
}
|
|
if got := w.Header().Get("Content-Range"); got != "bytes 0-15/128" {
|
|
t.Fatalf("Content-Range = %q, want %q", got, "bytes 0-15/128")
|
|
}
|
|
if got := w.Header().Get("Accept-Ranges"); got != "bytes" {
|
|
t.Fatalf("Accept-Ranges = %q, want bytes", got)
|
|
}
|
|
if got := w.Body.String(); got != "0123456789abcdef" {
|
|
t.Fatalf("body = %q, want %q", got, "0123456789abcdef")
|
|
}
|
|
}
|
|
|
|
func checkWebSocketBypass(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
|
|
t.Helper()
|
|
|
|
if got := w.Code; got != http.StatusSwitchingProtocols {
|
|
t.Fatalf("status = %d, want %d", got, http.StatusSwitchingProtocols)
|
|
}
|
|
if got := w.Header().Get("Content-Encoding"); got != "" {
|
|
t.Fatalf("Content-Encoding = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func checkStripsEncodedETagSuffix(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
|
|
t.Helper()
|
|
|
|
if got := w.Code; got != http.StatusNotModified {
|
|
t.Fatalf("status = %d, want %d", got, http.StatusNotModified)
|
|
}
|
|
if !encode.HasVaryValue(w.Header(), "Accept-Encoding") {
|
|
t.Fatalf("Vary = %q, want Accept-Encoding", w.Header().Values("Vary"))
|
|
}
|
|
}
|
|
|
|
func conformanceLargeBodyHandler(contentType string) caddyhttp.Handler {
|
|
body := conformanceLargeBody()
|
|
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
w.Header().Set("Content-Type", contentType)
|
|
_, err := w.Write(body)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func checkBypassesEncoding(t *testing.T, w *httptest.ResponseRecorder, encCase encoderCase) {
|
|
t.Helper()
|
|
|
|
want := conformanceLargeBody()
|
|
if got := w.Header().Get("Content-Encoding"); got != "" {
|
|
t.Fatalf("Content-Encoding = %q, want empty", got)
|
|
}
|
|
if !bytes.Equal(w.Body.Bytes(), want) {
|
|
t.Fatalf("body len = %d, want len = %d", w.Body.Len(), len(want))
|
|
}
|
|
}
|