@marcel91200 / caddy Mirror · read-only
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 01:00 UTC

master
go 512 lines 15.3 KB
Raw
1 package encode
2
3 import (
4 "context"
5 "io"
6 "net/http"
7 "net/http/httptest"
8 "slices"
9 "sync"
10 "testing"
11
12 "github.com/caddyserver/caddy/v2"
13 "github.com/caddyserver/caddy/v2/modules/caddyhttp"
14 )
15
16 func BenchmarkOpenResponseWriter(b *testing.B) {
17 enc := new(Encode)
18 for b.Loop() {
19 enc.openResponseWriter("test", nil, false)
20 }
21 }
22
23 // discardResponseWriter is a minimal http.ResponseWriter used to isolate
24 // WriteHeader's own cost from a real transport.
25 type discardResponseWriter struct {
26 header http.Header
27 }
28
29 func (w *discardResponseWriter) Header() http.Header { return w.header }
30 func (w *discardResponseWriter) Write(p []byte) (int, error) { return len(p), nil }
31 func (w *discardResponseWriter) WriteHeader(int) {}
32
33 // BenchmarkResponseWriterWriteHeader covers the branches inside WriteHeader:
34 // the plain/common case, the SSE Content-Type check (both when it doesn't
35 // match and when it does and rw.init() runs), CONNECT 2xx, informational
36 // (1xx), and 304 Not Modified (Vary bookkeeping).
37 func BenchmarkResponseWriterWriteHeader(b *testing.B) {
38 benchCases := []struct {
39 name string
40 encoding string
41 isConnect bool
42 status int
43 contentType string
44 }{
45 {name: "plain", encoding: "test", status: http.StatusOK},
46 {name: "html", encoding: "test", status: http.StatusOK, contentType: "text/html; charset=utf-8"},
47 {name: "event-stream", encoding: "gzip", status: http.StatusOK, contentType: "text/event-stream"},
48 {name: "connect", encoding: "test", isConnect: true, status: http.StatusOK},
49 {name: "informational", encoding: "test", status: http.StatusEarlyHints},
50 {name: "not-modified", encoding: "test", status: http.StatusNotModified},
51 }
52
53 for _, bc := range benchCases {
54 b.Run(bc.name, func(b *testing.B) {
55 enc := new(Encode)
56 if bc.name == "event-stream" {
57 enc.writerPools = map[string]*sync.Pool{
58 "gzip": {New: func() any { return mockEncoder{} }},
59 }
60 ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
61 defer cancel()
62 if err := enc.Provision(ctx); err != nil {
63 b.Fatalf("Provision() error = %v", err)
64 }
65 }
66
67 w := &discardResponseWriter{header: make(http.Header)}
68 rw := enc.openResponseWriter(bc.encoding, w, bc.isConnect)
69
70 for b.Loop() {
71 for k := range rw.Header() {
72 delete(rw.Header(), k)
73 }
74 if bc.contentType != "" {
75 rw.Header().Set("Content-Type", bc.contentType)
76 }
77 rw.wroteHeader = false
78 rw.statusCode = 0
79 rw.disabled = false
80 rw.w = nil
81 rw.WriteHeader(bc.status)
82 }
83 })
84 }
85 }
86
87 func TestIsSSE(t *testing.T) {
88 for _, tc := range []struct {
89 contentType string
90 want bool
91 }{
92 {"", false},
93 {"text/plain", false},
94 {"text/event-stream", true},
95 {"Text/Event-Stream", true},
96 {"text/event-stream; charset=utf-8", true},
97 {"text/event-stream ; charset=utf-8", true},
98 {"text/event-stream ", true},
99 {"text/event-streamfoo", false},
100 {"text/event-stream nonsense", false},
101 {"text/event-stream x; charset=utf-8", false},
102 } {
103 if got := isSSE(tc.contentType); got != tc.want {
104 t.Errorf("isSSE(%q) = %v, want %v", tc.contentType, got, tc.want)
105 }
106 }
107 }
108
109 func TestPreferOrder(t *testing.T) {
110 testCases := []struct {
111 name string
112 accept string
113 prefer []string
114 expected []string
115 }{
116 {
117 name: "PreferOrder(): 4 accept, 3 prefer",
118 accept: "deflate, gzip, br, zstd",
119 prefer: []string{"zstd", "br", "gzip"},
120 expected: []string{"zstd", "br", "gzip", "deflate"},
121 },
122 {
123 name: "PreferOrder(): 2 accept, 3 prefer",
124 accept: "deflate, zstd",
125 prefer: []string{"zstd", "br", "gzip"},
126 expected: []string{"zstd", "deflate"},
127 },
128 {
129 name: "PreferOrder(): 2 accept (1 empty), 3 prefer",
130 accept: "gzip,,zstd",
131 prefer: []string{"zstd", "br", "gzip"},
132 expected: []string{"zstd", "gzip"},
133 },
134 {
135 name: "PreferOrder(): 1 accept, 2 prefer",
136 accept: "gzip",
137 prefer: []string{"zstd", "gzip"},
138 expected: []string{"gzip"},
139 },
140 {
141 name: "PreferOrder(): 4 accept (1 duplicate), 1 prefer",
142 accept: "deflate, gzip, br, br",
143 prefer: []string{"br"},
144 expected: []string{"br", "br", "deflate", "gzip"},
145 },
146 {
147 name: "PreferOrder(): empty accept, 0 prefer",
148 accept: "",
149 prefer: []string{},
150 expected: []string{},
151 },
152 {
153 name: "PreferOrder(): empty accept, 1 prefer",
154 accept: "",
155 prefer: []string{"gzip"},
156 expected: []string{},
157 },
158 {
159 name: "PreferOrder(): with q-factor",
160 accept: "deflate;q=0.8, gzip;q=0.4, br;q=0.2, zstd",
161 prefer: []string{"gzip"},
162 expected: []string{"zstd", "deflate", "gzip", "br"},
163 },
164 {
165 name: "PreferOrder(): with q-factor, no prefer",
166 accept: "deflate;q=0.8, gzip;q=0.4, br;q=0.2, zstd",
167 prefer: []string{},
168 expected: []string{"zstd", "deflate", "gzip", "br"},
169 },
170 {
171 name: "PreferOrder(): q-factor=0 filtered out",
172 accept: "deflate;q=0.1, gzip;q=0.4, br;q=0.5, zstd;q=0",
173 prefer: []string{"gzip"},
174 expected: []string{"br", "gzip", "deflate"},
175 },
176 {
177 name: "PreferOrder(): q-factor=0 filtered out, no prefer",
178 accept: "deflate;q=0.1, gzip;q=0.4, br;q=0.5, zstd;q=0",
179 prefer: []string{},
180 expected: []string{"br", "gzip", "deflate"},
181 },
182 {
183 name: "PreferOrder(): with invalid q-factor",
184 accept: "br, deflate, gzip;q=2, zstd;q=0.1",
185 prefer: []string{"zstd", "gzip"},
186 expected: []string{"gzip", "br", "deflate", "zstd"},
187 },
188 {
189 name: "PreferOrder(): with invalid q-factor, no prefer",
190 accept: "br, deflate, gzip;q=2, zstd;q=0.1",
191 prefer: []string{},
192 expected: []string{"br", "deflate", "gzip", "zstd"},
193 },
194 }
195
196 enc := new(Encode)
197 r, _ := http.NewRequest("", "", nil)
198
199 for _, test := range testCases {
200 t.Run(test.name, func(t *testing.T) {
201 if test.accept == "" {
202 r.Header.Del("Accept-Encoding")
203 } else {
204 r.Header.Set("Accept-Encoding", test.accept)
205 }
206 enc.Prefer = test.prefer
207 result := AcceptedEncodings(r, enc.Prefer)
208 if !slices.Equal(result, test.expected) {
209 t.Errorf("AcceptedEncodings() actual: %s expected: %s",
210 result,
211 test.expected)
212 }
213 })
214 }
215 }
216
217 func TestValidate(t *testing.T) {
218 type testCase struct {
219 name string
220 prefer []string
221 wantErr bool
222 }
223
224 var err error
225 var testCases []testCase
226 enc := new(Encode)
227
228 enc.writerPools = map[string]*sync.Pool{
229 "zstd": nil,
230 "gzip": nil,
231 "br": nil,
232 }
233 testCases = []testCase{
234 {
235 name: "ValidatePrefer (zstd, gzip & br enabled): valid order with all encoder",
236 prefer: []string{"zstd", "br", "gzip"},
237 wantErr: false,
238 },
239 {
240 name: "ValidatePrefer (zstd, gzip & br enabled): valid order with 2 out of 3 encoders",
241 prefer: []string{"br", "gzip"},
242 wantErr: false,
243 },
244 {
245 name: "ValidatePrefer (zstd, gzip & br enabled): valid order with 1 out of 3 encoders",
246 prefer: []string{"gzip"},
247 wantErr: false,
248 },
249 {
250 name: "ValidatePrefer (zstd, gzip & br enabled): 1 duplicated (once) encoder",
251 prefer: []string{"gzip", "zstd", "gzip"},
252 wantErr: true,
253 },
254 {
255 name: "ValidatePrefer (zstd, gzip & br enabled): 1 not enabled encoder in prefer list",
256 prefer: []string{"br", "zstd", "gzip", "deflate"},
257 wantErr: true,
258 },
259 {
260 name: "ValidatePrefer (zstd, gzip & br enabled): no prefer list",
261 prefer: []string{},
262 wantErr: false,
263 },
264 }
265
266 for _, test := range testCases {
267 t.Run(test.name, func(t *testing.T) {
268 enc.Prefer = test.prefer
269 err = enc.Validate()
270 if (err != nil) != test.wantErr {
271 t.Errorf("Validate() error = %v, wantErr = %v", err, test.wantErr)
272 }
273 })
274 }
275
276 enc.writerPools = map[string]*sync.Pool{
277 "zstd": nil,
278 "gzip": nil,
279 }
280 testCases = []testCase{
281 {
282 name: "ValidatePrefer (zstd & gzip enabled): 1 not enabled encoder in prefer list",
283 prefer: []string{"zstd", "br", "gzip"},
284 wantErr: true,
285 },
286 {
287 name: "ValidatePrefer (zstd & gzip enabled): 2 not enabled encoder in prefer list",
288 prefer: []string{"br", "zstd", "gzip", "deflate"},
289 wantErr: true,
290 },
291 {
292 name: "ValidatePrefer (zstd & gzip enabled): only not enabled encoder in prefer list",
293 prefer: []string{"deflate", "br", "gzip"},
294 wantErr: true,
295 },
296 {
297 name: "ValidatePrefer (zstd & gzip enabled): 1 duplicated (once) encoder in prefer list",
298 prefer: []string{"gzip", "zstd", "gzip"},
299 wantErr: true,
300 },
301 {
302 name: "ValidatePrefer (zstd & gzip enabled): 1 duplicated (twice) encoder in prefer list",
303 prefer: []string{"gzip", "zstd", "gzip", "gzip"},
304 wantErr: true,
305 },
306 {
307 name: "ValidatePrefer (zstd & gzip enabled): 1 duplicated encoder in prefer list",
308 prefer: []string{"zstd", "zstd", "gzip", "gzip"},
309 wantErr: true,
310 },
311 {
312 name: "ValidatePrefer (zstd & gzip enabled): 1 duplicated not enabled encoder in prefer list",
313 prefer: []string{"br", "br", "gzip"},
314 wantErr: true,
315 },
316 {
317 name: "ValidatePrefer (zstd & gzip enabled): 2 duplicated not enabled encoder in prefer list",
318 prefer: []string{"br", "deflate", "br", "deflate"},
319 wantErr: true,
320 },
321 {
322 name: "ValidatePrefer (zstd & gzip enabled): valid order zstd first",
323 prefer: []string{"zstd", "gzip"},
324 wantErr: false,
325 },
326 {
327 name: "ValidatePrefer (zstd & gzip enabled): valid order gzip first",
328 prefer: []string{"gzip", "zstd"},
329 wantErr: false,
330 },
331 }
332
333 for _, test := range testCases {
334 t.Run(test.name, func(t *testing.T) {
335 enc.Prefer = test.prefer
336 err = enc.Validate()
337 if (err != nil) != test.wantErr {
338 t.Errorf("Validate() error = %v, wantErr = %v", err, test.wantErr)
339 }
340 })
341 }
342 }
343
344 func TestServeHTTPResponseNoTransform(t *testing.T) {
345 enc := &Encode{
346 MinLength: 1,
347 writerPools: map[string]*sync.Pool{
348 "gzip": {New: func() any { return mockEncoder{} }},
349 },
350 }
351 ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
352 defer cancel()
353 if err := enc.Provision(ctx); err != nil {
354 t.Fatalf("Provision failed: %v", err)
355 }
356
357 for _, tc := range []struct {
358 cacheControl []string
359 encoded bool
360 }{
361 {cacheControl: nil, encoded: true},
362 {cacheControl: []string{"no-cache, no-transform"}, encoded: false},
363 {cacheControl: []string{"max-age=0, No-Transform"}, encoded: false},
364 {cacheControl: []string{"no-cache", "no-transform"}, encoded: false},
365 {cacheControl: []string{`private="Set-Cookie, Vary", no-transform`}, encoded: false},
366 // extension values and longer tokens are not the directive
367 {cacheControl: []string{`community="no-transform"`}, encoded: true},
368 {cacheControl: []string{"community=no-transform"}, encoded: true},
369 {cacheControl: []string{"x-no-transform-ext, no-transformation"}, encoded: true},
370 // semicolons are not Cache-Control list separators
371 {cacheControl: []string{"no-cache; no-transform"}, encoded: true},
372 } {
373 r := httptest.NewRequest(http.MethodGet, "/", nil)
374 r.Header.Set("Accept-Encoding", "gzip")
375 w := httptest.NewRecorder()
376 err := enc.ServeHTTP(w, r, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
377 w.Header()["Content-Type"] = []string{"text/plain"}
378 w.Header()["Cache-Control"] = tc.cacheControl
379 _, err := w.Write([]byte("hello"))
380 return err
381 }))
382 if err != nil {
383 t.Fatalf("Cache-Control %q: ServeHTTP: %v", tc.cacheControl, err)
384 }
385 if encoded := w.Header().Get("Content-Encoding") == "gzip"; encoded != tc.encoded {
386 t.Errorf("Cache-Control %q: encoded = %t, want %t", tc.cacheControl, encoded, tc.encoded)
387 }
388 }
389 }
390
391 type mockEncoder struct{}
392
393 func (mockEncoder) Write(p []byte) (n int, err error) { return len(p), nil }
394 func (mockEncoder) Close() error { return nil }
395 func (mockEncoder) Reset(w io.Writer) {}
396 func (mockEncoder) Flush() error { return nil }
397
398 func TestServeHTTPDefaultEncodingPreference(t *testing.T) {
399 enc := new(Encode)
400 enc.MinLength = 1 // compress everything
401 enc.writerPools = map[string]*sync.Pool{
402 "gzip": {
403 New: func() any { return mockEncoder{} },
404 },
405 "zstd": {
406 New: func() any { return mockEncoder{} },
407 },
408 }
409
410 // Call Provision() with a valid caddy.Context to exercise the real path
411 ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
412 defer cancel()
413 if err := enc.Provision(ctx); err != nil {
414 t.Fatalf("Provision failed: %v", err)
415 }
416
417 // Test default preference: zstd preferred over gzip
418 r, err := http.NewRequest("GET", "/", nil)
419 if err != nil {
420 t.Fatalf("error creating request: %v", err)
421 }
422 r.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
423
424 w := httptest.NewRecorder()
425 w.Header().Set("Content-Type", "text/plain")
426
427 next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
428 w.WriteHeader(http.StatusOK)
429 _, err := w.Write([]byte("Hello, world! This is a long enough string to satisfy min length if it wasn't 1."))
430 return err
431 })
432
433 err = enc.ServeHTTP(w, r, next)
434 if err != nil {
435 t.Fatalf("ServeHTTP returned error: %v", err)
436 }
437
438 // ETag suffix or Content-Encoding header should reflect zstd
439 contentEncoding := w.Header().Get("Content-Encoding")
440 if contentEncoding != "zstd" {
441 t.Errorf("Expected Content-Encoding to be 'zstd' by default, got '%s'", contentEncoding)
442 }
443
444 // Test explicit user preference: gzip over zstd
445 enc.Prefer = []string{"gzip", "zstd"}
446
447 w2 := httptest.NewRecorder()
448 w2.Header().Set("Content-Type", "text/plain")
449 err = enc.ServeHTTP(w2, r, next)
450 if err != nil {
451 t.Fatalf("ServeHTTP returned error: %v", err)
452 }
453
454 contentEncoding2 := w2.Header().Get("Content-Encoding")
455 if contentEncoding2 != "gzip" {
456 t.Errorf("Expected Content-Encoding to be 'gzip' when explicitly preferred, got '%s'", contentEncoding2)
457 }
458 }
459
460 // RFC 9110 section 5.3: a recipient MAY combine multiple field lines with the
461 // same name by joining their values with commas; the semantics of a list-based
462 // field like Accept-Encoding do not change when it is split across lines.
463 func TestAcceptedEncodingsMultipleFieldLines(t *testing.T) {
464 for _, tc := range []struct {
465 lines []string
466 expected []string
467 }{
468 {[]string{"gzip", "br"}, []string{"br", "gzip"}},
469 {[]string{"identity", "br"}, []string{"br", "identity"}},
470 {[]string{"gzip;q=0", "br"}, []string{"br"}},
471 {[]string{"", "gzip"}, []string{"gzip"}},
472 {[]string{"gzip;q=0.5", "zstd;q=0.8, br;q=0"}, []string{"zstd", "gzip"}},
473 } {
474 r, _ := http.NewRequest(http.MethodGet, "/", nil)
475 for _, l := range tc.lines {
476 r.Header.Add("Accept-Encoding", l)
477 }
478 got := AcceptedEncodings(r, []string{"zstd", "br", "gzip"})
479 if !slices.Equal(got, tc.expected) {
480 t.Errorf("Accept-Encoding lines %q: got %v, expected %v", tc.lines, got, tc.expected)
481 }
482 }
483 }
484
485 func TestServeHTTPMultipleAcceptEncodingLines(t *testing.T) {
486 enc := new(Encode)
487 enc.MinLength = 1
488 enc.writerPools = map[string]*sync.Pool{
489 "br": {New: func() any { return mockEncoder{} }},
490 }
491 ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
492 defer cancel()
493 if err := enc.Provision(ctx); err != nil {
494 t.Fatal(err)
495 }
496 r, _ := http.NewRequest(http.MethodGet, "/", nil)
497 r.Header.Add("Accept-Encoding", "identity")
498 r.Header.Add("Accept-Encoding", "br")
499 w := httptest.NewRecorder()
500 w.Header().Set("Content-Type", "text/plain")
501 next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
502 w.WriteHeader(http.StatusOK)
503 _, err := w.Write([]byte("hello world"))
504 return err
505 })
506 if err := enc.ServeHTTP(w, r, next); err != nil {
507 t.Fatal(err)
508 }
509 if ce := w.Header().Get("Content-Encoding"); ce != "br" {
510 t.Errorf("expected Content-Encoding br, got %q", ce)
511 }
512 }