carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3import (
4 "fmt"
5 "net/http"
6 "net/http/httptest"
7 "testing"
8 "time"
9)
10
11func TestCacheGetMissOnEmpty(t *testing.T) {
12 c := NewCache(10, 1<<20)
13 if _, ok := c.get(CacheKey{Host: "a", Method: "GET", Path: "/"}); ok {
14 t.Fatal("expected miss on empty cache")
15 }
16}
17
18func TestCachePutThenGetHits(t *testing.T) {
19 c := NewCache(10, 1<<20)
20 key := CacheKey{Host: "a", Method: "GET", Path: "/event"}
21 resp := &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute}
22 c.Put(key, "", resp)
23 got, ok := c.get(key)
24 if !ok {
25 t.Fatal("expected hit after put")
26 }
27 if string(got.Body) != "hi" {
28 t.Fatalf("body = %q, want %q", got.Body, "hi")
29 }
30}
31
32func TestCacheEntryExpiresAfterTTL(t *testing.T) {
33 c := NewCache(10, 1<<20)
34 key := CacheKey{Host: "a", Method: "GET", Path: "/event"}
35 resp := &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now().Add(-2 * time.Minute), TTL: time.Minute}
36 c.Put(key, "", resp)
37 if _, ok := c.get(key); ok {
38 t.Fatal("expected miss on expired entry")
39 }
40}
41
42func TestCachePutOverMaxBodyBytesIsDropped(t *testing.T) {
43 c := NewCache(10, 4) // 4-byte cap
44 key := CacheKey{Host: "a", Method: "GET", Path: "/event"}
45 resp := &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("way too big"), StoredAt: time.Now(), TTL: time.Minute}
46 c.Put(key, "", resp)
47 if _, ok := c.get(key); ok {
48 t.Fatal("expected oversized body to never be cached")
49 }
50}
51
52func TestCacheEvictsLeastRecentlyUsedAtCapacity(t *testing.T) {
53 c := NewCache(2, 1<<20)
54 mk := func(p string) CacheKey { return CacheKey{Host: "a", Method: "GET", Path: p} }
55 put := func(p string) {
56 c.Put(mk(p), "", &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(p), StoredAt: time.Now(), TTL: time.Minute})
57 }
58 put("/one")
59 put("/two")
60 c.get(mk("/one")) // touch /one so /two becomes the LRU victim
61 put("/three")
62 if _, ok := c.get(mk("/two")); ok {
63 t.Fatal("expected /two to have been evicted as least-recently-used")
64 }
65 if _, ok := c.get(mk("/one")); !ok {
66 t.Fatal("expected /one to survive (recently touched)")
67 }
68 if _, ok := c.get(mk("/three")); !ok {
69 t.Fatal("expected /three to have been inserted")
70 }
71}
72
73// varyOf reports the Vary field value the response behind k must have carried:
74// a key only has a VaryValue because its response varied by something, and Put
75// needs to be told which something.
76func varyOf(k CacheKey) string {
77 if k.VaryValue == "" {
78 return ""
79 }
80 return "Accept-Language"
81}
82
83// The Vary names travel with the entry, so one URL holds as many schemes as
84// its responses produce. A response that carries no Vary no longer invalidates
85// the variants stored from Vary-carrying ones: both stay reachable, and each
86// request is matched against the scheme its own shape produced.
87//
88// This replaces a test that pinned the opposite — the old design kept ONE Vary
89// scheme per URL, so storing a non-varying response swept every varying entry
90// beside it.
91func TestCacheMixedVarySchemesCoexistForOneURL(t *testing.T) {
92 c := NewCache(10, 1<<20)
93 uk := CacheKey{Host: "a", Method: "GET", Path: "/page"}.urlKey()
94 mkResp := func(b string) *CachedResponse {
95 return &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(b), StoredAt: time.Now(), TTL: time.Minute}
96 }
97
98 // The VaryValue is spelled the way varyValue renders it: each value carries
99 // its byte length ahead of it ("Origin=" + len("https://x") + ":" + value).
100 varied := CacheKey{Host: "a", Method: "GET", Path: "/page", VaryValue: "Origin=9:https://x;"}
101 c.Put(varied, "Origin", mkResp("cors"))
102 plain := CacheKey{Host: "a", Method: "GET", Path: "/page"}
103 c.Put(plain, "", mkResp("plain"))
104
105 c.mu.Lock()
106 variants := len(c.byURL[uk])
107 c.mu.Unlock()
108 if variants != 2 {
109 t.Fatalf("URL holds %d entries, want 2 — a response with no Vary must not evict the variants stored from Vary-carrying ones", variants)
110 }
111
112 // An Origin-bearing request resolves to the Origin variant; anything else
113 // to the non-varying one.
114 corsReq := httptest.NewRequest("GET", "http://a/page", nil)
115 corsReq.Header.Set("Origin", "https://x")
116 if got, ok := c.GetFor(plain, corsReq); !ok || string(got.Body) != "cors" {
117 t.Fatalf("GetFor(Origin: https://x) = %v, %v; want the cors body — the varying entry is the one selected on this request's headers", got, ok)
118 }
119 if got, ok := c.GetFor(plain, httptest.NewRequest("GET", "http://a/page", nil)); !ok || string(got.Body) != "plain" {
120 t.Fatalf("GetFor(no Origin) = %v, %v; want the plain body", got, ok)
121 }
122}
123
124// Two responses that named different Vary headers must never share a slot,
125// even when their rendered VaryValues collide. Length-prefixing now stops real
126// requests from colliding in the first place (see the varyValue tests below),
127// so this test forces the collision directly — both entries stored under one
128// hand-written VaryValue — to pin the structural guarantee behind it: Vary is a
129// key component, so agreement on the rendered values is only ever consulted
130// between entries that already agree on the name list. Without that, the second
131// store would overwrite the first's body while leaving the first's names on the
132// entry, and the next request matching those names would be served a body
133// computed for entirely different headers.
134func TestCacheCollidingVaryValuesUnderDifferentSchemesStaySeparate(t *testing.T) {
135 c := NewCache(10, 1<<20)
136 mkResp := func(b string) *CachedResponse {
137 return &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(b), StoredAt: time.Now(), TTL: time.Minute}
138 }
139 // What a request sending A: "b;C=d" renders under Vary: A — 5 is the length
140 // of that value, so the lookup below reaches the entry stored under it.
141 const collide = "A=5:b;C=d;"
142 key := CacheKey{Host: "a", Method: "GET", Path: "/page", VaryValue: collide}
143 c.Put(key, "A", mkResp("selected-on-A"))
144 c.Put(key, "A, C", mkResp("selected-on-A-and-C"))
145
146 c.mu.Lock()
147 variants := len(c.byURL[key.urlKey()])
148 c.mu.Unlock()
149 if variants != 2 {
150 t.Fatalf("URL holds %d entries, want 2 — different Vary schemes rendering the same VaryValue are different variants", variants)
151 }
152
153 // The request that only matches Vary: A must get that response, not the
154 // one computed for a request carrying both headers.
155 req := httptest.NewRequest("GET", "http://a/page", nil)
156 req.Header.Set("A", "b;C=d")
157 if got, ok := c.GetFor(CacheKey{Host: "a", Method: "GET", Path: "/page"}, req); !ok || string(got.Body) != "selected-on-A" {
158 t.Fatalf("GetFor(A: b;C=d) = %v, %v; want selected-on-A", got, ok)
159 }
160}
161
162// A variant whose stored response has expired must not be returned, and must
163// not shadow a live variant that also matches. The scan evicts what it walks
164// past, so the expired entry is gone afterwards too.
165func TestCacheGetForSkipsAndEvictsExpiredVariants(t *testing.T) {
166 c := NewCache(10, 1<<20)
167 base := CacheKey{Host: "a", Method: "GET", Path: "/page"}
168 stale := CacheKey{Host: "a", Method: "GET", Path: "/page", VaryValue: "Accept-Language=en;"}
169 c.Put(stale, "Accept-Language", &CachedResponse{
170 StatusCode: 200, Header: http.Header{}, Body: []byte("stale-en"),
171 StoredAt: time.Now().Add(-2 * time.Minute), TTL: time.Minute,
172 })
173 c.Put(base, "", &CachedResponse{
174 StatusCode: 200, Header: http.Header{}, Body: []byte("plain"),
175 StoredAt: time.Now(), TTL: time.Minute,
176 })
177
178 req := httptest.NewRequest("GET", "http://a/page", nil)
179 req.Header.Set("Accept-Language", "en")
180 if got, ok := c.GetFor(base, req); !ok || string(got.Body) != "plain" {
181 t.Fatalf("GetFor = %v, %v; want the live non-varying entry — an expired variant must neither be served nor shadow a live match", got, ok)
182 }
183 c.mu.Lock()
184 variants := len(c.byURL[base.urlKey()])
185 c.mu.Unlock()
186 if variants != 1 {
187 t.Fatalf("URL holds %d entries after the lookup, want 1 — the expired variant should have been evicted on the way past", variants)
188 }
189}
190
191// The per-URL cap bounds a URL's variants across every Vary scheme it holds,
192// not per scheme: an app flip-flopping between schemes must not be able to
193// multiply its own allowance.
194func TestCacheVariantCapSpansVarySchemes(t *testing.T) {
195 c := NewCache(1000, 1<<20)
196 uk := CacheKey{Host: "a", Method: "GET", Path: "/page"}.urlKey()
197 for i := 0; i < 4*maxVaryVariantsPerURL; i++ {
198 // Alternate two schemes, each with its own fresh value every round.
199 vary := "Accept-Language"
200 if i%2 == 1 {
201 vary = "Origin"
202 }
203 k := CacheKey{Host: "a", Method: "GET", Path: "/page", VaryValue: fmt.Sprintf("%s=v%d;", vary, i)}
204 c.Put(k, vary, &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("x"), StoredAt: time.Now(), TTL: time.Minute})
205 }
206 c.mu.Lock()
207 n := len(c.byURL[uk])
208 c.mu.Unlock()
209 if n > maxVaryVariantsPerURL {
210 t.Fatalf("one URL holds %d cached variants across two Vary schemes, want at most %d", n, maxVaryVariantsPerURL)
211 }
212}
213
214// The documented cost of most-specific-first selection, pinned so that anyone
215// who "fixes" it has to change this test on purpose: a more-specific variant
216// outranks a strictly NEWER general one that also matches, so the older
217// Vary: Accept-Encoding entry keeps being served to gzip-accepting requests
218// while a fresher no-Vary entry sits beside it — and only its own TTL ends
219// that. The trade is deliberate (selectLocked's doc comment): this staleness is
220// bounded by TTL, whereas "newest wins" would hand the non-varying entry to
221// requests whose stored response actually depended on their headers, for as
222// long as it stayed the newest.
223func TestCacheOlderSpecificVariantShadowsNewerGeneralUntilItExpires(t *testing.T) {
224 c := NewCache(10, 1<<20)
225 base := CacheKey{Host: "a", Method: "GET", Path: "/page"}
226 gz := httptest.NewRequest("GET", "http://a/page", nil)
227 gz.Header.Set("Accept-Encoding", "gzip")
228
229 // The specific entry is stored FIRST and expires soon; the general one is
230 // stored after it and stays live for a minute.
231 specific := base
232 specific.VaryValue = varyValue(gz, "Accept-Encoding")
233 c.Put(specific, "Accept-Encoding", &CachedResponse{
234 StatusCode: 200, Header: http.Header{}, Body: []byte("older-gzip"),
235 StoredAt: time.Now().Add(-10 * time.Millisecond), TTL: 100 * time.Millisecond,
236 })
237 c.Put(base, "", &CachedResponse{
238 StatusCode: 200, Header: http.Header{}, Body: []byte("newer-general"),
239 StoredAt: time.Now(), TTL: time.Minute,
240 })
241
242 if got, ok := c.GetFor(base, gz); !ok || string(got.Body) != "older-gzip" {
243 t.Fatalf("GetFor(Accept-Encoding: gzip) = %v, %v; want older-gzip — the accepted cost: the more-specific variant wins even though the general one is newer", got, ok)
244 }
245
246 // ...until the shadowing entry's own TTL runs out, which is what bounds it.
247 time.Sleep(150 * time.Millisecond)
248 if got, ok := c.GetFor(base, gz); !ok || string(got.Body) != "newer-general" {
249 t.Fatalf("GetFor after the specific variant's TTL = %v, %v; want newer-general — the shadowing is bounded by that TTL, not permanent", got, ok)
250 }
251}
252
253// Two requests that differ only in which side of a Vary pair holds a given
254// substring must not share a cache slot. Under Vary: A, B the unprefixed
255// rendering wrote "A=u;B=v;B=w;" for BOTH `A: "u;B=v", B: "w"` and
256// `A: "u", B: "v;B=w"` — the separators were forgeable from inside a header
257// value, so whichever request stored first had its body served to the other out
258// of a cache shared by every visitor of the host. Length-prefixing each value
259// makes the rendering injective for a given Vary: the reader takes exactly len
260// bytes, so no content can be read as structure.
261func TestVaryValueLengthPrefixSeparatesRequestsThatOnceCollided(t *testing.T) {
262 const vary = "A, B"
263 first := httptest.NewRequest("GET", "http://a/page", nil)
264 first.Header.Set("A", "u;B=v")
265 first.Header.Set("B", "w")
266 second := httptest.NewRequest("GET", "http://a/page", nil)
267 second.Header.Set("A", "u")
268 second.Header.Set("B", "v;B=w")
269
270 if varyValue(first, vary) == varyValue(second, vary) {
271 t.Fatalf("varyValue rendered both requests as %q — a header value must not be able to forge the separators around it", varyValue(first, vary))
272 }
273
274 c := NewCache(10, 1<<20)
275 base := CacheKey{Host: "a", Method: "GET", Path: "/page"}
276 put := func(r *http.Request, body string) {
277 k := base
278 k.VaryValue = varyValue(r, vary)
279 c.Put(k, vary, &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(body), StoredAt: time.Now(), TTL: time.Minute})
280 }
281 put(first, "for-first")
282 put(second, "for-second")
283
284 c.mu.Lock()
285 variants := len(c.byURL[base.urlKey()])
286 c.mu.Unlock()
287 if variants != 2 {
288 t.Fatalf("URL holds %d entries, want 2 — the two requests select on different values and must occupy different slots", variants)
289 }
290 if got, ok := c.GetFor(base, first); !ok || string(got.Body) != "for-first" {
291 t.Fatalf("GetFor(first) = %v, %v; want for-first", got, ok)
292 }
293 if got, ok := c.GetFor(base, second); !ok || string(got.Body) != "for-second" {
294 t.Fatalf("GetFor(second) = %v, %v; want for-second — the second request must never be served the first's body", got, ok)
295 }
296}
297
298// Header field names are case-insensitive (RFC 9110 §5.1), so a response
299// spelling its Vary "accept-encoding" names the same scheme as one spelling it
300// "Accept-Encoding". They must share one slot — otherwise two middlewares
301// disagreeing on capitalization quietly halve a URL's variant allowance and
302// double the misses.
303func TestCacheVaryNameCaseIsCanonicalizedToOneVariant(t *testing.T) {
304 c := NewCache(10, 1<<20)
305 base := CacheKey{Host: "a", Method: "GET", Path: "/page"}
306 req := httptest.NewRequest("GET", "http://a/page", nil)
307 req.Header.Set("Accept-Encoding", "gzip")
308 put := func(vary, body string) {
309 k := base
310 k.VaryValue = varyValue(req, vary)
311 c.Put(k, vary, &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(body), StoredAt: time.Now(), TTL: time.Minute})
312 }
313 put("accept-encoding", "lowercase")
314 put("Accept-Encoding", "canonical")
315
316 c.mu.Lock()
317 variants := len(c.byURL[base.urlKey()])
318 c.mu.Unlock()
319 if variants != 1 {
320 t.Fatalf("URL holds %d entries, want 1 — Vary: accept-encoding and Vary: Accept-Encoding are the same scheme", variants)
321 }
322 if got, ok := c.GetFor(base, req); !ok || string(got.Body) != "canonical" {
323 t.Fatalf("GetFor = %v, %v; want canonical — the second store must have replaced the first, not landed beside it", got, ok)
324 }
325}
326
327// The Vary list is a set, not a sequence (RFC 9110 §12.5.5): "Vary: B, A" and
328// "Vary: A, B" select on the same headers. Canonical sorting means they share
329// one slot AND render values in the same order, so an app that emits its Vary
330// through two different middlewares does not split its own cache.
331func TestCacheVaryNameOrderIsCanonicalizedToOneVariant(t *testing.T) {
332 c := NewCache(10, 1<<20)
333 base := CacheKey{Host: "a", Method: "GET", Path: "/page"}
334 req := httptest.NewRequest("GET", "http://a/page", nil)
335 req.Header.Set("A", "one")
336 req.Header.Set("B", "two")
337 if varyValue(req, "B, A") != varyValue(req, "A, B") {
338 t.Fatalf("varyValue(B, A) = %q, varyValue(A, B) = %q; want identical — the Vary list has no ordering meaning", varyValue(req, "B, A"), varyValue(req, "A, B"))
339 }
340 put := func(vary, body string) {
341 k := base
342 k.VaryValue = varyValue(req, vary)
343 c.Put(k, vary, &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(body), StoredAt: time.Now(), TTL: time.Minute})
344 }
345 put("B, A", "stored-as-B-A")
346 put("A, B", "stored-as-A-B")
347
348 c.mu.Lock()
349 variants := len(c.byURL[base.urlKey()])
350 c.mu.Unlock()
351 if variants != 1 {
352 t.Fatalf("URL holds %d entries, want 1 — Vary: B, A and Vary: A, B are the same scheme", variants)
353 }
354 if got, ok := c.GetFor(base, req); !ok || string(got.Body) != "stored-as-A-B" {
355 t.Fatalf("GetFor = %v, %v; want stored-as-A-B", got, ok)
356 }
357}
358
359// A Vary that is nothing but separators names no headers, so it selects on
360// nothing — which is what "no Vary" means. It must therefore take the no-Vary
361// path exactly: specificity 0, one shared slot with a genuinely Vary-less
362// response, and a match for every request. Left uncanonicalized it rendered
363// four empty names ("=;=;=;=;"), giving a scheme that also matched everything
364// but outranked real single-header variants with a specificity of 4.
365func TestCacheVaryOfOnlySeparatorsIsTreatedAsNoVary(t *testing.T) {
366 c := NewCache(10, 1<<20)
367 base := CacheKey{Host: "a", Method: "GET", Path: "/page"}
368 req := httptest.NewRequest("GET", "http://a/page", nil)
369 if vv := varyValue(req, ", , ,"); vv != "" {
370 t.Fatalf("varyValue(%q) = %q, want \"\" — a Vary naming no headers selects on nothing", ", , ,", vv)
371 }
372 c.Put(base, ", , ,", &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("separators"), StoredAt: time.Now(), TTL: time.Minute})
373
374 c.mu.Lock()
375 var stored *cacheEntry
376 for _, el := range c.byURL[base.urlKey()] {
377 stored = el.Value.(*cacheEntry)
378 }
379 c.mu.Unlock()
380 if stored == nil {
381 t.Fatal("nothing cached")
382 }
383 if stored.key.Vary != "" {
384 t.Fatalf("stored under Vary %q, want \"\" — a Vary of only separators canonicalizes to no Vary at all", stored.key.Vary)
385 }
386 if s := stored.specificity(); s != 0 {
387 t.Fatalf("specificity = %d, want 0 — it must not outrank variants selected on real headers", s)
388 }
389
390 // Same slot as a genuinely Vary-less response, and a match for any request.
391 c.Put(base, "", &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("no-vary"), StoredAt: time.Now(), TTL: time.Minute})
392 c.mu.Lock()
393 variants := len(c.byURL[base.urlKey()])
394 c.mu.Unlock()
395 if variants != 1 {
396 t.Fatalf("URL holds %d entries, want 1 — an empty Vary scheme is the no-Vary scheme", variants)
397 }
398 other := httptest.NewRequest("GET", "http://a/page", nil)
399 other.Header.Set("Accept-Language", "fr")
400 if got, ok := c.GetFor(base, other); !ok || string(got.Body) != "no-vary" {
401 t.Fatalf("GetFor = %v, %v; want no-vary for any request shape", got, ok)
402 }
403}
404
405// selectLocked ranges over a map, whose iteration order Go randomizes. Two
406// variants tied on both specificity and StoredAt must still resolve the same
407// way every time: a shared cache that answers one request two ways, run to run,
408// is unreproducible exactly when that matters.
409func TestCacheSelectionIsDeterministicWhenSpecificityAndAgeTie(t *testing.T) {
410 base := CacheKey{Host: "a", Method: "GET", Path: "/page"}
411 // Neither header is sent, so both entries match; both were stored at the
412 // same instant on one selecting header each.
413 req := httptest.NewRequest("GET", "http://a/page", nil)
414 stored := time.Now()
415
416 for i := 0; i < 50; i++ {
417 c := NewCache(10, 1<<20)
418 for _, v := range []struct{ vary, body string }{{"A", "selected-on-A"}, {"B", "selected-on-B"}} {
419 k := base
420 k.VaryValue = varyValue(req, v.vary)
421 c.Put(k, v.vary, &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(v.body), StoredAt: stored, TTL: time.Minute})
422 }
423 got, ok := c.GetFor(base, req)
424 if !ok {
425 t.Fatal("expected a hit")
426 }
427 // The tie-break is by key, so the A variant wins every round.
428 if string(got.Body) != "selected-on-A" {
429 t.Fatalf("round %d served %q, want selected-on-A every round — a tie must not resolve by map iteration order", i, got.Body)
430 }
431 }
432}
433
434// A deploy replaces the code behind a host, so every cached page for it is
435// suspect — PurgeHost drops all of them, across paths and Vary variants, and
436// nothing belonging to any other host.
437func TestCachePurgeHostRemovesEveryEntryForThatHost(t *testing.T) {
438 c := NewCache(100, 1<<20)
439 mkResp := func(b string) *CachedResponse {
440 return &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(b), StoredAt: time.Now(), TTL: time.Minute}
441 }
442 keys := []CacheKey{
443 {Host: "a", Method: "GET", Path: "/one"},
444 {Host: "a", Method: "GET", Path: "/two", VaryValue: "en"},
445 {Host: "a", Method: "GET", Path: "/two", VaryValue: "fr"},
446 {Host: "a", Method: "HEAD", Path: "/one"},
447 {Host: "a", Method: "GET", Path: "/one", RawQuery: "page=2"},
448 }
449 for _, k := range keys {
450 c.Put(k, varyOf(k), mkResp(k.Path))
451 }
452 other := CacheKey{Host: "b", Method: "GET", Path: "/one"}
453 c.Put(other, "", mkResp("b/one"))
454
455 if n := c.PurgeHost("a"); n != len(keys) {
456 t.Fatalf("PurgeHost removed %d entries, want %d", n, len(keys))
457 }
458 for _, k := range keys {
459 if _, ok := c.get(k); ok {
460 t.Fatalf("entry %+v survived PurgeHost", k)
461 }
462 }
463 if _, ok := c.get(other); !ok {
464 t.Fatal("PurgeHost(\"a\") must leave host b's entries untouched")
465 }
466 if n := c.PurgeHost("nobody"); n != 0 {
467 t.Fatalf("PurgeHost on an uncached host removed %d, want 0", n)
468 }
469}
470
471// PurgeHost must ride a per-host index rather than scan every entry: a
472// capped Carlos-Purge header degrades to PurgeHost on the request path (see
473// purge_header.go), so a whole-cache scan there is request-reachable, not
474// just a once-per-deploy cost.
475func TestPurgeHostUsesPerHostIndex(t *testing.T) {
476 c := NewCache(100, 1<<20)
477 put := func(host, path, q string) {
478 c.Put(CacheKey{Host: host, Method: "GET", Path: path, RawQuery: q}, "",
479 &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("x"), StoredAt: time.Now(), TTL: time.Minute})
480 }
481 put("a.example", "/1", "")
482 put("a.example", "/1", "v=2")
483 put("a.example", "/2", "")
484 put("b.example", "/1", "")
485 if n := c.PurgeHost("a.example"); n != 3 {
486 t.Fatalf("purged %d, want 3", n)
487 }
488 if _, ok := c.get(CacheKey{Host: "b.example", Method: "GET", Path: "/1"}); !ok {
489 t.Fatal("b.example must survive a.example's purge")
490 }
491 if len(c.byHost) != 1 {
492 t.Fatalf("byHost holds %d hosts after purge, want 1 (empty groups must be deleted)", len(c.byHost))
493 }
494 c.PurgeHost("b.example")
495 if len(c.byHost) != 0 || len(c.entries) != 0 {
496 t.Fatal("indexes must be empty after purging everything")
497 }
498}
499
500// An attacker pumping distinct query strings at one path must not be able to
501// evict the rest of the fleet's cache: the per-path cap bounds how many
502// entries a single path can occupy, independent of the global LRU.
503func TestCacheQueryStringFloodIsBoundedPerPath(t *testing.T) {
504 c := NewCache(1000, 1<<20)
505 for i := 0; i < 10*maxEntriesPerPath; i++ {
506 k := CacheKey{Host: "a", Method: "GET", Path: "/event", RawQuery: fmt.Sprintf("x=%d", i)}
507 c.Put(k, "", &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("x"), StoredAt: time.Now(), TTL: time.Minute})
508 }
509 c.mu.Lock()
510 n := len(c.byPath[cachePathKey{Host: "a", Method: "GET", Path: "/event"}])
511 total := len(c.entries)
512 c.mu.Unlock()
513 if n > maxEntriesPerPath {
514 t.Fatalf("one path holds %d cached entries, want at most %d", n, maxEntriesPerPath)
515 }
516 if total > maxEntriesPerPath {
517 t.Fatalf("cache holds %d entries after a query-string flood, want at most %d", total, maxEntriesPerPath)
518 }
519}
520
521func TestCachePurgeURLRemovesAllVaryVariants(t *testing.T) {
522 c := NewCache(10, 1<<20)
523 en := CacheKey{Host: "a", Method: "GET", Path: "/event", VaryValue: "en"}
524 fr := CacheKey{Host: "a", Method: "GET", Path: "/event", VaryValue: "fr"}
525 other := CacheKey{Host: "a", Method: "GET", Path: "/other"}
526 mkResp := func(b string) *CachedResponse {
527 return &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(b), StoredAt: time.Now(), TTL: time.Minute}
528 }
529 c.Put(en, "Accept-Language", mkResp("en"))
530 c.Put(fr, "Accept-Language", mkResp("fr"))
531 c.Put(other, "", mkResp("other"))
532 n := c.PurgeURL("a", "/event", "")
533 if n != 2 {
534 t.Fatalf("purged %d entries, want 2", n)
535 }
536 if _, ok := c.get(en); ok {
537 t.Fatal("expected en variant purged")
538 }
539 if _, ok := c.get(fr); ok {
540 t.Fatal("expected fr variant purged")
541 }
542 if _, ok := c.get(other); !ok {
543 t.Fatal("expected unrelated URL to survive purge")
544 }
545}
546