carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// temporary_test.go
2package main
3
4import (
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "net/http"
10 "net/http/httptest"
11 "os/exec"
12 "strings"
13 "sync"
14 "sync/atomic"
15 "testing"
16 "time"
17)
18
19// Review Focus 1.
20func TestParseTTL(t *testing.T) {
21 for in, want := range map[string]time.Duration{
22 "3d": 72 * time.Hour, "3D": 72 * time.Hour, " 30d ": 30 * 24 * time.Hour,
23 "1h": time.Hour, "90m": 90 * time.Minute, "72h30m": 72*time.Hour + 30*time.Minute,
24 } {
25 if got, err := parseTTL(in); err != nil || got != want {
26 t.Errorf("parseTTL(%q) = %v %v, want %v", in, got, err, want)
27 }
28 }
29 for _, in := range []string{"", "d", "1.5d", "-1d", "31d", "59m", "0", "7", "9999999999999d", "3 days"} {
30 if _, err := parseTTL(in); err == nil {
31 t.Errorf("parseTTL(%q) accepted", in)
32 }
33 }
34}
35
36// Review Focus 2.
37func TestTemporaryAppName(t *testing.T) {
38 gen := func() string { return "a1b2c3" }
39 for in, want := range map[string]string{
40 "": "tmp-a1b2c3", "acme-docs": "tmp-acme-docs", "tmp-acme": "tmp-acme", " TMP-Acme ": "tmp-acme",
41 } {
42 if got, err := temporaryAppName(in, gen); err != nil || got != want {
43 t.Errorf("temporaryAppName(%q) = %q %v, want %q", in, got, err, want)
44 }
45 }
46 for _, in := range []string{strings.Repeat("a", 29), "acme_docs", "acme-", "tmp-", "tmp-abc-"} {
47 if got, err := temporaryAppName(in, gen); err == nil {
48 t.Errorf("temporaryAppName(%q) = %q, want a refusal", in, got)
49 }
50 }
51 for i := 0; i < 50; i++ {
52 label := randomTemporaryLabel()
53 if len(label) != 6 || strings.Trim(label, "abcdefghijklmnopqrstuvwxyz0123456789") != "" {
54 t.Fatalf("randomTemporaryLabel() = %q, want 6 lowercase letters and digits", label)
55 }
56 }
57}
58
59// Spec §9 "CLI": --ttl implies --temporary; local refusals.
60func TestTemporaryDeployFlags(t *testing.T) {
61 if temp, ttl, err := temporaryDeployFlags(false, true, "3d", "", false, "binary", 1); err != nil || !temp || ttl != 72*time.Hour {
62 t.Errorf("--ttl 3d alone = %v %v %v, want temporary for 3d", temp, ttl, err)
63 }
64 if temp, ttl, err := temporaryDeployFlags(true, false, "", "", false, "binary", 1); err != nil || !temp || ttl != ttlDefault {
65 t.Errorf("--temporary alone = %v %v %v, want 7d", temp, ttl, err)
66 }
67 if temp, _, err := temporaryDeployFlags(false, false, "", "www.example.com", true, "binary", 1); err != nil || temp {
68 t.Errorf("an ordinary deploy = %v %v, want untouched", temp, err)
69 }
70 // Astra plan R1 finding 5: --ttl given is --ttl given, whatever its value.
71 // An empty or blank value is an invalid TTL, never an ordinary deploy.
72 for _, raw := range []string{"", " ", "\t"} {
73 if temp, _, err := temporaryDeployFlags(false, true, raw, "", false, "binary", 1); err == nil || temp {
74 t.Errorf("--ttl %q = %v %v, want a refusal", raw, temp, err)
75 }
76 }
77 for name, c := range map[string]struct {
78 host string
79 kindSet bool
80 kind string
81 nargs int
82 }{
83 "--host": {"www.example.com", false, "binary", 1},
84 "--kind binary": {"", true, "binary", 1},
85 "no site dir": {"", false, "binary", 0},
86 } {
87 if _, _, err := temporaryDeployFlags(true, false, "", c.host, c.kindSet, c.kind, c.nargs); err == nil {
88 t.Errorf("--temporary with %s was accepted", name)
89 }
90 }
91}
92
93// TestDeployRefusesABlankTTL drives the real entry point: flag parsing, the
94// flagWasSet presence check and the refusal, before any session is resolved
95// or anything is created. The binary runs with carlosTestEnv (no login) in a
96// scratch dir, so reaching the ordinary deploy path would fail differently
97// (no console) and not name --ttl.
98func TestDeployRefusesABlankTTL(t *testing.T) {
99 bin := buildCarlos(t)
100 for _, args := range [][]string{
101 {"deploy", "--ttl", "", "./site"},
102 {"deploy", "--ttl=", "./site"},
103 {"deploy", "--ttl", " ", "./site"},
104 } {
105 cmd := exec.Command(bin, args...)
106 cmd.Env = carlosTestEnv(t)
107 cmd.Dir = t.TempDir()
108 out, err := cmd.CombinedOutput()
109 if err == nil {
110 t.Fatalf("carlos %q: want a non-zero exit, got 0\n%s", args, out)
111 }
112 if !strings.Contains(string(out), "--ttl") {
113 t.Fatalf("carlos %q: the refusal does not name --ttl:\n%s", args, out)
114 }
115 }
116}
117
118// fakeTemporaryConsole answers /new-temporary with the statuses given, in
119// order, and counts every request it sees.
120func fakeTemporaryConsole(t *testing.T, statuses ...int) (*apiSession, *atomic.Int32, *[]string) {
121 t.Helper()
122 var n atomic.Int32
123 var mu sync.Mutex
124 var names []string
125 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
126 i := int(n.Add(1)) - 1
127 var in struct {
128 Name string `json:"name"`
129 }
130 json.NewDecoder(r.Body).Decode(&in)
131 mu.Lock()
132 names = append(names, in.Name)
133 mu.Unlock()
134 st := statuses[min(i, len(statuses)-1)]
135 switch st {
136 case http.StatusOK:
137 json.NewEncoder(w).Encode(map[string]string{"account": "bab", "app": in.Name, "expires_at": "2026-10-03T12:00:00Z"})
138 case http.StatusConflict:
139 w.WriteHeader(st)
140 json.NewEncoder(w).Encode(map[string]string{"error": "that name is taken", "code": "name_taken"})
141 default:
142 http.NotFound(w, r)
143 }
144 }))
145 t.Cleanup(ts.Close)
146 return newTestSession(ts.URL), &n, &names
147}
148
149// Spec §9 "CLI": generated name shape and retry.
150func TestCreateTemporaryRetriesGeneratedNames(t *testing.T) {
151 labels := []string{"aaaaaa", "bbbbbb", "cccccc"}
152 i := 0
153 gen := func() string { l := labels[i]; i++; return l }
154 sess, n, names := fakeTemporaryConsole(t, http.StatusConflict, http.StatusConflict, http.StatusOK)
155 res, err := createTemporary(context.Background(), sess, apiAcct{Seg: "bab"}, "", ttlDefault, "", "", gen)
156 if err != nil || res.App != "tmp-cccccc" || n.Load() != 3 {
157 t.Fatalf("createTemporary = %+v %v after %d requests (%v), want tmp-cccccc on the third", res, err, n.Load(), *names)
158 }
159 // A name the caller chose is never retried under another name.
160 sess, n, _ = fakeTemporaryConsole(t, http.StatusConflict)
161 if _, err := createTemporary(context.Background(), sess, apiAcct{Seg: "bab"}, "acme-docs", ttlDefault, "", "", gen); !errors.Is(err, errTemporaryNameTaken) || n.Load() != 1 {
162 t.Fatalf("taken --app = %v after %d requests, want errTemporaryNameTaken after 1", err, n.Load())
163 }
164 // Generated names give up after temporaryNameAttempts.
165 sess, n, _ = fakeTemporaryConsole(t, http.StatusConflict)
166 if _, err := createTemporary(context.Background(), sess, apiAcct{Seg: "bab"}, "", ttlDefault, "", "", func() string { return "zzzzzz" }); err == nil || n.Load() != temporaryNameAttempts {
167 t.Fatalf("all taken = %v after %d requests, want an error after %d", err, n.Load(), temporaryNameAttempts)
168 }
169}
170
171// Spec §9: the CLI against a console without the route creates nothing.
172func TestCreateTemporaryAgainstAConsoleWithoutTheRoute(t *testing.T) {
173 sess, n, _ := fakeTemporaryConsole(t, http.StatusNotFound)
174 _, err := createTemporary(context.Background(), sess, apiAcct{Seg: "bab"}, "", ttlDefault, "", "", func() string { return "aaaaaa" })
175 if !errors.Is(err, errTemporaryUnavailable) || n.Load() != 1 {
176 t.Fatalf("older console = %v after %d requests, want errTemporaryUnavailable after exactly 1", err, n.Load())
177 }
178}
179
180// Spec §3: the CLI checks the response carries a non-zero expires_at before
181// it ships.
182func TestCreateTemporaryRequiresTheExpiryEcho(t *testing.T) {
183 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
184 json.NewEncoder(w).Encode(map[string]string{"account": "bab", "app": "tmp-docs"})
185 }))
186 defer ts.Close()
187 _, err := newTestSession(ts.URL).createTemporaryApp(context.Background(), apiAcct{Seg: "bab"}, "tmp-docs", ttlDefault, "", "")
188 if !errors.Is(err, errTemporaryNotConfirmed) || !strings.Contains(err.Error(), "carlos apps delete tmp-docs") {
189 t.Fatalf("no expires_at = %v, want errTemporaryNotConfirmed naming the cleanup", err)
190 }
191}
192
193func TestAPISessionLifecycleDoors(t *testing.T) {
194 var got []string
195 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
196 got = append(got, r.Method+" "+r.URL.Path)
197 switch {
198 case strings.HasSuffix(r.URL.Path, "/ttl"):
199 var in struct {
200 TTLSeconds int64 `json:"ttl_seconds"`
201 }
202 json.NewDecoder(r.Body).Decode(&in)
203 if in.TTLSeconds != 5*86400 {
204 t.Errorf("ttl_seconds = %d, want %d", in.TTLSeconds, 5*86400)
205 }
206 json.NewEncoder(w).Encode(map[string]string{"app": "tmp-a", "expires_at": "2026-10-01T12:00:00Z"})
207 case strings.HasSuffix(r.URL.Path, "/keep"):
208 json.NewEncoder(w).Encode(map[string]any{"app": "tmp-a", "expires_at": nil})
209 default:
210 w.Write([]byte(`[{"app":"tmp-a","deleted_at":null,"expires_at":"2026-10-01T12:00:00Z","purge_after":null},` +
211 `{"app":"kass","deleted_at":"2026-09-26T12:00:00Z","expires_at":null,"purge_after":"2026-10-26T12:00:00Z"}]`))
212 }
213 }))
214 defer ts.Close()
215 s := newTestSession(ts.URL)
216 ctx, acct := context.Background(), apiAcct{Seg: "bab"}
217 if exp, err := s.appTTL(ctx, acct, "tmp-a", 5*24*time.Hour); err != nil || exp.Format(time.RFC3339) != "2026-10-01T12:00:00Z" {
218 t.Fatalf("appTTL = %v %v", exp, err)
219 }
220 if err := s.appKeep(ctx, acct, "tmp-a"); err != nil {
221 t.Fatalf("appKeep = %v", err)
222 }
223 lives, err := s.appList(ctx, acct)
224 if err != nil || len(lives) != 2 || lives[0].ExpiresAt == nil || lives[1].DeletedAt == nil || lives[1].PurgeAfter == nil {
225 t.Fatalf("appList = %+v %v", lives, err)
226 }
227 want := []string{"POST /api/cli/apps/bab/tmp-a/ttl", "POST /api/cli/apps/bab/tmp-a/keep", "GET /api/cli/apps/bab/list"}
228 if strings.Join(got, "|") != strings.Join(want, "|") {
229 t.Fatalf("requests = %v, want %v", got, want)
230 }
231}
232
233func TestAppLifeLine(t *testing.T) {
234 now := time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)
235 at := func(d time.Duration) *time.Time { v := now.Add(d); return &v }
236 if line := appLifeLine(apiAppLife{App: "tmp-a", ExpiresAt: at(72 * time.Hour)}, now); !strings.Contains(line, "live") || !strings.Contains(line, "expires in 3d") {
237 t.Errorf("live temporary = %q", line)
238 }
239 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") {
240 t.Errorf("trashed temporary = %q", line)
241 }
242 if line := appLifeLine(apiAppLife{App: "kass"}, now); strings.Contains(line, "expires") || strings.Contains(line, "purged") {
243 t.Errorf("live permanent = %q, want no time", line)
244 }
245}
246
247// Review round 1, finding 2: carlos apps ttl's own usage line shows a flag
248// AFTER the positional TTL ("--app <name> <ttl> [--account ...]"), so
249// `carlos apps ttl --app tmp-docs 5d --account bab` must parse --account,
250// not leave it as a second positional. Plain fs.Parse stops at the first
251// non-flag word ("5d") and never looks past it; parseInterspersed must.
252func TestAppsLifecycleFlagsAcceptAFlagAfterThePositional(t *testing.T) {
253 fs, app, acctRaw, _, _ := appsLifecycleFlags("apps ttl")
254 pos, err := parseInterspersed(fs, []string{"--app", "tmp-docs", "5d", "--account", "bab"})
255 if err != nil {
256 t.Fatalf("parseInterspersed = %v", err)
257 }
258 if len(pos) != 1 || pos[0] != "5d" {
259 t.Fatalf("positional = %v, want exactly [\"5d\"]", pos)
260 }
261 if *app != "tmp-docs" || *acctRaw != "bab" {
262 t.Fatalf("app=%q account=%q, want tmp-docs/bab (the trailing --account must have been parsed)", *app, *acctRaw)
263 }
264}
265
266// Review round 1, finding 3: a sentinel's own Error() text (errTemporaryNotConfirmed's
267// "the console did not confirm an expiry", errTemporaryNameTaken's "that name
268// is taken") must never leak into what gets printed by being appended after
269// approved copy. errors.Is must still find the sentinel underneath.
270func TestCreateTemporaryAppPrintsOnlyTheApprovedTextOnNoExpiry(t *testing.T) {
271 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
272 json.NewEncoder(w).Encode(map[string]string{"account": "bab", "app": "tmp-docs"})
273 }))
274 defer ts.Close()
275 _, err := newTestSession(ts.URL).createTemporaryApp(context.Background(), apiAcct{Seg: "bab"}, "tmp-docs", ttlDefault, "", "")
276 want := "the console created tmp-docs but did not confirm it is temporary, so nothing was shipped. " +
277 "If you do not want it, delete it with: carlos apps delete tmp-docs" // C11.19, filled
278 if err == nil || err.Error() != want {
279 t.Fatalf("err = %q, want exactly %q", err, want)
280 }
281 if !errors.Is(err, errTemporaryNotConfirmed) {
282 t.Fatalf("err = %v, want errors.Is(err, errTemporaryNotConfirmed)", err)
283 }
284}
285
286// Review round 1, finding 3 (continued): createTemporary's exhausted-retries
287// message must be the approved C11.6 text alone, never with the last
288// attempt's own error (e.g. "that name is taken: tmp-zzzzzz") appended.
289func TestCreateTemporaryExhaustedRetriesPrintsOnlyTheApprovedText(t *testing.T) {
290 sess, _, _ := fakeTemporaryConsole(t, http.StatusConflict)
291 _, err := createTemporary(context.Background(), sess, apiAcct{Seg: "bab"}, "", ttlDefault, "", "", func() string { return "zzzzzz" })
292 want := fmt.Sprintf("%d generated names in a row were taken. Try again, or name the app with --app", temporaryNameAttempts) // C11.6, filled
293 if err == nil || err.Error() != want {
294 t.Fatalf("err = %q, want exactly %q", err, want)
295 }
296}
297
298// Review round 1, finding 4: on a 409 name_taken for a name the CALLER
299// chose, the printed message must be the console's own reason (existing,
300// already-reviewed console copy), not a flattened generic CLI string. Here
301// the console's reason ("that name is reserved") is deliberately NOT the
302// generic "that name is taken" the fake console uses elsewhere, so a test
303// that only checked errors.Is would miss a regression back to the flattened
304// string.
305func TestCreateTemporaryShowsTheConsolesOwnConflictReason(t *testing.T) {
306 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
307 w.WriteHeader(http.StatusConflict)
308 json.NewEncoder(w).Encode(map[string]string{"error": "that name is reserved", "code": "name_taken"})
309 }))
310 defer ts.Close()
311 _, err := createTemporary(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "acme-docs", ttlDefault, "", "",
312 func() string { return "zzzzzz" })
313 if err == nil || err.Error() != "that name is reserved" {
314 t.Fatalf("err = %q, want exactly the console's own reason %q", err, "that name is reserved")
315 }
316 if !errors.Is(err, errTemporaryNameTaken) {
317 t.Fatalf("err = %v, want errors.Is(err, errTemporaryNameTaken)", err)
318 }
319}
320
321// deploy --temporary sends /new's region and timezone fields, and reads the
322// region echo in /new's shape for printAppRegion.
323func TestCreateTemporarySendsTheRegionFields(t *testing.T) {
324 var got map[string]any
325 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
326 got = nil
327 json.NewDecoder(r.Body).Decode(&got)
328 json.NewEncoder(w).Encode(map[string]any{"account": "bab", "app": "tmp-docs",
329 "expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
330 "region": "uk", "region_name": "London", "region_source": "chosen", "regions_available": 2})
331 }))
332 defer ts.Close()
333 res, err := createTemporary(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "tmp-docs", ttlDefault, "uk", "Europe/London", nil)
334 if err != nil || got["region"] != "uk" || got["timezone"] != "Europe/London" {
335 t.Fatalf("sent %v (%v), want region and timezone", got, err)
336 }
337 if res.Region.Region != "uk" || res.Region.RegionSource != "chosen" || res.Region.RegionsAvailable != 2 {
338 t.Fatalf("region echo = %+v", res.Region)
339 }
340 // With neither, the body is the old one.
341 if _, err := createTemporary(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "tmp-docs", ttlDefault, "", "", nil); err != nil {
342 t.Fatal(err)
343 }
344 if _, ok := got["region"]; ok {
345 t.Fatalf("sent %v, want no region field", got)
346 }
347 if _, ok := got["timezone"]; ok {
348 t.Fatalf("sent %v, want no timezone field", got)
349 }
350}
351