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) } } // TestPathMatchTriesBothFormsOfTheFirstSegment is the flip-bypass pin, and it // pins BOTH directions of it, because the matcher's two halves fail opposite // ways and each one alone reopens what the other closes. // // The invariant: during a reversible dual-run migration, the app an account // was flipped AWAY from must never answer for that account. // // - Raw-split alone yields "" for "//flipped/foo", so the request falls // through to classic — which most backends then serve as // "/flipped/foo". One character, silent. (The original I2 finding.) // - Clean-first alone yields "notflipped" for "/flipped/../notflipped", so // THAT falls through to classic — and a backend which does not collapse // ".." (or which routes "/:account/*rest") serves flipped's page from // classic. Same bypass, reached from the other side. // // Consulting both closes both. The cost is the benign direction: a // normalising target gets asked for an account it was not given and 404s. func TestPathMatchTriesBothFormsOfTheFirstSegment(t *testing.T) { classic, _ := hostEchoBackend(t, "classic") seen := make(chan string, 16) golive := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen <- r.URL.Path w.Write([]byte("go")) })) t.Cleanup(golive.Close) beta, _ := hostEchoBackend(t, "beta") reg := openReg(t) for _, rt := range []registry.Route{ {Host: "ti.to", Kind: "service", Addr: classic.Listener.Addr().String(), Status: "ready"}, {Host: "go.tito.io", Kind: "service", Addr: golive.Listener.Addr().String(), Status: "ready"}, {Host: "beta.tito.io", Kind: "service", Addr: beta.Listener.Addr().String(), Status: "ready"}, } { if err := reg.Upsert(rt); err != nil { t.Fatal(err) } } upsertPath(t, reg, "ti.to", "flipped", "go.tito.io") upsertPath(t, reg, "ti.to", "onbeta", "beta.tito.io") e, err := New(reg, nil) if err != nil { t.Fatal(err) } srv := httptest.NewServer(e) t.Cleanup(srv.Close) for _, tc := range []struct{ path, want, why string }{ // Only the CLEANED form finds the account. {"//flipped", "go", "leading empty segment"}, {"//flipped/event", "go", "leading empty segment"}, {"///flipped/event", "go", "leading empty segments"}, {"/./flipped", "go", "dot segment"}, // Only the RAW form finds it. These are the rows that would go to // classic under a clean-only matcher — the regression this test // exists to prevent. {"/flipped/../notflipped", "go", "raw first segment is still flipped"}, {"/flipped/..", "go", "raw first segment is still flipped"}, {"/flipped/../", "go", "raw first segment is still flipped"}, // BOTH forms hit, and they name different prefixes: the CLEANED one // wins, because that is the account a normalising backend will serve. {"/flipped/../onbeta", "beta", "cleaned wins when both hit"}, // Neither form names a flipped account: the origin still answers. {"/notflipped", "classic", "no prefix matches"}, {"/notflipped/../alsonot", "classic", "no prefix matches either way"}, {"/", "classic", "root"}, {"//", "classic", "root, however spelled"}, // Segment-bounded as ever; normalisation must not have loosened it. {"/flippedxyz", "classic", "segment equality, never HasPrefix"}, {"//flippedxyz/event", "classic", "segment equality after cleaning too"}, } { 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 (%s)", tc.path, body, tc.want, tc.why) } } // Matching normalises; PROXYING does not. The upstream must still receive // the request's original, uncleaned path — no stripping, no rewriting, so // the flipped and unflipped URL spaces stay byte-identical (spec §6). for _, want := range []string{"//flipped", "//flipped/event", "///flipped/event", "/./flipped"} { if got := <-seen; got != want { t.Fatalf("upstream saw %q, want the original uncleaned path %q", got, want) } } for _, want := range []string{"/flipped/../notflipped", "/flipped/..", "/flipped/../"} { if got := <-seen; got != want { t.Fatalf("upstream saw %q, want the original uncleaned path %q", got, want) } } } // 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) } } } // TestPathRoutedRequestMetersAgainstTheTarget pins the attribution ruling // documented on lookupUpstreamPath: a path hit returns the TARGET's upstream // entry wholesale, so the usage meter bills the target's route, account and // app — not the hostname the browser asked for. That is correct (the target's // backend burned the bytes) and it is easy to "fix" the wrong way later, so // it is pinned here. The same entry is what makes the version header, the // purge tenancy and the activator keying the target's; see that doc comment. func TestPathRoutedRequestMetersAgainstTheTarget(t *testing.T) { body := "go" classic, _ := hostEchoBackend(t, "classic") golive, _ := hostEchoBackend(t, body) reg := openReg(t) for _, rt := range []registry.Route{ {Host: "ti.to", Kind: "service", Addr: classic.Listener.Addr().String(), Status: "ready", Account: "tito", App: "classic"}, {Host: "go.tito.io", Kind: "service", Addr: golive.Listener.Addr().String(), Status: "ready", Account: "tito", App: "titogo"}, } { if err := reg.Upsert(rt); err != nil { t.Fatal(err) } } upsertPath(t, reg, "ti.to", "flipped", "go.tito.io") e, err := New(reg, nil) if err != nil { t.Fatal(err) } meter := NewUsageMeter() e.EnableUsage(meter) srv := httptest.NewServer(e) t.Cleanup(srv.Close) if code, got := getHostPath(t, srv.URL, "ti.to", "/flipped/event"); code != http.StatusOK || got != body { t.Fatalf("flipped request = %d %q, want 200 %q", code, got, body) } rows := meter.Drain() row, ok := rows["go.tito.io"] if !ok { t.Fatalf("usage rows = %+v, want the TARGET route metered", rows) } if row.Requests != 1 || row.BytesOut != int64(len(body)) { t.Fatalf("go.tito.io row = %+v, want Requests=1 BytesOut=%d", row, len(body)) } if row.App != "titogo" { t.Fatalf("go.tito.io row = %+v, want the TARGET's app, not the requested hostname's", row) } if _, ok := rows["ti.to"]; ok { t.Fatalf("usage rows = %+v, want nothing billed to the requested hostname for a path-routed request", rows) } } // TestFirstSegment is the matcher's two halves, tested directly because every // routing decision in this feature rests on them. Both columns are pinned for // every row, INCLUDING the rows where they disagree — the disagreements are // the whole reason lookupUpstreamPath consults both (see its match block). func TestFirstSegment(t *testing.T) { for _, tc := range []struct{ in, wantRaw, wantClean string }{ // Ordinary shapes: the two halves agree, and this is every real // request on the platform. {"/paulca", "paulca", "paulca"}, {"/paulca/", "paulca", "paulca"}, {"/paulca/awesomeconf", "paulca", "paulca"}, {"/paulca/awesomeconf/admin", "paulca", "paulca"}, {"/paulca/awesomeconf/", "paulca", "paulca"}, {"/paulcarson", "paulcarson", "paulcarson"}, // segment equality, never HasPrefix {"/", "", ""}, {"", "", ""}, {"paulca", "", ""}, // not an absolute path; never matches {"//", "", ""}, // Leading empty segments and dot segments: only the CLEANED half // finds the account. Rails and friends serve //paulca/foo as // /paulca/foo, so without this half a flipped account falls through // to the app it was flipped away from — the original I2 bypass. {"//paulca", "", "paulca"}, {"//paulca/foo", "", "paulca"}, {"///paulca/foo", "", "paulca"}, {"/./paulca", ".", "paulca"}, {"/paulca/./awesomeconf", "paulca", "paulca"}, {"/paulca//awesomeconf", "paulca", "paulca"}, // ".." shapes: the two halves DISAGREE, and only the RAW half still // names paulca. A backend that does not collapse ".." would serve // paulca's page, so dropping the raw half here reopens the same // bypass from the other side. Consulting both is what closes it. {"/paulca/../bob", "paulca", "bob"}, {"/paulca/..", "paulca", ""}, {"/paulca/../", "paulca", ""}, {"/../bob", "..", "bob"}, // ".." can never be a prefix (validPathPrefix) {"/..", "..", ""}, {"/.", ".", ""}, } { if got := firstSegment(tc.in); got != tc.wantRaw { t.Fatalf("firstSegment(%q) = %q, want %q", tc.in, got, tc.wantRaw) } if got := firstSegmentClean(tc.in); got != tc.wantClean { t.Fatalf("firstSegmentClean(%q) = %q, want %q", tc.in, got, tc.wantClean) } } } // 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) }