package edge import ( "fmt" "net/http" "net/http/httptest" "testing" "time" ) func TestCacheGetMissOnEmpty(t *testing.T) { c := NewCache(10, 1<<20) if _, ok := c.get(CacheKey{Host: "a", Method: "GET", Path: "/"}); ok { t.Fatal("expected miss on empty cache") } } func TestCachePutThenGetHits(t *testing.T) { c := NewCache(10, 1<<20) key := CacheKey{Host: "a", Method: "GET", Path: "/event"} resp := &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute} c.Put(key, "", resp) got, ok := c.get(key) if !ok { t.Fatal("expected hit after put") } if string(got.Body) != "hi" { t.Fatalf("body = %q, want %q", got.Body, "hi") } } func TestCacheEntryExpiresAfterTTL(t *testing.T) { c := NewCache(10, 1<<20) key := CacheKey{Host: "a", Method: "GET", Path: "/event"} resp := &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now().Add(-2 * time.Minute), TTL: time.Minute} c.Put(key, "", resp) if _, ok := c.get(key); ok { t.Fatal("expected miss on expired entry") } } func TestCachePutOverMaxBodyBytesIsDropped(t *testing.T) { c := NewCache(10, 4) // 4-byte cap key := CacheKey{Host: "a", Method: "GET", Path: "/event"} resp := &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("way too big"), StoredAt: time.Now(), TTL: time.Minute} c.Put(key, "", resp) if _, ok := c.get(key); ok { t.Fatal("expected oversized body to never be cached") } } func TestCacheEvictsLeastRecentlyUsedAtCapacity(t *testing.T) { c := NewCache(2, 1<<20) mk := func(p string) CacheKey { return CacheKey{Host: "a", Method: "GET", Path: p} } put := func(p string) { c.Put(mk(p), "", &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(p), StoredAt: time.Now(), TTL: time.Minute}) } put("/one") put("/two") c.get(mk("/one")) // touch /one so /two becomes the LRU victim put("/three") if _, ok := c.get(mk("/two")); ok { t.Fatal("expected /two to have been evicted as least-recently-used") } if _, ok := c.get(mk("/one")); !ok { t.Fatal("expected /one to survive (recently touched)") } if _, ok := c.get(mk("/three")); !ok { t.Fatal("expected /three to have been inserted") } } // varyOf reports the Vary field value the response behind k must have carried: // a key only has a VaryValue because its response varied by something, and Put // needs to be told which something. func varyOf(k CacheKey) string { if k.VaryValue == "" { return "" } return "Accept-Language" } // The Vary names travel with the entry, so one URL holds as many schemes as // its responses produce. A response that carries no Vary no longer invalidates // the variants stored from Vary-carrying ones: both stay reachable, and each // request is matched against the scheme its own shape produced. // // This replaces a test that pinned the opposite — the old design kept ONE Vary // scheme per URL, so storing a non-varying response swept every varying entry // beside it. func TestCacheMixedVarySchemesCoexistForOneURL(t *testing.T) { c := NewCache(10, 1<<20) uk := CacheKey{Host: "a", Method: "GET", Path: "/page"}.urlKey() mkResp := func(b string) *CachedResponse { return &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(b), StoredAt: time.Now(), TTL: time.Minute} } // The VaryValue is spelled the way varyValue renders it: each value carries // its byte length ahead of it ("Origin=" + len("https://x") + ":" + value). varied := CacheKey{Host: "a", Method: "GET", Path: "/page", VaryValue: "Origin=9:https://x;"} c.Put(varied, "Origin", mkResp("cors")) plain := CacheKey{Host: "a", Method: "GET", Path: "/page"} c.Put(plain, "", mkResp("plain")) c.mu.Lock() variants := len(c.byURL[uk]) c.mu.Unlock() if variants != 2 { t.Fatalf("URL holds %d entries, want 2 — a response with no Vary must not evict the variants stored from Vary-carrying ones", variants) } // An Origin-bearing request resolves to the Origin variant; anything else // to the non-varying one. corsReq := httptest.NewRequest("GET", "http://a/page", nil) corsReq.Header.Set("Origin", "https://x") if got, ok := c.GetFor(plain, corsReq); !ok || string(got.Body) != "cors" { 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) } if got, ok := c.GetFor(plain, httptest.NewRequest("GET", "http://a/page", nil)); !ok || string(got.Body) != "plain" { t.Fatalf("GetFor(no Origin) = %v, %v; want the plain body", got, ok) } } // Two responses that named different Vary headers must never share a slot, // even when their rendered VaryValues collide. Length-prefixing now stops real // requests from colliding in the first place (see the varyValue tests below), // so this test forces the collision directly — both entries stored under one // hand-written VaryValue — to pin the structural guarantee behind it: Vary is a // key component, so agreement on the rendered values is only ever consulted // between entries that already agree on the name list. Without that, the second // store would overwrite the first's body while leaving the first's names on the // entry, and the next request matching those names would be served a body // computed for entirely different headers. func TestCacheCollidingVaryValuesUnderDifferentSchemesStaySeparate(t *testing.T) { c := NewCache(10, 1<<20) mkResp := func(b string) *CachedResponse { return &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(b), StoredAt: time.Now(), TTL: time.Minute} } // What a request sending A: "b;C=d" renders under Vary: A — 5 is the length // of that value, so the lookup below reaches the entry stored under it. const collide = "A=5:b;C=d;" key := CacheKey{Host: "a", Method: "GET", Path: "/page", VaryValue: collide} c.Put(key, "A", mkResp("selected-on-A")) c.Put(key, "A, C", mkResp("selected-on-A-and-C")) c.mu.Lock() variants := len(c.byURL[key.urlKey()]) c.mu.Unlock() if variants != 2 { t.Fatalf("URL holds %d entries, want 2 — different Vary schemes rendering the same VaryValue are different variants", variants) } // The request that only matches Vary: A must get that response, not the // one computed for a request carrying both headers. req := httptest.NewRequest("GET", "http://a/page", nil) req.Header.Set("A", "b;C=d") if got, ok := c.GetFor(CacheKey{Host: "a", Method: "GET", Path: "/page"}, req); !ok || string(got.Body) != "selected-on-A" { t.Fatalf("GetFor(A: b;C=d) = %v, %v; want selected-on-A", got, ok) } } // A variant whose stored response has expired must not be returned, and must // not shadow a live variant that also matches. The scan evicts what it walks // past, so the expired entry is gone afterwards too. func TestCacheGetForSkipsAndEvictsExpiredVariants(t *testing.T) { c := NewCache(10, 1<<20) base := CacheKey{Host: "a", Method: "GET", Path: "/page"} stale := CacheKey{Host: "a", Method: "GET", Path: "/page", VaryValue: "Accept-Language=en;"} c.Put(stale, "Accept-Language", &CachedResponse{ StatusCode: 200, Header: http.Header{}, Body: []byte("stale-en"), StoredAt: time.Now().Add(-2 * time.Minute), TTL: time.Minute, }) c.Put(base, "", &CachedResponse{ StatusCode: 200, Header: http.Header{}, Body: []byte("plain"), StoredAt: time.Now(), TTL: time.Minute, }) req := httptest.NewRequest("GET", "http://a/page", nil) req.Header.Set("Accept-Language", "en") if got, ok := c.GetFor(base, req); !ok || string(got.Body) != "plain" { 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) } c.mu.Lock() variants := len(c.byURL[base.urlKey()]) c.mu.Unlock() if variants != 1 { t.Fatalf("URL holds %d entries after the lookup, want 1 — the expired variant should have been evicted on the way past", variants) } } // The per-URL cap bounds a URL's variants across every Vary scheme it holds, // not per scheme: an app flip-flopping between schemes must not be able to // multiply its own allowance. func TestCacheVariantCapSpansVarySchemes(t *testing.T) { c := NewCache(1000, 1<<20) uk := CacheKey{Host: "a", Method: "GET", Path: "/page"}.urlKey() for i := 0; i < 4*maxVaryVariantsPerURL; i++ { // Alternate two schemes, each with its own fresh value every round. vary := "Accept-Language" if i%2 == 1 { vary = "Origin" } k := CacheKey{Host: "a", Method: "GET", Path: "/page", VaryValue: fmt.Sprintf("%s=v%d;", vary, i)} c.Put(k, vary, &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("x"), StoredAt: time.Now(), TTL: time.Minute}) } c.mu.Lock() n := len(c.byURL[uk]) c.mu.Unlock() if n > maxVaryVariantsPerURL { t.Fatalf("one URL holds %d cached variants across two Vary schemes, want at most %d", n, maxVaryVariantsPerURL) } } // The documented cost of most-specific-first selection, pinned so that anyone // who "fixes" it has to change this test on purpose: a more-specific variant // outranks a strictly NEWER general one that also matches, so the older // Vary: Accept-Encoding entry keeps being served to gzip-accepting requests // while a fresher no-Vary entry sits beside it — and only its own TTL ends // that. The trade is deliberate (selectLocked's doc comment): this staleness is // bounded by TTL, whereas "newest wins" would hand the non-varying entry to // requests whose stored response actually depended on their headers, for as // long as it stayed the newest. func TestCacheOlderSpecificVariantShadowsNewerGeneralUntilItExpires(t *testing.T) { c := NewCache(10, 1<<20) base := CacheKey{Host: "a", Method: "GET", Path: "/page"} gz := httptest.NewRequest("GET", "http://a/page", nil) gz.Header.Set("Accept-Encoding", "gzip") // The specific entry is stored FIRST and expires soon; the general one is // stored after it and stays live for a minute. specific := base specific.VaryValue = varyValue(gz, "Accept-Encoding") c.Put(specific, "Accept-Encoding", &CachedResponse{ StatusCode: 200, Header: http.Header{}, Body: []byte("older-gzip"), StoredAt: time.Now().Add(-10 * time.Millisecond), TTL: 100 * time.Millisecond, }) c.Put(base, "", &CachedResponse{ StatusCode: 200, Header: http.Header{}, Body: []byte("newer-general"), StoredAt: time.Now(), TTL: time.Minute, }) if got, ok := c.GetFor(base, gz); !ok || string(got.Body) != "older-gzip" { 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) } // ...until the shadowing entry's own TTL runs out, which is what bounds it. time.Sleep(150 * time.Millisecond) if got, ok := c.GetFor(base, gz); !ok || string(got.Body) != "newer-general" { 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) } } // Two requests that differ only in which side of a Vary pair holds a given // substring must not share a cache slot. Under Vary: A, B the unprefixed // rendering wrote "A=u;B=v;B=w;" for BOTH `A: "u;B=v", B: "w"` and // `A: "u", B: "v;B=w"` — the separators were forgeable from inside a header // value, so whichever request stored first had its body served to the other out // of a cache shared by every visitor of the host. Length-prefixing each value // makes the rendering injective for a given Vary: the reader takes exactly len // bytes, so no content can be read as structure. func TestVaryValueLengthPrefixSeparatesRequestsThatOnceCollided(t *testing.T) { const vary = "A, B" first := httptest.NewRequest("GET", "http://a/page", nil) first.Header.Set("A", "u;B=v") first.Header.Set("B", "w") second := httptest.NewRequest("GET", "http://a/page", nil) second.Header.Set("A", "u") second.Header.Set("B", "v;B=w") if varyValue(first, vary) == varyValue(second, vary) { t.Fatalf("varyValue rendered both requests as %q — a header value must not be able to forge the separators around it", varyValue(first, vary)) } c := NewCache(10, 1<<20) base := CacheKey{Host: "a", Method: "GET", Path: "/page"} put := func(r *http.Request, body string) { k := base k.VaryValue = varyValue(r, vary) c.Put(k, vary, &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(body), StoredAt: time.Now(), TTL: time.Minute}) } put(first, "for-first") put(second, "for-second") c.mu.Lock() variants := len(c.byURL[base.urlKey()]) c.mu.Unlock() if variants != 2 { t.Fatalf("URL holds %d entries, want 2 — the two requests select on different values and must occupy different slots", variants) } if got, ok := c.GetFor(base, first); !ok || string(got.Body) != "for-first" { t.Fatalf("GetFor(first) = %v, %v; want for-first", got, ok) } if got, ok := c.GetFor(base, second); !ok || string(got.Body) != "for-second" { t.Fatalf("GetFor(second) = %v, %v; want for-second — the second request must never be served the first's body", got, ok) } } // Header field names are case-insensitive (RFC 9110 §5.1), so a response // spelling its Vary "accept-encoding" names the same scheme as one spelling it // "Accept-Encoding". They must share one slot — otherwise two middlewares // disagreeing on capitalization quietly halve a URL's variant allowance and // double the misses. func TestCacheVaryNameCaseIsCanonicalizedToOneVariant(t *testing.T) { c := NewCache(10, 1<<20) base := CacheKey{Host: "a", Method: "GET", Path: "/page"} req := httptest.NewRequest("GET", "http://a/page", nil) req.Header.Set("Accept-Encoding", "gzip") put := func(vary, body string) { k := base k.VaryValue = varyValue(req, vary) c.Put(k, vary, &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(body), StoredAt: time.Now(), TTL: time.Minute}) } put("accept-encoding", "lowercase") put("Accept-Encoding", "canonical") c.mu.Lock() variants := len(c.byURL[base.urlKey()]) c.mu.Unlock() if variants != 1 { t.Fatalf("URL holds %d entries, want 1 — Vary: accept-encoding and Vary: Accept-Encoding are the same scheme", variants) } if got, ok := c.GetFor(base, req); !ok || string(got.Body) != "canonical" { t.Fatalf("GetFor = %v, %v; want canonical — the second store must have replaced the first, not landed beside it", got, ok) } } // The Vary list is a set, not a sequence (RFC 9110 §12.5.5): "Vary: B, A" and // "Vary: A, B" select on the same headers. Canonical sorting means they share // one slot AND render values in the same order, so an app that emits its Vary // through two different middlewares does not split its own cache. func TestCacheVaryNameOrderIsCanonicalizedToOneVariant(t *testing.T) { c := NewCache(10, 1<<20) base := CacheKey{Host: "a", Method: "GET", Path: "/page"} req := httptest.NewRequest("GET", "http://a/page", nil) req.Header.Set("A", "one") req.Header.Set("B", "two") if varyValue(req, "B, A") != varyValue(req, "A, B") { 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")) } put := func(vary, body string) { k := base k.VaryValue = varyValue(req, vary) c.Put(k, vary, &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(body), StoredAt: time.Now(), TTL: time.Minute}) } put("B, A", "stored-as-B-A") put("A, B", "stored-as-A-B") c.mu.Lock() variants := len(c.byURL[base.urlKey()]) c.mu.Unlock() if variants != 1 { t.Fatalf("URL holds %d entries, want 1 — Vary: B, A and Vary: A, B are the same scheme", variants) } if got, ok := c.GetFor(base, req); !ok || string(got.Body) != "stored-as-A-B" { t.Fatalf("GetFor = %v, %v; want stored-as-A-B", got, ok) } } // A Vary that is nothing but separators names no headers, so it selects on // nothing — which is what "no Vary" means. It must therefore take the no-Vary // path exactly: specificity 0, one shared slot with a genuinely Vary-less // response, and a match for every request. Left uncanonicalized it rendered // four empty names ("=;=;=;=;"), giving a scheme that also matched everything // but outranked real single-header variants with a specificity of 4. func TestCacheVaryOfOnlySeparatorsIsTreatedAsNoVary(t *testing.T) { c := NewCache(10, 1<<20) base := CacheKey{Host: "a", Method: "GET", Path: "/page"} req := httptest.NewRequest("GET", "http://a/page", nil) if vv := varyValue(req, ", , ,"); vv != "" { t.Fatalf("varyValue(%q) = %q, want \"\" — a Vary naming no headers selects on nothing", ", , ,", vv) } c.Put(base, ", , ,", &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("separators"), StoredAt: time.Now(), TTL: time.Minute}) c.mu.Lock() var stored *cacheEntry for _, el := range c.byURL[base.urlKey()] { stored = el.Value.(*cacheEntry) } c.mu.Unlock() if stored == nil { t.Fatal("nothing cached") } if stored.key.Vary != "" { t.Fatalf("stored under Vary %q, want \"\" — a Vary of only separators canonicalizes to no Vary at all", stored.key.Vary) } if s := stored.specificity(); s != 0 { t.Fatalf("specificity = %d, want 0 — it must not outrank variants selected on real headers", s) } // Same slot as a genuinely Vary-less response, and a match for any request. c.Put(base, "", &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("no-vary"), StoredAt: time.Now(), TTL: time.Minute}) c.mu.Lock() variants := len(c.byURL[base.urlKey()]) c.mu.Unlock() if variants != 1 { t.Fatalf("URL holds %d entries, want 1 — an empty Vary scheme is the no-Vary scheme", variants) } other := httptest.NewRequest("GET", "http://a/page", nil) other.Header.Set("Accept-Language", "fr") if got, ok := c.GetFor(base, other); !ok || string(got.Body) != "no-vary" { t.Fatalf("GetFor = %v, %v; want no-vary for any request shape", got, ok) } } // selectLocked ranges over a map, whose iteration order Go randomizes. Two // variants tied on both specificity and StoredAt must still resolve the same // way every time: a shared cache that answers one request two ways, run to run, // is unreproducible exactly when that matters. func TestCacheSelectionIsDeterministicWhenSpecificityAndAgeTie(t *testing.T) { base := CacheKey{Host: "a", Method: "GET", Path: "/page"} // Neither header is sent, so both entries match; both were stored at the // same instant on one selecting header each. req := httptest.NewRequest("GET", "http://a/page", nil) stored := time.Now() for i := 0; i < 50; i++ { c := NewCache(10, 1<<20) for _, v := range []struct{ vary, body string }{{"A", "selected-on-A"}, {"B", "selected-on-B"}} { k := base k.VaryValue = varyValue(req, v.vary) c.Put(k, v.vary, &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(v.body), StoredAt: stored, TTL: time.Minute}) } got, ok := c.GetFor(base, req) if !ok { t.Fatal("expected a hit") } // The tie-break is by key, so the A variant wins every round. if string(got.Body) != "selected-on-A" { t.Fatalf("round %d served %q, want selected-on-A every round — a tie must not resolve by map iteration order", i, got.Body) } } } // A deploy replaces the code behind a host, so every cached page for it is // suspect — PurgeHost drops all of them, across paths and Vary variants, and // nothing belonging to any other host. func TestCachePurgeHostRemovesEveryEntryForThatHost(t *testing.T) { c := NewCache(100, 1<<20) mkResp := func(b string) *CachedResponse { return &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(b), StoredAt: time.Now(), TTL: time.Minute} } keys := []CacheKey{ {Host: "a", Method: "GET", Path: "/one"}, {Host: "a", Method: "GET", Path: "/two", VaryValue: "en"}, {Host: "a", Method: "GET", Path: "/two", VaryValue: "fr"}, {Host: "a", Method: "HEAD", Path: "/one"}, {Host: "a", Method: "GET", Path: "/one", RawQuery: "page=2"}, } for _, k := range keys { c.Put(k, varyOf(k), mkResp(k.Path)) } other := CacheKey{Host: "b", Method: "GET", Path: "/one"} c.Put(other, "", mkResp("b/one")) if n := c.PurgeHost("a"); n != len(keys) { t.Fatalf("PurgeHost removed %d entries, want %d", n, len(keys)) } for _, k := range keys { if _, ok := c.get(k); ok { t.Fatalf("entry %+v survived PurgeHost", k) } } if _, ok := c.get(other); !ok { t.Fatal("PurgeHost(\"a\") must leave host b's entries untouched") } if n := c.PurgeHost("nobody"); n != 0 { t.Fatalf("PurgeHost on an uncached host removed %d, want 0", n) } } // PurgeHost must ride a per-host index rather than scan every entry: a // capped Carlos-Purge header degrades to PurgeHost on the request path (see // purge_header.go), so a whole-cache scan there is request-reachable, not // just a once-per-deploy cost. func TestPurgeHostUsesPerHostIndex(t *testing.T) { c := NewCache(100, 1<<20) put := func(host, path, q string) { c.Put(CacheKey{Host: host, Method: "GET", Path: path, RawQuery: q}, "", &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("x"), StoredAt: time.Now(), TTL: time.Minute}) } put("a.example", "/1", "") put("a.example", "/1", "v=2") put("a.example", "/2", "") put("b.example", "/1", "") if n := c.PurgeHost("a.example"); n != 3 { t.Fatalf("purged %d, want 3", n) } if _, ok := c.get(CacheKey{Host: "b.example", Method: "GET", Path: "/1"}); !ok { t.Fatal("b.example must survive a.example's purge") } if len(c.byHost) != 1 { t.Fatalf("byHost holds %d hosts after purge, want 1 (empty groups must be deleted)", len(c.byHost)) } c.PurgeHost("b.example") if len(c.byHost) != 0 || len(c.entries) != 0 { t.Fatal("indexes must be empty after purging everything") } } // An attacker pumping distinct query strings at one path must not be able to // evict the rest of the fleet's cache: the per-path cap bounds how many // entries a single path can occupy, independent of the global LRU. func TestCacheQueryStringFloodIsBoundedPerPath(t *testing.T) { c := NewCache(1000, 1<<20) for i := 0; i < 10*maxEntriesPerPath; i++ { k := CacheKey{Host: "a", Method: "GET", Path: "/event", RawQuery: fmt.Sprintf("x=%d", i)} c.Put(k, "", &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte("x"), StoredAt: time.Now(), TTL: time.Minute}) } c.mu.Lock() n := len(c.byPath[cachePathKey{Host: "a", Method: "GET", Path: "/event"}]) total := len(c.entries) c.mu.Unlock() if n > maxEntriesPerPath { t.Fatalf("one path holds %d cached entries, want at most %d", n, maxEntriesPerPath) } if total > maxEntriesPerPath { t.Fatalf("cache holds %d entries after a query-string flood, want at most %d", total, maxEntriesPerPath) } } func TestCachePurgeURLRemovesAllVaryVariants(t *testing.T) { c := NewCache(10, 1<<20) en := CacheKey{Host: "a", Method: "GET", Path: "/event", VaryValue: "en"} fr := CacheKey{Host: "a", Method: "GET", Path: "/event", VaryValue: "fr"} other := CacheKey{Host: "a", Method: "GET", Path: "/other"} mkResp := func(b string) *CachedResponse { return &CachedResponse{StatusCode: 200, Header: http.Header{}, Body: []byte(b), StoredAt: time.Now(), TTL: time.Minute} } c.Put(en, "Accept-Language", mkResp("en")) c.Put(fr, "Accept-Language", mkResp("fr")) c.Put(other, "", mkResp("other")) n := c.PurgeURL("a", "/event", "") if n != 2 { t.Fatalf("purged %d entries, want 2", n) } if _, ok := c.get(en); ok { t.Fatal("expected en variant purged") } if _, ok := c.get(fr); ok { t.Fatal("expected fr variant purged") } if _, ok := c.get(other); !ok { t.Fatal("expected unrelated URL to survive purge") } }