carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Tests for the `carlos fleets` verbs and `carlos apps place` — the CLI
2// door onto internal/console/fleets.go (Task 7). Three layers:
3//
4// - apiSession-level tests drive the six new client methods (apimode.go)
5// directly against an httptest fake console: path, body, and wire-shape
6// round trips, with no flag parsing or os.Exit in the loop.
7// - RunFleets/RunApps-level tests drive the full verb (flag parsing,
8// mode resolution, printing) with GOOD arguments only, against the
9// same kind of fake console, credentials written straight to disk
10// (mirroring auth_e2e_test.go's TestAccountsCreateUsesTheLoggedInIdentity
11// rather than a real login round trip) — this is what proves the token
12// really reaches stdout exactly once and the detach-not-rotate warning
13// is really in the output.
14// - one subprocess test (TestFleetsAndPlaceCLIUsageErrors, buildCarlos-
15// style like auth_e2e_test.go) drives every verb's BAD-argument path:
16// those call log.Fatal/os.Exit, which would kill the test binary if
17// driven in-process (the reason instances_cli_test.go gives for
18// skipping runInstancesEnable's own argv parsing) — a real subprocess
19// is the only way to observe "exits nonzero and says usage" without
20// restructuring the verbs around it.
21package main
22
23import (
24 "context"
25 "encoding/json"
26 "net/http"
27 "net/http/httptest"
28 "os"
29 "os/exec"
30 "path/filepath"
31 "strings"
32 "sync/atomic"
33 "testing"
34 "time"
35)
36
37// ---- pure grammar gates -----------------------------------------------------
38
39func TestValidFleetNameAndBoxLabel(t *testing.T) {
40 for _, good := range []string{"acme", "pi-1", "a", strings.Repeat("a", 32)} {
41 if err := validFleetName(good); err != nil {
42 t.Errorf("validFleetName(%q) = %v, want nil", good, err)
43 }
44 if err := validBoxLabel(good); err != nil {
45 t.Errorf("validBoxLabel(%q) = %v, want nil", good, err)
46 }
47 }
48 for _, bad := range []string{"", "Acme", "has spaces", "under_score", strings.Repeat("a", 33)} {
49 if err := validFleetName(bad); err == nil {
50 t.Errorf("validFleetName(%q) = nil, want an error", bad)
51 }
52 if err := validBoxLabel(bad); err == nil {
53 t.Errorf("validBoxLabel(%q) = nil, want an error", bad)
54 }
55 }
56}
57
58func TestValidPlaceTarget(t *testing.T) {
59 for _, good := range []string{"", "fleet/pi", "fleet/acme-1"} {
60 if err := validPlaceTarget(good); err != nil {
61 t.Errorf("validPlaceTarget(%q) = %v, want nil", good, err)
62 }
63 }
64 for _, bad := range []string{"eu", "fleet/", "fleet/Acme", "pi"} {
65 if err := validPlaceTarget(bad); err == nil {
66 t.Errorf("validPlaceTarget(%q) = nil, want an error", bad)
67 }
68 }
69}
70
71// ---- apiSession-level: path + body + wire-shape round trips ---------------
72
73// TestAPISessionFleetCreateAndList: createFleet posts to the literal `new`
74// door with name+bucket in the body and unmarshals the "fleet" wrapper;
75// listFleets GETs the account door and unmarshals "fleets".
76func TestAPISessionFleetCreateAndList(t *testing.T) {
77 var gotPath, gotMethod string
78 var gotBody map[string]string
79 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
80 gotPath, gotMethod = r.URL.Path, r.Method
81 if r.Method == http.MethodPost {
82 json.NewDecoder(r.Body).Decode(&gotBody)
83 json.NewEncoder(w).Encode(map[string]any{
84 "fleet": map[string]any{"name": gotBody["name"], "bucket": gotBody["bucket"], "boxes": []any{}},
85 })
86 return
87 }
88 json.NewEncoder(w).Encode(map[string]any{
89 "fleets": []map[string]any{
90 {"name": "acme", "bucket": "acme-data", "boxes": []map[string]any{
91 {"label": "pi-1", "status": "active"},
92 }},
93 },
94 })
95 }))
96 defer ts.Close()
97 s := newTestSession(ts.URL)
98
99 f, err := s.createFleet(context.Background(), apiAcct{Seg: "bab"}, "acme", "acme-data")
100 if err != nil {
101 t.Fatalf("createFleet: %v", err)
102 }
103 if gotPath != "/api/cli/fleets/bab/new" || gotMethod != http.MethodPost {
104 t.Fatalf("request = %s %s, want POST /api/cli/fleets/bab/new", gotMethod, gotPath)
105 }
106 if gotBody["name"] != "acme" || gotBody["bucket"] != "acme-data" {
107 t.Fatalf("body = %v", gotBody)
108 }
109 if f.Name != "acme" || f.Bucket != "acme-data" || len(f.Boxes) != 0 {
110 t.Fatalf("createFleet result = %+v", f)
111 }
112
113 fleets, err := s.listFleets(context.Background(), apiAcct{Seg: "bab"})
114 if err != nil {
115 t.Fatalf("listFleets: %v", err)
116 }
117 if gotPath != "/api/cli/fleets/bab" || gotMethod != http.MethodGet {
118 t.Fatalf("request = %s %s, want GET /api/cli/fleets/bab", gotMethod, gotPath)
119 }
120 if len(fleets) != 1 || fleets[0].Name != "acme" || len(fleets[0].Boxes) != 1 || fleets[0].Boxes[0].Label != "pi-1" {
121 t.Fatalf("listFleets result = %+v", fleets)
122 }
123}
124
125// TestAPISessionFleetCreateExactAccountID: -account-id travels as
126// exact=sqid, same as every other apiPath-built call.
127func TestAPISessionFleetCreateExactAccountID(t *testing.T) {
128 var gotQuery string
129 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
130 gotQuery = r.URL.RawQuery
131 json.NewEncoder(w).Encode(map[string]any{"fleet": map[string]any{"name": "x", "bucket": "y", "boxes": []any{}}})
132 }))
133 defer ts.Close()
134 s := newTestSession(ts.URL)
135 if _, err := s.createFleet(context.Background(), apiAcct{Seg: "bab", Exact: true}, "x", "y"); err != nil {
136 t.Fatalf("createFleet: %v", err)
137 }
138 if gotQuery != "exact=sqid" {
139 t.Fatalf("query = %q, want exact=sqid", gotQuery)
140 }
141}
142
143// TestAPISessionFleetCreateDuplicateName: the server's 409 sentence surfaces
144// verbatim, same convention as apps create's duplicate refusal.
145func TestAPISessionFleetCreateDuplicateName(t *testing.T) {
146 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
147 w.WriteHeader(http.StatusConflict)
148 json.NewEncoder(w).Encode(map[string]string{"error": "that name is taken"})
149 }))
150 defer ts.Close()
151 s := newTestSession(ts.URL)
152 if _, err := s.createFleet(context.Background(), apiAcct{Seg: "bab"}, "acme", "d"); err == nil ||
153 err.Error() != "that name is taken" {
154 t.Fatalf("createFleet duplicate = %v, want the server's sentence", err)
155 }
156}
157
158// TestAPISessionFleetAddBoxRotateDetach: add-box and rotate-token both POST
159// to their own path and return the plaintext token in the "token" field;
160// detach-box POSTs and returns no token at all.
161func TestAPISessionFleetAddBoxRotateDetach(t *testing.T) {
162 var gotPath, gotLabel string
163 step := "add"
164 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
165 gotPath = r.URL.Path
166 switch step {
167 case "add":
168 var req struct{ Label string }
169 json.NewDecoder(r.Body).Decode(&req)
170 gotLabel = req.Label
171 json.NewEncoder(w).Encode(map[string]any{"label": req.Label, "token": "tok-first-64hex"})
172 case "rotate":
173 json.NewEncoder(w).Encode(map[string]any{"token": "tok-second-64hex"})
174 case "detach":
175 json.NewEncoder(w).Encode(map[string]any{})
176 }
177 }))
178 defer ts.Close()
179 s := newTestSession(ts.URL)
180 acct := apiAcct{Seg: "bab"}
181
182 tok, err := s.addFleetBox(context.Background(), acct, "acme", "pi-1")
183 if err != nil {
184 t.Fatalf("addFleetBox: %v", err)
185 }
186 if gotPath != "/api/cli/fleets/bab/acme/boxes" || gotLabel != "pi-1" || tok != "tok-first-64hex" {
187 t.Fatalf("addFleetBox: path=%s label=%s token=%s", gotPath, gotLabel, tok)
188 }
189
190 step = "rotate"
191 tok, err = s.rotateFleetBoxToken(context.Background(), acct, "acme", "pi-1")
192 if err != nil {
193 t.Fatalf("rotateFleetBoxToken: %v", err)
194 }
195 if gotPath != "/api/cli/fleets/bab/acme/boxes/pi-1/rotate" || tok != "tok-second-64hex" {
196 t.Fatalf("rotateFleetBoxToken: path=%s token=%s", gotPath, tok)
197 }
198
199 step = "detach"
200 if err := s.detachFleetBox(context.Background(), acct, "acme", "pi-1"); err != nil {
201 t.Fatalf("detachFleetBox: %v", err)
202 }
203 if gotPath != "/api/cli/fleets/bab/acme/boxes/pi-1/detach" {
204 t.Fatalf("detachFleetBox path = %s", gotPath)
205 }
206}
207
208// TestAPISessionFleetDoorsSurfaceServerErrors: a non-200 from any of the
209// three box doors surfaces the server's own message, not a swallowed one.
210func TestAPISessionFleetDoorsSurfaceServerErrors(t *testing.T) {
211 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
212 w.WriteHeader(http.StatusNotFound)
213 w.Write([]byte("404 page not found\n"))
214 }))
215 defer ts.Close()
216 s := newTestSession(ts.URL)
217 acct := apiAcct{Seg: "bab"}
218
219 if _, err := s.addFleetBox(context.Background(), acct, "ghost", "x"); err == nil {
220 t.Fatal("addFleetBox against a 404 must error")
221 }
222 if _, err := s.rotateFleetBoxToken(context.Background(), acct, "ghost", "x"); err == nil {
223 t.Fatal("rotateFleetBoxToken against a 404 must error")
224 }
225 if err := s.detachFleetBox(context.Background(), acct, "ghost", "x"); err == nil {
226 t.Fatal("detachFleetBox against a 404 must error")
227 }
228 if err := s.tagFleetBox(context.Background(), acct, "ghost", "x", "office"); err == nil {
229 t.Fatal("tagFleetBox against a 404 must error")
230 }
231}
232
233// TestAPISessionFleetTagBox: tagFleetBox POSTs to the labelled tags door
234// with the tags field in the body (full replace, including the "" clearing
235// case), and returns no token — unlike add-box/rotate-token, this door's
236// response body carries nothing this client reads.
237func TestAPISessionFleetTagBox(t *testing.T) {
238 var gotPath string
239 var gotBody struct{ Tags string }
240 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
241 gotPath = r.URL.Path
242 json.NewDecoder(r.Body).Decode(&gotBody)
243 json.NewEncoder(w).Encode(map[string]any{})
244 }))
245 defer ts.Close()
246 s := newTestSession(ts.URL)
247 acct := apiAcct{Seg: "bab"}
248
249 if err := s.tagFleetBox(context.Background(), acct, "acme", "pi-1", "office,free"); err != nil {
250 t.Fatalf("tagFleetBox: %v", err)
251 }
252 if gotPath != "/api/cli/fleets/bab/acme/boxes/pi-1/tags" || gotBody.Tags != "office,free" {
253 t.Fatalf("tagFleetBox request: path=%s body=%+v", gotPath, gotBody)
254 }
255
256 if err := s.tagFleetBox(context.Background(), acct, "acme", "pi-1", ""); err != nil {
257 t.Fatalf("tagFleetBox clear: %v", err)
258 }
259 if gotBody.Tags != "" {
260 t.Fatalf("tagFleetBox clear sent tags=%q, want empty", gotBody.Tags)
261 }
262}
263
264// TestAPISessionPlace: place POSTs the target in the body to .../place, for
265// both a fleet target and the clearing "".
266func TestAPISessionPlace(t *testing.T) {
267 var gotPath string
268 var gotTarget string
269 var sawTarget bool
270 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
271 gotPath = r.URL.Path
272 var req struct {
273 Target string `json:"target"`
274 }
275 json.NewDecoder(r.Body).Decode(&req)
276 gotTarget, sawTarget = req.Target, true
277 json.NewEncoder(w).Encode(map[string]any{})
278 }))
279 defer ts.Close()
280 s := newTestSession(ts.URL)
281
282 if err := s.place(context.Background(), apiAcct{Seg: "bab"}, "titogo", "fleet/pi"); err != nil {
283 t.Fatalf("place: %v", err)
284 }
285 if gotPath != "/api/cli/apps/bab/titogo/place" || !sawTarget || gotTarget != "fleet/pi" {
286 t.Fatalf("place request: path=%s target=%q sawTarget=%v", gotPath, gotTarget, sawTarget)
287 }
288
289 if err := s.place(context.Background(), apiAcct{Seg: "bab"}, "titogo", ""); err != nil {
290 t.Fatalf("place clear: %v", err)
291 }
292 if gotTarget != "" {
293 t.Fatalf("clearing place: target=%q, want empty", gotTarget)
294 }
295}
296
297// TestAPISessionPlaceSurfacesRefusal: an invalid-shape 400 and a
298// foreign-fleet 404 both surface through place unchanged.
299func TestAPISessionPlaceSurfacesRefusal(t *testing.T) {
300 status := http.StatusBadRequest
301 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
302 if status == http.StatusNotFound {
303 http.NotFound(w, r)
304 return
305 }
306 w.WriteHeader(status)
307 json.NewEncoder(w).Encode(map[string]string{"error": `invalid target — want "" or "fleet/<name>"`})
308 }))
309 defer ts.Close()
310 s := newTestSession(ts.URL)
311
312 if err := s.place(context.Background(), apiAcct{Seg: "bab"}, "titogo", "eu"); err == nil ||
313 !strings.Contains(err.Error(), "invalid target") {
314 t.Fatalf("place with a bad shape = %v, want the server's message", err)
315 }
316
317 status = http.StatusNotFound
318 if err := s.place(context.Background(), apiAcct{Seg: "bab"}, "titogo", "fleet/ghost"); err == nil {
319 t.Fatal("place onto a foreign/unknown fleet must error")
320 }
321}
322
323// ---- RunFleets / RunApps-level: full verb, good args, real printing -------
324
325// fakeFleetsConsole answers the CLI endpoints RunFleets/apps-create/apps-place
326// reach, stateful enough to prove add-box then rotate-token return DISTINCT
327// tokens and that create-with-place's "place" field actually arrives.
328type fakeFleetsConsole struct {
329 ts *httptest.Server
330
331 tokenSeq atomic.Int64
332
333 lastAuth atomic.Value // string
334
335 // what the last relevant call carried, for assertions.
336 lastCreateBucket atomic.Value // string
337 lastCreatePlace atomic.Value // string
338 lastPlaceTarget atomic.Value // string
339 lastDetachPath atomic.Value // string
340 lastTagPath atomic.Value // string
341 lastTagBody atomic.Value // string
342}
343
344func newFakeFleetsConsole(t *testing.T) *fakeFleetsConsole {
345 t.Helper()
346 fc := &fakeFleetsConsole{}
347 mux := http.NewServeMux()
348 mux.HandleFunc("POST /api/cli/fleets/{account}/new", func(w http.ResponseWriter, r *http.Request) {
349 fc.lastAuth.Store(r.Header.Get("Authorization"))
350 var req struct{ Name, Bucket string }
351 json.NewDecoder(r.Body).Decode(&req)
352 fc.lastCreateBucket.Store(req.Bucket)
353 json.NewEncoder(w).Encode(map[string]any{
354 "fleet": map[string]any{"name": req.Name, "bucket": req.Bucket, "boxes": []any{}},
355 })
356 })
357 mux.HandleFunc("GET /api/cli/fleets/{account}", func(w http.ResponseWriter, r *http.Request) {
358 json.NewEncoder(w).Encode(map[string]any{
359 "fleets": []map[string]any{
360 {"name": "acme", "bucket": "acme-data", "boxes": []map[string]any{
361 {"label": "pi-1", "status": "active", "tags": "office,free"},
362 {"label": "pi-2", "status": "detached"},
363 }},
364 },
365 })
366 })
367 mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes", func(w http.ResponseWriter, r *http.Request) {
368 var req struct{ Label string }
369 json.NewDecoder(r.Body).Decode(&req)
370 n := fc.tokenSeq.Add(1)
371 json.NewEncoder(w).Encode(map[string]any{
372 "label": req.Label, "token": tokenFor(n),
373 })
374 })
375 mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/rotate", func(w http.ResponseWriter, r *http.Request) {
376 n := fc.tokenSeq.Add(1)
377 json.NewEncoder(w).Encode(map[string]any{"token": tokenFor(n)})
378 })
379 mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/detach", func(w http.ResponseWriter, r *http.Request) {
380 fc.lastDetachPath.Store(r.URL.Path)
381 json.NewEncoder(w).Encode(map[string]any{})
382 })
383 mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/tags", func(w http.ResponseWriter, r *http.Request) {
384 var req struct{ Tags string }
385 json.NewDecoder(r.Body).Decode(&req)
386 fc.lastTagPath.Store(r.URL.Path)
387 fc.lastTagBody.Store(req.Tags)
388 json.NewEncoder(w).Encode(map[string]any{})
389 })
390 mux.HandleFunc("POST /api/cli/apps/{account}/new", func(w http.ResponseWriter, r *http.Request) {
391 var req struct{ Name, Place string }
392 json.NewDecoder(r.Body).Decode(&req)
393 fc.lastCreatePlace.Store(req.Place)
394 // Echoes place back as "placement" — this fake stands in for a
395 // CURRENT console, which always echoes what it actually stored
396 // (handleAPICreateApp, internal/console/apicli.go); the mismatch/
397 // absent-field cases that echo is FOR are exercised separately by
398 // TestRunAppsCreatePlacementVerification's own canned-response fakes.
399 json.NewEncoder(w).Encode(map[string]any{
400 "account": "bab", "app": req.Name, "url": fc.ts.URL + "/a/bab/apps/" + req.Name,
401 "placement": req.Place,
402 })
403 })
404 mux.HandleFunc("POST /api/cli/apps/{account}/{app}/place", func(w http.ResponseWriter, r *http.Request) {
405 var req struct{ Target string }
406 json.NewDecoder(r.Body).Decode(&req)
407 fc.lastPlaceTarget.Store(req.Target)
408 json.NewEncoder(w).Encode(map[string]any{})
409 })
410 fc.ts = httptest.NewServer(mux)
411 t.Cleanup(fc.ts.Close)
412 return fc
413}
414
415// tokenFor mints a distinguishable fake token per call so a test can prove
416// add-box and a later rotate-token did NOT return the same value.
417func tokenFor(n int64) string {
418 return strings.Repeat("a", 60) + "-" + string(rune('0'+n%10))
419}
420
421// writeTestCredential drops a credential straight on disk for console at
422// url with a fixed bearer, the same shortcut
423// TestAccountsCreateUsesTheLoggedInIdentity (auth_e2e_test.go) takes to
424// avoid a login round trip in a test that isn't about login.
425func writeTestCredential(t *testing.T, path, url string) {
426 t.Helper()
427 f := &credentialFile{Version: 1, Credentials: []credential{{
428 Console: url, Address: "paul@keymail.dev", Token: "cli-token",
429 CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().Add(24 * time.Hour).UTC(),
430 }}}
431 raw, err := json.Marshal(f)
432 if err != nil {
433 t.Fatal(err)
434 }
435 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
436 t.Fatal(err)
437 }
438 if err := os.WriteFile(path, raw, 0o600); err != nil {
439 t.Fatal(err)
440 }
441}
442
443// fleetsCLIFixture wires up a fake console plus a credential pointed at it,
444// entirely in-process (no subprocess, no real login flow) — the "package
445// level tests against httptest console servers" shape.
446func fleetsCLIFixture(t *testing.T) *fakeFleetsConsole {
447 t.Helper()
448 home := t.TempDir()
449 t.Setenv("HOME", home)
450 t.Setenv("CARLOS_CONSOLE", "")
451 t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
452 t.Setenv("CARLOS_DEPLOYMENT_DIR", "")
453 t.Chdir(t.TempDir())
454 creds := filepath.Join(home, "carlos-credentials")
455 t.Setenv("CARLOS_CREDENTIALS", creds)
456 fc := newFakeFleetsConsole(t)
457 writeTestCredential(t, creds, fc.ts.URL)
458 return fc
459}
460
461// TestRunFleetsCreateHitsRightPathWithBody drives `carlos fleets create`
462// with good args end to end (flag parsing included) and checks the console
463// saw the right bucket and the verb printed the fleet name back.
464func TestRunFleetsCreateHitsRightPathWithBody(t *testing.T) {
465 fc := fleetsCLIFixture(t)
466 buf := captureStdout(t)
467
468 RunFleets([]string{"create", "-bucket", "acme-data", "-account", "bab", "acme"})
469
470 if got := fc.lastCreateBucket.Load(); got != "acme-data" {
471 t.Fatalf("console saw bucket %v, want acme-data", got)
472 }
473 if !strings.Contains(buf.String(), "created fleet acme") {
474 t.Fatalf("stdout = %q, want it to confirm the created fleet", buf.String())
475 }
476}
477
478// TestRunFleetsAddBoxPrintsTokenOnceWithDetachWarning: add-box's token
479// appears in stdout EXACTLY ONCE, and the output carries the detach-not-
480// rotate warning verbatim (Task 3's review finding, carried into this
481// task's brief).
482func TestRunFleetsAddBoxPrintsTokenOnceWithDetachWarning(t *testing.T) {
483 fleetsCLIFixture(t)
484 buf := captureStdout(t)
485
486 RunFleets([]string{"add-box", "-account", "bab", "acme", "pi-1"})
487
488 out := buf.String()
489 tok := tokenFor(1)
490 if n := strings.Count(out, tok); n != 1 {
491 t.Fatalf("token %q appeared %d times in stdout, want exactly 1:\n%s", tok, n, out)
492 }
493 if !strings.Contains(out, "detach-box") {
494 t.Fatalf("stdout missing the detach-box pointer:\n%s", out)
495 }
496 if !strings.Contains(strings.ToLower(out), "not evict") {
497 t.Fatalf("stdout missing the does-NOT-evict-a-live-session warning:\n%s", out)
498 }
499}
500
501// TestRunFleetsRotateTokenPrintsFreshTokenOnceWithWarning: rotate-token's
502// output carries the SAME warning as add-box's (rotation refuses the next
503// dial-in but does not evict a live channel), and a token distinct from a
504// prior add-box's.
505func TestRunFleetsRotateTokenPrintsFreshTokenOnceWithWarning(t *testing.T) {
506 fleetsCLIFixture(t)
507 addBuf := captureStdout(t)
508 RunFleets([]string{"add-box", "-account", "bab", "acme", "pi-1"})
509 firstToken := tokenFor(1)
510 if !strings.Contains(addBuf.String(), firstToken) {
511 t.Fatalf("add-box did not print its own token:\n%s", addBuf.String())
512 }
513
514 rotBuf := captureStdout(t)
515 RunFleets([]string{"rotate-token", "-account", "bab", "acme", "pi-1"})
516 out := rotBuf.String()
517 secondToken := tokenFor(2)
518 if n := strings.Count(out, secondToken); n != 1 {
519 t.Fatalf("rotated token %q appeared %d times, want exactly 1:\n%s", secondToken, n, out)
520 }
521 if strings.Contains(out, firstToken) {
522 t.Fatalf("rotate-token output leaked the OLD token:\n%s", out)
523 }
524 if !strings.Contains(out, "detach-box") || !strings.Contains(strings.ToLower(out), "not evict") {
525 t.Fatalf("rotate-token stdout missing the detach-not-rotate warning:\n%s", out)
526 }
527}
528
529// TestRunFleetsDetachBoxHitsRightPath: detach-box reaches the right door and
530// confirms in its own words (not the token-print wording).
531func TestRunFleetsDetachBoxHitsRightPath(t *testing.T) {
532 fc := fleetsCLIFixture(t)
533 buf := captureStdout(t)
534
535 RunFleets([]string{"detach-box", "-account", "bab", "acme", "pi-1"})
536
537 if got := fc.lastDetachPath.Load(); got != "/api/cli/fleets/bab/acme/boxes/pi-1/detach" {
538 t.Fatalf("detach path = %v, want the labelled detach door", got)
539 }
540 if !strings.Contains(buf.String(), "detached acme/pi-1") {
541 t.Fatalf("stdout = %q, want it to confirm the detach", buf.String())
542 }
543}
544
545// TestRunFleetsListPrintsEveryBox: `carlos fleets ls` prints one row per
546// box, including a detached one, from the fake console's fixed listing —
547// and a TAGS column: the tagged box's tags verbatim, the untagged box's "-".
548func TestRunFleetsListPrintsEveryBox(t *testing.T) {
549 fleetsCLIFixture(t)
550 buf := captureStdout(t)
551
552 RunFleets([]string{"ls", "-account", "bab"})
553
554 out := buf.String()
555 for _, want := range []string{"acme", "acme-data", "pi-1", "active", "pi-2", "detached", "TAGS", "office,free"} {
556 if !strings.Contains(out, want) {
557 t.Fatalf("fleets ls stdout missing %q:\n%s", want, out)
558 }
559 }
560 // pi-2 carries no tags — its row must show the placeholder, not a blank
561 // cell (which whitespace-splitting could not tell apart from a missing
562 // column).
563 for _, line := range strings.Split(out, "\n") {
564 if !strings.Contains(line, "pi-2") {
565 continue
566 }
567 fields := strings.Fields(line)
568 if len(fields) == 0 || fields[len(fields)-1] != "-" {
569 t.Fatalf("pi-2 row = %q, want its last (TAGS) field to be the '-' placeholder", line)
570 }
571 }
572}
573
574// TestRunFleetsTagBoxHitsRightPathAndClears: `carlos fleets tag-box` PUTs
575// (POSTs) the tags in the body to the labelled tags door, full-replace, and
576// -tags "" clears — both print their own confirmation, mirroring
577// TestRunAppsPlaceHitsRightPathAndClears's two-call shape.
578func TestRunFleetsTagBoxHitsRightPathAndClears(t *testing.T) {
579 fc := fleetsCLIFixture(t)
580 buf := captureStdout(t)
581
582 RunFleets([]string{"tag-box", "-tags", "office,free", "-account", "bab", "acme", "pi-1"})
583 if got := fc.lastTagPath.Load(); got != "/api/cli/fleets/bab/acme/boxes/pi-1/tags" {
584 t.Fatalf("tag-box path = %v, want the labelled tags door", got)
585 }
586 if got := fc.lastTagBody.Load(); got != "office,free" {
587 t.Fatalf("console saw tags %v, want office,free", got)
588 }
589 if !strings.Contains(buf.String(), "tagged acme/pi-1 [office,free]") {
590 t.Fatalf("stdout = %q, want it to confirm the tags", buf.String())
591 }
592
593 buf2 := captureStdout(t)
594 RunFleets([]string{"tag-box", "-tags", "", "-account", "bab", "acme", "pi-1"})
595 if got := fc.lastTagBody.Load(); got != "" {
596 t.Fatalf("console saw tags %v, want \"\" (cleared)", got)
597 }
598 if !strings.Contains(buf2.String(), "cleared tags for acme/pi-1") {
599 t.Fatalf("stdout = %q, want the cleared confirmation", buf2.String())
600 }
601}
602
603// TestRunAppsCreateWithPlaceSendsPlaceField: `carlos apps create -place
604// fleet/<f>` reaches the console with place in the body (the 2026-08-06
605// ruling's CLI surface).
606func TestRunAppsCreateWithPlaceSendsPlaceField(t *testing.T) {
607 fc := fleetsCLIFixture(t)
608 buf := captureStdout(t)
609
610 RunApps([]string{"create", "-app", "titogo", "-place", "fleet/pi", "-account", "bab"})
611
612 if got := fc.lastCreatePlace.Load(); got != "fleet/pi" {
613 t.Fatalf("console saw place %v, want fleet/pi", got)
614 }
615 if !strings.Contains(buf.String(), "placed on fleet/pi") {
616 t.Fatalf("stdout = %q, want it to confirm the placement", buf.String())
617 }
618}
619
620// TestRunAppsPlaceHitsRightPathAndClears: `carlos apps place` sends the
621// target, and passing "" clears it — both print their own confirmation.
622func TestRunAppsPlaceHitsRightPathAndClears(t *testing.T) {
623 fc := fleetsCLIFixture(t)
624 buf := captureStdout(t)
625
626 RunApps([]string{"place", "-app", "titogo", "-target", "fleet/pi", "-account", "bab"})
627 if got := fc.lastPlaceTarget.Load(); got != "fleet/pi" {
628 t.Fatalf("console saw target %v, want fleet/pi", got)
629 }
630 if !strings.Contains(buf.String(), "placed titogo on fleet/pi") {
631 t.Fatalf("stdout = %q, want the placed confirmation", buf.String())
632 }
633
634 buf2 := captureStdout(t)
635 RunApps([]string{"place", "-app", "titogo", "-target", "", "-account", "bab"})
636 if got := fc.lastPlaceTarget.Load(); got != "" {
637 t.Fatalf("console saw target %v, want \"\" (cleared)", got)
638 }
639 if !strings.Contains(buf2.String(), "cleared placement for titogo") {
640 t.Fatalf("stdout = %q, want the cleared confirmation", buf2.String())
641 }
642}
643
644// ---- subprocess: the usage-error / bad-argument paths ---------------------
645
646// TestFleetsAndPlaceCLIUsageErrors drives every fleets verb and apps place
647// with missing or malshaped arguments as a real subprocess (buildCarlos,
648// same helper auth_e2e_test.go's TestAuthCLI uses) — the ONLY way to observe
649// a log.Fatal/os.Exit refusal without killing the test binary. Every case
650// here must exit nonzero without ever reaching the network: carlosTestEnv
651// masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE and cmd.Dir is a scratch dir
652// (AGENTS.md's rule — a subprocess that inherited a real login has minted
653// real accounts on the flagship before), so a case that DID try to dial out
654// would fail on "not logged in", not silently succeed.
655func TestFleetsAndPlaceCLIUsageErrors(t *testing.T) {
656 bin := buildCarlos(t)
657 scratch := t.TempDir()
658 env := carlosTestEnv(t)
659
660 run := func(args ...string) (string, error) {
661 cmd := exec.Command(bin, args...)
662 cmd.Env = env
663 cmd.Dir = scratch
664 out, err := cmd.CombinedOutput()
665 return string(out), err
666 }
667
668 for _, tc := range []struct {
669 name string
670 args []string
671 want string // substring the combined output must contain
672 }{
673 {"fleets no args", []string{"fleets"}, "usage: carlos fleets"},
674 {"fleets bogus verb", []string{"fleets", "bogus"}, "usage: carlos fleets"},
675 {"fleets create no args", []string{"fleets", "create"}, "usage: carlos fleets create"},
676 {"fleets create missing bucket", []string{"fleets", "create", "-account", "bab", "acme"}, "usage: carlos fleets create"},
677 {"fleets create bad name", []string{"fleets", "create", "-bucket", "d", "-account", "bab", "Bad Name"}, "not a usable fleet name"},
678 {"fleets add-box no args", []string{"fleets", "add-box"}, "usage: carlos fleets add-box"},
679 {"fleets add-box missing label", []string{"fleets", "add-box", "acme"}, "usage: carlos fleets add-box"},
680 {"fleets add-box bad label", []string{"fleets", "add-box", "acme", "Bad Label"}, "not a usable box label"},
681 {"fleets rotate-token no args", []string{"fleets", "rotate-token"}, "usage: carlos fleets rotate-token"},
682 {"fleets tag-box no args", []string{"fleets", "tag-box"}, "usage: carlos fleets tag-box"},
683 {"fleets tag-box bad tags grammar", []string{"fleets", "tag-box", "-tags", "Office", "acme", "pi-1"}, "invalid tags"},
684 {"fleets tag-box bad fleet name", []string{"fleets", "tag-box", "-tags", "office", "Bad Name", "pi-1"}, "not a usable fleet name"},
685 // Review finding: -tags is TRI-state, the exact -target hazard the
686 // "apps place omitted target" case below pins. -tags defaults to ""
687 // and "" is the legal, meaningful "clear every tag" value, so an
688 // omitted -tags must NEVER be read as "clear" — that would silently
689 // destroy a box's tags on a forgotten flag. fleet/label ARE present
690 // here (only -tags is missing) so this fails on the flag, not the args.
691 {"fleets tag-box omitted --tags", []string{"fleets", "tag-box", "acme", "pi-1"}, "usage: carlos fleets tag-box"},
692 {"fleets detach-box no args", []string{"fleets", "detach-box"}, "usage: carlos fleets detach-box"},
693 {"apps place missing app", []string{"apps", "place", "-target", "fleet/pi"}, "usage: carlos apps place"},
694 {"apps place bad target shape", []string{"apps", "place", "-app", "titogo", "-target", "eu"}, "not a usable placement target"},
695 {"apps create bad place shape", []string{"apps", "create", "-app", "titogo", "-place", "eu"}, "not a usable placement target"},
696 // I2 (2026-08-06 review): -target is tri-state — omitting it must
697 // NEVER be read as "clear placement" (that would retire a fleet-
698 // placed app's routes on the fleet box's next reconcile), so an
699 // invocation with -app but no -target at all is a usage error, the
700 // same as omitting -app.
701 {"apps place omitted target", []string{"apps", "place", "-app", "titogo"}, "usage: carlos apps place"},
702 } {
703 t.Run(tc.name, func(t *testing.T) {
704 out, err := run(tc.args...)
705 if err == nil {
706 t.Fatalf("carlos %s exited 0, want a nonzero (usage/refusal) exit:\n%s", strings.Join(tc.args, " "), out)
707 }
708 if !strings.Contains(out, tc.want) {
709 t.Fatalf("carlos %s output missing %q:\n%s", strings.Join(tc.args, " "), tc.want, out)
710 }
711 })
712 }
713}
714
715// ---- I1: placement is verified, not just requested (2026-08-06 review) ---
716
717// fakeCreateEcho spins a minimal httptest console that answers ONLY POST
718// /api/cli/apps/{account}/new, with a fixed canned body — enough to drive
719// runAppsCreate's create-with-place path without any of the real claim
720// machinery, since these tests are entirely about what the CLI does with
721// three different possible echoes.
722func fakeCreateEcho(t *testing.T, body string) *httptest.Server {
723 t.Helper()
724 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
725 w.Header().Set("Content-Type", "application/json")
726 w.Write([]byte(body))
727 }))
728 t.Cleanup(ts.Close)
729 return ts
730}
731
732// TestRunAppsCreatePlacementVerification drives `carlos apps create -place
733// fleet/pi` as a subprocess against three canned console responses — the
734// three cases I1's review comment named by name. Subprocess because the
735// mismatch path calls log.Fatal (nonzero exit is the very thing under
736// test), which would kill an in-process test binary outright.
737func TestRunAppsCreatePlacementVerification(t *testing.T) {
738 bin := buildCarlos(t)
739
740 run := func(t *testing.T, consoleURL string) (string, error) {
741 t.Helper()
742 home := t.TempDir()
743 creds := filepath.Join(home, "credentials")
744 writeTestCredential(t, creds, consoleURL)
745 cmd := exec.Command(bin, "apps", "create", "-app", "titogo", "-place", "fleet/pi", "-account", "bab")
746 cmd.Env = append(os.Environ(),
747 "CARLOS_CREDENTIALS="+creds,
748 "HOME="+home,
749 "CARLOS_CONSOLE=",
750 )
751 cmd.Dir = home
752 out, err := cmd.CombinedOutput()
753 return string(out), err
754 }
755
756 t.Run("swallowed write: placement echoed empty", func(t *testing.T) {
757 ts := fakeCreateEcho(t, `{"account":"bab","app":"titogo","url":"http://x/a/bab/apps/titogo","placement":""}`)
758 out, err := run(t, ts.URL)
759 if err == nil {
760 t.Fatalf("exited 0, want nonzero (placement did not stick):\n%s", out)
761 }
762 if !strings.Contains(out, "did NOT stick") {
763 t.Fatalf("output missing the did-NOT-stick warning:\n%s", out)
764 }
765 if !strings.Contains(out, "carlos apps place --app titogo --target fleet/pi") {
766 t.Fatalf("output missing the exact remedy command:\n%s", out)
767 }
768 })
769
770 t.Run("old console: placement key absent entirely", func(t *testing.T) {
771 ts := fakeCreateEcho(t, `{"account":"bab","app":"titogo","url":"http://x/a/bab/apps/titogo"}`)
772 out, err := run(t, ts.URL)
773 if err == nil {
774 t.Fatalf("exited 0, want nonzero (no placement key at all — an old console's response shape):\n%s", out)
775 }
776 if !strings.Contains(out, "did NOT stick") || !strings.Contains(out, "carlos apps place --app titogo --target fleet/pi") {
777 t.Fatalf("output missing the warning/remedy:\n%s", out)
778 }
779 })
780
781 t.Run("happy path: echo matches", func(t *testing.T) {
782 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}`)
783 out, err := run(t, ts.URL)
784 if err != nil {
785 t.Fatalf("exited nonzero, want 0:\n%s", out)
786 }
787 if !strings.Contains(out, "Region: UK · London (from your timezone). Use --region to choose another.\n") {
788 t.Fatalf("output missing the region line:\n%s", out)
789 }
790 if !strings.Contains(out, "placed on fleet/pi") {
791 t.Fatalf("output missing the placed confirmation:\n%s", out)
792 }
793 })
794}
795
796// A create that names a fleet (--place fleet/<name>) is placed by the fleet,
797// so a switch-off console's "--region was not applied" (C13b) would be wrong
798// there: it prints nothing. A plain create with the same response still says
799// it (C13b).
800func TestRunAppsCreateRegionNoticeOnFleet(t *testing.T) {
801 bin := buildCarlos(t)
802 const c13b = "This console is not assigning app regions yet, so --region was not applied."
803
804 run := func(t *testing.T, body string, extra ...string) string {
805 t.Helper()
806 ts := fakeCreateEcho(t, body)
807 home := t.TempDir()
808 creds := filepath.Join(home, "credentials")
809 writeTestCredential(t, creds, ts.URL)
810 args := append([]string{"apps", "create", "-app", "titogo", "-account", "bab", "-region", "uk"}, extra...)
811 cmd := exec.Command(bin, args...)
812 cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+creds, "HOME="+home)
813 cmd.Dir = home
814 out, err := cmd.CombinedOutput()
815 if err != nil {
816 t.Fatalf("exited nonzero: %v\n%s", err, out)
817 }
818 return string(out)
819 }
820 const switchOff = `"region":"ie","region_name":"Ireland · Dublin","region_source":"default","regions_available":2`
821
822 t.Run("fleet placement: no C13b", func(t *testing.T) {
823 out := run(t, `{"account":"bab","app":"titogo","url":"http://x/a/bab/apps/titogo","placement":"fleet/pi",`+switchOff+`}`, "-place", "fleet/pi")
824 if strings.Contains(out, c13b) {
825 t.Fatalf("C13b printed for a fleet placement:\n%s", out)
826 }
827 if !strings.Contains(out, "placed on fleet/pi") {
828 t.Fatalf("output missing the placed confirmation:\n%s", out)
829 }
830 })
831 t.Run("no fleet: C13b", func(t *testing.T) {
832 out := run(t, `{"account":"bab","app":"titogo","url":"http://x/a/bab/apps/titogo",`+switchOff+`}`)
833 if !strings.Contains(out, c13b) {
834 t.Fatalf("C13b missing for a plain create:\n%s", out)
835 }
836 })
837}
838