mirror of
https://github.com/caddyserver/caddy.git
synced 2026-06-27 20:31:47 +00:00
encode: add standard benchmark and conformance harness (#7804)
Some checks are pending
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Waiting to run
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Waiting to run
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Waiting to run
Tests / test (s390x on IBM Z) (push) Waiting to run
Tests / goreleaser-check (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, aix) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, linux) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, windows) (push) Waiting to run
Lint / lint (push) Waiting to run
Lint / lint-1 (push) Waiting to run
Lint / lint-2 (push) Waiting to run
Lint / govulncheck (push) Waiting to run
Lint / dependency-review (push) Waiting to run
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Some checks are pending
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Waiting to run
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Waiting to run
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Waiting to run
Tests / test (s390x on IBM Z) (push) Waiting to run
Tests / goreleaser-check (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, aix) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, linux) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, windows) (push) Waiting to run
Lint / lint (push) Waiting to run
Lint / lint-1 (push) Waiting to run
Lint / lint-2 (push) Waiting to run
Lint / govulncheck (push) Waiting to run
Lint / dependency-review (push) Waiting to run
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
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
This commit is contained in:
parent
0f7f8e9cf6
commit
997d3f6b0a
12 changed files with 5128 additions and 6 deletions
|
|
@ -251,13 +251,16 @@ type responseWriter struct {
|
|||
statusCode int
|
||||
wroteHeader bool
|
||||
isConnect bool
|
||||
disabled bool // disable encoding (for error responses)
|
||||
disabled bool // disable encoding for this response
|
||||
}
|
||||
|
||||
// WriteHeader stores the status to write when the time comes
|
||||
// to actually write the header.
|
||||
func (rw *responseWriter) WriteHeader(status int) {
|
||||
rw.statusCode = status
|
||||
if status == http.StatusPartialContent {
|
||||
rw.disabled = true // partial representations must not be dynamically re-encoded
|
||||
}
|
||||
|
||||
// See #5849 and RFC 9110 section 15.4.5 (https://www.rfc-editor.org/rfc/rfc9110.html#section-15.4.5) - 304
|
||||
// Not Modified must have certain headers set as if it was a 200 response, and according to the issue
|
||||
|
|
@ -444,8 +447,7 @@ func (rw *responseWriter) Unwrap() http.ResponseWriter {
|
|||
|
||||
// init should be called before we write a response, if rw.buf has contents.
|
||||
func (rw *responseWriter) init() {
|
||||
// Don't initialize encoder for error responses
|
||||
// This prevents response corruption when handle_errors is used
|
||||
// Don't initialize encoder for responses that must not be encoded.
|
||||
if rw.disabled {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
169
modules/caddyhttp/encode/encode_bench_test.go
Normal file
169
modules/caddyhttp/encode/encode_bench_test.go
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
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 (
|
||||
benchmarkParallelism = 4
|
||||
handlerBenchWarmupIterations = 5
|
||||
)
|
||||
|
||||
// BenchmarkStandardEncodingPayloads measures raw encoder throughput (NewEncoder → Write → Close)
|
||||
// across the standard HTML/JSON/JS/CSS payloads and gzip/zstd compression levels.
|
||||
// Each subtest runs with 4 parallel workers (SetParallelism).
|
||||
func BenchmarkStandardEncodingPayloads(b *testing.B) {
|
||||
forEachBenchmarkCase(b, func(b *testing.B, corpus benchmarkCorpus, encCase encoderCase) {
|
||||
benchmarkEncode(b, corpus.data, encCase.encoding)
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkEncodeHandlerCorpus measures the full encode middleware path (ServeHTTP,
|
||||
// responseWriter, writer pools) using the same payload and level grid.
|
||||
func BenchmarkEncodeHandlerCorpus(b *testing.B) {
|
||||
forEachBenchmarkCase(b, func(b *testing.B, corpus benchmarkCorpus, encCase encoderCase) {
|
||||
enc := newEncodeHandler(b, encCase, 1)
|
||||
benchmarkEncodeHandler(b, enc, encCase, corpus)
|
||||
})
|
||||
}
|
||||
|
||||
func forEachBenchmarkCase(b *testing.B, fn func(b *testing.B, corpus benchmarkCorpus, encCase encoderCase)) {
|
||||
for _, corpus := range benchmarkCorpora(b) {
|
||||
for _, encCase := range benchmarkEncoderCases(b) {
|
||||
b.Run(benchmarkSubtestName(corpus.name, encCase), func(b *testing.B) {
|
||||
fn(b, corpus, encCase)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func benchmarkSubtestName(corpus string, encCase encoderCase) string {
|
||||
return fmt.Sprintf("payload-%s/encoder-%s/compress-level-%s",
|
||||
corpus, encCase.encoder, encCase.level)
|
||||
}
|
||||
|
||||
func benchmarkEncode(b *testing.B, payload []byte, encoding encode.Encoding) {
|
||||
b.Helper()
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(payload)))
|
||||
b.SetParallelism(benchmarkParallelism)
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
encoder := encoding.NewEncoder()
|
||||
var dst bytes.Buffer
|
||||
for pb.Next() {
|
||||
dst.Reset()
|
||||
encoder.Reset(&dst)
|
||||
if _, err := encoder.Write(payload); err != nil {
|
||||
b.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
if err := encoder.Close(); err != nil {
|
||||
b.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func benchmarkEncodeHandler(b *testing.B, enc *encode.Encode, encCase encoderCase, corpus benchmarkCorpus) {
|
||||
b.Helper()
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(corpus.data)))
|
||||
b.SetParallelism(benchmarkParallelism)
|
||||
|
||||
next := corpusHandler(corpus)
|
||||
w := newBenchmarkResponseWriter()
|
||||
r := newHandlerBenchRequest(encCase)
|
||||
warmupEncodeHandler(enc, w, r, next)
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
w := newBenchmarkResponseWriter()
|
||||
r := newHandlerBenchRequest(encCase)
|
||||
for pb.Next() {
|
||||
w.reset()
|
||||
if err := enc.ServeHTTP(w, r, next); err != nil {
|
||||
b.Fatalf("ServeHTTP() error = %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newHandlerBenchRequest(encCase encoderCase) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("Accept-Encoding", encCase.encoding.AcceptEncoding())
|
||||
return r
|
||||
}
|
||||
|
||||
func warmupEncodeHandler(enc *encode.Encode, w *benchmarkResponseWriter, r *http.Request, next caddyhttp.Handler) {
|
||||
for range handlerBenchWarmupIterations {
|
||||
w.reset()
|
||||
if err := enc.ServeHTTP(w, r, next); err != nil {
|
||||
panic("warmup ServeHTTP: " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// benchmarkResponseWriter is a resettable http.ResponseWriter for handler benchmarks.
|
||||
// httptest.ResponseRecorder cannot be safely reused because it keeps unexported state.
|
||||
type benchmarkResponseWriter struct {
|
||||
header http.Header
|
||||
code int
|
||||
body bytes.Buffer
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func newBenchmarkResponseWriter() *benchmarkResponseWriter {
|
||||
return &benchmarkResponseWriter{
|
||||
header: make(http.Header),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *benchmarkResponseWriter) reset() {
|
||||
w.code = 0
|
||||
w.wroteHeader = false
|
||||
w.body.Reset()
|
||||
for k := range w.header {
|
||||
delete(w.header, k)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *benchmarkResponseWriter) Header() http.Header {
|
||||
return w.header
|
||||
}
|
||||
|
||||
func (w *benchmarkResponseWriter) Write(p []byte) (int, error) {
|
||||
if !w.wroteHeader {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return w.body.Write(p)
|
||||
}
|
||||
|
||||
func (w *benchmarkResponseWriter) WriteHeader(statusCode int) {
|
||||
if w.wroteHeader {
|
||||
return
|
||||
}
|
||||
w.code = statusCode
|
||||
w.wroteHeader = true
|
||||
}
|
||||
|
||||
func (w *benchmarkResponseWriter) Flush() {
|
||||
if !w.wroteHeader {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func corpusHandler(corpus benchmarkCorpus) caddyhttp.Handler {
|
||||
return caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
||||
w.Header().Set("Content-Type", corpus.contentType)
|
||||
_, err := w.Write(corpus.data)
|
||||
return err
|
||||
})
|
||||
}
|
||||
400
modules/caddyhttp/encode/encode_conformance_test.go
Normal file
400
modules/caddyhttp/encode/encode_conformance_test.go
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
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))
|
||||
}
|
||||
}
|
||||
224
modules/caddyhttp/encode/encode_harness_test.go
Normal file
224
modules/caddyhttp/encode/encode_harness_test.go
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
// Package encode_test provides the standard encode benchmark and conformance suite
|
||||
// for Caddy's gzip and zstd encoder modules.
|
||||
//
|
||||
// Run encoder-level benchmarks (direct NewEncoder calls):
|
||||
//
|
||||
// go test -bench=BenchmarkStandardEncodingPayloads -benchmem ./modules/caddyhttp/encode/
|
||||
//
|
||||
// Run middleware-level benchmarks (Encode.ServeHTTP, writer pools, responseWriter):
|
||||
//
|
||||
// go test -bench=BenchmarkEncodeHandlerCorpus -benchmem ./modules/caddyhttp/encode/
|
||||
//
|
||||
// Benchmark subtest names:
|
||||
//
|
||||
// payload-{html|json|js|css}/encoder-{gzip|zstd}/compress-level-{N|fastest|...}
|
||||
//
|
||||
// Each subtest uses 4 parallel workers (benchmarkParallelism in encode_bench_test.go).
|
||||
// Go may append -{GOMAXPROCS} to the printed benchmark name; ignore it when comparing runs.
|
||||
//
|
||||
// Grid: 4 payloads × 6 compress levels (gzip 1/5/9, zstd fastest/default/best) = 24 subtests
|
||||
// per benchmark function (48 total with encoder + handler).
|
||||
//
|
||||
// Run conformance tests (Reset/Flush/Close, Vary, ETag, 304/HEAD/range/WebSocket, minimum_length):
|
||||
//
|
||||
// go test -run='TestStandardEncoderContract|TestEncodeCorpusResponse|TestEncodeResponseSemantics' ./modules/caddyhttp/encode/
|
||||
//
|
||||
// Conformance also covers Cache-Control no-transform, content-type matcher rejection,
|
||||
// and encoder Reset-after-Close reuse (pool pattern).
|
||||
package encode_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
stdgzip "compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig"
|
||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
|
||||
caddygzip "github.com/caddyserver/caddy/v2/modules/caddyhttp/encode/gzip"
|
||||
caddyzstd "github.com/caddyserver/caddy/v2/modules/caddyhttp/encode/zstd"
|
||||
)
|
||||
|
||||
// benchmarkCorpus is a fixed payload used by both benchmarks and conformance tests.
|
||||
type benchmarkCorpus struct {
|
||||
name string
|
||||
data []byte
|
||||
contentType string
|
||||
}
|
||||
|
||||
var (
|
||||
benchmarkGzipLevels = []int{1, 5, 9}
|
||||
benchmarkZstdLevels = []string{"fastest", "default", "best"}
|
||||
)
|
||||
|
||||
type encoderCase struct {
|
||||
name string // conformance subtest label, e.g. gzip-level-5
|
||||
encoder string // gzip or zstd
|
||||
level string // gzip numeric level or zstd level name
|
||||
encoding encode.Encoding
|
||||
decompress func([]byte) ([]byte, error)
|
||||
contentType string
|
||||
}
|
||||
|
||||
func benchmarkCorpora(tb testing.TB) []benchmarkCorpus {
|
||||
tb.Helper()
|
||||
|
||||
return []benchmarkCorpus{
|
||||
{name: "html", data: readBenchmarkPayload(tb, "testdata/caddy_home.html"), contentType: "text/html; charset=utf-8"},
|
||||
{name: "json", data: readBenchmarkPayload(tb, "testdata/caddy_config_http_servers.json"), contentType: "application/json"},
|
||||
{name: "js", data: readBenchmarkPayload(tb, "testdata/caddy_asciinema_player.js"), contentType: "application/javascript"},
|
||||
{name: "css", data: readBenchmarkPayload(tb, "testdata/caddy_asciinema_player.css"), contentType: "text/css"},
|
||||
}
|
||||
}
|
||||
|
||||
func readBenchmarkPayload(tb testing.TB, filename string) []byte {
|
||||
tb.Helper()
|
||||
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
tb.Fatalf("reading benchmark payload %s: %v", filename, err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// conformanceLargeBody returns a payload large enough to exceed default minimum_length.
|
||||
func conformanceLargeBody() []byte {
|
||||
data, err := os.ReadFile("testdata/caddy_home.html")
|
||||
if err != nil {
|
||||
panic("conformanceLargeBody: " + err.Error())
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func standardEncoderCases(t testing.TB) []encoderCase {
|
||||
t.Helper()
|
||||
return provisionEncoderCases(t, []int{5}, []string{"default"})
|
||||
}
|
||||
|
||||
func benchmarkEncoderCases(t testing.TB) []encoderCase {
|
||||
t.Helper()
|
||||
return provisionEncoderCases(t, benchmarkGzipLevels, benchmarkZstdLevels)
|
||||
}
|
||||
|
||||
func provisionEncoderCases(t testing.TB, gzipLevels []int, zstdLevels []string) []encoderCase {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
|
||||
t.Cleanup(cancel)
|
||||
|
||||
var cases []encoderCase
|
||||
for _, level := range gzipLevels {
|
||||
gzipEncoding := &caddygzip.Gzip{Level: level}
|
||||
if err := gzipEncoding.Provision(ctx); err != nil {
|
||||
t.Fatalf("gzip level %d Provision() error = %v", level, err)
|
||||
}
|
||||
cases = append(cases, encoderCase{
|
||||
name: fmt.Sprintf("gzip-level-%d", level),
|
||||
encoder: "gzip",
|
||||
level: fmt.Sprintf("%d", level),
|
||||
encoding: gzipEncoding,
|
||||
decompress: decompressGzip,
|
||||
contentType: "text/plain",
|
||||
})
|
||||
}
|
||||
for _, level := range zstdLevels {
|
||||
zstdEncoding := &caddyzstd.Zstd{Level: level}
|
||||
if err := zstdEncoding.Provision(ctx); err != nil {
|
||||
t.Fatalf("zstd level %q Provision() error = %v", level, err)
|
||||
}
|
||||
cases = append(cases, encoderCase{
|
||||
name: "zstd-level-" + level,
|
||||
encoder: "zstd",
|
||||
level: level,
|
||||
encoding: zstdEncoding,
|
||||
decompress: decompressZstd,
|
||||
contentType: "text/plain",
|
||||
})
|
||||
}
|
||||
return cases
|
||||
}
|
||||
|
||||
func newEncodeHandler(tb testing.TB, encCase encoderCase, minLength int) *encode.Encode {
|
||||
tb.Helper()
|
||||
|
||||
encodingName := encCase.encoding.AcceptEncoding()
|
||||
enc := &encode.Encode{
|
||||
EncodingsRaw: caddy.ModuleMap{
|
||||
encodingName: caddyconfig.JSON(encCase.encoding, nil),
|
||||
},
|
||||
Prefer: []string{encodingName},
|
||||
MinLength: minLength,
|
||||
}
|
||||
ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
|
||||
tb.Cleanup(cancel)
|
||||
if err := enc.Provision(ctx); err != nil {
|
||||
tb.Fatalf("Provision() error = %v", err)
|
||||
}
|
||||
if err := enc.Validate(); err != nil {
|
||||
tb.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
return enc
|
||||
}
|
||||
|
||||
func assertDecompresses(t *testing.T, encCase encoderCase, compressed, original []byte) {
|
||||
t.Helper()
|
||||
|
||||
decompressed, err := encCase.decompress(compressed)
|
||||
if err != nil {
|
||||
t.Fatalf("decompress %s: %v", encCase.name, err)
|
||||
}
|
||||
if !bytes.Equal(decompressed, original) {
|
||||
t.Fatalf("decompressed len = %d, want len = %d", len(decompressed), len(original))
|
||||
}
|
||||
}
|
||||
|
||||
// encodeAndVerifyRoundTrip exercises Write → Flush → Write → Close and verifies
|
||||
// the compressed stream round-trips to original.
|
||||
func encodeAndVerifyRoundTrip(t *testing.T, encCase encoderCase, encoder encode.Encoder, original []byte) {
|
||||
t.Helper()
|
||||
|
||||
var compressed bytes.Buffer
|
||||
encoder.Reset(&compressed)
|
||||
|
||||
if _, err := encoder.Write(original[:len(original)/2]); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
if err := encoder.Flush(); err != nil {
|
||||
t.Fatalf("Flush() error = %v", err)
|
||||
}
|
||||
if compressed.Len() == 0 {
|
||||
t.Fatal("Flush() wrote no compressed bytes")
|
||||
}
|
||||
if _, err := encoder.Write(original[len(original)/2:]); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
if err := encoder.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
|
||||
assertDecompresses(t, encCase, compressed.Bytes(), original)
|
||||
}
|
||||
|
||||
func decompressGzip(compressed []byte) ([]byte, error) {
|
||||
reader, err := stdgzip.NewReader(bytes.NewReader(compressed))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
return io.ReadAll(reader)
|
||||
}
|
||||
|
||||
func decompressZstd(compressed []byte) ([]byte, error) {
|
||||
decoder, err := zstd.NewReader(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer decoder.Close()
|
||||
return decoder.DecodeAll(compressed, nil)
|
||||
}
|
||||
|
|
@ -305,9 +305,9 @@ func TestIsEncodeAllowed(t *testing.T) {
|
|||
type mockEncoder struct{}
|
||||
|
||||
func (mockEncoder) Write(p []byte) (n int, err error) { return len(p), nil }
|
||||
func (mockEncoder) Close() error { return nil }
|
||||
func (mockEncoder) Reset(w io.Writer) {}
|
||||
func (mockEncoder) Flush() error { return nil }
|
||||
func (mockEncoder) Close() error { return nil }
|
||||
func (mockEncoder) Reset(w io.Writer) {}
|
||||
func (mockEncoder) Flush() error { return nil }
|
||||
|
||||
func TestServeHTTPDefaultEncodingPreference(t *testing.T) {
|
||||
enc := new(Encode)
|
||||
|
|
|
|||
8
modules/caddyhttp/encode/export_test.go
Normal file
8
modules/caddyhttp/encode/export_test.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package encode
|
||||
|
||||
import "net/http"
|
||||
|
||||
// HasVaryValue exposes hasVaryValue for external tests in encode_test.
|
||||
func HasVaryValue(hdr http.Header, target string) bool {
|
||||
return hasVaryValue(hdr, target)
|
||||
}
|
||||
7
modules/caddyhttp/encode/testdata/README.md
vendored
Normal file
7
modules/caddyhttp/encode/testdata/README.md
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
These benchmark payloads are snapshots from caddyserver.com, fetched on
|
||||
2026-06-06:
|
||||
|
||||
- `caddy_home.html`: https://caddyserver.com/
|
||||
- `caddy_config_http_servers.json`: https://caddyserver.com/api/docs/config/apps/http/servers
|
||||
- `caddy_asciinema_player.css`: https://caddyserver.com/resources/css/vendor/asciinema-player-3.6.1.css?v=378d6d0
|
||||
- `caddy_asciinema_player.js`: https://caddyserver.com/resources/js/vendor/asciinema-player-3.6.1.min.js?v=378d6d0
|
||||
2836
modules/caddyhttp/encode/testdata/caddy_asciinema_player.css
vendored
Normal file
2836
modules/caddyhttp/encode/testdata/caddy_asciinema_player.css
vendored
Normal file
File diff suppressed because it is too large
Load diff
1
modules/caddyhttp/encode/testdata/caddy_asciinema_player.js
vendored
Normal file
1
modules/caddyhttp/encode/testdata/caddy_asciinema_player.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
modules/caddyhttp/encode/testdata/caddy_config_http_servers.json
vendored
Normal file
1
modules/caddyhttp/encode/testdata/caddy_config_http_servers.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1351
modules/caddyhttp/encode/testdata/caddy_home.html
vendored
Normal file
1351
modules/caddyhttp/encode/testdata/caddy_home.html
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -15,7 +15,10 @@
|
|||
package fileserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
|
|
@ -26,6 +29,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
|
||||
)
|
||||
|
||||
func TestFileHidden(t *testing.T) {
|
||||
|
|
@ -184,3 +188,122 @@ func check_validator_headers(modTime time.Time, expect_headers bool, t *testing.
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecompressedRangeResponse(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "range.txt"), []byte("original response body"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sidecar := gzipBytes(t, []byte("original response body"))
|
||||
if err := os.WriteFile(filepath.Join(root, "range.txt.gz"), sidecar, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fsrv := FileServer{
|
||||
Root: root,
|
||||
CanonicalURIs: new(bool),
|
||||
PrecompressedOrder: []string{"gzip"},
|
||||
}
|
||||
|
||||
ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
|
||||
if err := fsrv.Provision(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fsrv.precompressors = map[string]encode.Precompressed{
|
||||
"gzip": testPrecompressed{encoding: "gzip", suffix: ".gz"},
|
||||
}
|
||||
|
||||
t.Run("full response", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r := newPrecompressedRequest(t, "/range.txt")
|
||||
r.Header.Set("Accept-Encoding", "gzip")
|
||||
|
||||
if err := fsrv.ServeHTTP(w, r, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := w.Code; got != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", got, http.StatusOK)
|
||||
}
|
||||
if got := w.Header().Get("Content-Encoding"); got != "gzip" {
|
||||
t.Fatalf("Content-Encoding = %q, want gzip", got)
|
||||
}
|
||||
if got := w.Header().Get("Content-Length"); got != fmt.Sprintf("%d", len(sidecar)) {
|
||||
t.Fatalf("Content-Length = %q, want %d", got, len(sidecar))
|
||||
}
|
||||
if got := w.Header().Get("Vary"); got != "Accept-Encoding" {
|
||||
t.Fatalf("Vary = %q, want Accept-Encoding", got)
|
||||
}
|
||||
if got := w.Body.Bytes(); !bytes.Equal(got, sidecar) {
|
||||
t.Fatalf("body len = %d, want len = %d", len(got), len(sidecar))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("range response", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r := newPrecompressedRequest(t, "/range.txt")
|
||||
r.Header.Set("Accept-Encoding", "gzip")
|
||||
r.Header.Set("Range", "bytes=2-5")
|
||||
|
||||
if err := fsrv.ServeHTTP(w, r, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := w.Code; got != http.StatusPartialContent {
|
||||
t.Fatalf("status = %d, want %d", got, http.StatusPartialContent)
|
||||
}
|
||||
if got := w.Header().Get("Content-Encoding"); got != "gzip" {
|
||||
t.Fatalf("Content-Encoding = %q, want gzip", got)
|
||||
}
|
||||
wantContentRange := fmt.Sprintf("bytes 2-5/%d", len(sidecar))
|
||||
if got := w.Header().Get("Content-Range"); got != wantContentRange {
|
||||
t.Fatalf("Content-Range = %q, want %q", got, wantContentRange)
|
||||
}
|
||||
if got := w.Header().Get("Content-Length"); got != "4" {
|
||||
t.Fatalf("Content-Length = %q, want 4", got)
|
||||
}
|
||||
if got := w.Header().Get("Vary"); got != "Accept-Encoding" {
|
||||
t.Fatalf("Vary = %q, want Accept-Encoding", got)
|
||||
}
|
||||
if got, want := w.Body.Bytes(), sidecar[2:6]; !bytes.Equal(got, want) {
|
||||
t.Fatalf("body = %x, want %x", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func gzipBytes(t *testing.T, data []byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
if _, err := zw.Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func newPrecompressedRequest(t *testing.T, target string) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
repl := caddy.NewReplacer()
|
||||
ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, repl)
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
type testPrecompressed struct {
|
||||
encoding string
|
||||
suffix string
|
||||
}
|
||||
|
||||
func (p testPrecompressed) AcceptEncoding() string {
|
||||
return p.encoding
|
||||
}
|
||||
|
||||
func (p testPrecompressed) Suffix() string {
|
||||
return p.suffix
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue