carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package 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
10import (
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.
20func 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.
29func 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.
61func 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.
81func 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.
101func 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.
114func 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.
152func 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.
183func 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
224func 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.
245func 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.
264func 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.
281func 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.
297func 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.
313func 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