// Tests for the `carlos apps` verb: the pure mode-selection decision // (appsMode), plus the wire shape of the two session methods (apimode.go) // runAppsDelete/runAppsRestore build on. No shelling out to the carlos // binary here — everything below drives Go functions directly, so there is // no CARLOS_CREDENTIALS-isolation hazard to guard against (the Kass rule). package main import ( "context" "crypto/ed25519" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/carlosframework/platform/internal/release" ) // TestAppsMode is Step 1's failing-test-first table: signed-in wins // outright; headless without -account-id errors mentioning -account-id; // neither configured errors mentioning both carlos auth login and the store // env vars. func TestAppsMode(t *testing.T) { for _, tc := range []struct { name string loggedIn bool bucketConfigured bool accountID string wantMode string wantErrContains []string }{ { name: "signed-in wins", loggedIn: true, wantMode: "signed-in", }, { name: "signed-in wins even with an account-id also given", loggedIn: true, accountID: "bab", wantMode: "signed-in", }, { name: "headless with account-id", bucketConfigured: true, accountID: "bab", wantMode: "headless", }, { name: "headless without account-id errors", bucketConfigured: true, wantErrContains: []string{"-account-id"}, }, { name: "headless with only whitespace account-id errors", bucketConfigured: true, accountID: " ", wantErrContains: []string{"-account-id"}, }, { name: "neither configured errors two ways", wantErrContains: []string{"carlos auth login", "CARLOS_DEPLOYMENT_BUCKET", "CARLOS_DEPLOYMENT_DIR"}, }, } { t.Run(tc.name, func(t *testing.T) { mode, err := appsMode(tc.loggedIn, tc.bucketConfigured, tc.accountID) if tc.wantErrContains == nil { if err != nil { t.Fatalf("appsMode(%v, %v, %q) = err %v, want nil", tc.loggedIn, tc.bucketConfigured, tc.accountID, err) } if mode != tc.wantMode { t.Fatalf("appsMode(%v, %v, %q) = mode %q, want %q", tc.loggedIn, tc.bucketConfigured, tc.accountID, mode, tc.wantMode) } return } if err == nil { t.Fatalf("appsMode(%v, %v, %q) = nil error, want one mentioning %v", tc.loggedIn, tc.bucketConfigured, tc.accountID, tc.wantErrContains) } if mode != "" { t.Fatalf("appsMode(%v, %v, %q) returned mode %q alongside an error, want \"\"", tc.loggedIn, tc.bucketConfigured, tc.accountID, mode) } for _, want := range tc.wantErrContains { if !strings.Contains(err.Error(), want) { t.Fatalf("appsMode error %q does not contain %q", err.Error(), want) } } }) } } // ---- apiSession.trashApp / restoreApp -------------------------------------- // TestAPISessionTrashApp pins the success wire shape handleAPITrashApp sends: // {"result": "trashed", "restorable_until": "..."}. func TestAPISessionTrashApp(t *testing.T) { var gotPath, gotAuth string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path gotAuth = r.Header.Get("Authorization") json.NewEncoder(w).Encode(map[string]any{ "result": "trashed", "restorable_until": "2026-09-03T16:00:00Z", }) })) defer ts.Close() s := newTestSession(ts.URL) res, err := s.trashApp(context.Background(), apiAcct{Seg: "acct"}, "app") if err != nil { t.Fatalf("trashApp: %v", err) } if res.RestorableUntil != "2026-09-03T16:00:00Z" { t.Fatalf("RestorableUntil = %q, want the server's value", res.RestorableUntil) } if gotPath != "/api/cli/apps/acct/app/delete" { t.Fatalf("path = %q, want the delete door", gotPath) } if gotAuth != "Bearer cli-token" { t.Fatalf("Authorization = %q, want the bearer token", gotAuth) } } // TestAPISessionTrashAppProductionRefusal pins the 409 case (handleAPITrashApp: // production on, or unreadable) surfacing the server's own text verbatim, // exactly like promote's stable refusal surfaces the console's own message. func TestAPISessionTrashAppProductionRefusal(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": "app is flagged production, so deleting is refused", "console_url": "http://x/a/b", }) })) defer ts.Close() s := newTestSession(ts.URL) _, err := s.trashApp(context.Background(), apiAcct{Seg: "acct"}, "app") if err == nil || !strings.Contains(err.Error(), "flagged production") { t.Fatalf("trashApp against a 409 = %v, want the server's production-refusal text", err) } } // TestAPISessionTrashAppNotFound pins the byte-identical-404 case: an // unknown account/app or one the caller doesn't belong to. func TestAPISessionTrashAppNotFound(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })) defer ts.Close() s := newTestSession(ts.URL) _, err := s.trashApp(context.Background(), apiAcct{Seg: "nope"}, "app") if err == nil || !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") { t.Fatalf("trashApp against the account/app gate 404 = %v, want the membership message", err) } } // TestAPISessionRestoreApp pins the success wire shape handleAPIRestoreApp // sends: {"result": "restored", "note": "..."}, note surfaced verbatim. func TestAPISessionRestoreApp(t *testing.T) { var gotPath string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path json.NewEncoder(w).Encode(map[string]any{ "result": "restored", "note": "app is restored with NO routes: run `carlos add `", }) })) defer ts.Close() s := newTestSession(ts.URL) res, err := s.restoreApp(context.Background(), apiAcct{Seg: "acct"}, "app") if err != nil { t.Fatalf("restoreApp: %v", err) } if !strings.Contains(res.Note, "carlos add") { t.Fatalf("Note = %q, want the server's routing reminder", res.Note) } if gotPath != "/api/cli/apps/acct/app/restore" { t.Fatalf("path = %q, want the restore door", gotPath) } } // TestAPISessionRestoreAppNotFoundNamesHeadlessRetry pins the KNOWN WINDOW // advice: a 404 (apiTrashedApp's gate — including a headlessly-trashed app // this console has not yet materialized) must name the headless retry as a // second way through, not just the bare membership message. func TestAPISessionRestoreAppNotFoundNamesHeadlessRetry(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })) defer ts.Close() s := newTestSession(ts.URL) _, err := s.restoreApp(context.Background(), apiAcct{Seg: "acct"}, "app") if err == nil { t.Fatal("restoreApp against a 404 must error") } if !strings.Contains(err.Error(), "not found") { t.Fatalf("restoreApp 404 error = %q, want the membership message", err) } if !strings.Contains(err.Error(), "headless") { t.Fatalf("restoreApp 404 error = %q, want it to name the headless retry", err) } } func TestAPISessionRestoreAppServerError(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadGateway) json.NewEncoder(w).Encode(map[string]string{ "error": "could not remove the trash marker — nothing was restored; try again", }) })) defer ts.Close() s := newTestSession(ts.URL) _, err := s.restoreApp(context.Background(), apiAcct{Seg: "acct"}, "app") if err == nil || !strings.Contains(err.Error(), "nothing was restored") { t.Fatalf("restoreApp against a 502 = %v, want the server's own text", err) } } // ---- review round: app-name validation and the headless trash gate -------- // TestValidAppNameRejectsWhatReleaseWouldAccidentallyMint pins the review // finding: MyApp is not release.ValidApp (uppercase), but nothing about the // bucket key shape itself would stop `apps//MyApp/deleted.json` from // being written — the real app "myapp" would keep serving, untouched, while // the CLI printed success for a name that can never be a real app. func TestValidAppNameRejectsWhatReleaseWouldAccidentallyMint(t *testing.T) { for _, bad := range []string{"MyApp", "", "-leadinghyphen", "has spaces", strings.Repeat("a", 33)} { if err := validAppName(bad); err == nil { t.Errorf("validAppName(%q) = nil, want an error", bad) } } for _, good := range []string{"myapp", "a", "my-app-2", strings.Repeat("a", 32)} { if err := validAppName(good); err != nil { t.Errorf("validAppName(%q) = %v, want nil", good, err) } } } // TestPlacementMismatchErr pins the pure comparison runAppsCreate exits on // (2026-08-06 review: the create path was printing a guarantee it never // verified). requested==echoed is the only nil case — everything else, // including echoed=="" (both the "store write silently failed" case AND an // OLDER console that never echoes "placement" at all, which unmarshals to // the same "" zero value a Go json.Decoder gives an absent field), must // error and the error must name the exact remedy command. func TestPlacementMismatchErr(t *testing.T) { if err := placementMismatchErr("titogo", "fleet/pi", "fleet/pi"); err != nil { t.Fatalf("matching echo = %v, want nil", err) } if err := placementMismatchErr("titogo", "fleet/pi", ""); err == nil { t.Fatal("empty echo against a real request = nil, want an error (swallowed write / old console)") } else if !strings.Contains(err.Error(), "carlos apps place --app titogo --target fleet/pi") { t.Fatalf("error %q does not name the exact remedy command", err) } if err := placementMismatchErr("titogo", "fleet/pi", "fleet/other"); err == nil { t.Fatal("disagreeing echo = nil, want an error") } // requested=="" is never called this way by runAppsCreate (guarded by // `if place2 == "" { return }` before this is ever reached), but the // function itself is still symmetric: "" == "" is fine. if err := placementMismatchErr("titogo", "", ""); err != nil { t.Fatalf("both empty = %v, want nil", err) } } // TestValidBuildProbeMirrorsTheServer pins the client-side grammar gate for // -build-probe against the same shapes the server's own validBuildProbe // (internal/console/server.go) refuses — a typo here must cost nothing // against the create verb's own scarce per-day budget, so the two gates // must agree on what's refused. "//evil.example/healthz" and the // control-character case pin the 2026-08-24 review finding: a // protocol-relative probe would silently point the deploy watch at another // host, and the doc comment must not promise a protection the code lacks. func TestValidBuildProbeMirrorsTheServer(t *testing.T) { for _, bad := range []string{ "healthz", "http://x/healthz", "/healthz?x=1", "/health z", "//evil.example/healthz", "/health\nz", "/" + strings.Repeat("a", 256), } { if err := validBuildProbe(bad); err == nil { t.Errorf("validBuildProbe(%q) = nil, want an error", bad) } } for _, good := range []string{"", "/healthz", "/a/b/c", "/" + strings.Repeat("a", 255)} { if err := validBuildProbe(good); err != nil { t.Errorf("validBuildProbe(%q) = %v, want nil", good, err) } } } // TestAPISessionCreateAppSendsAndEchoesControlPlaneAndProbe pins the wire // shape createApp sends (Task 3: -control-plane, -build-probe) and that it // surfaces the server's echo — NOT the request's own values — the same // "what actually stuck" principle Placement already established. func TestAPISessionCreateAppSendsAndEchoesControlPlaneAndProbe(t *testing.T) { var gotBody map[string]any ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewDecoder(r.Body).Decode(&gotBody) json.NewEncoder(w).Encode(map[string]any{ "account": "acct", "app": "app", "url": "http://x/a/acct/apps/app", "control_plane": true, "build_probe": "/healthz", }) })) defer ts.Close() s := newTestSession(ts.URL) res, err := s.createApp(context.Background(), apiAcct{Seg: "acct"}, "app", "", true, "/healthz", "", "") if err != nil { t.Fatalf("createApp: %v", err) } if gotBody["control_plane"] != true || gotBody["build_probe"] != "/healthz" { t.Fatalf("request body = %v, want control_plane=true build_probe=/healthz", gotBody) } if !res.ControlPlane || res.BuildProbe != "/healthz" { t.Fatalf("createApp result = %+v, want the server's echoed flags", res) } } // newHeadlessTestStore returns a fresh DirStore plus a parsed keypair // (release.GenerateKey base64-encoded, parsed the same way the CLI parses // CARLOS_RELEASE_PUBKEY/CARLOS_RELEASE_KEY) for headlessTrash's direct tests. func newHeadlessTestStore(t *testing.T) (release.Store, ed25519.PrivateKey, ed25519.PublicKey) { t.Helper() seed, pubB64, err := release.GenerateKey() if err != nil { t.Fatal(err) } priv, err := release.ParseSigningKey(seed, "CARLOS_RELEASE_KEY") if err != nil { t.Fatal(err) } pub, err := release.ParsePublicKey(pubB64, "CARLOS_RELEASE_PUBKEY") if err != nil { t.Fatal(err) } return release.NewDirStore(t.TempDir()), priv, pub } // TestHeadlessTrashNoKeyRefusesBeforeAnyWrite pins gate order: an absent (or // too-short) public key refuses before the store is asked anything at all, // and nothing is written. func TestHeadlessTrashNoKeyRefusesBeforeAnyWrite(t *testing.T) { store, _, _ := newHeadlessTestStore(t) _, err := headlessTrash(context.Background(), store, nil, "bab", "myapp", time.Now()) if err == nil || !strings.Contains(err.Error(), "CARLOS_RELEASE_PUBKEY") { t.Fatalf("headlessTrash with no key = %v, want it to name CARLOS_RELEASE_PUBKEY", err) } if tomb, terr := release.ReadAppTombstone(context.Background(), store, "bab", "myapp"); terr != nil || tomb != nil { t.Fatalf("no key must not write a marker: tomb=%+v err=%v", tomb, terr) } } // TestHeadlessTrashInvalidAppRefuses ties this gate to the app-name-shape // finding: an unusable app name refuses even in headless mode, before the // store is touched. func TestHeadlessTrashInvalidAppRefuses(t *testing.T) { store, _, pub := newHeadlessTestStore(t) _, err := headlessTrash(context.Background(), store, pub, "bab", "MyApp", time.Now()) if err == nil || !strings.Contains(err.Error(), "not a usable app name") { t.Fatalf("headlessTrash(%q) = %v, want the app-name refusal", "MyApp", err) } } // TestHeadlessTrashProductionOnRefusesNoWrite pins the production-ON gate: // refuses, and writes NOTHING — the whole point of checking before TrashApp. func TestHeadlessTrashProductionOnRefusesNoWrite(t *testing.T) { store, priv, pub := newHeadlessTestStore(t) ctx := context.Background() if err := release.SetProduction(ctx, store, priv, "bab", "myapp", true, "paul@keymail.dev"); err != nil { t.Fatal(err) } _, err := headlessTrash(ctx, store, pub, "bab", "myapp", time.Now()) if err == nil || !strings.Contains(err.Error(), "flagged production") { t.Fatalf("headlessTrash against a production app = %v, want the production refusal", err) } if tomb, terr := release.ReadAppTombstone(ctx, store, "bab", "myapp"); terr != nil || tomb != nil { t.Fatalf("production-ON must not write a marker: tomb=%+v err=%v", tomb, terr) } } // unreadableProductionStore wraps a release.Store and fails every Get for // one app's production.json — the "flag exists but cannot be verified" case // production.go's GetProduction turns into an error, which headlessTrash // must treat exactly like flag-ON (refuse, write nothing), never like // flag-absent. type unreadableProductionStore struct { release.Store acct, app string } func (u unreadableProductionStore) Get(ctx context.Context, key string) ([]byte, string, error) { if key == release.ProductionKey(u.acct, u.app) { return nil, "", errUnreadableProduction } return u.Store.Get(ctx, key) } var errUnreadableProduction = errors.New("simulated unreadable production flag") // TestHeadlessTrashProductionUnreadableRefusesNoWrite pins the "unreadable // fails toward refusal" rule (spec §5): a production flag this call cannot // read is treated as production, not as absent, and nothing is written. func TestHeadlessTrashProductionUnreadableRefusesNoWrite(t *testing.T) { store, _, pub := newHeadlessTestStore(t) wrapped := unreadableProductionStore{Store: store, acct: "bab", app: "myapp"} ctx := context.Background() _, err := headlessTrash(ctx, wrapped, pub, "bab", "myapp", time.Now()) if err == nil || !strings.Contains(err.Error(), "unreadable") { t.Fatalf("headlessTrash with an unreadable flag = %v, want the unreadable-flag refusal", err) } if tomb, terr := release.ReadAppTombstone(ctx, store, "bab", "myapp"); terr != nil || tomb != nil { t.Fatalf("an unreadable flag must not write a marker: tomb=%+v err=%v", tomb, terr) } } func TestHeadlessTrashReturnsTheMarkersClock(t *testing.T) { store, _, pub := newHeadlessTestStore(t) ctx := context.Background() now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) deletedAt, err := headlessTrash(ctx, store, pub, "bab", "myapp", now) if err != nil { t.Fatalf("headlessTrash: %v", err) } if !deletedAt.Equal(now) { t.Fatalf("deletedAt = %v, want the marker's %v", deletedAt, now) } // A retry two days in re-reads the marker: the clock never restarts. again, err := headlessTrash(ctx, store, pub, "bab", "myapp", now.Add(48*time.Hour)) if err != nil || !again.Equal(now) { t.Fatalf("retry = %v %v, want the first trash's clock %v", again, err, now) } } // Spec §5 "Direct mode" and §9: both dates from the marker, and a date // already passed is said as such. func TestHeadlessTrashLinesNameBothWindows(t *testing.T) { at := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) tmp, perm := "2026-08-07 12:00 UTC", "2026-09-03 12:00 UTC" for _, now := range []time.Time{at, at.Add(48 * time.Hour)} { out := strings.Join(headlessTrashLines("myapp", at, now), "\n") if !strings.Contains(out, tmp) || !strings.Contains(out, perm) || !strings.Contains(out, "carlos apps list") { t.Fatalf("at %v:\n%s\nwant both %s and %s, and carlos apps list", now, out, tmp, perm) } } out := strings.Join(headlessTrashLines("myapp", at, at.Add(4*24*time.Hour)), "\n") if strings.Contains(out, tmp) || !strings.Contains(out, perm) { t.Fatalf("after 3 days:\n%s\nwant the temporary date replaced by 'may be purged at any time'", out) } } func TestPrintTrashedSaysHowLong(t *testing.T) { buf := captureStdout(t) now := time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC) printTrashed("tmp-a", now.Add(72*time.Hour).Format(time.RFC3339), now) 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") { t.Fatalf("printTrashed:\n%s", out) } } func TestRestoreHintNeverSendsATmpAppToCarlosAdd(t *testing.T) { if h := restoreHint("tmp-a"); strings.Contains(h, "carlos add") { t.Errorf("restoreHint(tmp-a) = %q: carlos add refuses every tmp- name", h) } if h := restoreHint("docs"); !strings.Contains(h, "carlos add") { t.Errorf("restoreHint(docs) = %q, want the carlos add advice kept for ordinary apps", h) } buf := captureStdout(t) printTrashed("tmp-a", "2026-09-29T12:00:00Z", time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)) if strings.Contains(buf.String(), "carlos add") { t.Errorf("delete of a temporary app points at carlos add:\n%s", buf.String()) } } func TestInDuration(t *testing.T) { for d, want := range map[time.Duration]string{ 72 * time.Hour: "in 3d", 71 * time.Hour: "in 2d", 5 * time.Hour: "in 5h", 12 * time.Minute: "in 12m", 10 * time.Second: "in 1m", 0: "soon", -time.Hour: "soon", } { if got := inDuration(d); got != want { t.Errorf("inDuration(%v) = %q, want %q", d, got, want) } } } // appsUsage's restore paragraph must never tell a tmp- app to run // carlos add — carlos add refuses every tmp- name, kept ones included // (release.IsTemporaryName). func TestAppsUsageNeverSendsATmpAppToCarlosAdd(t *testing.T) { i := strings.Index(appsUsage, "restore [flags] ") if i < 0 { t.Fatal("appsUsage has no restore paragraph") } para := appsUsage[i:] if j := strings.Index(para, "\n\n"); j >= 0 { para = para[:j] } if !strings.Contains(para, "tmp- app needs none of that") { t.Errorf("restore usage does not tell a tmp- app its routes come back on their own:\n%s", para) } }