// Tests for the `carlos fleets` verbs and `carlos apps place` — the CLI // door onto internal/console/fleets.go (Task 7). Three layers: // // - apiSession-level tests drive the six new client methods (apimode.go) // directly against an httptest fake console: path, body, and wire-shape // round trips, with no flag parsing or os.Exit in the loop. // - RunFleets/RunApps-level tests drive the full verb (flag parsing, // mode resolution, printing) with GOOD arguments only, against the // same kind of fake console, credentials written straight to disk // (mirroring auth_e2e_test.go's TestAccountsCreateUsesTheLoggedInIdentity // rather than a real login round trip) — this is what proves the token // really reaches stdout exactly once and the detach-not-rotate warning // is really in the output. // - one subprocess test (TestFleetsAndPlaceCLIUsageErrors, buildCarlos- // style like auth_e2e_test.go) drives every verb's BAD-argument path: // those call log.Fatal/os.Exit, which would kill the test binary if // driven in-process (the reason instances_cli_test.go gives for // skipping runInstancesEnable's own argv parsing) — a real subprocess // is the only way to observe "exits nonzero and says usage" without // restructuring the verbs around it. package main import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "sync/atomic" "testing" "time" ) // ---- pure grammar gates ----------------------------------------------------- func TestValidFleetNameAndBoxLabel(t *testing.T) { for _, good := range []string{"acme", "pi-1", "a", strings.Repeat("a", 32)} { if err := validFleetName(good); err != nil { t.Errorf("validFleetName(%q) = %v, want nil", good, err) } if err := validBoxLabel(good); err != nil { t.Errorf("validBoxLabel(%q) = %v, want nil", good, err) } } for _, bad := range []string{"", "Acme", "has spaces", "under_score", strings.Repeat("a", 33)} { if err := validFleetName(bad); err == nil { t.Errorf("validFleetName(%q) = nil, want an error", bad) } if err := validBoxLabel(bad); err == nil { t.Errorf("validBoxLabel(%q) = nil, want an error", bad) } } } func TestValidPlaceTarget(t *testing.T) { for _, good := range []string{"", "fleet/pi", "fleet/acme-1"} { if err := validPlaceTarget(good); err != nil { t.Errorf("validPlaceTarget(%q) = %v, want nil", good, err) } } for _, bad := range []string{"eu", "fleet/", "fleet/Acme", "pi"} { if err := validPlaceTarget(bad); err == nil { t.Errorf("validPlaceTarget(%q) = nil, want an error", bad) } } } // ---- apiSession-level: path + body + wire-shape round trips --------------- // TestAPISessionFleetCreateAndList: createFleet posts to the literal `new` // door with name+bucket in the body and unmarshals the "fleet" wrapper; // listFleets GETs the account door and unmarshals "fleets". func TestAPISessionFleetCreateAndList(t *testing.T) { var gotPath, gotMethod string var gotBody map[string]string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath, gotMethod = r.URL.Path, r.Method if r.Method == http.MethodPost { json.NewDecoder(r.Body).Decode(&gotBody) json.NewEncoder(w).Encode(map[string]any{ "fleet": map[string]any{"name": gotBody["name"], "bucket": gotBody["bucket"], "boxes": []any{}}, }) return } json.NewEncoder(w).Encode(map[string]any{ "fleets": []map[string]any{ {"name": "acme", "bucket": "acme-data", "boxes": []map[string]any{ {"label": "pi-1", "status": "active"}, }}, }, }) })) defer ts.Close() s := newTestSession(ts.URL) f, err := s.createFleet(context.Background(), apiAcct{Seg: "bab"}, "acme", "acme-data") if err != nil { t.Fatalf("createFleet: %v", err) } if gotPath != "/api/cli/fleets/bab/new" || gotMethod != http.MethodPost { t.Fatalf("request = %s %s, want POST /api/cli/fleets/bab/new", gotMethod, gotPath) } if gotBody["name"] != "acme" || gotBody["bucket"] != "acme-data" { t.Fatalf("body = %v", gotBody) } if f.Name != "acme" || f.Bucket != "acme-data" || len(f.Boxes) != 0 { t.Fatalf("createFleet result = %+v", f) } fleets, err := s.listFleets(context.Background(), apiAcct{Seg: "bab"}) if err != nil { t.Fatalf("listFleets: %v", err) } if gotPath != "/api/cli/fleets/bab" || gotMethod != http.MethodGet { t.Fatalf("request = %s %s, want GET /api/cli/fleets/bab", gotMethod, gotPath) } if len(fleets) != 1 || fleets[0].Name != "acme" || len(fleets[0].Boxes) != 1 || fleets[0].Boxes[0].Label != "pi-1" { t.Fatalf("listFleets result = %+v", fleets) } } // TestAPISessionFleetCreateExactAccountID: -account-id travels as // exact=sqid, same as every other apiPath-built call. func TestAPISessionFleetCreateExactAccountID(t *testing.T) { var gotQuery string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotQuery = r.URL.RawQuery json.NewEncoder(w).Encode(map[string]any{"fleet": map[string]any{"name": "x", "bucket": "y", "boxes": []any{}}}) })) defer ts.Close() s := newTestSession(ts.URL) if _, err := s.createFleet(context.Background(), apiAcct{Seg: "bab", Exact: true}, "x", "y"); err != nil { t.Fatalf("createFleet: %v", err) } if gotQuery != "exact=sqid" { t.Fatalf("query = %q, want exact=sqid", gotQuery) } } // TestAPISessionFleetCreateDuplicateName: the server's 409 sentence surfaces // verbatim, same convention as apps create's duplicate refusal. func TestAPISessionFleetCreateDuplicateName(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusConflict) json.NewEncoder(w).Encode(map[string]string{"error": "that name is taken"}) })) defer ts.Close() s := newTestSession(ts.URL) if _, err := s.createFleet(context.Background(), apiAcct{Seg: "bab"}, "acme", "d"); err == nil || err.Error() != "that name is taken" { t.Fatalf("createFleet duplicate = %v, want the server's sentence", err) } } // TestAPISessionFleetAddBoxRotateDetach: add-box and rotate-token both POST // to their own path and return the plaintext token in the "token" field; // detach-box POSTs and returns no token at all. func TestAPISessionFleetAddBoxRotateDetach(t *testing.T) { var gotPath, gotLabel string step := "add" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path switch step { case "add": var req struct{ Label string } json.NewDecoder(r.Body).Decode(&req) gotLabel = req.Label json.NewEncoder(w).Encode(map[string]any{"label": req.Label, "token": "tok-first-64hex"}) case "rotate": json.NewEncoder(w).Encode(map[string]any{"token": "tok-second-64hex"}) case "detach": json.NewEncoder(w).Encode(map[string]any{}) } })) defer ts.Close() s := newTestSession(ts.URL) acct := apiAcct{Seg: "bab"} tok, err := s.addFleetBox(context.Background(), acct, "acme", "pi-1") if err != nil { t.Fatalf("addFleetBox: %v", err) } if gotPath != "/api/cli/fleets/bab/acme/boxes" || gotLabel != "pi-1" || tok != "tok-first-64hex" { t.Fatalf("addFleetBox: path=%s label=%s token=%s", gotPath, gotLabel, tok) } step = "rotate" tok, err = s.rotateFleetBoxToken(context.Background(), acct, "acme", "pi-1") if err != nil { t.Fatalf("rotateFleetBoxToken: %v", err) } if gotPath != "/api/cli/fleets/bab/acme/boxes/pi-1/rotate" || tok != "tok-second-64hex" { t.Fatalf("rotateFleetBoxToken: path=%s token=%s", gotPath, tok) } step = "detach" if err := s.detachFleetBox(context.Background(), acct, "acme", "pi-1"); err != nil { t.Fatalf("detachFleetBox: %v", err) } if gotPath != "/api/cli/fleets/bab/acme/boxes/pi-1/detach" { t.Fatalf("detachFleetBox path = %s", gotPath) } } // TestAPISessionFleetDoorsSurfaceServerErrors: a non-200 from any of the // three box doors surfaces the server's own message, not a swallowed one. func TestAPISessionFleetDoorsSurfaceServerErrors(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) w.Write([]byte("404 page not found\n")) })) defer ts.Close() s := newTestSession(ts.URL) acct := apiAcct{Seg: "bab"} if _, err := s.addFleetBox(context.Background(), acct, "ghost", "x"); err == nil { t.Fatal("addFleetBox against a 404 must error") } if _, err := s.rotateFleetBoxToken(context.Background(), acct, "ghost", "x"); err == nil { t.Fatal("rotateFleetBoxToken against a 404 must error") } if err := s.detachFleetBox(context.Background(), acct, "ghost", "x"); err == nil { t.Fatal("detachFleetBox against a 404 must error") } if err := s.tagFleetBox(context.Background(), acct, "ghost", "x", "office"); err == nil { t.Fatal("tagFleetBox against a 404 must error") } } // TestAPISessionFleetTagBox: tagFleetBox POSTs to the labelled tags door // with the tags field in the body (full replace, including the "" clearing // case), and returns no token — unlike add-box/rotate-token, this door's // response body carries nothing this client reads. func TestAPISessionFleetTagBox(t *testing.T) { var gotPath string var gotBody struct{ Tags string } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path json.NewDecoder(r.Body).Decode(&gotBody) json.NewEncoder(w).Encode(map[string]any{}) })) defer ts.Close() s := newTestSession(ts.URL) acct := apiAcct{Seg: "bab"} if err := s.tagFleetBox(context.Background(), acct, "acme", "pi-1", "office,free"); err != nil { t.Fatalf("tagFleetBox: %v", err) } if gotPath != "/api/cli/fleets/bab/acme/boxes/pi-1/tags" || gotBody.Tags != "office,free" { t.Fatalf("tagFleetBox request: path=%s body=%+v", gotPath, gotBody) } if err := s.tagFleetBox(context.Background(), acct, "acme", "pi-1", ""); err != nil { t.Fatalf("tagFleetBox clear: %v", err) } if gotBody.Tags != "" { t.Fatalf("tagFleetBox clear sent tags=%q, want empty", gotBody.Tags) } } // TestAPISessionPlace: place POSTs the target in the body to .../place, for // both a fleet target and the clearing "". func TestAPISessionPlace(t *testing.T) { var gotPath string var gotTarget string var sawTarget bool ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path var req struct { Target string `json:"target"` } json.NewDecoder(r.Body).Decode(&req) gotTarget, sawTarget = req.Target, true json.NewEncoder(w).Encode(map[string]any{}) })) defer ts.Close() s := newTestSession(ts.URL) if err := s.place(context.Background(), apiAcct{Seg: "bab"}, "titogo", "fleet/pi"); err != nil { t.Fatalf("place: %v", err) } if gotPath != "/api/cli/apps/bab/titogo/place" || !sawTarget || gotTarget != "fleet/pi" { t.Fatalf("place request: path=%s target=%q sawTarget=%v", gotPath, gotTarget, sawTarget) } if err := s.place(context.Background(), apiAcct{Seg: "bab"}, "titogo", ""); err != nil { t.Fatalf("place clear: %v", err) } if gotTarget != "" { t.Fatalf("clearing place: target=%q, want empty", gotTarget) } } // TestAPISessionPlaceSurfacesRefusal: an invalid-shape 400 and a // foreign-fleet 404 both surface through place unchanged. func TestAPISessionPlaceSurfacesRefusal(t *testing.T) { status := http.StatusBadRequest ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if status == http.StatusNotFound { http.NotFound(w, r) return } w.WriteHeader(status) json.NewEncoder(w).Encode(map[string]string{"error": `invalid target — want "" or "fleet/"`}) })) defer ts.Close() s := newTestSession(ts.URL) if err := s.place(context.Background(), apiAcct{Seg: "bab"}, "titogo", "eu"); err == nil || !strings.Contains(err.Error(), "invalid target") { t.Fatalf("place with a bad shape = %v, want the server's message", err) } status = http.StatusNotFound if err := s.place(context.Background(), apiAcct{Seg: "bab"}, "titogo", "fleet/ghost"); err == nil { t.Fatal("place onto a foreign/unknown fleet must error") } } // ---- RunFleets / RunApps-level: full verb, good args, real printing ------- // fakeFleetsConsole answers the CLI endpoints RunFleets/apps-create/apps-place // reach, stateful enough to prove add-box then rotate-token return DISTINCT // tokens and that create-with-place's "place" field actually arrives. type fakeFleetsConsole struct { ts *httptest.Server tokenSeq atomic.Int64 lastAuth atomic.Value // string // what the last relevant call carried, for assertions. lastCreateBucket atomic.Value // string lastCreatePlace atomic.Value // string lastPlaceTarget atomic.Value // string lastDetachPath atomic.Value // string lastTagPath atomic.Value // string lastTagBody atomic.Value // string } func newFakeFleetsConsole(t *testing.T) *fakeFleetsConsole { t.Helper() fc := &fakeFleetsConsole{} mux := http.NewServeMux() mux.HandleFunc("POST /api/cli/fleets/{account}/new", func(w http.ResponseWriter, r *http.Request) { fc.lastAuth.Store(r.Header.Get("Authorization")) var req struct{ Name, Bucket string } json.NewDecoder(r.Body).Decode(&req) fc.lastCreateBucket.Store(req.Bucket) json.NewEncoder(w).Encode(map[string]any{ "fleet": map[string]any{"name": req.Name, "bucket": req.Bucket, "boxes": []any{}}, }) }) mux.HandleFunc("GET /api/cli/fleets/{account}", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "fleets": []map[string]any{ {"name": "acme", "bucket": "acme-data", "boxes": []map[string]any{ {"label": "pi-1", "status": "active", "tags": "office,free"}, {"label": "pi-2", "status": "detached"}, }}, }, }) }) mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes", func(w http.ResponseWriter, r *http.Request) { var req struct{ Label string } json.NewDecoder(r.Body).Decode(&req) n := fc.tokenSeq.Add(1) json.NewEncoder(w).Encode(map[string]any{ "label": req.Label, "token": tokenFor(n), }) }) mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/rotate", func(w http.ResponseWriter, r *http.Request) { n := fc.tokenSeq.Add(1) json.NewEncoder(w).Encode(map[string]any{"token": tokenFor(n)}) }) mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/detach", func(w http.ResponseWriter, r *http.Request) { fc.lastDetachPath.Store(r.URL.Path) json.NewEncoder(w).Encode(map[string]any{}) }) mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags", func(w http.ResponseWriter, r *http.Request) { var req struct{ Tags string } json.NewDecoder(r.Body).Decode(&req) fc.lastTagPath.Store(r.URL.Path) fc.lastTagBody.Store(req.Tags) json.NewEncoder(w).Encode(map[string]any{}) }) mux.HandleFunc("POST /api/cli/apps/{account}/new", func(w http.ResponseWriter, r *http.Request) { var req struct{ Name, Place string } json.NewDecoder(r.Body).Decode(&req) fc.lastCreatePlace.Store(req.Place) // Echoes place back as "placement" — this fake stands in for a // CURRENT console, which always echoes what it actually stored // (handleAPICreateApp, internal/console/apicli.go); the mismatch/ // absent-field cases that echo is FOR are exercised separately by // TestRunAppsCreatePlacementVerification's own canned-response fakes. json.NewEncoder(w).Encode(map[string]any{ "account": "bab", "app": req.Name, "url": fc.ts.URL + "/a/bab/apps/" + req.Name, "placement": req.Place, }) }) mux.HandleFunc("POST /api/cli/apps/{account}/{app}/place", func(w http.ResponseWriter, r *http.Request) { var req struct{ Target string } json.NewDecoder(r.Body).Decode(&req) fc.lastPlaceTarget.Store(req.Target) json.NewEncoder(w).Encode(map[string]any{}) }) fc.ts = httptest.NewServer(mux) t.Cleanup(fc.ts.Close) return fc } // tokenFor mints a distinguishable fake token per call so a test can prove // add-box and a later rotate-token did NOT return the same value. func tokenFor(n int64) string { return strings.Repeat("a", 60) + "-" + string(rune('0'+n%10)) } // writeTestCredential drops a credential straight on disk for console at // url with a fixed bearer, the same shortcut // TestAccountsCreateUsesTheLoggedInIdentity (auth_e2e_test.go) takes to // avoid a login round trip in a test that isn't about login. func writeTestCredential(t *testing.T, path, url string) { t.Helper() f := &credentialFile{Version: 1, Credentials: []credential{{ Console: url, Address: "paul@keymail.dev", Token: "cli-token", CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().Add(24 * time.Hour).UTC(), }}} raw, err := json.Marshal(f) if err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(path, raw, 0o600); err != nil { t.Fatal(err) } } // fleetsCLIFixture wires up a fake console plus a credential pointed at it, // entirely in-process (no subprocess, no real login flow) — the "package // level tests against httptest console servers" shape. func fleetsCLIFixture(t *testing.T) *fakeFleetsConsole { t.Helper() home := t.TempDir() t.Setenv("HOME", home) t.Setenv("CARLOS_CONSOLE", "") t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") t.Setenv("CARLOS_DEPLOYMENT_DIR", "") t.Chdir(t.TempDir()) creds := filepath.Join(home, "carlos-credentials") t.Setenv("CARLOS_CREDENTIALS", creds) fc := newFakeFleetsConsole(t) writeTestCredential(t, creds, fc.ts.URL) return fc } // TestRunFleetsCreateHitsRightPathWithBody drives `carlos fleets create` // with good args end to end (flag parsing included) and checks the console // saw the right bucket and the verb printed the fleet name back. func TestRunFleetsCreateHitsRightPathWithBody(t *testing.T) { fc := fleetsCLIFixture(t) buf := captureStdout(t) RunFleets([]string{"create", "-bucket", "acme-data", "-account", "bab", "acme"}) if got := fc.lastCreateBucket.Load(); got != "acme-data" { t.Fatalf("console saw bucket %v, want acme-data", got) } if !strings.Contains(buf.String(), "created fleet acme") { t.Fatalf("stdout = %q, want it to confirm the created fleet", buf.String()) } } // TestRunFleetsAddBoxPrintsTokenOnceWithDetachWarning: add-box's token // appears in stdout EXACTLY ONCE, and the output carries the detach-not- // rotate warning verbatim (Task 3's review finding, carried into this // task's brief). func TestRunFleetsAddBoxPrintsTokenOnceWithDetachWarning(t *testing.T) { fleetsCLIFixture(t) buf := captureStdout(t) RunFleets([]string{"add-box", "-account", "bab", "acme", "pi-1"}) out := buf.String() tok := tokenFor(1) if n := strings.Count(out, tok); n != 1 { t.Fatalf("token %q appeared %d times in stdout, want exactly 1:\n%s", tok, n, out) } if !strings.Contains(out, "detach-box") { t.Fatalf("stdout missing the detach-box pointer:\n%s", out) } if !strings.Contains(strings.ToLower(out), "not evict") { t.Fatalf("stdout missing the does-NOT-evict-a-live-session warning:\n%s", out) } } // TestRunFleetsRotateTokenPrintsFreshTokenOnceWithWarning: rotate-token's // output carries the SAME warning as add-box's (rotation refuses the next // dial-in but does not evict a live channel), and a token distinct from a // prior add-box's. func TestRunFleetsRotateTokenPrintsFreshTokenOnceWithWarning(t *testing.T) { fleetsCLIFixture(t) addBuf := captureStdout(t) RunFleets([]string{"add-box", "-account", "bab", "acme", "pi-1"}) firstToken := tokenFor(1) if !strings.Contains(addBuf.String(), firstToken) { t.Fatalf("add-box did not print its own token:\n%s", addBuf.String()) } rotBuf := captureStdout(t) RunFleets([]string{"rotate-token", "-account", "bab", "acme", "pi-1"}) out := rotBuf.String() secondToken := tokenFor(2) if n := strings.Count(out, secondToken); n != 1 { t.Fatalf("rotated token %q appeared %d times, want exactly 1:\n%s", secondToken, n, out) } if strings.Contains(out, firstToken) { t.Fatalf("rotate-token output leaked the OLD token:\n%s", out) } if !strings.Contains(out, "detach-box") || !strings.Contains(strings.ToLower(out), "not evict") { t.Fatalf("rotate-token stdout missing the detach-not-rotate warning:\n%s", out) } } // TestRunFleetsDetachBoxHitsRightPath: detach-box reaches the right door and // confirms in its own words (not the token-print wording). func TestRunFleetsDetachBoxHitsRightPath(t *testing.T) { fc := fleetsCLIFixture(t) buf := captureStdout(t) RunFleets([]string{"detach-box", "-account", "bab", "acme", "pi-1"}) if got := fc.lastDetachPath.Load(); got != "/api/cli/fleets/bab/acme/boxes/pi-1/detach" { t.Fatalf("detach path = %v, want the labelled detach door", got) } if !strings.Contains(buf.String(), "detached acme/pi-1") { t.Fatalf("stdout = %q, want it to confirm the detach", buf.String()) } } // TestRunFleetsListPrintsEveryBox: `carlos fleets ls` prints one row per // box, including a detached one, from the fake console's fixed listing — // and a TAGS column: the tagged box's tags verbatim, the untagged box's "-". func TestRunFleetsListPrintsEveryBox(t *testing.T) { fleetsCLIFixture(t) buf := captureStdout(t) RunFleets([]string{"ls", "-account", "bab"}) out := buf.String() for _, want := range []string{"acme", "acme-data", "pi-1", "active", "pi-2", "detached", "TAGS", "office,free"} { if !strings.Contains(out, want) { t.Fatalf("fleets ls stdout missing %q:\n%s", want, out) } } // pi-2 carries no tags — its row must show the placeholder, not a blank // cell (which whitespace-splitting could not tell apart from a missing // column). for _, line := range strings.Split(out, "\n") { if !strings.Contains(line, "pi-2") { continue } fields := strings.Fields(line) if len(fields) == 0 || fields[len(fields)-1] != "-" { t.Fatalf("pi-2 row = %q, want its last (TAGS) field to be the '-' placeholder", line) } } } // TestRunFleetsTagBoxHitsRightPathAndClears: `carlos fleets tag-box` PUTs // (POSTs) the tags in the body to the labelled tags door, full-replace, and // -tags "" clears — both print their own confirmation, mirroring // TestRunAppsPlaceHitsRightPathAndClears's two-call shape. func TestRunFleetsTagBoxHitsRightPathAndClears(t *testing.T) { fc := fleetsCLIFixture(t) buf := captureStdout(t) RunFleets([]string{"tag-box", "-tags", "office,free", "-account", "bab", "acme", "pi-1"}) if got := fc.lastTagPath.Load(); got != "/api/cli/fleets/bab/acme/boxes/pi-1/tags" { t.Fatalf("tag-box path = %v, want the labelled tags door", got) } if got := fc.lastTagBody.Load(); got != "office,free" { t.Fatalf("console saw tags %v, want office,free", got) } if !strings.Contains(buf.String(), "tagged acme/pi-1 [office,free]") { t.Fatalf("stdout = %q, want it to confirm the tags", buf.String()) } buf2 := captureStdout(t) RunFleets([]string{"tag-box", "-tags", "", "-account", "bab", "acme", "pi-1"}) if got := fc.lastTagBody.Load(); got != "" { t.Fatalf("console saw tags %v, want \"\" (cleared)", got) } if !strings.Contains(buf2.String(), "cleared tags for acme/pi-1") { t.Fatalf("stdout = %q, want the cleared confirmation", buf2.String()) } } // TestRunAppsCreateWithPlaceSendsPlaceField: `carlos apps create -place // fleet/` reaches the console with place in the body (the 2026-08-06 // ruling's CLI surface). func TestRunAppsCreateWithPlaceSendsPlaceField(t *testing.T) { fc := fleetsCLIFixture(t) buf := captureStdout(t) RunApps([]string{"create", "-app", "titogo", "-place", "fleet/pi", "-account", "bab"}) if got := fc.lastCreatePlace.Load(); got != "fleet/pi" { t.Fatalf("console saw place %v, want fleet/pi", got) } if !strings.Contains(buf.String(), "placed on fleet/pi") { t.Fatalf("stdout = %q, want it to confirm the placement", buf.String()) } } // TestRunAppsPlaceHitsRightPathAndClears: `carlos apps place` sends the // target, and passing "" clears it — both print their own confirmation. func TestRunAppsPlaceHitsRightPathAndClears(t *testing.T) { fc := fleetsCLIFixture(t) buf := captureStdout(t) RunApps([]string{"place", "-app", "titogo", "-target", "fleet/pi", "-account", "bab"}) if got := fc.lastPlaceTarget.Load(); got != "fleet/pi" { t.Fatalf("console saw target %v, want fleet/pi", got) } if !strings.Contains(buf.String(), "placed titogo on fleet/pi") { t.Fatalf("stdout = %q, want the placed confirmation", buf.String()) } buf2 := captureStdout(t) RunApps([]string{"place", "-app", "titogo", "-target", "", "-account", "bab"}) if got := fc.lastPlaceTarget.Load(); got != "" { t.Fatalf("console saw target %v, want \"\" (cleared)", got) } if !strings.Contains(buf2.String(), "cleared placement for titogo") { t.Fatalf("stdout = %q, want the cleared confirmation", buf2.String()) } } // ---- subprocess: the usage-error / bad-argument paths --------------------- // TestFleetsAndPlaceCLIUsageErrors drives every fleets verb and apps place // with missing or malshaped arguments as a real subprocess (buildCarlos, // same helper auth_e2e_test.go's TestAuthCLI uses) — the ONLY way to observe // a log.Fatal/os.Exit refusal without killing the test binary. Every case // here must exit nonzero without ever reaching the network: carlosTestEnv // masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE and cmd.Dir is a scratch dir // (AGENTS.md's rule — a subprocess that inherited a real login has minted // real accounts on the flagship before), so a case that DID try to dial out // would fail on "not logged in", not silently succeed. func TestFleetsAndPlaceCLIUsageErrors(t *testing.T) { bin := buildCarlos(t) scratch := t.TempDir() env := carlosTestEnv(t) run := func(args ...string) (string, error) { cmd := exec.Command(bin, args...) cmd.Env = env cmd.Dir = scratch out, err := cmd.CombinedOutput() return string(out), err } for _, tc := range []struct { name string args []string want string // substring the combined output must contain }{ {"fleets no args", []string{"fleets"}, "usage: carlos fleets"}, {"fleets bogus verb", []string{"fleets", "bogus"}, "usage: carlos fleets"}, {"fleets create no args", []string{"fleets", "create"}, "usage: carlos fleets create"}, {"fleets create missing bucket", []string{"fleets", "create", "-account", "bab", "acme"}, "usage: carlos fleets create"}, {"fleets create bad name", []string{"fleets", "create", "-bucket", "d", "-account", "bab", "Bad Name"}, "not a usable fleet name"}, {"fleets add-box no args", []string{"fleets", "add-box"}, "usage: carlos fleets add-box"}, {"fleets add-box missing label", []string{"fleets", "add-box", "acme"}, "usage: carlos fleets add-box"}, {"fleets add-box bad label", []string{"fleets", "add-box", "acme", "Bad Label"}, "not a usable box label"}, {"fleets rotate-token no args", []string{"fleets", "rotate-token"}, "usage: carlos fleets rotate-token"}, {"fleets tag-box no args", []string{"fleets", "tag-box"}, "usage: carlos fleets tag-box"}, {"fleets tag-box bad tags grammar", []string{"fleets", "tag-box", "-tags", "Office", "acme", "pi-1"}, "invalid tags"}, {"fleets tag-box bad fleet name", []string{"fleets", "tag-box", "-tags", "office", "Bad Name", "pi-1"}, "not a usable fleet name"}, // Review finding: -tags is TRI-state, the exact -target hazard the // "apps place omitted target" case below pins. -tags defaults to "" // and "" is the legal, meaningful "clear every tag" value, so an // omitted -tags must NEVER be read as "clear" — that would silently // destroy a box's tags on a forgotten flag. fleet/label ARE present // here (only -tags is missing) so this fails on the flag, not the args. {"fleets tag-box omitted --tags", []string{"fleets", "tag-box", "acme", "pi-1"}, "usage: carlos fleets tag-box"}, {"fleets detach-box no args", []string{"fleets", "detach-box"}, "usage: carlos fleets detach-box"}, {"apps place missing app", []string{"apps", "place", "-target", "fleet/pi"}, "usage: carlos apps place"}, {"apps place bad target shape", []string{"apps", "place", "-app", "titogo", "-target", "eu"}, "not a usable placement target"}, {"apps create bad place shape", []string{"apps", "create", "-app", "titogo", "-place", "eu"}, "not a usable placement target"}, // I2 (2026-08-06 review): -target is tri-state — omitting it must // NEVER be read as "clear placement" (that would retire a fleet- // placed app's routes on the fleet box's next reconcile), so an // invocation with -app but no -target at all is a usage error, the // same as omitting -app. {"apps place omitted target", []string{"apps", "place", "-app", "titogo"}, "usage: carlos apps place"}, } { t.Run(tc.name, func(t *testing.T) { out, err := run(tc.args...) if err == nil { t.Fatalf("carlos %s exited 0, want a nonzero (usage/refusal) exit:\n%s", strings.Join(tc.args, " "), out) } if !strings.Contains(out, tc.want) { t.Fatalf("carlos %s output missing %q:\n%s", strings.Join(tc.args, " "), tc.want, out) } }) } } // ---- I1: placement is verified, not just requested (2026-08-06 review) --- // fakeCreateEcho spins a minimal httptest console that answers ONLY POST // /api/cli/apps/{account}/new, with a fixed canned body — enough to drive // runAppsCreate's create-with-place path without any of the real claim // machinery, since these tests are entirely about what the CLI does with // three different possible echoes. func fakeCreateEcho(t *testing.T, body string) *httptest.Server { t.Helper() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(body)) })) t.Cleanup(ts.Close) return ts } // TestRunAppsCreatePlacementVerification drives `carlos apps create -place // fleet/pi` as a subprocess against three canned console responses — the // three cases I1's review comment named by name. Subprocess because the // mismatch path calls log.Fatal (nonzero exit is the very thing under // test), which would kill an in-process test binary outright. func TestRunAppsCreatePlacementVerification(t *testing.T) { bin := buildCarlos(t) run := func(t *testing.T, consoleURL string) (string, error) { t.Helper() home := t.TempDir() creds := filepath.Join(home, "credentials") writeTestCredential(t, creds, consoleURL) cmd := exec.Command(bin, "apps", "create", "-app", "titogo", "-place", "fleet/pi", "-account", "bab") cmd.Env = append(os.Environ(), "CARLOS_CREDENTIALS="+creds, "HOME="+home, "CARLOS_CONSOLE=", ) cmd.Dir = home out, err := cmd.CombinedOutput() return string(out), err } t.Run("swallowed write: placement echoed empty", func(t *testing.T) { ts := fakeCreateEcho(t, `{"account":"bab","app":"titogo","url":"http://x/a/bab/apps/titogo","placement":""}`) out, err := run(t, ts.URL) if err == nil { t.Fatalf("exited 0, want nonzero (placement did not stick):\n%s", out) } if !strings.Contains(out, "did NOT stick") { t.Fatalf("output missing the did-NOT-stick warning:\n%s", out) } if !strings.Contains(out, "carlos apps place --app titogo --target fleet/pi") { t.Fatalf("output missing the exact remedy command:\n%s", out) } }) t.Run("old console: placement key absent entirely", func(t *testing.T) { ts := fakeCreateEcho(t, `{"account":"bab","app":"titogo","url":"http://x/a/bab/apps/titogo"}`) out, err := run(t, ts.URL) if err == nil { t.Fatalf("exited 0, want nonzero (no placement key at all — an old console's response shape):\n%s", out) } if !strings.Contains(out, "did NOT stick") || !strings.Contains(out, "carlos apps place --app titogo --target fleet/pi") { t.Fatalf("output missing the warning/remedy:\n%s", out) } }) t.Run("happy path: echo matches", func(t *testing.T) { ts := fakeCreateEcho(t, `{"account":"bab","app":"titogo","url":"http://x/a/bab/apps/titogo","placement":"fleet/pi","region":"uk","region_name":"UK · London","region_source":"timezone","regions_available":2}`) out, err := run(t, ts.URL) if err != nil { t.Fatalf("exited nonzero, want 0:\n%s", out) } if !strings.Contains(out, "Region: UK · London (from your timezone). Use --region to choose another.\n") { t.Fatalf("output missing the region line:\n%s", out) } if !strings.Contains(out, "placed on fleet/pi") { t.Fatalf("output missing the placed confirmation:\n%s", out) } }) } // A create that names a fleet (--place fleet/) is placed by the fleet, // so a switch-off console's "--region was not applied" (C13b) would be wrong // there: it prints nothing. A plain create with the same response still says // it (C13b). func TestRunAppsCreateRegionNoticeOnFleet(t *testing.T) { bin := buildCarlos(t) const c13b = "This console is not assigning app regions yet, so --region was not applied." run := func(t *testing.T, body string, extra ...string) string { t.Helper() ts := fakeCreateEcho(t, body) home := t.TempDir() creds := filepath.Join(home, "credentials") writeTestCredential(t, creds, ts.URL) args := append([]string{"apps", "create", "-app", "titogo", "-account", "bab", "-region", "uk"}, extra...) cmd := exec.Command(bin, args...) cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+creds, "HOME="+home) cmd.Dir = home out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("exited nonzero: %v\n%s", err, out) } return string(out) } const switchOff = `"region":"ie","region_name":"Ireland · Dublin","region_source":"default","regions_available":2` t.Run("fleet placement: no C13b", func(t *testing.T) { out := run(t, `{"account":"bab","app":"titogo","url":"http://x/a/bab/apps/titogo","placement":"fleet/pi",`+switchOff+`}`, "-place", "fleet/pi") if strings.Contains(out, c13b) { t.Fatalf("C13b printed for a fleet placement:\n%s", out) } if !strings.Contains(out, "placed on fleet/pi") { t.Fatalf("output missing the placed confirmation:\n%s", out) } }) t.Run("no fleet: C13b", func(t *testing.T) { out := run(t, `{"account":"bab","app":"titogo","url":"http://x/a/bab/apps/titogo",`+switchOff+`}`) if !strings.Contains(out, c13b) { t.Fatalf("C13b missing for a plain create:\n%s", out) } }) }