| 1 | package main |
| 2 | |
| 3 | // The CLI half of `carlos restart` (issue #113): the wire shape it sends, the |
| 4 | // answers it can get, and the one refusal it has to explain rather than |
| 5 | // repeat. |
| 6 | |
| 7 | import ( |
| 8 | "context" |
| 9 | "encoding/json" |
| 10 | "net/http" |
| 11 | "net/http/httptest" |
| 12 | "os/exec" |
| 13 | "strings" |
| 14 | "testing" |
| 15 | ) |
| 16 | |
| 17 | // TestAPISessionRestartWireShape pins what handleAPIRestart requires: a bearer |
| 18 | // POST to the app's /restart path, with the account segment carried verbatim |
| 19 | // (the console resolves names against the caller's own memberships — this |
| 20 | // client must never resolve one locally). |
| 21 | func TestAPISessionRestartWireShape(t *testing.T) { |
| 22 | var gotPath, gotMethod, gotAuth string |
| 23 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 24 | gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization") |
| 25 | json.NewEncoder(w).Encode(map[string]any{"requested": true, "account": "bab", "app": "keymail"}) |
| 26 | })) |
| 27 | defer ts.Close() |
| 28 | |
| 29 | res, err := newTestSession(ts.URL).restart(context.Background(), apiAcct{Seg: "keymail-team"}, "keymail") |
| 30 | if err != nil { |
| 31 | t.Fatalf("restart: %v", err) |
| 32 | } |
| 33 | if gotMethod != http.MethodPost { |
| 34 | t.Fatalf("method = %s, want POST", gotMethod) |
| 35 | } |
| 36 | if gotPath != "/api/cli/apps/keymail-team/keymail/restart" { |
| 37 | t.Fatalf("path = %s", gotPath) |
| 38 | } |
| 39 | if gotAuth != "Bearer cli-token" { |
| 40 | t.Fatalf("auth = %q", gotAuth) |
| 41 | } |
| 42 | // The CANONICAL names come back from the console, not from what was typed. |
| 43 | if res.Account != "bab" || res.App != "keymail" { |
| 44 | t.Fatalf("result = %+v, want the console's canonical bab/keymail", res) |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | // TestAPISessionRestartAccountIDIsExact: -account-id travels as ?exact=sqid, |
| 49 | // the console-side switch that matches the segment as a sqid ONLY — the |
| 50 | // reference an ambiguity error tells you to reach for must not itself be |
| 51 | // ambiguous. |
| 52 | func TestAPISessionRestartAccountIDIsExact(t *testing.T) { |
| 53 | var gotPath string |
| 54 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 55 | gotPath = r.URL.RequestURI() |
| 56 | json.NewEncoder(w).Encode(map[string]any{"requested": true, "account": "bab", "app": "keymail"}) |
| 57 | })) |
| 58 | defer ts.Close() |
| 59 | |
| 60 | if _, err := newTestSession(ts.URL).restart(context.Background(), apiAcct{Seg: "bab", Exact: true}, "keymail"); err != nil { |
| 61 | t.Fatalf("restart: %v", err) |
| 62 | } |
| 63 | if !strings.HasSuffix(gotPath, "?exact=sqid") { |
| 64 | t.Fatalf("path = %s, want the exact=sqid switch", gotPath) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | // TestAPISessionRestart404NamesBothCauses is the old-console case. net/http's |
| 69 | // mux answers an unregistered route with the SAME byte-identical "404 page not |
| 70 | // found" apiAccountApp gives a non-member, so there is nothing in the response |
| 71 | // to tell them apart — and a message that claimed either one alone would send |
| 72 | // somebody to fix the wrong thing. |
| 73 | func TestAPISessionRestart404NamesBothCauses(t *testing.T) { |
| 74 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 75 | http.NotFound(w, r) // a console that predates this door, or a stranger's probe |
| 76 | })) |
| 77 | defer ts.Close() |
| 78 | |
| 79 | _, err := newTestSession(ts.URL).restart(context.Background(), apiAcct{Seg: "bab"}, "keymail") |
| 80 | if err == nil { |
| 81 | t.Fatal("a 404 must error") |
| 82 | } |
| 83 | if !strings.Contains(err.Error(), "-account") { |
| 84 | t.Fatalf("error %q drops the membership explanation", err) |
| 85 | } |
| 86 | if !strings.Contains(err.Error(), "predates") || !strings.Contains(err.Error(), "redeploy the console") { |
| 87 | t.Fatalf("error %q does not name the old-console cause and its fix", err) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | // TestAPISessionRestartServerErrorSurfacesVerbatim: a door that answered with |
| 92 | // a real diagnosis (a 429 budget, a 503 with no bucket, a 502 that wrote |
| 93 | // nothing) must reach the terminal in the console's own words — that text |
| 94 | // already says whether a retry is safe. |
| 95 | func TestAPISessionRestartServerErrorSurfacesVerbatim(t *testing.T) { |
| 96 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 97 | w.WriteHeader(http.StatusTooManyRequests) |
| 98 | json.NewEncoder(w).Encode(map[string]string{"error": "too many requests — wait a minute and try again"}) |
| 99 | })) |
| 100 | defer ts.Close() |
| 101 | |
| 102 | _, err := newTestSession(ts.URL).restart(context.Background(), apiAcct{Seg: "bab"}, "keymail") |
| 103 | if err == nil || !strings.Contains(err.Error(), "wait a minute") { |
| 104 | t.Fatalf("restart 429 = %v, want the console's own text", err) |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | // TestRestartVerbRefusals drives the real binary for the two ways the verb |
| 109 | // stops before it ever dials anything: no -app, and direct bucket mode. Both |
| 110 | // run through carlosTestEnv with a scratch cmd.Dir — the repo rule — so a |
| 111 | // logged-in laptop's real credential and console can never be what this test |
| 112 | // exercises. |
| 113 | func TestRestartVerbRefusals(t *testing.T) { |
| 114 | bin := buildCarlos(t) |
| 115 | scratch := t.TempDir() |
| 116 | run := func(env []string, args ...string) (string, error) { |
| 117 | t.Helper() |
| 118 | cmd := exec.Command(bin, args...) |
| 119 | cmd.Env = carlosTestEnv(t, env...) |
| 120 | cmd.Dir = scratch |
| 121 | out, err := cmd.CombinedOutput() |
| 122 | return string(out), err |
| 123 | } |
| 124 | |
| 125 | // -app is resolvable since the project-defaults spec (2026-08-13), so a |
| 126 | // bare `carlos restart` with nothing configured stops on the REAL |
| 127 | // blocker — no session — not on a usage line for a flag that is no |
| 128 | // longer required. |
| 129 | out, err := run(nil, "restart") |
| 130 | if err == nil { |
| 131 | t.Fatalf("restart with no --app exited 0:\n%s", out) |
| 132 | } |
| 133 | if !strings.Contains(out, "no deployment target configured") { |
| 134 | t.Fatalf("bare restart = %q, want the no-target refusal", out) |
| 135 | } |
| 136 | |
| 137 | // Direct bucket mode: refused in restart's OWN words, naming the login |
| 138 | // that fixes it — never another verb's advice. |
| 139 | out, err = run([]string{"CARLOS_DEPLOYMENT_DIR=" + t.TempDir()}, "restart", "-app", "keymail") |
| 140 | if err == nil { |
| 141 | t.Fatalf("restart in direct bucket mode exited 0:\n%s", out) |
| 142 | } |
| 143 | if !strings.Contains(out, "carlos restart works through a console") || |
| 144 | !strings.Contains(out, "carlos auth login") { |
| 145 | t.Fatalf("direct-mode refusal = %q, want restart's own message and the login fix", out) |
| 146 | } |
| 147 | |
| 148 | // A malshaped app name is refused at the terminal, before the console's |
| 149 | // shared write budget is spent on a request it is certain to reject. |
| 150 | out, err = run(nil, "restart", "-app", "Not An App") |
| 151 | if err == nil { |
| 152 | t.Fatalf("restart with a bad app name exited 0:\n%s", out) |
| 153 | } |
| 154 | if !strings.Contains(out, "not a usable app name") { |
| 155 | t.Fatalf("bad app name = %q, want the name gate", out) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | // ---- the narrowed forms (discussion #16) ------------------------------------ |
| 160 | |
| 161 | // TestAPISessionRestartScopedWireShape pins the SEPARATE path and the body it |
| 162 | // carries. Separate is the safety property: the app-wide door reads no body at |
| 163 | // all, so a narrowed request sent there would be answered by cycling every |
| 164 | // instance the app owns. This client must never send one there. |
| 165 | func TestAPISessionRestartScopedWireShape(t *testing.T) { |
| 166 | var gotPath, gotMethod string |
| 167 | var gotBody map[string]any |
| 168 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 169 | gotPath, gotMethod = r.URL.RequestURI(), r.Method |
| 170 | json.NewDecoder(r.Body).Decode(&gotBody) |
| 171 | json.NewEncoder(w).Encode(map[string]any{ |
| 172 | "account": "bab", "app": "titogo", "requested": true, "dry_run": false, |
| 173 | "scope": map[string]string{"kind": "environment", "value": "relay"}, |
| 174 | "routes": []map[string]any{ |
| 175 | {"host": "connect.go.tito.io", "environment": "relay", "hibernate": false}, |
| 176 | {"host": "sleepy.go.tito.io", "environment": "relay", "hibernate": true}, |
| 177 | }, |
| 178 | }) |
| 179 | })) |
| 180 | defer ts.Close() |
| 181 | |
| 182 | res, err := newTestSession(ts.URL).restartScoped(context.Background(), |
| 183 | apiAcct{Seg: "bab"}, "titogo", "", "relay", false) |
| 184 | if err != nil { |
| 185 | t.Fatalf("restartScoped: %v", err) |
| 186 | } |
| 187 | if gotMethod != http.MethodPost { |
| 188 | t.Fatalf("method = %s, want POST", gotMethod) |
| 189 | } |
| 190 | if gotPath != "/api/cli/apps/bab/titogo/restart/scope" { |
| 191 | t.Fatalf("path = %s — a narrowed request must not reach the app-wide door", gotPath) |
| 192 | } |
| 193 | if gotBody["environment"] != "relay" || gotBody["host"] != "" || gotBody["dry_run"] != false { |
| 194 | t.Fatalf("body = %v, want the selector and no dry run", gotBody) |
| 195 | } |
| 196 | // The ADDRESSED SET comes back, which is the whole point of the flags: the |
| 197 | // complaint was that an operator could not tell what the blast radius was. |
| 198 | if len(res.Routes) != 2 || res.Routes[0].Host != "connect.go.tito.io" || !res.Routes[1].Hibernate { |
| 199 | t.Fatalf("routes = %+v, want both rows and the sleeping one flagged", res.Routes) |
| 200 | } |
| 201 | if !res.Requested || res.Scope.Kind != "environment" || res.Scope.Value != "relay" { |
| 202 | t.Fatalf("result = %+v, want the console's own account of what it did", res) |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | // TestAPISessionRestartScopedDryRunAsksForNothing: the flag travels, and the |
| 207 | // console's answer — not this client's own flags — is what says whether |
| 208 | // anything was restarted. |
| 209 | func TestAPISessionRestartScopedDryRunAsksForNothing(t *testing.T) { |
| 210 | var gotBody map[string]any |
| 211 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 212 | json.NewDecoder(r.Body).Decode(&gotBody) |
| 213 | json.NewEncoder(w).Encode(map[string]any{ |
| 214 | "account": "bab", "app": "titogo", "requested": false, "dry_run": true, |
| 215 | "scope": map[string]string{"kind": "app"}, "routes": []map[string]any{}, |
| 216 | }) |
| 217 | })) |
| 218 | defer ts.Close() |
| 219 | |
| 220 | res, err := newTestSession(ts.URL).restartScoped(context.Background(), apiAcct{Seg: "bab"}, "titogo", "", "", true) |
| 221 | if err != nil { |
| 222 | t.Fatalf("restartScoped dry run: %v", err) |
| 223 | } |
| 224 | if gotBody["dry_run"] != true { |
| 225 | t.Fatalf("body = %v, want the dry run asked for", gotBody) |
| 226 | } |
| 227 | if res.Requested { |
| 228 | t.Fatal("a dry run came back reporting a restart") |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | // TestAPISessionRestartScoped404NamesTheOldConsole: a console that predates |
| 233 | // the narrowed door answers net/http's byte-identical "404 page not found", |
| 234 | // exactly as the membership gate does. The message must name all three |
| 235 | // possibilities — including the form that still works there, because telling |
| 236 | // somebody to redeploy their console and nothing else leaves them stuck. |
| 237 | func TestAPISessionRestartScoped404NamesTheOldConsole(t *testing.T) { |
| 238 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 239 | http.NotFound(w, r) |
| 240 | })) |
| 241 | defer ts.Close() |
| 242 | |
| 243 | _, err := newTestSession(ts.URL).restartScoped(context.Background(), apiAcct{Seg: "bab"}, "titogo", "bill.go.tito.io", "", false) |
| 244 | if err == nil { |
| 245 | t.Fatal("a 404 must error") |
| 246 | } |
| 247 | for _, want := range []string{"-account", "predates", "redeploy the console", "carlos restart --app"} { |
| 248 | if !strings.Contains(err.Error(), want) { |
| 249 | t.Fatalf("error %q does not mention %q", err, want) |
| 250 | } |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | // TestAPISessionRestartScopedServerRefusalSurfacesVerbatim: the refusal that |
| 255 | // names what exists ("no route of this app reads environment relayy — this |
| 256 | // console's box has relay, router") is the console's own sentence, and it is |
| 257 | // the sentence that lets somebody spot their typo. It must not be replaced |
| 258 | // with a generic one here. |
| 259 | func TestAPISessionRestartScopedServerRefusalSurfacesVerbatim(t *testing.T) { |
| 260 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 261 | w.WriteHeader(http.StatusBadRequest) |
| 262 | json.NewEncoder(w).Encode(map[string]string{ |
| 263 | "error": "no route of this app reads environment relayy — this console's box has relay, router", |
| 264 | }) |
| 265 | })) |
| 266 | defer ts.Close() |
| 267 | |
| 268 | _, err := newTestSession(ts.URL).restartScoped(context.Background(), apiAcct{Seg: "bab"}, "titogo", "", "relayy", false) |
| 269 | if err == nil || !strings.Contains(err.Error(), "relay, router") { |
| 270 | t.Fatalf("restart scope 400 = %v, want the console's own list of what exists", err) |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | // TestRestartSelectorRefusals drives the real binary for the selector gates it |
| 275 | // applies before dialling anything — the client-side half of "a typo should |
| 276 | // cost nothing". |
| 277 | func TestRestartSelectorRefusals(t *testing.T) { |
| 278 | bin := buildCarlos(t) |
| 279 | scratch := t.TempDir() |
| 280 | run := func(args ...string) (string, error) { |
| 281 | t.Helper() |
| 282 | cmd := exec.Command(bin, args...) |
| 283 | cmd.Env = carlosTestEnv(t) |
| 284 | cmd.Dir = scratch |
| 285 | out, err := cmd.CombinedOutput() |
| 286 | return string(out), err |
| 287 | } |
| 288 | |
| 289 | // "default" is the base bundle no route is BOUND to, so a restart of what |
| 290 | // reads it is a restart of the app — refused by name rather than resolved |
| 291 | // to an empty set. Same gate `carlos env` applies to the same flag. |
| 292 | out, err := run("restart", "-app", "titogo", "-environment", "default") |
| 293 | if err == nil { |
| 294 | t.Fatalf("restart --environment default exited 0:\n%s", out) |
| 295 | } |
| 296 | if !strings.Contains(out, "already names the base bundle") { |
| 297 | t.Fatalf("--environment default = %q, want the named refusal", out) |
| 298 | } |
| 299 | |
| 300 | out, err = run("restart", "-app", "titogo", "-environment", "Not Valid") |
| 301 | if err == nil { |
| 302 | t.Fatalf("restart with a malformed environment exited 0:\n%s", out) |
| 303 | } |
| 304 | if !strings.Contains(out, "not a valid environment name") { |
| 305 | t.Fatalf("bad environment = %q, want the shape gate", out) |
| 306 | } |
| 307 | |
| 308 | // A selector that was TYPED but empty — a shell variable that expanded to |
| 309 | // nothing — must not fall through to "no selector", which is an app-wide |
| 310 | // restart. Somebody asking to cycle one route and cycling all of them |
| 311 | // instead is the accident the flags exist to prevent, arriving through the |
| 312 | // flags themselves. |
| 313 | for _, args := range [][]string{ |
| 314 | {"restart", "-app", "titogo", "-host", ""}, |
| 315 | {"restart", "-app", "titogo", "-environment", ""}, |
| 316 | } { |
| 317 | out, err := run(args...) |
| 318 | if err == nil { |
| 319 | t.Fatalf("%v exited 0:\n%s", args, out) |
| 320 | } |
| 321 | if !strings.Contains(out, "empty value") || !strings.Contains(out, "nothing was restarted") { |
| 322 | t.Fatalf("%v = %q, want the empty-selector refusal", args, out) |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | // The selectors do not change the mode refusal: a narrowed restart is |
| 327 | // still a console verb, and direct bucket mode still says so in restart's |
| 328 | // own words. |
| 329 | cmd := exec.Command(bin, "restart", "-app", "titogo", "-host", "bill.go.tito.io") |
| 330 | cmd.Env = carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+t.TempDir()) |
| 331 | cmd.Dir = scratch |
| 332 | raw, err := cmd.CombinedOutput() |
| 333 | if err == nil { |
| 334 | t.Fatalf("a narrowed restart in direct bucket mode exited 0:\n%s", raw) |
| 335 | } |
| 336 | if !strings.Contains(string(raw), "carlos restart works through a console") { |
| 337 | t.Fatalf("direct-mode refusal = %q, want restart's own message", raw) |
| 338 | } |
| 339 | } |
| 340 | |