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