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 `carlos store` (issue #55): the wire shape the four apiSession
2// methods send (request body/method/path, matching apistore_test.go's server
3// side), the honesty rules the print* functions must follow (create/status/
4// grant/rotate — no line claims a box has converged), and the two ways the
5// verb refuses before it ever dials anything (no -app, direct bucket mode) —
6// driven through the real binary via carlosTestEnv + a scratch cmd.Dir, the
7// same isolation restart_test.go's refusal tests use.
8package main
9
10import (
11 "bytes"
12 "context"
13 "encoding/json"
14 "errors"
15 "flag"
16 "io"
17 "net/http"
18 "net/http/httptest"
19 "os/exec"
20 "strings"
21 "testing"
22 "time"
23
24 "github.com/carlosframework/platform/internal/objstore"
25)
26
27func approvedEntitlement() *objstore.Entitlement {
28 return &objstore.Entitlement{
29 Schema: objstore.EntitlementSchema,
30 Account: "bac",
31 Applied: &objstore.Applied{By: "owner@example.com", At: time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)},
32 Decision: &objstore.Decision{Approved: true, By: "operator@example.com",
33 At: time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC)},
34 }
35}
36
37// ---- apiSession.store* wire shape -------------------------------------------
38
39// TestAPISessionStoreCreateWireShape pins handleAPIStoreCreate's contract: a
40// bearer POST to .../store, and — the review-tested case — a bare create
41// (no -env-prefix) sends the literal `{}` body, never a key with an empty
42// string value, since the door treats an ABSENT key and an empty one the
43// same way but a differently-shaped body is still the wrong thing to send.
44func TestAPISessionStoreCreateWireShape(t *testing.T) {
45 var gotPath, gotMethod, gotAuth, gotBody string
46 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
47 gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization")
48 body := new(bytes.Buffer)
49 body.ReadFrom(r.Body)
50 gotBody = body.String()
51 json.NewEncoder(w).Encode(map[string]any{
52 "app": "keymail", "backend": "aws",
53 "declared": map[string]any{"env_prefix": "CARLOS_STORE", "at": "2026-08-10T12:00:00Z", "by": "a@keymail.dev"},
54 "grant": nil,
55 "provisioned": nil,
56 "env_names": []string{"CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION"},
57 "pending_finish": false,
58 "missing_vars": []string{},
59 })
60 }))
61 defer ts.Close()
62
63 res, err := newTestSession(ts.URL).storeCreate(context.Background(), apiAcct{Seg: "keymail-team"}, "keymail", "", "")
64 if err != nil {
65 t.Fatalf("storeCreate: %v", err)
66 }
67 if gotMethod != http.MethodPost {
68 t.Fatalf("method = %s, want POST", gotMethod)
69 }
70 if gotPath != "/api/cli/apps/keymail-team/keymail/store" {
71 t.Fatalf("path = %s", gotPath)
72 }
73 if gotAuth != "Bearer cli-token" {
74 t.Fatalf("auth = %q", gotAuth)
75 }
76 if strings.TrimSpace(gotBody) != "{}" {
77 t.Fatalf("body = %q, want the bare {} a default create sends", gotBody)
78 }
79 if res.App != "keymail" || res.Backend != "aws" {
80 t.Fatalf("result = %+v, want the console's echo", res)
81 }
82 if res.Declared == nil || res.Declared.EnvPrefix != "CARLOS_STORE" {
83 t.Fatalf("Declared = %+v, want the echoed prefix", res.Declared)
84 }
85}
86
87// TestAPISessionStoreCreateSendsChosenPrefix pins the other half: a chosen
88// -env-prefix travels verbatim, not silently defaulted client-side (the
89// server owns the default, per handleAPIStoreCreate's own doc comment).
90func TestAPISessionStoreCreateSendsChosenPrefix(t *testing.T) {
91 var gotBody string
92 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93 body := new(bytes.Buffer)
94 body.ReadFrom(r.Body)
95 gotBody = body.String()
96 json.NewEncoder(w).Encode(map[string]any{
97 "app": "keymail", "backend": "aws",
98 "declared": map[string]any{"env_prefix": "TITO_S3", "at": "2026-08-10T12:00:00Z", "by": "a@keymail.dev"},
99 "env_names": []string{"TITO_S3_BUCKET"},
100 "pending_finish": false,
101 "missing_vars": []string{},
102 })
103 }))
104 defer ts.Close()
105
106 if _, err := newTestSession(ts.URL).storeCreate(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", "TITO_S3"); err != nil {
107 t.Fatalf("storeCreate: %v", err)
108 }
109 var sent struct {
110 EnvPrefix string `json:"env_prefix"`
111 }
112 if err := json.Unmarshal([]byte(gotBody), &sent); err != nil || sent.EnvPrefix != "TITO_S3" {
113 t.Fatalf("body = %q, want env_prefix TITO_S3", gotBody)
114 }
115}
116
117func TestStoreCreateAndProvisionPrintsDeclarationBeforeProvisionFailure(t *testing.T) {
118 var paths []string
119 var out bytes.Buffer
120 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
121 paths = append(paths, r.URL.Path)
122 switch r.URL.Path {
123 case "/api/cli/apps/bac/keymail/store":
124 json.NewEncoder(w).Encode(apiStoreReply{
125 App: "keymail", Backend: "aws", Entitlement: approvedEntitlement(),
126 Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE", By: "owner@example.com"},
127 EnvNames: []string{"CARLOS_STORE_BUCKET"},
128 })
129 case "/api/cli/apps/bac/keymail/store/provision":
130 if !strings.Contains(out.String(), "declared a store for keymail") {
131 t.Errorf("provision began before declaration was printed: %q", out.String())
132 }
133 w.WriteHeader(http.StatusBadGateway)
134 json.NewEncoder(w).Encode(map[string]string{"error": "object storage provisioning failed — retry store create; a recently destroyed bucket name may still be unavailable"})
135 default:
136 http.NotFound(w, r)
137 }
138 }))
139 defer ts.Close()
140
141 err := storeCreateAndProvision(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bac"}, "keymail", "", "", &out)
142 if err == nil || !strings.Contains(err.Error(), "provisioning failed") {
143 t.Fatalf("error = %v, want provision failure", err)
144 }
145 if got := strings.Join(paths, ","); got != "/api/cli/apps/bac/keymail/store,/api/cli/apps/bac/keymail/store/provision" {
146 t.Fatalf("paths = %q, want declare then provision", got)
147 }
148}
149
150func TestStoreCreateAndProvisionBranchesOnReply(t *testing.T) {
151 tests := []struct {
152 name string
153 backend string
154 entitlement *objstore.Entitlement
155 wantCalls int
156 }{
157 {name: "approved with backend", backend: "aws", entitlement: approvedEntitlement(), wantCalls: 2},
158 {name: "approved without backend", entitlement: approvedEntitlement(), wantCalls: 1},
159 {name: "not applied", backend: "aws", wantCalls: 1},
160 {name: "pending", backend: "aws", entitlement: &objstore.Entitlement{Applied: &objstore.Applied{}}, wantCalls: 1},
161 {name: "declined", backend: "aws", entitlement: &objstore.Entitlement{Applied: &objstore.Applied{}, Decision: &objstore.Decision{Approved: false}}, wantCalls: 1},
162 }
163 for _, tt := range tests {
164 t.Run(tt.name, func(t *testing.T) {
165 calls := 0
166 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
167 calls++
168 if strings.HasSuffix(r.URL.Path, "/provision") {
169 json.NewEncoder(w).Encode(apiStoreReply{App: "keymail", Backend: tt.backend, Entitlement: tt.entitlement, Provisioned: &apiStoreProvisioned{Bucket: "b", Region: "eu-west-1", IAMUser: "u"}})
170 return
171 }
172 json.NewEncoder(w).Encode(apiStoreReply{App: "keymail", Backend: tt.backend, Entitlement: tt.entitlement, Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}})
173 }))
174 defer ts.Close()
175 if err := storeCreateAndProvision(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bac"}, "keymail", "", "", new(bytes.Buffer)); err != nil {
176 t.Fatal(err)
177 }
178 if calls != tt.wantCalls {
179 t.Fatalf("calls = %d, want %d", calls, tt.wantCalls)
180 }
181 })
182 }
183}
184
185func TestStoreProvisionUsesLongWalkClient(t *testing.T) {
186 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
187 time.Sleep(40 * time.Millisecond)
188 json.NewEncoder(w).Encode(apiStoreReply{App: "keymail"})
189 }))
190 defer ts.Close()
191 s := newTestSession(ts.URL)
192 s.c.httpc.Timeout = time.Millisecond
193 if _, err := s.storeProvision(context.Background(), apiAcct{Seg: "bac"}, "keymail", ""); err != nil {
194 t.Fatalf("storeProvision inherited ordinary JSON timeout: %v", err)
195 }
196 if s.c.httpc.Timeout != time.Millisecond {
197 t.Fatalf("ordinary client timeout changed to %s", s.c.httpc.Timeout)
198 }
199}
200
201func TestStoreOperatorDoorsUseExplicitSqidWithoutMembershipResolution(t *testing.T) {
202 var paths []string
203 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
204 paths = append(paths, r.Method+" "+r.URL.RequestURI())
205 json.NewEncoder(w).Encode(apiStoreAccountReply{Account: "bac", Entitlement: &objstore.Entitlement{Account: "bac"}})
206 }))
207 defer ts.Close()
208 s := newTestSession(ts.URL)
209 if _, err := s.storeApprove(context.Background(), "bac"); err != nil {
210 t.Fatal(err)
211 }
212 if _, err := s.storeDecline(context.Background(), "bac", "not yet"); err != nil {
213 t.Fatal(err)
214 }
215 want := []string{
216 "POST /api/cli/accounts/bac/store/approve",
217 "POST /api/cli/accounts/bac/store/decline",
218 }
219 if strings.Join(paths, "\n") != strings.Join(want, "\n") {
220 t.Fatalf("paths = %#v, want %#v", paths, want)
221 }
222 if s.acctsFetched {
223 t.Fatal("operator decision fetched memberships")
224 }
225}
226
227func TestRunStoreApproveWithSessionDoesNotResolveMemberships(t *testing.T) {
228 var path string
229 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
230 path = r.URL.RequestURI()
231 json.NewEncoder(w).Encode(apiStoreAccountReply{Account: "bac", Entitlement: &objstore.Entitlement{Account: "bac"}})
232 }))
233 defer ts.Close()
234 s := newTestSession(ts.URL)
235 var out bytes.Buffer
236 if err := runStoreApproveWithSession(context.Background(), s, []string{"--account", "bac"}, &out); err != nil {
237 t.Fatal(err)
238 }
239 if path != "/api/cli/accounts/bac/store/approve" {
240 t.Fatalf("path = %q", path)
241 }
242 if s.acctsFetched {
243 t.Fatal("store approve fetched memberships")
244 }
245}
246
247func TestRunStoreApproveHelpAndValidationStopBeforeNetwork(t *testing.T) {
248 hits := 0
249 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
250 hits++
251 }))
252 defer ts.Close()
253 s := newTestSession(ts.URL)
254
255 var help bytes.Buffer
256 if err := runStoreApproveWithSession(context.Background(), s, []string{"--help"}, &help); !errors.Is(err, flag.ErrHelp) {
257 t.Fatalf("--help error = %v, want flag.ErrHelp", err)
258 }
259 if !strings.Contains(help.String(), "usage: carlos store approve") || !strings.Contains(help.String(), "--account") {
260 t.Fatalf("--help output = %q", help.String())
261 }
262 for _, args := range [][]string{{"--account", "account-name"}, {"--account", "bac", "extra"}, {}} {
263 if err := runStoreApproveWithSession(context.Background(), s, args, io.Discard); err == nil {
264 t.Fatalf("args %q succeeded", args)
265 }
266 }
267 if hits != 0 {
268 t.Fatalf("validation made %d requests", hits)
269 }
270}
271
272func TestStoreApplyUsesOwnerAccountPathAndApprovalsUseFleetPath(t *testing.T) {
273 var paths []string
274 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
275 paths = append(paths, r.Method+" "+r.URL.RequestURI())
276 if r.URL.Path == "/api/cli/store/approvals" {
277 json.NewEncoder(w).Encode(apiStoreApprovalsReply{Applications: []*objstore.Entitlement{{Account: "bac"}}})
278 return
279 }
280 json.NewEncoder(w).Encode(apiStoreAccountReply{Account: "bac", Entitlement: &objstore.Entitlement{Account: "bac"}})
281 }))
282 defer ts.Close()
283 s := newTestSession(ts.URL)
284 if _, err := s.storeApply(context.Background(), apiAcct{Seg: "bac", Exact: true}, "needed for backups"); err != nil {
285 t.Fatal(err)
286 }
287 if _, err := s.storeApprovals(context.Background()); err != nil {
288 t.Fatal(err)
289 }
290 want := []string{"POST /api/cli/accounts/bac/store/apply?exact=sqid", "GET /api/cli/store/approvals"}
291 if strings.Join(paths, "\n") != strings.Join(want, "\n") {
292 t.Fatalf("paths = %#v, want %#v", paths, want)
293 }
294}
295
296func TestStoreOperatorBareNotFoundDoesNotSuggestMembership(t *testing.T) {
297 ts := httptest.NewServer(http.NotFoundHandler())
298 defer ts.Close()
299 s := newTestSession(ts.URL)
300 for name, call := range map[string]func() error{
301 "approve": func() error { _, err := s.storeApprove(context.Background(), "bac"); return err },
302 "decline": func() error { _, err := s.storeDecline(context.Background(), "bac", "no"); return err },
303 "approvals": func() error { _, err := s.storeApprovals(context.Background()); return err },
304 } {
305 t.Run(name, func(t *testing.T) {
306 err := call()
307 if err == nil || !strings.Contains(err.Error(), "operator access") {
308 t.Fatalf("error = %v, want operator guidance", err)
309 }
310 if strings.Contains(err.Error(), "member") {
311 t.Fatalf("operator error suggests target membership: %v", err)
312 }
313 })
314 }
315}
316
317// TestAPISessionStoreGetWireShape pins handleAPIStoreGet's contract: a GET
318// to the same .../store path, decoding the full reply including drift and
319// pending-finish.
320func TestAPISessionStoreGetWireShape(t *testing.T) {
321 var gotPath, gotMethod string
322 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
323 gotPath, gotMethod = r.URL.RequestURI(), r.Method
324 json.NewEncoder(w).Encode(map[string]any{
325 "app": "keymail", "backend": "aws",
326 "declared": map[string]any{"env_prefix": "CARLOS_STORE", "at": "2026-08-10T12:00:00Z", "by": "a@keymail.dev"},
327 "grant": map[string]any{"backend": "aws", "at": "2026-08-10T12:01:00Z", "by": "ops@keymail.dev", "source": "account-approval"},
328 "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"},
329 "env_names": []string{"CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION", "CARLOS_STORE_ACCESS_KEY", "CARLOS_STORE_SECRET_KEY"},
330 "pending_finish": true,
331 "missing_vars": []string{"CARLOS_STORE_SECRET_KEY"},
332 })
333 }))
334 defer ts.Close()
335
336 res, err := newTestSession(ts.URL).storeGet(context.Background(), apiAcct{Seg: "acct"}, "keymail", "")
337 if err != nil {
338 t.Fatalf("storeGet: %v", err)
339 }
340 if gotMethod != http.MethodGet || gotPath != "/api/cli/apps/acct/keymail/store" {
341 t.Fatalf("method/path = %s %s, want GET .../store", gotMethod, gotPath)
342 }
343 if !res.PendingFinish {
344 t.Fatal("PendingFinish = false, want true")
345 }
346 if len(res.MissingVars) != 1 || res.MissingVars[0] != "CARLOS_STORE_SECRET_KEY" {
347 t.Fatalf("MissingVars = %v, want the one undelivered var", res.MissingVars)
348 }
349 if res.Provisioned == nil || res.Provisioned.KeyID != "AKIA1" {
350 t.Fatalf("Provisioned = %+v, want the echoed key id", res.Provisioned)
351 }
352 if res.Grant == nil || res.Grant.Source != "account-approval" {
353 t.Fatalf("Grant = %+v, want account-approval source", res.Grant)
354 }
355}
356
357// TestAPISessionStoreGetNotDeclaredSurfacesServerText pins the real (not
358// byte-identical-membership) 404 handleAPIStoreGet answers once membership
359// is already proven: "no store is declared" reaches the terminal verbatim.
360func TestAPISessionStoreGetNotDeclaredSurfacesServerText(t *testing.T) {
361 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
362 w.WriteHeader(http.StatusNotFound)
363 json.NewEncoder(w).Encode(map[string]string{"error": "no store is declared for this app — run `carlos store create`"})
364 }))
365 defer ts.Close()
366
367 _, err := newTestSession(ts.URL).storeGet(context.Background(), apiAcct{Seg: "acct"}, "keymail", "")
368 if err == nil || !strings.Contains(err.Error(), "no store is declared") {
369 t.Fatalf("storeGet 404 = %v, want the server's own text", err)
370 }
371}
372
373// TestAPISessionStoreGrantWireShape pins handleAPIStoreGrant's contract: a
374// POST to .../store/grant, and — the review-tested case — a bare grant (no
375// -bucket) sends `{}`.
376func TestAPISessionStoreGrantWireShape(t *testing.T) {
377 var gotPath, gotBody string
378 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
379 gotPath = r.URL.RequestURI()
380 body := new(bytes.Buffer)
381 body.ReadFrom(r.Body)
382 gotBody = body.String()
383 json.NewEncoder(w).Encode(map[string]any{
384 "app": "keymail", "backend": "aws",
385 "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"},
386 "env_names": []string{"CARLOS_STORE_BUCKET"},
387 "pending_finish": false,
388 "missing_vars": []string{},
389 })
390 }))
391 defer ts.Close()
392
393 res, err := newTestSession(ts.URL).storeGrant(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", "")
394 if err != nil {
395 t.Fatalf("storeGrant: %v", err)
396 }
397 if gotPath != "/api/cli/apps/acct/keymail/store/grant" {
398 t.Fatalf("path = %q, want the grant door", gotPath)
399 }
400 if strings.TrimSpace(gotBody) != "{}" {
401 t.Fatalf("body = %q, want the bare {} a default grant sends", gotBody)
402 }
403 if res.Provisioned == nil || res.Provisioned.Bucket != "carlos-keymail-abc" {
404 t.Fatalf("Provisioned = %+v, want the minted bucket echoed", res.Provisioned)
405 }
406}
407
408// TestAPISessionStoreGrantSendsChosenBucket pins the adopt case: a -bucket
409// travels verbatim in the request body.
410func TestAPISessionStoreGrantSendsChosenBucket(t *testing.T) {
411 var gotBody string
412 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
413 body := new(bytes.Buffer)
414 body.ReadFrom(r.Body)
415 gotBody = body.String()
416 json.NewEncoder(w).Encode(map[string]any{"app": "keymail", "env_names": []string{}, "missing_vars": []string{}})
417 }))
418 defer ts.Close()
419
420 if _, err := newTestSession(ts.URL).storeGrant(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", "my-existing-bucket"); err != nil {
421 t.Fatalf("storeGrant: %v", err)
422 }
423 var sent struct {
424 Bucket string `json:"bucket"`
425 }
426 if err := json.Unmarshal([]byte(gotBody), &sent); err != nil || sent.Bucket != "my-existing-bucket" {
427 t.Fatalf("body = %q, want bucket my-existing-bucket", gotBody)
428 }
429}
430
431// TestAPISessionStoreGrant403SurfacesVerbatim pins the operator-allowlist
432// refusal: printed exactly as the console worded it, never pattern-matched
433// or rewritten — the same rule apiErrorFromBody already gives every other
434// verb's server diagnosis.
435func TestAPISessionStoreGrant403SurfacesVerbatim(t *testing.T) {
436 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
437 w.WriteHeader(http.StatusForbidden)
438 json.NewEncoder(w).Encode(map[string]string{"error": "store grants need a deployment operator (CARLOS_STORE_OPERATORS)"})
439 }))
440 defer ts.Close()
441
442 _, err := newTestSession(ts.URL).storeGrant(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", "")
443 if err == nil || err.Error() != "store grants need a deployment operator (CARLOS_STORE_OPERATORS)" {
444 t.Fatalf("storeGrant 403 = %v, want the console's own text byte-for-byte", err)
445 }
446}
447
448// TestAPISessionStoreRotateWireShape pins handleAPIStoreRotate's contract:
449// POST .../store/rotate, no body.
450func TestAPISessionStoreRotateWireShape(t *testing.T) {
451 var gotPath, gotBody string
452 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
453 gotPath = r.URL.RequestURI()
454 body := new(bytes.Buffer)
455 body.ReadFrom(r.Body)
456 gotBody = body.String()
457 json.NewEncoder(w).Encode(map[string]any{
458 "app": "keymail",
459 "provisioned": map[string]any{"bucket": "b1", "region": "eu-west-1", "key_id": "AKIA2", "prev_key_id": "AKIA1", "at": "2026-08-10T12:03:00Z"},
460 "pending_finish": true,
461 "env_names": []string{},
462 "missing_vars": []string{},
463 })
464 }))
465 defer ts.Close()
466
467 res, err := newTestSession(ts.URL).storeRotate(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", false)
468 if err != nil {
469 t.Fatalf("storeRotate: %v", err)
470 }
471 if gotPath != "/api/cli/apps/acct/keymail/store/rotate" {
472 t.Fatalf("path = %q, want the rotate door (not /finish)", gotPath)
473 }
474 if strings.TrimSpace(gotBody) != "{}" {
475 t.Fatalf("body = %q, want no arguments", gotBody)
476 }
477 if res.Provisioned == nil || res.Provisioned.KeyID != "AKIA2" {
478 t.Fatalf("Provisioned = %+v, want the new key echoed", res.Provisioned)
479 }
480}
481
482// TestAPISessionStoreRotateFinishHitsFinishDoor pins the ONE thing that
483// distinguishes rotate from rotate -finish on the wire: which path is
484// POSTed. Everything else about the request is identical.
485func TestAPISessionStoreRotateFinishHitsFinishDoor(t *testing.T) {
486 var gotPath string
487 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
488 gotPath = r.URL.RequestURI()
489 json.NewEncoder(w).Encode(map[string]any{
490 "app": "keymail",
491 "provisioned": map[string]any{"bucket": "b1", "region": "eu-west-1", "key_id": "AKIA2", "at": "2026-08-10T12:04:00Z"},
492 "pending_finish": false,
493 "env_names": []string{},
494 "missing_vars": []string{},
495 })
496 }))
497 defer ts.Close()
498
499 res, err := newTestSession(ts.URL).storeRotate(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", true)
500 if err != nil {
501 t.Fatalf("storeRotate(finish=true): %v", err)
502 }
503 if gotPath != "/api/cli/apps/acct/keymail/store/rotate/finish" {
504 t.Fatalf("path = %q, want the finish door", gotPath)
505 }
506 if res.PendingFinish {
507 t.Fatal("PendingFinish = true after finish, want false")
508 }
509}
510
511// TestAPISessionStoreRotateConflictSurfacesVerbatim pins the "nothing
512// provisioned to rotate" 409, printed as the console worded it.
513func TestAPISessionStoreRotateConflictSurfacesVerbatim(t *testing.T) {
514 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
515 w.WriteHeader(http.StatusConflict)
516 json.NewEncoder(w).Encode(map[string]string{"error": "no provisioned store to rotate — grant one first"})
517 }))
518 defer ts.Close()
519
520 _, err := newTestSession(ts.URL).storeRotate(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", false)
521 if err == nil || !strings.Contains(err.Error(), "grant one first") {
522 t.Fatalf("storeRotate 409 = %v, want the console's own text", err)
523 }
524}
525
526// ---- print* output honesty ---------------------------------------------------
527//
528// Each print function is driven directly against a literal apiStoreReply —
529// no stub server, no credential file — so the wording rules stay pinned
530// independent of the wire plumbing above.
531
532func TestPrintStoreCreate(t *testing.T) {
533 var buf bytes.Buffer
534 printStoreCreate(&buf, apiStoreReply{
535 App: "keymail",
536 Backend: "aws",
537 Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"},
538 EnvNames: []string{"CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION"},
539 })
540 out := buf.String()
541 for _, want := range []string{"keymail", "CARLOS_STORE", "CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION",
542 "this account is not approved for object storage", "carlos store apply"} {
543 if !strings.Contains(out, want) {
544 t.Fatalf("printStoreCreate missing %q:\n%s", want, out)
545 }
546 }
547 if strings.Contains(out, "CARLOS_STORE_BACKEND") {
548 t.Fatalf("printStoreCreate with a configured backend must not print the not-configured line:\n%s", out)
549 }
550}
551
552// TestPrintStoreCreateNoBackendWarnsOperatorGap pins the honesty rule: a
553// deployment with NO object storage backend at all must say so, naming the
554// env var an operator sets — a member cannot see CARLOS_STORE_BACKEND for
555// themselves, and "declared, nothing happening" with no way to tell a queued
556// request from one that will never be granted fails the no-infra-access rule.
557func TestPrintStoreCreateNoBackendWarnsOperatorGap(t *testing.T) {
558 var buf bytes.Buffer
559 printStoreCreate(&buf, apiStoreReply{
560 App: "keymail",
561 Backend: "",
562 Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"},
563 EnvNames: []string{"CARLOS_STORE_BUCKET"},
564 })
565 out := buf.String()
566 if !strings.Contains(out, "CARLOS_STORE_BACKEND") {
567 t.Fatalf("printStoreCreate with backend==\"\" must name CARLOS_STORE_BACKEND:\n%s", out)
568 }
569 if !strings.Contains(out, "no object storage backend") {
570 t.Fatalf("printStoreCreate with backend==\"\" must say this deployment has none:\n%s", out)
571 }
572}
573
574func TestPrintStoreGrant(t *testing.T) {
575 var buf bytes.Buffer
576 printStoreGrant(&buf, apiStoreReply{
577 App: "keymail",
578 Provisioned: &apiStoreProvisioned{
579 Bucket: "carlos-keymail-abc", Region: "eu-west-1", IAMUser: "carlos-keymail",
580 },
581 })
582 out := buf.String()
583 for _, want := range []string{"keymail", "carlos-keymail-abc", "eu-west-1", "carlos-keymail",
584 "sealed into the app's config", "within seconds"} {
585 if !strings.Contains(out, want) {
586 t.Fatalf("printStoreGrant missing %q:\n%s", want, out)
587 }
588 }
589 // The lesson restart.go paid for: never claim a box has already acted.
590 for _, mustNotClaim := range []string{"instance has the", "instances have the", "delivered"} {
591 if strings.Contains(strings.ToLower(out), strings.ToLower(mustNotClaim)) {
592 t.Fatalf("printStoreGrant over-claims delivery already happened (%q):\n%s", mustNotClaim, out)
593 }
594 }
595}
596
597func TestPrintStoreRotate(t *testing.T) {
598 var buf bytes.Buffer
599 printStoreRotate(&buf, apiStoreReply{
600 App: "keymail",
601 Provisioned: &apiStoreProvisioned{KeyID: "AKIANEW"},
602 }, false)
603 out := buf.String()
604 for _, want := range []string{"keymail", "AKIANEW", "old key stays active", "carlos store rotate --finish --app keymail"} {
605 if !strings.Contains(out, want) {
606 t.Fatalf("printStoreRotate missing %q:\n%s", want, out)
607 }
608 }
609}
610
611func TestPrintStoreRotateFinish(t *testing.T) {
612 var buf bytes.Buffer
613 printStoreRotate(&buf, apiStoreReply{App: "keymail"}, true)
614 out := buf.String()
615 if !strings.Contains(out, "previous key deleted") || !strings.Contains(out, "keymail") {
616 t.Fatalf("printStoreRotate(finish) = %q, want the previous-key-deleted confirmation", out)
617 }
618 if strings.Contains(out, "stays active") {
619 t.Fatalf("finish output must not repeat the pending-finish nag:\n%s", out)
620 }
621}
622
623// TestPrintStoreStatusPendingFinishNag pins the nag: a status read on a
624// half-rotated store must name the exact finish command.
625func TestPrintStoreStatusPendingFinishNag(t *testing.T) {
626 var buf bytes.Buffer
627 printStoreStatus(&buf, apiStoreReply{
628 App: "keymail",
629 Backend: "aws",
630 Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"},
631 Provisioned: &apiStoreProvisioned{
632 Bucket: "b1", Region: "eu-west-1", IAMUser: "u1", KeyID: "AKIA2", PrevKeyID: "AKIA1",
633 },
634 PendingFinish: true,
635 })
636 out := buf.String()
637 if !strings.Contains(out, "pending finish") {
638 t.Fatalf("status output missing the pending-finish nag:\n%s", out)
639 }
640 if !strings.Contains(out, "carlos store rotate --finish --app keymail") {
641 t.Fatalf("status output does not name the exact finish command:\n%s", out)
642 }
643 if !strings.Contains(out, "last credential change") {
644 t.Fatalf("status output must label the provisioned timestamp \"last credential change\":\n%s", out)
645 }
646 if strings.Contains(out, "provisioned at") {
647 t.Fatalf("status output must NOT say \"provisioned at\" — that label goes stale on rotate/repair:\n%s", out)
648 }
649}
650
651// TestPrintStoreStatusMissingVarsWarnsDriftAndRepair pins the drift warning:
652// it must name `carlos store grant` as the repair AND note that a re-run
653// rotates the credential — the two facts the reviewer required together, so
654// nobody re-runs grant expecting a no-op.
655func TestPrintStoreStatusMissingVarsWarnsDriftAndRepair(t *testing.T) {
656 var buf bytes.Buffer
657 printStoreStatus(&buf, apiStoreReply{
658 App: "keymail",
659 Backend: "aws",
660 Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"},
661 Provisioned: &apiStoreProvisioned{
662 Bucket: "b1", Region: "eu-west-1", IAMUser: "u1", KeyID: "AKIA1",
663 },
664 MissingVars: []string{"CARLOS_STORE_SECRET_KEY"},
665 })
666 out := buf.String()
667 if !strings.Contains(out, "CARLOS_STORE_SECRET_KEY") {
668 t.Fatalf("status output missing the drifted variable's name:\n%s", out)
669 }
670 if !strings.Contains(out, "carlos store grant --app keymail") {
671 t.Fatalf("status output does not name the repair command:\n%s", out)
672 }
673 if !strings.Contains(out, "rotates the credential") {
674 t.Fatalf("status output does not warn that the repair rotates the credential:\n%s", out)
675 }
676}
677
678// TestPrintStoreStatusNoBackend pins the same not-configured line status
679// owes that create does.
680func TestPrintStoreStatusNoBackend(t *testing.T) {
681 var buf bytes.Buffer
682 printStoreStatus(&buf, apiStoreReply{App: "keymail", Backend: "", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}})
683 out := buf.String()
684 if !strings.Contains(out, "CARLOS_STORE_BACKEND") {
685 t.Fatalf("status output with backend==\"\" must name CARLOS_STORE_BACKEND:\n%s", out)
686 }
687}
688
689// TestPrintStoreStatusNotDeclared pins the "nothing to show" shape — no
690// panics on nil blocks, and the create command named as the way forward.
691func TestPrintStoreStatusNotDeclared(t *testing.T) {
692 var buf bytes.Buffer
693 printStoreStatus(&buf, apiStoreReply{App: "keymail", Backend: "aws"})
694 out := buf.String()
695 if !strings.Contains(out, "not declared") || !strings.Contains(out, "carlos store create --app keymail") {
696 t.Fatalf("status output for an undeclared store = %q, want the create pointer", out)
697 }
698}
699
700// TestPrintStoreStatusDeclaredNotProvisioned pins the waiting-on-an-operator
701// state: a declared-but-not-granted store must say it is waiting, not
702// silently omit the provisioned section.
703func TestPrintStoreStatusDeclaredNotProvisioned(t *testing.T) {
704 var buf bytes.Buffer
705 printStoreStatus(&buf, apiStoreReply{
706 App: "keymail", Backend: "aws",
707 Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"},
708 })
709 out := buf.String()
710 if !strings.Contains(out, "waiting on a deployment operator") {
711 t.Fatalf("status output for a declared-not-granted store missing the waiting line:\n%s", out)
712 }
713 if !strings.Contains(out, "carlos store grant --app keymail") {
714 t.Fatalf("status output missing the grant pointer:\n%s", out)
715 }
716}
717
718func TestPrintStoreEntitlementStates(t *testing.T) {
719 when := time.Date(2026, 9, 2, 15, 4, 5, 0, time.UTC)
720 tests := []struct {
721 name string
722 ent *objstore.Entitlement
723 want []string
724 absent []string
725 }{
726 {name: "not applied", want: []string{"this account is not approved for object storage", "carlos store apply"}},
727 {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"}},
728 {name: "approved decision only", ent: &objstore.Entitlement{Decision: &objstore.Decision{Approved: true}}, absent: []string{"not approved", "waiting for a deployment operator", "declined"}},
729 {name: "declined decision only", ent: &objstore.Entitlement{Decision: &objstore.Decision{Reason: "need billing details"}}, want: []string{"declined", "need billing details", "apply again"}},
730 }
731 for _, tt := range tests {
732 t.Run(tt.name, func(t *testing.T) {
733 var out bytes.Buffer
734 printStoreStatus(&out, apiStoreReply{App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Entitlement: tt.ent})
735 for _, want := range tt.want {
736 if !strings.Contains(out.String(), want) {
737 t.Errorf("missing %q:\n%s", want, out.String())
738 }
739 }
740 for _, absent := range tt.absent {
741 if strings.Contains(out.String(), absent) {
742 t.Errorf("contains %q:\n%s", absent, out.String())
743 }
744 }
745 })
746 }
747}
748
749func TestPrintStoreStatusRepairVerbFollowsCurrentApproval(t *testing.T) {
750 for _, tt := range []struct {
751 name string
752 ent *objstore.Entitlement
753 want string
754 }{
755 {name: "approved", ent: approvedEntitlement(), want: "carlos store create --app keymail"},
756 {name: "revoked", ent: &objstore.Entitlement{Applied: &objstore.Applied{}, Decision: &objstore.Decision{Approved: false}}, want: "carlos store grant --app keymail"},
757 } {
758 t.Run(tt.name, func(t *testing.T) {
759 var partial bytes.Buffer
760 printStoreStatus(&partial, apiStoreReply{App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Grant: &apiStoreGrantBlock{}, Entitlement: tt.ent})
761 if !strings.Contains(partial.String(), tt.want) {
762 t.Fatalf("partial grant output missing %q:\n%s", tt.want, partial.String())
763 }
764 var drift bytes.Buffer
765 printStoreStatus(&drift, apiStoreReply{App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Provisioned: &apiStoreProvisioned{}, MissingVars: []string{"CARLOS_STORE_SECRET_KEY"}, Entitlement: tt.ent})
766 if !strings.Contains(drift.String(), tt.want) {
767 t.Fatalf("drift output missing %q:\n%s", tt.want, drift.String())
768 }
769 })
770 }
771}
772
773func TestPrintStoreCreateProvisionedAndDrift(t *testing.T) {
774 var healthy bytes.Buffer
775 printStoreCreate(&healthy, apiStoreReply{App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Provisioned: &apiStoreProvisioned{Bucket: "b", Region: "eu-west-1", IAMUser: "u"}})
776 if !strings.Contains(healthy.String(), "bucket b") || strings.Contains(healthy.String(), "nothing is provisioned") {
777 t.Fatalf("healthy provisioned output = %q", healthy.String())
778 }
779
780 var drift bytes.Buffer
781 printStoreCreate(&drift, apiStoreReply{App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Provisioned: &apiStoreProvisioned{Bucket: "b"}, MissingVars: []string{"CARLOS_STORE_SECRET_KEY"}, Entitlement: approvedEntitlement()})
782 if !strings.Contains(drift.String(), "CARLOS_STORE_SECRET_KEY") || !strings.Contains(drift.String(), "carlos store create --app keymail") {
783 t.Fatalf("provisioned drift output = %q", drift.String())
784 }
785 if strings.Contains(drift.String(), "credentials are sealed") {
786 t.Fatalf("drift output claims healthy delivery: %q", drift.String())
787 }
788
789 var revoked bytes.Buffer
790 printStoreCreate(&revoked, apiStoreReply{App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Provisioned: &apiStoreProvisioned{Bucket: "b"}, MissingVars: []string{"CARLOS_STORE_SECRET_KEY"}})
791 if !strings.Contains(revoked.String(), "carlos store grant --app keymail") {
792 t.Fatalf("unapproved provisioned drift output = %q, want operator repair", revoked.String())
793 }
794}
795
796func TestPrintStoreApprovalQueue(t *testing.T) {
797 var out bytes.Buffer
798 printStoreApprovals(&out, apiStoreApprovalsReply{Applications: []*objstore.Entitlement{
799 {Account: "bac", Applied: &objstore.Applied{By: "owner@example.com", At: time.Date(2026, 9, 1, 9, 0, 0, 0, time.UTC), Note: "backups"}},
800 }})
801 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"} {
802 if !strings.Contains(out.String(), want) {
803 t.Errorf("missing %q:\n%s", want, out.String())
804 }
805 }
806}
807
808// ---- runStore dispatch / refusals (real binary) -----------------------------
809
810// TestStoreVerbDispatchAndRefusals drives the real binary through
811// carlosTestEnv with a scratch cmd.Dir — the repo rule (AGENTS.md) — so a
812// logged-in laptop's real credential and console can never be what this test
813// exercises: unknown sub-verb, missing -app on each sub-verb, and direct
814// bucket mode refused with store's own sentence.
815func TestStoreVerbDispatchAndRefusals(t *testing.T) {
816 bin := buildCarlos(t)
817 scratch := t.TempDir()
818 run := func(env []string, args ...string) (string, error) {
819 t.Helper()
820 cmd := exec.Command(bin, args...)
821 cmd.Env = carlosTestEnv(t, env...)
822 cmd.Dir = scratch
823 out, err := cmd.CombinedOutput()
824 return string(out), err
825 }
826
827 // No sub-verb at all, and an unknown one: both print the usage block.
828 for _, args := range [][]string{{"store"}, {"store", "wat"}} {
829 out, err := run(nil, args...)
830 if err == nil {
831 t.Fatalf("carlos %v exited 0:\n%s", args, out)
832 }
833 if !strings.Contains(out, "usage: carlos store <create|status|apply|approvals|approve|decline|grant|rotate|scan>") {
834 t.Fatalf("carlos %v = %q, want the store usage block", args, out)
835 }
836 }
837
838 // -app is resolvable since the project-defaults spec (2026-08-13), so a
839 // missing -app with nothing configured stops on the REAL blocker — no
840 // session — not on a usage line for a flag that is no longer required.
841 for _, sub := range []string{"create", "status", "grant", "rotate"} {
842 out, err := run(nil, "store", sub)
843 if err == nil {
844 t.Fatalf("carlos store %s with no --app exited 0:\n%s", sub, out)
845 }
846 if !strings.Contains(out, "no deployment target configured") {
847 t.Fatalf("carlos store %s with no --app = %q, want the no-target refusal", sub, out)
848 }
849 }
850
851 // Direct bucket mode: refused in store's OWN words, naming the login
852 // that fixes it — never another verb's advice.
853 out, err := run([]string{"CARLOS_DEPLOYMENT_DIR=" + t.TempDir()}, "store", "status", "-app", "keymail")
854 if err == nil {
855 t.Fatalf("store in direct bucket mode exited 0:\n%s", out)
856 }
857 if !strings.Contains(out, "carlos store works through a console") || !strings.Contains(out, "carlos auth login") {
858 t.Fatalf("direct-mode refusal = %q, want store's own message and the login fix", out)
859 }
860
861 // A malshaped app name is refused at the terminal before any dial.
862 out, err = run(nil, "store", "status", "-app", "Not An App")
863 if err == nil {
864 t.Fatalf("store status with a bad app name exited 0:\n%s", out)
865 }
866 if !strings.Contains(out, "not a usable app name") {
867 t.Fatalf("bad app name = %q, want the name gate", out)
868 }
869}
870
871// TestPrintStoreStatusSize: a provisioned store prints its swept size with
872// the sweep's date, "not measured yet" before any sweep, and a warning (never
873// a 0) when the sweep could not read the bucket.
874func TestPrintStoreStatusSize(t *testing.T) {
875 base := apiStoreReply{App: "keymail", Backend: "aws",
876 Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"},
877 Provisioned: &apiStoreProvisioned{Bucket: "b1", Region: "eu-west-1", IAMUser: "u1", KeyID: "AKIA1"}}
878 for _, tc := range []struct {
879 name string
880 size *apiStoreSize
881 want string
882 }{
883 {"swept", &apiStoreSize{AsOf: "2026-09-17", StoreBytes: 3 << 30}, "size: 3.0GB (as of 2026-09-17)"},
884 {"never", nil, "size: not measured yet"},
885 {"unswept", &apiStoreSize{AsOf: "2026-09-17", Unswept: true}, "could not read this bucket"},
886 } {
887 var buf bytes.Buffer
888 res := base
889 res.Size = tc.size
890 printStoreStatus(&buf, res)
891 if !strings.Contains(buf.String(), tc.want) {
892 t.Errorf("%s: output missing %q:\n%s", tc.name, tc.want, buf.String())
893 }
894 }
895}
896