| 1 | package console |
| 2 | |
| 3 | // The routes read (apiroutes.go, issue #64). The properties that matter, in |
| 4 | // the order they'd hurt if lost: the gate is the family's byte-identical |
| 5 | // 404 and an app token cannot reach this door at all; a caller sees only |
| 6 | // their own app's rows out of a registry holding every tenant's; and the |
| 7 | // environment binding — the whole reason the issue was reopened — comes |
| 8 | // back. |
| 9 | |
| 10 | import ( |
| 11 | "net/http" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/registry" |
| 17 | ) |
| 18 | |
| 19 | // routesURL is the routes door for one app in the fixture's account. |
| 20 | func routesURL(fx *consoleFixture, app string) string { |
| 21 | return fx.ts.URL + "/api/cli/apps/bab/" + app + "/routes" |
| 22 | } |
| 23 | |
| 24 | // routesFixture is a console with titogo claimed by the ops account, a |
| 25 | // member token for it, and a registry holding a spread of rows: two of |
| 26 | // titogo's (one bound to an environment, one not), one belonging to |
| 27 | // another app in the SAME account, and one stamped to a different account |
| 28 | // entirely. |
| 29 | func routesFixture(t *testing.T, code string) (*consoleFixture, string) { |
| 30 | t.Helper() |
| 31 | fx, token := configFixture(t, code) |
| 32 | reg, err := registry.Open(fx.regPath) |
| 33 | if err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | for _, rt := range []registry.Route{ |
| 37 | // The hand-added relay: no instance record anywhere, which is |
| 38 | // exactly why `carlos instances list` could not answer for it. |
| 39 | {Host: "connect.go.tito.io", Kind: "instance", Status: "ready", App: "titogo", |
| 40 | Account: "bab", Addr: "127.0.0.1:9001", Channel: "canary/relay", Environment: "relay"}, |
| 41 | // Hibernating, with a database path and a rung on it: the two halves |
| 42 | // `carlos route --replication` requires, so this row is shaped the |
| 43 | // way a real tuned tenant is. |
| 44 | {Host: "router-a.go.tito.io", Kind: "instance", Status: "ready", App: "titogo", |
| 45 | Account: "bab", Addr: "127.0.0.1:9002", Channel: "stable", |
| 46 | Hibernate: true, DBPath: "/data/router-a.go.tito.io.db", |
| 47 | Replication: registry.ReplicationDaily}, |
| 48 | {Host: "neighbour.example.com", Kind: "instance", Status: "ready", App: "kass", |
| 49 | Account: "bab", Addr: "127.0.0.1:9003"}, |
| 50 | {Host: "foreign.example.com", Kind: "instance", Status: "ready", App: "titogo", |
| 51 | Account: "zzzz", Addr: "127.0.0.1:9004", Environment: "someone-elses"}, |
| 52 | } { |
| 53 | if err := reg.Upsert(rt); err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | } |
| 57 | return fx, token |
| 58 | } |
| 59 | |
| 60 | // hostsOf indexes a routes response by host. |
| 61 | func hostsOf(t *testing.T, out map[string]any) map[string]map[string]any { |
| 62 | t.Helper() |
| 63 | rows, ok := out["routes"].([]any) |
| 64 | if !ok { |
| 65 | t.Fatalf("response has no routes array: %v", out) |
| 66 | } |
| 67 | byHost := map[string]map[string]any{} |
| 68 | for _, r := range rows { |
| 69 | m, _ := r.(map[string]any) |
| 70 | byHost[str(t, m, "host")] = m |
| 71 | } |
| 72 | return byHost |
| 73 | } |
| 74 | |
| 75 | // ---- the gate --------------------------------------------------------------- |
| 76 | |
| 77 | // TestAPIRoutesGateIsByteIdentical: a token belonging to no account, an |
| 78 | // account segment that doesn't exist, and an app the account never claimed |
| 79 | // are one and the same 404 — byte-identical to http.NotFound's own output, |
| 80 | // so probing this door teaches a token holder nothing. |
| 81 | func TestAPIRoutesGateIsByteIdentical(t *testing.T) { |
| 82 | fx, token := routesFixture(t, "code-routes-gate") |
| 83 | eve := cliToken(t, fx, "eve@keymail.dev", "code-routes-gate-eve") |
| 84 | |
| 85 | statusA, bodyA := getRaw(t, routesURL(fx, "titogo"), eve) |
| 86 | statusB, bodyB := getRaw(t, fx.ts.URL+"/api/cli/apps/zzzz/titogo/routes", token) |
| 87 | statusC, bodyC := getRaw(t, routesURL(fx, "ghost-app"), token) |
| 88 | if statusA != http.StatusNotFound || statusB != http.StatusNotFound || statusC != http.StatusNotFound { |
| 89 | t.Fatalf("routes gate = %d / %d / %d, want 404 for all three", statusA, statusB, statusC) |
| 90 | } |
| 91 | if bodyA != bodyB || bodyB != bodyC { |
| 92 | t.Fatalf("refusals are not byte-identical:\n(a) %q\n(b) %q\n(c) %q", bodyA, bodyB, bodyC) |
| 93 | } |
| 94 | if bodyA != "404 page not found\n" { |
| 95 | t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", bodyA) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | // TestAPIRoutesNoToken: the same 401 cliUser always gives, before it can |
| 100 | // matter what account or app was named. |
| 101 | func TestAPIRoutesNoToken(t *testing.T) { |
| 102 | fx, _ := routesFixture(t, "code-routes-notoken") |
| 103 | status, out := getJSON(t, routesURL(fx, "titogo"), "") |
| 104 | if status != http.StatusUnauthorized { |
| 105 | t.Fatalf("GET with no token = %d %v, want 401", status, out) |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | // ---- the answer ------------------------------------------------------------- |
| 110 | |
| 111 | // TestAPIRoutesReportsTheEnvironmentBinding is the whole point of issue |
| 112 | // #64: after `carlos route -host <h> -environment relay`, a member can |
| 113 | // read the binding back without a box shell. |
| 114 | func TestAPIRoutesReportsTheEnvironmentBinding(t *testing.T) { |
| 115 | fx, token := routesFixture(t, "code-routes-env") |
| 116 | status, out := getJSON(t, routesURL(fx, "titogo"), token) |
| 117 | if status != http.StatusOK { |
| 118 | t.Fatalf("GET routes = %d %v", status, out) |
| 119 | } |
| 120 | byHost := hostsOf(t, out) |
| 121 | relay, ok := byHost["connect.go.tito.io"] |
| 122 | if !ok { |
| 123 | t.Fatalf("routes = %v, want the bound route listed", out["routes"]) |
| 124 | } |
| 125 | if got := str(t, relay, "environment"); got != "relay" { |
| 126 | t.Fatalf("environment = %q, want %q", got, "relay") |
| 127 | } |
| 128 | if got := str(t, relay, "channel"); got != "canary/relay" { |
| 129 | t.Fatalf("channel = %q, want %q", got, "canary/relay") |
| 130 | } |
| 131 | if got := str(t, relay, "upstream"); got != "127.0.0.1:9001" { |
| 132 | t.Fatalf("upstream = %q, want the route's addr", got) |
| 133 | } |
| 134 | // An UNBOUND route reports "" — a real answer (it takes the app's |
| 135 | // default bundle), not missing data. |
| 136 | unbound, ok := byHost["router-a.go.tito.io"] |
| 137 | if !ok { |
| 138 | t.Fatalf("routes = %v, want the unbound route listed too", out["routes"]) |
| 139 | } |
| 140 | if got := str(t, unbound, "environment"); got != "" { |
| 141 | t.Fatalf("unbound route environment = %q, want empty", got) |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | // TestAPIRoutesReportsTheReplicationRung: the rung is set on the box, by an |
| 146 | // operator, with `carlos route --replication` — and the member reading |
| 147 | // `carlos routes` is the only one who can tell whether the tenant they are |
| 148 | // paying for is on the rung somebody intended. The field is `omitempty` on |
| 149 | // the wire and nothing else in this repo reads it back out of JSON, so an |
| 150 | // untested hop is one where the struct field could be deleted and every |
| 151 | // route would print "default" with a green suite. |
| 152 | func TestAPIRoutesReportsTheReplicationRung(t *testing.T) { |
| 153 | fx, token := routesFixture(t, "code-routes-repl") |
| 154 | status, out := getJSON(t, routesURL(fx, "titogo"), token) |
| 155 | if status != http.StatusOK { |
| 156 | t.Fatalf("GET routes = %d %v", status, out) |
| 157 | } |
| 158 | byHost := hostsOf(t, out) |
| 159 | tuned, ok := byHost["router-a.go.tito.io"] |
| 160 | if !ok { |
| 161 | t.Fatalf("routes = %v, want the tuned route listed", out["routes"]) |
| 162 | } |
| 163 | if got := str(t, tuned, "replication"); got != registry.ReplicationDaily { |
| 164 | t.Fatalf("replication = %q, want %q — the rung an operator set is unreadable to the member whose bill it is", got, registry.ReplicationDaily) |
| 165 | } |
| 166 | // A route on the pre-ladder default reports nothing rather than a rung it |
| 167 | // is not on: the field is omitempty, and "" is what every route carried |
| 168 | // before the column had a door. The CLI gives that absence its name |
| 169 | // ("default", routes_cli.go's replicationCell) — the wire does not. |
| 170 | if v, present := byHost["connect.go.tito.io"]["replication"]; present { |
| 171 | t.Fatalf("an unset rung was sent as %v, want the field omitted", v) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | // TestAPIRoutesListsOnlyThisAppsRoutes: the registry holds every tenant's |
| 176 | // rows, and the tenant boundary is the only thing standing between a |
| 177 | // caller and a neighbour's upstream addresses and unit names. |
| 178 | // TestAPIRoutesMarksPlatformCanaries: a route the domains reconciler mints |
| 179 | // and rebuilds (Platform && Canary) says so, and nothing else does, because |
| 180 | // the CLI reads this to keep from recommending `carlos route` for a row that |
| 181 | // verb refuses (#264). A Platform route that is not a canary stays |
| 182 | // unmarked: `carlos route --host` may bind that one. |
| 183 | func TestAPIRoutesMarksPlatformCanaries(t *testing.T) { |
| 184 | fx, token := configFixture(t, "code-routes-canary") |
| 185 | if _, err := fx.st.CreateAppIn(OpsAccountID, "shop", "paul@keymail.dev", time.Now()); err != nil { |
| 186 | t.Fatal(err) |
| 187 | } |
| 188 | reg, err := registry.Open(fx.regPath) |
| 189 | if err != nil { |
| 190 | t.Fatal(err) |
| 191 | } |
| 192 | for _, rt := range []registry.Route{ |
| 193 | {Host: "canary-ab12.shop.bab.oncarlos.test", Kind: "instance", Status: "ready", App: "shop", |
| 194 | Account: "bab", Addr: "127.0.0.1:9005", Channel: "canary/ab12", Platform: true, Canary: true}, |
| 195 | {Host: "shop.bab.oncarlos.test", Kind: "instance", Status: "ready", App: "shop", |
| 196 | Account: "bab", Addr: "127.0.0.1:9006", Channel: "stable", Platform: true}, |
| 197 | {Host: "shop.example.com", Kind: "instance", Status: "ready", App: "shop", |
| 198 | Account: "bab", Addr: "127.0.0.1:9007", Channel: "stable"}, |
| 199 | } { |
| 200 | if err := reg.Upsert(rt); err != nil { |
| 201 | t.Fatal(err) |
| 202 | } |
| 203 | } |
| 204 | status, out := getJSON(t, routesURL(fx, "shop"), token) |
| 205 | if status != http.StatusOK { |
| 206 | t.Fatalf("GET routes = %d %v", status, out) |
| 207 | } |
| 208 | byHost := hostsOf(t, out) |
| 209 | for host, want := range map[string]bool{ |
| 210 | "canary-ab12.shop.bab.oncarlos.test": true, |
| 211 | "shop.bab.oncarlos.test": false, |
| 212 | "shop.example.com": false, |
| 213 | } { |
| 214 | row, ok := byHost[host] |
| 215 | if !ok { |
| 216 | t.Fatalf("routes = %v, want %s listed", out["routes"], host) |
| 217 | } |
| 218 | if got, _ := row["platform_canary"].(bool); got != want { |
| 219 | t.Errorf("%s platform_canary = %v, want %v", host, row["platform_canary"], want) |
| 220 | } |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | func TestAPIRoutesListsOnlyThisAppsRoutes(t *testing.T) { |
| 225 | fx, token := routesFixture(t, "code-routes-scope") |
| 226 | status, out := getJSON(t, routesURL(fx, "titogo"), token) |
| 227 | if status != http.StatusOK { |
| 228 | t.Fatalf("GET routes = %d %v", status, out) |
| 229 | } |
| 230 | byHost := hostsOf(t, out) |
| 231 | if _, present := byHost["neighbour.example.com"]; present { |
| 232 | t.Fatal("another app's route in the same account was listed") |
| 233 | } |
| 234 | if _, present := byHost["foreign.example.com"]; present { |
| 235 | t.Fatal("a route stamped to another ACCOUNT was listed — the app name alone is not the boundary") |
| 236 | } |
| 237 | if len(byHost) != 2 { |
| 238 | t.Fatalf("routes = %v, want exactly titogo's two rows", out["routes"]) |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | // TestAPIRoutesSortsByHost: registry order is not stable across passes, so |
| 243 | // the answer must not be either — a listing that reshuffles between calls |
| 244 | // is one nobody can diff. |
| 245 | func TestAPIRoutesSortsByHost(t *testing.T) { |
| 246 | fx, token := routesFixture(t, "code-routes-sort") |
| 247 | status, out := getJSON(t, routesURL(fx, "titogo"), token) |
| 248 | if status != http.StatusOK { |
| 249 | t.Fatalf("GET routes = %d %v", status, out) |
| 250 | } |
| 251 | rows, _ := out["routes"].([]any) |
| 252 | if len(rows) != 2 { |
| 253 | t.Fatalf("routes = %v, want two", out["routes"]) |
| 254 | } |
| 255 | first, _ := rows[0].(map[string]any) |
| 256 | if str(t, first, "host") != "connect.go.tito.io" { |
| 257 | t.Fatalf("first row = %v, want the alphabetically first host", first) |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | // TestAPIRoutesSaysSoWhenTheConsoleServesNoRegistry: never an empty list. |
| 262 | // "[]" would read as "this app has no routes", which is a different and |
| 263 | // wrong answer. |
| 264 | func TestAPIRoutesSaysSoWhenTheConsoleServesNoRegistry(t *testing.T) { |
| 265 | fx, token := routesFixture(t, "code-routes-noregistry") |
| 266 | fx.srv.cfg.Registry = "" |
| 267 | status, out := getJSON(t, routesURL(fx, "titogo"), token) |
| 268 | if status != http.StatusServiceUnavailable { |
| 269 | t.Fatalf("GET routes with no registry = %d %v, want 503", status, out) |
| 270 | } |
| 271 | if _, present := out["routes"]; present { |
| 272 | t.Fatalf("a console with no registry answered with a routes array: %v", out) |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | // TestAPIRoutesAppTokenIsRefused: an app token is a machine credential |
| 277 | // minted for one instance to register itself. This answer describes every |
| 278 | // route of the app — upstreams, units, bindings — so it is a person's |
| 279 | // view, and the narrower gate is the whole difference between this door |
| 280 | // and the instance family's. |
| 281 | func TestAPIRoutesAppTokenIsRefused(t *testing.T) { |
| 282 | fx, _ := routesFixture(t, "code-routes-apptoken") |
| 283 | appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "paul@keymail.dev", "test", time.Now()) |
| 284 | if err != nil { |
| 285 | t.Fatal(err) |
| 286 | } |
| 287 | status, _ := getRaw(t, routesURL(fx, "titogo"), appTok) |
| 288 | if status == http.StatusOK { |
| 289 | t.Fatal("an app token reached the routes door — it must not") |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | // TestAPIRoutesPathsIsAnEmptyArrayNotNull: no path rows must serialise as [], |
| 294 | // the way `routes` beside it always does. A key that is sometimes null and |
| 295 | // sometimes an array is a key a client eventually handles wrong, and every |
| 296 | // deployment on the platform hits this branch until someone publishes a map. |
| 297 | func TestAPIRoutesPathsIsAnEmptyArrayNotNull(t *testing.T) { |
| 298 | fx, token := routesFixture(t, "code-routes-nopaths") |
| 299 | status, body := getRaw(t, routesURL(fx, "titogo"), token) |
| 300 | if status != http.StatusOK { |
| 301 | t.Fatalf("GET routes = %d %s", status, body) |
| 302 | } |
| 303 | if !strings.Contains(body, `"paths":[]`) { |
| 304 | t.Fatalf("routes body = %s, want an empty paths ARRAY, not null", body) |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | // TestAPIRoutesListsPathRoutes: a hostname whose traffic is split by leading |
| 309 | // path segment reports those prefixes, so an operator can read a migration's |
| 310 | // flipped set from the command they already use. Same tenant boundary as the |
| 311 | // routes beside them — a path row stamped to another account or another app |
| 312 | // is another tenant's routing table. |
| 313 | func TestAPIRoutesListsPathRoutes(t *testing.T) { |
| 314 | fx, token := routesFixture(t, "code-routes-paths") |
| 315 | reg, err := registry.Open(fx.regPath) |
| 316 | if err != nil { |
| 317 | t.Fatal(err) |
| 318 | } |
| 319 | for _, p := range []registry.RoutePath{ |
| 320 | {Host: "connect.go.tito.io", Prefix: "paulca", Target: "router-a.go.tito.io", Account: "bab", App: "titogo"}, |
| 321 | {Host: "connect.go.tito.io", Prefix: "bigcorp", Target: "router-a.go.tito.io", Account: "bab", App: "titogo"}, |
| 322 | // A neighbouring app's row, and another account's: the answer must |
| 323 | // carry neither, exactly as it carries neither of their routes. |
| 324 | {Host: "neighbour.example.com", Prefix: "paulca", Target: "connect.go.tito.io", Account: "bab", App: "kass"}, |
| 325 | {Host: "foreign.example.com", Prefix: "paulca", Target: "foreign.example.com", Account: "zzzz", App: "titogo"}, |
| 326 | // The rows that actually exercise the TENANCY compare. The two above |
| 327 | // sit on hostnames that are not in routeFor at all, so the host |
| 328 | // filter excludes them whatever their stamps say — delete the |
| 329 | // account/app compare and the test still passes. These two sit on |
| 330 | // connect.go.tito.io, which IS in the answer, and are stamped to |
| 331 | // another app and another account: only the tenancy compare keeps |
| 332 | // them out. |
| 333 | {Host: "connect.go.tito.io", Prefix: "otherapp", Target: "router-a.go.tito.io", Account: "bab", App: "kass"}, |
| 334 | {Host: "connect.go.tito.io", Prefix: "otheracct", Target: "router-a.go.tito.io", Account: "zzzz", App: "titogo"}, |
| 335 | } { |
| 336 | if err := reg.UpsertPath(p); err != nil { |
| 337 | t.Fatal(err) |
| 338 | } |
| 339 | } |
| 340 | status, out := getJSON(t, routesURL(fx, "titogo"), token) |
| 341 | if status != http.StatusOK { |
| 342 | t.Fatalf("GET routes = %d %v", status, out) |
| 343 | } |
| 344 | rows, ok := out["paths"].([]any) |
| 345 | if !ok { |
| 346 | t.Fatalf("response has no paths array: %v", out) |
| 347 | } |
| 348 | if len(rows) != 2 { |
| 349 | t.Fatalf("paths = %v, want exactly titogo's two rows in this account", out["paths"]) |
| 350 | } |
| 351 | for _, r := range rows { |
| 352 | m, _ := r.(map[string]any) |
| 353 | if p := str(t, m, "prefix"); p == "otherapp" || p == "otheracct" { |
| 354 | 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) |
| 355 | } |
| 356 | } |
| 357 | // ListPaths orders by host then prefix, and the CLI prints them in the |
| 358 | // order they arrive — so the order is part of the answer. |
| 359 | first, _ := rows[0].(map[string]any) |
| 360 | second, _ := rows[1].(map[string]any) |
| 361 | if str(t, first, "prefix") != "bigcorp" || str(t, second, "prefix") != "paulca" { |
| 362 | t.Fatalf("paths = %v, want them ordered by host then prefix", out["paths"]) |
| 363 | } |
| 364 | if str(t, first, "host") != "connect.go.tito.io" || str(t, first, "target") != "router-a.go.tito.io" { |
| 365 | t.Fatalf("path row = %v, want the host it splits and the route it points at", first) |
| 366 | } |
| 367 | } |
| 368 | |