carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3import (
4 "bytes"
5 "fmt"
6 "log"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "strings"
11 "testing"
12
13 "github.com/carlosframework/platform/internal/registry"
14)
15
16// ---------------------------------------------------------------------------
17// Carlos-Purge-Host as a LIST: one response reaching every host of its own
18// app and account (amadan carlos/platform#173).
19// ---------------------------------------------------------------------------
20
21// purgeHostsEdge is purgeOverrideEdge for any number of routes. The first is
22// the one requests are served on in these tests.
23func purgeHostsEdge(t *testing.T, routes []registry.Route, h http.HandlerFunc) *Edge {
24 t.Helper()
25 backend := httptest.NewServer(h)
26 t.Cleanup(backend.Close)
27 reg := openReg(t)
28 for _, rt := range routes {
29 rt.Kind, rt.Addr, rt.Status = "instance", backend.Listener.Addr().String(), "ready"
30 if err := reg.Upsert(rt); err != nil {
31 t.Fatalf("upsert %s: %v", rt.Host, err)
32 }
33 }
34 e, err := New(reg, nil)
35 if err != nil {
36 t.Fatalf("New: %v", err)
37 }
38 e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
39 return e
40}
41
42// titoAccount is the #173 topology: a canonical account host, a custom domain
43// wired to one event, another wired to a series, all one app and one account.
44func titoAccount() []registry.Route {
45 return []registry.Route{
46 route("go.tito.example", "tito", "tenanta"),
47 route("acme.go.tito.example", "tito", "tenanta"),
48 route("tickets.acme.example", "tito", "tenanta"),
49 route("series.acme.example", "tito", "tenanta"),
50 }
51}
52
53func servePOST(e *Edge, url string) *httptest.ResponseRecorder {
54 rec := httptest.NewRecorder()
55 e.ServeHTTP(rec, httptest.NewRequest("POST", url, nil))
56 return rec
57}
58
59// TestPurgeHostListPurgesEveryListedHost is the headline of #173: an admin
60// write on the root domain purges the same page under the canonical host and
61// every custom domain at once, whichever list spelling the app uses, and the
62// serving host is still left alone because the list did not name it.
63func TestPurgeHostListPurgesEveryListedHost(t *testing.T) {
64 listed := []string{"acme.go.tito.example", "tickets.acme.example", "series.acme.example"}
65 for _, tc := range []struct {
66 name string
67 set func(http.Header)
68 }{
69 {"comma joined", func(h http.Header) {
70 h.Set("Carlos-Purge-Host", strings.Join(listed, ", "))
71 }},
72 {"several lines", func(h http.Header) {
73 for _, host := range listed {
74 h.Add("Carlos-Purge-Host", host)
75 }
76 }},
77 {"mixed, with a duplicate", func(h http.Header) {
78 h.Add("Carlos-Purge-Host", listed[0]+", "+listed[1])
79 h.Add("Carlos-Purge-Host", listed[2]+","+listed[0])
80 }},
81 } {
82 t.Run(tc.name, func(t *testing.T) {
83 e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
84 tc.set(w.Header())
85 w.Header().Set("Carlos-Purge", "/events/42")
86 w.WriteHeader(http.StatusFound)
87 })
88 for _, host := range listed {
89 seed(t, e.cache, host, "/events/42", "", "old")
90 seed(t, e.cache, host, "/events/43", "", "untouched")
91 }
92 seed(t, e.cache, "go.tito.example", "/events/42", "", "serving host")
93
94 rec := servePOST(e, "http://go.tito.example/admin/events/42")
95
96 for _, host := range listed {
97 if cached(e.cache, host, "/events/42", "") {
98 t.Fatalf("%s /events/42 survived a list that named it", host)
99 }
100 if !cached(e.cache, host, "/events/43", "") {
101 t.Fatalf("%s /events/43 was purged but nothing named it", host)
102 }
103 }
104 if !cached(e.cache, "go.tito.example", "/events/42", "") {
105 t.Fatal("the SERVING host was purged — the list replaces it, and did not name it")
106 }
107 if got := rec.Header().Values("Carlos-Purge-Host"); len(got) != 0 {
108 t.Fatalf("Carlos-Purge-Host reached the client: %q", got)
109 }
110 })
111 }
112}
113
114// TestPurgeHostListNamingTheServingHostPurgesItToo — the list replaces the
115// serving host rather than adding to it, so an app that wants both says so.
116func TestPurgeHostListNamingTheServingHostPurgesItToo(t *testing.T) {
117 e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
118 w.Header().Set("Carlos-Purge-Host", "go.tito.example, tickets.acme.example")
119 w.Header().Set("Carlos-Purge", "/x")
120 w.WriteHeader(http.StatusOK)
121 })
122 seed(t, e.cache, "go.tito.example", "/x", "", "serving")
123 seed(t, e.cache, "tickets.acme.example", "/x", "", "custom")
124 seed(t, e.cache, "series.acme.example", "/x", "", "unnamed")
125
126 servePOST(e, "http://go.tito.example/write")
127
128 if cached(e.cache, "go.tito.example", "/x", "") {
129 t.Fatal("the serving host survived a list that named it")
130 }
131 if cached(e.cache, "tickets.acme.example", "/x", "") {
132 t.Fatal("the listed custom domain survived")
133 }
134 if !cached(e.cache, "series.acme.example", "/x", "") {
135 t.Fatal("a host the list did not name was purged")
136 }
137}
138
139// TestPurgeHostListStarPurgesEveryListedHost — Tito's "every organiser write
140// purges the whole host" rule, reaching every host of the account in one
141// response.
142func TestPurgeHostListStarPurgesEveryListedHost(t *testing.T) {
143 e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
144 w.Header().Set("Carlos-Purge-Host", "acme.go.tito.example, tickets.acme.example")
145 w.Header().Set("Carlos-Purge", "/ignored, *")
146 w.WriteHeader(http.StatusOK)
147 })
148 for _, host := range []string{"acme.go.tito.example", "tickets.acme.example"} {
149 seed(t, e.cache, host, "/one", "", "1")
150 seed(t, e.cache, host, "/two", "q=1", "2")
151 }
152 seed(t, e.cache, "series.acme.example", "/one", "", "unnamed")
153
154 servePOST(e, "http://go.tito.example/write")
155
156 for _, host := range []string{"acme.go.tito.example", "tickets.acme.example"} {
157 if cached(e.cache, host, "/one", "") || cached(e.cache, host, "/two", "q=1") {
158 t.Fatalf("* did not purge the whole of %s", host)
159 }
160 }
161 if !cached(e.cache, "series.acme.example", "/one", "") {
162 t.Fatal("* reached a host the list did not name")
163 }
164}
165
166// TestPurgeHostListCapDegradeHitsEveryListedHost — the target-count degrade
167// follows the list, not the serving host.
168func TestPurgeHostListCapDegradeHitsEveryListedHost(t *testing.T) {
169 targets := make([]string, maxPurgeTargets+1)
170 for i := range targets {
171 targets[i] = fmt.Sprintf("/p/%d", i)
172 }
173 e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
174 w.Header().Set("Carlos-Purge-Host", "acme.go.tito.example, tickets.acme.example")
175 w.Header().Set("Carlos-Purge", strings.Join(targets, ", "))
176 w.WriteHeader(http.StatusOK)
177 })
178 for _, host := range []string{"go.tito.example", "acme.go.tito.example", "tickets.acme.example"} {
179 seed(t, e.cache, host, "/unnamed", "", host)
180 }
181
182 servePOST(e, "http://go.tito.example/write")
183
184 for _, host := range []string{"acme.go.tito.example", "tickets.acme.example"} {
185 if cached(e.cache, host, "/unnamed", "") {
186 t.Fatalf("the cap degrade did not purge listed host %s", host)
187 }
188 }
189 if !cached(e.cache, "go.tito.example", "/unnamed", "") {
190 t.Fatal("the cap degrade purged the SERVING host, which the list did not name")
191 }
192}
193
194// TestPurgeHostListOneBadEntryRefusesTheWholeList — all-or-nothing survives
195// the widening. Wherever the bad entry sits in the list, and whatever is
196// wrong with it, no host is purged: a list that clears the good entries and
197// drops the bad one looks like it worked.
198func TestPurgeHostListOneBadEntryRefusesTheWholeList(t *testing.T) {
199 routes := append(titoAccount(),
200 route("other-app.example", "other", "tenanta"),
201 route("other-account.example", "tito", "tenantb"),
202 )
203 for _, tc := range []struct{ name, list string }{
204 {"different app, last", "acme.go.tito.example, tickets.acme.example, other-app.example"},
205 {"different app, first", "other-app.example, acme.go.tito.example"},
206 {"different account", "acme.go.tito.example, other-account.example"},
207 {"malformed, last", "acme.go.tito.example, tickets.acme.example:443"},
208 {"malformed, first", "HTTPS://acme.go.tito.example, tickets.acme.example"},
209 {"malformed beside an unserved one", "ghost.example, tickets.acme.example/x"},
210 } {
211 t.Run(tc.name, func(t *testing.T) {
212 e := purgeHostsEdge(t, routes, func(w http.ResponseWriter, r *http.Request) {
213 w.Header().Set("Carlos-Purge-Host", tc.list)
214 w.Header().Set("Carlos-Purge", "/x")
215 w.WriteHeader(http.StatusOK)
216 })
217 all := []string{"go.tito.example", "acme.go.tito.example", "tickets.acme.example",
218 "series.acme.example", "other-app.example", "other-account.example"}
219 for _, host := range all {
220 seed(t, e.cache, host, "/x", "", host)
221 }
222
223 servePOST(e, "http://go.tito.example/write")
224
225 for _, host := range all {
226 if !cached(e.cache, host, "/x", "") {
227 t.Fatalf("%s was purged by a list carrying a bad entry (%q)", host, tc.list)
228 }
229 }
230 })
231 }
232}
233
234// TestPurgeHostListSkipsUnservedHosts — the one exception to all-or-nothing.
235// An app lists its domains from its own records, which do not know which box
236// serves which host; a domain waiting on DNS, or placed elsewhere, must not
237// cost the purge of the hosts this edge does serve. The unserved host's own
238// (orphaned) entry is left alone: skipped means not judged, never purged.
239func TestPurgeHostListSkipsUnservedHosts(t *testing.T) {
240 var buf bytes.Buffer
241 log.SetOutput(&buf)
242 t.Cleanup(func() { log.SetOutput(os.Stderr) })
243
244 e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
245 w.Header().Set("Carlos-Purge-Host", "ghost.example, tickets.acme.example")
246 w.Header().Set("Carlos-Purge", "/x")
247 w.WriteHeader(http.StatusOK)
248 })
249 seed(t, e.cache, "ghost.example", "/x", "", "orphan")
250 seed(t, e.cache, "tickets.acme.example", "/x", "", "custom")
251 seed(t, e.cache, "go.tito.example", "/x", "", "serving")
252
253 servePOST(e, "http://go.tito.example/write")
254
255 if cached(e.cache, "tickets.acme.example", "/x", "") {
256 t.Fatal("an unserved entry cost the purge of a served, in-tenancy host")
257 }
258 if !cached(e.cache, "ghost.example", "/x", "") {
259 t.Fatal("an unserved host's entries were purged without any tenancy check")
260 }
261 if !cached(e.cache, "go.tito.example", "/x", "") {
262 t.Fatal("the serving host was purged, but the list did not name it")
263 }
264 if !strings.Contains(buf.String(), "ghost.example") {
265 t.Fatalf("the skipped host was not logged; log:\n%s", buf.String())
266 }
267}
268
269// TestPurgeHostListOverTheCapIsRefused — the host cap cannot degrade the way
270// the target cap does, because over-purging past it would mean acting on
271// hosts nobody validated. Distinct hosts are what count: duplicates collapse
272// before the cap is applied.
273func TestPurgeHostListOverTheCapIsRefused(t *testing.T) {
274 names := make([]string, maxPurgeHosts)
275 names[0] = "acme.go.tito.example"
276 for i := 1; i < len(names); i++ {
277 names[i] = fmt.Sprintf("unserved-%d.example", i)
278 }
279
280 t.Run("at the cap, with duplicates", func(t *testing.T) {
281 list := strings.Join(append(names, names...), ", ")
282 e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
283 w.Header().Set("Carlos-Purge-Host", list)
284 w.Header().Set("Carlos-Purge", "/x")
285 w.WriteHeader(http.StatusOK)
286 })
287 seed(t, e.cache, "acme.go.tito.example", "/x", "", "A")
288 servePOST(e, "http://go.tito.example/write")
289 if cached(e.cache, "acme.go.tito.example", "/x", "") {
290 t.Fatalf("a list of exactly %d distinct hosts was refused", maxPurgeHosts)
291 }
292 })
293
294 t.Run("one over", func(t *testing.T) {
295 list := strings.Join(append(names, "one-too-many.example"), ", ")
296 e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
297 w.Header().Set("Carlos-Purge-Host", list)
298 w.Header().Set("Carlos-Purge", "/x")
299 w.WriteHeader(http.StatusOK)
300 })
301 seed(t, e.cache, "acme.go.tito.example", "/x", "", "A")
302 servePOST(e, "http://go.tito.example/write")
303 if !cached(e.cache, "acme.go.tito.example", "/x", "") {
304 t.Fatalf("a list of %d distinct hosts was honoured", maxPurgeHosts+1)
305 }
306 })
307}
308
309// TestPurgeHostListOversizeIsRefused — the byte cap bounds the header before
310// any splitting happens, so a huge header of duplicates cannot buy work by
311// staying under the distinct-host cap.
312func TestPurgeHostListOversizeIsRefused(t *testing.T) {
313 list := strings.Repeat("acme.go.tito.example,", maxPurgeHeaderBytes/len("acme.go.tito.example,")+1)
314 e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
315 w.Header().Set("Carlos-Purge-Host", list)
316 w.Header().Set("Carlos-Purge", "/x")
317 w.WriteHeader(http.StatusOK)
318 })
319 seed(t, e.cache, "acme.go.tito.example", "/x", "", "A")
320 servePOST(e, "http://go.tito.example/write")
321 if !cached(e.cache, "acme.go.tito.example", "/x", "") {
322 t.Fatalf("a %d-byte Carlos-Purge-Host was honoured", len(list))
323 }
324}
325
326// TestPurgeHostListEveryEntryUnservedPurgesNothing — the lone-unknown-host
327// case generalised: nothing served, nothing purged, no fallback.
328func TestPurgeHostListEveryEntryUnservedPurgesNothing(t *testing.T) {
329 e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
330 w.Header().Set("Carlos-Purge-Host", "ghost-1.example, ghost-2.example")
331 w.Header().Set("Carlos-Purge", "*")
332 w.WriteHeader(http.StatusOK)
333 })
334 seed(t, e.cache, "go.tito.example", "/x", "", "serving")
335 servePOST(e, "http://go.tito.example/write")
336 if !cached(e.cache, "go.tito.example", "/x", "") {
337 t.Fatal("a list of unserved hosts fell back to the serving host")
338 }
339}
340
341// TestPurgeHostListReachesAHostServedThroughAWildcard — a catch-all alias
342// ("*.acme.example") routes every subdomain to one route, and the cache keys
343// each page on the name the browser asked for. Listing that name must purge
344// it: the edge serves it, so skipping it as "unserved" would leave a stale
345// page behind a purge that cleared the rest of the list.
346func TestPurgeHostListReachesAHostServedThroughAWildcard(t *testing.T) {
347 e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
348 w.Header().Set("Carlos-Purge-Host", "tickets.acme.example, foo.wild.example")
349 w.Header().Set("Carlos-Purge", "/x")
350 w.WriteHeader(http.StatusOK)
351 })
352 upsertAlias(t, e.reg, "*.wild.example", "acme.go.tito.example")
353 if err := e.Reload(); err != nil {
354 t.Fatalf("Reload: %v", err)
355 }
356 if _, ok := e.current()["*.wild.example"]; !ok {
357 t.Fatal("fixture: the wildcard alias is not in the served table, so this proves nothing")
358 }
359 seed(t, e.cache, "foo.wild.example", "/x", "", "wild")
360 seed(t, e.cache, "tickets.acme.example", "/x", "", "custom")
361
362 servePOST(e, "http://go.tito.example/write")
363
364 if cached(e.cache, "foo.wild.example", "/x", "") {
365 t.Fatal("a host served through a wildcard alias was skipped as unserved")
366 }
367 if cached(e.cache, "tickets.acme.example", "/x", "") {
368 t.Fatal("the rest of the list was not honoured")
369 }
370}
371
372// TestPurgeHostListWildcardOfAnotherTenantIsRefused — resolving through the
373// wildcard is still a tenancy check on the route it lands on: a name under
374// another account's catch-all refuses the whole list.
375func TestPurgeHostListWildcardOfAnotherTenantIsRefused(t *testing.T) {
376 routes := append(titoAccount(), route("other-account.example", "tito", "tenantb"))
377 e := purgeHostsEdge(t, routes, func(w http.ResponseWriter, r *http.Request) {
378 w.Header().Set("Carlos-Purge-Host", "tickets.acme.example, foo.theirs.example")
379 w.Header().Set("Carlos-Purge", "/x")
380 w.WriteHeader(http.StatusOK)
381 })
382 upsertAlias(t, e.reg, "*.theirs.example", "other-account.example")
383 if err := e.Reload(); err != nil {
384 t.Fatalf("Reload: %v", err)
385 }
386 if _, ok := e.current()["*.theirs.example"]; !ok {
387 t.Fatal("fixture: the wildcard alias is not in the served table, so this proves nothing")
388 }
389 seed(t, e.cache, "foo.theirs.example", "/x", "", "theirs")
390 seed(t, e.cache, "tickets.acme.example", "/x", "", "custom")
391
392 servePOST(e, "http://go.tito.example/write")
393
394 if !cached(e.cache, "foo.theirs.example", "/x", "") {
395 t.Fatal("a host under ANOTHER ACCOUNT's wildcard was purged")
396 }
397 if !cached(e.cache, "tickets.acme.example", "/x", "") {
398 t.Fatal("a list carrying a cross-tenant entry was partly honoured")
399 }
400}
401
402// TestPurgeHostListNamingAWildcardKeyIsRefused — "*.acme.example" is a
403// routing pattern, not a name anything is cached under, so naming it is a
404// malformed entry and refuses the list rather than purging nothing quietly.
405func TestPurgeHostListNamingAWildcardKeyIsRefused(t *testing.T) {
406 e := purgeHostsEdge(t, titoAccount(), func(w http.ResponseWriter, r *http.Request) {
407 w.Header().Set("Carlos-Purge-Host", "tickets.acme.example, *.wild.example")
408 w.Header().Set("Carlos-Purge", "/x")
409 w.WriteHeader(http.StatusOK)
410 })
411 upsertAlias(t, e.reg, "*.wild.example", "acme.go.tito.example")
412 if err := e.Reload(); err != nil {
413 t.Fatalf("Reload: %v", err)
414 }
415 seed(t, e.cache, "tickets.acme.example", "/x", "", "custom")
416
417 servePOST(e, "http://go.tito.example/write")
418
419 if !cached(e.cache, "tickets.acme.example", "/x", "") {
420 t.Fatal("a list naming a wildcard key was honoured")
421 }
422}
423
424// TestPurgeHostListRefusedWhenTheServingRouteChangedSinceCapture — the
425// staleness re-read runs for the first SERVED cross-host entry wherever it
426// sits, not only when it leads the list. Own host first and an unserved host
427// first are the two orders that reach a served sibling without it being
428// first.
429func TestPurgeHostListRefusedWhenTheServingRouteChangedSinceCapture(t *testing.T) {
430 for _, list := range []string{
431 "a.example.com, b.example.com",
432 "ghost.example.com, b.example.com",
433 } {
434 t.Run(list, func(t *testing.T) {
435 var (
436 e *Edge
437 reg *registry.Registry
438 addr string
439 )
440 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
441 // The route table moves under the in-flight request.
442 if err := reg.Upsert(registry.Route{
443 Host: "a.example.com", App: "tito", Account: "tenantb",
444 Kind: "instance", Addr: addr, Status: "ready",
445 }); err != nil {
446 t.Errorf("upsert during flight: %v", err)
447 }
448 if err := e.Reload(); err != nil {
449 t.Errorf("reload during flight: %v", err)
450 }
451 w.Header().Set("Carlos-Purge-Host", list)
452 w.Header().Set("Carlos-Purge", "/x")
453 w.WriteHeader(http.StatusOK)
454 }))
455 t.Cleanup(backend.Close)
456 addr = backend.Listener.Addr().String()
457 reg = openReg(t)
458 for _, h := range []string{"a.example.com", "b.example.com"} {
459 if err := reg.Upsert(registry.Route{
460 Host: h, App: "tito", Account: "tenanta",
461 Kind: "instance", Addr: addr, Status: "ready",
462 }); err != nil {
463 t.Fatalf("upsert %s: %v", h, err)
464 }
465 }
466 var err error
467 if e, err = New(reg, nil); err != nil {
468 t.Fatalf("New: %v", err)
469 }
470 e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
471 seed(t, e.cache, "a.example.com", "/x", "", "A")
472 seed(t, e.cache, "b.example.com", "/x", "", "B")
473
474 servePOST(e, "http://a.example.com/write")
475
476 if !cached(e.cache, "b.example.com", "/x", "") {
477 t.Fatal("the sibling was purged on the strength of a STALE captured identity")
478 }
479 if !cached(e.cache, "a.example.com", "/x", "") {
480 t.Fatal("a refused list was partly honoured on the serving host")
481 }
482 })
483 }
484}
485