carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Tests for the `carlos apps` verb: the pure mode-selection decision
2// (appsMode), plus the wire shape of the two session methods (apimode.go)
3// runAppsDelete/runAppsRestore build on. No shelling out to the carlos
4// binary here — everything below drives Go functions directly, so there is
5// no CARLOS_CREDENTIALS-isolation hazard to guard against (the Kass rule).
6package main
7
8import (
9 "context"
10 "crypto/ed25519"
11 "encoding/json"
12 "errors"
13 "net/http"
14 "net/http/httptest"
15 "strings"
16 "testing"
17 "time"
18
19 "github.com/carlosframework/platform/internal/release"
20)
21
22// TestAppsMode is Step 1's failing-test-first table: signed-in wins
23// outright; headless without -account-id errors mentioning -account-id;
24// neither configured errors mentioning both carlos auth login and the store
25// env vars.
26func TestAppsMode(t *testing.T) {
27 for _, tc := range []struct {
28 name string
29 loggedIn bool
30 bucketConfigured bool
31 accountID string
32 wantMode string
33 wantErrContains []string
34 }{
35 {
36 name: "signed-in wins",
37 loggedIn: true,
38 wantMode: "signed-in",
39 },
40 {
41 name: "signed-in wins even with an account-id also given",
42 loggedIn: true,
43 accountID: "bab",
44 wantMode: "signed-in",
45 },
46 {
47 name: "headless with account-id",
48 bucketConfigured: true,
49 accountID: "bab",
50 wantMode: "headless",
51 },
52 {
53 name: "headless without account-id errors",
54 bucketConfigured: true,
55 wantErrContains: []string{"-account-id"},
56 },
57 {
58 name: "headless with only whitespace account-id errors",
59 bucketConfigured: true,
60 accountID: " ",
61 wantErrContains: []string{"-account-id"},
62 },
63 {
64 name: "neither configured errors two ways",
65 wantErrContains: []string{"carlos auth login", "CARLOS_DEPLOYMENT_BUCKET", "CARLOS_DEPLOYMENT_DIR"},
66 },
67 } {
68 t.Run(tc.name, func(t *testing.T) {
69 mode, err := appsMode(tc.loggedIn, tc.bucketConfigured, tc.accountID)
70 if tc.wantErrContains == nil {
71 if err != nil {
72 t.Fatalf("appsMode(%v, %v, %q) = err %v, want nil", tc.loggedIn, tc.bucketConfigured, tc.accountID, err)
73 }
74 if mode != tc.wantMode {
75 t.Fatalf("appsMode(%v, %v, %q) = mode %q, want %q", tc.loggedIn, tc.bucketConfigured, tc.accountID, mode, tc.wantMode)
76 }
77 return
78 }
79 if err == nil {
80 t.Fatalf("appsMode(%v, %v, %q) = nil error, want one mentioning %v", tc.loggedIn, tc.bucketConfigured, tc.accountID, tc.wantErrContains)
81 }
82 if mode != "" {
83 t.Fatalf("appsMode(%v, %v, %q) returned mode %q alongside an error, want \"\"", tc.loggedIn, tc.bucketConfigured, tc.accountID, mode)
84 }
85 for _, want := range tc.wantErrContains {
86 if !strings.Contains(err.Error(), want) {
87 t.Fatalf("appsMode error %q does not contain %q", err.Error(), want)
88 }
89 }
90 })
91 }
92}
93
94// ---- apiSession.trashApp / restoreApp --------------------------------------
95
96// TestAPISessionTrashApp pins the success wire shape handleAPITrashApp sends:
97// {"result": "trashed", "restorable_until": "..."}.
98func TestAPISessionTrashApp(t *testing.T) {
99 var gotPath, gotAuth string
100 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
101 gotPath = r.URL.Path
102 gotAuth = r.Header.Get("Authorization")
103 json.NewEncoder(w).Encode(map[string]any{
104 "result": "trashed", "restorable_until": "2026-09-03T16:00:00Z",
105 })
106 }))
107 defer ts.Close()
108
109 s := newTestSession(ts.URL)
110 res, err := s.trashApp(context.Background(), apiAcct{Seg: "acct"}, "app")
111 if err != nil {
112 t.Fatalf("trashApp: %v", err)
113 }
114 if res.RestorableUntil != "2026-09-03T16:00:00Z" {
115 t.Fatalf("RestorableUntil = %q, want the server's value", res.RestorableUntil)
116 }
117 if gotPath != "/api/cli/apps/acct/app/delete" {
118 t.Fatalf("path = %q, want the delete door", gotPath)
119 }
120 if gotAuth != "Bearer cli-token" {
121 t.Fatalf("Authorization = %q, want the bearer token", gotAuth)
122 }
123}
124
125// TestAPISessionTrashAppProductionRefusal pins the 409 case (handleAPITrashApp:
126// production on, or unreadable) surfacing the server's own text verbatim,
127// exactly like promote's stable refusal surfaces the console's own message.
128func TestAPISessionTrashAppProductionRefusal(t *testing.T) {
129 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
130 w.WriteHeader(http.StatusConflict)
131 json.NewEncoder(w).Encode(map[string]string{
132 "error": "app is flagged production, so deleting is refused",
133 "console_url": "http://x/a/b",
134 })
135 }))
136 defer ts.Close()
137
138 s := newTestSession(ts.URL)
139 _, err := s.trashApp(context.Background(), apiAcct{Seg: "acct"}, "app")
140 if err == nil || !strings.Contains(err.Error(), "flagged production") {
141 t.Fatalf("trashApp against a 409 = %v, want the server's production-refusal text", err)
142 }
143}
144
145// TestAPISessionTrashAppNotFound pins the byte-identical-404 case: an
146// unknown account/app or one the caller doesn't belong to.
147func TestAPISessionTrashAppNotFound(t *testing.T) {
148 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
149 http.NotFound(w, r)
150 }))
151 defer ts.Close()
152
153 s := newTestSession(ts.URL)
154 _, err := s.trashApp(context.Background(), apiAcct{Seg: "nope"}, "app")
155 if err == nil || !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") {
156 t.Fatalf("trashApp against the account/app gate 404 = %v, want the membership message", err)
157 }
158}
159
160// TestAPISessionRestoreApp pins the success wire shape handleAPIRestoreApp
161// sends: {"result": "restored", "note": "..."}, note surfaced verbatim.
162func TestAPISessionRestoreApp(t *testing.T) {
163 var gotPath string
164 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
165 gotPath = r.URL.Path
166 json.NewEncoder(w).Encode(map[string]any{
167 "result": "restored", "note": "app is restored with NO routes: run `carlos add <host>`",
168 })
169 }))
170 defer ts.Close()
171
172 s := newTestSession(ts.URL)
173 res, err := s.restoreApp(context.Background(), apiAcct{Seg: "acct"}, "app")
174 if err != nil {
175 t.Fatalf("restoreApp: %v", err)
176 }
177 if !strings.Contains(res.Note, "carlos add") {
178 t.Fatalf("Note = %q, want the server's routing reminder", res.Note)
179 }
180 if gotPath != "/api/cli/apps/acct/app/restore" {
181 t.Fatalf("path = %q, want the restore door", gotPath)
182 }
183}
184
185// TestAPISessionRestoreAppNotFoundNamesHeadlessRetry pins the KNOWN WINDOW
186// advice: a 404 (apiTrashedApp's gate — including a headlessly-trashed app
187// this console has not yet materialized) must name the headless retry as a
188// second way through, not just the bare membership message.
189func TestAPISessionRestoreAppNotFoundNamesHeadlessRetry(t *testing.T) {
190 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
191 http.NotFound(w, r)
192 }))
193 defer ts.Close()
194
195 s := newTestSession(ts.URL)
196 _, err := s.restoreApp(context.Background(), apiAcct{Seg: "acct"}, "app")
197 if err == nil {
198 t.Fatal("restoreApp against a 404 must error")
199 }
200 if !strings.Contains(err.Error(), "not found") {
201 t.Fatalf("restoreApp 404 error = %q, want the membership message", err)
202 }
203 if !strings.Contains(err.Error(), "headless") {
204 t.Fatalf("restoreApp 404 error = %q, want it to name the headless retry", err)
205 }
206}
207
208func TestAPISessionRestoreAppServerError(t *testing.T) {
209 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
210 w.WriteHeader(http.StatusBadGateway)
211 json.NewEncoder(w).Encode(map[string]string{
212 "error": "could not remove the trash marker — nothing was restored; try again",
213 })
214 }))
215 defer ts.Close()
216
217 s := newTestSession(ts.URL)
218 _, err := s.restoreApp(context.Background(), apiAcct{Seg: "acct"}, "app")
219 if err == nil || !strings.Contains(err.Error(), "nothing was restored") {
220 t.Fatalf("restoreApp against a 502 = %v, want the server's own text", err)
221 }
222}
223
224// ---- review round: app-name validation and the headless trash gate --------
225
226// TestValidAppNameRejectsWhatReleaseWouldAccidentallyMint pins the review
227// finding: MyApp is not release.ValidApp (uppercase), but nothing about the
228// bucket key shape itself would stop `apps/<sqid>/MyApp/deleted.json` from
229// being written — the real app "myapp" would keep serving, untouched, while
230// the CLI printed success for a name that can never be a real app.
231func TestValidAppNameRejectsWhatReleaseWouldAccidentallyMint(t *testing.T) {
232 for _, bad := range []string{"MyApp", "", "-leadinghyphen", "has spaces", strings.Repeat("a", 33)} {
233 if err := validAppName(bad); err == nil {
234 t.Errorf("validAppName(%q) = nil, want an error", bad)
235 }
236 }
237 for _, good := range []string{"myapp", "a", "my-app-2", strings.Repeat("a", 32)} {
238 if err := validAppName(good); err != nil {
239 t.Errorf("validAppName(%q) = %v, want nil", good, err)
240 }
241 }
242}
243
244// TestPlacementMismatchErr pins the pure comparison runAppsCreate exits on
245// (2026-08-06 review: the create path was printing a guarantee it never
246// verified). requested==echoed is the only nil case — everything else,
247// including echoed=="" (both the "store write silently failed" case AND an
248// OLDER console that never echoes "placement" at all, which unmarshals to
249// the same "" zero value a Go json.Decoder gives an absent field), must
250// error and the error must name the exact remedy command.
251func TestPlacementMismatchErr(t *testing.T) {
252 if err := placementMismatchErr("titogo", "fleet/pi", "fleet/pi"); err != nil {
253 t.Fatalf("matching echo = %v, want nil", err)
254 }
255 if err := placementMismatchErr("titogo", "fleet/pi", ""); err == nil {
256 t.Fatal("empty echo against a real request = nil, want an error (swallowed write / old console)")
257 } else if !strings.Contains(err.Error(), "carlos apps place --app titogo --target fleet/pi") {
258 t.Fatalf("error %q does not name the exact remedy command", err)
259 }
260 if err := placementMismatchErr("titogo", "fleet/pi", "fleet/other"); err == nil {
261 t.Fatal("disagreeing echo = nil, want an error")
262 }
263 // requested=="" is never called this way by runAppsCreate (guarded by
264 // `if place2 == "" { return }` before this is ever reached), but the
265 // function itself is still symmetric: "" == "" is fine.
266 if err := placementMismatchErr("titogo", "", ""); err != nil {
267 t.Fatalf("both empty = %v, want nil", err)
268 }
269}
270
271// TestValidBuildProbeMirrorsTheServer pins the client-side grammar gate for
272// -build-probe against the same shapes the server's own validBuildProbe
273// (internal/console/server.go) refuses — a typo here must cost nothing
274// against the create verb's own scarce per-day budget, so the two gates
275// must agree on what's refused. "//evil.example/healthz" and the
276// control-character case pin the 2026-08-24 review finding: a
277// protocol-relative probe would silently point the deploy watch at another
278// host, and the doc comment must not promise a protection the code lacks.
279func TestValidBuildProbeMirrorsTheServer(t *testing.T) {
280 for _, bad := range []string{
281 "healthz", "http://x/healthz", "/healthz?x=1", "/health z",
282 "//evil.example/healthz", "/health\nz", "/" + strings.Repeat("a", 256),
283 } {
284 if err := validBuildProbe(bad); err == nil {
285 t.Errorf("validBuildProbe(%q) = nil, want an error", bad)
286 }
287 }
288 for _, good := range []string{"", "/healthz", "/a/b/c", "/" + strings.Repeat("a", 255)} {
289 if err := validBuildProbe(good); err != nil {
290 t.Errorf("validBuildProbe(%q) = %v, want nil", good, err)
291 }
292 }
293}
294
295// TestAPISessionCreateAppSendsAndEchoesControlPlaneAndProbe pins the wire
296// shape createApp sends (Task 3: -control-plane, -build-probe) and that it
297// surfaces the server's echo — NOT the request's own values — the same
298// "what actually stuck" principle Placement already established.
299func TestAPISessionCreateAppSendsAndEchoesControlPlaneAndProbe(t *testing.T) {
300 var gotBody map[string]any
301 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
302 json.NewDecoder(r.Body).Decode(&gotBody)
303 json.NewEncoder(w).Encode(map[string]any{
304 "account": "acct", "app": "app", "url": "http://x/a/acct/apps/app",
305 "control_plane": true, "build_probe": "/healthz",
306 })
307 }))
308 defer ts.Close()
309
310 s := newTestSession(ts.URL)
311 res, err := s.createApp(context.Background(), apiAcct{Seg: "acct"}, "app", "", true, "/healthz", "", "")
312 if err != nil {
313 t.Fatalf("createApp: %v", err)
314 }
315 if gotBody["control_plane"] != true || gotBody["build_probe"] != "/healthz" {
316 t.Fatalf("request body = %v, want control_plane=true build_probe=/healthz", gotBody)
317 }
318 if !res.ControlPlane || res.BuildProbe != "/healthz" {
319 t.Fatalf("createApp result = %+v, want the server's echoed flags", res)
320 }
321}
322
323// newHeadlessTestStore returns a fresh DirStore plus a parsed keypair
324// (release.GenerateKey base64-encoded, parsed the same way the CLI parses
325// CARLOS_RELEASE_PUBKEY/CARLOS_RELEASE_KEY) for headlessTrash's direct tests.
326func newHeadlessTestStore(t *testing.T) (release.Store, ed25519.PrivateKey, ed25519.PublicKey) {
327 t.Helper()
328 seed, pubB64, err := release.GenerateKey()
329 if err != nil {
330 t.Fatal(err)
331 }
332 priv, err := release.ParseSigningKey(seed, "CARLOS_RELEASE_KEY")
333 if err != nil {
334 t.Fatal(err)
335 }
336 pub, err := release.ParsePublicKey(pubB64, "CARLOS_RELEASE_PUBKEY")
337 if err != nil {
338 t.Fatal(err)
339 }
340 return release.NewDirStore(t.TempDir()), priv, pub
341}
342
343// TestHeadlessTrashNoKeyRefusesBeforeAnyWrite pins gate order: an absent (or
344// too-short) public key refuses before the store is asked anything at all,
345// and nothing is written.
346func TestHeadlessTrashNoKeyRefusesBeforeAnyWrite(t *testing.T) {
347 store, _, _ := newHeadlessTestStore(t)
348 _, err := headlessTrash(context.Background(), store, nil, "bab", "myapp", time.Now())
349 if err == nil || !strings.Contains(err.Error(), "CARLOS_RELEASE_PUBKEY") {
350 t.Fatalf("headlessTrash with no key = %v, want it to name CARLOS_RELEASE_PUBKEY", err)
351 }
352 if tomb, terr := release.ReadAppTombstone(context.Background(), store, "bab", "myapp"); terr != nil || tomb != nil {
353 t.Fatalf("no key must not write a marker: tomb=%+v err=%v", tomb, terr)
354 }
355}
356
357// TestHeadlessTrashInvalidAppRefuses ties this gate to the app-name-shape
358// finding: an unusable app name refuses even in headless mode, before the
359// store is touched.
360func TestHeadlessTrashInvalidAppRefuses(t *testing.T) {
361 store, _, pub := newHeadlessTestStore(t)
362 _, err := headlessTrash(context.Background(), store, pub, "bab", "MyApp", time.Now())
363 if err == nil || !strings.Contains(err.Error(), "not a usable app name") {
364 t.Fatalf("headlessTrash(%q) = %v, want the app-name refusal", "MyApp", err)
365 }
366}
367
368// TestHeadlessTrashProductionOnRefusesNoWrite pins the production-ON gate:
369// refuses, and writes NOTHING — the whole point of checking before TrashApp.
370func TestHeadlessTrashProductionOnRefusesNoWrite(t *testing.T) {
371 store, priv, pub := newHeadlessTestStore(t)
372 ctx := context.Background()
373 if err := release.SetProduction(ctx, store, priv, "bab", "myapp", true, "paul@keymail.dev"); err != nil {
374 t.Fatal(err)
375 }
376 _, err := headlessTrash(ctx, store, pub, "bab", "myapp", time.Now())
377 if err == nil || !strings.Contains(err.Error(), "flagged production") {
378 t.Fatalf("headlessTrash against a production app = %v, want the production refusal", err)
379 }
380 if tomb, terr := release.ReadAppTombstone(ctx, store, "bab", "myapp"); terr != nil || tomb != nil {
381 t.Fatalf("production-ON must not write a marker: tomb=%+v err=%v", tomb, terr)
382 }
383}
384
385// unreadableProductionStore wraps a release.Store and fails every Get for
386// one app's production.json — the "flag exists but cannot be verified" case
387// production.go's GetProduction turns into an error, which headlessTrash
388// must treat exactly like flag-ON (refuse, write nothing), never like
389// flag-absent.
390type unreadableProductionStore struct {
391 release.Store
392 acct, app string
393}
394
395func (u unreadableProductionStore) Get(ctx context.Context, key string) ([]byte, string, error) {
396 if key == release.ProductionKey(u.acct, u.app) {
397 return nil, "", errUnreadableProduction
398 }
399 return u.Store.Get(ctx, key)
400}
401
402var errUnreadableProduction = errors.New("simulated unreadable production flag")
403
404// TestHeadlessTrashProductionUnreadableRefusesNoWrite pins the "unreadable
405// fails toward refusal" rule (spec §5): a production flag this call cannot
406// read is treated as production, not as absent, and nothing is written.
407func TestHeadlessTrashProductionUnreadableRefusesNoWrite(t *testing.T) {
408 store, _, pub := newHeadlessTestStore(t)
409 wrapped := unreadableProductionStore{Store: store, acct: "bab", app: "myapp"}
410 ctx := context.Background()
411 _, err := headlessTrash(ctx, wrapped, pub, "bab", "myapp", time.Now())
412 if err == nil || !strings.Contains(err.Error(), "unreadable") {
413 t.Fatalf("headlessTrash with an unreadable flag = %v, want the unreadable-flag refusal", err)
414 }
415 if tomb, terr := release.ReadAppTombstone(ctx, store, "bab", "myapp"); terr != nil || tomb != nil {
416 t.Fatalf("an unreadable flag must not write a marker: tomb=%+v err=%v", tomb, terr)
417 }
418}
419
420func TestHeadlessTrashReturnsTheMarkersClock(t *testing.T) {
421 store, _, pub := newHeadlessTestStore(t)
422 ctx := context.Background()
423 now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
424 deletedAt, err := headlessTrash(ctx, store, pub, "bab", "myapp", now)
425 if err != nil {
426 t.Fatalf("headlessTrash: %v", err)
427 }
428 if !deletedAt.Equal(now) {
429 t.Fatalf("deletedAt = %v, want the marker's %v", deletedAt, now)
430 }
431 // A retry two days in re-reads the marker: the clock never restarts.
432 again, err := headlessTrash(ctx, store, pub, "bab", "myapp", now.Add(48*time.Hour))
433 if err != nil || !again.Equal(now) {
434 t.Fatalf("retry = %v %v, want the first trash's clock %v", again, err, now)
435 }
436}
437
438// Spec §5 "Direct mode" and §9: both dates from the marker, and a date
439// already passed is said as such.
440func TestHeadlessTrashLinesNameBothWindows(t *testing.T) {
441 at := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
442 tmp, perm := "2026-08-07 12:00 UTC", "2026-09-03 12:00 UTC"
443 for _, now := range []time.Time{at, at.Add(48 * time.Hour)} {
444 out := strings.Join(headlessTrashLines("myapp", at, now), "\n")
445 if !strings.Contains(out, tmp) || !strings.Contains(out, perm) || !strings.Contains(out, "carlos apps list") {
446 t.Fatalf("at %v:\n%s\nwant both %s and %s, and carlos apps list", now, out, tmp, perm)
447 }
448 }
449 out := strings.Join(headlessTrashLines("myapp", at, at.Add(4*24*time.Hour)), "\n")
450 if strings.Contains(out, tmp) || !strings.Contains(out, perm) {
451 t.Fatalf("after 3 days:\n%s\nwant the temporary date replaced by 'may be purged at any time'", out)
452 }
453}
454
455func TestPrintTrashedSaysHowLong(t *testing.T) {
456 buf := captureStdout(t)
457 now := time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)
458 printTrashed("tmp-a", now.Add(72*time.Hour).Format(time.RFC3339), now)
459 if out := buf.String(); !strings.Contains(out, "in 3d") || !strings.Contains(out, "2026-09-29 12:00 UTC") || !strings.Contains(out, "carlos apps restore tmp-a") {
460 t.Fatalf("printTrashed:\n%s", out)
461 }
462}
463
464func TestRestoreHintNeverSendsATmpAppToCarlosAdd(t *testing.T) {
465 if h := restoreHint("tmp-a"); strings.Contains(h, "carlos add") {
466 t.Errorf("restoreHint(tmp-a) = %q: carlos add refuses every tmp- name", h)
467 }
468 if h := restoreHint("docs"); !strings.Contains(h, "carlos add") {
469 t.Errorf("restoreHint(docs) = %q, want the carlos add advice kept for ordinary apps", h)
470 }
471 buf := captureStdout(t)
472 printTrashed("tmp-a", "2026-09-29T12:00:00Z", time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC))
473 if strings.Contains(buf.String(), "carlos add") {
474 t.Errorf("delete of a temporary app points at carlos add:\n%s", buf.String())
475 }
476}
477
478func TestInDuration(t *testing.T) {
479 for d, want := range map[time.Duration]string{
480 72 * time.Hour: "in 3d", 71 * time.Hour: "in 2d", 5 * time.Hour: "in 5h",
481 12 * time.Minute: "in 12m", 10 * time.Second: "in 1m", 0: "soon", -time.Hour: "soon",
482 } {
483 if got := inDuration(d); got != want {
484 t.Errorf("inDuration(%v) = %q, want %q", d, got, want)
485 }
486 }
487}
488
489// appsUsage's restore paragraph must never tell a tmp- app to run
490// carlos add — carlos add refuses every tmp- name, kept ones included
491// (release.IsTemporaryName).
492func TestAppsUsageNeverSendsATmpAppToCarlosAdd(t *testing.T) {
493 i := strings.Index(appsUsage, "restore [flags] <app>")
494 if i < 0 {
495 t.Fatal("appsUsage has no restore paragraph")
496 }
497 para := appsUsage[i:]
498 if j := strings.Index(para, "\n\n"); j >= 0 {
499 para = para[:j]
500 }
501 if !strings.Contains(para, "tmp- app needs none of that") {
502 t.Errorf("restore usage does not tell a tmp- app its routes come back on their own:\n%s", para)
503 }
504}
505