| new file mode 100644 |
| index 000000000..8c3ff8ae9 |
| --- /dev/null |
| +++ b/internal/edge/path_test.go |
| @@ -0,0 +1,292 @@ |
| +package edge |
| + |
| +import ( |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// upsertPath writes one route_paths row, the shape the domains reconcile pass |
| +// derives from a claim's Paths map. |
| +func upsertPath(t *testing.T, reg *registry.Registry, host, prefix, target string) { |
| + t.Helper() |
| + if err := reg.UpsertPath(registry.RoutePath{ |
| + Host: host, Prefix: prefix, Target: target, Account: "acct", App: "app", |
| + }); err != nil { |
| + t.Fatalf("upsert path %s/%s -> %s: %v", host, prefix, target, err) |
| + } |
| +} |
| + |
| +// splitHost stands up one hostname whose default route is "classic" and whose |
| +// "flipped" prefix reaches a second route, returning the live test server. |
| +func splitHost(t *testing.T) *httptest.Server { |
| + t.Helper() |
| + classic, _ := hostEchoBackend(t, "classic") |
| + golive, _ := hostEchoBackend(t, "go") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "ti.to", Kind: "service", |
| + Addr: classic.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "go.tito.io", Kind: "service", |
| + Addr: golive.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + upsertPath(t, reg, "ti.to", "flipped", "go.tito.io") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + return srv |
| +} |
| + |
| +// TestPathRouteReachesTarget is the feature: a flipped prefix reaches the |
| +// other route's backend while the same hostname's other traffic does not. |
| +func TestPathRouteReachesTarget(t *testing.T) { |
| + srv := splitHost(t) |
| + for _, tc := range []struct{ path, want string }{ |
| + {"/flipped", "go"}, |
| + {"/flipped/awesomeconf", "go"}, |
| + {"/flipped/awesomeconf/admin", "go"}, |
| + {"/notflipped", "classic"}, |
| + {"/notflipped/event", "classic"}, |
| + {"/", "classic"}, |
| + {"", "classic"}, |
| + } { |
| + code, body := getHostPath(t, srv.URL, "ti.to", tc.path) |
| + if code != http.StatusOK { |
| + t.Fatalf("%s: status = %d, want 200", tc.path, code) |
| + } |
| + if body != tc.want { |
| + t.Fatalf("%s: body = %q, want %q", tc.path, body, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// TestPathMatchIsSegmentBounded is the boundary pin. A prefix match must be |
| +// segment-wise equality, never a string prefix — "flippedxyz" is a different |
| +// account from "flipped" and must not be dragged across the migration with it. |
| +func TestPathMatchIsSegmentBounded(t *testing.T) { |
| + srv := splitHost(t) |
| + for _, p := range []string{"/flippedxyz", "/flipped-two", "/flippedxyz/event"} { |
| + code, body := getHostPath(t, srv.URL, "ti.to", p) |
| + if code != http.StatusOK { |
| + t.Fatalf("%s: status = %d, want 200", p, code) |
| + } |
| + if body != "classic" { |
| + t.Fatalf("%s: body = %q, want classic — a prefix matches whole segments only", p, body) |
| + } |
| + } |
| +} |
| + |
| +// TestPathFoldOrderAliasInheritsPrefixMap pins the fold order in Reload: |
| +// path routes are folded into the map BEFORE aliases (edge.go, the comment |
| +// above the path fold), so an alias of a prefix-routed hostname inherits its |
| +// prefix map along with everything else it copies. Swap that order and this |
| +// test is the one that catches it — every other test in this file talks to |
| +// the primary hostname directly and would keep passing. |
| +func TestPathFoldOrderAliasInheritsPrefixMap(t *testing.T) { |
| + classic, _ := hostEchoBackend(t, "classic") |
| + golive, _ := hostEchoBackend(t, "go") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "ti.to", Kind: "service", |
| + Addr: classic.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "go.tito.io", Kind: "service", |
| + Addr: golive.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + upsertPath(t, reg, "ti.to", "flipped", "go.tito.io") |
| + upsertAlias(t, reg, "ti-to.alias.example", "ti.to") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + code, body := getHostPath(t, srv.URL, "ti-to.alias.example", "/flipped") |
| + if code != http.StatusOK || body != "go" { |
| + 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) |
| + } |
| +} |
| + |
| +// TestPathRouteDoesNotStripThePrefix: the upstream sees the original URL. The |
| +// flipped and unflipped URL spaces must be identical, which is the whole |
| +// point of a seamless migration. |
| +func TestPathRouteDoesNotStripThePrefix(t *testing.T) { |
| + classic, _ := hostEchoBackend(t, "classic") |
| + golive := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Write([]byte(r.URL.Path)) |
| + })) |
| + t.Cleanup(golive.Close) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "ti.to", Kind: "service", |
| + Addr: classic.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "go.tito.io", Kind: "service", |
| + Addr: golive.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + upsertPath(t, reg, "ti.to", "paulca", "go.tito.io") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + _, body := getHostPath(t, srv.URL, "ti.to", "/paulca/awesomeconf") |
| + if body != "/paulca/awesomeconf" { |
| + t.Fatalf("upstream saw %q, want /paulca/awesomeconf — the prefix must NOT be stripped", body) |
| + } |
| +} |
| + |
| +// TestPathPreservesInboundHost: the upstream sees the hostname the browser |
| +// asked for, matching the alias contract. Tito Go must be able to build |
| +// absolute URLs that keep the visitor on ti.to. |
| +func TestPathPreservesInboundHost(t *testing.T) { |
| + classic, _ := hostEchoBackend(t, "classic") |
| + golive, seenHost := hostEchoBackend(t, "go") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "ti.to", Kind: "service", |
| + Addr: classic.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "go.tito.io", Kind: "service", |
| + Addr: golive.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + upsertPath(t, reg, "ti.to", "paulca", "go.tito.io") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + getHostPath(t, srv.URL, "ti.to", "/paulca") |
| + if *seenHost != "ti.to" { |
| + t.Fatalf("upstream saw Host %q, want ti.to", *seenHost) |
| + } |
| +} |
| + |
| +// TestPathRowForMissingTargetFallsThrough: a row whose target is not in the |
| +// live map must serve the hostname's own route, never 404. This is the |
| +// failure direction the whole design depends on. |
| +func TestPathRowForMissingTargetFallsThrough(t *testing.T) { |
| + classic, _ := hostEchoBackend(t, "classic") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "ti.to", Kind: "service", |
| + Addr: classic.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + upsertPath(t, reg, "ti.to", "paulca", "vanished.example") |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + code, body := getHostPath(t, srv.URL, "ti.to", "/paulca") |
| + if code != http.StatusOK || body != "classic" { |
| + t.Fatalf("status=%d body=%q, want 200/classic — a dangling target must fall through", code, body) |
| + } |
| +} |
| + |
| +// TestHostsWithoutPathsAreUnaffected: the additive guarantee. Every host on |
| +// the platform today has no prefix map and must behave exactly as before. |
| +func TestHostsWithoutPathsAreUnaffected(t *testing.T) { |
| + backend, _ := hostEchoBackend(t, "plain") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "app.acct.oncarlos.com", Kind: "service", |
| + Addr: backend.Listener.Addr().String(), Status: "ready", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + for _, p := range []string{"/", "/anything", "/deep/path/here"} { |
| + code, body := getHostPath(t, srv.URL, "app.acct.oncarlos.com", p) |
| + if code != http.StatusOK || body != "plain" { |
| + t.Fatalf("%s: status=%d body=%q, want 200/plain", p, code, body) |
| + } |
| + } |
| +} |
| + |
| +// TestFirstSegment is the matcher itself, tested directly because every |
| +// routing decision in this feature rests on it. |
| +func TestFirstSegment(t *testing.T) { |
| + for _, tc := range []struct{ in, want string }{ |
| + {"/paulca", "paulca"}, |
| + {"/paulca/", "paulca"}, |
| + {"/paulca/awesomeconf", "paulca"}, |
| + {"/paulca/awesomeconf/admin", "paulca"}, |
| + {"/", ""}, |
| + {"", ""}, |
| + {"paulca", ""}, // not an absolute path; never matches |
| + {"//", ""}, |
| + } { |
| + if got := firstSegment(tc.in); got != tc.want { |
| + t.Fatalf("firstSegment(%q) = %q, want %q", tc.in, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// getHostPath issues GET path against srv with an explicit Host header. |
| +func getHostPath(t *testing.T, srvURL, host, path string) (int, string) { |
| + t.Helper() |
| + req, err := http.NewRequest("GET", srvURL+path, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Host = host |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + b, err := io.ReadAll(resp.Body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return resp.StatusCode, string(b) |
| +} |