carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "net/http"
8 "strings"
9 "testing"
10)
11
12// routesConsole plants a credential for an httptest console that answers
13// GET /api/cli/apps (account resolution) and the routes door for titogo.
14// No `paths` key at all, which is also what a console predating path routing
15// sends — so every test using it is exercising that older shape too.
16func routesConsole(t *testing.T, rows []map[string]any) string {
17 t.Helper()
18 return routesConsolePaths(t, rows, nil)
19}
20
21// routesConsolePaths is routesConsole with the path routes hanging off those
22// hosts. Split out rather than folded into every caller: the prefix map is
23// additive, and the tests that predate it must keep sending the answer that
24// predates it.
25func routesConsolePaths(t *testing.T, rows, paths []map[string]any) string {
26 t.Helper()
27 return signedInConsole(t, func(w http.ResponseWriter, r *http.Request) {
28 switch {
29 case r.URL.Path == "/api/cli/apps":
30 json.NewEncoder(w).Encode(map[string]any{"accounts": []map[string]any{
31 {"sqid": "bae", "name": "tom@keymail.dev", "role": "owner", "apps": []string{"titogo"}},
32 }})
33 case strings.HasSuffix(r.URL.Path, "/titogo/routes"):
34 body := map[string]any{"routes": rows}
35 if paths != nil {
36 body["paths"] = paths
37 }
38 json.NewEncoder(w).Encode(body)
39 default:
40 http.NotFound(w, r)
41 }
42 })
43}
44
45// TestRouteListReportsTheEnvironmentBinding is issue #64 at the client
46// edge: the binding survives the wire and reaches the caller.
47func TestRouteListReportsTheEnvironmentBinding(t *testing.T) {
48 useTempCredentials(t)
49 origin := routesConsole(t, []map[string]any{
50 {"host": "connect.go.tito.io", "kind": "instance", "upstream": "127.0.0.1:9001",
51 "channel": "canary/relay", "environment": "relay", "status": "ready", "health": "Running"},
52 })
53 sess, err := resolveMode(origin)
54 if err != nil {
55 t.Fatalf("session: %v", err)
56 }
57 rows, paths, err := sess.routeList(context.Background(), apiAcct{Seg: "bae"}, "titogo")
58 if err != nil {
59 t.Fatalf("routeList: %v", err)
60 }
61 // A console predating path routing sends no `paths` key at all; that
62 // must decode as "no path routes", never as an error.
63 if len(paths) != 0 {
64 t.Fatalf("paths = %v, want none from a console that sends no paths key", paths)
65 }
66 if len(rows) != 1 {
67 t.Fatalf("rows = %v, want one", rows)
68 }
69 if rows[0].Environment != "relay" {
70 t.Fatalf("environment = %q, want %q", rows[0].Environment, "relay")
71 }
72 if rows[0].Upstream != "127.0.0.1:9001" || rows[0].Channel != "canary/relay" {
73 t.Fatalf("row = %+v, want the upstream and channel decoded too", rows[0])
74 }
75}
76
77// TestRouteListOldConsoleIsNamed: a console predating the endpoint 404s the
78// path itself. That must not be paraphrased as "no such app" — the
79// membership gate's 404 and a missing endpoint are different problems with
80// different fixes.
81func TestRouteListOldConsoleIsNamed(t *testing.T) {
82 useTempCredentials(t)
83 origin := signedInConsole(t, func(w http.ResponseWriter, r *http.Request) {
84 http.NotFound(w, r)
85 })
86 sess, err := resolveMode(origin)
87 if err != nil {
88 t.Fatalf("session: %v", err)
89 }
90 if _, _, err := sess.routeList(context.Background(), apiAcct{Seg: "bae"}, "titogo"); err == nil {
91 t.Fatal("a 404 from the routes door must surface as an error, never as an empty list")
92 }
93}
94
95// TestDashIfEmpty: an unbound route's empty ENVIRONMENT is a real answer —
96// it takes the app's default bundle — and must render as such rather than
97// as a blank cell a reader would take for missing data.
98func TestDashIfEmpty(t *testing.T) {
99 if got := dashIfEmpty(""); got != "—" {
100 t.Fatalf("dashIfEmpty(\"\") = %q, want an em dash", got)
101 }
102 if got := dashIfEmpty("relay"); got != "relay" {
103 t.Fatalf("dashIfEmpty(%q) = %q, want it unchanged", "relay", got)
104 }
105}
106
107// TestRoutesTableCarriesEveryColumn drives the VERB, not a copy of its
108// rendering: a column dropped from runRoutes' Fprintf must fail here
109// rather than in an operator's terminal.
110func TestRoutesTableCarriesEveryColumn(t *testing.T) {
111 useTempCredentials(t)
112 origin := routesConsole(t, []map[string]any{
113 {"host": "connect.go.tito.io", "kind": "instance", "upstream": "127.0.0.1:9001",
114 "channel": "canary/relay", "environment": "relay", "status": "ready", "health": "Running"},
115 {"host": "router-a.go.tito.io", "kind": "instance", "upstream": "127.0.0.1:9002",
116 "channel": "stable", "status": "ready", "health": "Running"},
117 })
118 var out bytes.Buffer
119 old := stdout
120 stdout = &out
121 t.Cleanup(func() { stdout = old })
122
123 runRoutes([]string{"-app", "titogo", "-account-id", "bae", "-console", origin})
124
125 for _, want := range []string{
126 "HOST", "KIND", "UPSTREAM", "CHANNEL", "ENVIRONMENT", "STATUS", "HEALTH",
127 "connect.go.tito.io", "canary/relay", "relay", "Running", "127.0.0.1:9001",
128 } {
129 if !strings.Contains(out.String(), want) {
130 t.Fatalf("routes output missing %q:\n%s", want, out.String())
131 }
132 }
133 // The unbound row's ENVIRONMENT cell is the dash, not a blank: taking
134 // the app's default bundle is an answer, not missing data.
135 line := ""
136 for _, l := range strings.Split(out.String(), "\n") {
137 if strings.HasPrefix(l, "router-a.go.tito.io") {
138 line = l
139 }
140 }
141 if !strings.Contains(line, "\u2014") {
142 t.Fatalf("unbound row = %q, want an em dash in the ENVIRONMENT cell", line)
143 }
144 // The fleet caveat is printed every time, not only on an empty list.
145 if !strings.Contains(out.String(), "other boxes in a fleet are not listed") {
146 t.Fatalf("routes output missing the multi-box caveat:\n%s", out.String())
147 }
148}
149
150// TestRoutesShowsPathRoutesUnderTheirHost: a hostname whose traffic is split
151// by leading path segment prints those prefixes indented beneath it, naming
152// the route each one is served by. Without this the flipped set of a
153// migration is invisible from the command operators already use, and stays
154// invisible until something breaks.
155func TestRoutesShowsPathRoutesUnderTheirHost(t *testing.T) {
156 useTempCredentials(t)
157 origin := routesConsolePaths(t,
158 []map[string]any{
159 {"host": "connect.go.tito.io", "kind": "instance", "upstream": "127.0.0.1:9001",
160 "channel": "stable", "status": "ready", "health": "Running"},
161 {"host": "router-a.go.tito.io", "kind": "instance", "upstream": "127.0.0.1:9002",
162 "channel": "stable", "status": "ready", "health": "Running"},
163 },
164 []map[string]any{
165 {"host": "connect.go.tito.io", "prefix": "bigcorp", "target": "router-a.go.tito.io"},
166 {"host": "connect.go.tito.io", "prefix": "paulca", "target": "router-a.go.tito.io"},
167 })
168 var out bytes.Buffer
169 old := stdout
170 stdout = &out
171 t.Cleanup(func() { stdout = old })
172
173 runRoutes([]string{"-app", "titogo", "-account-id", "bae", "-console", origin})
174
175 lines := strings.Split(out.String(), "\n")
176 hostAt, prefixAt := -1, map[string]int{}
177 for i, l := range lines {
178 switch {
179 case strings.HasPrefix(l, "connect.go.tito.io"):
180 hostAt = i
181 case strings.HasPrefix(strings.TrimSpace(l), "/bigcorp"):
182 prefixAt["bigcorp"] = i
183 case strings.HasPrefix(strings.TrimSpace(l), "/paulca"):
184 prefixAt["paulca"] = i
185 }
186 }
187 if hostAt < 0 || len(prefixAt) != 2 {
188 t.Fatalf("routes output has no host row with its two prefixes under it:\n%s", out.String())
189 }
190 // UNDER their own host, not appended at the end of the table: which
191 // hostname a prefix splits is the whole fact being reported.
192 for p, at := range prefixAt {
193 if at != hostAt+1 && at != hostAt+2 {
194 t.Fatalf("/%s printed at line %d, want it directly under connect.go.tito.io at %d:\n%s",
195 p, at, hostAt, out.String())
196 }
197 if !strings.HasPrefix(lines[at], " ") {
198 t.Fatalf("/%s row = %q, want it indented under its hostname", p, lines[at])
199 }
200 if !strings.Contains(lines[at], "router-a.go.tito.io") {
201 t.Fatalf("/%s row = %q, want the route it is served by named", p, lines[at])
202 }
203 }
204 // A path row is a POINTER at another route: it has no upstream, channel,
205 // environment or health of its own, and those cells must read as answers
206 // rather than as data this client failed to fetch.
207 if !strings.Contains(lines[prefixAt["paulca"]], "\u2014") {
208 t.Fatalf("path row = %q, want em dashes in the columns a pointer has none of", lines[prefixAt["paulca"]])
209 }
210}
211
212// TestRoutesPathRowsAreNotCountedAsRoutes: a path row is a pointer at a
213// route, not a route. It is printed under its host, so when --match filters
214// every host away nothing is printed at all — and the "no routes" line must
215// still appear, because path rows must not inflate the count of what was
216// shown. The obvious spelling of this check (assert the line is ABSENT from
217// a listing that prints two routes) cannot fail, which is why it is written
218// the other way round.
219func TestRoutesPathRowsAreNotCountedAsRoutes(t *testing.T) {
220 useTempCredentials(t)
221 origin := routesConsolePaths(t,
222 []map[string]any{
223 {"host": "connect.go.tito.io", "kind": "instance", "upstream": "127.0.0.1:9001",
224 "channel": "stable", "status": "ready", "health": "Running"},
225 },
226 []map[string]any{
227 {"host": "connect.go.tito.io", "prefix": "paulca", "target": "router-a.go.tito.io"},
228 })
229 var out bytes.Buffer
230 old := stdout
231 stdout = &out
232 t.Cleanup(func() { stdout = old })
233
234 runRoutes([]string{"-app", "titogo", "-account-id", "bae", "-console", origin,
235 "-match", "nothing-matches-this"})
236
237 if !strings.Contains(out.String(), "no routes for titogo") {
238 t.Fatalf("every route was filtered away, so the listing must say so:\n%s", out.String())
239 }
240 if strings.Contains(out.String(), "/paulca") {
241 t.Fatalf("a path row survived a filter its host did not:\n%s", out.String())
242 }
243}
244
245// TestRoutesEmptyListSaysSo: an empty answer prints a line, not a bare
246// header an operator has to interpret.
247func TestRoutesEmptyListSaysSo(t *testing.T) {
248 useTempCredentials(t)
249 origin := routesConsole(t, []map[string]any{})
250 var out bytes.Buffer
251 old := stdout
252 stdout = &out
253 t.Cleanup(func() { stdout = old })
254
255 runRoutes([]string{"-app", "titogo", "-account-id", "bae", "-console", origin})
256 if !strings.Contains(out.String(), "no routes for titogo") {
257 t.Fatalf("empty routes output = %q, want a line saying so", out.String())
258 }
259}
260