// Tests for `carlos store` (issue #55): the wire shape the four apiSession // methods send (request body/method/path, matching apistore_test.go's server // side), the honesty rules the print* functions must follow (create/status/ // grant/rotate — no line claims a box has converged), and the two ways the // verb refuses before it ever dials anything (no -app, direct bucket mode) — // driven through the real binary via carlosTestEnv + a scratch cmd.Dir, the // same isolation restart_test.go's refusal tests use. package main import ( "bytes" "context" "encoding/json" "errors" "flag" "io" "net/http" "net/http/httptest" "os/exec" "strings" "testing" "time" "github.com/carlosframework/platform/internal/objstore" ) func approvedEntitlement() *objstore.Entitlement { return &objstore.Entitlement{ Schema: objstore.EntitlementSchema, Account: "bac", Applied: &objstore.Applied{By: "owner@example.com", At: time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)}, Decision: &objstore.Decision{Approved: true, By: "operator@example.com", At: time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC)}, } } // ---- apiSession.store* wire shape ------------------------------------------- // TestAPISessionStoreCreateWireShape pins handleAPIStoreCreate's contract: a // bearer POST to .../store, and — the review-tested case — a bare create // (no -env-prefix) sends the literal `{}` body, never a key with an empty // string value, since the door treats an ABSENT key and an empty one the // same way but a differently-shaped body is still the wrong thing to send. func TestAPISessionStoreCreateWireShape(t *testing.T) { var gotPath, gotMethod, gotAuth, gotBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization") body := new(bytes.Buffer) body.ReadFrom(r.Body) gotBody = body.String() json.NewEncoder(w).Encode(map[string]any{ "app": "keymail", "backend": "aws", "declared": map[string]any{"env_prefix": "CARLOS_STORE", "at": "2026-08-10T12:00:00Z", "by": "a@keymail.dev"}, "grant": nil, "provisioned": nil, "env_names": []string{"CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION"}, "pending_finish": false, "missing_vars": []string{}, }) })) defer ts.Close() res, err := newTestSession(ts.URL).storeCreate(context.Background(), apiAcct{Seg: "keymail-team"}, "keymail", "", "") if err != nil { t.Fatalf("storeCreate: %v", err) } if gotMethod != http.MethodPost { t.Fatalf("method = %s, want POST", gotMethod) } if gotPath != "/api/cli/apps/keymail-team/keymail/store" { t.Fatalf("path = %s", gotPath) } if gotAuth != "Bearer cli-token" { t.Fatalf("auth = %q", gotAuth) } if strings.TrimSpace(gotBody) != "{}" { t.Fatalf("body = %q, want the bare {} a default create sends", gotBody) } if res.App != "keymail" || res.Backend != "aws" { t.Fatalf("result = %+v, want the console's echo", res) } if res.Declared == nil || res.Declared.EnvPrefix != "CARLOS_STORE" { t.Fatalf("Declared = %+v, want the echoed prefix", res.Declared) } } // TestAPISessionStoreCreateSendsChosenPrefix pins the other half: a chosen // -env-prefix travels verbatim, not silently defaulted client-side (the // server owns the default, per handleAPIStoreCreate's own doc comment). func TestAPISessionStoreCreateSendsChosenPrefix(t *testing.T) { var gotBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body := new(bytes.Buffer) body.ReadFrom(r.Body) gotBody = body.String() json.NewEncoder(w).Encode(map[string]any{ "app": "keymail", "backend": "aws", "declared": map[string]any{"env_prefix": "TITO_S3", "at": "2026-08-10T12:00:00Z", "by": "a@keymail.dev"}, "env_names": []string{"TITO_S3_BUCKET"}, "pending_finish": false, "missing_vars": []string{}, }) })) defer ts.Close() if _, err := newTestSession(ts.URL).storeCreate(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", "TITO_S3"); err != nil { t.Fatalf("storeCreate: %v", err) } var sent struct { EnvPrefix string `json:"env_prefix"` } if err := json.Unmarshal([]byte(gotBody), &sent); err != nil || sent.EnvPrefix != "TITO_S3" { t.Fatalf("body = %q, want env_prefix TITO_S3", gotBody) } } func TestStoreCreateAndProvisionPrintsDeclarationBeforeProvisionFailure(t *testing.T) { var paths []string var out bytes.Buffer ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.URL.Path) switch r.URL.Path { case "/api/cli/apps/bac/keymail/store": json.NewEncoder(w).Encode(apiStoreReply{ App: "keymail", Backend: "aws", Entitlement: approvedEntitlement(), Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE", By: "owner@example.com"}, EnvNames: []string{"CARLOS_STORE_BUCKET"}, }) case "/api/cli/apps/bac/keymail/store/provision": if !strings.Contains(out.String(), "declared a store for keymail") { t.Errorf("provision began before declaration was printed: %q", out.String()) } w.WriteHeader(http.StatusBadGateway) json.NewEncoder(w).Encode(map[string]string{"error": "object storage provisioning failed — retry store create; a recently destroyed bucket name may still be unavailable"}) default: http.NotFound(w, r) } })) defer ts.Close() err := storeCreateAndProvision(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bac"}, "keymail", "", "", &out) if err == nil || !strings.Contains(err.Error(), "provisioning failed") { t.Fatalf("error = %v, want provision failure", err) } if got := strings.Join(paths, ","); got != "/api/cli/apps/bac/keymail/store,/api/cli/apps/bac/keymail/store/provision" { t.Fatalf("paths = %q, want declare then provision", got) } } func TestStoreCreateAndProvisionBranchesOnReply(t *testing.T) { tests := []struct { name string backend string entitlement *objstore.Entitlement wantCalls int }{ {name: "approved with backend", backend: "aws", entitlement: approvedEntitlement(), wantCalls: 2}, {name: "approved without backend", entitlement: approvedEntitlement(), wantCalls: 1}, {name: "not applied", backend: "aws", wantCalls: 1}, {name: "pending", backend: "aws", entitlement: &objstore.Entitlement{Applied: &objstore.Applied{}}, wantCalls: 1}, {name: "declined", backend: "aws", entitlement: &objstore.Entitlement{Applied: &objstore.Applied{}, Decision: &objstore.Decision{Approved: false}}, wantCalls: 1}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { calls := 0 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ if strings.HasSuffix(r.URL.Path, "/provision") { json.NewEncoder(w).Encode(apiStoreReply{App: "keymail", Backend: tt.backend, Entitlement: tt.entitlement, Provisioned: &apiStoreProvisioned{Bucket: "b", Region: "eu-west-1", IAMUser: "u"}}) return } json.NewEncoder(w).Encode(apiStoreReply{App: "keymail", Backend: tt.backend, Entitlement: tt.entitlement, Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}}) })) defer ts.Close() if err := storeCreateAndProvision(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bac"}, "keymail", "", "", new(bytes.Buffer)); err != nil { t.Fatal(err) } if calls != tt.wantCalls { t.Fatalf("calls = %d, want %d", calls, tt.wantCalls) } }) } } func TestStoreProvisionUsesLongWalkClient(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(40 * time.Millisecond) json.NewEncoder(w).Encode(apiStoreReply{App: "keymail"}) })) defer ts.Close() s := newTestSession(ts.URL) s.c.httpc.Timeout = time.Millisecond if _, err := s.storeProvision(context.Background(), apiAcct{Seg: "bac"}, "keymail", ""); err != nil { t.Fatalf("storeProvision inherited ordinary JSON timeout: %v", err) } if s.c.httpc.Timeout != time.Millisecond { t.Fatalf("ordinary client timeout changed to %s", s.c.httpc.Timeout) } } func TestStoreOperatorDoorsUseExplicitSqidWithoutMembershipResolution(t *testing.T) { var paths []string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.Method+" "+r.URL.RequestURI()) json.NewEncoder(w).Encode(apiStoreAccountReply{Account: "bac", Entitlement: &objstore.Entitlement{Account: "bac"}}) })) defer ts.Close() s := newTestSession(ts.URL) if _, err := s.storeApprove(context.Background(), "bac"); err != nil { t.Fatal(err) } if _, err := s.storeDecline(context.Background(), "bac", "not yet"); err != nil { t.Fatal(err) } want := []string{ "POST /api/cli/accounts/bac/store/approve", "POST /api/cli/accounts/bac/store/decline", } if strings.Join(paths, "\n") != strings.Join(want, "\n") { t.Fatalf("paths = %#v, want %#v", paths, want) } if s.acctsFetched { t.Fatal("operator decision fetched memberships") } } func TestRunStoreApproveWithSessionDoesNotResolveMemberships(t *testing.T) { var path string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path = r.URL.RequestURI() json.NewEncoder(w).Encode(apiStoreAccountReply{Account: "bac", Entitlement: &objstore.Entitlement{Account: "bac"}}) })) defer ts.Close() s := newTestSession(ts.URL) var out bytes.Buffer if err := runStoreApproveWithSession(context.Background(), s, []string{"--account", "bac"}, &out); err != nil { t.Fatal(err) } if path != "/api/cli/accounts/bac/store/approve" { t.Fatalf("path = %q", path) } if s.acctsFetched { t.Fatal("store approve fetched memberships") } } func TestRunStoreApproveHelpAndValidationStopBeforeNetwork(t *testing.T) { hits := 0 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ })) defer ts.Close() s := newTestSession(ts.URL) var help bytes.Buffer if err := runStoreApproveWithSession(context.Background(), s, []string{"--help"}, &help); !errors.Is(err, flag.ErrHelp) { t.Fatalf("--help error = %v, want flag.ErrHelp", err) } if !strings.Contains(help.String(), "usage: carlos store approve") || !strings.Contains(help.String(), "--account") { t.Fatalf("--help output = %q", help.String()) } for _, args := range [][]string{{"--account", "account-name"}, {"--account", "bac", "extra"}, {}} { if err := runStoreApproveWithSession(context.Background(), s, args, io.Discard); err == nil { t.Fatalf("args %q succeeded", args) } } if hits != 0 { t.Fatalf("validation made %d requests", hits) } } func TestStoreApplyUsesOwnerAccountPathAndApprovalsUseFleetPath(t *testing.T) { var paths []string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.Method+" "+r.URL.RequestURI()) if r.URL.Path == "/api/cli/store/approvals" { json.NewEncoder(w).Encode(apiStoreApprovalsReply{Applications: []*objstore.Entitlement{{Account: "bac"}}}) return } json.NewEncoder(w).Encode(apiStoreAccountReply{Account: "bac", Entitlement: &objstore.Entitlement{Account: "bac"}}) })) defer ts.Close() s := newTestSession(ts.URL) if _, err := s.storeApply(context.Background(), apiAcct{Seg: "bac", Exact: true}, "needed for backups"); err != nil { t.Fatal(err) } if _, err := s.storeApprovals(context.Background()); err != nil { t.Fatal(err) } want := []string{"POST /api/cli/accounts/bac/store/apply?exact=sqid", "GET /api/cli/store/approvals"} if strings.Join(paths, "\n") != strings.Join(want, "\n") { t.Fatalf("paths = %#v, want %#v", paths, want) } } func TestStoreOperatorBareNotFoundDoesNotSuggestMembership(t *testing.T) { ts := httptest.NewServer(http.NotFoundHandler()) defer ts.Close() s := newTestSession(ts.URL) for name, call := range map[string]func() error{ "approve": func() error { _, err := s.storeApprove(context.Background(), "bac"); return err }, "decline": func() error { _, err := s.storeDecline(context.Background(), "bac", "no"); return err }, "approvals": func() error { _, err := s.storeApprovals(context.Background()); return err }, } { t.Run(name, func(t *testing.T) { err := call() if err == nil || !strings.Contains(err.Error(), "operator access") { t.Fatalf("error = %v, want operator guidance", err) } if strings.Contains(err.Error(), "member") { t.Fatalf("operator error suggests target membership: %v", err) } }) } } // TestAPISessionStoreGetWireShape pins handleAPIStoreGet's contract: a GET // to the same .../store path, decoding the full reply including drift and // pending-finish. func TestAPISessionStoreGetWireShape(t *testing.T) { var gotPath, gotMethod string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath, gotMethod = r.URL.RequestURI(), r.Method json.NewEncoder(w).Encode(map[string]any{ "app": "keymail", "backend": "aws", "declared": map[string]any{"env_prefix": "CARLOS_STORE", "at": "2026-08-10T12:00:00Z", "by": "a@keymail.dev"}, "grant": map[string]any{"backend": "aws", "at": "2026-08-10T12:01:00Z", "by": "ops@keymail.dev", "source": "account-approval"}, "provisioned": map[string]any{"bucket": "b1", "region": "eu-west-1", "iam_user": "u1", "user_arn": "arn:aws:iam::1:user/u1", "key_id": "AKIA1", "at": "2026-08-10T12:02:00Z"}, "env_names": []string{"CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION", "CARLOS_STORE_ACCESS_KEY", "CARLOS_STORE_SECRET_KEY"}, "pending_finish": true, "missing_vars": []string{"CARLOS_STORE_SECRET_KEY"}, }) })) defer ts.Close() res, err := newTestSession(ts.URL).storeGet(context.Background(), apiAcct{Seg: "acct"}, "keymail", "") if err != nil { t.Fatalf("storeGet: %v", err) } if gotMethod != http.MethodGet || gotPath != "/api/cli/apps/acct/keymail/store" { t.Fatalf("method/path = %s %s, want GET .../store", gotMethod, gotPath) } if !res.PendingFinish { t.Fatal("PendingFinish = false, want true") } if len(res.MissingVars) != 1 || res.MissingVars[0] != "CARLOS_STORE_SECRET_KEY" { t.Fatalf("MissingVars = %v, want the one undelivered var", res.MissingVars) } if res.Provisioned == nil || res.Provisioned.KeyID != "AKIA1" { t.Fatalf("Provisioned = %+v, want the echoed key id", res.Provisioned) } if res.Grant == nil || res.Grant.Source != "account-approval" { t.Fatalf("Grant = %+v, want account-approval source", res.Grant) } } // TestAPISessionStoreGetNotDeclaredSurfacesServerText pins the real (not // byte-identical-membership) 404 handleAPIStoreGet answers once membership // is already proven: "no store is declared" reaches the terminal verbatim. func TestAPISessionStoreGetNotDeclaredSurfacesServerText(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"error": "no store is declared for this app — run `carlos store create`"}) })) defer ts.Close() _, err := newTestSession(ts.URL).storeGet(context.Background(), apiAcct{Seg: "acct"}, "keymail", "") if err == nil || !strings.Contains(err.Error(), "no store is declared") { t.Fatalf("storeGet 404 = %v, want the server's own text", err) } } // TestAPISessionStoreGrantWireShape pins handleAPIStoreGrant's contract: a // POST to .../store/grant, and — the review-tested case — a bare grant (no // -bucket) sends `{}`. func TestAPISessionStoreGrantWireShape(t *testing.T) { var gotPath, gotBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.RequestURI() body := new(bytes.Buffer) body.ReadFrom(r.Body) gotBody = body.String() json.NewEncoder(w).Encode(map[string]any{ "app": "keymail", "backend": "aws", "provisioned": map[string]any{"bucket": "carlos-keymail-abc", "region": "eu-west-1", "iam_user": "carlos-keymail", "user_arn": "arn:aws:iam::1:user/carlos-keymail", "key_id": "AKIA1", "at": "2026-08-10T12:02:00Z"}, "env_names": []string{"CARLOS_STORE_BUCKET"}, "pending_finish": false, "missing_vars": []string{}, }) })) defer ts.Close() res, err := newTestSession(ts.URL).storeGrant(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", "") if err != nil { t.Fatalf("storeGrant: %v", err) } if gotPath != "/api/cli/apps/acct/keymail/store/grant" { t.Fatalf("path = %q, want the grant door", gotPath) } if strings.TrimSpace(gotBody) != "{}" { t.Fatalf("body = %q, want the bare {} a default grant sends", gotBody) } if res.Provisioned == nil || res.Provisioned.Bucket != "carlos-keymail-abc" { t.Fatalf("Provisioned = %+v, want the minted bucket echoed", res.Provisioned) } } // TestAPISessionStoreGrantSendsChosenBucket pins the adopt case: a -bucket // travels verbatim in the request body. func TestAPISessionStoreGrantSendsChosenBucket(t *testing.T) { var gotBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body := new(bytes.Buffer) body.ReadFrom(r.Body) gotBody = body.String() json.NewEncoder(w).Encode(map[string]any{"app": "keymail", "env_names": []string{}, "missing_vars": []string{}}) })) defer ts.Close() if _, err := newTestSession(ts.URL).storeGrant(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", "my-existing-bucket"); err != nil { t.Fatalf("storeGrant: %v", err) } var sent struct { Bucket string `json:"bucket"` } if err := json.Unmarshal([]byte(gotBody), &sent); err != nil || sent.Bucket != "my-existing-bucket" { t.Fatalf("body = %q, want bucket my-existing-bucket", gotBody) } } // TestAPISessionStoreGrant403SurfacesVerbatim pins the operator-allowlist // refusal: printed exactly as the console worded it, never pattern-matched // or rewritten — the same rule apiErrorFromBody already gives every other // verb's server diagnosis. func TestAPISessionStoreGrant403SurfacesVerbatim(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": "store grants need a deployment operator (CARLOS_STORE_OPERATORS)"}) })) defer ts.Close() _, err := newTestSession(ts.URL).storeGrant(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", "") if err == nil || err.Error() != "store grants need a deployment operator (CARLOS_STORE_OPERATORS)" { t.Fatalf("storeGrant 403 = %v, want the console's own text byte-for-byte", err) } } // TestAPISessionStoreRotateWireShape pins handleAPIStoreRotate's contract: // POST .../store/rotate, no body. func TestAPISessionStoreRotateWireShape(t *testing.T) { var gotPath, gotBody string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.RequestURI() body := new(bytes.Buffer) body.ReadFrom(r.Body) gotBody = body.String() json.NewEncoder(w).Encode(map[string]any{ "app": "keymail", "provisioned": map[string]any{"bucket": "b1", "region": "eu-west-1", "key_id": "AKIA2", "prev_key_id": "AKIA1", "at": "2026-08-10T12:03:00Z"}, "pending_finish": true, "env_names": []string{}, "missing_vars": []string{}, }) })) defer ts.Close() res, err := newTestSession(ts.URL).storeRotate(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", false) if err != nil { t.Fatalf("storeRotate: %v", err) } if gotPath != "/api/cli/apps/acct/keymail/store/rotate" { t.Fatalf("path = %q, want the rotate door (not /finish)", gotPath) } if strings.TrimSpace(gotBody) != "{}" { t.Fatalf("body = %q, want no arguments", gotBody) } if res.Provisioned == nil || res.Provisioned.KeyID != "AKIA2" { t.Fatalf("Provisioned = %+v, want the new key echoed", res.Provisioned) } } // TestAPISessionStoreRotateFinishHitsFinishDoor pins the ONE thing that // distinguishes rotate from rotate -finish on the wire: which path is // POSTed. Everything else about the request is identical. func TestAPISessionStoreRotateFinishHitsFinishDoor(t *testing.T) { var gotPath string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.RequestURI() json.NewEncoder(w).Encode(map[string]any{ "app": "keymail", "provisioned": map[string]any{"bucket": "b1", "region": "eu-west-1", "key_id": "AKIA2", "at": "2026-08-10T12:04:00Z"}, "pending_finish": false, "env_names": []string{}, "missing_vars": []string{}, }) })) defer ts.Close() res, err := newTestSession(ts.URL).storeRotate(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", true) if err != nil { t.Fatalf("storeRotate(finish=true): %v", err) } if gotPath != "/api/cli/apps/acct/keymail/store/rotate/finish" { t.Fatalf("path = %q, want the finish door", gotPath) } if res.PendingFinish { t.Fatal("PendingFinish = true after finish, want false") } } // TestAPISessionStoreRotateConflictSurfacesVerbatim pins the "nothing // provisioned to rotate" 409, printed as the console worded it. func TestAPISessionStoreRotateConflictSurfacesVerbatim(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": "no provisioned store to rotate — grant one first"}) })) defer ts.Close() _, err := newTestSession(ts.URL).storeRotate(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", false) if err == nil || !strings.Contains(err.Error(), "grant one first") { t.Fatalf("storeRotate 409 = %v, want the console's own text", err) } } // ---- print* output honesty --------------------------------------------------- // // Each print function is driven directly against a literal apiStoreReply — // no stub server, no credential file — so the wording rules stay pinned // independent of the wire plumbing above. func TestPrintStoreCreate(t *testing.T) { var buf bytes.Buffer printStoreCreate(&buf, apiStoreReply{ App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, EnvNames: []string{"CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION"}, }) out := buf.String() for _, want := range []string{"keymail", "CARLOS_STORE", "CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION", "this account is not approved for object storage", "carlos store apply"} { if !strings.Contains(out, want) { t.Fatalf("printStoreCreate missing %q:\n%s", want, out) } } if strings.Contains(out, "CARLOS_STORE_BACKEND") { t.Fatalf("printStoreCreate with a configured backend must not print the not-configured line:\n%s", out) } } // TestPrintStoreCreateNoBackendWarnsOperatorGap pins the honesty rule: a // deployment with NO object storage backend at all must say so, naming the // env var an operator sets — a member cannot see CARLOS_STORE_BACKEND for // themselves, and "declared, nothing happening" with no way to tell a queued // request from one that will never be granted fails the no-infra-access rule. func TestPrintStoreCreateNoBackendWarnsOperatorGap(t *testing.T) { var buf bytes.Buffer printStoreCreate(&buf, apiStoreReply{ App: "keymail", Backend: "", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, EnvNames: []string{"CARLOS_STORE_BUCKET"}, }) out := buf.String() if !strings.Contains(out, "CARLOS_STORE_BACKEND") { t.Fatalf("printStoreCreate with backend==\"\" must name CARLOS_STORE_BACKEND:\n%s", out) } if !strings.Contains(out, "no object storage backend") { t.Fatalf("printStoreCreate with backend==\"\" must say this deployment has none:\n%s", out) } } func TestPrintStoreGrant(t *testing.T) { var buf bytes.Buffer printStoreGrant(&buf, apiStoreReply{ App: "keymail", Provisioned: &apiStoreProvisioned{ Bucket: "carlos-keymail-abc", Region: "eu-west-1", IAMUser: "carlos-keymail", }, }) out := buf.String() for _, want := range []string{"keymail", "carlos-keymail-abc", "eu-west-1", "carlos-keymail", "sealed into the app's config", "within seconds"} { if !strings.Contains(out, want) { t.Fatalf("printStoreGrant missing %q:\n%s", want, out) } } // The lesson restart.go paid for: never claim a box has already acted. for _, mustNotClaim := range []string{"instance has the", "instances have the", "delivered"} { if strings.Contains(strings.ToLower(out), strings.ToLower(mustNotClaim)) { t.Fatalf("printStoreGrant over-claims delivery already happened (%q):\n%s", mustNotClaim, out) } } } func TestPrintStoreRotate(t *testing.T) { var buf bytes.Buffer printStoreRotate(&buf, apiStoreReply{ App: "keymail", Provisioned: &apiStoreProvisioned{KeyID: "AKIANEW"}, }, false) out := buf.String() for _, want := range []string{"keymail", "AKIANEW", "old key stays active", "carlos store rotate --finish --app keymail"} { if !strings.Contains(out, want) { t.Fatalf("printStoreRotate missing %q:\n%s", want, out) } } } func TestPrintStoreRotateFinish(t *testing.T) { var buf bytes.Buffer printStoreRotate(&buf, apiStoreReply{App: "keymail"}, true) out := buf.String() if !strings.Contains(out, "previous key deleted") || !strings.Contains(out, "keymail") { t.Fatalf("printStoreRotate(finish) = %q, want the previous-key-deleted confirmation", out) } if strings.Contains(out, "stays active") { t.Fatalf("finish output must not repeat the pending-finish nag:\n%s", out) } } // TestPrintStoreStatusPendingFinishNag pins the nag: a status read on a // half-rotated store must name the exact finish command. func TestPrintStoreStatusPendingFinishNag(t *testing.T) { var buf bytes.Buffer printStoreStatus(&buf, apiStoreReply{ App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Provisioned: &apiStoreProvisioned{ Bucket: "b1", Region: "eu-west-1", IAMUser: "u1", KeyID: "AKIA2", PrevKeyID: "AKIA1", }, PendingFinish: true, }) out := buf.String() if !strings.Contains(out, "pending finish") { t.Fatalf("status output missing the pending-finish nag:\n%s", out) } if !strings.Contains(out, "carlos store rotate --finish --app keymail") { t.Fatalf("status output does not name the exact finish command:\n%s", out) } if !strings.Contains(out, "last credential change") { t.Fatalf("status output must label the provisioned timestamp \"last credential change\":\n%s", out) } if strings.Contains(out, "provisioned at") { t.Fatalf("status output must NOT say \"provisioned at\" — that label goes stale on rotate/repair:\n%s", out) } } // TestPrintStoreStatusMissingVarsWarnsDriftAndRepair pins the drift warning: // it must name `carlos store grant` as the repair AND note that a re-run // rotates the credential — the two facts the reviewer required together, so // nobody re-runs grant expecting a no-op. func TestPrintStoreStatusMissingVarsWarnsDriftAndRepair(t *testing.T) { var buf bytes.Buffer printStoreStatus(&buf, apiStoreReply{ App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Provisioned: &apiStoreProvisioned{ Bucket: "b1", Region: "eu-west-1", IAMUser: "u1", KeyID: "AKIA1", }, MissingVars: []string{"CARLOS_STORE_SECRET_KEY"}, }) out := buf.String() if !strings.Contains(out, "CARLOS_STORE_SECRET_KEY") { t.Fatalf("status output missing the drifted variable's name:\n%s", out) } if !strings.Contains(out, "carlos store grant --app keymail") { t.Fatalf("status output does not name the repair command:\n%s", out) } if !strings.Contains(out, "rotates the credential") { t.Fatalf("status output does not warn that the repair rotates the credential:\n%s", out) } } // TestPrintStoreStatusNoBackend pins the same not-configured line status // owes that create does. func TestPrintStoreStatusNoBackend(t *testing.T) { var buf bytes.Buffer printStoreStatus(&buf, apiStoreReply{App: "keymail", Backend: "", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}}) out := buf.String() if !strings.Contains(out, "CARLOS_STORE_BACKEND") { t.Fatalf("status output with backend==\"\" must name CARLOS_STORE_BACKEND:\n%s", out) } } // TestPrintStoreStatusNotDeclared pins the "nothing to show" shape — no // panics on nil blocks, and the create command named as the way forward. func TestPrintStoreStatusNotDeclared(t *testing.T) { var buf bytes.Buffer printStoreStatus(&buf, apiStoreReply{App: "keymail", Backend: "aws"}) out := buf.String() if !strings.Contains(out, "not declared") || !strings.Contains(out, "carlos store create --app keymail") { t.Fatalf("status output for an undeclared store = %q, want the create pointer", out) } } // TestPrintStoreStatusDeclaredNotProvisioned pins the waiting-on-an-operator // state: a declared-but-not-granted store must say it is waiting, not // silently omit the provisioned section. func TestPrintStoreStatusDeclaredNotProvisioned(t *testing.T) { var buf bytes.Buffer printStoreStatus(&buf, apiStoreReply{ App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, }) out := buf.String() if !strings.Contains(out, "waiting on a deployment operator") { t.Fatalf("status output for a declared-not-granted store missing the waiting line:\n%s", out) } if !strings.Contains(out, "carlos store grant --app keymail") { t.Fatalf("status output missing the grant pointer:\n%s", out) } } func TestPrintStoreEntitlementStates(t *testing.T) { when := time.Date(2026, 9, 2, 15, 4, 5, 0, time.UTC) tests := []struct { name string ent *objstore.Entitlement want []string absent []string }{ {name: "not applied", want: []string{"this account is not approved for object storage", "carlos store apply"}}, {name: "pending", ent: &objstore.Entitlement{Applied: &objstore.Applied{By: "owner@example.com", At: when}}, want: []string{"applied by owner@example.com on 2026-09-02T15:04:05Z", "waiting for a deployment operator"}}, {name: "approved decision only", ent: &objstore.Entitlement{Decision: &objstore.Decision{Approved: true}}, absent: []string{"not approved", "waiting for a deployment operator", "declined"}}, {name: "declined decision only", ent: &objstore.Entitlement{Decision: &objstore.Decision{Reason: "need billing details"}}, want: []string{"declined", "need billing details", "apply again"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var out bytes.Buffer printStoreStatus(&out, apiStoreReply{App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Entitlement: tt.ent}) for _, want := range tt.want { if !strings.Contains(out.String(), want) { t.Errorf("missing %q:\n%s", want, out.String()) } } for _, absent := range tt.absent { if strings.Contains(out.String(), absent) { t.Errorf("contains %q:\n%s", absent, out.String()) } } }) } } func TestPrintStoreStatusRepairVerbFollowsCurrentApproval(t *testing.T) { for _, tt := range []struct { name string ent *objstore.Entitlement want string }{ {name: "approved", ent: approvedEntitlement(), want: "carlos store create --app keymail"}, {name: "revoked", ent: &objstore.Entitlement{Applied: &objstore.Applied{}, Decision: &objstore.Decision{Approved: false}}, want: "carlos store grant --app keymail"}, } { t.Run(tt.name, func(t *testing.T) { var partial bytes.Buffer printStoreStatus(&partial, apiStoreReply{App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Grant: &apiStoreGrantBlock{}, Entitlement: tt.ent}) if !strings.Contains(partial.String(), tt.want) { t.Fatalf("partial grant output missing %q:\n%s", tt.want, partial.String()) } var drift bytes.Buffer printStoreStatus(&drift, apiStoreReply{App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Provisioned: &apiStoreProvisioned{}, MissingVars: []string{"CARLOS_STORE_SECRET_KEY"}, Entitlement: tt.ent}) if !strings.Contains(drift.String(), tt.want) { t.Fatalf("drift output missing %q:\n%s", tt.want, drift.String()) } }) } } func TestPrintStoreCreateProvisionedAndDrift(t *testing.T) { var healthy bytes.Buffer printStoreCreate(&healthy, apiStoreReply{App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Provisioned: &apiStoreProvisioned{Bucket: "b", Region: "eu-west-1", IAMUser: "u"}}) if !strings.Contains(healthy.String(), "bucket b") || strings.Contains(healthy.String(), "nothing is provisioned") { t.Fatalf("healthy provisioned output = %q", healthy.String()) } var drift bytes.Buffer printStoreCreate(&drift, apiStoreReply{App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Provisioned: &apiStoreProvisioned{Bucket: "b"}, MissingVars: []string{"CARLOS_STORE_SECRET_KEY"}, Entitlement: approvedEntitlement()}) if !strings.Contains(drift.String(), "CARLOS_STORE_SECRET_KEY") || !strings.Contains(drift.String(), "carlos store create --app keymail") { t.Fatalf("provisioned drift output = %q", drift.String()) } if strings.Contains(drift.String(), "credentials are sealed") { t.Fatalf("drift output claims healthy delivery: %q", drift.String()) } var revoked bytes.Buffer printStoreCreate(&revoked, apiStoreReply{App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Provisioned: &apiStoreProvisioned{Bucket: "b"}, MissingVars: []string{"CARLOS_STORE_SECRET_KEY"}}) if !strings.Contains(revoked.String(), "carlos store grant --app keymail") { t.Fatalf("unapproved provisioned drift output = %q, want operator repair", revoked.String()) } } func TestPrintStoreApprovalQueue(t *testing.T) { var out bytes.Buffer printStoreApprovals(&out, apiStoreApprovalsReply{Applications: []*objstore.Entitlement{ {Account: "bac", Applied: &objstore.Applied{By: "owner@example.com", At: time.Date(2026, 9, 1, 9, 0, 0, 0, time.UTC), Note: "backups"}}, }}) for _, want := range []string{"bac", "owner@example.com", "2026-09-01T09:00:00Z", "backups", "carlos store approve --account bac", "carlos store decline --account bac --reason"} { if !strings.Contains(out.String(), want) { t.Errorf("missing %q:\n%s", want, out.String()) } } } // ---- runStore dispatch / refusals (real binary) ----------------------------- // TestStoreVerbDispatchAndRefusals drives the real binary through // carlosTestEnv with a scratch cmd.Dir — the repo rule (AGENTS.md) — so a // logged-in laptop's real credential and console can never be what this test // exercises: unknown sub-verb, missing -app on each sub-verb, and direct // bucket mode refused with store's own sentence. func TestStoreVerbDispatchAndRefusals(t *testing.T) { bin := buildCarlos(t) scratch := t.TempDir() run := func(env []string, args ...string) (string, error) { t.Helper() cmd := exec.Command(bin, args...) cmd.Env = carlosTestEnv(t, env...) cmd.Dir = scratch out, err := cmd.CombinedOutput() return string(out), err } // No sub-verb at all, and an unknown one: both print the usage block. for _, args := range [][]string{{"store"}, {"store", "wat"}} { out, err := run(nil, args...) if err == nil { t.Fatalf("carlos %v exited 0:\n%s", args, out) } if !strings.Contains(out, "usage: carlos store ") { t.Fatalf("carlos %v = %q, want the store usage block", args, out) } } // -app is resolvable since the project-defaults spec (2026-08-13), so a // missing -app with nothing configured stops on the REAL blocker — no // session — not on a usage line for a flag that is no longer required. for _, sub := range []string{"create", "status", "grant", "rotate"} { out, err := run(nil, "store", sub) if err == nil { t.Fatalf("carlos store %s with no --app exited 0:\n%s", sub, out) } if !strings.Contains(out, "no deployment target configured") { t.Fatalf("carlos store %s with no --app = %q, want the no-target refusal", sub, out) } } // Direct bucket mode: refused in store's OWN words, naming the login // that fixes it — never another verb's advice. out, err := run([]string{"CARLOS_DEPLOYMENT_DIR=" + t.TempDir()}, "store", "status", "-app", "keymail") if err == nil { t.Fatalf("store in direct bucket mode exited 0:\n%s", out) } if !strings.Contains(out, "carlos store works through a console") || !strings.Contains(out, "carlos auth login") { t.Fatalf("direct-mode refusal = %q, want store's own message and the login fix", out) } // A malshaped app name is refused at the terminal before any dial. out, err = run(nil, "store", "status", "-app", "Not An App") if err == nil { t.Fatalf("store status with a bad app name exited 0:\n%s", out) } if !strings.Contains(out, "not a usable app name") { t.Fatalf("bad app name = %q, want the name gate", out) } } // TestPrintStoreStatusSize: a provisioned store prints its swept size with // the sweep's date, "not measured yet" before any sweep, and a warning (never // a 0) when the sweep could not read the bucket. func TestPrintStoreStatusSize(t *testing.T) { base := apiStoreReply{App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Provisioned: &apiStoreProvisioned{Bucket: "b1", Region: "eu-west-1", IAMUser: "u1", KeyID: "AKIA1"}} for _, tc := range []struct { name string size *apiStoreSize want string }{ {"swept", &apiStoreSize{AsOf: "2026-09-17", StoreBytes: 3 << 30}, "size: 3.0GB (as of 2026-09-17)"}, {"never", nil, "size: not measured yet"}, {"unswept", &apiStoreSize{AsOf: "2026-09-17", Unswept: true}, "could not read this bucket"}, } { var buf bytes.Buffer res := base res.Size = tc.size printStoreStatus(&buf, res) if !strings.Contains(buf.String(), tc.want) { t.Errorf("%s: output missing %q:\n%s", tc.name, tc.want, buf.String()) } } }