repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / httpcache
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}