Eric Bower
·
2026-04-28
cache_test.go
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// - Returns 304 to the client
733// - Updates header metadata from the 304 response
734// - Retains the cached body for subsequent requests.
735func TestCache304NotModifiedMerge(t *testing.T) {
736 originCalls := 0
737
738 // Validation handler: returns 304 when ETag matches, 200 otherwise
739 validationMux := http.NewServeMux()
740 validationMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
741 originCalls++
742 if r.Header.Get("If-None-Match") == "\"abc\"" {
743 w.WriteHeader(http.StatusNotModified)
744 w.Header().Set("etag", "\"abc-updated\"")
745 return
746 }
747 w.Header().Set("etag", "\"abc\"")
748 w.Header().Set("cache-control", "max-age=60")
749 w.WriteHeader(200)
750 _, _ = w.Write([]byte("original body"))
751 })
752
753 logger := slog.Default()
754 handler := NewHttpCache(logger, validationMux)
755 tc := NewTestContext(t, handler)
756
757 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
758
759 // Manually populate cache with a stale entry that has must-revalidate
760 // so validation is triggered on stale entries rather than the entry being deleted.
761 cacheKey := handler.GetCacheKey(req)
762 staleCv := testCacheValue(250 * time.Second)
763 staleCv.Header["ETag"] = []string{"\"abc\""}
764 staleCv.Header["Cache-Control"] = []string{"max-age=60, must-revalidate"}
765 staleCv.Body = []byte("original body")
766 cacheData, _ := json.Marshal(staleCv)
767 handler.Cache.Add(cacheKey, cacheData)
768
769 // First request with If-None-Match triggers validation; origin returns 304.
770 // Client sent conditional headers, so if they still match the updated cache
771 // entry, the client gets 304. Here the upstream updated the ETag to "abc-updated"
772 // so the client's If-None-Match "abc" no longer matches — serve cached body as 200.
773 resp1, _ := tc.DoWithHeaders(req, map[string][]string{
774 "If-None-Match": {"\"abc\""},
775 })
776 if resp1.StatusCode != http.StatusNotModified {
777 t.Errorf("expected 304 (ETag changed after revalidation), got %d", resp1.StatusCode)
778 }
779 status := resp1.Header.Get("cache-status")
780 if !strings.Contains(status, "fwd=stale") {
781 t.Errorf("expected cache-status hit, got %s", status)
782 }
783
784 // Second request without conditional headers should still serve the cached body
785 resp2, _ := tc.Do(req)
786 if resp2.StatusCode != http.StatusOK {
787 t.Errorf("expected 200, got %d", resp2.StatusCode)
788 }
789 bodyBuf := make([]byte, 1024)
790 n, _ := resp2.Body.Read(bodyBuf)
791 bodyStr := string(bodyBuf[:n])
792 if bodyStr != "original body" {
793 t.Errorf("expected cached body 'original body', got %q", bodyStr)
794 }
795 status2 := resp2.Header.Get("cache-status")
796 if !strings.Contains(status2, "hit") {
797 t.Errorf("expected cache-status hit on second request, got %s", status2)
798 }
799
800 // Origin should have been called exactly once (304 validation only)
801 if originCalls != 1 {
802 t.Errorf("expected 1 origin call, got %d", originCalls)
803 }
804}
805
806func TestCacheUpstreamResponseBody(t *testing.T) {
807 expectedBody := strings.Repeat("hello world! ", 1000)
808 mux := http.NewServeMux()
809 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
810 w.Header().Set("content-length", strconv.Itoa(len(expectedBody)))
811 w.WriteHeader(200)
812 _, _ = w.Write([]byte(expectedBody))
813 })
814
815 logger := slog.Default()
816 handler := NewHttpCache(logger, mux)
817 tc := NewTestContext(t, handler)
818 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
819
820 // first request goes to upstream
821 resp1, _ := tc.Do(req)
822 if resp1.StatusCode != http.StatusOK {
823 t.Fatalf("expected 200, got %d", resp1.StatusCode)
824 }
825 body1, _ := readBody(resp1)
826 if body1 != expectedBody {
827 t.Errorf("upstream body mismatch: got %d bytes, want %d bytes", len(body1), len(expectedBody))
828 }
829
830 // second request served from cache
831 resp2, _ := tc.Do(req)
832 if resp2.StatusCode != http.StatusOK {
833 t.Fatalf("expected 200, got %d", resp2.StatusCode)
834 }
835 body2, _ := readBody(resp2)
836 if body2 != expectedBody {
837 t.Errorf("cached body mismatch: got %d bytes, want %d bytes", len(body2), len(expectedBody))
838 }
839}
840
841func TestCacheUpstreamStatusCode(t *testing.T) {
842 mux := http.NewServeMux()
843 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
844 w.WriteHeader(201)
845 _, _ = w.Write([]byte("created"))
846 })
847
848 logger := slog.Default()
849 handler := NewHttpCache(logger, mux)
850 tc := NewTestContext(t, handler)
851 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
852
853 resp, _ := tc.Do(req)
854 if resp.StatusCode != 201 {
855 t.Errorf("expected 201, got %d", resp.StatusCode)
856 }
857 body, _ := readBody(resp)
858 if body != "created" {
859 t.Errorf("expected body 'created', got %q", body)
860 }
861}
862
863// RFC 9110 15.4.5: 304 responses MUST NOT contain a body.
864// Even if the upstream handler writes body bytes with a 304,
865// the cache layer must strip them before sending to the client.
866func TestCache304NoBody(t *testing.T) {
867 mux := http.NewServeMux()
868 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
869 if r.Header.Get("If-None-Match") == "\"abc\"" {
870 w.WriteHeader(http.StatusNotModified)
871 // Misbehaving upstream writes body alongside 304
872 _, _ = w.Write([]byte("should not appear"))
873 return
874 }
875 w.Header().Set("etag", "\"abc\"")
876 w.Header().Set("cache-control", "max-age=60, must-revalidate")
877 w.WriteHeader(200)
878 _, _ = w.Write([]byte("original body"))
879 })
880
881 logger := slog.Default()
882 handler := NewHttpCache(logger, mux)
883 tc := NewTestContext(t, handler)
884
885 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
886
887 // Populate cache with a stale must-revalidate entry so revalidation is triggered
888 cacheKey := handler.GetCacheKey(req)
889 cv := testCacheValue(250 * time.Second)
890 cv.Header["ETag"] = []string{"\"abc\""}
891 cv.Header["Cache-Control"] = []string{"max-age=60, must-revalidate"}
892 cv.Body = []byte("original body")
893 cacheData, _ := json.Marshal(cv)
894 handler.Cache.Add(cacheKey, cacheData)
895
896 // Trigger revalidation — upstream returns 304 with a spurious body.
897 // Client request is unconditional, so cache serves the stored body as 200.
898 resp, _ := tc.Do(req)
899 if resp.StatusCode != http.StatusOK {
900 t.Fatalf("expected 200, got %d", resp.StatusCode)
901 }
902 body, _ := readBody(resp)
903 if body != "original body" {
904 t.Errorf("expected cached body 'original body', got %q", body)
905 }
906}
907
908func readBody(resp *http.Response) (string, error) {
909 defer resp.Body.Close() //nolint:errcheck
910 buf := make([]byte, 0, 64*1024)
911 tmp := make([]byte, 4096)
912 for {
913 n, err := resp.Body.Read(tmp)
914 buf = append(buf, tmp[:n]...)
915 if err != nil {
916 break
917 }
918 }
919 return string(buf), nil
920}
921
922// Regression: a 304 from cache validation must include the cached response
923// headers (ETag, Content-Type, Cache-Control, etc.) so the browser can match
924// the 304 to its local cached body. Without them browsers show a blank page.
925func TestCache304IncludesCachedHeaders(t *testing.T) {
926 logger := slog.Default()
927 mux := http.NewServeMux()
928 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
929 w.Header().Set("etag", "\"abc\"")
930 w.Header().Set("content-type", "text/html; charset=utf-8")
931 w.Header().Set("cache-control", "max-age=300")
932 w.WriteHeader(200)
933 _, _ = w.Write([]byte("<h1>hello</h1>"))
934 })
935
936 handler := NewHttpCache(logger, mux)
937 tc := NewTestContext(t, handler)
938 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
939
940 // Populate cache with a fresh entry that has ETag, Content-Type, Cache-Control
941 cacheKey := handler.GetCacheKey(req)
942 cv := testCacheValue(10 * time.Second)
943 cv.Header["ETag"] = []string{"\"abc\""}
944 cv.Header["Content-Type"] = []string{"text/html; charset=utf-8"}
945 cv.Header["Cache-Control"] = []string{"max-age=300"}
946 cv.Body = []byte("<h1>hello</h1>")
947 cacheData, _ := json.Marshal(cv)
948 handler.Cache.Add(cacheKey, cacheData)
949
950 // Send conditional request that triggers a 304 from the cache layer
951 resp, _ := tc.DoWithHeaders(req, map[string][]string{
952 "If-None-Match": {"\"abc\""},
953 })
954 if resp.StatusCode != http.StatusNotModified {
955 t.Fatalf("expected 304, got %d", resp.StatusCode)
956 }
957
958 // The 304 must carry the cached headers so the browser can use them
959 // Note: Go's HTTP server strips Content-Type on 304 responses, which is fine
960 // per RFC 9110 — the browser already has it from the original 200.
961 if got := resp.Header.Get("ETag"); got != "\"abc\"" {
962 t.Errorf("expected ETag %q, got %q", "\"abc\"", got)
963 }
964 if got := resp.Header.Get("Cache-Control"); got != "max-age=300" {
965 t.Errorf("expected Cache-Control %q, got %q", "max-age=300", got)
966 }
967
968 // Body must be empty per RFC 9110 15.4.5
969 body, _ := readBody(resp)
970 if body != "" {
971 t.Errorf("expected empty body for 304, got %q", body)
972 }
973}
974
975func TestCacheAgeTtl(t *testing.T) {
976 mux := http.NewServeMux()
977 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
978 w.Header().Set("cache-control", "max-age=60")
979 w.WriteHeader(200)
980 _, _ = w.Write([]byte("success"))
981 })
982
983 logger := slog.Default()
984 handler := NewHttpCache(logger, mux)
985 tc := NewTestContext(t, handler)
986
987 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
988
989 // first request hits backend
990 resp1, _ := tc.Do(req)
991 if resp1.StatusCode != http.StatusOK {
992 t.Errorf("expected 200, got %d", resp1.StatusCode)
993 }
994
995 resp2, _ := tc.Do(req)
996 status := resp2.Header.Get("cache-status")
997 if !strings.Contains(status, "ttl=59;") {
998 t.Errorf("expected ttl=59, got %s\n", status)
999 }
1000}
1001
1002// RFC 9111 4.2.4 Stale Serving - must-revalidate requires revalidation.
1003// RFC 9111 4.3.1/4.3.2 Validation - cache MUST send stored validators
1004// when generating conditional upstream requests for stale entries.
1005func TestCacheMustRevalidateRevalidationHeaders(t *testing.T) {
1006 actual := time.Now().Add(-10 * time.Minute).UTC()
1007 actualStr := actual.Format(time.RFC1123)
1008
1009 tests := []struct {
1010 name string
1011 cachedETag string
1012 cachedLastModified string
1013 expectedIfNoneMatch string
1014 expectedIfModified string
1015 }{
1016 {
1017 name: "RFC 9111 4.3.1 If-None-Match from stored ETag",
1018 cachedETag: "\"abc\"",
1019 cachedLastModified: "",
1020 expectedIfNoneMatch: "\"abc\"",
1021 expectedIfModified: "",
1022 },
1023 {
1024 name: "RFC 9111 4.3.2 If-Modified-Since from stored Last-Modified",
1025 cachedETag: "",
1026 cachedLastModified: actualStr,
1027 expectedIfNoneMatch: "",
1028 expectedIfModified: actualStr,
1029 },
1030 {
1031 name: "RFC 9111 4.3.1+4.3.2 Both validators present",
1032 cachedETag: "\"xyz\"",
1033 cachedLastModified: actualStr,
1034 expectedIfNoneMatch: "\"xyz\"",
1035 expectedIfModified: actualStr,
1036 },
1037 }
1038
1039 for _, tt := range tests {
1040 t.Run(tt.name, func(t *testing.T) {
1041 var receivedIfNoneMatch, receivedIfModifiedSince string
1042 var receivedRequest *http.Request
1043
1044 mux := http.NewServeMux()
1045 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
1046 receivedIfNoneMatch = r.Header.Get("If-None-Match")
1047 receivedIfModifiedSince = r.Header.Get("If-Modified-Since")
1048 receivedRequest = r
1049
1050 if r.Header.Get("If-None-Match") == "\"abc\"" ||
1051 r.Header.Get("If-None-Match") == "\"xyz\"" ||
1052 r.Header.Get("If-Modified-Since") != "" {
1053 w.WriteHeader(http.StatusNotModified)
1054 return
1055 }
1056 w.Header().Set("etag", "\"abc\"")
1057 w.Header().Set("cache-control", "max-age=60")
1058 w.WriteHeader(http.StatusOK)
1059 _, _ = w.Write([]byte("success"))
1060 })
1061
1062 logger := slog.Default()
1063 handler := NewHttpCache(logger, mux)
1064 tc := NewTestContext(t, handler)
1065
1066 req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
1067 cacheKey := handler.GetCacheKey(req)
1068
1069 cv := testCacheValue(250 * time.Second)
1070 if tt.cachedETag != "" {
1071 cv.Header["ETag"] = []string{tt.cachedETag}
1072 }
1073 if tt.cachedLastModified != "" {
1074 cv.Header["Last-Modified"] = []string{tt.cachedLastModified}
1075 }
1076 cv.Header["Cache-Control"] = []string{"max-age=60, must-revalidate"}
1077 cv.Body = []byte("cached body")
1078 cacheData, _ := json.Marshal(cv)
1079 handler.Cache.Add(cacheKey, cacheData)
1080
1081 resp, _ := tc.Do(req)
1082
1083 // Client request is unconditional — after upstream 304, cache
1084 // serves the stored body as 200.
1085 if resp.StatusCode != http.StatusOK {
1086 t.Errorf("expected 200, got %d", resp.StatusCode)
1087 }
1088 status := resp.Header.Get("cache-status")
1089 if !strings.Contains(status, "hit") {
1090 t.Errorf("expected cache-status hit, got %s", status)
1091 }
1092
1093 if receivedRequest == nil {
1094 t.Fatal("no request reached upstream handler")
1095 }
1096
1097 if tt.expectedIfNoneMatch != "" && receivedIfNoneMatch != tt.expectedIfNoneMatch {
1098 t.Errorf("expected If-None-Match %q, got %q", tt.expectedIfNoneMatch, receivedIfNoneMatch)
1099 }
1100 if tt.expectedIfModified != "" && receivedIfModifiedSince != tt.expectedIfModified {
1101 t.Errorf("expected If-Modified-Since %q, got %q", tt.expectedIfModified, receivedIfModifiedSince)
1102 }
1103 })
1104 }
1105}