// temporary_test.go package main import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "os/exec" "strings" "sync" "sync/atomic" "testing" "time" ) // Review Focus 1. func TestParseTTL(t *testing.T) { for in, want := range map[string]time.Duration{ "3d": 72 * time.Hour, "3D": 72 * time.Hour, " 30d ": 30 * 24 * time.Hour, "1h": time.Hour, "90m": 90 * time.Minute, "72h30m": 72*time.Hour + 30*time.Minute, } { if got, err := parseTTL(in); err != nil || got != want { t.Errorf("parseTTL(%q) = %v %v, want %v", in, got, err, want) } } for _, in := range []string{"", "d", "1.5d", "-1d", "31d", "59m", "0", "7", "9999999999999d", "3 days"} { if _, err := parseTTL(in); err == nil { t.Errorf("parseTTL(%q) accepted", in) } } } // Review Focus 2. func TestTemporaryAppName(t *testing.T) { gen := func() string { return "a1b2c3" } for in, want := range map[string]string{ "": "tmp-a1b2c3", "acme-docs": "tmp-acme-docs", "tmp-acme": "tmp-acme", " TMP-Acme ": "tmp-acme", } { if got, err := temporaryAppName(in, gen); err != nil || got != want { t.Errorf("temporaryAppName(%q) = %q %v, want %q", in, got, err, want) } } for _, in := range []string{strings.Repeat("a", 29), "acme_docs", "acme-", "tmp-", "tmp-abc-"} { if got, err := temporaryAppName(in, gen); err == nil { t.Errorf("temporaryAppName(%q) = %q, want a refusal", in, got) } } for i := 0; i < 50; i++ { label := randomTemporaryLabel() if len(label) != 6 || strings.Trim(label, "abcdefghijklmnopqrstuvwxyz0123456789") != "" { t.Fatalf("randomTemporaryLabel() = %q, want 6 lowercase letters and digits", label) } } } // Spec §9 "CLI": --ttl implies --temporary; local refusals. func TestTemporaryDeployFlags(t *testing.T) { if temp, ttl, err := temporaryDeployFlags(false, true, "3d", "", false, "binary", 1); err != nil || !temp || ttl != 72*time.Hour { t.Errorf("--ttl 3d alone = %v %v %v, want temporary for 3d", temp, ttl, err) } if temp, ttl, err := temporaryDeployFlags(true, false, "", "", false, "binary", 1); err != nil || !temp || ttl != ttlDefault { t.Errorf("--temporary alone = %v %v %v, want 7d", temp, ttl, err) } if temp, _, err := temporaryDeployFlags(false, false, "", "www.example.com", true, "binary", 1); err != nil || temp { t.Errorf("an ordinary deploy = %v %v, want untouched", temp, err) } // Astra plan R1 finding 5: --ttl given is --ttl given, whatever its value. // An empty or blank value is an invalid TTL, never an ordinary deploy. for _, raw := range []string{"", " ", "\t"} { if temp, _, err := temporaryDeployFlags(false, true, raw, "", false, "binary", 1); err == nil || temp { t.Errorf("--ttl %q = %v %v, want a refusal", raw, temp, err) } } for name, c := range map[string]struct { host string kindSet bool kind string nargs int }{ "--host": {"www.example.com", false, "binary", 1}, "--kind binary": {"", true, "binary", 1}, "no site dir": {"", false, "binary", 0}, } { if _, _, err := temporaryDeployFlags(true, false, "", c.host, c.kindSet, c.kind, c.nargs); err == nil { t.Errorf("--temporary with %s was accepted", name) } } } // TestDeployRefusesABlankTTL drives the real entry point: flag parsing, the // flagWasSet presence check and the refusal, before any session is resolved // or anything is created. The binary runs with carlosTestEnv (no login) in a // scratch dir, so reaching the ordinary deploy path would fail differently // (no console) and not name --ttl. func TestDeployRefusesABlankTTL(t *testing.T) { bin := buildCarlos(t) for _, args := range [][]string{ {"deploy", "--ttl", "", "./site"}, {"deploy", "--ttl=", "./site"}, {"deploy", "--ttl", " ", "./site"}, } { cmd := exec.Command(bin, args...) cmd.Env = carlosTestEnv(t) cmd.Dir = t.TempDir() out, err := cmd.CombinedOutput() if err == nil { t.Fatalf("carlos %q: want a non-zero exit, got 0\n%s", args, out) } if !strings.Contains(string(out), "--ttl") { t.Fatalf("carlos %q: the refusal does not name --ttl:\n%s", args, out) } } } // fakeTemporaryConsole answers /new-temporary with the statuses given, in // order, and counts every request it sees. func fakeTemporaryConsole(t *testing.T, statuses ...int) (*apiSession, *atomic.Int32, *[]string) { t.Helper() var n atomic.Int32 var mu sync.Mutex var names []string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { i := int(n.Add(1)) - 1 var in struct { Name string `json:"name"` } json.NewDecoder(r.Body).Decode(&in) mu.Lock() names = append(names, in.Name) mu.Unlock() st := statuses[min(i, len(statuses)-1)] switch st { case http.StatusOK: json.NewEncoder(w).Encode(map[string]string{"account": "bab", "app": in.Name, "expires_at": "2026-10-03T12:00:00Z"}) case http.StatusConflict: w.WriteHeader(st) json.NewEncoder(w).Encode(map[string]string{"error": "that name is taken", "code": "name_taken"}) default: http.NotFound(w, r) } })) t.Cleanup(ts.Close) return newTestSession(ts.URL), &n, &names } // Spec §9 "CLI": generated name shape and retry. func TestCreateTemporaryRetriesGeneratedNames(t *testing.T) { labels := []string{"aaaaaa", "bbbbbb", "cccccc"} i := 0 gen := func() string { l := labels[i]; i++; return l } sess, n, names := fakeTemporaryConsole(t, http.StatusConflict, http.StatusConflict, http.StatusOK) res, err := createTemporary(context.Background(), sess, apiAcct{Seg: "bab"}, "", ttlDefault, "", "", gen) if err != nil || res.App != "tmp-cccccc" || n.Load() != 3 { t.Fatalf("createTemporary = %+v %v after %d requests (%v), want tmp-cccccc on the third", res, err, n.Load(), *names) } // A name the caller chose is never retried under another name. sess, n, _ = fakeTemporaryConsole(t, http.StatusConflict) if _, err := createTemporary(context.Background(), sess, apiAcct{Seg: "bab"}, "acme-docs", ttlDefault, "", "", gen); !errors.Is(err, errTemporaryNameTaken) || n.Load() != 1 { t.Fatalf("taken --app = %v after %d requests, want errTemporaryNameTaken after 1", err, n.Load()) } // Generated names give up after temporaryNameAttempts. sess, n, _ = fakeTemporaryConsole(t, http.StatusConflict) if _, err := createTemporary(context.Background(), sess, apiAcct{Seg: "bab"}, "", ttlDefault, "", "", func() string { return "zzzzzz" }); err == nil || n.Load() != temporaryNameAttempts { t.Fatalf("all taken = %v after %d requests, want an error after %d", err, n.Load(), temporaryNameAttempts) } } // Spec §9: the CLI against a console without the route creates nothing. func TestCreateTemporaryAgainstAConsoleWithoutTheRoute(t *testing.T) { sess, n, _ := fakeTemporaryConsole(t, http.StatusNotFound) _, err := createTemporary(context.Background(), sess, apiAcct{Seg: "bab"}, "", ttlDefault, "", "", func() string { return "aaaaaa" }) if !errors.Is(err, errTemporaryUnavailable) || n.Load() != 1 { t.Fatalf("older console = %v after %d requests, want errTemporaryUnavailable after exactly 1", err, n.Load()) } } // Spec §3: the CLI checks the response carries a non-zero expires_at before // it ships. func TestCreateTemporaryRequiresTheExpiryEcho(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"account": "bab", "app": "tmp-docs"}) })) defer ts.Close() _, err := newTestSession(ts.URL).createTemporaryApp(context.Background(), apiAcct{Seg: "bab"}, "tmp-docs", ttlDefault, "", "") if !errors.Is(err, errTemporaryNotConfirmed) || !strings.Contains(err.Error(), "carlos apps delete tmp-docs") { t.Fatalf("no expires_at = %v, want errTemporaryNotConfirmed naming the cleanup", err) } } func TestAPISessionLifecycleDoors(t *testing.T) { var got []string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = append(got, r.Method+" "+r.URL.Path) switch { case strings.HasSuffix(r.URL.Path, "/ttl"): var in struct { TTLSeconds int64 `json:"ttl_seconds"` } json.NewDecoder(r.Body).Decode(&in) if in.TTLSeconds != 5*86400 { t.Errorf("ttl_seconds = %d, want %d", in.TTLSeconds, 5*86400) } json.NewEncoder(w).Encode(map[string]string{"app": "tmp-a", "expires_at": "2026-10-01T12:00:00Z"}) case strings.HasSuffix(r.URL.Path, "/keep"): json.NewEncoder(w).Encode(map[string]any{"app": "tmp-a", "expires_at": nil}) default: w.Write([]byte(`[{"app":"tmp-a","deleted_at":null,"expires_at":"2026-10-01T12:00:00Z","purge_after":null},` + `{"app":"kass","deleted_at":"2026-09-26T12:00:00Z","expires_at":null,"purge_after":"2026-10-26T12:00:00Z"}]`)) } })) defer ts.Close() s := newTestSession(ts.URL) ctx, acct := context.Background(), apiAcct{Seg: "bab"} if exp, err := s.appTTL(ctx, acct, "tmp-a", 5*24*time.Hour); err != nil || exp.Format(time.RFC3339) != "2026-10-01T12:00:00Z" { t.Fatalf("appTTL = %v %v", exp, err) } if err := s.appKeep(ctx, acct, "tmp-a"); err != nil { t.Fatalf("appKeep = %v", err) } lives, err := s.appList(ctx, acct) if err != nil || len(lives) != 2 || lives[0].ExpiresAt == nil || lives[1].DeletedAt == nil || lives[1].PurgeAfter == nil { t.Fatalf("appList = %+v %v", lives, err) } want := []string{"POST /api/cli/apps/bab/tmp-a/ttl", "POST /api/cli/apps/bab/tmp-a/keep", "GET /api/cli/apps/bab/list"} if strings.Join(got, "|") != strings.Join(want, "|") { t.Fatalf("requests = %v, want %v", got, want) } } func TestAppLifeLine(t *testing.T) { now := time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC) at := func(d time.Duration) *time.Time { v := now.Add(d); return &v } if line := appLifeLine(apiAppLife{App: "tmp-a", ExpiresAt: at(72 * time.Hour)}, now); !strings.Contains(line, "live") || !strings.Contains(line, "expires in 3d") { t.Errorf("live temporary = %q", line) } if line := appLifeLine(apiAppLife{App: "tmp-b", ExpiresAt: at(-time.Hour), DeletedAt: at(-time.Hour), PurgeAfter: at(49 * time.Hour)}, now); !strings.Contains(line, "trashed") || !strings.Contains(line, "purged in 2d") { t.Errorf("trashed temporary = %q", line) } if line := appLifeLine(apiAppLife{App: "kass"}, now); strings.Contains(line, "expires") || strings.Contains(line, "purged") { t.Errorf("live permanent = %q, want no time", line) } } // Review round 1, finding 2: carlos apps ttl's own usage line shows a flag // AFTER the positional TTL ("--app [--account ...]"), so // `carlos apps ttl --app tmp-docs 5d --account bab` must parse --account, // not leave it as a second positional. Plain fs.Parse stops at the first // non-flag word ("5d") and never looks past it; parseInterspersed must. func TestAppsLifecycleFlagsAcceptAFlagAfterThePositional(t *testing.T) { fs, app, acctRaw, _, _ := appsLifecycleFlags("apps ttl") pos, err := parseInterspersed(fs, []string{"--app", "tmp-docs", "5d", "--account", "bab"}) if err != nil { t.Fatalf("parseInterspersed = %v", err) } if len(pos) != 1 || pos[0] != "5d" { t.Fatalf("positional = %v, want exactly [\"5d\"]", pos) } if *app != "tmp-docs" || *acctRaw != "bab" { t.Fatalf("app=%q account=%q, want tmp-docs/bab (the trailing --account must have been parsed)", *app, *acctRaw) } } // Review round 1, finding 3: a sentinel's own Error() text (errTemporaryNotConfirmed's // "the console did not confirm an expiry", errTemporaryNameTaken's "that name // is taken") must never leak into what gets printed by being appended after // approved copy. errors.Is must still find the sentinel underneath. func TestCreateTemporaryAppPrintsOnlyTheApprovedTextOnNoExpiry(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"account": "bab", "app": "tmp-docs"}) })) defer ts.Close() _, err := newTestSession(ts.URL).createTemporaryApp(context.Background(), apiAcct{Seg: "bab"}, "tmp-docs", ttlDefault, "", "") want := "the console created tmp-docs but did not confirm it is temporary, so nothing was shipped. " + "If you do not want it, delete it with: carlos apps delete tmp-docs" // C11.19, filled if err == nil || err.Error() != want { t.Fatalf("err = %q, want exactly %q", err, want) } if !errors.Is(err, errTemporaryNotConfirmed) { t.Fatalf("err = %v, want errors.Is(err, errTemporaryNotConfirmed)", err) } } // Review round 1, finding 3 (continued): createTemporary's exhausted-retries // message must be the approved C11.6 text alone, never with the last // attempt's own error (e.g. "that name is taken: tmp-zzzzzz") appended. func TestCreateTemporaryExhaustedRetriesPrintsOnlyTheApprovedText(t *testing.T) { sess, _, _ := fakeTemporaryConsole(t, http.StatusConflict) _, err := createTemporary(context.Background(), sess, apiAcct{Seg: "bab"}, "", ttlDefault, "", "", func() string { return "zzzzzz" }) want := fmt.Sprintf("%d generated names in a row were taken. Try again, or name the app with --app", temporaryNameAttempts) // C11.6, filled if err == nil || err.Error() != want { t.Fatalf("err = %q, want exactly %q", err, want) } } // Review round 1, finding 4: on a 409 name_taken for a name the CALLER // chose, the printed message must be the console's own reason (existing, // already-reviewed console copy), not a flattened generic CLI string. Here // the console's reason ("that name is reserved") is deliberately NOT the // generic "that name is taken" the fake console uses elsewhere, so a test // that only checked errors.Is would miss a regression back to the flattened // string. func TestCreateTemporaryShowsTheConsolesOwnConflictReason(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": "that name is reserved", "code": "name_taken"}) })) defer ts.Close() _, err := createTemporary(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "acme-docs", ttlDefault, "", "", func() string { return "zzzzzz" }) if err == nil || err.Error() != "that name is reserved" { t.Fatalf("err = %q, want exactly the console's own reason %q", err, "that name is reserved") } if !errors.Is(err, errTemporaryNameTaken) { t.Fatalf("err = %v, want errors.Is(err, errTemporaryNameTaken)", err) } } // deploy --temporary sends /new's region and timezone fields, and reads the // region echo in /new's shape for printAppRegion. func TestCreateTemporarySendsTheRegionFields(t *testing.T) { var got map[string]any ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = nil json.NewDecoder(r.Body).Decode(&got) json.NewEncoder(w).Encode(map[string]any{"account": "bab", "app": "tmp-docs", "expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339), "region": "uk", "region_name": "London", "region_source": "chosen", "regions_available": 2}) })) defer ts.Close() res, err := createTemporary(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "tmp-docs", ttlDefault, "uk", "Europe/London", nil) if err != nil || got["region"] != "uk" || got["timezone"] != "Europe/London" { t.Fatalf("sent %v (%v), want region and timezone", got, err) } if res.Region.Region != "uk" || res.Region.RegionSource != "chosen" || res.Region.RegionsAvailable != 2 { t.Fatalf("region echo = %+v", res.Region) } // With neither, the body is the old one. if _, err := createTemporary(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "tmp-docs", ttlDefault, "", "", nil); err != nil { t.Fatal(err) } if _, ok := got["region"]; ok { t.Fatalf("sent %v, want no region field", got) } if _, ok := got["timezone"]; ok { t.Fatalf("sent %v, want no timezone field", got) } }