package main // The CLI half of `carlos restart` (issue #113): the wire shape it sends, the // answers it can get, and the one refusal it has to explain rather than // repeat. import ( "context" "encoding/json" "net/http" "net/http/httptest" "os/exec" "strings" "testing" ) // TestAPISessionRestartWireShape pins what handleAPIRestart requires: a bearer // POST to the app's /restart path, with the account segment carried verbatim // (the console resolves names against the caller's own memberships — this // client must never resolve one locally). func TestAPISessionRestartWireShape(t *testing.T) { var gotPath, gotMethod, gotAuth string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization") json.NewEncoder(w).Encode(map[string]any{"requested": true, "account": "bab", "app": "keymail"}) })) defer ts.Close() res, err := newTestSession(ts.URL).restart(context.Background(), apiAcct{Seg: "keymail-team"}, "keymail") if err != nil { t.Fatalf("restart: %v", err) } if gotMethod != http.MethodPost { t.Fatalf("method = %s, want POST", gotMethod) } if gotPath != "/api/cli/apps/keymail-team/keymail/restart" { t.Fatalf("path = %s", gotPath) } if gotAuth != "Bearer cli-token" { t.Fatalf("auth = %q", gotAuth) } // The CANONICAL names come back from the console, not from what was typed. if res.Account != "bab" || res.App != "keymail" { t.Fatalf("result = %+v, want the console's canonical bab/keymail", res) } } // TestAPISessionRestartAccountIDIsExact: -account-id travels as ?exact=sqid, // the console-side switch that matches the segment as a sqid ONLY — the // reference an ambiguity error tells you to reach for must not itself be // ambiguous. func TestAPISessionRestartAccountIDIsExact(t *testing.T) { var gotPath string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.RequestURI() json.NewEncoder(w).Encode(map[string]any{"requested": true, "account": "bab", "app": "keymail"}) })) defer ts.Close() if _, err := newTestSession(ts.URL).restart(context.Background(), apiAcct{Seg: "bab", Exact: true}, "keymail"); err != nil { t.Fatalf("restart: %v", err) } if !strings.HasSuffix(gotPath, "?exact=sqid") { t.Fatalf("path = %s, want the exact=sqid switch", gotPath) } } // TestAPISessionRestart404NamesBothCauses is the old-console case. net/http's // mux answers an unregistered route with the SAME byte-identical "404 page not // found" apiAccountApp gives a non-member, so there is nothing in the response // to tell them apart — and a message that claimed either one alone would send // somebody to fix the wrong thing. func TestAPISessionRestart404NamesBothCauses(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) // a console that predates this door, or a stranger's probe })) defer ts.Close() _, err := newTestSession(ts.URL).restart(context.Background(), apiAcct{Seg: "bab"}, "keymail") if err == nil { t.Fatal("a 404 must error") } if !strings.Contains(err.Error(), "-account") { t.Fatalf("error %q drops the membership explanation", err) } if !strings.Contains(err.Error(), "predates") || !strings.Contains(err.Error(), "redeploy the console") { t.Fatalf("error %q does not name the old-console cause and its fix", err) } } // TestAPISessionRestartServerErrorSurfacesVerbatim: a door that answered with // a real diagnosis (a 429 budget, a 503 with no bucket, a 502 that wrote // nothing) must reach the terminal in the console's own words — that text // already says whether a retry is safe. func TestAPISessionRestartServerErrorSurfacesVerbatim(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTooManyRequests) json.NewEncoder(w).Encode(map[string]string{"error": "too many requests — wait a minute and try again"}) })) defer ts.Close() _, err := newTestSession(ts.URL).restart(context.Background(), apiAcct{Seg: "bab"}, "keymail") if err == nil || !strings.Contains(err.Error(), "wait a minute") { t.Fatalf("restart 429 = %v, want the console's own text", err) } } // TestRestartVerbRefusals drives the real binary for the two ways the verb // stops before it ever dials anything: no -app, and direct bucket mode. Both // run through carlosTestEnv with a scratch cmd.Dir — the repo rule — so a // logged-in laptop's real credential and console can never be what this test // exercises. func TestRestartVerbRefusals(t *testing.T) { bin := buildCarlos(t) scratch := t.TempDir() run := func(env []string, args ...string) (string, error) { t.Helper() cmd := exec.Command(bin, args...) cmd.Env = carlosTestEnv(t, env...) cmd.Dir = scratch out, err := cmd.CombinedOutput() return string(out), err } // -app is resolvable since the project-defaults spec (2026-08-13), so a // bare `carlos restart` with nothing configured stops on the REAL // blocker — no session — not on a usage line for a flag that is no // longer required. out, err := run(nil, "restart") if err == nil { t.Fatalf("restart with no --app exited 0:\n%s", out) } if !strings.Contains(out, "no deployment target configured") { t.Fatalf("bare restart = %q, want the no-target refusal", out) } // Direct bucket mode: refused in restart's OWN words, naming the login // that fixes it — never another verb's advice. out, err = run([]string{"CARLOS_DEPLOYMENT_DIR=" + t.TempDir()}, "restart", "-app", "keymail") if err == nil { t.Fatalf("restart in direct bucket mode exited 0:\n%s", out) } if !strings.Contains(out, "carlos restart works through a console") || !strings.Contains(out, "carlos auth login") { t.Fatalf("direct-mode refusal = %q, want restart's own message and the login fix", out) } // A malshaped app name is refused at the terminal, before the console's // shared write budget is spent on a request it is certain to reject. out, err = run(nil, "restart", "-app", "Not An App") if err == nil { t.Fatalf("restart with a bad app name exited 0:\n%s", out) } if !strings.Contains(out, "not a usable app name") { t.Fatalf("bad app name = %q, want the name gate", out) } } // ---- the narrowed forms (discussion #16) ------------------------------------ // TestAPISessionRestartScopedWireShape pins the SEPARATE path and the body it // carries. Separate is the safety property: the app-wide door reads no body at // all, so a narrowed request sent there would be answered by cycling every // instance the app owns. This client must never send one there. func TestAPISessionRestartScopedWireShape(t *testing.T) { var gotPath, gotMethod string var gotBody map[string]any ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath, gotMethod = r.URL.RequestURI(), r.Method json.NewDecoder(r.Body).Decode(&gotBody) json.NewEncoder(w).Encode(map[string]any{ "account": "bab", "app": "titogo", "requested": true, "dry_run": false, "scope": map[string]string{"kind": "environment", "value": "relay"}, "routes": []map[string]any{ {"host": "connect.go.tito.io", "environment": "relay", "hibernate": false}, {"host": "sleepy.go.tito.io", "environment": "relay", "hibernate": true}, }, }) })) defer ts.Close() res, err := newTestSession(ts.URL).restartScoped(context.Background(), apiAcct{Seg: "bab"}, "titogo", "", "relay", false) if err != nil { t.Fatalf("restartScoped: %v", err) } if gotMethod != http.MethodPost { t.Fatalf("method = %s, want POST", gotMethod) } if gotPath != "/api/cli/apps/bab/titogo/restart/scope" { t.Fatalf("path = %s — a narrowed request must not reach the app-wide door", gotPath) } if gotBody["environment"] != "relay" || gotBody["host"] != "" || gotBody["dry_run"] != false { t.Fatalf("body = %v, want the selector and no dry run", gotBody) } // The ADDRESSED SET comes back, which is the whole point of the flags: the // complaint was that an operator could not tell what the blast radius was. if len(res.Routes) != 2 || res.Routes[0].Host != "connect.go.tito.io" || !res.Routes[1].Hibernate { t.Fatalf("routes = %+v, want both rows and the sleeping one flagged", res.Routes) } if !res.Requested || res.Scope.Kind != "environment" || res.Scope.Value != "relay" { t.Fatalf("result = %+v, want the console's own account of what it did", res) } } // TestAPISessionRestartScopedDryRunAsksForNothing: the flag travels, and the // console's answer — not this client's own flags — is what says whether // anything was restarted. func TestAPISessionRestartScopedDryRunAsksForNothing(t *testing.T) { var gotBody map[string]any ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewDecoder(r.Body).Decode(&gotBody) json.NewEncoder(w).Encode(map[string]any{ "account": "bab", "app": "titogo", "requested": false, "dry_run": true, "scope": map[string]string{"kind": "app"}, "routes": []map[string]any{}, }) })) defer ts.Close() res, err := newTestSession(ts.URL).restartScoped(context.Background(), apiAcct{Seg: "bab"}, "titogo", "", "", true) if err != nil { t.Fatalf("restartScoped dry run: %v", err) } if gotBody["dry_run"] != true { t.Fatalf("body = %v, want the dry run asked for", gotBody) } if res.Requested { t.Fatal("a dry run came back reporting a restart") } } // TestAPISessionRestartScoped404NamesTheOldConsole: a console that predates // the narrowed door answers net/http's byte-identical "404 page not found", // exactly as the membership gate does. The message must name all three // possibilities — including the form that still works there, because telling // somebody to redeploy their console and nothing else leaves them stuck. func TestAPISessionRestartScoped404NamesTheOldConsole(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })) defer ts.Close() _, err := newTestSession(ts.URL).restartScoped(context.Background(), apiAcct{Seg: "bab"}, "titogo", "bill.go.tito.io", "", false) if err == nil { t.Fatal("a 404 must error") } for _, want := range []string{"-account", "predates", "redeploy the console", "carlos restart --app"} { if !strings.Contains(err.Error(), want) { t.Fatalf("error %q does not mention %q", err, want) } } } // TestAPISessionRestartScopedServerRefusalSurfacesVerbatim: the refusal that // names what exists ("no route of this app reads environment relayy — this // console's box has relay, router") is the console's own sentence, and it is // the sentence that lets somebody spot their typo. It must not be replaced // with a generic one here. func TestAPISessionRestartScopedServerRefusalSurfacesVerbatim(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "error": "no route of this app reads environment relayy — this console's box has relay, router", }) })) defer ts.Close() _, err := newTestSession(ts.URL).restartScoped(context.Background(), apiAcct{Seg: "bab"}, "titogo", "", "relayy", false) if err == nil || !strings.Contains(err.Error(), "relay, router") { t.Fatalf("restart scope 400 = %v, want the console's own list of what exists", err) } } // TestRestartSelectorRefusals drives the real binary for the selector gates it // applies before dialling anything — the client-side half of "a typo should // cost nothing". func TestRestartSelectorRefusals(t *testing.T) { bin := buildCarlos(t) scratch := t.TempDir() run := func(args ...string) (string, error) { t.Helper() cmd := exec.Command(bin, args...) cmd.Env = carlosTestEnv(t) cmd.Dir = scratch out, err := cmd.CombinedOutput() return string(out), err } // "default" is the base bundle no route is BOUND to, so a restart of what // reads it is a restart of the app — refused by name rather than resolved // to an empty set. Same gate `carlos env` applies to the same flag. out, err := run("restart", "-app", "titogo", "-environment", "default") if err == nil { t.Fatalf("restart --environment default exited 0:\n%s", out) } if !strings.Contains(out, "already names the base bundle") { t.Fatalf("--environment default = %q, want the named refusal", out) } out, err = run("restart", "-app", "titogo", "-environment", "Not Valid") if err == nil { t.Fatalf("restart with a malformed environment exited 0:\n%s", out) } if !strings.Contains(out, "not a valid environment name") { t.Fatalf("bad environment = %q, want the shape gate", out) } // A selector that was TYPED but empty — a shell variable that expanded to // nothing — must not fall through to "no selector", which is an app-wide // restart. Somebody asking to cycle one route and cycling all of them // instead is the accident the flags exist to prevent, arriving through the // flags themselves. for _, args := range [][]string{ {"restart", "-app", "titogo", "-host", ""}, {"restart", "-app", "titogo", "-environment", ""}, } { out, err := run(args...) if err == nil { t.Fatalf("%v exited 0:\n%s", args, out) } if !strings.Contains(out, "empty value") || !strings.Contains(out, "nothing was restarted") { t.Fatalf("%v = %q, want the empty-selector refusal", args, out) } } // The selectors do not change the mode refusal: a narrowed restart is // still a console verb, and direct bucket mode still says so in restart's // own words. cmd := exec.Command(bin, "restart", "-app", "titogo", "-host", "bill.go.tito.io") cmd.Env = carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+t.TempDir()) cmd.Dir = scratch raw, err := cmd.CombinedOutput() if err == nil { t.Fatalf("a narrowed restart in direct bucket mode exited 0:\n%s", raw) } if !strings.Contains(string(raw), "carlos restart works through a console") { t.Fatalf("direct-mode refusal = %q, want restart's own message", raw) } }