package edge import ( "bytes" "fmt" "log" "net/http" "net/http/httptest" "os" "strings" "testing" "github.com/carlosframework/platform/internal/registry" ) // --------------------------------------------------------------------------- // Carlos-Purge-Host as a LIST: one response reaching every host of its own // app and account (amadan carlos/platform#173). // --------------------------------------------------------------------------- // purgeHostsEdge is purgeOverrideEdge for any number of routes. The first is // the one requests are served on in these tests. func purgeHostsEdge(t *testing.T, routes []registry.Route, h http.HandlerFunc) *Edge { t.Helper() backend := httptest.NewServer(h) t.Cleanup(backend.Close) reg := openReg(t) for _, rt := range routes { 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 } // titoAccount is the #173 topology: a canonical account host, a custom domain // wired to one event, another wired to a series, all one app and one account. func titoAccount() []registry.Route { return []registry.Route{ route("go.tito.example", "tito", "tenanta"), route("acme.go.tito.example", "tito", "tenanta"), route("tickets.acme.example", "tito", "tenanta"), route("series.acme.example", "tito", "tenanta"), } } func servePOST(e *Edge, url string) *httptest.ResponseRecorder { rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("POST", url, nil)) return rec } // TestPurgeHostListPurgesEveryListedHost is the headline of #173: an admin // write on the root domain purges the same page under the canonical host and // every custom domain at once, whichever list spelling the app uses, and the // serving host is still left alone because the list did not name it. func TestPurgeHostListPurgesEveryListedHost(t *testing.T) { listed := []string{"acme.go.tito.example", "tickets.acme.example", "series.acme.example"} for _, tc := range []struct { name string set func(http.Header) }{ {"comma joined", func(h http.Header) { h.Set("Carlos-Purge-Host", strings.Join(listed, ", ")) }}, {"several lines", func(h http.Header) { for _, host := range listed { h.Add("Carlos-Purge-Host", host) } }}, {"mixed, with a duplicate", func(h http.Header) { h.Add("Carlos-Purge-Host", listed[0]+", "+listed[1]) h.Add("Carlos-Purge-Host", listed[2]+","+listed[0]) }}, } { t.Run(tc.name, func(t *testing.T) { e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { tc.set(w.Header()) w.Header().Set("Carlos-Purge", "/events/42") w.WriteHeader(http.StatusFound) }) for _, host := range listed { seed(t, e.cache, host, "/events/42", "", "old") seed(t, e.cache, host, "/events/43", "", "untouched") } seed(t, e.cache, "go.tito.example", "/events/42", "", "serving host") rec := servePOST(e, "http://go.tito.example/admin/events/42") for _, host := range listed { if cached(e.cache, host, "/events/42", "") { t.Fatalf("%s /events/42 survived a list that named it", host) } if !cached(e.cache, host, "/events/43", "") { t.Fatalf("%s /events/43 was purged but nothing named it", host) } } if !cached(e.cache, "go.tito.example", "/events/42", "") { t.Fatal("the SERVING host was purged — the list replaces it, and did not name it") } if got := rec.Header().Values("Carlos-Purge-Host"); len(got) != 0 { t.Fatalf("Carlos-Purge-Host reached the client: %q", got) } }) } } // TestPurgeHostListNamingTheServingHostPurgesItToo — the list replaces the // serving host rather than adding to it, so an app that wants both says so. func TestPurgeHostListNamingTheServingHostPurgesItToo(t *testing.T) { e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "go.tito.example, tickets.acme.example") w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "go.tito.example", "/x", "", "serving") seed(t, e.cache, "tickets.acme.example", "/x", "", "custom") seed(t, e.cache, "series.acme.example", "/x", "", "unnamed") servePOST(e, "http://go.tito.example/write") if cached(e.cache, "go.tito.example", "/x", "") { t.Fatal("the serving host survived a list that named it") } if cached(e.cache, "tickets.acme.example", "/x", "") { t.Fatal("the listed custom domain survived") } if !cached(e.cache, "series.acme.example", "/x", "") { t.Fatal("a host the list did not name was purged") } } // TestPurgeHostListStarPurgesEveryListedHost — Tito's "every organiser write // purges the whole host" rule, reaching every host of the account in one // response. func TestPurgeHostListStarPurgesEveryListedHost(t *testing.T) { e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "acme.go.tito.example, tickets.acme.example") w.Header().Set("Carlos-Purge", "/ignored, *") w.WriteHeader(http.StatusOK) }) for _, host := range []string{"acme.go.tito.example", "tickets.acme.example"} { seed(t, e.cache, host, "/one", "", "1") seed(t, e.cache, host, "/two", "q=1", "2") } seed(t, e.cache, "series.acme.example", "/one", "", "unnamed") servePOST(e, "http://go.tito.example/write") for _, host := range []string{"acme.go.tito.example", "tickets.acme.example"} { if cached(e.cache, host, "/one", "") || cached(e.cache, host, "/two", "q=1") { t.Fatalf("* did not purge the whole of %s", host) } } if !cached(e.cache, "series.acme.example", "/one", "") { t.Fatal("* reached a host the list did not name") } } // TestPurgeHostListCapDegradeHitsEveryListedHost — the target-count degrade // follows the list, not the serving host. func TestPurgeHostListCapDegradeHitsEveryListedHost(t *testing.T) { targets := make([]string, maxPurgeTargets+1) for i := range targets { targets[i] = fmt.Sprintf("/p/%d", i) } e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "acme.go.tito.example, tickets.acme.example") w.Header().Set("Carlos-Purge", strings.Join(targets, ", ")) w.WriteHeader(http.StatusOK) }) for _, host := range []string{"go.tito.example", "acme.go.tito.example", "tickets.acme.example"} { seed(t, e.cache, host, "/unnamed", "", host) } servePOST(e, "http://go.tito.example/write") for _, host := range []string{"acme.go.tito.example", "tickets.acme.example"} { if cached(e.cache, host, "/unnamed", "") { t.Fatalf("the cap degrade did not purge listed host %s", host) } } if !cached(e.cache, "go.tito.example", "/unnamed", "") { t.Fatal("the cap degrade purged the SERVING host, which the list did not name") } } // TestPurgeHostListOneBadEntryRefusesTheWholeList — all-or-nothing survives // the widening. Wherever the bad entry sits in the list, and whatever is // wrong with it, no host is purged: a list that clears the good entries and // drops the bad one looks like it worked. func TestPurgeHostListOneBadEntryRefusesTheWholeList(t *testing.T) { routes := append(titoAccount(), route("other-app.example", "other", "tenanta"), route("other-account.example", "tito", "tenantb"), ) for _, tc := range []struct{ name, list string }{ {"different app, last", "acme.go.tito.example, tickets.acme.example, other-app.example"}, {"different app, first", "other-app.example, acme.go.tito.example"}, {"different account", "acme.go.tito.example, other-account.example"}, {"malformed, last", "acme.go.tito.example, tickets.acme.example:443"}, {"malformed, first", "HTTPS://acme.go.tito.example, tickets.acme.example"}, {"malformed beside an unserved one", "ghost.example, tickets.acme.example/x"}, } { t.Run(tc.name, func(t *testing.T) { e := purgeHostsEdge(t, routes, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", tc.list) w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) }) all := []string{"go.tito.example", "acme.go.tito.example", "tickets.acme.example", "series.acme.example", "other-app.example", "other-account.example"} for _, host := range all { seed(t, e.cache, host, "/x", "", host) } servePOST(e, "http://go.tito.example/write") for _, host := range all { if !cached(e.cache, host, "/x", "") { t.Fatalf("%s was purged by a list carrying a bad entry (%q)", host, tc.list) } } }) } } // TestPurgeHostListSkipsUnservedHosts — the one exception to all-or-nothing. // An app lists its domains from its own records, which do not know which box // serves which host; a domain waiting on DNS, or placed elsewhere, must not // cost the purge of the hosts this edge does serve. The unserved host's own // (orphaned) entry is left alone: skipped means not judged, never purged. func TestPurgeHostListSkipsUnservedHosts(t *testing.T) { var buf bytes.Buffer log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "ghost.example, tickets.acme.example") w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "ghost.example", "/x", "", "orphan") seed(t, e.cache, "tickets.acme.example", "/x", "", "custom") seed(t, e.cache, "go.tito.example", "/x", "", "serving") servePOST(e, "http://go.tito.example/write") if cached(e.cache, "tickets.acme.example", "/x", "") { t.Fatal("an unserved entry cost the purge of a served, in-tenancy host") } if !cached(e.cache, "ghost.example", "/x", "") { t.Fatal("an unserved host's entries were purged without any tenancy check") } if !cached(e.cache, "go.tito.example", "/x", "") { t.Fatal("the serving host was purged, but the list did not name it") } if !strings.Contains(buf.String(), "ghost.example") { t.Fatalf("the skipped host was not logged; log:\n%s", buf.String()) } } // TestPurgeHostListOverTheCapIsRefused — the host cap cannot degrade the way // the target cap does, because over-purging past it would mean acting on // hosts nobody validated. Distinct hosts are what count: duplicates collapse // before the cap is applied. func TestPurgeHostListOverTheCapIsRefused(t *testing.T) { names := make([]string, maxPurgeHosts) names[0] = "acme.go.tito.example" for i := 1; i < len(names); i++ { names[i] = fmt.Sprintf("unserved-%d.example", i) } t.Run("at the cap, with duplicates", func(t *testing.T) { list := strings.Join(append(names, names...), ", ") e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", list) w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "acme.go.tito.example", "/x", "", "A") servePOST(e, "http://go.tito.example/write") if cached(e.cache, "acme.go.tito.example", "/x", "") { t.Fatalf("a list of exactly %d distinct hosts was refused", maxPurgeHosts) } }) t.Run("one over", func(t *testing.T) { list := strings.Join(append(names, "one-too-many.example"), ", ") e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", list) w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "acme.go.tito.example", "/x", "", "A") servePOST(e, "http://go.tito.example/write") if !cached(e.cache, "acme.go.tito.example", "/x", "") { t.Fatalf("a list of %d distinct hosts was honoured", maxPurgeHosts+1) } }) } // TestPurgeHostListOversizeIsRefused — the byte cap bounds the header before // any splitting happens, so a huge header of duplicates cannot buy work by // staying under the distinct-host cap. func TestPurgeHostListOversizeIsRefused(t *testing.T) { list := strings.Repeat("acme.go.tito.example,", maxPurgeHeaderBytes/len("acme.go.tito.example,")+1) e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", list) w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "acme.go.tito.example", "/x", "", "A") servePOST(e, "http://go.tito.example/write") if !cached(e.cache, "acme.go.tito.example", "/x", "") { t.Fatalf("a %d-byte Carlos-Purge-Host was honoured", len(list)) } } // TestPurgeHostListEveryEntryUnservedPurgesNothing — the lone-unknown-host // case generalised: nothing served, nothing purged, no fallback. func TestPurgeHostListEveryEntryUnservedPurgesNothing(t *testing.T) { e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "ghost-1.example, ghost-2.example") w.Header().Set("Carlos-Purge", "*") w.WriteHeader(http.StatusOK) }) seed(t, e.cache, "go.tito.example", "/x", "", "serving") servePOST(e, "http://go.tito.example/write") if !cached(e.cache, "go.tito.example", "/x", "") { t.Fatal("a list of unserved hosts fell back to the serving host") } } // TestPurgeHostListReachesAHostServedThroughAWildcard — a catch-all alias // ("*.acme.example") routes every subdomain to one route, and the cache keys // each page on the name the browser asked for. Listing that name must purge // it: the edge serves it, so skipping it as "unserved" would leave a stale // page behind a purge that cleared the rest of the list. func TestPurgeHostListReachesAHostServedThroughAWildcard(t *testing.T) { e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "tickets.acme.example, foo.wild.example") w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) }) upsertAlias(t, e.reg, "*.wild.example", "acme.go.tito.example") if err := e.Reload(); err != nil { t.Fatalf("Reload: %v", err) } if _, ok := e.current()["*.wild.example"]; !ok { t.Fatal("fixture: the wildcard alias is not in the served table, so this proves nothing") } seed(t, e.cache, "foo.wild.example", "/x", "", "wild") seed(t, e.cache, "tickets.acme.example", "/x", "", "custom") servePOST(e, "http://go.tito.example/write") if cached(e.cache, "foo.wild.example", "/x", "") { t.Fatal("a host served through a wildcard alias was skipped as unserved") } if cached(e.cache, "tickets.acme.example", "/x", "") { t.Fatal("the rest of the list was not honoured") } } // TestPurgeHostListWildcardOfAnotherTenantIsRefused — resolving through the // wildcard is still a tenancy check on the route it lands on: a name under // another account's catch-all refuses the whole list. func TestPurgeHostListWildcardOfAnotherTenantIsRefused(t *testing.T) { routes := append(titoAccount(), route("other-account.example", "tito", "tenantb")) e := purgeHostsEdge(t, routes, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "tickets.acme.example, foo.theirs.example") w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) }) upsertAlias(t, e.reg, "*.theirs.example", "other-account.example") if err := e.Reload(); err != nil { t.Fatalf("Reload: %v", err) } if _, ok := e.current()["*.theirs.example"]; !ok { t.Fatal("fixture: the wildcard alias is not in the served table, so this proves nothing") } seed(t, e.cache, "foo.theirs.example", "/x", "", "theirs") seed(t, e.cache, "tickets.acme.example", "/x", "", "custom") servePOST(e, "http://go.tito.example/write") if !cached(e.cache, "foo.theirs.example", "/x", "") { t.Fatal("a host under ANOTHER ACCOUNT's wildcard was purged") } if !cached(e.cache, "tickets.acme.example", "/x", "") { t.Fatal("a list carrying a cross-tenant entry was partly honoured") } } // TestPurgeHostListNamingAWildcardKeyIsRefused — "*.acme.example" is a // routing pattern, not a name anything is cached under, so naming it is a // malformed entry and refuses the list rather than purging nothing quietly. func TestPurgeHostListNamingAWildcardKeyIsRefused(t *testing.T) { e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Carlos-Purge-Host", "tickets.acme.example, *.wild.example") w.Header().Set("Carlos-Purge", "/x") w.WriteHeader(http.StatusOK) }) upsertAlias(t, e.reg, "*.wild.example", "acme.go.tito.example") if err := e.Reload(); err != nil { t.Fatalf("Reload: %v", err) } seed(t, e.cache, "tickets.acme.example", "/x", "", "custom") servePOST(e, "http://go.tito.example/write") if !cached(e.cache, "tickets.acme.example", "/x", "") { t.Fatal("a list naming a wildcard key was honoured") } } // TestPurgeHostListRefusedWhenTheServingRouteChangedSinceCapture — the // staleness re-read runs for the first SERVED cross-host entry wherever it // sits, not only when it leads the list. Own host first and an unserved host // first are the two orders that reach a served sibling without it being // first. func TestPurgeHostListRefusedWhenTheServingRouteChangedSinceCapture(t *testing.T) { for _, list := range []string{ "a.example.com, b.example.com", "ghost.example.com, b.example.com", } { t.Run(list, func(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", list) 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") servePOST(e, "http://a.example.com/write") if !cached(e.cache, "b.example.com", "/x", "") { t.Fatal("the sibling was purged on the strength of a STALE captured identity") } if !cached(e.cache, "a.example.com", "/x", "") { t.Fatal("a refused list was partly honoured on the serving host") } }) } }