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 "net/http"
5 "net/http/httptest"
6 "testing"
7
8 "github.com/carlosframework/platform/internal/registry"
9)
10
11// purgePathEdge stands up the shape path routing exists to create: a front
12// hostname whose prefix routes to a second route belonging to a DIFFERENT app
13// in the same account, plus a cache. The path row is written before New so
14// Reload folds it into the upstream map.
15func purgePathEdge(t *testing.T, front, target registry.Route, prefix string, h http.HandlerFunc) *Edge {
16 t.Helper()
17 backend := httptest.NewServer(h)
18 t.Cleanup(backend.Close)
19 reg := openReg(t)
20 for _, rt := range []registry.Route{front, target} {
21 rt.Kind, rt.Addr, rt.Status = "instance", backend.Listener.Addr().String(), "ready"
22 if err := reg.Upsert(rt); err != nil {
23 t.Fatalf("upsert %s: %v", rt.Host, err)
24 }
25 }
26 if err := reg.UpsertPath(registry.RoutePath{
27 Host: front.Host, Prefix: prefix, Target: target.Host,
28 Account: front.Account, App: front.App,
29 }); err != nil {
30 t.Fatalf("upsert path %s/%s: %v", front.Host, prefix, err)
31 }
32 e, err := New(reg, nil)
33 if err != nil {
34 t.Fatalf("New: %v", err)
35 }
36 e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
37 return e
38}
39
40// TestPurgeHostOverrideAllowedFromAPathRoutedHost is the hole this closes.
41//
42// A path-routed request is cached under the hostname the browser asked for
43// (ti.to), but it is served by the target's route (go.tito.example), and
44// purgeModifier is built from that route. So the app's own Carlos-Purge lands
45// under go.tito.example and hits nothing, and the documented escape hatch —
46// naming ti.to explicitly — used to be refused too, because the override
47// required the same app and path routing crosses apps by design.
48//
49// The request really is path-routed here rather than simulated: it arrives on
50// ti.to/paulca and reaches the target through the prefix map, so this pins the
51// composition and not just resolvePurgeHosts in isolation.
52func TestPurgeHostOverrideAllowedFromAPathRoutedHost(t *testing.T) {
53 e := purgePathEdge(t,
54 route("ti.to", "classic", "tenanta"),
55 route("go.tito.example", "gonew", "tenanta"),
56 "paulca",
57 func(w http.ResponseWriter, r *http.Request) {
58 w.Header().Set("Carlos-Purge-Host", "ti.to")
59 w.Header().Set("Carlos-Purge", "/paulca/events/42")
60 w.WriteHeader(http.StatusOK)
61 })
62 seed(t, e.cache, "ti.to", "/paulca/events/42", "", "stale")
63 seed(t, e.cache, "ti.to", "/paulca/events/43", "", "untouched")
64
65 rec := httptest.NewRecorder()
66 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://ti.to/paulca/admin", nil))
67
68 if cached(e.cache, "ti.to", "/paulca/events/42", "") {
69 t.Fatal("a path-routed app could not purge its own stale page under the hostname it is served on")
70 }
71 if !cached(e.cache, "ti.to", "/paulca/events/43", "") {
72 t.Fatal("/paulca/events/43 was purged but nothing named it")
73 }
74 if got := rec.Header().Get("Carlos-Purge-Host"); got != "" {
75 t.Fatalf("Carlos-Purge-Host reached the client: %q", got)
76 }
77}
78
79// TestPurgeHostOverrideFromPathRoutedHostStillNeedsTheAccount — the new branch
80// drops the same-APP requirement and nothing else. Account remains the
81// boundary, and the edge checks it here rather than trusting that the
82// reconcile pass already refused a cross-account path row: a row written by an
83// older binary, or by hand, must not be able to reach another tenant's cache.
84//
85// Both accounts must be valid sqids (^[a-z]{3,16}$) or release.RouteAccount
86// folds them both onto the built-in ops account and the test asserts a
87// boundary it never crossed.
88func TestPurgeHostOverrideFromPathRoutedHostStillNeedsTheAccount(t *testing.T) {
89 e := purgePathEdge(t,
90 route("ti.to", "classic", "tenanta"),
91 route("go.tito.example", "gonew", "tenantb"),
92 "paulca",
93 func(w http.ResponseWriter, r *http.Request) {
94 w.Header().Set("Carlos-Purge-Host", "ti.to")
95 w.Header().Set("Carlos-Purge", "/paulca/events/42")
96 w.WriteHeader(http.StatusOK)
97 })
98 seed(t, e.cache, "ti.to", "/paulca/events/42", "", "stale")
99
100 rec := httptest.NewRecorder()
101 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://ti.to/paulca/admin", nil))
102
103 if !cached(e.cache, "ti.to", "/paulca/events/42", "") {
104 t.Fatal("a path row pointing across ACCOUNTS let the target purge the front host's cache")
105 }
106}
107
108// TestPurgeHostOverrideRefusedWhenNoPrefixPointsAtTheRoute — the exception is
109// scoped to the route the prefix map actually names. A second route in the
110// same account, serving nothing of ti.to's traffic, gets the ordinary
111// cross-app refusal.
112//
113// Without this, "the front host path-routes somewhere" would be enough, and
114// any app in the account could purge ti.to by naming it.
115func TestPurgeHostOverrideRefusedWhenNoPrefixPointsAtTheRoute(t *testing.T) {
116 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
117 w.Header().Set("Carlos-Purge-Host", "ti.to")
118 w.Header().Set("Carlos-Purge", "/paulca/events/42")
119 w.WriteHeader(http.StatusOK)
120 }))
121 t.Cleanup(backend.Close)
122
123 reg := openReg(t)
124 for _, rt := range []registry.Route{
125 route("ti.to", "classic", "tenanta"),
126 route("go.tito.example", "gonew", "tenanta"),
127 route("bystander.example", "other", "tenanta"),
128 } {
129 rt.Kind, rt.Addr, rt.Status = "instance", backend.Listener.Addr().String(), "ready"
130 if err := reg.Upsert(rt); err != nil {
131 t.Fatalf("upsert %s: %v", rt.Host, err)
132 }
133 }
134 // ti.to routes /paulca to go.tito.example — and to nothing else.
135 if err := reg.UpsertPath(registry.RoutePath{
136 Host: "ti.to", Prefix: "paulca", Target: "go.tito.example",
137 Account: "tenanta", App: "classic",
138 }); err != nil {
139 t.Fatal(err)
140 }
141 e, err := New(reg, nil)
142 if err != nil {
143 t.Fatal(err)
144 }
145 e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
146 seed(t, e.cache, "ti.to", "/paulca/events/42", "", "stale")
147
148 // The write arrives on the bystander's OWN host, so it is served by the
149 // bystander's route — which no prefix of ti.to names.
150 rec := httptest.NewRecorder()
151 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://bystander.example/write", nil))
152
153 if !cached(e.cache, "ti.to", "/paulca/events/42", "") {
154 t.Fatal("a route no prefix points at purged ti.to — the exception is not scoped to the path target")
155 }
156}
157