// Tests for the `carlos services` verbs — the CLI door onto // internal/console/apiservices.go (Task 7). Same three layers // fleets_cli_test.go uses, for the same reasons (see that file's package // doc): // // - apiSession-level tests drive the four 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. // - RunServices-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 // fleetsCLIFixture) — this is what proves the secret really reaches // stdout exactly once, the door list prints, and the rotate/revoke // wording is really in the output. // - subprocess tests (buildCarlos-style, like auth_e2e_test.go and // fleets_cli_test.go's TestFleetsAndPlaceCLIUsageErrors) drive the // bad-argument paths, which call log.Fatal/os.Exit and would kill an // in-process test binary outright. // // EVERY test here that shells out to the built carlos binary builds its env // through carlosTestEnv (masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE) and // sets cmd.Dir to its own scratch dir — AGENTS.md's rule, restated in this // task's brief: a subprocess that inherited a real login has minted real // accounts on the flagship before. package main import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "reflect" "strings" "sync/atomic" "testing" ) // ---- pure grammar gate ----------------------------------------------------- func TestValidServiceName(t *testing.T) { for _, good := range []string{"acme", "amadan-hub", "a", strings.Repeat("a", 32)} { if err := validServiceName(good); err != nil { t.Errorf("validServiceName(%q) = %v, want nil", good, err) } } for _, bad := range []string{"", "Acme", "has spaces", "under_score", strings.Repeat("a", 33)} { if err := validServiceName(bad); err == nil { t.Errorf("validServiceName(%q) = nil, want an error", bad) } } } // TestValidServiceHandle: revoke's grammar is a name OR a 64-hex listing id, // in either case — the two grammars are disjoint by length so both can be // accepted by the same gate with no ambiguity. func TestValidServiceHandle(t *testing.T) { for _, good := range []string{ "acme", strings.Repeat("a", 64), strings.ToUpper(strings.Repeat("a", 64)), strings.Repeat("0123456789abcdef", 4), } { if err := validServiceHandle(good); err != nil { t.Errorf("validServiceHandle(%q) = %v, want nil", good, err) } } for _, bad := range []string{"", "Acme", "has spaces", strings.Repeat("a", 63), strings.Repeat("a", 65), strings.Repeat("g", 64)} { if err := validServiceHandle(bad); err == nil { t.Errorf("validServiceHandle(%q) = nil, want an error", bad) } } } // ---- apiSession-level: path + body + wire-shape round trips --------------- // TestAPISessionServiceCreateAndList: createService POSTs to the account // door with name/role/app in the body and reads back token+doors; listServices // GETs the same door and unmarshals the "services" wrapper. func TestAPISessionServiceCreateAndList(t *testing.T) { var gotPath, gotMethod string var gotBody struct{ Name, Role, App 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) w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]any{ "name": gotBody.Name, "role": gotBody.Role, "app": gotBody.App, "account": "bab", "token": "svc-secret-once", "doors": []string{"POST /api/cli/apps/{account}/new", "GET /api/cli/apps"}, }) return } json.NewEncoder(w).Encode(map[string]any{ "services": []map[string]any{ {"id": strings.Repeat("a", 64), "name": "amadan-hub", "role": "publish", "app": "", "label": "", "created": "2026-08-18T00:00:00Z", "rotated": "2026-08-18T00:00:00Z", "last_used": "", "minted_by": "paul@keymail.dev"}, }, }) })) defer ts.Close() s := newTestSession(ts.URL) token, doors, _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", "", nil, nil) if err != nil { t.Fatalf("createService: %v", err) } if gotPath != "/api/cli/services/bab" || gotMethod != http.MethodPost { t.Fatalf("request = %s %s, want POST /api/cli/services/bab", gotMethod, gotPath) } if gotBody.Name != "amadan-hub" || gotBody.Role != "publish" || gotBody.App != "" { t.Fatalf("body = %+v", gotBody) } if token != "svc-secret-once" || len(doors) != 2 { t.Fatalf("createService result: token=%q doors=%v", token, doors) } rows, err := s.listServices(context.Background(), apiAcct{Seg: "bab"}) if err != nil { t.Fatalf("listServices: %v", err) } if gotPath != "/api/cli/services/bab" || gotMethod != http.MethodGet { t.Fatalf("request = %s %s, want GET /api/cli/services/bab", gotMethod, gotPath) } if len(rows) != 1 || rows[0].Name != "amadan-hub" || rows[0].Role != "publish" || rows[0].MintedBy != "paul@keymail.dev" { t.Fatalf("listServices result = %+v", rows) } } // TestAPISessionServiceCreateExactAccountID: -account-id travels as // exact=sqid, same as every other apiPath/fleetPath-built call. func TestAPISessionServiceCreateExactAccountID(t *testing.T) { var gotQuery string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotQuery = r.URL.RawQuery w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]any{"name": "x", "role": "publish", "app": "", "account": "bab", "token": "t", "doors": []string{}}) })) defer ts.Close() s := newTestSession(ts.URL) if _, _, _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab", Exact: true}, "x", "publish", "", nil, nil); err != nil { t.Fatalf("createService: %v", err) } if gotQuery != "exact=sqid" { t.Fatalf("query = %q, want exact=sqid", gotQuery) } } // TestAPISessionServiceCreateDuplicateName: the server's 409 sentence // surfaces verbatim, same convention as fleets create's duplicate refusal. func TestAPISessionServiceCreateDuplicateName(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": "a credential named amadan-hub already exists in this account"}) })) defer ts.Close() s := newTestSession(ts.URL) if _, _, _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", "", nil, nil); err == nil || err.Error() != "a credential named amadan-hub already exists in this account" { t.Fatalf("createService duplicate = %v, want the server's sentence", err) } } // TestAPISessionServiceRotateAndRevoke: rotate POSTs to the name-addressed // rotate door and returns a fresh token; revoke POSTs to the revoke door and // returns the matched boolean — the field that tells the caller whether // anything was actually revoked. func TestAPISessionServiceRotateAndRevoke(t *testing.T) { var gotPath string step := "rotate" matched := true ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path switch step { case "rotate": json.NewEncoder(w).Encode(map[string]any{"name": "amadan-hub", "account": "bab", "token": "fresh-secret"}) case "revoke": json.NewEncoder(w).Encode(map[string]any{"result": "revoked", "matched": matched}) } })) defer ts.Close() s := newTestSession(ts.URL) acct := apiAcct{Seg: "bab"} tok, err := s.rotateService(context.Background(), acct, "amadan-hub") if err != nil { t.Fatalf("rotateService: %v", err) } if gotPath != "/api/cli/services/bab/amadan-hub/rotate" || tok != "fresh-secret" { t.Fatalf("rotateService: path=%s token=%s", gotPath, tok) } step = "revoke" m, err := s.revokeService(context.Background(), acct, "amadan-hub") if err != nil { t.Fatalf("revokeService: %v", err) } if gotPath != "/api/cli/services/bab/amadan-hub/revoke" || !m { t.Fatalf("revokeService: path=%s matched=%v, want matched=true", gotPath, m) } matched = false m, err = s.revokeService(context.Background(), acct, "typo-name") if err != nil { t.Fatalf("revokeService (unmatched): %v", err) } if m { t.Fatal("revokeService matched=true, want false — the server said nothing matched") } } // TestAPISessionServiceDoorsSurfaceServerErrors: a non-2xx from any of the // four doors surfaces the server's own message, not a swallowed one. func TestAPISessionServiceDoorsSurfaceServerErrors(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) json.NewEncoder(w).Encode(map[string]string{"error": "only an account owner can manage service credentials"}) })) defer ts.Close() s := newTestSession(ts.URL) acct := apiAcct{Seg: "bab"} if _, _, _, _, err := s.createService(context.Background(), acct, "x", "publish", "", nil, nil); err == nil { t.Fatal("createService against a 403 must error") } if _, err := s.listServices(context.Background(), acct); err == nil { t.Fatal("listServices against a 403 must error") } if _, err := s.rotateService(context.Background(), acct, "x"); err == nil { t.Fatal("rotateService against a 403 must error") } if _, err := s.revokeService(context.Background(), acct, "x"); err == nil { t.Fatal("revokeService against a 403 must error") } } // TestAPISessionServiceCreateUnreadableBodyTellsOperatorToRecover: a 201 // whose body createService cannot parse still means the mint happened // server-side — the credential exists and its only plaintext copy was in // that unreadable body. Silence here would leave an operator not knowing a // credential now exists with a secret nobody can ever see; the error must // point at the recovery (review, minor). func TestAPISessionServiceCreateUnreadableBodyTellsOperatorToRecover(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) w.Write([]byte("not json")) })) defer ts.Close() s := newTestSession(ts.URL) _, _, _, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "amadan-hub", "publish", "", nil, nil) if err == nil { t.Fatal("createService with an unreadable 201 body must error") } msg := strings.ToLower(err.Error()) if !strings.Contains(msg, "carlos services ls") { t.Fatalf("error missing the recovery pointer to `carlos services ls`: %v", err) } if !strings.Contains(msg, "rotate") { t.Fatalf("error missing the recovery pointer to rotate: %v", err) } } // ---- RunServices-level: full verb, good args, real printing --------------- // fakeServicesConsole answers the four CLI endpoints RunServices reaches, // stateful enough to prove create then rotate return DISTINCT secrets and // that revoke's matched flag really drives what RunServices prints. type fakeServicesConsole struct { ts *httptest.Server tokenSeq atomic.Int64 lastCreateBody atomic.Value // struct{ Name, Role, App string } lastRevokeName atomic.Value // string revokeMatched atomic.Bool } func newFakeServicesConsole(t *testing.T) *fakeServicesConsole { t.Helper() fc := &fakeServicesConsole{} fc.revokeMatched.Store(true) mux := http.NewServeMux() mux.HandleFunc("POST /api/cli/services/{account}", func(w http.ResponseWriter, r *http.Request) { var req struct{ Name, Role, App string } json.NewDecoder(r.Body).Decode(&req) fc.lastCreateBody.Store(req) n := fc.tokenSeq.Add(1) w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]any{ "name": req.Name, "role": req.Role, "app": req.App, "account": "bab", "token": tokenFor(n), "doors": []string{ "POST /api/cli/apps/{account}/{app}/releases", "POST /api/cli/apps/{account}/{app}/promote", "GET /api/cli/apps", }, }) }) mux.HandleFunc("GET /api/cli/services/{account}", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "services": []map[string]any{ {"id": strings.Repeat("a", 64), "name": "amadan-hub", "role": "publish", "app": "titogo", "label": "", "created": "2026-08-18T00:00:00Z", "rotated": "2026-08-18T00:00:00Z", "last_used": "2026-08-18T01:00:00Z", "minted_by": "paul@keymail.dev"}, {"id": strings.Repeat("b", 64), "name": "", "role": "instance", "app": "router", "label": "router", "created": "2026-08-01T00:00:00Z", "rotated": "", "last_used": "", "minted_by": "paul@keymail.dev"}, {"id": strings.Repeat("c", 64), "name": "importer", "role": "run", "app": "titogo", "commands": [][]string{{"import", "classic"}, {"import", "sync"}}, "created": "2026-10-24T00:00:00Z", "minted_by": "paul@keymail.dev"}, {"id": strings.Repeat("d", 64), "name": "mangled", "role": "run", "app": "titogo", "commands": [][]string{}, "commands_unreadable": true, "created": "2026-10-24T00:00:00Z", "minted_by": "paul@keymail.dev"}, }, }) }) mux.HandleFunc("POST /api/cli/services/{account}/{name}/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/services/{account}/{name}/revoke", func(w http.ResponseWriter, r *http.Request) { fc.lastRevokeName.Store(r.PathValue("name")) json.NewEncoder(w).Encode(map[string]any{"result": "revoked", "matched": fc.revokeMatched.Load()}) }) fc.ts = httptest.NewServer(mux) t.Cleanup(fc.ts.Close) return fc } // servicesCLIFixture wires up a fake console plus a credential pointed at // it, entirely in-process (no subprocess, no real login flow) — the same // shortcut fleetsCLIFixture takes. func servicesCLIFixture(t *testing.T) *fakeServicesConsole { 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 := newFakeServicesConsole(t) writeTestCredential(t, creds, fc.ts.URL) return fc } // TestServicesCreatePrintsTheSecretAndTheDoors — the secret appears exactly // once, in this output, and the door list is printed with it so an operator // sees what they granted at the moment they grant it. func TestServicesCreatePrintsTheSecretAndTheDoors(t *testing.T) { fc := servicesCLIFixture(t) buf := captureStdout(t) RunServices([]string{"create", "-role", "publish", "-account", "bab", "amadan-hub"}) got := fc.lastCreateBody.Load().(struct{ Name, Role, App string }) if got.Name != "amadan-hub" || got.Role != "publish" || got.App != "" { t.Fatalf("console saw create body = %+v", got) } out := buf.String() tok := tokenFor(1) if n := strings.Count(out, tok); n != 1 { t.Fatalf("secret %q appeared %d times in stdout, want exactly 1:\n%s", tok, n, out) } if !strings.Contains(out, "shown once") { t.Fatalf("stdout missing the shown-once warning:\n%s", out) } for _, want := range []string{ "POST", "/api/cli/apps/{account}/{app}/releases", "/api/cli/apps/{account}/{app}/promote", "GET", "/api/cli/apps", } { if !strings.Contains(out, want) { t.Fatalf("stdout missing door %q:\n%s", want, out) } } if !strings.Contains(out, "account-wide") { t.Fatalf("stdout missing the account-wide scope label for an --app-less create:\n%s", out) } } // TestServicesCreateAppScopedSaysSoNextToTheDoors — requirement 2: the door // list contains {app}-wildcarded patterns that read as "any app"; when the // credential is scoped to one app, RunServices must say so right where that // list is rendered, not just leave it to the -app flag the operator typed. func TestServicesCreateAppScopedSaysSoNextToTheDoors(t *testing.T) { servicesCLIFixture(t) buf := captureStdout(t) RunServices([]string{"create", "-role", "publish", "-app", "titogo", "-account", "bab", "amadan-hub"}) out := buf.String() if !strings.Contains(out, "titogo") { t.Fatalf("stdout missing the app scope:\n%s", out) } if strings.Contains(out, "account-wide") { t.Fatalf("stdout claims account-wide for an --app-scoped credential:\n%s", out) } // Review finding (minor 1): the deliver line itself should interpolate // the real app rather than leave the generic "" placeholder when // it is already known — only the secret stays a placeholder. if got := deliverLineFromOutput(t, out); !strings.Contains(got, "--app titogo") { t.Fatalf("deliver line = %q, want it to name the real app (--app titogo)", got) } } // TestServicesCreateRefusesABadNameBeforeCalling — a shape the server is // certain to refuse should cost nothing, the principle apps.go's // validAppName states. Subprocess: the refusal is a log.Fatal, which would // kill an in-process test binary. Builds its env through carlosTestEnv (with // CARLOS_CREDENTIALS overridden to point at a REAL, reachable fake console) // and runs from a scratch cmd.Dir, so this proves the name gate runs before // any network call even when a call would otherwise succeed. func TestServicesCreateRefusesABadNameBeforeCalling(t *testing.T) { bin := buildCarlos(t) var reqs atomic.Int64 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reqs.Add(1) json.NewEncoder(w).Encode(map[string]any{}) })) defer ts.Close() creds := filepath.Join(t.TempDir(), "credentials") writeTestCredential(t, creds, ts.URL) scratch := t.TempDir() cmd := exec.Command(bin, "services", "create", "-role", "publish", "-account", "bab", "Bad Name") // CARLOS_DEPLOYMENT_BUCKET/DIR masked explicitly too (review, minor): // carlosTestEnv does not clear them on its own, and deploy_test.go's own // subprocess helpers always pin both alongside CARLOS_CREDENTIALS for // exactly this reason — not a live hazard here (nothing in this // process's own env sets them), but this is the constraint where "not a // hazard today" is not the bar. cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+creds, "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=") cmd.Dir = scratch out, err := cmd.CombinedOutput() if err == nil { t.Fatalf("exited 0, want nonzero (bad name refused):\n%s", out) } if !strings.Contains(string(out), "not a usable service credential name") { t.Fatalf("output missing the name refusal:\n%s", out) } if n := reqs.Load(); n != 0 { t.Fatalf("console recorded %d requests, want 0 — the name gate must run before any API call", n) } } // TestRunServicesListPrintsEveryRow: `carlos services ls` prints one row per // credential, including a nameless legacy one, from the fake console's fixed // listing. func TestRunServicesListPrintsEveryRow(t *testing.T) { servicesCLIFixture(t) buf := captureStdout(t) RunServices([]string{"ls", "-account", "bab"}) out := buf.String() for _, want := range []string{"amadan-hub", "publish", "titogo", "instance", "router"} { if !strings.Contains(out, want) { t.Fatalf("services ls stdout missing %q:\n%s", want, out) } } } // TestServicesRotateSaysWhatRotationIsNot — rotation refuses the // credential's next call with the old secret; it does not stop work already // in flight, and it is not what a compromised credential needs. That is // `revoke`. The fleets CLI makes the same distinction in words and this must // too. func TestServicesRotateSaysWhatRotationIsNot(t *testing.T) { servicesCLIFixture(t) buf := captureStdout(t) RunServices([]string{"rotate", "-account", "bab", "amadan-hub"}) out := buf.String() tok := tokenFor(1) if n := strings.Count(out, tok); n != 1 { t.Fatalf("rotated secret appeared %d times, want exactly 1:\n%s", n, out) } if !strings.Contains(out, "shown once") { t.Fatalf("stdout missing the shown-once warning:\n%s", out) } lower := strings.ToLower(out) if !strings.Contains(lower, "not revocation") && !strings.Contains(lower, "is not revocation") { t.Fatalf("stdout missing the ROTATION IS NOT REVOCATION wording:\n%s", out) } if !strings.Contains(out, "revoke") { t.Fatalf("stdout missing the pointer to revoke for a COMPROMISED credential:\n%s", out) } // Review finding (minor 2): the revoke hint must name the REAL account // this session resolved, not a generic "" placeholder — // create already did; rotate has acct in hand too. if !strings.Contains(out, "carlos services revoke --account bab amadan-hub") { t.Fatalf("stdout revoke hint does not name the real account (bab):\n%s", out) } // C45 (review): rotation is the moment a RUNNING service's secret stops // working, so this output must also say how to deliver the fresh one — // a credential nobody can deliver is not finished, the same principle // create's own output states. deliverLineFromOutput itself fails the // test if the line is missing. deliverLineFromOutput(t, out) } // deliverLineFromOutput pulls the "deliver it:" line out of printed // RunServices output — the shared parsing TestServicesDeliverLineIsARealCommand // uses to check what create's and rotate's output actually tells an // operator to type. func deliverLineFromOutput(t *testing.T, out string) string { t.Helper() for _, line := range strings.Split(out, "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "deliver it:") { return strings.TrimSpace(strings.TrimPrefix(trimmed, "deliver it:")) } } t.Fatalf("no \"deliver it:\" line in output:\n%s", out) return "" } // parseDeliverCommand splits a printed command line ("carlos secrets set // -app titogo CARLOS_SERVICE_TOKEN=") into the verb tokens that // precede its first flag ("secrets", "set") and the flag tokens themselves // ("-app") — everything needed to ask the real binary's own -h output // whether the command it just told an operator to run actually exists. func parseDeliverCommand(t *testing.T, line string) (verb, flags []string) { t.Helper() line = strings.TrimPrefix(strings.TrimSpace(line), "carlos ") for _, f := range strings.Fields(line) { if strings.HasPrefix(f, "-") { flags = append(flags, f) continue } if len(flags) == 0 { verb = append(verb, f) } } if len(verb) == 0 { t.Fatalf("deliver command %q named no verb", line) } return verb, flags } // helpOutputIsForVerb reports whether helpOut is the `-h` output of EXACTLY // the verb it was asked about, not a fall-through to a different usage page // (C46, review). Every sub-verb's flag.NewFlagSet in this binary is named // after the verb itself (e.g. flag.NewFlagSet("secrets set", ...)), and // Go's flag package always prints "Usage of :" as that FlagSet's own // -h header — so this line is a reliable anchor for "this really is that // verb's own usage", which a typo'd verb cannot produce: a typo'd TOP-LEVEL // word falls through to main.go's usage() (real English text that // coincidentally contains "-app" in "carlos version target -app 0.5.0" // — the review's own example), and a typo'd SUB-verb falls through to that // family's own bare usage constant (secretsUsage etc.), neither of which // ever prints "Usage of :" for the verb that was actually typed. func helpOutputIsForVerb(helpOut string, verb []string) bool { // The shared printer's synopsis line: "usage: carlos ..." — // matched to the verb WORDS, so the top-level page ("usage: carlos // [flags]") can never pass for "secrets set". want := "usage: carlos " + strings.Join(verb, " ") for _, line := range strings.Split(helpOut, "\n") { trimmed := strings.TrimSpace(line) if trimmed == want || strings.HasPrefix(trimmed, want+" ") { return true } } return false } // helpOutputHasFlag reports whether helpOut's flagUsage output lists // EXACTLY flagName as a registered flag — a NAME match, never a substring // match (C46, review). Each flag prints as " --name usage" (or // " --name, -n usage"; single-letter primaries as " -n"), so // this compares the row head's dash-stripped first token rather than // searching for flagName anywhere in the text: a strings.Contains check // would let a search for -app pass against a renamed -application flag, // since "-application" begins with the plain substring "-app" — the exact // false positive the review demonstrated. func helpOutputHasFlag(helpOut, flagName string) bool { name := strings.TrimLeft(flagName, "-") for _, line := range strings.Split(helpOut, "\n") { trimmed := strings.TrimSpace(line) // A row's head is "--name" or "-n", optionally ", -alias", then // " ". Take the first token and compare the NAME exactly. head, _, _ := strings.Cut(trimmed, " ") head = strings.TrimSuffix(head, ",") if strings.TrimLeft(head, "-") == name && strings.HasPrefix(head, "-") { return true } } return false } // TestHelpOutputIsForVerbRejectsFallThrough — C46 (review), a permanent // fixture-based proof (no subprocess needed) that helpOutputIsForVerb // rejects the exact fall-through the review demonstrated against the real // binary: `carlos secret set -h` (typo'd verb) exits nonzero and prints the // TOP-LEVEL usage page, which itself contains "-app" as a substring inside // an unrelated verb's description — a page a naive Contains-based flag // search would have wrongly accepted as if it were secrets set's own -h. func TestHelpOutputIsForVerbRejectsFallThrough(t *testing.T) { topLevelFallThrough := "usage: carlos [flags]\n\n" + " version target the semver ship counts iterations under: print it, or set\n" + " it -- \"carlos version target --app 0.5.0\"\n" + " secrets sealed per-app secrets: set | unset | list | genkey\n" if helpOutputIsForVerb(topLevelFallThrough, []string{"secrets", "set"}) { t.Fatal("helpOutputIsForVerb matched a fall-through top-level usage page, " + "which happens to contain \"--app\" as a substring in an unrelated verb's description") } real := "usage: carlos secrets set [flags]\nflags:\n --app, -a app name\n" if !helpOutputIsForVerb(real, []string{"secrets", "set"}) { t.Fatal("helpOutputIsForVerb did not match the real usage page for the verb it names") } } // TestHelpOutputHasFlagRejectsPrefixPreservingRename — C46 (review), a // permanent fixture-based proof that helpOutputHasFlag matches flag NAMES, // not substrings: the prior strings.Contains-based check would have let a // search for -app pass against a hypothetically renamed -application flag, // since "-application" begins with the plain substring "-app". func TestHelpOutputHasFlagRejectsPrefixPreservingRename(t *testing.T) { renamed := "usage: carlos secrets set [flags]\nflags:\n --application app name\n" if helpOutputHasFlag(renamed, "-app") { t.Fatal("helpOutputHasFlag(--app) matched -application -- a substring match, not an exact one") } if !helpOutputHasFlag(renamed, "-application") { t.Fatal("helpOutputHasFlag(-application) should match its own exact name") } if !helpOutputHasFlag("usage: carlos secrets set [flags]\nflags:\n --app, -a app name\n", "-app") { t.Fatal("helpOutputHasFlag(--app) should match its own exact name") } } // TestServicesDeliverLineIsARealCommand — C44 (review), the systemic fix // for C43: a printed "deliver it" command whose flag did not exist // (`carlos env set ... -secret`, env set has no such flag) sent an operator // who copied it verbatim to a `flag provided but not defined` refusal while // holding a plaintext secret that could never be shown again. Counting // token occurrences (this file's other create/rotate tests) never asked // whether the printed line RUNS — this test does, for both create's and // rotate's deliver line: it extracts the verb and flag names the line // names and asks the real, subprocess-built binary's own `-h` output for // that verb whether each flag actually exists. This is a test about the // COMMAND, so the check that runs it is itself a subprocess, built through // carlosTestEnv and run from a scratch cmd.Dir — the constraint this whole // suite is written around. // // C46 (review): the first cut of this test trusted -h output it never // verified belonged to the verb it asked about, and matched flags by // substring -- both closed only ONE axis of the class C44 exists to close // (wrong flag), not the other (wrong verb: a typo'd verb falls through to a // DIFFERENT usage page, which is real English text that can coincidentally // contain a flag name -- see helpOutputIsForVerb's doc comment for the // review's own example). This now asserts the subprocess exited 0, asserts // the output is that verb's OWN usage page (helpOutputIsForVerb) BEFORE // trusting anything in it, and matches each flag by name // (helpOutputHasFlag), not by substring. func TestServicesDeliverLineIsARealCommand(t *testing.T) { bin := buildCarlos(t) scratch := t.TempDir() env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=") checkDeliverLine := func(t *testing.T, out string) { t.Helper() line := deliverLineFromOutput(t, out) verb, flags := parseDeliverCommand(t, line) cmd := exec.Command(bin, append(append([]string{}, verb...), "-h")...) cmd.Env = env cmd.Dir = scratch raw, err := cmd.CombinedOutput() helpOut := string(raw) // A real verb's own -h exits 0 (flag.ExitOnError treats -h as help // requested, not an error). A typo'd verb -- top-level or sub-verb -- // exits nonzero and prints a DIFFERENT page; trusting that page's // text at all, even before searching it, is the bug this guards. if err != nil { t.Fatalf("deliver line %q names `carlos %s -h`, which exited nonzero (%v) -- not a real verb:\n%s", line, strings.Join(verb, " "), err, helpOut) } if !helpOutputIsForVerb(helpOut, verb) { t.Fatalf("deliver line %q names `carlos %s`, but its -h output is not that verb's own "+ "usage page (no \"Usage of %s:\" line) -- a fall-through to a different page must fail here, "+ "not be searched:\n%s", line, strings.Join(verb, " "), strings.Join(verb, " "), helpOut) } for _, f := range flags { if !helpOutputHasFlag(helpOut, f) { t.Fatalf("deliver line %q names flag %q, which `carlos %s -h` does not list as a real flag:\n%s", line, f, strings.Join(verb, " "), helpOut) } } } servicesCLIFixture(t) createBuf := captureStdout(t) RunServices([]string{"create", "-role", "publish", "-app", "titogo", "-account", "bab", "amadan-hub"}) checkDeliverLine(t, createBuf.String()) rotateBuf := captureStdout(t) RunServices([]string{"rotate", "-account", "bab", "amadan-hub"}) checkDeliverLine(t, rotateBuf.String()) } // TestRunServicesRevokeMatchedTrue: an ordinary revoke of a live credential // prints an unambiguous "revoked" and exits 0. func TestRunServicesRevokeMatchedTrue(t *testing.T) { fc := servicesCLIFixture(t) fc.revokeMatched.Store(true) buf := captureStdout(t) RunServices([]string{"revoke", "-account", "bab", "amadan-hub"}) if got := fc.lastRevokeName.Load(); got != "amadan-hub" { t.Fatalf("console saw revoke name %v, want amadan-hub", got) } if !strings.Contains(buf.String(), "revoked") { t.Fatalf("stdout = %q, want it to confirm the revoke", buf.String()) } } // TestRunServicesRevokeMatchedFalseDoesNotPrintSuccess — the door used // during an incident. `matched: false` must not print as plain "revoked": // that is the exact failure this field was added to prevent (a typo'd name // reading as success). It must still exit 0 — the state the caller wanted // (no live credential of that name) is the state that holds. func TestRunServicesRevokeMatchedFalseDoesNotPrintSuccess(t *testing.T) { fc := servicesCLIFixture(t) fc.revokeMatched.Store(false) buf := captureStdout(t) RunServices([]string{"revoke", "-account", "bab", "typo-name"}) out := buf.String() if strings.TrimSpace(out) == "" { t.Fatal("revoke with matched=false printed nothing") } lower := strings.ToLower(out) if lower == "revoked\n" || strings.HasPrefix(strings.TrimSpace(lower), "revoked typo-name") { t.Fatalf("stdout reads as plain success for matched=false:\n%s", out) } if !strings.Contains(lower, "nothing to revoke") && !strings.Contains(lower, "no live credential") { t.Fatalf("stdout missing the matched=false wording:\n%s", out) } // Review finding (minor): the substring "revoked" must not appear // anywhere in a matched=false line — not even inside a word like // "already revoked" — because a script checking `| grep -q revoked` // would read a typo as success just as a human skimming "revoked" would. if strings.Contains(lower, "revoked") { t.Fatalf("stdout contains the substring \"revoked\" for matched=false, which a naive grep would misread as success:\n%s", out) } } // ---- subprocess: the usage-error / bad-argument paths --------------------- // TestServicesCLIUsageErrors drives every services verb with missing or // malshaped arguments as a real subprocess (buildCarlos, same helper // auth_e2e_test.go's TestAuthCLI and fleets_cli_test.go's // TestFleetsAndPlaceCLIUsageErrors use) — 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 TestServicesCLIUsageErrors(t *testing.T) { bin := buildCarlos(t) scratch := t.TempDir() // CARLOS_DEPLOYMENT_BUCKET/DIR masked explicitly too (review, minor) — // see TestServicesCreateRefusesABadNameBeforeCalling's comment on the // same fix. env := carlosTestEnv(t, "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=") 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 }{ {"services no args", []string{"services"}, "usage: carlos services"}, {"services bogus verb", []string{"services", "bogus"}, "usage: carlos services"}, {"services create no args", []string{"services", "create"}, "usage: carlos services create"}, {"services create missing role", []string{"services", "create", "-account", "bab", "amadan-hub"}, "usage: carlos services create"}, {"services create bad role", []string{"services", "create", "-role", "wizard", "-account", "bab", "amadan-hub"}, "unknown role"}, {"services create bad name", []string{"services", "create", "-role", "publish", "-account", "bab", "Bad Name"}, "not a usable service credential name"}, {"services create commands without run", []string{"services", "create", "-role", "publish", "-commands", "import classic", "-account", "bab", "hub"}, "--commands is only for --role run"}, {"services create run without commands", []string{"services", "create", "-role", "run", "-app", "hello", "-account", "bab", "importer"}, "--role run needs --commands"}, {"services create run with only commas", []string{"services", "create", "-role", "run", "-app", "hello", "-commands", " , ", "-account", "bab", "importer"}, "--role run needs --commands"}, {"services rotate no args", []string{"services", "rotate"}, "usage: carlos services rotate"}, {"services rotate bad name", []string{"services", "rotate", "-account", "bab", "Bad Name"}, "not a usable service credential name"}, {"services revoke no args", []string{"services", "revoke"}, "usage: carlos services revoke"}, {"services revoke bad handle", []string{"services", "revoke", "-account", "bab", "Bad Name"}, "not a usable"}, } { 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) } }) } } // TestServicesDirectBucketModeRefusesWithServicesSpecificMessage: signed-out // / direct-bucket-mode shells get a services-specific fatal message, the // same posture fleetsSignedInSession takes for `carlos fleets`. func TestServicesDirectBucketModeRefusesWithServicesSpecificMessage(t *testing.T) { bin := buildCarlos(t) storeDir := filepath.Join(t.TempDir(), "store") if err := os.MkdirAll(storeDir, 0o755); err != nil { t.Fatal(err) } scratch := t.TempDir() cmd := exec.Command(bin, "services", "ls") cmd.Env = carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+storeDir) cmd.Dir = scratch out, err := cmd.CombinedOutput() if err == nil { t.Fatalf("exited 0, want nonzero (direct bucket mode has no services door):\n%s", out) } if !strings.Contains(string(out), "services works through a console") { t.Fatalf("output missing the services-specific direct-bucket-mode message:\n%s", out) } } // TestAPISessionServiceCreateCarriesRungs: the request body carries the // rungs exactly as given, the response's canonical order comes back as the // third return, and a listing row's rungs decode into apiServiceView.Rungs. func TestAPISessionServiceCreateCarriesRungs(t *testing.T) { var gotRaw []byte ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { gotRaw, _ = io.ReadAll(r.Body) w.WriteHeader(http.StatusCreated) w.Write([]byte(`{"token":"t","doors":[],"rungs":["canary","edge"]}`)) return } w.Write([]byte(`{"services":[{"id":"x","name":"ci","role":"deploy","app":"hello","rungs":["edge"]}]}`)) })) defer ts.Close() s := newTestSession(ts.URL) _, _, gotRungs, _, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "ci", "deploy", "hello", []string{"edge", "canary"}, nil) if err != nil { t.Fatalf("createService: %v", err) } if !strings.Contains(string(gotRaw), `"rungs":["edge","canary"]`) { t.Fatalf("request body = %s, want it to carry \"rungs\":[\"edge\",\"canary\"]", gotRaw) } if want := []string{"canary", "edge"}; !reflect.DeepEqual(gotRungs, want) { t.Fatalf("gotRungs = %v, want %v", gotRungs, want) } rows, err := s.listServices(context.Background(), apiAcct{Seg: "bab"}) if err != nil { t.Fatalf("listServices: %v", err) } if len(rows) != 1 || !reflect.DeepEqual(rows[0].Rungs, []string{"edge"}) { t.Fatalf("listServices rows = %+v, want one row with Rungs [edge]", rows) } } // TestServicesCreatePrintsTheRungsForADeployCredential: a deploy credential's // first line names its rungs; every other role keeps today's line. func TestServicesCreatePrintsTheRungsForADeployCredential(t *testing.T) { buf := captureStdout(t) printServiceCreated("bab", "ci", "deploy", "hello", []string{"canary", "edge"}, nil, "tok", nil) first, _, _ := strings.Cut(buf.String(), "\n") if want := "minted service:bab/ci (role deploy, app hello, rungs canary, edge)"; first != want { t.Fatalf("first line = %q, want %q", first, want) } buf2 := captureStdout(t) printServiceCreated("bab", "hub", "publish", "", nil, nil, "tok", nil) first, _, _ = strings.Cut(buf2.String(), "\n") if want := "minted service:bab/hub (role publish, account-wide)"; first != want { t.Fatalf("publish first line = %q, want %q", first, want) } } // TestAPISessionServiceCreateCarriesCommands: the request body carries the // command prefixes as given, the response's commands come back as the // fourth return, and a listing row's commands decode into // apiServiceView.Commands. func TestAPISessionServiceCreateCarriesCommands(t *testing.T) { var gotRaw []byte ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { gotRaw, _ = io.ReadAll(r.Body) w.WriteHeader(http.StatusCreated) w.Write([]byte(`{"token":"t","doors":[],"rungs":[],"commands":[["import","classic"]]}`)) return } w.Write([]byte(`{"services":[{"id":"x","name":"imp","role":"run","app":"hello","rungs":[],"commands":[["import","classic"],["import","sync"]]}]}`)) })) defer ts.Close() s := newTestSession(ts.URL) _, _, _, gotCommands, err := s.createService(context.Background(), apiAcct{Seg: "bab"}, "imp", "run", "hello", nil, [][]string{{"import", "classic"}}) if err != nil { t.Fatalf("createService: %v", err) } if !strings.Contains(string(gotRaw), `"commands":[["import","classic"]]`) { t.Fatalf("request body = %s, want it to carry the commands", gotRaw) } if strings.Contains(string(gotRaw), `"rungs"`) { t.Fatalf("request body = %s, want no rungs for a run credential", gotRaw) } if want := [][]string{{"import", "classic"}}; !reflect.DeepEqual(gotCommands, want) { t.Fatalf("gotCommands = %v, want %v", gotCommands, want) } rows, err := s.listServices(context.Background(), apiAcct{Seg: "bab"}) if err != nil { t.Fatalf("listServices: %v", err) } if len(rows) != 1 || !reflect.DeepEqual(rows[0].Commands, [][]string{{"import", "classic"}, {"import", "sync"}}) { t.Fatalf("listServices rows = %+v", rows) } } // TestServicesCreatePrintsTheCommandsForARunCredential: a run credential's // first line names what it may run. func TestServicesCreatePrintsTheCommandsForARunCredential(t *testing.T) { buf := captureStdout(t) printServiceCreated("bab", "imp", "run", "hello", nil, [][]string{{"import", "classic"}, {"import", "sync"}}, "tok", nil) first, _, _ := strings.Cut(buf.String(), "\n") if want := "minted service:bab/imp (role run, app hello, commands import classic, import sync)"; first != want { t.Fatalf("first line = %q, want %q", first, want) } } // TestRunServicesListShowsCommands: a run row's commands print in the // --commands form, so they can be copied into another mint, and a run row // whose stored list the console cannot read says so instead of "-". func TestRunServicesListShowsCommands(t *testing.T) { servicesCLIFixture(t) buf := captureStdout(t) RunServices([]string{"ls", "-account", "bab"}) out := buf.String() for _, line := range strings.Split(out, "\n") { switch { case strings.HasPrefix(line, "importer "): if !strings.Contains(line, "import classic,import sync") { t.Errorf("importer row does not show its commands: %q", line) } case strings.HasPrefix(line, "mangled "): if !strings.Contains(line, "(unreadable — refused at sign-in)") { t.Errorf("mangled row does not say its commands are unreadable: %q", line) } case strings.HasPrefix(line, "amadan-hub "): if strings.Contains(line, "unreadable") { t.Errorf("a publish row says unreadable: %q", line) } } } if !strings.Contains(out, "importer ") || !strings.Contains(out, "mangled ") { t.Fatalf("services ls is missing a run row:\n%s", out) } }