carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/url"
9 "os"
10 "path/filepath"
11 "strings"
12 "sync"
13 "testing"
14 "time"
15
16 "github.com/carlosframework/platform/internal/registry"
17 "github.com/carlosframework/platform/internal/release"
18 "github.com/carlosframework/platform/internal/svcrole"
19)
20
21func temporaryFixture(t *testing.T) (*consoleFixture, string) {
22 t.Helper()
23 fx := newConsoleFixture(t)
24 fx.srv.cfg.TemporaryApps = true
25 return fx, cliToken(t, fx, "paul@keymail.dev", "code-"+strings.ReplaceAll(t.Name(), "/", "-"))
26}
27
28func createTemporary(t *testing.T, fx *consoleFixture, token, name string, ttl int64) (int, map[string]any) {
29 t.Helper()
30 return postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new-temporary",
31 map[string]any{"name": name, "ttl_seconds": ttl}, token)
32}
33
34func TestTemporaryRouteIsOffUntilSwitchedOn(t *testing.T) {
35 fx := newConsoleFixture(t)
36 token := cliToken(t, fx, "paul@keymail.dev", "code-temp-off")
37 status, h, body := rawReq(t, http.MethodPost, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new-temporary", token, `{"name":"tmp-docs"}`)
38 assertNotFound(t, "feature off", canonicalNotFound(t), status, h, body)
39 if l := lifeOf(t, fx, "tmp-docs"); l != nil {
40 t.Fatal("the switched-off route created a row")
41 }
42}
43
44func TestTemporaryCreateSetsTheExpiryInTheClaim(t *testing.T) {
45 fx, token := temporaryFixture(t)
46 before := time.Now()
47 status, out := createTemporary(t, fx, token, "tmp-docs", 0)
48 if status != http.StatusOK {
49 t.Fatalf("create = %d %v", status, out)
50 }
51 exp, err := time.Parse(time.RFC3339, str(t, out, "expires_at"))
52 if err != nil {
53 t.Fatalf("expires_at %q: %v", out["expires_at"], err)
54 }
55 if d := exp.Sub(before); d < temporaryTTLDefault-time.Minute || d > temporaryTTLDefault+time.Minute {
56 t.Fatalf("default expiry is %v away, want 7 days", d)
57 }
58 if l := lifeOf(t, fx, "tmp-docs"); l == nil || l.ExpiresAt != exp.Unix() {
59 t.Fatalf("row = %+v, want expires_at %d", l, exp.Unix())
60 }
61 if p, err := release.GetPipeline(t.Context(), fx.rel, fx.pub, fx.sqid, "tmp-docs"); err != nil || p == nil {
62 t.Fatalf("a new temporary app gets the default pipeline like any new app: %v %v", p, err)
63 }
64}
65
66// Review Focus 1.
67func TestTemporaryTTLBounds(t *testing.T) {
68 for secs, ok := range map[int64]bool{
69 0: true, 3599: false, 3600: true, 2592000: true, 2592001: false, -1: false, 1 << 62: false,
70 } {
71 if _, err := temporaryTTL(secs, true); (err == nil) != ok {
72 t.Errorf("temporaryTTL(%d) = %v, want ok=%v", secs, err, ok)
73 }
74 }
75 if _, err := temporaryTTL(0, false); err == nil {
76 t.Error("ttl 0 is only the default at create, never for carlos apps ttl")
77 }
78 fx, token := temporaryFixture(t)
79 if status, _ := createTemporary(t, fx, token, "tmp-short", 3599); status != http.StatusBadRequest {
80 t.Fatalf("ttl 3599 = %d, want 400", status)
81 }
82 if status, _ := createTemporary(t, fx, token, "tmp-huge", 1<<62); status != http.StatusBadRequest {
83 t.Fatalf("ttl 1<<62 = %d, want 400", status)
84 }
85 if l := lifeOf(t, fx, "tmp-huge"); l != nil {
86 t.Fatal("a refused ttl created a row")
87 }
88}
89
90// Review Focus 2.
91func TestTemporaryCreateNameShapes(t *testing.T) {
92 fx, token := temporaryFixture(t)
93 for _, name := range []string{"docs", "tmp-", "tmp-abc-", "tmp-" + strings.Repeat("a", 29), "tmp_docs"} {
94 if status, out := createTemporary(t, fx, token, name, 0); status != http.StatusBadRequest {
95 t.Errorf("name %q = %d %v, want 400", name, status, out)
96 }
97 }
98 if status, out := createTemporary(t, fx, token, " TMP-Docs ", 0); status != http.StatusOK || out["app"] != "tmp-docs" {
99 t.Fatalf("TMP-Docs = %d %v, want 200 creating tmp-docs", status, out)
100 }
101}
102
103func TestTemporaryCreateRefusesUnknownFields(t *testing.T) {
104 fx, token := temporaryFixture(t)
105 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new-temporary",
106 map[string]any{"name": "tmp-cp", "control_plane": true}, token)
107 if status != http.StatusBadRequest || !strings.Contains(str(t, out, "error"), "control_plane") {
108 t.Fatalf("control_plane on /new-temporary = %d %v, want 400 naming the field", status, out)
109 }
110}
111
112// Spec §9: any key under the prefix refuses, including a manifest with no
113// channel pointer, and the owner role does not exempt it (paul owns ops).
114func TestTemporaryCreateRefusesAnyKeyUnderThePrefix(t *testing.T) {
115 fx, token := temporaryFixture(t)
116 if _, err := fx.rel.Put(t.Context(), "apps/"+fx.sqid+"/tmp-shipped/releases/v1/manifest.json", []byte(`{}`), ""); err != nil {
117 t.Fatal(err)
118 }
119 status, out := createTemporary(t, fx, token, "tmp-shipped", 0)
120 if status != http.StatusConflict || out["code"] != "name_taken" {
121 t.Fatalf("create over an unpromoted release = %d %v, want 409 name_taken", status, out)
122 }
123 if l := lifeOf(t, fx, "tmp-shipped"); l != nil {
124 t.Fatal("the owner adopted release history into a temporary app")
125 }
126}
127
128// Spec §3 (astra 5.1): a route-only service of the same account and app, with
129// no row and no bucket objects, refuses. Another account's route does not.
130func TestTemporaryCreateRefusesARouteOfTheSameAccount(t *testing.T) {
131 fx, token := temporaryFixture(t)
132 reg, err := registry.Open(fx.regPath)
133 if err != nil {
134 t.Fatal(err)
135 }
136 for _, rt := range []registry.Route{
137 {Host: "svc.example.com", Kind: "service", Status: "ready", App: "tmp-svc", Account: fx.sqid, Addr: "127.0.0.1:9"},
138 {Host: "other.example.com", Kind: "service", Status: "ready", App: "tmp-other", Account: "bzz", Addr: "127.0.0.1:9"},
139 } {
140 if err := reg.Upsert(rt); err != nil {
141 t.Fatal(err)
142 }
143 }
144 if status, out := createTemporary(t, fx, token, "tmp-svc", 0); status != http.StatusConflict {
145 t.Fatalf("same-account route = %d %v, want 409", status, out)
146 }
147 if status, out := createTemporary(t, fx, token, "tmp-other", 0); status != http.StatusOK {
148 t.Fatalf("another account's route = %d %v, want 200", status, out)
149 }
150}
151
152// "Could not check" is not "no routes".
153func TestTemporaryCreateRefusesWhenTheRegistryCannotBeRead(t *testing.T) {
154 fx, token := temporaryFixture(t)
155 notADir := filepath.Join(t.TempDir(), "file")
156 if err := os.WriteFile(notADir, []byte("x"), 0o600); err != nil {
157 t.Fatal(err)
158 }
159 fx.srv.cfg.Registry = filepath.Join(notADir, "registry.db")
160 if status, out := createTemporary(t, fx, token, "tmp-blind", 0); status != http.StatusBadGateway {
161 t.Fatalf("unreadable registry = %d %v, want 502", status, out)
162 }
163 if l := lifeOf(t, fx, "tmp-blind"); l != nil {
164 t.Fatal("created without checking the routes")
165 }
166}
167
168// Spec §9: the cap refuses the 21st and is exact under concurrent creates.
169func TestTemporaryCapIsExactUnderConcurrentCreates(t *testing.T) {
170 fx, token := temporaryFixture(t)
171 now := time.Now()
172 for i := 0; i < 18; i++ {
173 if ok, err := fx.st.CreateTemporaryAppIn(OpsAccountID, fmt.Sprintf("tmp-seed%02d", i), "paul@keymail.dev", now, now.Add(time.Hour)); err != nil || !ok {
174 t.Fatal(ok, err)
175 }
176 }
177 var wg sync.WaitGroup
178 statuses := make([]int, 5)
179 for i := range statuses {
180 wg.Add(1)
181 go func(i int) {
182 defer wg.Done()
183 statuses[i], _ = createTemporary(t, fx, token, fmt.Sprintf("tmp-race%d", i), 0)
184 }(i)
185 }
186 wg.Wait()
187 won := 0
188 for _, s := range statuses {
189 if s == http.StatusOK {
190 won++
191 }
192 }
193 if won != 2 {
194 t.Fatalf("statuses = %v, want exactly 2 of 5 to fit under the cap of 20", statuses)
195 }
196 live, _ := fx.st.LiveTemporaryApps(OpsAccountID)
197 if len(live) != temporaryAppCap {
198 t.Fatalf("%d live temporary apps, want %d", len(live), temporaryAppCap)
199 }
200 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new-temporary", map[string]any{"name": "tmp-21st"}, token)
201 msg := str(t, out, "error")
202 if status != http.StatusConflict || out["code"] != "cap" || !strings.Contains(msg, "tmp-seed00") {
203 t.Fatalf("21st = %d %v, want 409 cap listing each app with its expiry", status, out)
204 }
205}
206
207// Review Focus 5.
208func TestAppScopedServiceCannotClaimATemporaryApp(t *testing.T) {
209 fx := newConsoleFixture(t)
210 fx.srv.cfg.TemporaryApps = true
211 u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/new-temporary"
212 appTok := serviceToken(t, fx.st, OpsAccountID, "kass-ci", "kass", svcrole.Publish)
213 status, h, body := rawReq(t, http.MethodPost, u, appTok, `{"name":"tmp-ci"}`)
214 assertNotFound(t, "app-scoped credential claiming a temporary app", canonicalNotFound(t), status, h, body)
215 if l := lifeOf(t, fx, "tmp-ci"); l != nil {
216 t.Fatal("an app-scoped credential claimed a temporary app")
217 }
218 acctTok := serviceToken(t, fx.st, OpsAccountID, "pages", "", svcrole.Publish)
219 if status, _, body := rawReq(t, http.MethodPost, u, acctTok, `{"name":"tmp-ci"}`); status != http.StatusOK {
220 t.Fatalf("account-scoped Publish credential = %d %s, want 200", status, body)
221 }
222}
223
224func TestTemporaryCreateHasOwnBudget(t *testing.T) {
225 fx, token := temporaryFixture(t)
226 // Attempts, not successes, spend the budget (cliCreateBurst's rule):
227 // past the live cap of 20 these are 409s, and they still count.
228 for i := 0; i < cliTemporaryCreateBurst; i++ {
229 status, out := createTemporary(t, fx, token, fmt.Sprintf("tmp-b%d", i), 0)
230 if status == http.StatusTooManyRequests {
231 t.Fatalf("temporary attempt %d refused by the rate limit: %v", i, out)
232 }
233 }
234 if status, out := createTemporary(t, fx, token, "tmp-over", 0); status != http.StatusTooManyRequests {
235 t.Fatalf("attempt %d = %d %v, want 429", cliTemporaryCreateBurst+1, status, out)
236 }
237 // The permanent budget is untouched by all of that.
238 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new",
239 map[string]any{"name": "permanent-one"}, token)
240 if status == http.StatusTooManyRequests {
241 t.Fatalf("/new refused after temporary creates: %v; the budgets must be separate", out)
242 }
243}
244
245func appURL(fx *consoleFixture, app, tail string) string {
246 return fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/" + app + tail
247}
248
249func TestKeepMakesATemporaryAppPermanent(t *testing.T) {
250 fx, token := temporaryFixture(t)
251 tempApp(t, fx, "tmp-a", time.Now().Add(time.Hour))
252 status, out := postJSON(t, appURL(fx, "tmp-a", "/keep"), map[string]string{}, token)
253 if status != http.StatusOK || out["app"] != "tmp-a" || out["expires_at"] != nil {
254 t.Fatalf("keep = %d %v, want 200 with expires_at null", status, out)
255 }
256 if l := lifeOf(t, fx, "tmp-a"); l.Temporary() {
257 t.Fatalf("row = %+v, want permanent", l)
258 }
259}
260
261func TestTTLMovesTheExpiry(t *testing.T) {
262 fx, token := temporaryFixture(t)
263 tempApp(t, fx, "tmp-a", time.Now().Add(time.Hour))
264 before := time.Now()
265 status, out := postJSON(t, appURL(fx, "tmp-a", "/ttl"), map[string]int64{"ttl_seconds": 5 * 86400}, token)
266 if status != http.StatusOK {
267 t.Fatalf("ttl = %d %v", status, out)
268 }
269 exp, _ := time.Parse(time.RFC3339, str(t, out, "expires_at"))
270 if d := exp.Sub(before); d < 5*24*time.Hour-time.Minute || d > 5*24*time.Hour+time.Minute {
271 t.Fatalf("expiry is %v away, want 5 days", d)
272 }
273 if l := lifeOf(t, fx, "tmp-a"); l.ExpiresAt != exp.Unix() {
274 t.Fatalf("row = %+v", l)
275 }
276}
277
278// Review Focus 4.
279func TestTTLAndKeepRefuseWhatTheyCannotChange(t *testing.T) {
280 fx, token := temporaryFixture(t)
281 for _, tail := range []string{"/keep", "/ttl"} {
282 status, out := postJSON(t, appURL(fx, "kass", tail), map[string]int64{"ttl_seconds": 86400}, token)
283 if status != http.StatusConflict {
284 t.Errorf("%s on a permanent app = %d %v, want 409", tail, status, out)
285 }
286 }
287 if l := lifeOf(t, fx, "kass"); l.Temporary() {
288 t.Fatal("a door made a permanent app temporary")
289 }
290 tempApp(t, fx, "tmp-a", time.Now().Add(time.Hour))
291 if status, _ := postJSON(t, appURL(fx, "tmp-a", "/ttl"), map[string]int64{"ttl_seconds": 3599}, token); status != http.StatusBadRequest {
292 t.Errorf("ttl 3599 = %d, want 400", status)
293 }
294 if status, _ := postJSON(t, appURL(fx, "tmp-a", "/ttl"), map[string]int64{}, token); status != http.StatusBadRequest {
295 t.Errorf("ttl with no ttl_seconds = %d, want 400", status)
296 }
297 fx.st.TrashAppRow(OpsAccountID, "tmp-a", time.Now())
298 trashed := *lifeOf(t, fx, "tmp-a")
299 // The live-app gate's answer is http.NotFound's plain text, not JSON, so
300 // these go through rawReq (apiinstances_test.go:42): postJSON's doJSON
301 // fails the test on any body that does not decode (authcli_test.go:62).
302 for _, tail := range []string{"/keep", "/ttl"} {
303 status, h, body := rawReq(t, http.MethodPost, appURL(fx, "tmp-a", tail), token, `{"ttl_seconds":86400}`)
304 assertNotFound(t, tail+" on a trashed app", canonicalNotFound(t), status, h, body)
305 }
306 if l := lifeOf(t, fx, "tmp-a"); l == nil || *l != trashed {
307 t.Fatalf("row = %+v after refused keep/ttl, want unchanged %+v", l, trashed)
308 }
309}
310
311// Spec §9 "failure at step 4 followed by a keep": the marker is written and
312// the row is live and expired. Keep settles first, finds the app in the
313// Trash, and refuses; the row is left trashed with the 3-day window.
314func TestKeepAfterAFailedExpiryStepFourIsRefused(t *testing.T) {
315 fx, token := temporaryFixture(t)
316 at := time.Now().Truncate(time.Second).Add(-time.Minute)
317 tempApp(t, fx, "tmp-a", at)
318 if err := release.TrashApp(context.Background(), fx.rel, fx.sqid, "tmp-a", expiryActor, at); err != nil {
319 t.Fatal(err)
320 }
321 status, out := postJSON(t, appURL(fx, "tmp-a", "/keep"), map[string]string{}, token)
322 if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), "carlos apps restore") {
323 t.Fatalf("keep = %d %v, want 409 pointing at carlos apps restore", status, out)
324 }
325 if l := lifeOf(t, fx, "tmp-a"); l.DeletedAt != at.Unix() || l.PurgeAfter != at.Unix()+72*3600 || !l.Temporary() {
326 t.Fatalf("row = %+v, want trashed at the marker's clock with the 3-day window, still temporary", l)
327 }
328}
329
330// Spec §9 "/list": trashed apps shown; the accounts listing's []string
331// unchanged; service-principal filtering matches.
332func TestListShowsTheTrashAndFiltersServices(t *testing.T) {
333 fx, token := temporaryFixture(t)
334 now := time.Now().Truncate(time.Second)
335 tempApp(t, fx, "tmp-live", now.Add(3*24*time.Hour))
336 tempApp(t, fx, "tmp-gone", now.Add(time.Hour))
337 fx.st.TrashAppRow(OpsAccountID, "tmp-gone", now)
338 fx.st.TrashAppRow(OpsAccountID, "kass", now)
339
340 list := func(tok string) map[string]map[string]any {
341 t.Helper()
342 status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/list", tok)
343 if status != http.StatusOK {
344 t.Fatalf("list = %d %s", status, body)
345 }
346 var rows []map[string]any
347 if err := json.Unmarshal([]byte(body), &rows); err != nil {
348 t.Fatalf("list is not a JSON array: %v\n%s", err, body)
349 }
350 out := map[string]map[string]any{}
351 for _, r := range rows {
352 out[r["app"].(string)] = r
353 }
354 return out
355 }
356 rows := list(token)
357 if len(rows) != 3 {
358 t.Fatalf("list = %v, want tmp-live, tmp-gone and kass", rows)
359 }
360 if r := rows["tmp-live"]; r["deleted_at"] != nil || r["purge_after"] != nil || r["expires_at"] != now.Add(3*24*time.Hour).UTC().Format(time.RFC3339) {
361 t.Errorf("tmp-live = %v", r)
362 }
363 if r := rows["tmp-gone"]; r["purge_after"] != now.Add(72*time.Hour).UTC().Format(time.RFC3339) {
364 t.Errorf("tmp-gone = %v, want purge_after 3 days after the trash", r)
365 }
366 if r := rows["kass"]; r["purge_after"] != now.Add(30*24*time.Hour).UTC().Format(time.RFC3339) || r["expires_at"] != nil {
367 t.Errorf("kass = %v, want purge_after 30 days after the trash", r)
368 }
369 // The accounts listing keeps its []string of LIVE names.
370 _, accts := getJSON(t, fx.ts.URL+"/api/cli/apps", token)
371 for _, a := range accts["accounts"].([]any) {
372 for _, name := range a.(map[string]any)["apps"].([]any) {
373 if _, ok := name.(string); !ok || name == "kass" || name == "tmp-gone" {
374 t.Errorf("accounts listing apps = %v, want live names only, as strings", a)
375 }
376 }
377 }
378 // An app-scoped service sees only its app.
379 svc := serviceToken(t, fx.st, OpsAccountID, "live-ci", "tmp-live", svcrole.Publish)
380 if rows := list(svc); len(rows) != 1 || rows["tmp-live"] == nil {
381 t.Errorf("app-scoped list = %v, want only tmp-live", rows)
382 }
383}
384
385func TestBrowserKeepButton(t *testing.T) {
386 fx := newConsoleFixture(t)
387 tempApp(t, fx, "tmp-a", time.Now().Add(time.Hour))
388 c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-keep-button")
389 if _, _, page := get(t, c, fx.u("/apps/tmp-a")); !strings.Contains(page, fx.path("/apps/tmp-a/keep")) {
390 t.Fatal("the app page of a temporary app has no Keep form")
391 }
392 if _, _, page := get(t, c, fx.u("/apps/kass")); strings.Contains(page, "/apps/kass/keep") {
393 t.Fatal("a permanent app shows a Keep form")
394 }
395 status, _, _ := postForm(t, c, fx.u("/apps/tmp-a/keep"), url.Values{"csrf": {csrf}})
396 if status != http.StatusSeeOther {
397 t.Fatalf("keep = %d, want 303", status)
398 }
399 if l := lifeOf(t, fx, "tmp-a"); l.Temporary() {
400 t.Fatal("the Keep button did not keep")
401 }
402}
403
404// Review carryover from Task 8: keepApp and setAppTTL must take the SAME
405// s.lockApp key the expiry pass and the trash doors use, settle first, and
406// only then act. Proved here with real lock contention, not sequencing: the
407// test holds the app's lock, lets the request queue on it, writes the trash
408// marker the expiry pass's first steps would have written, and only then
409// releases. The queued keep/ttl must find the trash when it finally gets the
410// lock and refuse, never a stale live row read before it waited.
411func TestKeepAndTTLRecheckAfterTheLock(t *testing.T) {
412 race := func(t *testing.T, fx *consoleFixture, app string, request func() int, change func()) int {
413 t.Helper()
414 unlock := fx.srv.lockApp(fx.sqid, app)
415 done := make(chan int, 1)
416 go func() { done <- request() }()
417 waitForWaiter(t, &fx.srv.appLocks, fx.sqid+"/"+app)
418 change()
419 unlock()
420 return <-done
421 }
422
423 t.Run("keep queued while the expiry pass trashes it", func(t *testing.T) {
424 fx, token := temporaryFixture(t)
425 at := time.Now().Truncate(time.Second).Add(-time.Minute)
426 tempApp(t, fx, "tmp-a", at)
427 status := race(t, fx, "tmp-a", func() int {
428 st, _ := postJSON(t, appURL(fx, "tmp-a", "/keep"), map[string]string{}, token)
429 return st
430 }, func() {
431 if err := release.TrashApp(context.Background(), fx.rel, fx.sqid, "tmp-a", expiryActor, at); err != nil {
432 t.Error(err)
433 }
434 })
435 if status != http.StatusConflict {
436 t.Fatalf("keep queued behind a trash = %d, want 409", status)
437 }
438 if l := lifeOf(t, fx, "tmp-a"); !l.Trashed() || !l.Temporary() {
439 t.Fatalf("row = %+v, want trashed, still temporary: the queued keep must not have won", l)
440 }
441 })
442
443 t.Run("ttl queued while the expiry pass trashes it", func(t *testing.T) {
444 fx, token := temporaryFixture(t)
445 at := time.Now().Truncate(time.Second).Add(-time.Minute)
446 tempApp(t, fx, "tmp-b", at)
447 status := race(t, fx, "tmp-b", func() int {
448 st, _ := postJSON(t, appURL(fx, "tmp-b", "/ttl"), map[string]int64{"ttl_seconds": 86400}, token)
449 return st
450 }, func() {
451 if err := release.TrashApp(context.Background(), fx.rel, fx.sqid, "tmp-b", expiryActor, at); err != nil {
452 t.Error(err)
453 }
454 })
455 if status != http.StatusConflict {
456 t.Fatalf("ttl queued behind a trash = %d, want 409", status)
457 }
458 if l := lifeOf(t, fx, "tmp-b"); !l.Trashed() || l.ExpiresAt != at.Unix() {
459 t.Fatalf("row = %+v, want trashed with its original expiry: the queued ttl must not have won", l)
460 }
461 })
462}
463