package edge import ( "bufio" "bytes" "errors" "fmt" "io" "log" "net" "net/http" "net/http/httptest" "net/http/httptrace" "net/textproto" "os" "path/filepath" "strings" "syscall" "testing" "time" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // purgeEdge builds an edge with a cache in front of one addr-backed route for // host, whose upstream runs h. hibernate=false is the direct-proxy shape (a // plain `routed` app behind the edge — tito production), true routes the // request through the activator instead. func purgeEdge(t *testing.T, host string, hibernate bool, h http.HandlerFunc) (*Edge, *registry.Registry) { t.Helper() backend := httptest.NewServer(h) t.Cleanup(backend.Close) reg := openReg(t) rt := registry.Route{Host: host, Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready", Hibernate: hibernate} if err := reg.Upsert(rt); err != nil { t.Fatalf("upsert: %v", err) } var act Activator if hibernate { act = &fakeActivator{} } e, err := New(reg, act) if err != nil { t.Fatalf("New: %v", err) } // EnableCache deliberately AFTER New (which already ran Reload) — the // production ordering in RunWithActivator. A purge hook that captured // e.cache at reload time would capture nil and never purge anything. e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) return e, reg } // seed puts a trivially-cacheable entry for one URL so a purge has something // to remove. func seed(t *testing.T, c *Cache, host, path, rawQuery, body string) { t.Helper() c.Put(CacheKey{Host: host, Method: "GET", Path: path, RawQuery: rawQuery}, "", &CachedResponse{ StatusCode: 200, Header: http.Header{"Content-Type": {"text/html"}}, Body: []byte(body), StoredAt: time.Now(), TTL: time.Minute, }) } func cached(c *Cache, host, path, rawQuery string) bool { _, ok := c.get(CacheKey{Host: host, Method: "GET", Path: path, RawQuery: rawQuery}) return ok } // TestPurgeHeaderOnPOSTPurgesAndIsStripped is the headline contract: a write // (POST — never a cacheable method, so it never touches the GET/HEAD caching // path at all) purges the URLs it names, and the client never sees the header. func TestPurgeHeaderOnPOSTPurgesAndIsStripped(t *testing.T) { e, _ := purgeEdge(t, "w.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", "/events/42, /events/42?lang=fr") w.Header().Set("Location", "/events/42") w.WriteHeader(http.StatusFound) }) seed(t, e.cache, "w.example.com", "/events/42", "", "old") seed(t, e.cache, "w.example.com", "/events/42", "lang=fr", "vieux") seed(t, e.cache, "w.example.com", "/events/43", "", "untouched") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://w.example.com/events/42/edit", strings.NewReader("x=1"))) if rec.Code != http.StatusFound { t.Fatalf("status = %d, want 302", rec.Code) } if got := rec.Header().Get("Carlos-Purge"); got != "" { t.Fatalf("Carlos-Purge reached the client: %q", got) } if cached(e.cache, "w.example.com", "/events/42", "") { t.Fatal("/events/42 still cached after purge") } if cached(e.cache, "w.example.com", "/events/42", "lang=fr") { t.Fatal("/events/42?lang=fr still cached after purge") } if !cached(e.cache, "w.example.com", "/events/43", "") { t.Fatal("/events/43 was purged but nothing named it") } } // TestPurgeHeaderOnDirectProxyPath pins the shape this feature exists for: // an addr-backed, NON-hibernating route (tito production) — no activator in // the picture at all. func TestPurgeHeaderOnDirectProxyPath(t *testing.T) { e, _ := purgeEdge(t, "tito.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", "/tickets") io.WriteString(w, "ok") }) seed(t, e.cache, "tito.example.com", "/tickets", "", "old") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://tito.example.com/tickets/buy", nil)) if cached(e.cache, "tito.example.com", "/tickets", "") { t.Fatal("direct-proxy path did not honour Carlos-Purge") } if got := rec.Header().Get("Carlos-Purge"); got != "" { t.Fatalf("Carlos-Purge reached the client: %q", got) } } // TestPurgeHeaderOnHibernatingPath proves the same on the activator path, // where the activator wraps the very same proxy. func TestPurgeHeaderOnHibernatingPath(t *testing.T) { e, _ := purgeEdge(t, "hib.example.com", true, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", "/tickets") io.WriteString(w, "ok") }) seed(t, e.cache, "hib.example.com", "/tickets", "", "old") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://hib.example.com/tickets/buy", nil)) if cached(e.cache, "hib.example.com", "/tickets", "") { t.Fatal("hibernating/activator path did not honour Carlos-Purge") } if got := rec.Header().Get("Carlos-Purge"); got != "" { t.Fatalf("Carlos-Purge reached the client: %q", got) } } // TestPurgeAppliesBeforeStore is the ordering regression: a cacheable GET // whose own response purges its own URL must end with the NEW response // cached, not with an empty entry. Purge runs in ModifyResponse, strictly // before maybeStore sees the response — reverse the two and this GET purges // exactly what it just stored, so the URL can never stay cached and a // hibernating route wakes on every single request. // // The URL is deliberately NOT pre-seeded: a seeded entry would be a cache hit // and the request would never reach the backend at all. func TestPurgeAppliesBeforeStore(t *testing.T) { e, _ := purgeEdge(t, "order.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", "/page, /index") w.Header().Set("Cache-Control", "max-age=60") io.WriteString(w, "fresh") }) seed(t, e.cache, "order.example.com", "/index", "", "stale index") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("GET", "http://order.example.com/page", nil)) if rec.Body.String() != "fresh" { t.Fatalf("body = %q, want %q", rec.Body.String(), "fresh") } got, ok := e.cache.get(CacheKey{Host: "order.example.com", Method: "GET", Path: "/page"}) if !ok { t.Fatal("nothing cached: the purge ran AFTER maybeStore and deleted what it just stored") } if string(got.Body) != "fresh" { t.Fatalf("cached body = %q, want %q", got.Body, "fresh") } if v := got.Header.Get("Carlos-Purge"); v != "" { t.Fatalf("Carlos-Purge was stored in the cached response: %q", v) } // The other target in the same declaration really was purged — proving // the entry above survived because of ordering, not because the purge // silently did nothing. if cached(e.cache, "order.example.com", "/index", "") { t.Fatal("/index survived: the purge did not run at all") } } // TestPurgeHeaderStrippedFromCachedReplay — the second half of "the client // never sees it": a response stored and later replayed from cache must not // carry the header either. func TestPurgeHeaderStrippedFromCachedReplay(t *testing.T) { e, _ := purgeEdge(t, "replay.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", "/other") w.Header().Set("Cache-Control", "max-age=60") io.WriteString(w, "page") }) rec1 := httptest.NewRecorder() e.ServeHTTP(rec1, httptest.NewRequest("GET", "http://replay.example.com/page", nil)) if got := rec1.Header().Get("Carlos-Purge"); got != "" { t.Fatalf("pass-through response carried Carlos-Purge: %q", got) } rec2 := httptest.NewRecorder() e.ServeHTTP(rec2, httptest.NewRequest("GET", "http://replay.example.com/page", nil)) if rec2.Body.String() != "page" { t.Fatalf("second request body = %q, want a cache hit of %q", rec2.Body.String(), "page") } if got := rec2.Header().Get("Carlos-Purge"); got != "" { t.Fatalf("cached replay carried Carlos-Purge: %q", got) } } // TestPurgeHeaderStarPurgesHost — `*` maps to Cache.PurgeHost, and only for // the host being served. func TestPurgeHeaderStarPurgesHost(t *testing.T) { e, _ := purgeEdge(t, "star.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", "*") w.WriteHeader(http.StatusNoContent) }) seed(t, e.cache, "star.example.com", "/a", "", "a") seed(t, e.cache, "star.example.com", "/b", "x=1", "b") seed(t, e.cache, "other.example.com", "/a", "", "other") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://star.example.com/deploy", nil)) if cached(e.cache, "star.example.com", "/a", "") || cached(e.cache, "star.example.com", "/b", "x=1") { t.Fatal("* did not purge the whole host") } if !cached(e.cache, "other.example.com", "/a", "") { t.Fatal("* purged another host's entries") } } // TestPurgeHeaderCannotTouchAnotherHost — the isolation half. Every shape // that smuggles an authority must be IGNORED, never reinterpreted: neither // the named foreign host's entry nor the serving host's same-path entry may // be touched by them. func TestPurgeHeaderCannotTouchAnotherHost(t *testing.T) { smugglers := []string{ "https://a.example.com/x", "http://a.example.com/x", "//a.example.com/x", `/\a.example.com/x`, "a.example.com/x", "x", "", "a.example.com", "*.a.example.com", } for _, target := range smugglers { t.Run(fmt.Sprintf("%q", target), func(t *testing.T) { e, _ := purgeEdge(t, "b.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", target) w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "a.example.com", "/x", "", "host A") seed(t, e.cache, "b.example.com", "/x", "", "host B") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://b.example.com/write", nil)) if !cached(e.cache, "a.example.com", "/x", "") { t.Fatalf("target %q purged another host's entry", target) } if !cached(e.cache, "b.example.com", "/x", "") { t.Fatalf("target %q was reinterpreted as the path /x on the serving host", target) } }) } } // TestPurgeHeaderMixedValidAndSmuggled — a rejected target does not stop the // valid ones beside it from being honoured. func TestPurgeHeaderMixedValidAndSmuggled(t *testing.T) { e, _ := purgeEdge(t, "mix.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", "https://a.example.com/x, /good, //a.example.com/y") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "a.example.com", "/x", "", "A x") seed(t, e.cache, "a.example.com", "/y", "", "A y") seed(t, e.cache, "mix.example.com", "/good", "", "good") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://mix.example.com/write", nil)) if cached(e.cache, "mix.example.com", "/good", "") { t.Fatal("/good was not purged: one bad target discarded the whole header") } if !cached(e.cache, "a.example.com", "/x", "") || !cached(e.cache, "a.example.com", "/y", "") { t.Fatal("a smuggled target reached another host") } } // TestListValuedPurgeHeader — the fourth instance of this package's // list-valued-header bug class, caught before merge this time. The header // split across two Add() calls must be fully honoured. func TestListValuedPurgeHeader(t *testing.T) { e, _ := purgeEdge(t, "list.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Carlos-Purge", "/a") w.Header().Add("Carlos-Purge", "/b, /c") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "list.example.com", "/a", "", "a") seed(t, e.cache, "list.example.com", "/b", "", "b") seed(t, e.cache, "list.example.com", "/c", "", "c") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://list.example.com/write", nil)) for _, p := range []string{"/a", "/b", "/c"} { if cached(e.cache, "list.example.com", p, "") { t.Fatalf("%s survived: only the first Carlos-Purge line was read", p) } } if got := rec.Header().Values("Carlos-Purge"); len(got) != 0 { t.Fatalf("Carlos-Purge lines reached the client: %v", got) } } // TestListValuedPurgeHeaderStarOnSecondLine — `*` hiding on a later line // still purges the whole host. func TestListValuedPurgeHeaderStarOnSecondLine(t *testing.T) { e, _ := purgeEdge(t, "list2.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Carlos-Purge", "/a") w.Header().Add("Carlos-Purge", "*") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "list2.example.com", "/a", "", "a") seed(t, e.cache, "list2.example.com", "/z", "", "z") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://list2.example.com/write", nil)) _ = rec if cached(e.cache, "list2.example.com", "/z", "") { t.Fatal("* on the second header line was not honoured") } } // TestPurgeHeaderNonCanonicalCase — Go canonicalizes header names off the // wire, so an app writing the lowercase form still lands on Carlos-Purge. func TestPurgeHeaderNonCanonicalCase(t *testing.T) { e, _ := purgeEdge(t, "case.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header()["carlos-purge"] = []string{"/a"} w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "case.example.com", "/a", "", "a") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://case.example.com/write", nil)) if cached(e.cache, "case.example.com", "/a", "") { t.Fatal("lowercase carlos-purge was not honoured") } if got := rec.Header().Get("Carlos-Purge"); got != "" { t.Fatalf("Carlos-Purge reached the client: %q", got) } } // TestPurgeHeaderEmptyAndWhitespaceValues — an empty header, and a header of // nothing but separators and spaces, purge nothing and break nothing. func TestPurgeHeaderEmptyAndWhitespaceValues(t *testing.T) { for _, value := range []string{"", " ", ",,,", " , , "} { t.Run(fmt.Sprintf("%q", value), func(t *testing.T) { e, _ := purgeEdge(t, "empty.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", value) w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "empty.example.com", "/a", "", "a") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://empty.example.com/write", nil)) if !cached(e.cache, "empty.example.com", "/a", "") { t.Fatalf("value %q purged something", value) } if got := rec.Header().Get("Carlos-Purge"); got != "" { t.Fatalf("Carlos-Purge reached the client: %q", got) } }) } } // TestPurgeHeaderSurroundingWhitespace — targets are trimmed, so the // human-readable ", " form and a tab-padded one both work. func TestPurgeHeaderSurroundingWhitespace(t *testing.T) { e, _ := purgeEdge(t, "ws.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", " /a ,\t/b\t") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "ws.example.com", "/a", "", "a") seed(t, e.cache, "ws.example.com", "/b", "", "b") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://ws.example.com/write", nil)) _ = rec if cached(e.cache, "ws.example.com", "/a", "") || cached(e.cache, "ws.example.com", "/b", "") { t.Fatal("whitespace-padded targets were not honoured") } } // TestPurgeHeaderOnNon2xx — the purge is decoupled from cacheability: a 302 // and a 404 both purge, and neither is stored. func TestPurgeHeaderOnNon2xx(t *testing.T) { for _, status := range []int{http.StatusFound, http.StatusNotFound, http.StatusInternalServerError} { t.Run(http.StatusText(status), func(t *testing.T) { e, _ := purgeEdge(t, "nx.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", "/a") w.Header().Set("Cache-Control", "max-age=60") w.WriteHeader(status) io.WriteString(w, "body") }) seed(t, e.cache, "nx.example.com", "/a", "", "a") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("GET", "http://nx.example.com/thing", nil)) if rec.Code != status { t.Fatalf("status = %d, want %d", rec.Code, status) } if cached(e.cache, "nx.example.com", "/a", "") { t.Fatalf("a %d response did not purge", status) } if cached(e.cache, "nx.example.com", "/thing", "") { t.Fatalf("a %d response was cached", status) } }) } } // TestPurgeHeaderBoundsDegradeToHost — over either bound the edge over-purges // (whole host) rather than partially applying. Freshness above cleverness. func TestPurgeHeaderBoundsDegradeToHost(t *testing.T) { tooMany := make([]string, maxPurgeTargets+1) for i := range tooMany { tooMany[i] = fmt.Sprintf("/p%d", i) } cases := map[string]string{ "too many targets": strings.Join(tooMany, ","), "oversized header": "/" + strings.Repeat("a", maxPurgeHeaderBytes), } for name, value := range cases { t.Run(name, func(t *testing.T) { e, _ := purgeEdge(t, "cap.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", value) w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "cap.example.com", "/untouched", "", "u") seed(t, e.cache, "other.example.com", "/untouched", "", "o") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://cap.example.com/write", nil)) if cached(e.cache, "cap.example.com", "/untouched", "") { t.Fatal("over-bound header did not degrade to a whole-host purge") } if !cached(e.cache, "other.example.com", "/untouched", "") { t.Fatal("degrading to a host purge crossed the host boundary") } if got := rec.Header().Get("Carlos-Purge"); got != "" { t.Fatalf("Carlos-Purge reached the client: %q", got) } }) } } // TestPurgeHeaderAtBound — exactly maxPurgeTargets is honoured target by // target, not degraded. Pins the boundary from the other side. func TestPurgeHeaderAtBound(t *testing.T) { targets := make([]string, maxPurgeTargets) for i := range targets { targets[i] = fmt.Sprintf("/p%d", i) } e, _ := purgeEdge(t, "atcap.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", strings.Join(targets, ",")) w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "atcap.example.com", "/p0", "", "p0") seed(t, e.cache, "atcap.example.com", "/untouched", "", "u") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://atcap.example.com/write", nil)) _ = rec if cached(e.cache, "atcap.example.com", "/p0", "") { t.Fatal("/p0 was not purged at exactly the target bound") } if !cached(e.cache, "atcap.example.com", "/untouched", "") { t.Fatal("exactly maxPurgeTargets degraded to a whole-host purge") } } // TestPurgeHeaderNeverReadFromRequest — a client-supplied Carlos-Purge is // exactly the externally-reachable purge endpoint the parent spec rejects as // a wake-storm vector. It must do nothing. func TestPurgeHeaderNeverReadFromRequest(t *testing.T) { e, _ := purgeEdge(t, "req.example.com", false, func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") }) seed(t, e.cache, "req.example.com", "/a", "", "a") req := httptest.NewRequest("POST", "http://req.example.com/write", nil) req.Header.Set("Carlos-Purge", "*") rec := httptest.NewRecorder() e.ServeHTTP(rec, req) if !cached(e.cache, "req.example.com", "/a", "") { t.Fatal("a request-borne Carlos-Purge purged the cache") } } // TestPurgeHeaderWithoutCacheIsStillStripped — with caching disabled there is // nothing to purge, but the header is an edge-internal signal and must still // never reach a client. func TestPurgeHeaderWithoutCacheIsStillStripped(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", "/a") io.WriteString(w, "ok") })) t.Cleanup(backend.Close) reg := openReg(t) if err := reg.Upsert(registry.Route{Host: "nocache.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { t.Fatalf("upsert: %v", err) } e, err := New(reg, nil) if err != nil { t.Fatalf("New: %v", err) } rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://nocache.example.com/write", nil)) if got := rec.Header().Get("Carlos-Purge"); got != "" { t.Fatalf("Carlos-Purge reached the client with caching disabled: %q", got) } } // TestPurgeHeaderOnUpgradeBypassPath — the upgrade path skips caching AND the // recordingResponseWriter entirely (edge.go's isUpgradeRequest branch), so it // is the one proxied path where a purge hook placed anywhere but // ModifyResponse would silently not fire. This drives a REAL 101 handshake // over a real socket, which is the only way that branch is exercised end to // end. func TestPurgeHeaderOnUpgradeBypassPath(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { conn, buf, err := w.(http.Hijacker).Hijack() if err != nil { t.Errorf("backend hijack: %v", err) return } defer conn.Close() fmt.Fprint(buf, "HTTP/1.1 101 Switching Protocols\r\n"+ "Upgrade: carlos-test\r\n"+ "Connection: Upgrade\r\n"+ "Carlos-Purge: /live\r\n\r\n") buf.Flush() // Hold the tunnel open long enough for the client to read the // handshake response. io.Copy(io.Discard, buf) })) t.Cleanup(backend.Close) reg := openReg(t) if err := reg.Upsert(registry.Route{Host: "ws.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { t.Fatalf("upsert: %v", err) } e, err := New(reg, nil) if err != nil { t.Fatalf("New: %v", err) } e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) seed(t, e.cache, "ws.example.com", "/live", "", "live") srv := httptest.NewServer(e) t.Cleanup(srv.Close) conn, err := net.Dial("tcp", srv.Listener.Addr().String()) if err != nil { t.Fatalf("dial edge: %v", err) } defer conn.Close() fmt.Fprint(conn, "GET /socket HTTP/1.1\r\n"+ "Host: ws.example.com\r\n"+ "Upgrade: carlos-test\r\n"+ "Connection: Upgrade\r\n\r\n") conn.SetReadDeadline(time.Now().Add(10 * time.Second)) resp, err := http.ReadResponse(bufio.NewReader(conn), nil) if err != nil { t.Fatalf("read upgrade response: %v", err) } if resp.StatusCode != http.StatusSwitchingProtocols { t.Fatalf("status = %d, want 101", resp.StatusCode) } if got := resp.Header.Get("Carlos-Purge"); got != "" { t.Fatalf("Carlos-Purge survived onto the upgrade response: %q", got) } if cached(e.cache, "ws.example.com", "/live", "") { t.Fatal("upgrade-bypass path did not honour Carlos-Purge") } } // TestPurgeHeaderPurgesEveryVaryVariant — a target names a URL, and purging // it drops ALL stored variants of that URL (existing PurgeURL semantics). func TestPurgeHeaderPurgesEveryVaryVariant(t *testing.T) { e, _ := purgeEdge(t, "vary.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", "/page") w.WriteHeader(http.StatusOK) }) base := CacheKey{Host: "vary.example.com", Method: "GET", Path: "/page"} for _, lang := range []string{"en", "fr"} { k := base k.VaryValue = "Accept-Language=" + lang + ";" e.cache.Put(k, "Accept-Language", &CachedResponse{ StatusCode: 200, Header: http.Header{}, Body: []byte(lang), StoredAt: time.Now(), TTL: time.Minute, }) } rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://vary.example.com/write", nil)) _ = rec for _, lang := range []string{"en", "fr"} { k := base k.VaryValue = "Accept-Language=" + lang + ";" if _, ok := e.cache.get(k); ok { t.Fatalf("Vary variant %q survived a URL purge", lang) } } } // TestPurgeHeaderHEADEntriesToo — PurgeURL drops GET and HEAD together; a // write invalidates both. func TestPurgeHeaderHEADEntriesToo(t *testing.T) { e, _ := purgeEdge(t, "head.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", "/page") w.WriteHeader(http.StatusOK) }) k := CacheKey{Host: "head.example.com", Method: "HEAD", Path: "/page"} e.cache.Put(k, "", &CachedResponse{StatusCode: 200, Header: http.Header{}, StoredAt: time.Now(), TTL: time.Minute}) rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://head.example.com/write", nil)) _ = rec if _, ok := e.cache.get(k); ok { t.Fatal("the HEAD entry for a purged URL survived") } } // TestValidPurgeTarget is the unit-level table for the shape gate, including // the paths that must stay VALID — a query string, a path containing a colon // (which is not an authority once the value starts with a single slash), and // a trailing slash. func TestValidPurgeTarget(t *testing.T) { valid := []string{"/", "/a", "/a/b/", "/events/42", "/events/42?lang=fr", "/a:b", "/x://y", "/a%20b", "/a?b=c&d=e"} invalid := []string{"", "*", "a", "a/b", "//host", `/\host`, "http://h/x", "https://h/x", "//", `//\`, "/a b", "/a\tb", "/a\nb", "/a\x00b", "/a\x7f"} for _, v := range valid { if !validPurgeTarget(v) { t.Errorf("validPurgeTarget(%q) = false, want true", v) } } for _, v := range invalid { if validPurgeTarget(v) { t.Errorf("validPurgeTarget(%q) = true, want false", v) } } } // TestPurgeHeaderScrubbedFromRequestBeforeUpstream — Carlos-Purge is an // edge-internal signal in BOTH directions. TestPurgeHeaderNeverReadFromRequest // pins that the edge itself ignores a client-supplied one; this pins that the // client-supplied one never reaches the APP either. // // It matters because the app is the other half of this contract. An app that // reflects request headers onto its response (a debug echo, a framework's // "copy these through" list, a CORS mirror) would turn a client-supplied // Carlos-Purge into an app-declared one — and that one IS honoured, which // hands any client on the internet the purge channel the whole design exists // to keep unreachable. Scrubbing it on the way in means no app can be tricked // into laundering it, regardless of how the app behaves. func TestPurgeHeaderScrubbedFromRequestBeforeUpstream(t *testing.T) { seen := make(chan string, 1) e, _ := purgeEdge(t, "scrub.example.com", false, func(w http.ResponseWriter, r *http.Request) { seen <- strings.Join(r.Header.Values(PurgeHeader), "|") io.WriteString(w, "ok") }) req := httptest.NewRequest("POST", "http://scrub.example.com/write", nil) req.Header.Add(PurgeHeader, "*") req.Header.Add(PurgeHeader, "/a") rec := httptest.NewRecorder() e.ServeHTTP(rec, req) if got := <-seen; got != "" { t.Fatalf("upstream saw Carlos-Purge %q — an echoing app would launder it into a real purge", got) } } // The same scrub on the unix-socket proxy: unixProxy and tcpProxy have // separate Rewrite funcs, so a fix applied to one is not applied to the other. func TestPurgeHeaderScrubbedFromRequestOverUnixSocket(t *testing.T) { sock := filepath.Join(t.TempDir(), "u.sock") ln, err := net.Listen("unix", sock) if err != nil { if errors.Is(err, syscall.EPERM) || strings.Contains(err.Error(), "operation not permitted") { t.Skipf("unix-socket listeners not permitted in this environment: %v", err) } t.Fatalf("listen unix: %v", err) } seen := make(chan string, 1) backend := &httptest.Server{ Listener: ln, Config: &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen <- strings.Join(r.Header.Values(PurgeHeader), "|") io.WriteString(w, "ok") })}, } backend.Start() t.Cleanup(backend.Close) reg := openReg(t) if err := reg.Upsert(registry.Route{Host: "sockscrub.example.com", Kind: "instance", Socket: sock, Status: "ready"}); err != nil { t.Fatalf("upsert: %v", err) } e, err := New(reg, nil) if err != nil { t.Fatalf("New: %v", err) } e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) req := httptest.NewRequest("POST", "http://sockscrub.example.com/write", nil) req.Header.Set(PurgeHeader, "*") rec := httptest.NewRecorder() e.ServeHTTP(rec, req) if got := <-seen; got != "" { t.Fatalf("unix-socket upstream saw Carlos-Purge %q", got) } } // --------------------------------------------------------------------------- // Query-variant semantics: a bare path means EVERY query variant. // --------------------------------------------------------------------------- // TestPurgeHeaderBarePathPurgesEveryQueryVariant is the contract change. An // app that writes /events/42 and declares "/events/42" means the page is // stale — all of it. Keying the purge on the exact empty query left // /events/42?utm_source=… (a campaign link) and /events/42?lang=fr serving the // pre-write body until their TTLs expired, which are precisely the copies a // reader arrives on. func TestPurgeHeaderBarePathPurgesEveryQueryVariant(t *testing.T) { e, _ := purgeEdge(t, "q.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", "/events/42") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "q.example.com", "/events/42", "", "bare") seed(t, e.cache, "q.example.com", "/events/42", "utm_source=newsletter", "utm") seed(t, e.cache, "q.example.com", "/events/42", "lang=fr", "fr") // A HEAD entry under a query, to prove both method groups of byPath are // swept and not just GET. headKey := CacheKey{Host: "q.example.com", Method: "HEAD", Path: "/events/42", RawQuery: "lang=fr"} e.cache.Put(headKey, "", &CachedResponse{StatusCode: 200, Header: http.Header{}, StoredAt: time.Now(), TTL: time.Minute}) // Neighbours that share a query but not the path must be untouched. seed(t, e.cache, "q.example.com", "/events/43", "lang=fr", "neighbour") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://q.example.com/events/42/edit", nil)) _ = rec for _, rq := range []string{"", "utm_source=newsletter", "lang=fr"} { if cached(e.cache, "q.example.com", "/events/42", rq) { t.Fatalf("/events/42?%s survived a bare-path purge", rq) } } if _, ok := e.cache.get(headKey); ok { t.Fatal("the HEAD entry under a query survived a bare-path purge") } if !cached(e.cache, "q.example.com", "/events/43", "lang=fr") { t.Fatal("a bare-path purge crossed into a different path") } } // TestPurgeHeaderQueryFormPurgesOnlyThatVariant — the other half of the // contract: WITH a "?" the target is exact, so an app that really does mean // "only the French one" still has that. func TestPurgeHeaderQueryFormPurgesOnlyThatVariant(t *testing.T) { e, _ := purgeEdge(t, "qx.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", "/events/42?lang=fr") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "qx.example.com", "/events/42", "", "bare") seed(t, e.cache, "qx.example.com", "/events/42", "lang=fr", "fr") seed(t, e.cache, "qx.example.com", "/events/42", "lang=de", "de") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://qx.example.com/write", nil)) _ = rec if cached(e.cache, "qx.example.com", "/events/42", "lang=fr") { t.Fatal("the named variant was not purged") } for _, rq := range []string{"", "lang=de"} { if !cached(e.cache, "qx.example.com", "/events/42", rq) { t.Fatalf("/events/42?%s was purged, but a ?-form target is exact", rq) } } } // TestPurgeHeaderEmptyQueryFormIsExact — "/events/42?" is how an app asks for // the unqueried variant ALONE, now that the bare form means "all of them". func TestPurgeHeaderEmptyQueryFormIsExact(t *testing.T) { e, _ := purgeEdge(t, "qe.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", "/events/42?") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "qe.example.com", "/events/42", "", "bare") seed(t, e.cache, "qe.example.com", "/events/42", "lang=fr", "fr") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://qe.example.com/write", nil)) _ = rec if cached(e.cache, "qe.example.com", "/events/42", "") { t.Fatal(`"/events/42?" did not purge the empty-query variant`) } if !cached(e.cache, "qe.example.com", "/events/42", "lang=fr") { t.Fatal(`"/events/42?" purged a queried variant — the ?-form is exact`) } } // --------------------------------------------------------------------------- // Percent-encoding: path decoded, query raw — matching how the key is stored. // --------------------------------------------------------------------------- // TestPurgeHeaderPercentEncodedPathDecodes — edge.go stores CacheKey.Path from // r.URL.Path, which net/url has ALREADY decoded. A target is therefore // PathUnescape'd before it is used as a key, or "/caf%C3%A9" would purge // nothing at all while looking like it worked. func TestPurgeHeaderPercentEncodedPathDecodes(t *testing.T) { for _, tc := range []struct{ target, stored string }{ {"/a%20b", "/a b"}, {"/caf%C3%A9", "/café"}, {"/a%2Fb", "/a/b"}, } { t.Run(tc.target, func(t *testing.T) { e, _ := purgeEdge(t, "pct.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", tc.target) w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "pct.example.com", tc.stored, "", "old") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://pct.example.com/write", nil)) _ = rec if cached(e.cache, "pct.example.com", tc.stored, "") { t.Fatalf("target %q did not purge the entry stored under %q", tc.target, tc.stored) } }) } } // TestPurgeHeaderQueryIsComparedRaw pins the other side of the asymmetry. // CacheKey.RawQuery is r.URL.RawQuery — the query byte-for-byte as received, // never decoded — so a declared query must match RAW, not decoded. func TestPurgeHeaderQueryIsComparedRaw(t *testing.T) { e, _ := purgeEdge(t, "rawq.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", "/s?q=caf%C3%A9, /s2?q=caf%C3%A9") w.WriteHeader(http.StatusOK) }) // Stored exactly as a request would arrive percent-encoded: raw matches raw. seed(t, e.cache, "rawq.example.com", "/s", "q=caf%C3%A9", "encoded") // Stored under a DECODED query. Nothing can arrive this way over the wire, // and the raw-vs-raw comparison must not reach it. seed(t, e.cache, "rawq.example.com", "/s2", "q=café", "decoded") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://rawq.example.com/write", nil)) _ = rec if cached(e.cache, "rawq.example.com", "/s", "q=caf%C3%A9") { t.Fatal("a raw-encoded query target did not match the raw-encoded stored key") } if !cached(e.cache, "rawq.example.com", "/s2", "q=café") { t.Fatal("the query half was decoded before comparison — store and purge must agree on RAW") } } // TestPurgeTargetKey is the unit table for the decode layer: what survives the // raw gate can still be refused once decoded, because decoding manufactures // the very shapes the raw gate exists to refuse. func TestPurgeTargetKey(t *testing.T) { type want struct { path string rawQuery string exact bool } ok := map[string]want{ "/a": {"/a", "", false}, "/events/42": {"/events/42", "", false}, "/events/42?lang=fr": {"/events/42", "lang=fr", true}, "/events/42?": {"/events/42", "", true}, "/a%20b": {"/a b", "", false}, // decoded space is a real path byte "/caf%C3%A9": {"/café", "", false}, // decoded UTF-8 "/a%2Fb": {"/a/b", "", false}, // encoded slash decodes mid-path "/s?q=caf%C3%A9": {"/s", "q=caf%C3%A9", true}, // query kept RAW "/a:b": {"/a:b", "", false}, } for target, w := range ok { p, q, exact, good := purgeTargetKey(target) if !good { t.Errorf("purgeTargetKey(%q) refused a valid target", target) continue } if p != w.path || q != w.rawQuery || exact != w.exact { t.Errorf("purgeTargetKey(%q) = (%q, %q, %v), want (%q, %q, %v)", target, p, q, exact, w.path, w.rawQuery, w.exact) } } // Refused: undecodable, and decoded-into-a-smuggle or a control byte. bad := []string{ "/a%zz", // not hex "/a%A", // truncated escape "/%2Fhost", // decodes to //host — protocol-relative "/%2F%2Fhost", // decodes to ///host `/%5Chost`, // decodes to /\host — the backslash smuggle "/a%00b", // decoded NUL "/a%0ab", // decoded LF — log-injection shaped "/a%09b", // decoded TAB "/a%7f", // decoded DEL "//host", // refused by the raw gate already "/a b", // raw space: malformed as declared "", "*", } for _, target := range bad { if _, _, _, good := purgeTargetKey(target); good { t.Errorf("purgeTargetKey(%q) accepted a target it must refuse", target) } } } // TestPurgeHeaderDecodedSmuggleIsRefusedEndToEnd — the decode re-check at the // level that matters: a target that decodes into a protocol-relative shape // must purge nothing, not be honoured as a path. func TestPurgeHeaderDecodedSmuggleIsRefusedEndToEnd(t *testing.T) { // "/%2Fa.example.com/x" decodes to "//a.example.com/x" — leading slash plus // one decoded slash. (Note "/%2F%2F…" would decode to THREE slashes and // match nothing, which would make this test pass vacuously.) e, _ := purgeEdge(t, "dec.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", "/%2Fa.example.com/x, /good") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "a.example.com", "/x", "", "host A") seed(t, e.cache, "dec.example.com", "//a.example.com/x", "", "decoded shape") seed(t, e.cache, "dec.example.com", "/good", "", "good") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://dec.example.com/write", nil)) _ = rec if !cached(e.cache, "a.example.com", "/x", "") { t.Fatal("a decoded smuggle reached another host") } if !cached(e.cache, "dec.example.com", "//a.example.com/x", "") { t.Fatal("a target that decodes to //host was honoured as a path") } if cached(e.cache, "dec.example.com", "/good", "") { t.Fatal("the valid target beside it was discarded") } } // --------------------------------------------------------------------------- // The narrowed contract: FINAL response headers only. // --------------------------------------------------------------------------- // TestPurgeHeaderOn1xxIsNotHonoured pins a documented GAP rather than a fix. // // ModifyResponse is handed the FINAL response only; the stdlib forwards 1xx // informational blocks to the client on a separate path that never reaches // this package. So a Carlos-Purge on a 103 Early Hints block is neither // honoured NOR stripped — the client sees it, and the assertion below records // that as the truth rather than pretending otherwise. // // It stays a gap on purpose. Catching it would mean wrapping the client's // ResponseWriter to filter 1xx writes, and the upgrade path deliberately hands // the RAW ResponseWriter to the stdlib — a wrapper there re-opens the // WebSocket-breakage class this package has already paid for. Narrowing the // promise is the cheaper, honest trade. func TestPurgeHeaderOn1xxIsNotHonoured(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Declared on the 103 block only, then removed so the FINAL response // carries nothing — isolating the informational path. w.Header().Set("Carlos-Purge", "/early") w.WriteHeader(http.StatusEarlyHints) w.Header().Del("Carlos-Purge") io.WriteString(w, "ok") })) t.Cleanup(backend.Close) reg := openReg(t) if err := reg.Upsert(registry.Route{Host: "early.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { t.Fatalf("upsert: %v", err) } e, err := New(reg, nil) if err != nil { t.Fatalf("New: %v", err) } e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) seed(t, e.cache, "early.example.com", "/early", "", "still here") srv := httptest.NewServer(e) t.Cleanup(srv.Close) var got1xx string req, err := http.NewRequest("POST", srv.URL+"/write", nil) if err != nil { t.Fatalf("new request: %v", err) } req.Host = "early.example.com" trace := &httptrace.ClientTrace{ Got1xxResponse: func(code int, h textproto.MIMEHeader) error { if code == http.StatusEarlyHints { got1xx = h.Get("Carlos-Purge") } return nil }, } req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("do: %v", err) } io.Copy(io.Discard, resp.Body) resp.Body.Close() // The load-bearing assertion: a 1xx declaration purges NOTHING. if !cached(e.cache, "early.example.com", "/early", "") { t.Fatal("a Carlos-Purge on a 103 was honoured — the contract says final-response headers only") } // And the documented consequence, pinned so a future stdlib change that // silently starts stripping it shows up here as a surprise rather than a // quiet behaviour drift. if got1xx != "/early" { t.Logf("note: the 103 block did not carry Carlos-Purge to the client (got %q); "+ "the contract permits either, but this used to reach the client", got1xx) } if got := resp.Header.Get("Carlos-Purge"); got != "" { t.Fatalf("the FINAL response carried Carlos-Purge: %q", got) } } // TestPurgeHeaderInTrailerIsNotHonoured — the other half of the narrowing. A // header announced via Trailer and sent after the body is not in resp.Header // when ModifyResponse runs, so it is neither honoured nor stripped. func TestPurgeHeaderInTrailerIsNotHonoured(t *testing.T) { e, _ := purgeEdge(t, "trail.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Trailer", "Carlos-Purge") w.WriteHeader(http.StatusOK) io.WriteString(w, "body") // Set AFTER the body: net/http moves this into the trailer block. w.Header().Set("Carlos-Purge", "/trailed") }) seed(t, e.cache, "trail.example.com", "/trailed", "", "still here") srv := httptest.NewServer(e) t.Cleanup(srv.Close) req, err := http.NewRequest("POST", srv.URL+"/write", nil) if err != nil { t.Fatalf("new request: %v", err) } req.Host = "trail.example.com" resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("do: %v", err) } body, _ := io.ReadAll(resp.Body) resp.Body.Close() // Proves the backend actually ran and the trailer was actually sent — // without this the assertion below could pass because nothing happened. if string(body) != "body" { t.Fatalf("body = %q, want %q", body, "body") } if got := resp.Trailer.Get("Carlos-Purge"); got != "/trailed" { t.Fatalf("the trailer did not arrive (got %q) — this test proves nothing without it", got) } if !cached(e.cache, "trail.example.com", "/trailed", "") { t.Fatal("a Carlos-Purge sent as a TRAILER was honoured — the contract says final-response headers only") } } // TestPurgeHeaderListedInConnectionIsEatenBeforeTheHook is the footgun the // contract now names explicitly. httputil.ReverseProxy calls // removeHopByHopHeaders(res.Header) BEFORE ModifyResponse, so an app that // names Carlos-Purge in its Connection header has its declaration deleted // before this package can ever see it — it purges nothing, silently, and the // app author has no way to tell from the outside. func TestPurgeHeaderListedInConnectionIsEatenBeforeTheHook(t *testing.T) { e, _ := purgeEdge(t, "conn.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Connection", "Carlos-Purge") w.Header().Set("Carlos-Purge", "/conn") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "conn.example.com", "/conn", "", "still here") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://conn.example.com/write", nil)) if !cached(e.cache, "conn.example.com", "/conn", "") { t.Fatal("a Connection-listed Carlos-Purge was honoured — if the stdlib stopped " + "treating it as hop-by-hop, the contract note about Connection is now wrong") } if got := rec.Header().Get("Carlos-Purge"); got != "" { t.Fatalf("Carlos-Purge reached the client: %q", got) } } // --------------------------------------------------------------------------- // Log rate-limiting. // --------------------------------------------------------------------------- // TestPurgeHeaderComplaintLogIsRateLimited — every one of these lines is // triggered by a remote party's response on the request hot path, so an app // that emits a malformed declaration on every response would write one journal // line per request forever. Same first-then-every-300th shape as the registry // watcher in edge.go. func TestPurgeHeaderComplaintLogIsRateLimited(t *testing.T) { var buf bytes.Buffer log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) e, _ := purgeEdge(t, "noisy.example.com", false, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge", "a.example.com/smuggled") w.WriteHeader(http.StatusOK) }) const requests = 12 for i := 0; i < requests; i++ { rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://noisy.example.com/write", nil)) } got := strings.Count(buf.String(), "malformed Carlos-Purge target(s)") if got != 1 { t.Fatalf("%d requests produced %d complaint lines, want exactly 1 (the first); log:\n%s", requests, got, buf.String()) } // The first is always logged — nothing is silently swallowed. if !strings.Contains(buf.String(), "occurrence #1") { t.Fatalf("the first occurrence was not logged with its count; log:\n%s", buf.String()) } } // --------------------------------------------------------------------------- // Carlos-Purge-Host: redirecting one response's declaration to a SIBLING route // of the same app and account (spec §6.4). // --------------------------------------------------------------------------- // purgeOverrideEdge builds a cached edge over two ready routes sharing one // backend handler — the Tito topology in miniature. `a` is the route the // request is served on (the root domain, where admin edits and Stripe webhooks // land); `b` is the sibling whose public pages are the cached ones // ({slug}.go.tito.io). Callers set Host/App/Account on each; everything else is // filled in here. func purgeOverrideEdge(t *testing.T, a, b registry.Route, h http.HandlerFunc) (*Edge, *registry.Registry) { t.Helper() backend := httptest.NewServer(h) t.Cleanup(backend.Close) reg := openReg(t) for _, rt := range []registry.Route{a, b} { rt.Kind, rt.Addr, rt.Status = "instance", backend.Listener.Addr().String(), "ready" if err := reg.Upsert(rt); err != nil { t.Fatalf("upsert %s: %v", rt.Host, err) } } e, err := New(reg, nil) if err != nil { t.Fatalf("New: %v", err) } e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) return e, reg } // route is shorthand for the three fields these tests actually vary. func route(host, app, account string) registry.Route { return registry.Route{Host: host, App: app, Account: account} } // TestPurgeHostOverridePurgesTheSiblingRoute is the headline of this feature: // the admin/webhook write arrives on the root-domain route while the stale // pages live on the per-tenant route. Same app, same account, so the // declaration is redirected there — and the serving host's own entries are // left alone, because the app named exactly one host and it was not this one. func TestPurgeHostOverridePurgesTheSiblingRoute(t *testing.T) { e, _ := purgeOverrideEdge(t, route("go.tito.example", "tito", "tenanta"), route("acme.go.tito.example", "tito", "tenanta"), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "acme.go.tito.example") w.Header().Set("Carlos-Purge", "/events/42, /events/42?lang=fr") w.WriteHeader(http.StatusFound) }) seed(t, e.cache, "acme.go.tito.example", "/events/42", "", "old") seed(t, e.cache, "acme.go.tito.example", "/events/42", "lang=fr", "vieux") seed(t, e.cache, "acme.go.tito.example", "/events/43", "", "untouched") seed(t, e.cache, "go.tito.example", "/events/42", "", "serving host") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://go.tito.example/admin/events/42", nil)) if cached(e.cache, "acme.go.tito.example", "/events/42", "") { t.Fatal("the override host's /events/42 survived") } if cached(e.cache, "acme.go.tito.example", "/events/42", "lang=fr") { t.Fatal("the override host's /events/42?lang=fr survived") } if !cached(e.cache, "acme.go.tito.example", "/events/43", "") { t.Fatal("/events/43 was purged but nothing named it") } if !cached(e.cache, "go.tito.example", "/events/42", "") { t.Fatal("the SERVING host was purged too — the override redirects, it does not fan out") } if got := rec.Header().Get("Carlos-Purge-Host"); got != "" { t.Fatalf("Carlos-Purge-Host reached the client: %q", got) } } // TestPurgeHostOverrideRefusedAcrossApps — the boundary this feature must // never cross. A different app on the same box is a different tenant, and the // whole declaration is dropped rather than falling back to the serving host: a // half-honoured purge is worse than none, because it looks like it worked. func TestPurgeHostOverrideRefusedAcrossApps(t *testing.T) { e, _ := purgeOverrideEdge(t, route("a.example.com", "app-x", "tenanta"), route("b.example.com", "app-y", "tenanta"), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "b.example.com") w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "a.example.com", "/x", "", "A") seed(t, e.cache, "b.example.com", "/x", "", "B") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil)) if !cached(e.cache, "b.example.com", "/x", "") { t.Fatal("a route of a DIFFERENT APP was purged across the tenancy boundary") } if !cached(e.cache, "a.example.com", "/x", "") { t.Fatal("the refused declaration fell back to the serving host instead of being dropped") } if got := rec.Header().Get("Carlos-Purge-Host"); got != "" { t.Fatalf("Carlos-Purge-Host reached the client: %q", got) } } // TestPurgeHostOverrideRefusedAcrossAccounts — same app name, different // account. App equality alone is not the boundary: two accounts can run the // same app, and one tenant's admin must not reach the other's cache. // // Both fixture accounts must be VALID sqids (release.ValidAccount — // ^[a-z]{3,16}$). The comparison runs through release.RouteAccount, which maps // anything unset or name-shaped onto the built-in ops account, so two // placeholder spellings like "acct-1"/"acct-2" would both resolve to ops and // this test would assert a boundary it was not actually crossing. func TestPurgeHostOverrideRefusedAcrossAccounts(t *testing.T) { e, _ := purgeOverrideEdge(t, route("one.example.com", "tito", "tenanta"), route("two.example.com", "tito", "tenantb"), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "two.example.com") w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "one.example.com", "/x", "", "one") seed(t, e.cache, "two.example.com", "/x", "", "two") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://one.example.com/write", nil)) if !cached(e.cache, "two.example.com", "/x", "") { t.Fatal("a route of a DIFFERENT ACCOUNT was purged") } if !cached(e.cache, "one.example.com", "/x", "") { t.Fatal("the refused declaration fell back to the serving host") } } // TestPurgeHostOverrideUnknownHostIsRefused — a host the route table does not // know cannot be validated against anything, so the declaration is dropped. // Note the seeded entry: the cache is keyed by host with no reference to the // route table, so an unvalidated override would happily purge a host that has // no route at all. func TestPurgeHostOverrideUnknownHostIsRefused(t *testing.T) { e, _ := purgeOverrideEdge(t, route("known.example.com", "tito", "tenanta"), route("sibling.example.com", "tito", "tenanta"), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "ghost.example.com") w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "ghost.example.com", "/x", "", "ghost") seed(t, e.cache, "known.example.com", "/x", "", "known") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://known.example.com/write", nil)) if !cached(e.cache, "ghost.example.com", "/x", "") { t.Fatal("an unrouted host's entries were purged") } if !cached(e.cache, "known.example.com", "/x", "") { t.Fatal("the refused declaration fell back to the serving host") } } // TestPurgeHostOverrideTwoValuesPurgeTheServedOne — this used to pin "at most ONE // host", with two names refused as malformed. #173 made the header a list, so // two names now mean "purge both" — on separate lines or comma-joined on one // (RFC 9110 §5.3). c.example.com has no route here, so it is skipped rather // than purged (see TestPurgeHostListSkipsUnservedHosts), and the serving host // a.example.com is untouched because neither spelling names it. func TestPurgeHostOverrideTwoValuesPurgeTheServedOne(t *testing.T) { for _, tc := range []struct { name string set func(http.Header) }{ {"two lines", func(h http.Header) { h.Add("Carlos-Purge-Host", "b.example.com") h.Add("Carlos-Purge-Host", "c.example.com") }}, {"comma joined", func(h http.Header) { h.Set("Carlos-Purge-Host", "b.example.com, c.example.com") }}, } { t.Run(tc.name, func(t *testing.T) { e, _ := purgeOverrideEdge(t, route("a.example.com", "tito", "tenanta"), route("b.example.com", "tito", "tenanta"), func(w http.ResponseWriter, r *http.Request) { tc.set(w.Header()) w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "a.example.com", "/x", "", "A") seed(t, e.cache, "b.example.com", "/x", "", "B") seed(t, e.cache, "c.example.com", "/x", "", "C") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil)) if cached(e.cache, "b.example.com", "/x", "") { t.Fatal("b.example.com survived a list that named it") } for _, h := range []string{"a.example.com", "c.example.com"} { if !cached(e.cache, h, "/x", "") { t.Fatalf("%s was purged, but it is unnamed or unserved", h) } } }) } } // TestPurgeHostOverrideMalformedValuesRefused — the value is a bare hostname // and nothing else. Every shape below is refused outright rather than // normalized down to a hostname: an authority with a port is a different // string from a host key, and "strip it and carry on" is how a purge lands // somewhere its author did not name. func TestPurgeHostOverrideMalformedValuesRefused(t *testing.T) { for _, bad := range []string{ "b.example.com:443", "https://b.example.com", "//b.example.com", `b.example.com\`, "B.example.com", "b.example.com/", "b.example.com/x", "b example.com", "user@b.example.com", "b.example.com?x=1", "b.example.com#f", "", } { t.Run(fmt.Sprintf("%q", bad), func(t *testing.T) { e, _ := purgeOverrideEdge(t, route("a.example.com", "tito", "tenanta"), route("b.example.com", "tito", "tenanta"), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", bad) w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "a.example.com", "/x", "", "A") seed(t, e.cache, "b.example.com", "/x", "", "B") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil)) if !cached(e.cache, "b.example.com", "/x", "") { t.Fatalf("Carlos-Purge-Host %q was normalized into a real host and honoured", bad) } if !cached(e.cache, "a.example.com", "/x", "") { t.Fatalf("Carlos-Purge-Host %q fell back to the serving host", bad) } }) } } // TestPurgeHostOverrideStarPurgesTheOverrideHost — "*" is a target like any // other, so the override redirects it too: the whole SIBLING host is purged // and the serving host is untouched. func TestPurgeHostOverrideStarPurgesTheOverrideHost(t *testing.T) { e, _ := purgeOverrideEdge(t, route("a.example.com", "tito", "tenanta"), route("b.example.com", "tito", "tenanta"), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "b.example.com") w.Header().Set("Carlos-Purge", "*") w.WriteHeader(http.StatusNoContent) }) seed(t, e.cache, "b.example.com", "/one", "", "1") seed(t, e.cache, "b.example.com", "/two", "x=1", "2") seed(t, e.cache, "a.example.com", "/one", "", "serving") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/deploy", nil)) if cached(e.cache, "b.example.com", "/one", "") || cached(e.cache, "b.example.com", "/two", "x=1") { t.Fatal("* did not purge the whole OVERRIDE host") } if !cached(e.cache, "a.example.com", "/one", "") { t.Fatal("* purged the serving host as well as the override host") } } // TestPurgeHostOverrideCapDegradeHitsTheOverrideHost — the degrade path // (§6.1's bounds) must follow the override too. Degrading to the SERVING // host's PurgeHost would be the worst of both: it wipes a host nobody asked // about and leaves the stale pages the app was trying to invalidate. func TestPurgeHostOverrideCapDegradeHitsTheOverrideHost(t *testing.T) { targets := make([]string, maxPurgeTargets+1) for i := range targets { targets[i] = fmt.Sprintf("/p/%d", i) } e, _ := purgeOverrideEdge(t, route("a.example.com", "tito", "tenanta"), route("b.example.com", "tito", "tenanta"), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "b.example.com") w.Header().Set("Carlos-Purge", strings.Join(targets, ", ")) w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "b.example.com", "/unnamed", "", "B") seed(t, e.cache, "a.example.com", "/unnamed", "", "A") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil)) if cached(e.cache, "b.example.com", "/unnamed", "") { t.Fatal("the cap degrade did not purge the OVERRIDE host") } if !cached(e.cache, "a.example.com", "/unnamed", "") { t.Fatal("the cap degrade purged the SERVING host instead of the override host") } } // TestPurgeHostOverrideResolvesAgainstTheCurrentRouteTable — the override is // validated against e.current() at RESPONSE time, not against a table snapshot // taken when the proxy was built. A modifier that captured the map at build // time would hold the table as it was BEFORE its own reload, so a route added // by the very reload that rebuilt this proxy would be invisible to it. func TestPurgeHostOverrideResolvesAgainstTheCurrentRouteTable(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "late.example.com") w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) })) t.Cleanup(backend.Close) reg := openReg(t) addr := backend.Listener.Addr().String() if err := reg.Upsert(registry.Route{Host: "early.example.com", App: "tito", Account: "tenanta", Kind: "instance", Addr: addr, Status: "ready"}); err != nil { t.Fatalf("upsert: %v", err) } e, err := New(reg, nil) if err != nil { t.Fatalf("New: %v", err) } e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) seed(t, e.cache, "late.example.com", "/x", "", "late") // The sibling arrives after the edge was built, exactly as `carlos add` // does in production; the watcher's Reload picks it up. if err := reg.Upsert(registry.Route{Host: "late.example.com", App: "tito", Account: "tenanta", Kind: "instance", Addr: addr, Status: "ready"}); err != nil { t.Fatalf("upsert late: %v", err) } if err := e.Reload(); err != nil { t.Fatalf("reload: %v", err) } rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://early.example.com/write", nil)) if cached(e.cache, "late.example.com", "/x", "") { t.Fatal("a route added by the reload that rebuilt this proxy was invisible to the override check") } } // TestPurgeHostHeaderStrippedWithNoTargets — the strip is unconditional. A // response carrying only Carlos-Purge-Host declares no purge at all, and the // header still must not reach a client. func TestPurgeHostHeaderStrippedWithNoTargets(t *testing.T) { e, _ := purgeOverrideEdge(t, route("a.example.com", "tito", "tenanta"), route("b.example.com", "tito", "tenanta"), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "b.example.com") io.WriteString(w, "ok") }) rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("GET", "http://a.example.com/page", nil)) if got := rec.Header().Get("Carlos-Purge-Host"); got != "" { t.Fatalf("Carlos-Purge-Host reached the client: %q", got) } } // TestPurgeHostHeaderWithoutCacheIsStillStripped — same as its Carlos-Purge // sibling: with caching disabled there is nothing to redirect, but the header // is an edge-internal signal and must never be forwarded. func TestPurgeHostHeaderWithoutCacheIsStillStripped(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "other.example.com") w.Header().Set("Carlos-Purge", "/a") io.WriteString(w, "ok") })) t.Cleanup(backend.Close) reg := openReg(t) if err := reg.Upsert(registry.Route{Host: "nocache2.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { t.Fatalf("upsert: %v", err) } e, err := New(reg, nil) if err != nil { t.Fatalf("New: %v", err) } rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://nocache2.example.com/write", nil)) if got := rec.Header().Get("Carlos-Purge-Host"); got != "" { t.Fatalf("Carlos-Purge-Host reached the client with caching disabled: %q", got) } } // TestPurgeHostHeaderScrubbedFromRequestBeforeUpstream — reflection is still // the attack. A cross-host purge inside one app is a purge an outsider must // not be able to trigger, so the inbound scrub covers this header exactly as // it covers Carlos-Purge. func TestPurgeHostHeaderScrubbedFromRequestBeforeUpstream(t *testing.T) { seen := make(chan string, 1) e, _ := purgeOverrideEdge(t, route("a.example.com", "tito", "tenanta"), route("b.example.com", "tito", "tenanta"), func(w http.ResponseWriter, r *http.Request) { seen <- strings.Join(r.Header.Values(PurgeHostHeader), "|") io.WriteString(w, "ok") }) req := httptest.NewRequest("POST", "http://a.example.com/write", nil) req.Header.Add(PurgeHostHeader, "b.example.com") req.Header.Add(PurgeHostHeader, "c.example.com") rec := httptest.NewRecorder() e.ServeHTTP(rec, req) if got := <-seen; got != "" { t.Fatalf("upstream saw Carlos-Purge-Host %q — an echoing app would launder it into a cross-host purge", got) } } // The same scrub on the unix-socket proxy: unixProxy and tcpProxy have // separate Rewrite funcs, so a fix applied to one is not applied to the other. func TestPurgeHostHeaderScrubbedFromRequestOverUnixSocket(t *testing.T) { sock := filepath.Join(t.TempDir(), "u.sock") ln, err := net.Listen("unix", sock) if err != nil { if errors.Is(err, syscall.EPERM) || strings.Contains(err.Error(), "operation not permitted") { t.Skipf("unix-socket listeners not permitted in this environment: %v", err) } t.Fatalf("listen unix: %v", err) } seen := make(chan string, 1) backend := &httptest.Server{ Listener: ln, Config: &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen <- strings.Join(r.Header.Values(PurgeHostHeader), "|") io.WriteString(w, "ok") })}, } backend.Start() t.Cleanup(backend.Close) reg := openReg(t) if err := reg.Upsert(registry.Route{Host: "sockscrub2.example.com", Kind: "instance", Socket: sock, Status: "ready"}); err != nil { t.Fatalf("upsert: %v", err) } e, err := New(reg, nil) if err != nil { t.Fatalf("New: %v", err) } e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) req := httptest.NewRequest("POST", "http://sockscrub2.example.com/write", nil) req.Header.Set(PurgeHostHeader, "other.example.com") rec := httptest.NewRecorder() e.ServeHTTP(rec, req) if got := <-seen; got != "" { t.Fatalf("unix-socket upstream saw Carlos-Purge-Host %q", got) } } // TestPurgeHostOverrideRefusalIsLoggedRateLimited — refusals are driven by a // remote party's response on the request hot path, so they share the // first-then-every-300th shape every other purge complaint uses. func TestPurgeHostOverrideRefusalIsLoggedRateLimited(t *testing.T) { var buf bytes.Buffer log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) e, _ := purgeOverrideEdge(t, route("a.example.com", "app-x", "tenanta"), route("b.example.com", "app-y", "tenanta"), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "b.example.com") w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) }) const requests = 12 for i := 0; i < requests; i++ { rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil)) } got := strings.Count(buf.String(), "Carlos-Purge-Host") if got != 1 { t.Fatalf("%d requests produced %d complaint lines, want exactly 1 (the first); log:\n%s", requests, got, buf.String()) } if !strings.Contains(buf.String(), "occurrence #1") { t.Fatalf("the first occurrence was not logged with its count; log:\n%s", buf.String()) } } // TestPurgeHostOverrideNamingTheServingHostIsANoOp — naming your own host is // trivially same-app/same-account and must behave exactly as no override at // all, so an app can emit the header unconditionally from shared code. func TestPurgeHostOverrideNamingTheServingHostIsANoOp(t *testing.T) { e, _ := purgeOverrideEdge(t, route("a.example.com", "tito", "tenanta"), route("b.example.com", "tito", "tenanta"), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "a.example.com") w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "a.example.com", "/x", "", "A") seed(t, e.cache, "b.example.com", "/x", "", "B") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil)) if cached(e.cache, "a.example.com", "/x", "") { t.Fatal("naming the serving host did not purge it") } if !cached(e.cache, "b.example.com", "/x", "") { t.Fatal("naming the serving host purged the sibling") } } // TestPurgeHostOverrideRefusedWithoutAnApp — a route with no App has no // releases and no provable tenancy, so "" == "" is not an app match. Without // this, two unrelated App-less routes on one box could purge each other. func TestPurgeHostOverrideRefusedWithoutAnApp(t *testing.T) { e, _ := purgeOverrideEdge(t, route("a.example.com", "", ""), route("b.example.com", "", ""), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "b.example.com") w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "a.example.com", "/x", "", "A") seed(t, e.cache, "b.example.com", "/x", "", "B") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil)) if !cached(e.cache, "b.example.com", "/x", "") { t.Fatal("two App-less routes purged each other") } if !cached(e.cache, "a.example.com", "/x", "") { t.Fatal("the refused declaration fell back to the serving host") } } // --------------------------------------------------------------------------- // Carlos-Purge-Host: the review wave's additions (findings 1-4, 7). // --------------------------------------------------------------------------- // TestPurgeHostHeaderListedInConnectionRetargetsAtTheServingHost pins the // worst footgun this header has, and it is deliberately an assertion of // TODAY'S behaviour rather than a fix. // // httputil.ReverseProxy calls removeHopByHopHeaders(res.Header) BEFORE // ModifyResponse. An app that names Carlos-Purge-Host in its Connection header // therefore has the REDIRECT deleted while the Carlos-Purge line beside it // SURVIVES — so the declaration is silently retargeted at the serving host. // That is a half-honoured purge, the exact outcome the all-or-nothing rule // exists to prevent, and the edge cannot refuse it: the evidence was destroyed // before this package was ever called, so there is nothing left to detect. It // is a contract obligation on the app (see PurgeHostHeader's doc comment and // spec §6.4), not something the edge can enforce. // // Compare TestPurgeHeaderListedInConnectionIsEatenBeforeTheHook: naming // Carlos-Purge that way merely LOSES the purge, which is the benign direction. // Naming this one clears the wrong host and looks like it worked. func TestPurgeHostHeaderListedInConnectionRetargetsAtTheServingHost(t *testing.T) { e, _ := purgeOverrideEdge(t, route("a.example.com", "tito", "tenanta"), route("b.example.com", "tito", "tenanta"), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Connection", "Carlos-Purge-Host") w.Header().Set("Carlos-Purge-Host", "b.example.com") w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "a.example.com", "/x", "", "A") seed(t, e.cache, "b.example.com", "/x", "", "B") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil)) if cached(e.cache, "a.example.com", "/x", "") { t.Fatal("the SERVING host was NOT purged — the stdlib appears to have stopped " + "deleting Connection-listed headers before ModifyResponse, which would make " + "the Connection warning in PurgeHostHeader's contract and spec §6.4 wrong") } if !cached(e.cache, "b.example.com", "/x", "") { t.Fatal("the OVERRIDE host was purged — the hop-by-hop strip no longer eats " + "Carlos-Purge-Host, so the half-honoured-purge footgun is gone and the " + "contract note about it should be revisited") } if got := rec.Header().Get("Carlos-Purge"); got != "" { t.Fatalf("Carlos-Purge reached the client: %q", got) } if got := rec.Header().Get("Carlos-Purge-Host"); got != "" { t.Fatalf("Carlos-Purge-Host reached the client: %q", got) } } // TestPurgeHostOverrideAccountIsResolvedNotComparedRaw — the account halves // are compared through release.RouteAccount, the same function that decides // which account's bucket keys a route's app lives under, not as raw column // bytes. // // An unset account column resolves to the built-in ops account // (release.OpsAccount), so a route stamped "" and a route stamped "bab" are // the SAME tenant everywhere else on the box — same key space, same releases. // A raw comparison splits that one account in two and refuses a purge between // two routes that provably share everything. This is the pre-multi-account // registry state a real box can still be carrying. func TestPurgeHostOverrideAccountIsResolvedNotComparedRaw(t *testing.T) { e, _ := purgeOverrideEdge(t, route("a.example.com", "tito", ""), route("b.example.com", "tito", release.OpsAccount), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "b.example.com") w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "a.example.com", "/x", "", "A") seed(t, e.cache, "b.example.com", "/x", "", "B") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil)) if cached(e.cache, "b.example.com", "/x", "") { t.Fatalf("the override was refused between account %q and account %q, which "+ "release.RouteAccount resolves to the same account (%q)", "", release.OpsAccount, release.RouteAccount(route("b.example.com", "tito", ""))) } if !cached(e.cache, "a.example.com", "/x", "") { t.Fatal("the SERVING host was purged too — the override redirects, it does not fan out") } } // TestPurgeHostOverrideMalformedShapesRefusedEvenAsRealRouteKeys makes the // shape gate's table NON-VACUOUS. // // TestPurgeHostOverrideMalformedValuesRefused above proves the 12 shapes are // refused, but it cannot prove WHO refuses them: none of those strings is a // route key in its fixture, so the e.current() lookup would reject every one // of them even with validPurgeOverrideHost deleted entirely. Replace the // gate's body with `return true` and that test stays green. // // These two shapes are the ones a route table can genuinely contain — nothing // validates a host column, so an uppercase key or a host:port key can be // upserted — so here they ARE real routes, with a matching app and account. // The lookup now SUCCEEDS and the tenancy check PASSES; the shape gate is the // only thing left that can refuse them. The same mutation turns this test red. func TestPurgeHostOverrideMalformedShapesRefusedEvenAsRealRouteKeys(t *testing.T) { for _, bad := range []string{"B.example.com", "b.example.com:8443"} { t.Run(fmt.Sprintf("%q", bad), func(t *testing.T) { e, _ := purgeOverrideEdge(t, route("a.example.com", "tito", "tenanta"), route(bad, "tito", "tenanta"), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", bad) w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) }) // The guard that keeps this test honest: if the fixture ever stops // putting the malformed spelling into the table as a real route, the // lookup starts doing the refusing again and the assertions below go // back to proving nothing. if _, ok := e.current()[bad]; !ok { t.Fatalf("fixture is vacuous: %q is not a route key, so the route-table "+ "lookup would refuse it whatever the shape gate does", bad) } seed(t, e.cache, "a.example.com", "/x", "", "A") seed(t, e.cache, bad, "/x", "", "B") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil)) if !cached(e.cache, bad, "/x", "") { t.Fatalf("Carlos-Purge-Host %q was honoured — it resolves to a real route "+ "with a matching app and account, so only validPurgeOverrideHost "+ "stands between this declaration and a purge", bad) } if !cached(e.cache, "a.example.com", "/x", "") { t.Fatalf("Carlos-Purge-Host %q fell back to the serving host", bad) } }) } } // TestPurgeHostOverrideRefusedWhenTheServingRouteChangedSinceCapture — the // serving route is a value CAPTURED when the proxy was built, and a reload can // land between that capture and this response. // // The backend below re-stamps the serving route into a different account and // reloads the edge WHILE the request is in flight — the proxy running // ModifyResponse for this response still carries the pre-swap identity. That // stale identity must not be enough to authorise a cross-host purge: the edge // re-reads the serving host from e.current() and refuses when it has changed // or gone. Without that check the captured (tito, tenanta) still matches the // sibling, and the sibling is purged on the strength of an identity the box no // longer serves. func TestPurgeHostOverrideRefusedWhenTheServingRouteChangedSinceCapture(t *testing.T) { var ( e *Edge reg *registry.Registry addr string ) backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // The route table moves under the in-flight request. if err := reg.Upsert(registry.Route{ Host: "a.example.com", App: "tito", Account: "tenantb", Kind: "instance", Addr: addr, Status: "ready", }); err != nil { t.Errorf("upsert during flight: %v", err) } if err := e.Reload(); err != nil { t.Errorf("reload during flight: %v", err) } w.Header().Set("Carlos-Purge-Host", "b.example.com") w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) })) t.Cleanup(backend.Close) addr = backend.Listener.Addr().String() reg = openReg(t) for _, h := range []string{"a.example.com", "b.example.com"} { if err := reg.Upsert(registry.Route{ Host: h, App: "tito", Account: "tenanta", Kind: "instance", Addr: addr, Status: "ready", }); err != nil { t.Fatalf("upsert %s: %v", h, err) } } var err error if e, err = New(reg, nil); err != nil { t.Fatalf("New: %v", err) } e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) seed(t, e.cache, "a.example.com", "/x", "", "A") seed(t, e.cache, "b.example.com", "/x", "", "B") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil)) if !cached(e.cache, "b.example.com", "/x", "") { t.Fatal("the sibling was purged on the strength of a STALE captured identity — " + "the serving route had already moved to another account") } if !cached(e.cache, "a.example.com", "/x", "") { t.Fatal("the refused declaration fell back to the serving host") } } // TestPurgeHostOverrideNonReadyRouteIsRefused — the override resolves against // e.current(), which Reload builds from READY routes only. A route that exists // in the registry but is still 'creating' (or has 'failed') is therefore not // nameable, and this pins that the ready-filter is load-bearing for the gate // rather than incidental: an unready route has no working backend, so purging // its cache would be acting on a tenancy the edge is not currently serving. func TestPurgeHostOverrideNonReadyRouteIsRefused(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "creating.example.com") w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) })) t.Cleanup(backend.Close) reg := openReg(t) addr := backend.Listener.Addr().String() if err := reg.Upsert(registry.Route{ Host: "a.example.com", App: "tito", Account: "tenanta", Kind: "instance", Addr: addr, Status: "ready", }); err != nil { t.Fatalf("upsert serving: %v", err) } // Same app, same account: the ONLY thing wrong with it is that it is not // ready, so it would sail through the tenancy check if Reload had let it in. if err := reg.Upsert(registry.Route{ Host: "creating.example.com", App: "tito", Account: "tenanta", Kind: "instance", Addr: addr, Status: "creating", }); err != nil { t.Fatalf("upsert sibling: %v", err) } e, err := New(reg, nil) if err != nil { t.Fatalf("New: %v", err) } e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) if _, ok := e.current()["creating.example.com"]; ok { t.Fatal("fixture: the unready route reached the served map, so this proves nothing") } seed(t, e.cache, "a.example.com", "/x", "", "A") seed(t, e.cache, "creating.example.com", "/x", "", "B") rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil)) if !cached(e.cache, "creating.example.com", "/x", "") { t.Fatal("a registry route that is NOT ready was purged — Reload's ready-filter " + "is what confines the override to hosts this edge actually serves") } if !cached(e.cache, "a.example.com", "/x", "") { t.Fatal("the refused declaration fell back to the serving host") } }