package main import ( "bytes" "context" "encoding/json" "net/http" "strings" "testing" ) // routesConsole plants a credential for an httptest console that answers // GET /api/cli/apps (account resolution) and the routes door for titogo. // No `paths` key at all, which is also what a console predating path routing // sends — so every test using it is exercising that older shape too. func routesConsole(t *testing.T, rows []map[string]any) string { t.Helper() return routesConsolePaths(t, rows, nil) } // routesConsolePaths is routesConsole with the path routes hanging off those // hosts. Split out rather than folded into every caller: the prefix map is // additive, and the tests that predate it must keep sending the answer that // predates it. func routesConsolePaths(t *testing.T, rows, paths []map[string]any) string { t.Helper() return signedInConsole(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/api/cli/apps": json.NewEncoder(w).Encode(map[string]any{"accounts": []map[string]any{ {"sqid": "bae", "name": "tom@keymail.dev", "role": "owner", "apps": []string{"titogo"}}, }}) case strings.HasSuffix(r.URL.Path, "/titogo/routes"): body := map[string]any{"routes": rows} if paths != nil { body["paths"] = paths } json.NewEncoder(w).Encode(body) default: http.NotFound(w, r) } }) } // TestRouteListReportsTheEnvironmentBinding is issue #64 at the client // edge: the binding survives the wire and reaches the caller. func TestRouteListReportsTheEnvironmentBinding(t *testing.T) { useTempCredentials(t) origin := routesConsole(t, []map[string]any{ {"host": "connect.go.tito.io", "kind": "instance", "upstream": "127.0.0.1:9001", "channel": "canary/relay", "environment": "relay", "status": "ready", "health": "Running"}, }) sess, err := resolveMode(origin) if err != nil { t.Fatalf("session: %v", err) } rows, paths, err := sess.routeList(context.Background(), apiAcct{Seg: "bae"}, "titogo") if err != nil { t.Fatalf("routeList: %v", err) } // A console predating path routing sends no `paths` key at all; that // must decode as "no path routes", never as an error. if len(paths) != 0 { t.Fatalf("paths = %v, want none from a console that sends no paths key", paths) } if len(rows) != 1 { t.Fatalf("rows = %v, want one", rows) } if rows[0].Environment != "relay" { t.Fatalf("environment = %q, want %q", rows[0].Environment, "relay") } if rows[0].Upstream != "127.0.0.1:9001" || rows[0].Channel != "canary/relay" { t.Fatalf("row = %+v, want the upstream and channel decoded too", rows[0]) } } // TestRouteListOldConsoleIsNamed: a console predating the endpoint 404s the // path itself. That must not be paraphrased as "no such app" — the // membership gate's 404 and a missing endpoint are different problems with // different fixes. func TestRouteListOldConsoleIsNamed(t *testing.T) { useTempCredentials(t) origin := signedInConsole(t, func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) }) sess, err := resolveMode(origin) if err != nil { t.Fatalf("session: %v", err) } if _, _, err := sess.routeList(context.Background(), apiAcct{Seg: "bae"}, "titogo"); err == nil { t.Fatal("a 404 from the routes door must surface as an error, never as an empty list") } } // TestDashIfEmpty: an unbound route's empty ENVIRONMENT is a real answer — // it takes the app's default bundle — and must render as such rather than // as a blank cell a reader would take for missing data. func TestDashIfEmpty(t *testing.T) { if got := dashIfEmpty(""); got != "—" { t.Fatalf("dashIfEmpty(\"\") = %q, want an em dash", got) } if got := dashIfEmpty("relay"); got != "relay" { t.Fatalf("dashIfEmpty(%q) = %q, want it unchanged", "relay", got) } } // TestRoutesTableCarriesEveryColumn drives the VERB, not a copy of its // rendering: a column dropped from runRoutes' Fprintf must fail here // rather than in an operator's terminal. func TestRoutesTableCarriesEveryColumn(t *testing.T) { useTempCredentials(t) origin := routesConsole(t, []map[string]any{ {"host": "connect.go.tito.io", "kind": "instance", "upstream": "127.0.0.1:9001", "channel": "canary/relay", "environment": "relay", "status": "ready", "health": "Running"}, {"host": "router-a.go.tito.io", "kind": "instance", "upstream": "127.0.0.1:9002", "channel": "stable", "status": "ready", "health": "Running"}, }) var out bytes.Buffer old := stdout stdout = &out t.Cleanup(func() { stdout = old }) runRoutes([]string{"-app", "titogo", "-account-id", "bae", "-console", origin}) for _, want := range []string{ "HOST", "KIND", "UPSTREAM", "CHANNEL", "ENVIRONMENT", "STATUS", "HEALTH", "connect.go.tito.io", "canary/relay", "relay", "Running", "127.0.0.1:9001", } { if !strings.Contains(out.String(), want) { t.Fatalf("routes output missing %q:\n%s", want, out.String()) } } // The unbound row's ENVIRONMENT cell is the dash, not a blank: taking // the app's default bundle is an answer, not missing data. line := "" for _, l := range strings.Split(out.String(), "\n") { if strings.HasPrefix(l, "router-a.go.tito.io") { line = l } } if !strings.Contains(line, "\u2014") { t.Fatalf("unbound row = %q, want an em dash in the ENVIRONMENT cell", line) } // The fleet caveat is printed every time, not only on an empty list. if !strings.Contains(out.String(), "other boxes in a fleet are not listed") { t.Fatalf("routes output missing the multi-box caveat:\n%s", out.String()) } } // TestRoutesShowsPathRoutesUnderTheirHost: a hostname whose traffic is split // by leading path segment prints those prefixes indented beneath it, naming // the route each one is served by. Without this the flipped set of a // migration is invisible from the command operators already use, and stays // invisible until something breaks. func TestRoutesShowsPathRoutesUnderTheirHost(t *testing.T) { useTempCredentials(t) origin := routesConsolePaths(t, []map[string]any{ {"host": "connect.go.tito.io", "kind": "instance", "upstream": "127.0.0.1:9001", "channel": "stable", "status": "ready", "health": "Running"}, {"host": "router-a.go.tito.io", "kind": "instance", "upstream": "127.0.0.1:9002", "channel": "stable", "status": "ready", "health": "Running"}, }, []map[string]any{ {"host": "connect.go.tito.io", "prefix": "bigcorp", "target": "router-a.go.tito.io"}, {"host": "connect.go.tito.io", "prefix": "paulca", "target": "router-a.go.tito.io"}, }) var out bytes.Buffer old := stdout stdout = &out t.Cleanup(func() { stdout = old }) runRoutes([]string{"-app", "titogo", "-account-id", "bae", "-console", origin}) lines := strings.Split(out.String(), "\n") hostAt, prefixAt := -1, map[string]int{} for i, l := range lines { switch { case strings.HasPrefix(l, "connect.go.tito.io"): hostAt = i case strings.HasPrefix(strings.TrimSpace(l), "/bigcorp"): prefixAt["bigcorp"] = i case strings.HasPrefix(strings.TrimSpace(l), "/paulca"): prefixAt["paulca"] = i } } if hostAt < 0 || len(prefixAt) != 2 { t.Fatalf("routes output has no host row with its two prefixes under it:\n%s", out.String()) } // UNDER their own host, not appended at the end of the table: which // hostname a prefix splits is the whole fact being reported. for p, at := range prefixAt { if at != hostAt+1 && at != hostAt+2 { t.Fatalf("/%s printed at line %d, want it directly under connect.go.tito.io at %d:\n%s", p, at, hostAt, out.String()) } if !strings.HasPrefix(lines[at], " ") { t.Fatalf("/%s row = %q, want it indented under its hostname", p, lines[at]) } if !strings.Contains(lines[at], "router-a.go.tito.io") { t.Fatalf("/%s row = %q, want the route it is served by named", p, lines[at]) } } // A path row is a POINTER at another route: it has no upstream, channel, // environment or health of its own, and those cells must read as answers // rather than as data this client failed to fetch. if !strings.Contains(lines[prefixAt["paulca"]], "\u2014") { t.Fatalf("path row = %q, want em dashes in the columns a pointer has none of", lines[prefixAt["paulca"]]) } } // TestRoutesPathRowsAreNotCountedAsRoutes: a path row is a pointer at a // route, not a route. It is printed under its host, so when --match filters // every host away nothing is printed at all — and the "no routes" line must // still appear, because path rows must not inflate the count of what was // shown. The obvious spelling of this check (assert the line is ABSENT from // a listing that prints two routes) cannot fail, which is why it is written // the other way round. func TestRoutesPathRowsAreNotCountedAsRoutes(t *testing.T) { useTempCredentials(t) origin := routesConsolePaths(t, []map[string]any{ {"host": "connect.go.tito.io", "kind": "instance", "upstream": "127.0.0.1:9001", "channel": "stable", "status": "ready", "health": "Running"}, }, []map[string]any{ {"host": "connect.go.tito.io", "prefix": "paulca", "target": "router-a.go.tito.io"}, }) var out bytes.Buffer old := stdout stdout = &out t.Cleanup(func() { stdout = old }) runRoutes([]string{"-app", "titogo", "-account-id", "bae", "-console", origin, "-match", "nothing-matches-this"}) if !strings.Contains(out.String(), "no routes for titogo") { t.Fatalf("every route was filtered away, so the listing must say so:\n%s", out.String()) } if strings.Contains(out.String(), "/paulca") { t.Fatalf("a path row survived a filter its host did not:\n%s", out.String()) } } // TestRoutesEmptyListSaysSo: an empty answer prints a line, not a bare // header an operator has to interpret. func TestRoutesEmptyListSaysSo(t *testing.T) { useTempCredentials(t) origin := routesConsole(t, []map[string]any{}) var out bytes.Buffer old := stdout stdout = &out t.Cleanup(func() { stdout = old }) runRoutes([]string{"-app", "titogo", "-account-id", "bae", "-console", origin}) if !strings.Contains(out.String(), "no routes for titogo") { t.Fatalf("empty routes output = %q, want a line saying so", out.String()) } }