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 services` verbs — the CLI door onto
2// internal/console/apiservices.go (Task 7). Same three layers
3// fleets_cli_test.go uses, for the same reasons (see that file's package
4// doc):
5//
6// - apiSession-level tests drive the four new client methods (apimode.go)
7// directly against an httptest fake console: path, body and wire-shape
8// round trips, with no flag parsing or os.Exit in the loop.
9// - RunServices-level tests drive the full verb (flag parsing, mode
10// resolution, printing) with GOOD arguments only, against the same kind
11// of fake console, credentials written straight to disk (mirroring
12// fleetsCLIFixture) — this is what proves the secret really reaches
13// stdout exactly once, the door list prints, and the rotate/revoke
14// wording is really in the output.
15// - subprocess tests (buildCarlos-style, like auth_e2e_test.go and
16// fleets_cli_test.go's TestFleetsAndPlaceCLIUsageErrors) drive the
17// bad-argument paths, which call log.Fatal/os.Exit and would kill an
18// in-process test binary outright.
19//
20// EVERY test here that shells out to the built carlos binary builds its env
21// through carlosTestEnv (masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE) and
22// sets cmd.Dir to its own scratch dir — AGENTS.md's rule, restated in this
23// task's brief: a subprocess that inherited a real login has minted real
24// accounts on the flagship before.
25package main
26
27import (
28 "context"
29 "encoding/json"
30 "io"
31 "net/http"
32 "net/http/httptest"
33 "os"
34 "os/exec"
35 "path/filepath"
36 "reflect"
37 "strings"
38 "sync/atomic"
39 "testing"
40)
41
42// ---- pure grammar gate -----------------------------------------------------
43
44func TestValidServiceName(t *testing.T) {
45 for _, good := range []string{"acme", "amadan-hub", "a", strings.Repeat("a", 32)} {
46 if err := validServiceName(good); err != nil {
47 t.Errorf("validServiceName(%q) = %v, want nil", good, err)
48 }
49 }
50 for _, bad := range []string{"", "Acme", "has spaces", "under_score", strings.Repeat("a", 33)} {
51 if err := validServiceName(bad); err == nil {
52 t.Errorf("validServiceName(%q) = nil, want an error", bad)
53 }
54 }
55}
56
57// TestValidServiceHandle: revoke's grammar is a name OR a 64-hex listing id,
58// in either case — the two grammars are disjoint by length so both can be
59// accepted by the same gate with no ambiguity.
60func TestValidServiceHandle(t *testing.T) {
61 for _, good := range []string{
62 "acme",
63 strings.Repeat("a", 64),
64 strings.ToUpper(strings.Repeat("a", 64)),
65 strings.Repeat("0123456789abcdef", 4),
66 } {
67 if err := validServiceHandle(good); err != nil {
68 t.Errorf("validServiceHandle(%q) = %v, want nil", good, err)
69 }
70 }
71 for _, bad := range []string{"", "Acme", "has spaces", strings.Repeat("a", 63), strings.Repeat("a", 65), strings.Repeat("g", 64)} {
72 if err := validServiceHandle(bad); err == nil {
73 t.Errorf("validServiceHandle(%q) = nil, want an error", bad)
74 }
75 }
76}
77
78// ---- apiSession-level: path + body + wire-shape round trips ---------------
79
80// TestAPISessionServiceCreateAndList: createService POSTs to the account
81// door with name/role/app in the body and reads back token+doors; listServices
82// GETs the same door and unmarshals the "services" wrapper.
83func TestAPISessionServiceCreateAndList(t *testing.T) {
84 var gotPath, gotMethod string
85 var gotBody struct{ Name, Role, App string }
86 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87 gotPath, gotMethod = r.URL.Path, r.Method
88 if r.Method == http.MethodPost {
89 json.NewDecoder(r.Body).Decode(&gotBody)
90 w.WriteHeader(http.StatusCreated)
91 json.NewEncoder(w).Encode(map[string]any{
92 "name": gotBody.Name, "role": gotBody.Role, "app": gotBody.App,
93 "account": "bab", "token": "svc-secret-once",
94 "doors": []string{"POST /api/cli/apps/{account}/new", "GET /api/cli/apps"},
95 })
96 return
97 }
98 json.NewEncoder(w).Encode(map[string]any{
99 "services": []map[string]any{
100 {"id": strings.Repeat("a", 64), "name": "amadan-hub", "role": "publish", "app": "",
101 "label": "", "created": "2026-08-18T00:00:00Z", "rotated": "2026-08-18T00:00:00Z",
102 "last_used": "", "minted_by": "paul@keymail.dev"},
103 },
104 })
105 }))
106 defer ts.Close()
107 s := newTestSession(ts.URL)
108
109 token, doors, _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", "", nil, nil)
110 if err != nil {
111 t.Fatalf("createService: %v", err)
112 }
113 if gotPath != "/api/cli/services/bab" || gotMethod != http.MethodPost {
114 t.Fatalf("request = %s %s, want POST /api/cli/services/bab", gotMethod, gotPath)
115 }
116 if gotBody.Name != "amadan-hub" || gotBody.Role != "publish" || gotBody.App != "" {
117 t.Fatalf("body = %+v", gotBody)
118 }
119 if token != "svc-secret-once" || len(doors) != 2 {
120 t.Fatalf("createService result: token=%q doors=%v", token, doors)
121 }
122
123 rows, err := s.listServices(context.Background(), apiAcct{Seg: "bab"})
124 if err != nil {
125 t.Fatalf("listServices: %v", err)
126 }
127 if gotPath != "/api/cli/services/bab" || gotMethod != http.MethodGet {
128 t.Fatalf("request = %s %s, want GET /api/cli/services/bab", gotMethod, gotPath)
129 }
130 if len(rows) != 1 || rows[0].Name != "amadan-hub" || rows[0].Role != "publish" || rows[0].MintedBy != "paul@keymail.dev" {
131 t.Fatalf("listServices result = %+v", rows)
132 }
133}
134
135// TestAPISessionServiceCreateExactAccountID: -account-id travels as
136// exact=sqid, same as every other apiPath/fleetPath-built call.
137func TestAPISessionServiceCreateExactAccountID(t *testing.T) {
138 var gotQuery string
139 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
140 gotQuery = r.URL.RawQuery
141 w.WriteHeader(http.StatusCreated)
142 json.NewEncoder(w).Encode(map[string]any{"name": "x", "role": "publish", "app": "", "account": "bab", "token": "t", "doors": []string{}})
143 }))
144 defer ts.Close()
145 s := newTestSession(ts.URL)
146 if _, _, _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab", Exact: true}, "x", "publish", "", nil, nil); err != nil {
147 t.Fatalf("createService: %v", err)
148 }
149 if gotQuery != "exact=sqid" {
150 t.Fatalf("query = %q, want exact=sqid", gotQuery)
151 }
152}
153
154// TestAPISessionServiceCreateDuplicateName: the server's 409 sentence
155// surfaces verbatim, same convention as fleets create's duplicate refusal.
156func TestAPISessionServiceCreateDuplicateName(t *testing.T) {
157 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
158 w.WriteHeader(http.StatusConflict)
159 json.NewEncoder(w).Encode(map[string]string{"error": "a credential named amadan-hub already exists in this account"})
160 }))
161 defer ts.Close()
162 s := newTestSession(ts.URL)
163 if _, _, _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", "", nil, nil); err == nil ||
164 err.Error() != "a credential named amadan-hub already exists in this account" {
165 t.Fatalf("createService duplicate = %v, want the server's sentence", err)
166 }
167}
168
169// TestAPISessionServiceRotateAndRevoke: rotate POSTs to the name-addressed
170// rotate door and returns a fresh token; revoke POSTs to the revoke door and
171// returns the matched boolean — the field that tells the caller whether
172// anything was actually revoked.
173func TestAPISessionServiceRotateAndRevoke(t *testing.T) {
174 var gotPath string
175 step := "rotate"
176 matched := true
177 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
178 gotPath = r.URL.Path
179 switch step {
180 case "rotate":
181 json.NewEncoder(w).Encode(map[string]any{"name": "amadan-hub", "account": "bab", "token": "fresh-secret"})
182 case "revoke":
183 json.NewEncoder(w).Encode(map[string]any{"result": "revoked", "matched": matched})
184 }
185 }))
186 defer ts.Close()
187 s := newTestSession(ts.URL)
188 acct := apiAcct{Seg: "bab"}
189
190 tok, err := s.rotateService(context.Background(), acct, "amadan-hub")
191 if err != nil {
192 t.Fatalf("rotateService: %v", err)
193 }
194 if gotPath != "/api/cli/services/bab/amadan-hub/rotate" || tok != "fresh-secret" {
195 t.Fatalf("rotateService: path=%s token=%s", gotPath, tok)
196 }
197
198 step = "revoke"
199 m, err := s.revokeService(context.Background(), acct, "amadan-hub")
200 if err != nil {
201 t.Fatalf("revokeService: %v", err)
202 }
203 if gotPath != "/api/cli/services/bab/amadan-hub/revoke" || !m {
204 t.Fatalf("revokeService: path=%s matched=%v, want matched=true", gotPath, m)
205 }
206
207 matched = false
208 m, err = s.revokeService(context.Background(), acct, "typo-name")
209 if err != nil {
210 t.Fatalf("revokeService (unmatched): %v", err)
211 }
212 if m {
213 t.Fatal("revokeService matched=true, want false — the server said nothing matched")
214 }
215}
216
217// TestAPISessionServiceDoorsSurfaceServerErrors: a non-2xx from any of the
218// four doors surfaces the server's own message, not a swallowed one.
219func TestAPISessionServiceDoorsSurfaceServerErrors(t *testing.T) {
220 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
221 w.WriteHeader(http.StatusForbidden)
222 json.NewEncoder(w).Encode(map[string]string{"error": "only an account owner can manage service credentials"})
223 }))
224 defer ts.Close()
225 s := newTestSession(ts.URL)
226 acct := apiAcct{Seg: "bab"}
227
228 if _, _, _, _, err := s.createService(context.Background(), acct, "x", "publish", "", nil, nil); err == nil {
229 t.Fatal("createService against a 403 must error")
230 }
231 if _, err := s.listServices(context.Background(), acct); err == nil {
232 t.Fatal("listServices against a 403 must error")
233 }
234 if _, err := s.rotateService(context.Background(), acct, "x"); err == nil {
235 t.Fatal("rotateService against a 403 must error")
236 }
237 if _, err := s.revokeService(context.Background(), acct, "x"); err == nil {
238 t.Fatal("revokeService against a 403 must error")
239 }
240}
241
242// TestAPISessionServiceCreateUnreadableBodyTellsOperatorToRecover: a 201
243// whose body createService cannot parse still means the mint happened
244// server-side — the credential exists and its only plaintext copy was in
245// that unreadable body. Silence here would leave an operator not knowing a
246// credential now exists with a secret nobody can ever see; the error must
247// point at the recovery (review, minor).
248func TestAPISessionServiceCreateUnreadableBodyTellsOperatorToRecover(t *testing.T) {
249 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
250 w.WriteHeader(http.StatusCreated)
251 w.Write([]byte("not json"))
252 }))
253 defer ts.Close()
254 s := newTestSession(ts.URL)
255
256 _, _, _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", "", nil, nil)
257 if err == nil {
258 t.Fatal("createService with an unreadable 201 body must error")
259 }
260 msg := strings.ToLower(err.Error())
261 if !strings.Contains(msg, "carlos services ls") {
262 t.Fatalf("error missing the recovery pointer to `carlos services ls`: %v", err)
263 }
264 if !strings.Contains(msg, "rotate") {
265 t.Fatalf("error missing the recovery pointer to rotate: %v", err)
266 }
267}
268
269// ---- RunServices-level: full verb, good args, real printing ---------------
270
271// fakeServicesConsole answers the four CLI endpoints RunServices reaches,
272// stateful enough to prove create then rotate return DISTINCT secrets and
273// that revoke's matched flag really drives what RunServices prints.
274type fakeServicesConsole struct {
275 ts *httptest.Server
276
277 tokenSeq atomic.Int64
278
279 lastCreateBody atomic.Value // struct{ Name, Role, App string }
280 lastRevokeName atomic.Value // string
281 revokeMatched atomic.Bool
282}
283
284func newFakeServicesConsole(t *testing.T) *fakeServicesConsole {
285 t.Helper()
286 fc := &fakeServicesConsole{}
287 fc.revokeMatched.Store(true)
288 mux := http.NewServeMux()
289 mux.HandleFunc("POST /api/cli/services/{account}", func(w http.ResponseWriter, r *http.Request) {
290 var req struct{ Name, Role, App string }
291 json.NewDecoder(r.Body).Decode(&req)
292 fc.lastCreateBody.Store(req)
293 n := fc.tokenSeq.Add(1)
294 w.WriteHeader(http.StatusCreated)
295 json.NewEncoder(w).Encode(map[string]any{
296 "name": req.Name, "role": req.Role, "app": req.App, "account": "bab",
297 "token": tokenFor(n),
298 "doors": []string{
299 "POST /api/cli/apps/{account}/{app}/releases",
300 "POST /api/cli/apps/{account}/{app}/promote",
301 "GET /api/cli/apps",
302 },
303 })
304 })
305 mux.HandleFunc("GET /api/cli/services/{account}", func(w http.ResponseWriter, r *http.Request) {
306 json.NewEncoder(w).Encode(map[string]any{
307 "services": []map[string]any{
308 {"id": strings.Repeat("a", 64), "name": "amadan-hub", "role": "publish", "app": "titogo",
309 "label": "", "created": "2026-08-18T00:00:00Z", "rotated": "2026-08-18T00:00:00Z",
310 "last_used": "2026-08-18T01:00:00Z", "minted_by": "paul@keymail.dev"},
311 {"id": strings.Repeat("b", 64), "name": "", "role": "instance", "app": "router",
312 "label": "router", "created": "2026-08-01T00:00:00Z", "rotated": "",
313 "last_used": "", "minted_by": "paul@keymail.dev"},
314 {"id": strings.Repeat("c", 64), "name": "importer", "role": "run", "app": "titogo",
315 "commands": [][]string{{"import", "classic"}, {"import", "sync"}},
316 "created": "2026-10-24T00:00:00Z", "minted_by": "paul@keymail.dev"},
317 {"id": strings.Repeat("d", 64), "name": "mangled", "role": "run", "app": "titogo",
318 "commands": [][]string{}, "commands_unreadable": true,
319 "created": "2026-10-24T00:00:00Z", "minted_by": "paul@keymail.dev"},
320 },
321 })
322 })
323 mux.HandleFunc("POST /api/cli/services/{account}/{name}/rotate", func(w http.ResponseWriter, r *http.Request) {
324 n := fc.tokenSeq.Add(1)
325 json.NewEncoder(w).Encode(map[string]any{"token": tokenFor(n)})
326 })
327 mux.HandleFunc("POST /api/cli/services/{account}/{name}/revoke", func(w http.ResponseWriter, r *http.Request) {
328 fc.lastRevokeName.Store(r.PathValue("name"))
329 json.NewEncoder(w).Encode(map[string]any{"result": "revoked", "matched": fc.revokeMatched.Load()})
330 })
331 fc.ts = httptest.NewServer(mux)
332 t.Cleanup(fc.ts.Close)
333 return fc
334}
335
336// servicesCLIFixture wires up a fake console plus a credential pointed at
337// it, entirely in-process (no subprocess, no real login flow) — the same
338// shortcut fleetsCLIFixture takes.
339func servicesCLIFixture(t *testing.T) *fakeServicesConsole {
340 t.Helper()
341 home := t.TempDir()
342 t.Setenv("HOME", home)
343 t.Setenv("CARLOS_CONSOLE", "")
344 t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
345 t.Setenv("CARLOS_DEPLOYMENT_DIR", "")
346 t.Chdir(t.TempDir())
347 creds := filepath.Join(home, "carlos-credentials")
348 t.Setenv("CARLOS_CREDENTIALS", creds)
349 fc := newFakeServicesConsole(t)
350 writeTestCredential(t, creds, fc.ts.URL)
351 return fc
352}
353
354// TestServicesCreatePrintsTheSecretAndTheDoors — the secret appears exactly
355// once, in this output, and the door list is printed with it so an operator
356// sees what they granted at the moment they grant it.
357func TestServicesCreatePrintsTheSecretAndTheDoors(t *testing.T) {
358 fc := servicesCLIFixture(t)
359 buf := captureStdout(t)
360
361 RunServices([]string{"create", "-role", "publish", "-account", "bab", "amadan-hub"})
362
363 got := fc.lastCreateBody.Load().(struct{ Name, Role, App string })
364 if got.Name != "amadan-hub" || got.Role != "publish" || got.App != "" {
365 t.Fatalf("console saw create body = %+v", got)
366 }
367 out := buf.String()
368 tok := tokenFor(1)
369 if n := strings.Count(out, tok); n != 1 {
370 t.Fatalf("secret %q appeared %d times in stdout, want exactly 1:\n%s", tok, n, out)
371 }
372 if !strings.Contains(out, "shown once") {
373 t.Fatalf("stdout missing the shown-once warning:\n%s", out)
374 }
375 for _, want := range []string{
376 "POST", "/api/cli/apps/{account}/{app}/releases",
377 "/api/cli/apps/{account}/{app}/promote",
378 "GET", "/api/cli/apps",
379 } {
380 if !strings.Contains(out, want) {
381 t.Fatalf("stdout missing door %q:\n%s", want, out)
382 }
383 }
384 if !strings.Contains(out, "account-wide") {
385 t.Fatalf("stdout missing the account-wide scope label for an --app-less create:\n%s", out)
386 }
387}
388
389// TestServicesCreateAppScopedSaysSoNextToTheDoors — requirement 2: the door
390// list contains {app}-wildcarded patterns that read as "any app"; when the
391// credential is scoped to one app, RunServices must say so right where that
392// list is rendered, not just leave it to the -app flag the operator typed.
393func TestServicesCreateAppScopedSaysSoNextToTheDoors(t *testing.T) {
394 servicesCLIFixture(t)
395 buf := captureStdout(t)
396
397 RunServices([]string{"create", "-role", "publish", "-app", "titogo", "-account", "bab", "amadan-hub"})
398
399 out := buf.String()
400 if !strings.Contains(out, "titogo") {
401 t.Fatalf("stdout missing the app scope:\n%s", out)
402 }
403 if strings.Contains(out, "account-wide") {
404 t.Fatalf("stdout claims account-wide for an --app-scoped credential:\n%s", out)
405 }
406 // Review finding (minor 1): the deliver line itself should interpolate
407 // the real app rather than leave the generic "<app>" placeholder when
408 // it is already known — only the secret stays a placeholder.
409 if got := deliverLineFromOutput(t, out); !strings.Contains(got, "--app titogo") {
410 t.Fatalf("deliver line = %q, want it to name the real app (--app titogo)", got)
411 }
412}
413
414// TestServicesCreateRefusesABadNameBeforeCalling — a shape the server is
415// certain to refuse should cost nothing, the principle apps.go's
416// validAppName states. Subprocess: the refusal is a log.Fatal, which would
417// kill an in-process test binary. Builds its env through carlosTestEnv (with
418// CARLOS_CREDENTIALS overridden to point at a REAL, reachable fake console)
419// and runs from a scratch cmd.Dir, so this proves the name gate runs before
420// any network call even when a call would otherwise succeed.
421func TestServicesCreateRefusesABadNameBeforeCalling(t *testing.T) {
422 bin := buildCarlos(t)
423 var reqs atomic.Int64
424 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
425 reqs.Add(1)
426 json.NewEncoder(w).Encode(map[string]any{})
427 }))
428 defer ts.Close()
429
430 creds := filepath.Join(t.TempDir(), "credentials")
431 writeTestCredential(t, creds, ts.URL)
432 scratch := t.TempDir()
433
434 cmd := exec.Command(bin, "services", "create", "-role", "publish", "-account", "bab", "Bad Name")
435 // CARLOS_DEPLOYMENT_BUCKET/DIR masked explicitly too (review, minor):
436 // carlosTestEnv does not clear them on its own, and deploy_test.go's own
437 // subprocess helpers always pin both alongside CARLOS_CREDENTIALS for
438 // exactly this reason — not a live hazard here (nothing in this
439 // process's own env sets them), but this is the constraint where "not a
440 // hazard today" is not the bar.
441 cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+creds, "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=")
442 cmd.Dir = scratch
443 out, err := cmd.CombinedOutput()
444 if err == nil {
445 t.Fatalf("exited 0, want nonzero (bad name refused):\n%s", out)
446 }
447 if !strings.Contains(string(out), "not a usable service credential name") {
448 t.Fatalf("output missing the name refusal:\n%s", out)
449 }
450 if n := reqs.Load(); n != 0 {
451 t.Fatalf("console recorded %d requests, want 0 — the name gate must run before any API call", n)
452 }
453}
454
455// TestRunServicesListPrintsEveryRow: `carlos services ls` prints one row per
456// credential, including a nameless legacy one, from the fake console's fixed
457// listing.
458func TestRunServicesListPrintsEveryRow(t *testing.T) {
459 servicesCLIFixture(t)
460 buf := captureStdout(t)
461
462 RunServices([]string{"ls", "-account", "bab"})
463
464 out := buf.String()
465 for _, want := range []string{"amadan-hub", "publish", "titogo", "instance", "router"} {
466 if !strings.Contains(out, want) {
467 t.Fatalf("services ls stdout missing %q:\n%s", want, out)
468 }
469 }
470}
471
472// TestServicesRotateSaysWhatRotationIsNot — rotation refuses the
473// credential's next call with the old secret; it does not stop work already
474// in flight, and it is not what a compromised credential needs. That is
475// `revoke`. The fleets CLI makes the same distinction in words and this must
476// too.
477func TestServicesRotateSaysWhatRotationIsNot(t *testing.T) {
478 servicesCLIFixture(t)
479 buf := captureStdout(t)
480
481 RunServices([]string{"rotate", "-account", "bab", "amadan-hub"})
482
483 out := buf.String()
484 tok := tokenFor(1)
485 if n := strings.Count(out, tok); n != 1 {
486 t.Fatalf("rotated secret appeared %d times, want exactly 1:\n%s", n, out)
487 }
488 if !strings.Contains(out, "shown once") {
489 t.Fatalf("stdout missing the shown-once warning:\n%s", out)
490 }
491 lower := strings.ToLower(out)
492 if !strings.Contains(lower, "not revocation") && !strings.Contains(lower, "is not revocation") {
493 t.Fatalf("stdout missing the ROTATION IS NOT REVOCATION wording:\n%s", out)
494 }
495 if !strings.Contains(out, "revoke") {
496 t.Fatalf("stdout missing the pointer to revoke for a COMPROMISED credential:\n%s", out)
497 }
498 // Review finding (minor 2): the revoke hint must name the REAL account
499 // this session resolved, not a generic "<sqid|name>" placeholder —
500 // create already did; rotate has acct in hand too.
501 if !strings.Contains(out, "carlos services revoke --account bab amadan-hub") {
502 t.Fatalf("stdout revoke hint does not name the real account (bab):\n%s", out)
503 }
504 // C45 (review): rotation is the moment a RUNNING service's secret stops
505 // working, so this output must also say how to deliver the fresh one —
506 // a credential nobody can deliver is not finished, the same principle
507 // create's own output states. deliverLineFromOutput itself fails the
508 // test if the line is missing.
509 deliverLineFromOutput(t, out)
510}
511
512// deliverLineFromOutput pulls the "deliver it:" line out of printed
513// RunServices output — the shared parsing TestServicesDeliverLineIsARealCommand
514// uses to check what create's and rotate's output actually tells an
515// operator to type.
516func deliverLineFromOutput(t *testing.T, out string) string {
517 t.Helper()
518 for _, line := range strings.Split(out, "\n") {
519 trimmed := strings.TrimSpace(line)
520 if strings.HasPrefix(trimmed, "deliver it:") {
521 return strings.TrimSpace(strings.TrimPrefix(trimmed, "deliver it:"))
522 }
523 }
524 t.Fatalf("no \"deliver it:\" line in output:\n%s", out)
525 return ""
526}
527
528// parseDeliverCommand splits a printed command line ("carlos secrets set
529// -app titogo CARLOS_SERVICE_TOKEN=<secret>") into the verb tokens that
530// precede its first flag ("secrets", "set") and the flag tokens themselves
531// ("-app") — everything needed to ask the real binary's own -h output
532// whether the command it just told an operator to run actually exists.
533func parseDeliverCommand(t *testing.T, line string) (verb, flags []string) {
534 t.Helper()
535 line = strings.TrimPrefix(strings.TrimSpace(line), "carlos ")
536 for _, f := range strings.Fields(line) {
537 if strings.HasPrefix(f, "-") {
538 flags = append(flags, f)
539 continue
540 }
541 if len(flags) == 0 {
542 verb = append(verb, f)
543 }
544 }
545 if len(verb) == 0 {
546 t.Fatalf("deliver command %q named no verb", line)
547 }
548 return verb, flags
549}
550
551// helpOutputIsForVerb reports whether helpOut is the `-h` output of EXACTLY
552// the verb it was asked about, not a fall-through to a different usage page
553// (C46, review). Every sub-verb's flag.NewFlagSet in this binary is named
554// after the verb itself (e.g. flag.NewFlagSet("secrets set", ...)), and
555// Go's flag package always prints "Usage of <name>:" as that FlagSet's own
556// -h header — so this line is a reliable anchor for "this really is that
557// verb's own usage", which a typo'd verb cannot produce: a typo'd TOP-LEVEL
558// word falls through to main.go's usage() (real English text that
559// coincidentally contains "-app" in "carlos version target -app <a> 0.5.0"
560// — the review's own example), and a typo'd SUB-verb falls through to that
561// family's own bare usage constant (secretsUsage etc.), neither of which
562// ever prints "Usage of <name>:" for the verb that was actually typed.
563func helpOutputIsForVerb(helpOut string, verb []string) bool {
564 // The shared printer's synopsis line: "usage: carlos <verb> ..." —
565 // matched to the verb WORDS, so the top-level page ("usage: carlos
566 // <command> [flags]") can never pass for "secrets set".
567 want := "usage: carlos " + strings.Join(verb, " ")
568 for _, line := range strings.Split(helpOut, "\n") {
569 trimmed := strings.TrimSpace(line)
570 if trimmed == want || strings.HasPrefix(trimmed, want+" ") {
571 return true
572 }
573 }
574 return false
575}
576
577// helpOutputHasFlag reports whether helpOut's flagUsage output lists
578// EXACTLY flagName as a registered flag — a NAME match, never a substring
579// match (C46, review). Each flag prints as " --name <type> usage" (or
580// " --name, -n <type> usage"; single-letter primaries as " -n"), so
581// this compares the row head's dash-stripped first token rather than
582// searching for flagName anywhere in the text: a strings.Contains check
583// would let a search for -app pass against a renamed -application flag,
584// since "-application" begins with the plain substring "-app" — the exact
585// false positive the review demonstrated.
586func helpOutputHasFlag(helpOut, flagName string) bool {
587 name := strings.TrimLeft(flagName, "-")
588 for _, line := range strings.Split(helpOut, "\n") {
589 trimmed := strings.TrimSpace(line)
590 // A row's head is "--name" or "-n", optionally ", -alias", then
591 // " <type>". Take the first token and compare the NAME exactly.
592 head, _, _ := strings.Cut(trimmed, " ")
593 head = strings.TrimSuffix(head, ",")
594 if strings.TrimLeft(head, "-") == name && strings.HasPrefix(head, "-") {
595 return true
596 }
597 }
598 return false
599}
600
601// TestHelpOutputIsForVerbRejectsFallThrough — C46 (review), a permanent
602// fixture-based proof (no subprocess needed) that helpOutputIsForVerb
603// rejects the exact fall-through the review demonstrated against the real
604// binary: `carlos secret set -h` (typo'd verb) exits nonzero and prints the
605// TOP-LEVEL usage page, which itself contains "-app" as a substring inside
606// an unrelated verb's description — a page a naive Contains-based flag
607// search would have wrongly accepted as if it were secrets set's own -h.
608func TestHelpOutputIsForVerbRejectsFallThrough(t *testing.T) {
609 topLevelFallThrough := "usage: carlos <command> [flags]\n\n" +
610 " version target the semver ship counts iterations under: print it, or set\n" +
611 " it -- \"carlos version target --app <a> 0.5.0\"\n" +
612 " secrets sealed per-app secrets: set | unset | list | genkey\n"
613 if helpOutputIsForVerb(topLevelFallThrough, []string{"secrets", "set"}) {
614 t.Fatal("helpOutputIsForVerb matched a fall-through top-level usage page, " +
615 "which happens to contain \"--app\" as a substring in an unrelated verb's description")
616 }
617 real := "usage: carlos secrets set [flags]\nflags:\n --app, -a <string> app name\n"
618 if !helpOutputIsForVerb(real, []string{"secrets", "set"}) {
619 t.Fatal("helpOutputIsForVerb did not match the real usage page for the verb it names")
620 }
621}
622
623// TestHelpOutputHasFlagRejectsPrefixPreservingRename — C46 (review), a
624// permanent fixture-based proof that helpOutputHasFlag matches flag NAMES,
625// not substrings: the prior strings.Contains-based check would have let a
626// search for -app pass against a hypothetically renamed -application flag,
627// since "-application" begins with the plain substring "-app".
628func TestHelpOutputHasFlagRejectsPrefixPreservingRename(t *testing.T) {
629 renamed := "usage: carlos secrets set [flags]\nflags:\n --application <string> app name\n"
630 if helpOutputHasFlag(renamed, "-app") {
631 t.Fatal("helpOutputHasFlag(--app) matched -application -- a substring match, not an exact one")
632 }
633 if !helpOutputHasFlag(renamed, "-application") {
634 t.Fatal("helpOutputHasFlag(-application) should match its own exact name")
635 }
636 if !helpOutputHasFlag("usage: carlos secrets set [flags]\nflags:\n --app, -a <string> app name\n", "-app") {
637 t.Fatal("helpOutputHasFlag(--app) should match its own exact name")
638 }
639}
640
641// TestServicesDeliverLineIsARealCommand — C44 (review), the systemic fix
642// for C43: a printed "deliver it" command whose flag did not exist
643// (`carlos env set ... -secret`, env set has no such flag) sent an operator
644// who copied it verbatim to a `flag provided but not defined` refusal while
645// holding a plaintext secret that could never be shown again. Counting
646// token occurrences (this file's other create/rotate tests) never asked
647// whether the printed line RUNS — this test does, for both create's and
648// rotate's deliver line: it extracts the verb and flag names the line
649// names and asks the real, subprocess-built binary's own `-h` output for
650// that verb whether each flag actually exists. This is a test about the
651// COMMAND, so the check that runs it is itself a subprocess, built through
652// carlosTestEnv and run from a scratch cmd.Dir — the constraint this whole
653// suite is written around.
654//
655// C46 (review): the first cut of this test trusted -h output it never
656// verified belonged to the verb it asked about, and matched flags by
657// substring -- both closed only ONE axis of the class C44 exists to close
658// (wrong flag), not the other (wrong verb: a typo'd verb falls through to a
659// DIFFERENT usage page, which is real English text that can coincidentally
660// contain a flag name -- see helpOutputIsForVerb's doc comment for the
661// review's own example). This now asserts the subprocess exited 0, asserts
662// the output is that verb's OWN usage page (helpOutputIsForVerb) BEFORE
663// trusting anything in it, and matches each flag by name
664// (helpOutputHasFlag), not by substring.
665func TestServicesDeliverLineIsARealCommand(t *testing.T) {
666 bin := buildCarlos(t)
667 scratch := t.TempDir()
668 env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=")
669
670 checkDeliverLine := func(t *testing.T, out string) {
671 t.Helper()
672 line := deliverLineFromOutput(t, out)
673 verb, flags := parseDeliverCommand(t, line)
674 cmd := exec.Command(bin, append(append([]string{}, verb...), "-h")...)
675 cmd.Env = env
676 cmd.Dir = scratch
677 raw, err := cmd.CombinedOutput()
678 helpOut := string(raw)
679 // A real verb's own -h exits 0 (flag.ExitOnError treats -h as help
680 // requested, not an error). A typo'd verb -- top-level or sub-verb --
681 // exits nonzero and prints a DIFFERENT page; trusting that page's
682 // text at all, even before searching it, is the bug this guards.
683 if err != nil {
684 t.Fatalf("deliver line %q names `carlos %s -h`, which exited nonzero (%v) -- not a real verb:\n%s",
685 line, strings.Join(verb, " "), err, helpOut)
686 }
687 if !helpOutputIsForVerb(helpOut, verb) {
688 t.Fatalf("deliver line %q names `carlos %s`, but its -h output is not that verb's own "+
689 "usage page (no \"Usage of %s:\" line) -- a fall-through to a different page must fail here, "+
690 "not be searched:\n%s", line, strings.Join(verb, " "), strings.Join(verb, " "), helpOut)
691 }
692 for _, f := range flags {
693 if !helpOutputHasFlag(helpOut, f) {
694 t.Fatalf("deliver line %q names flag %q, which `carlos %s -h` does not list as a real flag:\n%s",
695 line, f, strings.Join(verb, " "), helpOut)
696 }
697 }
698 }
699
700 servicesCLIFixture(t)
701
702 createBuf := captureStdout(t)
703 RunServices([]string{"create", "-role", "publish", "-app", "titogo", "-account", "bab", "amadan-hub"})
704 checkDeliverLine(t, createBuf.String())
705
706 rotateBuf := captureStdout(t)
707 RunServices([]string{"rotate", "-account", "bab", "amadan-hub"})
708 checkDeliverLine(t, rotateBuf.String())
709}
710
711// TestRunServicesRevokeMatchedTrue: an ordinary revoke of a live credential
712// prints an unambiguous "revoked" and exits 0.
713func TestRunServicesRevokeMatchedTrue(t *testing.T) {
714 fc := servicesCLIFixture(t)
715 fc.revokeMatched.Store(true)
716 buf := captureStdout(t)
717
718 RunServices([]string{"revoke", "-account", "bab", "amadan-hub"})
719
720 if got := fc.lastRevokeName.Load(); got != "amadan-hub" {
721 t.Fatalf("console saw revoke name %v, want amadan-hub", got)
722 }
723 if !strings.Contains(buf.String(), "revoked") {
724 t.Fatalf("stdout = %q, want it to confirm the revoke", buf.String())
725 }
726}
727
728// TestRunServicesRevokeMatchedFalseDoesNotPrintSuccess — the door used
729// during an incident. `matched: false` must not print as plain "revoked":
730// that is the exact failure this field was added to prevent (a typo'd name
731// reading as success). It must still exit 0 — the state the caller wanted
732// (no live credential of that name) is the state that holds.
733func TestRunServicesRevokeMatchedFalseDoesNotPrintSuccess(t *testing.T) {
734 fc := servicesCLIFixture(t)
735 fc.revokeMatched.Store(false)
736 buf := captureStdout(t)
737
738 RunServices([]string{"revoke", "-account", "bab", "typo-name"})
739
740 out := buf.String()
741 if strings.TrimSpace(out) == "" {
742 t.Fatal("revoke with matched=false printed nothing")
743 }
744 lower := strings.ToLower(out)
745 if lower == "revoked\n" || strings.HasPrefix(strings.TrimSpace(lower), "revoked typo-name") {
746 t.Fatalf("stdout reads as plain success for matched=false:\n%s", out)
747 }
748 if !strings.Contains(lower, "nothing to revoke") && !strings.Contains(lower, "no live credential") {
749 t.Fatalf("stdout missing the matched=false wording:\n%s", out)
750 }
751 // Review finding (minor): the substring "revoked" must not appear
752 // anywhere in a matched=false line — not even inside a word like
753 // "already revoked" — because a script checking `| grep -q revoked`
754 // would read a typo as success just as a human skimming "revoked" would.
755 if strings.Contains(lower, "revoked") {
756 t.Fatalf("stdout contains the substring \"revoked\" for matched=false, which a naive grep would misread as success:\n%s", out)
757 }
758}
759
760// ---- subprocess: the usage-error / bad-argument paths ---------------------
761
762// TestServicesCLIUsageErrors drives every services verb with missing or
763// malshaped arguments as a real subprocess (buildCarlos, same helper
764// auth_e2e_test.go's TestAuthCLI and fleets_cli_test.go's
765// TestFleetsAndPlaceCLIUsageErrors use) — the ONLY way to observe a
766// log.Fatal/os.Exit refusal without killing the test binary. Every case
767// here must exit nonzero without ever reaching the network:
768// carlosTestEnv masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE and cmd.Dir is
769// a scratch dir (AGENTS.md's rule — a subprocess that inherited a real login
770// has minted real accounts on the flagship before), so a case that DID try
771// to dial out would fail on "not logged in", not silently succeed.
772func TestServicesCLIUsageErrors(t *testing.T) {
773 bin := buildCarlos(t)
774 scratch := t.TempDir()
775 // CARLOS_DEPLOYMENT_BUCKET/DIR masked explicitly too (review, minor) —
776 // see TestServicesCreateRefusesABadNameBeforeCalling's comment on the
777 // same fix.
778 env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=")
779
780 run := func(args ...string) (string, error) {
781 cmd := exec.Command(bin, args...)
782 cmd.Env = env
783 cmd.Dir = scratch
784 out, err := cmd.CombinedOutput()
785 return string(out), err
786 }
787
788 for _, tc := range []struct {
789 name string
790 args []string
791 want string // substring the combined output must contain
792 }{
793 {"services no args", []string{"services"}, "usage: carlos services"},
794 {"services bogus verb", []string{"services", "bogus"}, "usage: carlos services"},
795 {"services create no args", []string{"services", "create"}, "usage: carlos services create"},
796 {"services create missing role", []string{"services", "create", "-account", "bab", "amadan-hub"}, "usage: carlos services create"},
797 {"services create bad role", []string{"services", "create", "-role", "wizard", "-account", "bab", "amadan-hub"}, "unknown role"},
798 {"services create bad name", []string{"services", "create", "-role", "publish", "-account", "bab", "Bad Name"}, "not a usable service credential name"},
799 {"services create commands without run", []string{"services", "create", "-role", "publish", "-commands", "import classic", "-account", "bab", "hub"}, "--commands is only for --role run"},
800 {"services create run without commands", []string{"services", "create", "-role", "run", "-app", "hello", "-account", "bab", "importer"}, "--role run needs --commands"},
801 {"services create run with only commas", []string{"services", "create", "-role", "run", "-app", "hello", "-commands", " , ", "-account", "bab", "importer"}, "--role run needs --commands"},
802 {"services rotate no args", []string{"services", "rotate"}, "usage: carlos services rotate"},
803 {"services rotate bad name", []string{"services", "rotate", "-account", "bab", "Bad Name"}, "not a usable service credential name"},
804 {"services revoke no args", []string{"services", "revoke"}, "usage: carlos services revoke"},
805 {"services revoke bad handle", []string{"services", "revoke", "-account", "bab", "Bad Name"}, "not a usable"},
806 } {
807 t.Run(tc.name, func(t *testing.T) {
808 out, err := run(tc.args...)
809 if err == nil {
810 t.Fatalf("carlos %s exited 0, want a nonzero (usage/refusal) exit:\n%s", strings.Join(tc.args, " "), out)
811 }
812 if !strings.Contains(out, tc.want) {
813 t.Fatalf("carlos %s output missing %q:\n%s", strings.Join(tc.args, " "), tc.want, out)
814 }
815 })
816 }
817}
818
819// TestServicesDirectBucketModeRefusesWithServicesSpecificMessage: signed-out
820// / direct-bucket-mode shells get a services-specific fatal message, the
821// same posture fleetsSignedInSession takes for `carlos fleets`.
822func TestServicesDirectBucketModeRefusesWithServicesSpecificMessage(t *testing.T) {
823 bin := buildCarlos(t)
824 storeDir := filepath.Join(t.TempDir(), "store")
825 if err := os.MkdirAll(storeDir, 0o755); err != nil {
826 t.Fatal(err)
827 }
828 scratch := t.TempDir()
829
830 cmd := exec.Command(bin, "services", "ls")
831 cmd.Env = carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir)
832 cmd.Dir = scratch
833 out, err := cmd.CombinedOutput()
834 if err == nil {
835 t.Fatalf("exited 0, want nonzero (direct bucket mode has no services door):\n%s", out)
836 }
837 if !strings.Contains(string(out), "services works through a console") {
838 t.Fatalf("output missing the services-specific direct-bucket-mode message:\n%s", out)
839 }
840}
841
842// TestAPISessionServiceCreateCarriesRungs: the request body carries the
843// rungs exactly as given, the response's canonical order comes back as the
844// third return, and a listing row's rungs decode into apiServiceView.Rungs.
845func TestAPISessionServiceCreateCarriesRungs(t *testing.T) {
846 var gotRaw []byte
847 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
848 if r.Method == http.MethodPost {
849 gotRaw, _ = io.ReadAll(r.Body)
850 w.WriteHeader(http.StatusCreated)
851 w.Write([]byte(`{"token":"t","doors":[],"rungs":["canary","edge"]}`))
852 return
853 }
854 w.Write([]byte(`{"services":[{"id":"x","name":"ci","role":"deploy","app":"hello","rungs":["edge"]}]}`))
855 }))
856 defer ts.Close()
857 s := newTestSession(ts.URL)
858
859 _, _, gotRungs, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "ci", "deploy", "hello", []string{"edge", "canary"}, nil)
860 if err != nil {
861 t.Fatalf("createService: %v", err)
862 }
863 if !strings.Contains(string(gotRaw), `"rungs":["edge","canary"]`) {
864 t.Fatalf("request body = %s, want it to carry \"rungs\":[\"edge\",\"canary\"]", gotRaw)
865 }
866 if want := []string{"canary", "edge"}; !reflect.DeepEqual(gotRungs, want) {
867 t.Fatalf("gotRungs = %v, want %v", gotRungs, want)
868 }
869
870 rows, err := s.listServices(context.Background(), apiAcct{Seg: "bab"})
871 if err != nil {
872 t.Fatalf("listServices: %v", err)
873 }
874 if len(rows) != 1 || !reflect.DeepEqual(rows[0].Rungs, []string{"edge"}) {
875 t.Fatalf("listServices rows = %+v, want one row with Rungs [edge]", rows)
876 }
877}
878
879// TestServicesCreatePrintsTheRungsForADeployCredential: a deploy credential's
880// first line names its rungs; every other role keeps today's line.
881func TestServicesCreatePrintsTheRungsForADeployCredential(t *testing.T) {
882 buf := captureStdout(t)
883 printServiceCreated("bab", "ci", "deploy", "hello", []string{"canary", "edge"}, nil, "tok", nil)
884 first, _, _ := strings.Cut(buf.String(), "\n")
885 if want := "minted service:bab/ci (role deploy, app hello, rungs canary, edge)"; first != want {
886 t.Fatalf("first line = %q, want %q", first, want)
887 }
888
889 buf2 := captureStdout(t)
890 printServiceCreated("bab", "hub", "publish", "", nil, nil, "tok", nil)
891 first, _, _ = strings.Cut(buf2.String(), "\n")
892 if want := "minted service:bab/hub (role publish, account-wide)"; first != want {
893 t.Fatalf("publish first line = %q, want %q", first, want)
894 }
895}
896
897// TestAPISessionServiceCreateCarriesCommands: the request body carries the
898// command prefixes as given, the response's commands come back as the
899// fourth return, and a listing row's commands decode into
900// apiServiceView.Commands.
901func TestAPISessionServiceCreateCarriesCommands(t *testing.T) {
902 var gotRaw []byte
903 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
904 if r.Method == http.MethodPost {
905 gotRaw, _ = io.ReadAll(r.Body)
906 w.WriteHeader(http.StatusCreated)
907 w.Write([]byte(`{"token":"t","doors":[],"rungs":[],"commands":[["import","classic"]]}`))
908 return
909 }
910 w.Write([]byte(`{"services":[{"id":"x","name":"imp","role":"run","app":"hello","rungs":[],"commands":[["import","classic"],["import","sync"]]}]}`))
911 }))
912 defer ts.Close()
913 s := newTestSession(ts.URL)
914
915 _, _, _, gotCommands, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "imp", "run", "hello", nil,
916 [][]string{{"import", "classic"}})
917 if err != nil {
918 t.Fatalf("createService: %v", err)
919 }
920 if !strings.Contains(string(gotRaw), `"commands":[["import","classic"]]`) {
921 t.Fatalf("request body = %s, want it to carry the commands", gotRaw)
922 }
923 if strings.Contains(string(gotRaw), `"rungs"`) {
924 t.Fatalf("request body = %s, want no rungs for a run credential", gotRaw)
925 }
926 if want := [][]string{{"import", "classic"}}; !reflect.DeepEqual(gotCommands, want) {
927 t.Fatalf("gotCommands = %v, want %v", gotCommands, want)
928 }
929 rows, err := s.listServices(context.Background(), apiAcct{Seg: "bab"})
930 if err != nil {
931 t.Fatalf("listServices: %v", err)
932 }
933 if len(rows) != 1 || !reflect.DeepEqual(rows[0].Commands, [][]string{{"import", "classic"}, {"import", "sync"}}) {
934 t.Fatalf("listServices rows = %+v", rows)
935 }
936}
937
938// TestServicesCreatePrintsTheCommandsForARunCredential: a run credential's
939// first line names what it may run.
940func TestServicesCreatePrintsTheCommandsForARunCredential(t *testing.T) {
941 buf := captureStdout(t)
942 printServiceCreated("bab", "imp", "run", "hello", nil, [][]string{{"import", "classic"}, {"import", "sync"}}, "tok", nil)
943 first, _, _ := strings.Cut(buf.String(), "\n")
944 if want := "minted service:bab/imp (role run, app hello, commands import classic, import sync)"; first != want {
945 t.Fatalf("first line = %q, want %q", first, want)
946 }
947}
948
949// TestRunServicesListShowsCommands: a run row's commands print in the
950// --commands form, so they can be copied into another mint, and a run row
951// whose stored list the console cannot read says so instead of "-".
952func TestRunServicesListShowsCommands(t *testing.T) {
953 servicesCLIFixture(t)
954 buf := captureStdout(t)
955 RunServices([]string{"ls", "-account", "bab"})
956 out := buf.String()
957 for _, line := range strings.Split(out, "\n") {
958 switch {
959 case strings.HasPrefix(line, "importer "):
960 if !strings.Contains(line, "import classic,import sync") {
961 t.Errorf("importer row does not show its commands: %q", line)
962 }
963 case strings.HasPrefix(line, "mangled "):
964 if !strings.Contains(line, "(unreadable — refused at sign-in)") {
965 t.Errorf("mangled row does not say its commands are unreadable: %q", line)
966 }
967 case strings.HasPrefix(line, "amadan-hub "):
968 if strings.Contains(line, "unreadable") {
969 t.Errorf("a publish row says unreadable: %q", line)
970 }
971 }
972 }
973 if !strings.Contains(out, "importer ") || !strings.Contains(out, "mangled ") {
974 t.Fatalf("services ls is missing a run row:\n%s", out)
975 }
976}
977