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 "io"
5 "net/http"
6 "net/http/httptest"
7 "testing"
8
9 "github.com/carlosframework/platform/internal/registry"
10)
11
12// upsertPath writes one route_paths row, the shape the domains reconcile pass
13// derives from a claim's Paths map.
14func upsertPath(t *testing.T, reg *registry.Registry, host, prefix, target string) {
15 t.Helper()
16 if err := reg.UpsertPath(registry.RoutePath{
17 Host: host, Prefix: prefix, Target: target, Account: "acct", App: "app",
18 }); err != nil {
19 t.Fatalf("upsert path %s/%s -> %s: %v", host, prefix, target, err)
20 }
21}
22
23// splitHost stands up one hostname whose default route is "classic" and whose
24// "flipped" prefix reaches a second route, returning the live test server.
25func splitHost(t *testing.T) *httptest.Server {
26 t.Helper()
27 classic, _ := hostEchoBackend(t, "classic")
28 golive, _ := hostEchoBackend(t, "go")
29 reg := openReg(t)
30 if err := reg.Upsert(registry.Route{
31 Host: "ti.to", Kind: "service",
32 Addr: classic.Listener.Addr().String(), Status: "ready",
33 }); err != nil {
34 t.Fatal(err)
35 }
36 if err := reg.Upsert(registry.Route{
37 Host: "go.tito.io", Kind: "service",
38 Addr: golive.Listener.Addr().String(), Status: "ready",
39 }); err != nil {
40 t.Fatal(err)
41 }
42 upsertPath(t, reg, "ti.to", "flipped", "go.tito.io")
43
44 e, err := New(reg, nil)
45 if err != nil {
46 t.Fatalf("New: %v", err)
47 }
48 srv := httptest.NewServer(e)
49 t.Cleanup(srv.Close)
50 return srv
51}
52
53// TestPathRouteReachesTarget is the feature: a flipped prefix reaches the
54// other route's backend while the same hostname's other traffic does not.
55func TestPathRouteReachesTarget(t *testing.T) {
56 srv := splitHost(t)
57 for _, tc := range []struct{ path, want string }{
58 {"/flipped", "go"},
59 {"/flipped/awesomeconf", "go"},
60 {"/flipped/awesomeconf/admin", "go"},
61 {"/notflipped", "classic"},
62 {"/notflipped/event", "classic"},
63 {"/", "classic"},
64 {"", "classic"},
65 } {
66 code, body := getHostPath(t, srv.URL, "ti.to", tc.path)
67 if code != http.StatusOK {
68 t.Fatalf("%s: status = %d, want 200", tc.path, code)
69 }
70 if body != tc.want {
71 t.Fatalf("%s: body = %q, want %q", tc.path, body, tc.want)
72 }
73 }
74}
75
76// TestPathMatchIsSegmentBounded is the boundary pin. A prefix match must be
77// segment-wise equality, never a string prefix — "flippedxyz" is a different
78// account from "flipped" and must not be dragged across the migration with it.
79func TestPathMatchIsSegmentBounded(t *testing.T) {
80 srv := splitHost(t)
81 for _, p := range []string{"/flippedxyz", "/flipped-two", "/flippedxyz/event"} {
82 code, body := getHostPath(t, srv.URL, "ti.to", p)
83 if code != http.StatusOK {
84 t.Fatalf("%s: status = %d, want 200", p, code)
85 }
86 if body != "classic" {
87 t.Fatalf("%s: body = %q, want classic — a prefix matches whole segments only", p, body)
88 }
89 }
90}
91
92// TestPathFoldOrderAliasInheritsPrefixMap pins the fold order in Reload:
93// path routes are folded into the map BEFORE aliases (edge.go, the comment
94// above the path fold), so an alias of a prefix-routed hostname inherits its
95// prefix map along with everything else it copies. Swap that order and this
96// test is the one that catches it — every other test in this file talks to
97// the primary hostname directly and would keep passing.
98func TestPathFoldOrderAliasInheritsPrefixMap(t *testing.T) {
99 classic, _ := hostEchoBackend(t, "classic")
100 golive, _ := hostEchoBackend(t, "go")
101 reg := openReg(t)
102 if err := reg.Upsert(registry.Route{
103 Host: "ti.to", Kind: "service",
104 Addr: classic.Listener.Addr().String(), Status: "ready",
105 }); err != nil {
106 t.Fatal(err)
107 }
108 if err := reg.Upsert(registry.Route{
109 Host: "go.tito.io", Kind: "service",
110 Addr: golive.Listener.Addr().String(), Status: "ready",
111 }); err != nil {
112 t.Fatal(err)
113 }
114 upsertPath(t, reg, "ti.to", "flipped", "go.tito.io")
115 upsertAlias(t, reg, "ti-to.alias.example", "ti.to")
116
117 e, err := New(reg, nil)
118 if err != nil {
119 t.Fatal(err)
120 }
121 srv := httptest.NewServer(e)
122 t.Cleanup(srv.Close)
123
124 code, body := getHostPath(t, srv.URL, "ti-to.alias.example", "/flipped")
125 if code != http.StatusOK || body != "go" {
126 t.Fatalf("alias /flipped: status=%d body=%q, want 200/go — the alias must inherit ti.to's prefix map, which requires path routes to fold BEFORE aliases in Reload", code, body)
127 }
128}
129
130// TestPathRouteDoesNotStripThePrefix: the upstream sees the original URL. The
131// flipped and unflipped URL spaces must be identical, which is the whole
132// point of a seamless migration.
133func TestPathRouteDoesNotStripThePrefix(t *testing.T) {
134 classic, _ := hostEchoBackend(t, "classic")
135 golive := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
136 w.Write([]byte(r.URL.Path))
137 }))
138 t.Cleanup(golive.Close)
139
140 reg := openReg(t)
141 if err := reg.Upsert(registry.Route{
142 Host: "ti.to", Kind: "service",
143 Addr: classic.Listener.Addr().String(), Status: "ready",
144 }); err != nil {
145 t.Fatal(err)
146 }
147 if err := reg.Upsert(registry.Route{
148 Host: "go.tito.io", Kind: "service",
149 Addr: golive.Listener.Addr().String(), Status: "ready",
150 }); err != nil {
151 t.Fatal(err)
152 }
153 upsertPath(t, reg, "ti.to", "paulca", "go.tito.io")
154
155 e, err := New(reg, nil)
156 if err != nil {
157 t.Fatal(err)
158 }
159 srv := httptest.NewServer(e)
160 t.Cleanup(srv.Close)
161
162 _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf")
163 if body != "/paulca/awesomeconf" {
164 t.Fatalf("upstream saw %q, want /paulca/awesomeconf — the prefix must NOT be stripped", body)
165 }
166}
167
168// TestPathMatchTriesBothFormsOfTheFirstSegment is the flip-bypass pin, and it
169// pins BOTH directions of it, because the matcher's two halves fail opposite
170// ways and each one alone reopens what the other closes.
171//
172// The invariant: during a reversible dual-run migration, the app an account
173// was flipped AWAY from must never answer for that account.
174//
175// - Raw-split alone yields "" for "//flipped/foo", so the request falls
176// through to classic — which most backends then serve as
177// "/flipped/foo". One character, silent. (The original I2 finding.)
178// - Clean-first alone yields "notflipped" for "/flipped/../notflipped", so
179// THAT falls through to classic — and a backend which does not collapse
180// ".." (or which routes "/:account/*rest") serves flipped's page from
181// classic. Same bypass, reached from the other side.
182//
183// Consulting both closes both. The cost is the benign direction: a
184// normalising target gets asked for an account it was not given and 404s.
185func TestPathMatchTriesBothFormsOfTheFirstSegment(t *testing.T) {
186 classic, _ := hostEchoBackend(t, "classic")
187 seen := make(chan string, 16)
188 golive := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
189 seen <- r.URL.Path
190 w.Write([]byte("go"))
191 }))
192 t.Cleanup(golive.Close)
193 beta, _ := hostEchoBackend(t, "beta")
194
195 reg := openReg(t)
196 for _, rt := range []registry.Route{
197 {Host: "ti.to", Kind: "service", Addr: classic.Listener.Addr().String(), Status: "ready"},
198 {Host: "go.tito.io", Kind: "service", Addr: golive.Listener.Addr().String(), Status: "ready"},
199 {Host: "beta.tito.io", Kind: "service", Addr: beta.Listener.Addr().String(), Status: "ready"},
200 } {
201 if err := reg.Upsert(rt); err != nil {
202 t.Fatal(err)
203 }
204 }
205 upsertPath(t, reg, "ti.to", "flipped", "go.tito.io")
206 upsertPath(t, reg, "ti.to", "onbeta", "beta.tito.io")
207
208 e, err := New(reg, nil)
209 if err != nil {
210 t.Fatal(err)
211 }
212 srv := httptest.NewServer(e)
213 t.Cleanup(srv.Close)
214
215 for _, tc := range []struct{ path, want, why string }{
216 // Only the CLEANED form finds the account.
217 {"//flipped", "go", "leading empty segment"},
218 {"//flipped/event", "go", "leading empty segment"},
219 {"///flipped/event", "go", "leading empty segments"},
220 {"/./flipped", "go", "dot segment"},
221 // Only the RAW form finds it. These are the rows that would go to
222 // classic under a clean-only matcher — the regression this test
223 // exists to prevent.
224 {"/flipped/../notflipped", "go", "raw first segment is still flipped"},
225 {"/flipped/..", "go", "raw first segment is still flipped"},
226 {"/flipped/../", "go", "raw first segment is still flipped"},
227 // BOTH forms hit, and they name different prefixes: the CLEANED one
228 // wins, because that is the account a normalising backend will serve.
229 {"/flipped/../onbeta", "beta", "cleaned wins when both hit"},
230 // Neither form names a flipped account: the origin still answers.
231 {"/notflipped", "classic", "no prefix matches"},
232 {"/notflipped/../alsonot", "classic", "no prefix matches either way"},
233 {"/", "classic", "root"},
234 {"//", "classic", "root, however spelled"},
235 // Segment-bounded as ever; normalisation must not have loosened it.
236 {"/flippedxyz", "classic", "segment equality, never HasPrefix"},
237 {"//flippedxyz/event", "classic", "segment equality after cleaning too"},
238 } {
239 code, body := getHostPath(t, srv.URL, "ti.to", tc.path)
240 if code != http.StatusOK {
241 t.Fatalf("%s: status = %d, want 200", tc.path, code)
242 }
243 if body != tc.want {
244 t.Fatalf("%s: body = %q, want %q (%s)", tc.path, body, tc.want, tc.why)
245 }
246 }
247
248 // Matching normalises; PROXYING does not. The upstream must still receive
249 // the request's original, uncleaned path — no stripping, no rewriting, so
250 // the flipped and unflipped URL spaces stay byte-identical (spec §6).
251 for _, want := range []string{"//flipped", "//flipped/event", "///flipped/event", "/./flipped"} {
252 if got := <-seen; got != want {
253 t.Fatalf("upstream saw %q, want the original uncleaned path %q", got, want)
254 }
255 }
256 for _, want := range []string{"/flipped/../notflipped", "/flipped/..", "/flipped/../"} {
257 if got := <-seen; got != want {
258 t.Fatalf("upstream saw %q, want the original uncleaned path %q", got, want)
259 }
260 }
261}
262
263// TestPathPreservesInboundHost: the upstream sees the hostname the browser
264// asked for, matching the alias contract. Tito Go must be able to build
265// absolute URLs that keep the visitor on ti.to.
266func TestPathPreservesInboundHost(t *testing.T) {
267 classic, _ := hostEchoBackend(t, "classic")
268 golive, seenHost := hostEchoBackend(t, "go")
269 reg := openReg(t)
270 if err := reg.Upsert(registry.Route{
271 Host: "ti.to", Kind: "service",
272 Addr: classic.Listener.Addr().String(), Status: "ready",
273 }); err != nil {
274 t.Fatal(err)
275 }
276 if err := reg.Upsert(registry.Route{
277 Host: "go.tito.io", Kind: "service",
278 Addr: golive.Listener.Addr().String(), Status: "ready",
279 }); err != nil {
280 t.Fatal(err)
281 }
282 upsertPath(t, reg, "ti.to", "paulca", "go.tito.io")
283
284 e, err := New(reg, nil)
285 if err != nil {
286 t.Fatal(err)
287 }
288 srv := httptest.NewServer(e)
289 t.Cleanup(srv.Close)
290
291 getHostPath(t, srv.URL, "ti.to", "/paulca")
292 if *seenHost != "ti.to" {
293 t.Fatalf("upstream saw Host %q, want ti.to", *seenHost)
294 }
295}
296
297// TestPathRowForMissingTargetFallsThrough: a row whose target is not in the
298// live map must serve the hostname's own route, never 404. This is the
299// failure direction the whole design depends on.
300func TestPathRowForMissingTargetFallsThrough(t *testing.T) {
301 classic, _ := hostEchoBackend(t, "classic")
302 reg := openReg(t)
303 if err := reg.Upsert(registry.Route{
304 Host: "ti.to", Kind: "service",
305 Addr: classic.Listener.Addr().String(), Status: "ready",
306 }); err != nil {
307 t.Fatal(err)
308 }
309 upsertPath(t, reg, "ti.to", "paulca", "vanished.example")
310
311 e, err := New(reg, nil)
312 if err != nil {
313 t.Fatal(err)
314 }
315 srv := httptest.NewServer(e)
316 t.Cleanup(srv.Close)
317
318 code, body := getHostPath(t, srv.URL, "ti.to", "/paulca")
319 if code != http.StatusOK || body != "classic" {
320 t.Fatalf("status=%d body=%q, want 200/classic — a dangling target must fall through", code, body)
321 }
322}
323
324// TestHostsWithoutPathsAreUnaffected: the additive guarantee. Every host on
325// the platform today has no prefix map and must behave exactly as before.
326func TestHostsWithoutPathsAreUnaffected(t *testing.T) {
327 backend, _ := hostEchoBackend(t, "plain")
328 reg := openReg(t)
329 if err := reg.Upsert(registry.Route{
330 Host: "app.acct.oncarlos.com", Kind: "service",
331 Addr: backend.Listener.Addr().String(), Status: "ready",
332 }); err != nil {
333 t.Fatal(err)
334 }
335 e, err := New(reg, nil)
336 if err != nil {
337 t.Fatal(err)
338 }
339 srv := httptest.NewServer(e)
340 t.Cleanup(srv.Close)
341
342 for _, p := range []string{"/", "/anything", "/deep/path/here"} {
343 code, body := getHostPath(t, srv.URL, "app.acct.oncarlos.com", p)
344 if code != http.StatusOK || body != "plain" {
345 t.Fatalf("%s: status=%d body=%q, want 200/plain", p, code, body)
346 }
347 }
348}
349
350// TestPathRoutedRequestMetersAgainstTheTarget pins the attribution ruling
351// documented on lookupUpstreamPath: a path hit returns the TARGET's upstream
352// entry wholesale, so the usage meter bills the target's route, account and
353// app — not the hostname the browser asked for. That is correct (the target's
354// backend burned the bytes) and it is easy to "fix" the wrong way later, so
355// it is pinned here. The same entry is what makes the version header, the
356// purge tenancy and the activator keying the target's; see that doc comment.
357func TestPathRoutedRequestMetersAgainstTheTarget(t *testing.T) {
358 body := "go"
359 classic, _ := hostEchoBackend(t, "classic")
360 golive, _ := hostEchoBackend(t, body)
361 reg := openReg(t)
362 for _, rt := range []registry.Route{
363 {Host: "ti.to", Kind: "service", Addr: classic.Listener.Addr().String(),
364 Status: "ready", Account: "tito", App: "classic"},
365 {Host: "go.tito.io", Kind: "service", Addr: golive.Listener.Addr().String(),
366 Status: "ready", Account: "tito", App: "titogo"},
367 } {
368 if err := reg.Upsert(rt); err != nil {
369 t.Fatal(err)
370 }
371 }
372 upsertPath(t, reg, "ti.to", "flipped", "go.tito.io")
373
374 e, err := New(reg, nil)
375 if err != nil {
376 t.Fatal(err)
377 }
378 meter := NewUsageMeter()
379 e.EnableUsage(meter)
380 srv := httptest.NewServer(e)
381 t.Cleanup(srv.Close)
382
383 if code, got := getHostPath(t, srv.URL, "ti.to", "/flipped/event"); code != http.StatusOK || got != body {
384 t.Fatalf("flipped request = %d %q, want 200 %q", code, got, body)
385 }
386
387 rows := meter.Drain()
388 row, ok := rows["go.tito.io"]
389 if !ok {
390 t.Fatalf("usage rows = %+v, want the TARGET route metered", rows)
391 }
392 if row.Requests != 1 || row.BytesOut != int64(len(body)) {
393 t.Fatalf("go.tito.io row = %+v, want Requests=1 BytesOut=%d", row, len(body))
394 }
395 if row.App != "titogo" {
396 t.Fatalf("go.tito.io row = %+v, want the TARGET's app, not the requested hostname's", row)
397 }
398 if _, ok := rows["ti.to"]; ok {
399 t.Fatalf("usage rows = %+v, want nothing billed to the requested hostname for a path-routed request", rows)
400 }
401}
402
403// TestFirstSegment is the matcher's two halves, tested directly because every
404// routing decision in this feature rests on them. Both columns are pinned for
405// every row, INCLUDING the rows where they disagree — the disagreements are
406// the whole reason lookupUpstreamPath consults both (see its match block).
407func TestFirstSegment(t *testing.T) {
408 for _, tc := range []struct{ in, wantRaw, wantClean string }{
409 // Ordinary shapes: the two halves agree, and this is every real
410 // request on the platform.
411 {"/paulca", "paulca", "paulca"},
412 {"/paulca/", "paulca", "paulca"},
413 {"/paulca/awesomeconf", "paulca", "paulca"},
414 {"/paulca/awesomeconf/admin", "paulca", "paulca"},
415 {"/paulca/awesomeconf/", "paulca", "paulca"},
416 {"/paulcarson", "paulcarson", "paulcarson"}, // segment equality, never HasPrefix
417 {"/", "", ""},
418 {"", "", ""},
419 {"paulca", "", ""}, // not an absolute path; never matches
420 {"//", "", ""},
421
422 // Leading empty segments and dot segments: only the CLEANED half
423 // finds the account. Rails and friends serve //paulca/foo as
424 // /paulca/foo, so without this half a flipped account falls through
425 // to the app it was flipped away from — the original I2 bypass.
426 {"//paulca", "", "paulca"},
427 {"//paulca/foo", "", "paulca"},
428 {"///paulca/foo", "", "paulca"},
429 {"/./paulca", ".", "paulca"},
430 {"/paulca/./awesomeconf", "paulca", "paulca"},
431 {"/paulca//awesomeconf", "paulca", "paulca"},
432
433 // ".." shapes: the two halves DISAGREE, and only the RAW half still
434 // names paulca. A backend that does not collapse ".." would serve
435 // paulca's page, so dropping the raw half here reopens the same
436 // bypass from the other side. Consulting both is what closes it.
437 {"/paulca/../bob", "paulca", "bob"},
438 {"/paulca/..", "paulca", ""},
439 {"/paulca/../", "paulca", ""},
440 {"/../bob", "..", "bob"}, // ".." can never be a prefix (validPathPrefix)
441 {"/..", "..", ""},
442 {"/.", ".", ""},
443 } {
444 if got := firstSegment(tc.in); got != tc.wantRaw {
445 t.Fatalf("firstSegment(%q) = %q, want %q", tc.in, got, tc.wantRaw)
446 }
447 if got := firstSegmentClean(tc.in); got != tc.wantClean {
448 t.Fatalf("firstSegmentClean(%q) = %q, want %q", tc.in, got, tc.wantClean)
449 }
450 }
451}
452
453// getHostPath issues GET path against srv with an explicit Host header.
454func getHostPath(t *testing.T, srvURL, host, path string) (int, string) {
455 t.Helper()
456 req, err := http.NewRequest("GET", srvURL+path, nil)
457 if err != nil {
458 t.Fatal(err)
459 }
460 req.Host = host
461 resp, err := http.DefaultClient.Do(req)
462 if err != nil {
463 t.Fatal(err)
464 }
465 defer resp.Body.Close()
466 b, err := io.ReadAll(resp.Body)
467 if err != nil {
468 t.Fatal(err)
469 }
470 return resp.StatusCode, string(b)
471}
472