package console // The routes read (apiroutes.go, issue #64). The properties that matter, in // the order they'd hurt if lost: the gate is the family's byte-identical // 404 and an app token cannot reach this door at all; a caller sees only // their own app's rows out of a registry holding every tenant's; and the // environment binding — the whole reason the issue was reopened — comes // back. import ( "net/http" "strings" "testing" "time" "github.com/carlosframework/platform/internal/registry" ) // routesURL is the routes door for one app in the fixture's account. func routesURL(fx *consoleFixture, app string) string { return fx.ts.URL + "/api/cli/apps/bab/" + app + "/routes" } // routesFixture is a console with titogo claimed by the ops account, a // member token for it, and a registry holding a spread of rows: two of // titogo's (one bound to an environment, one not), one belonging to // another app in the SAME account, and one stamped to a different account // entirely. func routesFixture(t *testing.T, code string) (*consoleFixture, string) { t.Helper() fx, token := configFixture(t, code) reg, err := registry.Open(fx.regPath) if err != nil { t.Fatal(err) } for _, rt := range []registry.Route{ // The hand-added relay: no instance record anywhere, which is // exactly why `carlos instances list` could not answer for it. {Host: "connect.go.tito.io", Kind: "instance", Status: "ready", App: "titogo", Account: "bab", Addr: "127.0.0.1:9001", Channel: "canary/relay", Environment: "relay"}, // Hibernating, with a database path and a rung on it: the two halves // `carlos route --replication` requires, so this row is shaped the // way a real tuned tenant is. {Host: "router-a.go.tito.io", Kind: "instance", Status: "ready", App: "titogo", Account: "bab", Addr: "127.0.0.1:9002", Channel: "stable", Hibernate: true, DBPath: "/data/router-a.go.tito.io.db", Replication: registry.ReplicationDaily}, {Host: "neighbour.example.com", Kind: "instance", Status: "ready", App: "kass", Account: "bab", Addr: "127.0.0.1:9003"}, {Host: "foreign.example.com", Kind: "instance", Status: "ready", App: "titogo", Account: "zzzz", Addr: "127.0.0.1:9004", Environment: "someone-elses"}, } { if err := reg.Upsert(rt); err != nil { t.Fatal(err) } } return fx, token } // hostsOf indexes a routes response by host. func hostsOf(t *testing.T, out map[string]any) map[string]map[string]any { t.Helper() rows, ok := out["routes"].([]any) if !ok { t.Fatalf("response has no routes array: %v", out) } byHost := map[string]map[string]any{} for _, r := range rows { m, _ := r.(map[string]any) byHost[str(t, m, "host")] = m } return byHost } // ---- the gate --------------------------------------------------------------- // TestAPIRoutesGateIsByteIdentical: a token belonging to no account, an // account segment that doesn't exist, and an app the account never claimed // are one and the same 404 — byte-identical to http.NotFound's own output, // so probing this door teaches a token holder nothing. func TestAPIRoutesGateIsByteIdentical(t *testing.T) { fx, token := routesFixture(t, "code-routes-gate") eve := cliToken(t, fx, "eve@keymail.dev", "code-routes-gate-eve") statusA, bodyA := getRaw(t, routesURL(fx, "titogo"), eve) statusB, bodyB := getRaw(t, fx.ts.URL+"/api/cli/apps/zzzz/titogo/routes", token) statusC, bodyC := getRaw(t, routesURL(fx, "ghost-app"), token) if statusA != http.StatusNotFound || statusB != http.StatusNotFound || statusC != http.StatusNotFound { t.Fatalf("routes gate = %d / %d / %d, want 404 for all three", statusA, statusB, statusC) } if bodyA != bodyB || bodyB != bodyC { t.Fatalf("refusals are not byte-identical:\n(a) %q\n(b) %q\n(c) %q", bodyA, bodyB, bodyC) } if bodyA != "404 page not found\n" { t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", bodyA) } } // TestAPIRoutesNoToken: the same 401 cliUser always gives, before it can // matter what account or app was named. func TestAPIRoutesNoToken(t *testing.T) { fx, _ := routesFixture(t, "code-routes-notoken") status, out := getJSON(t, routesURL(fx, "titogo"), "") if status != http.StatusUnauthorized { t.Fatalf("GET with no token = %d %v, want 401", status, out) } } // ---- the answer ------------------------------------------------------------- // TestAPIRoutesReportsTheEnvironmentBinding is the whole point of issue // #64: after `carlos route -host -environment relay`, a member can // read the binding back without a box shell. func TestAPIRoutesReportsTheEnvironmentBinding(t *testing.T) { fx, token := routesFixture(t, "code-routes-env") status, out := getJSON(t, routesURL(fx, "titogo"), token) if status != http.StatusOK { t.Fatalf("GET routes = %d %v", status, out) } byHost := hostsOf(t, out) relay, ok := byHost["connect.go.tito.io"] if !ok { t.Fatalf("routes = %v, want the bound route listed", out["routes"]) } if got := str(t, relay, "environment"); got != "relay" { t.Fatalf("environment = %q, want %q", got, "relay") } if got := str(t, relay, "channel"); got != "canary/relay" { t.Fatalf("channel = %q, want %q", got, "canary/relay") } if got := str(t, relay, "upstream"); got != "127.0.0.1:9001" { t.Fatalf("upstream = %q, want the route's addr", got) } // An UNBOUND route reports "" — a real answer (it takes the app's // default bundle), not missing data. unbound, ok := byHost["router-a.go.tito.io"] if !ok { t.Fatalf("routes = %v, want the unbound route listed too", out["routes"]) } if got := str(t, unbound, "environment"); got != "" { t.Fatalf("unbound route environment = %q, want empty", got) } } // TestAPIRoutesReportsTheReplicationRung: the rung is set on the box, by an // operator, with `carlos route --replication` — and the member reading // `carlos routes` is the only one who can tell whether the tenant they are // paying for is on the rung somebody intended. The field is `omitempty` on // the wire and nothing else in this repo reads it back out of JSON, so an // untested hop is one where the struct field could be deleted and every // route would print "default" with a green suite. func TestAPIRoutesReportsTheReplicationRung(t *testing.T) { fx, token := routesFixture(t, "code-routes-repl") status, out := getJSON(t, routesURL(fx, "titogo"), token) if status != http.StatusOK { t.Fatalf("GET routes = %d %v", status, out) } byHost := hostsOf(t, out) tuned, ok := byHost["router-a.go.tito.io"] if !ok { t.Fatalf("routes = %v, want the tuned route listed", out["routes"]) } if got := str(t, tuned, "replication"); got != registry.ReplicationDaily { t.Fatalf("replication = %q, want %q — the rung an operator set is unreadable to the member whose bill it is", got, registry.ReplicationDaily) } // A route on the pre-ladder default reports nothing rather than a rung it // is not on: the field is omitempty, and "" is what every route carried // before the column had a door. The CLI gives that absence its name // ("default", routes_cli.go's replicationCell) — the wire does not. if v, present := byHost["connect.go.tito.io"]["replication"]; present { t.Fatalf("an unset rung was sent as %v, want the field omitted", v) } } // TestAPIRoutesListsOnlyThisAppsRoutes: the registry holds every tenant's // rows, and the tenant boundary is the only thing standing between a // caller and a neighbour's upstream addresses and unit names. // TestAPIRoutesMarksPlatformCanaries: a route the domains reconciler mints // and rebuilds (Platform && Canary) says so, and nothing else does, because // the CLI reads this to keep from recommending `carlos route` for a row that // verb refuses (#264). A Platform route that is not a canary stays // unmarked: `carlos route --host` may bind that one. func TestAPIRoutesMarksPlatformCanaries(t *testing.T) { fx, token := configFixture(t, "code-routes-canary") if _, err := fx.st.CreateAppIn(OpsAccountID, "shop", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } reg, err := registry.Open(fx.regPath) if err != nil { t.Fatal(err) } for _, rt := range []registry.Route{ {Host: "canary-ab12.shop.bab.oncarlos.test", Kind: "instance", Status: "ready", App: "shop", Account: "bab", Addr: "127.0.0.1:9005", Channel: "canary/ab12", Platform: true, Canary: true}, {Host: "shop.bab.oncarlos.test", Kind: "instance", Status: "ready", App: "shop", Account: "bab", Addr: "127.0.0.1:9006", Channel: "stable", Platform: true}, {Host: "shop.example.com", Kind: "instance", Status: "ready", App: "shop", Account: "bab", Addr: "127.0.0.1:9007", Channel: "stable"}, } { if err := reg.Upsert(rt); err != nil { t.Fatal(err) } } status, out := getJSON(t, routesURL(fx, "shop"), token) if status != http.StatusOK { t.Fatalf("GET routes = %d %v", status, out) } byHost := hostsOf(t, out) for host, want := range map[string]bool{ "canary-ab12.shop.bab.oncarlos.test": true, "shop.bab.oncarlos.test": false, "shop.example.com": false, } { row, ok := byHost[host] if !ok { t.Fatalf("routes = %v, want %s listed", out["routes"], host) } if got, _ := row["platform_canary"].(bool); got != want { t.Errorf("%s platform_canary = %v, want %v", host, row["platform_canary"], want) } } } func TestAPIRoutesListsOnlyThisAppsRoutes(t *testing.T) { fx, token := routesFixture(t, "code-routes-scope") status, out := getJSON(t, routesURL(fx, "titogo"), token) if status != http.StatusOK { t.Fatalf("GET routes = %d %v", status, out) } byHost := hostsOf(t, out) if _, present := byHost["neighbour.example.com"]; present { t.Fatal("another app's route in the same account was listed") } if _, present := byHost["foreign.example.com"]; present { t.Fatal("a route stamped to another ACCOUNT was listed — the app name alone is not the boundary") } if len(byHost) != 2 { t.Fatalf("routes = %v, want exactly titogo's two rows", out["routes"]) } } // TestAPIRoutesSortsByHost: registry order is not stable across passes, so // the answer must not be either — a listing that reshuffles between calls // is one nobody can diff. func TestAPIRoutesSortsByHost(t *testing.T) { fx, token := routesFixture(t, "code-routes-sort") status, out := getJSON(t, routesURL(fx, "titogo"), token) if status != http.StatusOK { t.Fatalf("GET routes = %d %v", status, out) } rows, _ := out["routes"].([]any) if len(rows) != 2 { t.Fatalf("routes = %v, want two", out["routes"]) } first, _ := rows[0].(map[string]any) if str(t, first, "host") != "connect.go.tito.io" { t.Fatalf("first row = %v, want the alphabetically first host", first) } } // TestAPIRoutesSaysSoWhenTheConsoleServesNoRegistry: never an empty list. // "[]" would read as "this app has no routes", which is a different and // wrong answer. func TestAPIRoutesSaysSoWhenTheConsoleServesNoRegistry(t *testing.T) { fx, token := routesFixture(t, "code-routes-noregistry") fx.srv.cfg.Registry = "" status, out := getJSON(t, routesURL(fx, "titogo"), token) if status != http.StatusServiceUnavailable { t.Fatalf("GET routes with no registry = %d %v, want 503", status, out) } if _, present := out["routes"]; present { t.Fatalf("a console with no registry answered with a routes array: %v", out) } } // TestAPIRoutesAppTokenIsRefused: an app token is a machine credential // minted for one instance to register itself. This answer describes every // route of the app — upstreams, units, bindings — so it is a person's // view, and the narrower gate is the whole difference between this door // and the instance family's. func TestAPIRoutesAppTokenIsRefused(t *testing.T) { fx, _ := routesFixture(t, "code-routes-apptoken") appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "test", time.Now()) if err != nil { t.Fatal(err) } status, _ := getRaw(t, routesURL(fx, "titogo"), appTok) if status == http.StatusOK { t.Fatal("an app token reached the routes door — it must not") } } // TestAPIRoutesPathsIsAnEmptyArrayNotNull: no path rows must serialise as [], // the way `routes` beside it always does. A key that is sometimes null and // sometimes an array is a key a client eventually handles wrong, and every // deployment on the platform hits this branch until someone publishes a map. func TestAPIRoutesPathsIsAnEmptyArrayNotNull(t *testing.T) { fx, token := routesFixture(t, "code-routes-nopaths") status, body := getRaw(t, routesURL(fx, "titogo"), token) if status != http.StatusOK { t.Fatalf("GET routes = %d %s", status, body) } if !strings.Contains(body, `"paths":[]`) { t.Fatalf("routes body = %s, want an empty paths ARRAY, not null", body) } } // TestAPIRoutesListsPathRoutes: a hostname whose traffic is split by leading // path segment reports those prefixes, so an operator can read a migration's // flipped set from the command they already use. Same tenant boundary as the // routes beside them — a path row stamped to another account or another app // is another tenant's routing table. func TestAPIRoutesListsPathRoutes(t *testing.T) { fx, token := routesFixture(t, "code-routes-paths") reg, err := registry.Open(fx.regPath) if err != nil { t.Fatal(err) } for _, p := range []registry.RoutePath{ {Host: "connect.go.tito.io", Prefix: "paulca", Target: "router-a.go.tito.io", Account: "bab", App: "titogo"}, {Host: "connect.go.tito.io", Prefix: "bigcorp", Target: "router-a.go.tito.io", Account: "bab", App: "titogo"}, // A neighbouring app's row, and another account's: the answer must // carry neither, exactly as it carries neither of their routes. {Host: "neighbour.example.com", Prefix: "paulca", Target: "connect.go.tito.io", Account: "bab", App: "kass"}, {Host: "foreign.example.com", Prefix: "paulca", Target: "foreign.example.com", Account: "zzzz", App: "titogo"}, // The rows that actually exercise the TENANCY compare. The two above // sit on hostnames that are not in routeFor at all, so the host // filter excludes them whatever their stamps say — delete the // account/app compare and the test still passes. These two sit on // connect.go.tito.io, which IS in the answer, and are stamped to // another app and another account: only the tenancy compare keeps // them out. {Host: "connect.go.tito.io", Prefix: "otherapp", Target: "router-a.go.tito.io", Account: "bab", App: "kass"}, {Host: "connect.go.tito.io", Prefix: "otheracct", Target: "router-a.go.tito.io", Account: "zzzz", App: "titogo"}, } { if err := reg.UpsertPath(p); err != nil { t.Fatal(err) } } status, out := getJSON(t, routesURL(fx, "titogo"), token) if status != http.StatusOK { t.Fatalf("GET routes = %d %v", status, out) } rows, ok := out["paths"].([]any) if !ok { t.Fatalf("response has no paths array: %v", out) } if len(rows) != 2 { t.Fatalf("paths = %v, want exactly titogo's two rows in this account", out["paths"]) } for _, r := range rows { m, _ := r.(map[string]any) if p := str(t, m, "prefix"); p == "otherapp" || p == "otheracct" { t.Fatalf("paths carries %q — a row on a hostname in the answer but stamped to another app or account is another tenant's routing table", p) } } // ListPaths orders by host then prefix, and the CLI prints them in the // order they arrive — so the order is part of the answer. first, _ := rows[0].(map[string]any) second, _ := rows[1].(map[string]any) if str(t, first, "prefix") != "bigcorp" || str(t, second, "prefix") != "paulca" { t.Fatalf("paths = %v, want them ordered by host then prefix", out["paths"]) } if str(t, first, "host") != "connect.go.tito.io" || str(t, first, "target") != "router-a.go.tito.io" { t.Fatalf("path row = %v, want the host it splits and the route it points at", first) } }