Antonio Mika
·
2026-10-09
1package httpcache
2
3import (
4 "log/slog"
5 "net/http"
6 "net/http/httptest"
7 "strconv"
8 "strings"
9 "testing"
10 "time"
11)
12
13/*
14TODO:
15 - Request no-store store-prevention (RFC 9111 §5.2.1.5): verify a no-store request does not populate/update cache for subsequent requests.
16 - Authorization storage/use constraints (RFC 9111 §3.5): authenticated responses should not be reused unless explicitly permitted by response directives.
17 - Vary: * behavior (RFC 9111 §4.1): ensure such responses are not reused for subsequent requests.
18 - Multi-field Vary matching (RFC 9111 §4.1): all nominated request fields must match original request values, not just one.
19 - Age correction with upstream metadata (RFC 9111 §4.2.3, §5.1): test interactions of stored response Date/Age values rather than only local clock delta.
20*/
21
22// TestContext holds shared test state.
23type TestContext struct {
24 t *testing.T
25 handler http.Handler
26 cachedServer *httptest.Server
27}
28
29// NewTestContext creates a test context with a backend and cached server.
30func NewTestContext(t *testing.T, cacheHandler http.Handler) *TestContext {
31 tc := &TestContext{
32 t: t,
33 handler: cacheHandler,
34 }
35
36 tc.cachedServer = httptest.NewServer(tc.handler)
37 t.Cleanup(tc.cachedServer.Close)
38
39 return tc
40}
41
42func (tc *TestContext) Do(req *http.Request) (*http.Response, error) {
43 return http.DefaultClient.Do(req)
44}
45
46func (tc *TestContext) DoWithHeaders(req *http.Request, headers map[string][]string) (*http.Response, error) {
47 reqCopy := req.Clone(req.Context())
48 reqCopy.Header = req.Header.Clone()
49 if reqCopy.Header == nil {
50 reqCopy.Header = make(http.Header)
51 }
52
53 for key, val := range headers {
54 reqCopy.Header.Del(key)
55 for _, v := range val {
56 reqCopy.Header.Add(key, v)
57 }
58 }
59 return http.DefaultClient.Do(reqCopy)
60}
61
62func testCacheValue(afterCreated time.Duration) *CacheValue {
63 return &CacheValue{
64 Header: map[string][]string{},
65 Body: []byte("success"),
66 CreatedAt: time.Now().Add(-afterCreated),
67 }
68}
69
70// RFC 9211 The Cache-Status HTTP Response Header Field
71// https://www.rfc-editor.org/rfc/rfc9211#section-2
72func TestCacheCacheStatus(t *testing.T) {
73 mux := http.NewServeMux()
74 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
75 w.WriteHeader(200)
76 _, _ = w.Write([]byte("success"))
77 })
78
79 logger := slog.Default()
80 handler := NewHttpCache(logger, mux)
81 tc := NewTestContext(t, handler)
82 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
83
84 // first request hits backend
85 resp1, _ := tc.Do(req)
86 if resp1.StatusCode != http.StatusOK {
87 t.Errorf("expected 200, got %d", resp1.StatusCode)
88 }
89 status := resp1.Header.Get("cache-status")
90 if !strings.Contains(status, "miss") {
91 t.Errorf("expected miss, got %s", status)
92 }
93
94 // second request hits cache
95 resp2, _ := tc.Do(req)
96 if resp2.StatusCode != http.StatusOK {
97 t.Errorf("expected 200, got %d", resp2.StatusCode)
98 }
99 status = resp2.Header.Get("cache-status")
100 if !strings.Contains(status, "hit") {
101 t.Errorf("expected hit, got %s", status)
102 }
103}
104
105// RFC 9110 15.1-2 Heuristically Cacheable
106// https://www.rfc-editor.org/rfc/rfc9110#section-15.1-2
107// 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, and 501.
108func TestCacheStatusCode(t *testing.T) {
109 mux := http.NewServeMux()
110 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
111 w.WriteHeader(500)
112 _, _ = w.Write([]byte("boom!"))
113 })
114
115 logger := slog.Default()
116 handler := NewHttpCache(logger, mux)
117 tc := NewTestContext(t, handler)
118 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
119
120 // first request hits backend
121 resp1, _ := tc.Do(req)
122 if resp1.StatusCode != http.StatusInternalServerError {
123 t.Errorf("expected 500, got %d", resp1.StatusCode)
124 }
125 status := resp1.Header.Get("cache-status")
126 if !strings.Contains(status, "miss") {
127 t.Errorf("expected miss, got %s", status)
128 }
129
130 // second request hits backend
131 resp2, _ := tc.Do(req)
132 if resp2.StatusCode != http.StatusInternalServerError {
133 t.Errorf("expected 500, got %d", resp2.StatusCode)
134 }
135 status = resp2.Header.Get("cache-status")
136 if !strings.Contains(status, "miss") {
137 t.Errorf("expected miss, got %s", status)
138 }
139}
140
141// RFC 9111 2.3 Opinion - Only store GET requests
142// https://www.rfc-editor.org/rfc/rfc9111.html#section-2-3
143func TestCacheMethod(t *testing.T) {
144 mux := http.NewServeMux()
145 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
146 w.WriteHeader(200)
147 _, _ = w.Write([]byte("success"))
148 })
149
150 logger := slog.Default()
151 handler := NewHttpCache(logger, mux)
152 tc := NewTestContext(t, handler)
153 req, _ := http.NewRequest("POST", tc.cachedServer.URL+"/test", nil)
154
155 // first request hits backend
156 resp1, _ := tc.Do(req)
157 if resp1.StatusCode != http.StatusOK {
158 t.Errorf("expected 200, got %d", resp1.StatusCode)
159 }
160 status := resp1.Header.Get("cache-status")
161 if !strings.Contains(status, "miss") {
162 t.Errorf("expected miss, got %s", status)
163 }
164
165 // second request hits backend
166 resp2, _ := tc.Do(req)
167 if resp2.StatusCode != http.StatusOK {
168 t.Errorf("expected 200, got %d", resp2.StatusCode)
169 }
170 status = resp2.Header.Get("cache-status")
171 if !strings.Contains(status, "miss") {
172 t.Errorf("expected miss, got %s", status)
173 }
174}
175
176// RFC 9111 3.1 Storing Header and Trailer Fields
177// https://www.rfc-editor.org/rfc/rfc9111.html#section-3.1
178func TestCacheStoringHeaders(t *testing.T) {
179 mux := http.NewServeMux()
180 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
181 w.Header().Set("connection", "idk")
182 w.Header().Set("proxy-authenticate", "idk")
183 w.Header().Set("proxy-authentication-info", "idk")
184 w.Header().Set("proxy-authorization", "idk")
185
186 w.WriteHeader(200)
187 _, _ = w.Write([]byte("success"))
188 })
189
190 logger := slog.Default()
191 handler := NewHttpCache(logger, mux)
192 tc := NewTestContext(t, handler)
193 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
194
195 // first request hits backend
196 resp1, _ := tc.Do(req)
197 if resp1.StatusCode != http.StatusOK {
198 t.Errorf("expected 200, got %d", resp1.StatusCode)
199 }
200 status := resp1.Header.Get("cache-status")
201 if !strings.Contains(status, "miss") {
202 t.Errorf("expected miss, got %s", status)
203 }
204
205 // second request hits cache
206 resp2, _ := tc.Do(req)
207 if resp2.StatusCode != http.StatusOK {
208 t.Errorf("expected 200, got %d", resp2.StatusCode)
209 }
210 headers := []string{
211 resp2.Header.Get("connection"),
212 resp2.Header.Get("proxy-authenticate"),
213 resp2.Header.Get("proxy-authentication-info"),
214 resp2.Header.Get("proxy-authorization"),
215 }
216 for _, hdr := range headers {
217 if hdr != "" {
218 t.Errorf("expected no header, found one: %s", hdr)
219 }
220 }
221}
222
223// RFC 9111 4.1 Vary.
224// https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1
225func TestCacheVary(t *testing.T) {
226 mux := http.NewServeMux()
227 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
228 w.WriteHeader(200)
229 _, _ = w.Write([]byte("success"))
230 })
231
232 logger := slog.Default()
233 handler := NewHttpCache(logger, mux)
234 tc := NewTestContext(t, handler)
235
236 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
237 cacheKey := handler.GetCacheKey(req)
238 cv := testCacheValue(250 * time.Second)
239 cv.Header["Vary"] = []string{"Accept-Encoding"}
240 // VaryRequestHeaders snapshots the request header values that were present
241 // when this entry was cached, keyed by the lowercase header name.
242 cv.VaryRequestHeaders = map[string]string{"accept-encoding": "gzip"}
243 cacheValue := cv.encode()
244 handler.Cache.Add(cacheKey, cacheValue)
245
246 respMatch, _ := tc.DoWithHeaders(req, map[string][]string{
247 "Accept-Encoding": {"gzip"},
248 })
249 status := respMatch.Header.Get("cache-status")
250 if !strings.Contains(status, "hit") {
251 t.Errorf("expected hit, got %s", status)
252 }
253
254 respMisMatch, _ := tc.DoWithHeaders(req, map[string][]string{
255 "Accept-Encoding": {"text/plain"},
256 })
257 status = respMisMatch.Header.Get("cache-status")
258 if !strings.Contains(status, "miss") {
259 t.Errorf("expected miss, got %s", status)
260 }
261}
262
263// RFC 9111 4.3 Validation.
264// https://www.rfc-editor.org/rfc/rfc9111.html#section-4.3
265// Last-Modified ETag If-Match If-None-Match If-Range If-Modified-Since If-Unmodified-Since
266// RFC 9110 13 Conditional Requests.
267// https://www.rfc-editor.org/rfc/rfc9110#section-13
268func TestCacheValidation(t *testing.T) {
269 actual := time.Now().Add(-10 * time.Minute).UTC()
270 actualStr := actual.Format(time.RFC1123)
271 now := time.Now().UTC()
272 nowStr := now.Format(time.RFC1123)
273 early := time.Now().Add(-20 * time.Minute)
274 earlyStr := early.Format(time.RFC1123)
275 tests := []struct {
276 name string
277 link string
278 validationHeader string
279 validationValue string
280 extraHeaders map[string][]string
281 expected string
282 originStatus int
283 expectedStatus int
284 }{
285 {
286 name: "RFC 9110 13.1.2 If-None-Match",
287 link: "https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2",
288 validationHeader: "If-None-Match",
289 validationValue: "\"abc\"",
290 expected: "hit",
291 originStatus: http.StatusOK,
292 expectedStatus: http.StatusOK,
293 },
294 {
295 name: "RFC 9110 13.1.2 If-None-Match Wildcard",
296 link: "https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2",
297 validationHeader: "If-None-Match",
298 validationValue: "*",
299 expected: "hit",
300 originStatus: http.StatusOK,
301 expectedStatus: http.StatusNotModified,
302 },
303 {
304 name: "RFC 9110 13.1.3 If-Modified-Since",
305 link: "https://www.rfc-editor.org/rfc/rfc9110#section-13.1.3",
306 validationHeader: "If-Modified-Since",
307 validationValue: nowStr,
308 expected: "hit",
309 originStatus: http.StatusOK,
310 expectedStatus: http.StatusNotModified,
311 },
312 {
313 name: "RFC 9110 13.1.4 If-Unmodified-Since",
314 link: "https://www.rfc-editor.org/rfc/rfc9110#section-13.1.4",
315 validationHeader: "If-Unmodified-Since",
316 validationValue: earlyStr,
317 expected: "hit",
318 originStatus: http.StatusOK,
319 expectedStatus: http.StatusOK,
320 },
321 {
322 name: "RFC 9110 13.1.5 If-Range Date",
323 link: "https://www.rfc-editor.org/rfc/rfc9110#section-13.1.5",
324 validationHeader: "If-Range",
325 validationValue: nowStr,
326 extraHeaders: map[string][]string{
327 "Range": {"bytes=0-3"},
328 },
329 expected: "hit",
330 originStatus: http.StatusOK,
331 expectedStatus: http.StatusOK,
332 },
333 {
334 name: "RFC 9110 13.1.5 If-Range Date Hit",
335 link: "https://www.rfc-editor.org/rfc/rfc9110#section-13.1.5",
336 validationHeader: "If-Range",
337 validationValue: actualStr,
338 extraHeaders: map[string][]string{
339 "Range": {"bytes=0-3"},
340 },
341 expected: "hit",
342 originStatus: http.StatusOK,
343 expectedStatus: http.StatusOK,
344 },
345 {
346 name: "RFC 9110 13.1.5 If-Range ETag",
347 link: "https://www.rfc-editor.org/rfc/rfc9110#section-13.1.5",
348 validationHeader: "If-Range",
349 validationValue: "\"abc\"",
350 extraHeaders: map[string][]string{
351 "Range": {"bytes=0-3"},
352 },
353 expected: "hit",
354 originStatus: http.StatusOK,
355 expectedStatus: http.StatusOK,
356 },
357 {
358 name: "RFC 9110 13.1.5 If-Range ETag Hit",
359 link: "https://www.rfc-editor.org/rfc/rfc9110#section-13.1.5",
360 validationHeader: "If-Range",
361 validationValue: "\"ccc\"",
362 extraHeaders: map[string][]string{
363 "Range": {"bytes=0-3"},
364 },
365 expected: "hit",
366 originStatus: http.StatusOK,
367 expectedStatus: http.StatusOK,
368 },
369 }
370
371 for _, tt := range tests {
372 mux := http.NewServeMux()
373 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
374 w.WriteHeader(tt.originStatus)
375 })
376
377 logger := slog.Default()
378 handler := NewHttpCache(logger, mux)
379 handler.Ttl = time.Minute * 10
380 tc := NewTestContext(t, handler)
381
382 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
383 cacheKey := handler.GetCacheKey(req)
384 cv := testCacheValue(250 * time.Second)
385 cv.Header["ETag"] = []string{"ccc"}
386 cv.Header["Last-Modified"] = []string{actualStr}
387 cacheValue := cv.encode()
388 handler.Cache.Add(cacheKey, cacheValue)
389
390 t.Run(tt.name, func(t *testing.T) {
391 reqHeaders := map[string][]string{tt.validationHeader: {tt.validationValue}}
392 for key, values := range tt.extraHeaders {
393 reqHeaders[key] = values
394 }
395
396 resp, _ := tc.DoWithHeaders(req, reqHeaders)
397 actual := resp.Header.Get("cache-status")
398 if !strings.Contains(actual, tt.expected) {
399 t.Errorf("expected %s, got %s\n%s", tt.expected, actual, tt.link)
400 }
401 if resp.StatusCode != tt.expectedStatus {
402 t.Errorf("expected status %d, got %d", tt.expectedStatus, resp.StatusCode)
403 }
404 })
405 }
406}
407
408// RFC 9111 5.1 Age.
409// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.1
410// RFC 9111 4.2.3 Calculating Age.
411// https://www.rfc-editor.org/rfc/rfc9111.html#section-4.2.3
412func TestCacheAge(t *testing.T) {
413 mux := http.NewServeMux()
414 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
415 w.WriteHeader(200)
416 _, _ = w.Write([]byte("success"))
417 })
418
419 logger := slog.Default()
420 handler := NewHttpCache(logger, mux)
421 tc := NewTestContext(t, handler)
422
423 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
424 cacheKey := handler.GetCacheKey(req)
425 cacheValue := testCacheValue(250 * time.Second).encode()
426 handler.Cache.Add(cacheKey, cacheValue)
427
428 resp, _ := tc.Do(req)
429 if resp.StatusCode != http.StatusOK {
430 t.Errorf("expected 200, got %d", resp.StatusCode)
431 }
432 age := resp.Header.Get("age")
433 ageNum, err := strconv.Atoi(age)
434 if err != nil {
435 t.Fatalf("invalide age header %s", err)
436 }
437 if ageNum != 251 {
438 t.Errorf("expected 250, got %d", ageNum)
439 }
440}
441
442// RFC 9111 5.2.1 Request Directives
443// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1
444func TestCacheRequestDirectives(t *testing.T) {
445 tests := []struct {
446 name string
447 link string
448 cacheControl string
449 expected string
450 }{
451 {
452 name: "RFC 9111 5.2.1.1 Request Cache-Control: max-age",
453 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.1",
454 cacheControl: "max-age=100",
455 expected: "miss",
456 },
457 {
458 name: "RFC 9111 5.2.1.2 Request Cache-Control: max-stale",
459 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.2",
460 cacheControl: "max-stale=300", // 300 max-stale + 250 age = 550 > 450 freshness
461 expected: "miss",
462 },
463 {
464 name: "RFC 9111 5.2.1.3 Request Cache-Control: min-fresh",
465 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.3",
466 cacheControl: "min-fresh=400", // 600 ttl - 250 age = 350 freshness
467 expected: "miss",
468 },
469 {
470 name: "RFC 9111 5.2.1.4 Request Cache-Control: no-cache",
471 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.4",
472 cacheControl: "no-cache",
473 expected: "miss",
474 },
475 {
476 name: "RFC 9111 5.2.1.5 Request Cache-Control: no-store",
477 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.5",
478 cacheControl: "no-store",
479 // you can reply with the cached version, just cannot store or update the response in
480 // the cache.
481 expected: "hit",
482 },
483 {
484 name: "RFC 9111 5.2.1.6 Request Cache-Control: no-transform",
485 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.6",
486 cacheControl: "no-transform",
487 expected: "miss",
488 },
489 {
490 name: "RFC 9111 5.2.1.7 Request Cache-Control: only-if-cached",
491 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.7",
492 cacheControl: "only-if-cached",
493 expected: "hit",
494 },
495 }
496
497 for _, tt := range tests {
498 mux := http.NewServeMux()
499 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
500 w.WriteHeader(200)
501 _, _ = w.Write([]byte("success"))
502 })
503
504 logger := slog.Default()
505 handler := NewHttpCache(logger, mux)
506 handler.Ttl = time.Minute * 10
507 tc := NewTestContext(t, handler)
508
509 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
510 cacheKey := handler.GetCacheKey(req)
511 cacheValue := testCacheValue(250 * time.Second).encode()
512 handler.Cache.Add(cacheKey, cacheValue)
513
514 t.Run(tt.name, func(t *testing.T) {
515 resp, _ := tc.DoWithHeaders(req, map[string][]string{"Cache-Control": {tt.cacheControl}})
516 actual := resp.Header.Get("cache-status")
517 if !strings.Contains(actual, tt.expected) {
518 t.Errorf("expected %s, got %s\n%s", tt.expected, actual, tt.link)
519 }
520 })
521 }
522}
523
524// RFC 9111 5.2.2 Response Directives
525// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2
526// These tests simply confirm that the response generated from origin server corresponds to the
527// correct cache-control, http status code, and is "revalidated" the correct number of times.
528// It does **not** validate the correct cache control logic like cache using max-age from origin
529// server.
530func TestCacheResponseDirectivesHasCacheControl(t *testing.T) {
531 tests := []struct {
532 name string
533 link string
534 cacheControl string
535 expectedOriginCalls int
536 expectedSecondCacheStatus string
537 }{
538 {
539 name: "RFC 9111 5.2.2.1 Response Cache-Control max-age",
540 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.1",
541 cacheControl: "max-age=100",
542 expectedOriginCalls: 1,
543 expectedSecondCacheStatus: "hit",
544 },
545 {
546 name: "RFC 9111 5.2.2.2 Response Cache-Control must-revalidate",
547 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.2",
548 cacheControl: "must-revalidate",
549 expectedOriginCalls: 1,
550 expectedSecondCacheStatus: "hit",
551 },
552 {
553 name: "RFC 9111 5.2.2.3 Response Cache-Control must-understand",
554 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.3",
555 cacheControl: "must-understand",
556 expectedOriginCalls: 1,
557 expectedSecondCacheStatus: "hit",
558 },
559 {
560 name: "RFC 9111 5.2.2.4 Response Cache-Control no-cache",
561 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.4",
562 cacheControl: "no-cache",
563 expectedOriginCalls: 2,
564 expectedSecondCacheStatus: "miss",
565 },
566 {
567 name: "RFC 9111 5.2.2.5 Response Cache-Control no-store",
568 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.5",
569 cacheControl: "no-store",
570 expectedOriginCalls: 2,
571 expectedSecondCacheStatus: "miss",
572 },
573 {
574 name: "RFC 9111 5.2.2.6 Response Cache-Control no-transform",
575 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.6",
576 cacheControl: "no-transform",
577 expectedOriginCalls: 1,
578 expectedSecondCacheStatus: "hit",
579 },
580 {
581 name: "RFC 9111 5.2.2.7 Response Cache-Control private",
582 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.7",
583 cacheControl: "private",
584 expectedOriginCalls: 2,
585 expectedSecondCacheStatus: "miss", // this is a shared cache, do not store private
586 },
587 {
588 name: "RFC 9111 5.2.2.8 Response Cache-Control proxy-revalidate",
589 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.8",
590 cacheControl: "proxy-revalidate",
591 expectedOriginCalls: 1,
592 expectedSecondCacheStatus: "hit",
593 },
594 {
595 name: "RFC 9111 5.2.2.9 Response Cache-Control public",
596 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.9",
597 cacheControl: "public",
598 expectedOriginCalls: 1,
599 expectedSecondCacheStatus: "hit",
600 },
601 {
602 name: "RFC 9111 5.2.2.10 Response Cache-Control s-maxage",
603 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10",
604 cacheControl: "s-maxage=100",
605 expectedOriginCalls: 1,
606 expectedSecondCacheStatus: "hit",
607 },
608 {
609 name: "RFC 9111 5.2.2.10 Response Cache-Control public+private",
610 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10",
611 cacheControl: "public, s-maxage=100, private",
612 expectedOriginCalls: 2,
613 expectedSecondCacheStatus: "miss", // be restrictive and adhere to private directive
614 },
615 }
616
617 for _, tt := range tests {
618 t.Run(tt.name, func(t *testing.T) {
619 mux := http.NewServeMux()
620 actualOriginCalls := 0
621 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
622 actualOriginCalls += 1
623 w.Header().Set("cache-control", tt.cacheControl)
624 w.WriteHeader(200)
625 _, _ = w.Write([]byte("success"))
626 })
627
628 logger := slog.Default()
629 handler := NewHttpCache(logger, mux)
630 handler.Ttl = time.Minute * 10
631 tc := NewTestContext(t, handler)
632
633 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
634
635 // first request hits backend
636 resp1, _ := tc.Do(req)
637 status := resp1.Header.Get("cache-status")
638 if !strings.Contains(status, "miss") {
639 t.Errorf("expected miss, got %s", status)
640 }
641
642 // second request can be served from cache or forwarded depending on directive
643 resp2, _ := tc.Do(req)
644
645 actualCc := resp2.Header.Get("cache-control")
646 if actualCc != tt.cacheControl {
647 t.Errorf("expected cache-control %s, got %s", tt.cacheControl, actualCc)
648 }
649 status = resp2.Header.Get("cache-status")
650 if tt.expectedSecondCacheStatus != "" && !strings.Contains(status, tt.expectedSecondCacheStatus) {
651 t.Errorf("expected %s, got %s", tt.expectedSecondCacheStatus, status)
652 }
653 if tt.expectedOriginCalls != actualOriginCalls {
654 t.Errorf("expected %d origin calls, got %d", tt.expectedOriginCalls, actualOriginCalls)
655 }
656 })
657 }
658}
659
660// RFC 9111 5.3 Expires
661// https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3
662func TestCacheExpires(t *testing.T) {
663 tests := []struct {
664 name string
665 link string
666 expires string
667 expectedStatus int
668 expectedCacheStatus string
669 }{
670 {
671 name: "RFC 9111 5.3 Expires - future date",
672 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3",
673 expires: time.Now().Add(10 * time.Minute).UTC().Format(http.TimeFormat),
674 expectedStatus: http.StatusOK,
675 expectedCacheStatus: "hit",
676 },
677 {
678 name: "RFC 9111 5.3 Expires - expired response",
679 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3",
680 expires: time.Now().Add(-10 * time.Minute).UTC().Format(http.TimeFormat),
681 expectedStatus: http.StatusOK,
682 expectedCacheStatus: "fwd=uri-miss",
683 },
684 {
685 name: "RFC 9111 5.3 Expires - invalid Expires header",
686 link: "https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3",
687 expires: "not-a-valid-date",
688 expectedStatus: http.StatusOK,
689 expectedCacheStatus: "fwd=uri-miss",
690 },
691 }
692
693 for _, tt := range tests {
694 t.Run(tt.name, func(t *testing.T) {
695 mux := http.NewServeMux()
696 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
697 w.Header().Set("Expires", tt.expires)
698 w.WriteHeader(200)
699 _, _ = w.Write([]byte("success"))
700 })
701
702 logger := slog.Default()
703 handler := NewHttpCache(logger, mux)
704 tc := NewTestContext(t, handler)
705
706 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
707
708 // first request hits backend
709 resp1, _ := tc.Do(req)
710 if resp1.StatusCode != http.StatusOK {
711 t.Errorf("expected 200, got %d", resp1.StatusCode)
712 }
713
714 // second request behavior depends on Expires header
715 resp2, _ := tc.Do(req)
716 status := resp2.Header.Get("cache-status")
717 if !strings.Contains(status, tt.expectedCacheStatus) {
718 t.Errorf("expected %s, got %s\n%s", tt.expectedCacheStatus, status, tt.link)
719 }
720 })
721 }
722}
723
724// RFC 9111 4.3.4 304 Not Modified
725// https://www.rfc-editor.org/rfc/rfc9111.html#section-4.3.4
726// When a cached entry is validated and the origin responds with 304, the cache:
727// - Updates header metadata from the 304 response
728// - Retains the cached body for subsequent requests.
729// If the client's conditional headers no longer match the updated cache entry,
730// the cache serves 200 with the full cached body.
731func TestCache304NotModifiedMerge(t *testing.T) {
732 originCalls := 0
733
734 // Validation handler: returns 304 when ETag matches, 200 otherwise.
735 // The 304 response includes an updated ETag header (set before WriteHeader).
736 validationMux := http.NewServeMux()
737 validationMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
738 originCalls++
739 if r.Header.Get("If-None-Match") == "\"abc\"" {
740 // Set headers before WriteHeader (correct HTTP practice).
741 w.Header().Set("etag", "\"abc-updated\"")
742 w.WriteHeader(http.StatusNotModified)
743 return
744 }
745 w.Header().Set("etag", "\"abc\"")
746 w.Header().Set("cache-control", "max-age=60")
747 w.WriteHeader(200)
748 _, _ = w.Write([]byte("original body"))
749 })
750
751 logger := slog.Default()
752 handler := NewHttpCache(logger, validationMux)
753 tc := NewTestContext(t, handler)
754
755 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
756
757 // Manually populate cache with a stale entry that has must-revalidate
758 // so validation is triggered on stale entries rather than the entry being deleted.
759 cacheKey := handler.GetCacheKey(req)
760 staleCv := testCacheValue(250 * time.Second)
761 staleCv.Header["ETag"] = []string{"\"abc\""}
762 staleCv.Header["Cache-Control"] = []string{"max-age=60, must-revalidate"}
763 staleCv.Body = []byte("original body")
764 cacheData := staleCv.encode()
765 handler.Cache.Add(cacheKey, cacheData)
766
767 // First request with If-None-Match triggers validation; origin returns 304
768 // with an updated ETag "abc-updated".
769 // The cache merges the new ETag into the stored entry, then re-evaluates
770 // the client's If-None-Match "abc" against the updated ETag "abc-updated".
771 // They no longer match, so the cache serves the full cached body as 200.
772 resp1, _ := tc.DoWithHeaders(req, map[string][]string{
773 "If-None-Match": {"\"abc\""},
774 })
775 if resp1.StatusCode != http.StatusOK {
776 t.Errorf("expected 200 (updated ETag no longer matches client If-None-Match), got %d", resp1.StatusCode)
777 }
778 status := resp1.Header.Get("cache-status")
779 if !strings.Contains(status, "hit") {
780 t.Errorf("expected cache-status to contain 'hit', got %s", status)
781 }
782
783 // Second request without conditional headers should still serve the cached body.
784 resp2, _ := tc.Do(req)
785 if resp2.StatusCode != http.StatusOK {
786 t.Errorf("expected 200, got %d", resp2.StatusCode)
787 }
788 bodyBuf := make([]byte, 1024)
789 n, _ := resp2.Body.Read(bodyBuf)
790 bodyStr := string(bodyBuf[:n])
791 if bodyStr != "original body" {
792 t.Errorf("expected cached body 'original body', got %q", bodyStr)
793 }
794 status2 := resp2.Header.Get("cache-status")
795 if !strings.Contains(status2, "hit") {
796 t.Errorf("expected cache-status hit on second request, got %s", status2)
797 }
798
799 // Origin should have been called exactly once (304 validation only).
800 if originCalls != 1 {
801 t.Errorf("expected 1 origin call, got %d", originCalls)
802 }
803}
804
805func TestCacheUpstreamResponseBody(t *testing.T) {
806 expectedBody := strings.Repeat("hello world! ", 1000)
807 mux := http.NewServeMux()
808 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
809 w.Header().Set("content-length", strconv.Itoa(len(expectedBody)))
810 w.WriteHeader(200)
811 _, _ = w.Write([]byte(expectedBody))
812 })
813
814 logger := slog.Default()
815 handler := NewHttpCache(logger, mux)
816 tc := NewTestContext(t, handler)
817 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
818
819 // first request goes to upstream
820 resp1, _ := tc.Do(req)
821 if resp1.StatusCode != http.StatusOK {
822 t.Fatalf("expected 200, got %d", resp1.StatusCode)
823 }
824 body1, _ := readBody(resp1)
825 if body1 != expectedBody {
826 t.Errorf("upstream body mismatch: got %d bytes, want %d bytes", len(body1), len(expectedBody))
827 }
828
829 // second request served from cache
830 resp2, _ := tc.Do(req)
831 if resp2.StatusCode != http.StatusOK {
832 t.Fatalf("expected 200, got %d", resp2.StatusCode)
833 }
834 body2, _ := readBody(resp2)
835 if body2 != expectedBody {
836 t.Errorf("cached body mismatch: got %d bytes, want %d bytes", len(body2), len(expectedBody))
837 }
838}
839
840func TestCacheUpstreamStatusCode(t *testing.T) {
841 mux := http.NewServeMux()
842 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
843 w.WriteHeader(201)
844 _, _ = w.Write([]byte("created"))
845 })
846
847 logger := slog.Default()
848 handler := NewHttpCache(logger, mux)
849 tc := NewTestContext(t, handler)
850 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
851
852 resp, _ := tc.Do(req)
853 if resp.StatusCode != 201 {
854 t.Errorf("expected 201, got %d", resp.StatusCode)
855 }
856 body, _ := readBody(resp)
857 if body != "created" {
858 t.Errorf("expected body 'created', got %q", body)
859 }
860}
861
862// RFC 9110 15.4.5: 304 responses MUST NOT contain a body.
863// Even if the upstream handler writes body bytes with a 304,
864// the cache layer must strip them before sending to the client.
865func TestCache304NoBody(t *testing.T) {
866 mux := http.NewServeMux()
867 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
868 if r.Header.Get("If-None-Match") == "\"abc\"" {
869 w.WriteHeader(http.StatusNotModified)
870 // Misbehaving upstream writes body alongside 304
871 _, _ = w.Write([]byte("should not appear"))
872 return
873 }
874 w.Header().Set("etag", "\"abc\"")
875 w.Header().Set("cache-control", "max-age=60, must-revalidate")
876 w.WriteHeader(200)
877 _, _ = w.Write([]byte("original body"))
878 })
879
880 logger := slog.Default()
881 handler := NewHttpCache(logger, mux)
882 tc := NewTestContext(t, handler)
883
884 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
885
886 // Populate cache with a stale must-revalidate entry so revalidation is triggered
887 cacheKey := handler.GetCacheKey(req)
888 cv := testCacheValue(250 * time.Second)
889 cv.Header["ETag"] = []string{"\"abc\""}
890 cv.Header["Cache-Control"] = []string{"max-age=60, must-revalidate"}
891 cv.Body = []byte("original body")
892 cacheData := cv.encode()
893 handler.Cache.Add(cacheKey, cacheData)
894
895 // Trigger revalidation — upstream returns 304 with a spurious body.
896 // Client request is unconditional, so cache serves the stored body as 200.
897 resp, _ := tc.Do(req)
898 if resp.StatusCode != http.StatusOK {
899 t.Fatalf("expected 200, got %d", resp.StatusCode)
900 }
901 body, _ := readBody(resp)
902 if body != "original body" {
903 t.Errorf("expected cached body 'original body', got %q", body)
904 }
905}
906
907func readBody(resp *http.Response) (string, error) {
908 defer resp.Body.Close() //nolint:errcheck
909 buf := make([]byte, 0, 64*1024)
910 tmp := make([]byte, 4096)
911 for {
912 n, err := resp.Body.Read(tmp)
913 buf = append(buf, tmp[:n]...)
914 if err != nil {
915 break
916 }
917 }
918 return string(buf), nil
919}
920
921// Regression: a 304 from cache validation must include the cached response
922// headers (ETag, Content-Type, Cache-Control, etc.) so the browser can match
923// the 304 to its local cached body. Without them browsers show a blank page.
924func TestCache304IncludesCachedHeaders(t *testing.T) {
925 logger := slog.Default()
926 mux := http.NewServeMux()
927 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
928 w.Header().Set("etag", "\"abc\"")
929 w.Header().Set("content-type", "text/html; charset=utf-8")
930 w.Header().Set("cache-control", "max-age=300")
931 w.WriteHeader(200)
932 _, _ = w.Write([]byte("<h1>hello</h1>"))
933 })
934
935 handler := NewHttpCache(logger, mux)
936 tc := NewTestContext(t, handler)
937 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
938
939 // Populate cache with a fresh entry that has ETag, Content-Type, Cache-Control
940 cacheKey := handler.GetCacheKey(req)
941 cv := testCacheValue(10 * time.Second)
942 cv.Header["ETag"] = []string{"\"abc\""}
943 cv.Header["Content-Type"] = []string{"text/html; charset=utf-8"}
944 cv.Header["Cache-Control"] = []string{"max-age=300"}
945 cv.Body = []byte("<h1>hello</h1>")
946 cacheData := cv.encode()
947 handler.Cache.Add(cacheKey, cacheData)
948
949 // Send conditional request that triggers a 304 from the cache layer
950 resp, _ := tc.DoWithHeaders(req, map[string][]string{
951 "If-None-Match": {"\"abc\""},
952 })
953 if resp.StatusCode != http.StatusNotModified {
954 t.Fatalf("expected 304, got %d", resp.StatusCode)
955 }
956
957 // The 304 must carry the cached headers so the browser can use them
958 // Note: Go's HTTP server strips Content-Type on 304 responses, which is fine
959 // per RFC 9110 — the browser already has it from the original 200.
960 if got := resp.Header.Get("ETag"); got != "\"abc\"" {
961 t.Errorf("expected ETag %q, got %q", "\"abc\"", got)
962 }
963 if got := resp.Header.Get("Cache-Control"); got != "max-age=300" {
964 t.Errorf("expected Cache-Control %q, got %q", "max-age=300", got)
965 }
966
967 // Body must be empty per RFC 9110 15.4.5
968 body, _ := readBody(resp)
969 if body != "" {
970 t.Errorf("expected empty body for 304, got %q", body)
971 }
972}
973
974func TestCacheAgeTtl(t *testing.T) {
975 mux := http.NewServeMux()
976 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
977 w.Header().Set("cache-control", "max-age=60")
978 w.WriteHeader(200)
979 _, _ = w.Write([]byte("success"))
980 })
981
982 logger := slog.Default()
983 handler := NewHttpCache(logger, mux)
984 tc := NewTestContext(t, handler)
985
986 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
987
988 // first request hits backend
989 resp1, _ := tc.Do(req)
990 if resp1.StatusCode != http.StatusOK {
991 t.Errorf("expected 200, got %d", resp1.StatusCode)
992 }
993
994 resp2, _ := tc.Do(req)
995 status := resp2.Header.Get("cache-status")
996 if !strings.Contains(status, "ttl=59;") {
997 t.Errorf("expected ttl=59, got %s\n", status)
998 }
999}
1000
1001// RFC 9111 4.2.4 Stale Serving - must-revalidate requires revalidation.
1002// RFC 9111 4.3.1/4.3.2 Validation - cache MUST send stored validators
1003// when generating conditional upstream requests for stale entries.
1004func TestCacheMustRevalidateRevalidationHeaders(t *testing.T) {
1005 actual := time.Now().Add(-10 * time.Minute).UTC()
1006 actualStr := actual.Format(time.RFC1123)
1007
1008 tests := []struct {
1009 name string
1010 cachedETag string
1011 cachedLastModified string
1012 expectedIfNoneMatch string
1013 expectedIfModified string
1014 }{
1015 {
1016 name: "RFC 9111 4.3.1 If-None-Match from stored ETag",
1017 cachedETag: "\"abc\"",
1018 cachedLastModified: "",
1019 expectedIfNoneMatch: "\"abc\"",
1020 expectedIfModified: "",
1021 },
1022 {
1023 name: "RFC 9111 4.3.2 If-Modified-Since from stored Last-Modified",
1024 cachedETag: "",
1025 cachedLastModified: actualStr,
1026 expectedIfNoneMatch: "",
1027 expectedIfModified: actualStr,
1028 },
1029 {
1030 name: "RFC 9111 4.3.1+4.3.2 Both validators present",
1031 cachedETag: "\"xyz\"",
1032 cachedLastModified: actualStr,
1033 expectedIfNoneMatch: "\"xyz\"",
1034 expectedIfModified: actualStr,
1035 },
1036 }
1037
1038 for _, tt := range tests {
1039 t.Run(tt.name, func(t *testing.T) {
1040 var receivedIfNoneMatch, receivedIfModifiedSince string
1041 var receivedRequest *http.Request
1042
1043 mux := http.NewServeMux()
1044 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
1045 receivedIfNoneMatch = r.Header.Get("If-None-Match")
1046 receivedIfModifiedSince = r.Header.Get("If-Modified-Since")
1047 receivedRequest = r
1048
1049 if r.Header.Get("If-None-Match") == "\"abc\"" ||
1050 r.Header.Get("If-None-Match") == "\"xyz\"" ||
1051 r.Header.Get("If-Modified-Since") != "" {
1052 w.WriteHeader(http.StatusNotModified)
1053 return
1054 }
1055 w.Header().Set("etag", "\"abc\"")
1056 w.Header().Set("cache-control", "max-age=60")
1057 w.WriteHeader(http.StatusOK)
1058 _, _ = w.Write([]byte("success"))
1059 })
1060
1061 logger := slog.Default()
1062 handler := NewHttpCache(logger, mux)
1063 tc := NewTestContext(t, handler)
1064
1065 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
1066 cacheKey := handler.GetCacheKey(req)
1067
1068 cv := testCacheValue(250 * time.Second)
1069 if tt.cachedETag != "" {
1070 cv.Header["ETag"] = []string{tt.cachedETag}
1071 }
1072 if tt.cachedLastModified != "" {
1073 cv.Header["Last-Modified"] = []string{tt.cachedLastModified}
1074 }
1075 cv.Header["Cache-Control"] = []string{"max-age=60, must-revalidate"}
1076 cv.Body = []byte("cached body")
1077 cacheData := cv.encode()
1078 handler.Cache.Add(cacheKey, cacheData)
1079
1080 resp, _ := tc.Do(req)
1081
1082 // Client request is unconditional — after upstream 304, cache
1083 // serves the stored body as 200.
1084 if resp.StatusCode != http.StatusOK {
1085 t.Errorf("expected 200, got %d", resp.StatusCode)
1086 }
1087 status := resp.Header.Get("cache-status")
1088 if !strings.Contains(status, "hit") {
1089 t.Errorf("expected cache-status hit, got %s", status)
1090 }
1091
1092 if receivedRequest == nil {
1093 t.Fatal("no request reached upstream handler")
1094 }
1095
1096 if tt.expectedIfNoneMatch != "" && receivedIfNoneMatch != tt.expectedIfNoneMatch {
1097 t.Errorf("expected If-None-Match %q, got %q", tt.expectedIfNoneMatch, receivedIfNoneMatch)
1098 }
1099 if tt.expectedIfModified != "" && receivedIfModifiedSince != tt.expectedIfModified {
1100 t.Errorf("expected If-Modified-Since %q, got %q", tt.expectedIfModified, receivedIfModifiedSince)
1101 }
1102 })
1103 }
1104}
1105
1106// A range request that misses must not leave a partial response cached for
1107// requests of the whole resource.
1108func TestCacheRangeNotStored(t *testing.T) {
1109 body := strings.Repeat("0123456789", 100)
1110 upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1111 http.ServeContent(w, r, "file.txt", time.Unix(1_700_000_000, 0), strings.NewReader(body))
1112 })
1113 handler := NewHttpCache(slog.New(slog.DiscardHandler), upstream)
1114
1115 req := httptest.NewRequest("GET", "http://example.com/file.txt", nil)
1116 req.Header.Set("Range", "bytes=0-9")
1117 rec := httptest.NewRecorder()
1118 handler.ServeHTTP(rec, req)
1119 if rec.Code != http.StatusPartialContent || rec.Body.String() != body[:10] {
1120 t.Fatalf("range request got %d %q", rec.Code, rec.Body.String())
1121 }
1122
1123 for _, want := range []string{"miss", "hit"} {
1124 rec = httptest.NewRecorder()
1125 handler.ServeHTTP(rec, httptest.NewRequest("GET", "http://example.com/file.txt", nil))
1126 if rec.Code != http.StatusOK || rec.Body.String() != body {
1127 t.Fatalf("full request got %d with %d bytes", rec.Code, rec.Body.Len())
1128 }
1129 if status := rec.Header().Get("cache-status"); !strings.Contains(status, want) {
1130 t.Fatalf("full request cache-status %q, want %s", status, want)
1131 }
1132 }
1133}
1134
1135// Bodies over MaxBodySize stream through and are not stored, whether or not
1136// upstream announces their length.
1137func TestCacheMaxBodySize(t *testing.T) {
1138 body := strings.Repeat("x", 1000)
1139 for name, setLength := range map[string]bool{"content-length": true, "chunked": false} {
1140 t.Run(name, func(t *testing.T) {
1141 upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1142 if setLength {
1143 w.Header().Set("content-length", strconv.Itoa(len(body)))
1144 }
1145 for i := 0; i < len(body); i += 100 {
1146 _, _ = w.Write([]byte(body[i : i+100]))
1147 }
1148 })
1149 handler := NewHttpCache(slog.New(slog.DiscardHandler), upstream)
1150 handler.MaxBodySize = 250
1151
1152 for range 2 {
1153 rec := httptest.NewRecorder()
1154 handler.ServeHTTP(rec, httptest.NewRequest("GET", "http://example.com/big", nil))
1155 if rec.Code != http.StatusOK || rec.Body.String() != body {
1156 t.Fatalf("got %d with %d bytes", rec.Code, rec.Body.Len())
1157 }
1158 if status := rec.Header().Get("cache-status"); !strings.Contains(status, "miss") {
1159 t.Fatalf("cache-status %q, want a miss", status)
1160 }
1161 }
1162 if handler.Cache.Len() != 0 {
1163 t.Fatalf("stored %d entries", handler.Cache.Len())
1164 }
1165 })
1166 }
1167}