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 "errors"
7 "fmt"
8 "net/http"
9 "net/url"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/carlosframework/platform/internal/appconfig"
15 "github.com/carlosframework/platform/internal/dnsrec"
16 "github.com/carlosframework/platform/internal/domains"
17 "github.com/carlosframework/platform/internal/doorkey"
18 "github.com/carlosframework/platform/internal/mail"
19 "github.com/carlosframework/platform/internal/objstore"
20 "github.com/carlosframework/platform/internal/release"
21)
22
23// deletableFixture signs an owner in and gives them a SECOND account — one that
24// is not ops, holds no apps, and therefore can actually be deleted. Minted
25// through the bucket and materialized, which is how every real account arrives.
26func deletableFixture(t *testing.T) (*consoleFixture, *http.Client, string, string) {
27 t.Helper()
28 fx := newConsoleFixture(t)
29 c := newBrowser(t)
30 signUp(t, fx, c, "code-delete")
31 _, sqid, err := domains.MintAccount(context.Background(), fx.rel, "Testco", "paul@keymail.dev", time.Now())
32 if err != nil {
33 t.Fatal(err)
34 }
35 if n, err := MaterializeAccounts(context.Background(), fx.rel, fx.st); err != nil || n != 1 {
36 t.Fatalf("materialize = %d %v, want 1", n, err)
37 }
38 return fx, c, sessionToken(t, fx, c), sqid
39}
40
41// A deleted account stays deleted across a console restart.
42//
43// This is the failure the tombstone exists for: account identity lives in the
44// bucket, and every console start materializes it back, so a deletion that only
45// emptied the console's own tables was undone by the next deploy — silently,
46// and complete with the owner membership the manifest names.
47func TestDeletedAccountDoesNotComeBack(t *testing.T) {
48 fx, c, csrf, sqid := deletableFixture(t)
49 ctx := context.Background()
50
51 status, _, body := postGated(t, fx, c, fx.ts.URL+"/a/"+sqid+"/delete",
52 url.Values{"csrf": {csrf}, "confirm": {"Testco"}})
53 if status != http.StatusSeeOther {
54 t.Fatalf("delete = %d:\n%s", status, body)
55 }
56 if a, _ := fx.st.AccountBySqid(sqid); a != nil {
57 t.Fatal("the account survived its own deletion")
58 }
59 // Invisible, but not forgotten: the console must remember the tombstone
60 // (see DeleteAccount) or the next materialize pass could be tricked into
61 // re-birthing it.
62 if a, deleted, err := fx.st.AccountAnyBySqid(sqid); err != nil || a == nil || !deleted {
63 t.Fatalf("AccountAnyBySqid after delete = %+v %v %v, want a marked row", a, deleted, err)
64 }
65
66 // The next console start. Nothing may bring it back.
67 if n, err := MaterializeAccounts(ctx, fx.rel, fx.st); err != nil || n != 0 {
68 t.Fatalf("materialize after deletion = %d %v, want 0", n, err)
69 }
70 if a, _ := fx.st.AccountBySqid(sqid); a != nil {
71 t.Fatal("the deleted account was materialized back out of the bucket")
72 }
73 // And no membership was resurrected with it — the manifest's owner field is
74 // cleared by the tombstone precisely so a consumer that has never heard of
75 // deleted_at cannot hand the account back to a former owner.
76 m := readManifest(t, fx.rel, sqid)
77 if !m.Deleted() {
78 t.Fatal("the manifest was not tombstoned")
79 }
80 if m.Owner != "" {
81 t.Fatalf("tombstone kept owner %q", m.Owner)
82 }
83}
84
85// The id a deleted account held is never handed to another account. Sqids ride
86// in hostnames and in Certificate Transparency; reissuing one would give a new
87// account a name the world already associates with somebody else.
88func TestTombstonedAccountKeepsItsIDSpent(t *testing.T) {
89 fx, c, csrf, sqid := deletableFixture(t)
90 ctx := context.Background()
91 before, err := domains.NextAccountID(ctx, fx.rel)
92 if err != nil {
93 t.Fatal(err)
94 }
95
96 if status, _, body := postGated(t, fx, c, fx.ts.URL+"/a/"+sqid+"/delete",
97 url.Values{"csrf": {csrf}, "confirm": {"Testco"}}); status != http.StatusSeeOther {
98 t.Fatalf("delete = %d:\n%s", status, body)
99 }
100
101 after, err := domains.NextAccountID(ctx, fx.rel)
102 if err != nil {
103 t.Fatal(err)
104 }
105 if after != before {
106 t.Fatalf("next account id moved from %d to %d after a deletion — the retired id is being reused", before, after)
107 }
108 _, next, err := domains.MintAccount(ctx, fx.rel, "Nextco", "paul@keymail.dev", time.Now())
109 if err != nil {
110 t.Fatal(err)
111 }
112 if next == sqid {
113 t.Fatalf("a new account was minted onto the retired sqid %q", sqid)
114 }
115}
116
117// A bucket that cannot be written is a deletion that does not happen. Refusing
118// is the only honest answer: a console-only deletion looks like it worked and
119// then undoes itself.
120func TestDeletionRefusedWhenTheBucketIsUnreachable(t *testing.T) {
121 fx, c, csrf, sqid := deletableFixture(t)
122 fx.srv.cfg.Releases = nil
123
124 status, _, body := postGated(t, fx, c, fx.ts.URL+"/a/"+sqid+"/delete",
125 url.Values{"csrf": {csrf}, "confirm": {"Testco"}})
126 if status != http.StatusSeeOther {
127 t.Fatalf("delete = %d:\n%s", status, body)
128 }
129 if a, _ := fx.st.AccountBySqid(sqid); a == nil {
130 t.Fatal("the account was deleted locally even though the bucket could not be told")
131 }
132}
133
134func readManifest(t *testing.T, s release.Store, sqid string) domains.AccountManifest {
135 t.Helper()
136 body, _, err := s.Get(context.Background(), AccountManifestKey(sqid))
137 if err != nil {
138 t.Fatal(err)
139 }
140 var m domains.AccountManifest
141 if err := json.Unmarshal(body, &m); err != nil {
142 t.Fatal(err)
143 }
144 return m
145}
146
147// ---- the Trash page ---------------------------------------------------------
148//
149// Restore is the undo button — any member, no passkey. "Delete now" is the
150// ceremonial end of the feature: owner, the app's own name typed out, a
151// passkey, and only then is anything destroyed. The tests below are mostly
152// about what must NOT happen, because every refusal here is the difference
153// between a recoverable mistake and an unrecoverable one.
154
155// trashKass puts the fixture's app in the Trash the way every other door
156// does: the bucket marker first, then the row, both carrying one clock.
157func trashKass(t *testing.T, fx *consoleFixture, actor string, now time.Time) {
158 t.Helper()
159 ctx := context.Background()
160 if err := release.TrashApp(ctx, fx.rel, fx.sqid, "kass", actor, now); err != nil {
161 t.Fatal(err)
162 }
163 if err := fx.st.TrashAppRow(OpsAccountID, "kass", now); err != nil {
164 t.Fatal(err)
165 }
166}
167
168// trashPageFixture is the owner signed in with kass already in the Trash.
169func trashPageFixture(t *testing.T) (*consoleFixture, *http.Client, string) {
170 t.Helper()
171 fx := newConsoleFixture(t)
172 c := newBrowser(t)
173 signUp(t, fx, c, "code-trash")
174 trashKass(t, fx, "paul@keymail.dev", time.Now())
175 return fx, c, sessionToken(t, fx, c)
176}
177
178// appKeys is everything the bucket still holds for an app — the yardstick for
179// "nothing was destroyed" and for "everything was".
180func appKeys(t *testing.T, fx *consoleFixture, app string) []string {
181 t.Helper()
182 keys, err := fx.rel.List(context.Background(), "apps/"+fx.sqid+"/"+app+"/")
183 if err != nil {
184 t.Fatal(err)
185 }
186 return keys
187}
188
189// The page itself: the trashed app, who trashed it, when the platform will
190// destroy it on its own, and the one thing a restore does not bring back.
191func TestTrashPageListsWhatIsInIt(t *testing.T) {
192 fx, c, _ := trashPageFixture(t)
193
194 status, _, body := get(t, c, fx.u("/trash"))
195 if status != http.StatusOK {
196 t.Fatalf("trash page = %d:\n%s", status, body)
197 }
198 tomb, err := release.ReadAppTombstone(context.Background(), fx.rel, fx.sqid, "kass")
199 if err != nil || tomb == nil {
200 t.Fatalf("marker = %+v %v", tomb, err)
201 }
202 purgesAt := tomb.DeletedAt.Add(release.TrashCooloff).UTC().Format("2006-01-02 15:04 UTC")
203 for _, want := range []string{"kass", "paul@keymail.dev", purgesAt, "carlos add"} {
204 if !strings.Contains(body, want) {
205 t.Fatalf("trash page missing %q in:\n%s", want, body)
206 }
207 }
208
209 // The account home links here — and only while there is something to see.
210 _, _, home := get(t, c, fx.u("/"))
211 if !strings.Contains(home, "Trash (1)") {
212 t.Fatalf("account home has no Trash link:\n%s", home)
213 }
214 if strings.Contains(home, `<a href="`+fx.path("")+`/apps/kass">`) {
215 t.Fatal("a trashed app is still listed among the live ones")
216 }
217 if err := fx.st.RestoreAppRow(OpsAccountID, "kass"); err != nil {
218 t.Fatal(err)
219 }
220 if _, _, home := get(t, c, fx.u("/")); strings.Contains(home, "Trash (") {
221 t.Fatalf("an empty trash still advertises itself:\n%s", home)
222 }
223}
224
225// The page renders when the bucket cannot be read: the actor is unknown, the
226// row's own date stands in, and nothing about the page is destructive. Only
227// destruction requires believing the marker.
228func TestTrashPageSurvivesAnUnreadableMarker(t *testing.T) {
229 fx, c, _ := trashPageFixture(t)
230 ctx := context.Background()
231 key := release.AppTombstoneKey(fx.sqid, "kass")
232 if err := fx.rel.Delete(ctx, key); err != nil {
233 t.Fatal(err)
234 }
235 if _, err := fx.rel.Put(ctx, key, []byte("{ not json"), ""); err != nil {
236 t.Fatal(err)
237 }
238
239 status, _, body := get(t, c, fx.u("/trash"))
240 if status != http.StatusOK {
241 t.Fatalf("trash page with an unreadable marker = %d:\n%s", status, body)
242 }
243 if !strings.Contains(body, "kass") || !strings.Contains(body, "<td>—</td>") {
244 t.Fatalf("trash page did not degrade to an em-dash actor:\n%s", body)
245 }
246 // The ROW's own date stands in, so the page still says when this will be
247 // destroyed rather than showing the zero time.
248 rows, err := fx.st.TrashedAppsIn(OpsAccountID)
249 if err != nil || len(rows) != 1 {
250 t.Fatalf("TrashedAppsIn = %+v %v", rows, err)
251 }
252 fromRow := rows[0].DeletedAt.Add(release.TrashCooloff).UTC().Format("2006-01-02 15:04 UTC")
253 if !strings.Contains(body, fromRow) {
254 t.Fatalf("trash page did not fall back to the row's date (%s):\n%s", fromRow, body)
255 }
256}
257
258// Neither the page nor either action may tell a stranger this account exists.
259func TestTrashIsNotAnOracle(t *testing.T) {
260 fx, _, _ := trashPageFixture(t)
261 stranger := newBrowser(t)
262 fx.setUser("stranger@keymail.dev")
263 signUp(t, fx, stranger, "code-trash-stranger")
264 csrf := sessionToken(t, fx, stranger)
265
266 _, _, missingGet := get(t, stranger, fx.ts.URL+"/a/zzzzz/trash")
267 if _, _, body := get(t, stranger, fx.u("/trash")); body != missingGet {
268 t.Fatalf("the trash page answers a non-member differently from a nonexistent account:\n%q\nvs\n%q",
269 body, missingGet)
270 }
271 _, _, missingPost := postForm(t, stranger, fx.ts.URL+"/a/zzzzz/trash/restore",
272 url.Values{"csrf": {csrf}, "app": {"kass"}})
273 for _, path := range []string{"/trash/restore", "/trash/delete"} {
274 status, loc, body := postForm(t, stranger, fx.u(path),
275 url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
276 if status != http.StatusNotFound {
277 t.Fatalf("%s as a non-member = %d -> %q, want 404", path, status, loc)
278 }
279 if body != missingPost {
280 t.Fatalf("%s as a non-member answers differently from a nonexistent account:\n%q\nvs\n%q",
281 path, body, missingPost)
282 }
283 }
284 if len(appKeys(t, fx, "kass")) == 0 {
285 t.Fatal("a stranger's post destroyed something")
286 }
287}
288
289// Restore is cheap: any member, no passkey, and it says what it did not do.
290func TestRestoreNeedsNoPasskey(t *testing.T) {
291 fx, _, _ := trashPageFixture(t)
292 if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
293 t.Fatal(err)
294 }
295 fx.setUser("sam@keymail.dev")
296 c := newBrowser(t)
297 signUp(t, fx, c, "code-sam-restore")
298 csrf := sessionToken(t, fx, c)
299
300 // postForm, deliberately: a step-up redirect here would be a bug.
301 status, loc, body := postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"kass"}})
302 if status != http.StatusSeeOther || strings.HasPrefix(loc, fx.fk.srv.URL) {
303 t.Fatalf("restore = %d -> %q, want a plain redirect with no passkey:\n%s", status, loc, body)
304 }
305 if tomb, err := release.ReadAppTombstone(context.Background(), fx.rel, fx.sqid, "kass"); err != nil || tomb != nil {
306 t.Fatalf("the trash marker survived the restore: %+v %v", tomb, err)
307 }
308 if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
309 t.Fatalf("HasAppIn after restore = %v %v, want the row live again", has, err)
310 }
311 // The one thing restore does NOT do, said where the person can read it.
312 _, _, page := get(t, c, fx.ts.URL+loc)
313 if !strings.Contains(page, "carlos add") {
314 t.Fatalf("the restore flash does not mention that routes need carlos add:\n%s", page)
315 }
316 auditedBy(t, fx, "kass", "restore", "sam@keymail.dev", "kass")
317}
318
319// A member who is not an owner meets the 404, before a passkey redirect could
320// confirm anything.
321func TestDeleteNowIsOwnerOnly(t *testing.T) {
322 fx, _, _ := trashPageFixture(t)
323 if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
324 t.Fatal(err)
325 }
326 fx.setUser("sam@keymail.dev")
327 c := newBrowser(t)
328 signUp(t, fx, c, "code-sam-purge")
329 csrf := sessionToken(t, fx, c)
330
331 before := appKeys(t, fx, "kass")
332 status, _, memberBody := postForm(t, c, fx.u("/trash/delete"),
333 url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
334 if status != http.StatusNotFound {
335 t.Fatalf("member deleting permanently = %d, want 404", status)
336 }
337 strangerBody := strangerRefusal(t, fx, "code-zoe-purge", fx.u("/trash/delete"),
338 url.Values{"app": {"kass"}, "confirm": {"kass"}})
339 if memberBody != strangerBody {
340 t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody)
341 }
342 if len(appKeys(t, fx, "kass")) != len(before) {
343 t.Fatal("a member's refused delete destroyed objects")
344 }
345 if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has {
346 t.Fatalf("HasTrashedAppIn = %v %v, want the row untouched", has, err)
347 }
348}
349
350// The typed name is checked BEFORE the passkey, so a slip costs nothing but a
351// retry — and destroys nothing.
352func TestDeleteNowRequiresTheAppsOwnName(t *testing.T) {
353 fx, c, csrf := trashPageFixture(t)
354 before := appKeys(t, fx, "kass")
355
356 status, loc, body := postForm(t, c, fx.u("/trash/delete"),
357 url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kas"}})
358 if status != http.StatusSeeOther || strings.HasPrefix(loc, fx.fk.srv.URL) {
359 t.Fatalf("mistyped delete = %d -> %q, want a flash and no passkey demand:\n%s", status, loc, body)
360 }
361 if len(appKeys(t, fx, "kass")) != len(before) {
362 t.Fatal("a mistyped delete destroyed objects")
363 }
364 if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has {
365 t.Fatalf("HasTrashedAppIn = %v %v, want the row untouched", has, err)
366 }
367 var stashed int
368 fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed)
369 if stashed != 0 {
370 t.Fatal("a mistyped delete parked a pending action — the passkey was asked for anyway")
371 }
372}
373
374// The whole ceremony, and what is left afterwards: nothing in the bucket, no
375// row, and a name anyone in the account may claim again.
376func TestDeleteNowDestroysEverythingAndFreesTheName(t *testing.T) {
377 fx, c, csrf := trashPageFixture(t)
378
379 // Without the passkey, nothing moves.
380 status, loc, _ := postForm(t, c, fx.u("/trash/delete"),
381 url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
382 if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
383 t.Fatalf("un-stepped-up delete = %d -> %q, want a redirect into Keymail", status, loc)
384 }
385 if len(appKeys(t, fx, "kass")) == 0 {
386 t.Fatal("objects were destroyed before the passkey confirmation")
387 }
388
389 // Purge does not announce: by the time it runs, kass's routes and minting
390 // were already stopped at trash time — there is nothing left a box's fast
391 // pass converges on (see handlePurgeApp, ownership.go).
392 before := serial(t, fx)
393
394 // With it, the held action resumes and lands.
395 status, loc, body := completeStepUp(t, fx, c, loc)
396 if status != http.StatusSeeOther {
397 t.Fatalf("resumed delete = %d:\n%s", status, body)
398 }
399 if keys := appKeys(t, fx, "kass"); len(keys) != 0 {
400 t.Fatalf("the bucket still holds %v", keys)
401 }
402 if got := serial(t, fx); got != before {
403 t.Fatalf("serial after a purge = %d, want unchanged (%d) — purge announces nothing", got, before)
404 }
405 if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || has {
406 t.Fatalf("HasTrashedAppIn after purge = %v %v, want the row gone", has, err)
407 }
408 created, err := fx.st.CreateAppIn(OpsAccountID, "kass", "paul@keymail.dev", time.Now())
409 if err != nil || !created {
410 t.Fatalf("re-claiming the purged name = %v %v, want it free", created, err)
411 }
412 auditedBy(t, fx, "kass", "purge", "paul@keymail.dev", "kass")
413 if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "free to claim again") {
414 t.Fatalf("the purge flash does not say the name is free:\n%s", page)
415 }
416}
417
418// Delete now takes the app's ledgers and door keys with it (#219 items 1
419// and 2): both are keyed by the name the purge frees, outside apps/. The
420// door-key record is republished at once, empty, rather than at the next
421// 12-hourly re-sign.
422func TestDeleteNowSweepsLedgersAndDoorKeys(t *testing.T) {
423 fx, c, csrf := trashPageFixture(t)
424 if _, err := fx.st.DoorKeyInsert(OpsAccountID, "kass", doorkey.Entry{ID: "0123456789abcdef", SHA256: strings.Repeat("a", 64)}); err != nil {
425 t.Fatal(err)
426 }
427 ledgerKey := release.LedgerAppPrefix(fx.sqid, "kass") + "ledgers.json"
428 if _, err := fx.rel.Put(context.Background(), ledgerKey, []byte(`{"schema":1,"public":true}`), ""); err != nil {
429 t.Fatal(err)
430 }
431 status, _, body := postGated(t, fx, c, fx.u("/trash/delete"),
432 url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
433 if status != http.StatusSeeOther {
434 t.Fatalf("delete = %d:\n%s", status, body)
435 }
436 if keys, err := fx.rel.List(context.Background(), release.LedgerAppPrefix(fx.sqid, "kass")); err != nil || len(keys) != 0 {
437 t.Fatalf("ledgers after purge = %v %v, want none", keys, err)
438 }
439 wire, _, err := fx.rel.Get(context.Background(), doorkey.RecordKey(fx.sqid, "kass"))
440 if err != nil {
441 t.Fatalf("door-key record after purge: %v", err)
442 }
443 rec, err := doorkey.Verify(fx.pub, wire, time.Now())
444 if err != nil {
445 t.Fatal(err)
446 }
447 if len(rec.Keys) != 0 {
448 t.Fatalf("published door keys after purge = %+v, want none", rec.Keys)
449 }
450}
451
452// The production flag is the deletion protection, all the way to the last
453// door: a trashed app that is flagged production is not destroyed, however
454// much ceremony is performed at it.
455func TestDeleteNowRefusesAProductionApp(t *testing.T) {
456 fx := newConsoleFixture(t)
457 ctx := context.Background()
458 // Flagged BEFORE the trash, bucket-direct: the API door refuses to trash a
459 // production app at all, so this is the state that can only arrive by a
460 // flag set after the fact — the impossible one the purge path still guards.
461 if err := release.SetProduction(ctx, fx.rel, fx.priv, release.OpsAccount, "kass", true, "test"); err != nil {
462 t.Fatal(err)
463 }
464 c := newBrowser(t)
465 signUp(t, fx, c, "code-trash-prod")
466 trashKass(t, fx, "paul@keymail.dev", time.Now())
467 csrf := sessionToken(t, fx, c)
468 before := appKeys(t, fx, "kass")
469
470 status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"),
471 url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
472 if status != http.StatusSeeOther {
473 t.Fatalf("delete of a production app = %d:\n%s", status, body)
474 }
475 if keys := appKeys(t, fx, "kass"); len(keys) != len(before) {
476 t.Fatalf("a refused delete destroyed objects: %v -> %v", before, keys)
477 }
478 if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has {
479 t.Fatalf("HasTrashedAppIn = %v %v, want the row still trashed", has, err)
480 }
481 // The same sentence the CLI's 409 gives, so an operator meeting both hears
482 // one instruction.
483 if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "the off-switch on the app page") {
484 t.Fatalf("the refusal does not name the off-switch on the app page:\n%s", page)
485 }
486}
487
488// ---- purge and the object store ---------------------------------------------
489//
490// An app's object store is the one part of it that lives OUTSIDE the deployment
491// bucket — a real bucket, in a real cloud account, with a real IAM principal
492// holding a live key. Sweeping apps/<acct>/<app>/ without tearing that down
493// first would leave a paid, credentialed estate that no record names, which is
494// why the box-side auto-purge refuses outright (release.ErrStoreProvisioned)
495// and why this door — the only one with backend authority — goes first.
496
497// armStoreBackend gives the fixture's console an in-memory storage backend and
498// a deployment that mints carlos-<acct>-<app> buckets, which is what makes it a
499// console that may destroy one.
500func armStoreBackend(t *testing.T, fx *consoleFixture) *objstore.MemProvisioner {
501 t.Helper()
502 fake := objstore.NewMemProvisioner("eu-west-1")
503 fx.srv.cfg.StoreProvisioner = fake
504 fx.srv.cfg.StoreDeployment = objstore.Deployment{Backend: "mem", Region: "eu-west-1", BucketPrefix: "carlos-"}
505 return fake
506}
507
508// seedStore builds the estate a completed grant leaves behind — a bucket with a
509// customer object in it, the principal holding its key, and the record naming
510// both — hand-written because the door that builds it is an operator API away
511// (apistore.go). crashed=true is the shape a grant that died before its step-6
512// Put leaves: bucket, user and key all real, `provisioned` never recorded. It
513// returns the bucket name, which is also the word this app's destroy door makes
514// a person type.
515func seedStore(t *testing.T, fx *consoleFixture, fake *objstore.MemProvisioner, app string, crashed bool) string {
516 t.Helper()
517 ctx := context.Background()
518 bucket := objstore.MintedBucket(fx.srv.cfg.StoreDeployment, fx.sqid, app)
519 if _, err := fake.EnsureBucket(ctx, bucket); err != nil {
520 t.Fatal(err)
521 }
522 fake.Buckets[bucket]["customer/data.bin"] = []byte("the customer's objects")
523 user := objstore.UserName(fx.sqid, app)
524 arn, err := fake.EnsureUser(ctx, user, objstore.UserPath, "arn:aws:s3:::"+bucket, nil)
525 if err != nil {
526 t.Fatal(err)
527 }
528 keyID, _, err := fake.MintKey(ctx, user)
529 if err != nil {
530 t.Fatal(err)
531 }
532 now := time.Now()
533 rec := &objstore.Record{
534 Declared: &objstore.Declared{EnvPrefix: objstore.DefaultEnvPrefix, At: now, By: "paul@keymail.dev"},
535 Grant: &objstore.GrantBlock{Backend: "mem", Bucket: bucket, At: now, By: "paul@keymail.dev"},
536 }
537 if !crashed {
538 rec.Provisioned = &objstore.Provisioned{Bucket: bucket, Region: "eu-west-1", IAMUser: user,
539 UserARN: arn, KeyID: keyID, At: now}
540 // The delivered half: the coordinates a grant writes into the app's
541 // config. Teardown unsets these, so their presence is what "nothing was
542 // destroyed" means on the config side, and their absence is half of what
543 // a completed teardown means.
544 bucketName, regionName, _, _, _ := objstore.EnvNames(objstore.DefaultEnvPrefix)
545 if _, err := appconfig.Apply(ctx, fx.rel, fx.sqid, app, map[string]appconfig.Var{
546 bucketName: {Kind: "env", Value: bucket},
547 regionName: {Kind: "env", Value: "eu-west-1"},
548 }, nil); err != nil {
549 t.Fatal(err)
550 }
551 }
552 putRecord(t, fx, app, rec)
553 return bucket
554}
555
556// storeVarsDelivered reports whether the app's config still holds the store
557// coordinates seedStore delivered.
558func storeVarsDelivered(t *testing.T, fx *consoleFixture, app string) bool {
559 t.Helper()
560 bucketName, _, _, _, _ := objstore.EnvNames(objstore.DefaultEnvPrefix)
561 _, ok := appVars(t, fx, app)[bucketName]
562 return ok
563}
564
565// noStoreDestroyAudit asserts nothing claimed to have destroyed a store — the
566// trail a refusal that ran too late would leave.
567func noStoreDestroyAudit(t *testing.T, fx *consoleFixture, app string) {
568 t.Helper()
569 events, err := fx.st.Events(OpsAccountID, app, 20)
570 if err != nil {
571 t.Fatal(err)
572 }
573 for _, e := range events {
574 if e.Action == "store-destroy" {
575 t.Fatalf("a refused purge audited a store teardown: %+v", e)
576 }
577 }
578}
579
580// estateStanding asserts the backend still holds everything seedStore built —
581// the yardstick for "a refusal destroyed nothing".
582func estateStanding(t *testing.T, fx *consoleFixture, fake *objstore.MemProvisioner, app, bucket, when string) {
583 t.Helper()
584 if len(fake.Buckets[bucket]) == 0 {
585 t.Fatalf("%s: the store bucket %s is empty or gone — objects were destroyed", when, bucket)
586 }
587 if _, ok := fake.Users[objstore.UserName(fx.sqid, app)]; !ok {
588 t.Fatalf("%s: the store's IAM principal was deleted", when)
589 }
590}
591
592// estateGone asserts the whole backend estate is down: no bucket, no principal.
593func estateGone(t *testing.T, fake *objstore.MemProvisioner, when string) {
594 t.Helper()
595 if len(fake.Buckets) != 0 || len(fake.Users) != 0 {
596 t.Fatalf("%s left an estate standing: buckets %v, users %v", when, fake.Buckets, fake.Users)
597 }
598}
599
600// A console with no storage backend configured cannot tear a store down, so it
601// must not purge the app either: the prefix sweep would delete store.json —
602// the ONLY record naming a live bucket and a live key — and nothing would ever
603// find them again. The refusal names the variable that fixes it.
604func TestPurgeRefusesAStoreThisConsoleCannotTearDown(t *testing.T) {
605 fx, c, csrf := trashPageFixture(t)
606 fake := armStoreBackend(t, fx)
607 bucket := seedStore(t, fx, fake, "kass", false)
608 fx.srv.cfg.StoreProvisioner = nil // the deployment shape with no backend
609
610 status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"),
611 url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
612 if status != http.StatusSeeOther {
613 t.Fatalf("purge with no backend = %d:\n%s", status, body)
614 }
615 estateStanding(t, fx, fake, "kass", bucket, "a backend-less purge")
616 if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil {
617 t.Fatalf("the store record did not survive the refusal: %+v", rec)
618 }
619 if len(appKeys(t, fx, "kass")) == 0 {
620 t.Fatal("the app's deployment objects were swept by a refused purge")
621 }
622 if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has {
623 t.Fatalf("HasTrashedAppIn = %v %v, want the row still trashed", has, err)
624 }
625 if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "CARLOS_STORE_BACKEND") {
626 t.Fatalf("the refusal does not name the backend variable:\n%s", page)
627 }
628}
629
630// TestPurgeRefusesNoMailBackendBeforeTouchingTheStore: R5, review
631// follow-up. The "this app has an email estate but no MailProvisioner"
632// refusal used to sit AFTER objstore.Teardown had already destroyed the
633// object store, mirroring the very bug
634// TestPurgeRefusesAStoreThisConsoleCannotTearDown above already guards
635// against on the store side — a purge that was always going to be refused
636// destroyed the store first. Same shape, but for the email-side refusal:
637// the store must still be standing after it.
638func TestPurgeRefusesNoMailBackendBeforeTouchingTheStore(t *testing.T) {
639 fx, c, csrf := trashPageFixture(t)
640 fake := armStoreBackend(t, fx)
641 bucket := seedStore(t, fx, fake, "kass", false)
642
643 // An email estate with no MailProvisioner configured — this deployment
644 // cannot tear it down, so the whole purge must refuse before it
645 // touches anything.
646 now := time.Now()
647 if _, err := mail.Declare(context.Background(), fx.rel, release.OpsAccount, "kass",
648 "KASS_SMTP", "noreply@kass.example.com", "paul@keymail.dev", now); err != nil {
649 t.Fatal(err)
650 }
651 mrec, metag, err := mail.Get(context.Background(), fx.rel, release.OpsAccount, "kass")
652 if err != nil {
653 t.Fatal(err)
654 }
655 mrec.Provisioned = &mail.Provisioned{IAMUser: "u", KeyID: "AKIA1", Active: true, At: now}
656 if _, err := mail.Put(context.Background(), fx.rel, release.OpsAccount, "kass", mrec, metag); err != nil {
657 t.Fatal(err)
658 }
659 fx.srv.cfg.MailProvisioner = nil
660
661 status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"),
662 url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
663 if status != http.StatusSeeOther {
664 t.Fatalf("purge with no mail backend = %d:\n%s", status, body)
665 }
666 // R5: the store must be untouched — the refusal must have fired BEFORE
667 // objstore.Teardown, not after.
668 estateStanding(t, fx, fake, "kass", bucket, "a purge refused for lacking a mail backend")
669 if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil {
670 t.Fatalf("the store record did not survive the refusal: %+v", rec)
671 }
672 if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "no SES backend configured") {
673 t.Fatalf("the refusal does not name the missing backend:\n%s", page)
674 }
675}
676
677// The whole point of the door: the backend estate comes down FIRST, and only
678// then is the deployment prefix swept — store.json included, now that it names
679// nothing.
680func TestPurgeTearsTheStoreDownBeforeSweepingThePrefix(t *testing.T) {
681 fx, c, csrf := trashPageFixture(t)
682 fake := armStoreBackend(t, fx)
683 seedStore(t, fx, fake, "kass", false)
684 before := serial(t, fx)
685
686 status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"),
687 url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
688 if status != http.StatusSeeOther {
689 t.Fatalf("purge = %d:\n%s", status, body)
690 }
691 estateGone(t, fake, "a completed purge")
692 if keys := appKeys(t, fx, "kass"); len(keys) != 0 {
693 t.Fatalf("the deployment bucket still holds %v", keys)
694 }
695 if rec := storedRecord(t, fx, "kass"); rec != nil {
696 t.Fatalf("the store record survived the purge: %+v", rec)
697 }
698 // Both halves are audited: the store teardown is a separate, irreversible
699 // act on a separate estate, and an operator reading the trail must see it.
700 auditedBy(t, fx, "kass", "store-destroy", "paul@keymail.dev", "carlos-bab-kass")
701 auditedBy(t, fx, "kass", "purge", "paul@keymail.dev", "kass")
702 if got := serial(t, fx); got != before {
703 t.Fatalf("serial after a purge = %d, want unchanged (%d)", got, before)
704 }
705 if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "free to claim again") {
706 t.Fatalf("no purge flash:\n%s", page)
707 }
708}
709
710// A grant that crashed before recording itself leaves a record with `grant` and
711// no `provisioned` — over a bucket, a user and a key that are all real. The
712// guard is on EITHER block for exactly this: reading it as "nothing to tear
713// down" would sweep the only witness to a live, billing estate.
714func TestPurgeTearsDownACrashedGrant(t *testing.T) {
715 fx, c, csrf := trashPageFixture(t)
716 fake := armStoreBackend(t, fx)
717 seedStore(t, fx, fake, "kass", true)
718 if rec := storedRecord(t, fx, "kass"); rec.Provisioned != nil {
719 t.Fatal("the crashed-grant fixture recorded a provisioned block")
720 }
721
722 status, _, body := postGated(t, fx, c, fx.u("/trash/delete"),
723 url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
724 if status != http.StatusSeeOther {
725 t.Fatalf("purge of a crashed grant = %d:\n%s", status, body)
726 }
727 estateGone(t, fake, "a purge over a crashed grant")
728 if keys := appKeys(t, fx, "kass"); len(keys) != 0 {
729 t.Fatalf("the deployment bucket still holds %v", keys)
730 }
731 auditedBy(t, fx, "kass", "store-destroy", "paul@keymail.dev", "carlos-bab-kass")
732}
733
734// The production flag outranks deletion all the way down — and that promise is
735// only kept if it is checked BEFORE the store comes down. A refusal that
736// arrives after the teardown has already destroyed a LIVE app's bucket, its
737// credentials and the variables its hosts are running on, and purge announces
738// nothing, so those hosts would keep serving dead credentials until the hourly
739// floor.
740func TestPurgeRefusesAProductionAppBeforeTouchingItsStore(t *testing.T) {
741 fx := newConsoleFixture(t)
742 ctx := context.Background()
743 // Flagged BEFORE the trash, bucket-direct — the state only a flag set after
744 // the fact can reach, which is exactly the one this ordering protects.
745 if err := release.SetProduction(ctx, fx.rel, fx.priv, release.OpsAccount, "kass", true, "test"); err != nil {
746 t.Fatal(err)
747 }
748 c := newBrowser(t)
749 signUp(t, fx, c, "code-purge-prod-store")
750 trashKass(t, fx, "paul@keymail.dev", time.Now())
751 csrf := sessionToken(t, fx, c)
752 fake := armStoreBackend(t, fx)
753 bucket := seedStore(t, fx, fake, "kass", false)
754
755 status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"),
756 url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
757 if status != http.StatusSeeOther {
758 t.Fatalf("purge of a production app with a store = %d:\n%s", status, body)
759 }
760 estateStanding(t, fx, fake, "kass", bucket, "a purge refused for production")
761 if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil {
762 t.Fatalf("the store record was destroyed by a refused purge: %+v", rec)
763 }
764 if !storeVarsDelivered(t, fx, "kass") {
765 t.Fatal("the delivered store variables were unset by a refused purge")
766 }
767 noStoreDestroyAudit(t, fx, "kass")
768 if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has {
769 t.Fatalf("HasTrashedAppIn = %v %v, want the row still trashed", has, err)
770 }
771 if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "the off-switch on the app page") {
772 t.Fatalf("the refusal does not name the off-switch:\n%s", page)
773 }
774}
775
776// "A restore that landed while this form sat open wins" — handlePurgeApp's own
777// promise, which is worth nothing if the store is already gone by the time the
778// marker is re-read. The app here is LIVE again: destroying its store would be
779// destroying a running app's data.
780func TestPurgeRefusesARestoredAppBeforeTouchingItsStore(t *testing.T) {
781 fx, c, csrf := trashPageFixture(t)
782 fake := armStoreBackend(t, fx)
783 bucket := seedStore(t, fx, fake, "kass", false)
784 // The restore lands while the "Delete now" form sits open.
785 if err := release.RestoreApp(context.Background(), fx.rel, fx.sqid, "kass"); err != nil {
786 t.Fatal(err)
787 }
788
789 status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"),
790 url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
791 if status != http.StatusSeeOther {
792 t.Fatalf("purge of a restored app with a store = %d:\n%s", status, body)
793 }
794 estateStanding(t, fx, fake, "kass", bucket, "a purge refused for a restore")
795 if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil {
796 t.Fatalf("the store record was destroyed by a refused purge: %+v", rec)
797 }
798 if !storeVarsDelivered(t, fx, "kass") {
799 t.Fatal("the delivered store variables were unset by a refused purge")
800 }
801 noStoreDestroyAudit(t, fx, "kass")
802 if len(appKeys(t, fx, "kass")) == 0 {
803 t.Fatal("the restored app's deployment objects were swept")
804 }
805 if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "not in the trash") {
806 t.Fatalf("the refusal does not say the app is out of the trash:\n%s", page)
807 }
808}
809
810// purgeAdvice must be honest in BOTH directions: the guard wraps
811// ErrStoreProvisioned for an unreadable store.json too, so this sentence
812// cannot claim a store certainly exists — and it must still name the way out.
813func TestPurgeAdviceMapsAStoreRefusalWithoutOverclaiming(t *testing.T) {
814 msg := purgeAdvice(fmt.Errorf("bab/kass: %w", release.ErrStoreProvisioned), "kass")
815 for _, want := range []string{"kass", "object store", "Settings"} {
816 if !strings.Contains(msg, want) {
817 t.Fatalf("purgeAdvice = %q, want it to mention %q", msg, want)
818 }
819 }
820 if !strings.Contains(msg, "may have") {
821 t.Fatalf("purgeAdvice = %q, want it to admit the store may only MAYBE exist "+
822 "(the guard wraps this sentinel for an unreadable record too)", msg)
823 }
824}
825
826// ---- purge and the email estate (I2, review follow-up) --------------------
827//
828// mail.Teardown had NO production caller anywhere before this fix. A purged
829// app left a live IAM user holding an active key, its SES identity, its
830// published DNS, and — worst — the create-only GLOBAL domain claim at
831// email/domain-claims/<domain>.json. Because release.PurgeApp removes
832// email.json while DomainRecord.Apps still names the deleted app, the
833// last-referrer branch inside mail.Teardown could never fire again for that
834// domain once the app was gone — the domain was then denied to every CARLOS
835// account, including its rightful owner, permanently.
836//
837// The proof used here is the claim's own semantics, not an internal peek at
838// bucket keys: a SECOND, unrelated account trying to ensure the SAME domain
839// is refused with ErrDomainClaimed before the purge (the claim is doing its
840// job), and succeeds after (the purge released it).
841func TestPurgeReleasesTheDomainClaim(t *testing.T) {
842 fx, c, csrf := trashPageFixture(t)
843 fake := mail.NewMemProvisioner("1")
844 writer := dnsrec.NewMemWriter()
845 fx.srv.cfg.MailProvisioner = fake
846 fx.srv.cfg.DNSWriter = writer
847 dep := mail.Deployment{Region: "eu-west-1"} // no PlatformSuffix: a custom
848 // domain keeps this test about the claim gate alone, not the SEPARATE
849 // platform-name-ownership gate (walk.go's platformNameOwnedBy), which
850 // would otherwise refuse the second account for an unrelated reason.
851
852 ctx := context.Background()
853 now := time.Now()
854 domain := "shared-claim.example.com"
855 // Deliberately no mail.Declare/Enable — kass has a domain record (and
856 // the claim it holds) but no Provisioned credential, exactly the shape
857 // `carlos email domains add` alone leaves. mail.Teardown must still
858 // find and release it: the domain-referrer set, not just a delivered
859 // credential, is what the claim traces back to.
860 if _, _, err := mail.EnsureDomain(ctx, fx.rel, fake, writer, dep, fx.sqid, "kass", domain, now); err != nil {
861 t.Fatalf("EnsureDomain for kass: %v", err)
862 }
863
864 if _, _, err := mail.EnsureDomain(ctx, fx.rel, fake, writer, dep, "zzzz", "other", domain, now); !errors.Is(err, mail.ErrDomainClaimed) {
865 t.Fatalf("a second account's EnsureDomain before the purge = %v, want ErrDomainClaimed", err)
866 }
867
868 status, _, body := postGated(t, fx, c, fx.u("/trash/delete"),
869 url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
870 if status != http.StatusSeeOther {
871 t.Fatalf("purge = %d:\n%s", status, body)
872 }
873 auditedBy(t, fx, "kass", "email-teardown", "paul@keymail.dev", "email estate")
874
875 if _, _, err := mail.EnsureDomain(ctx, fx.rel, fake, writer, dep, "zzzz", "other", domain, now); err != nil {
876 t.Fatalf("EnsureDomain after the purge = %v, want the claim released for anyone to take", err)
877 }
878}
879
880// Spec §9 "Deadline display": a temporary and a permanent app trashed
881// together show 3 and 30 days, on the Trash page and in the trash API.
882func TestTrashShowsEachAppsOwnDeadline(t *testing.T) {
883 fx := newConsoleFixture(t)
884 tempApp(t, fx, "tmp-a", time.Now().Add(24*time.Hour))
885 token := cliToken(t, fx, "paul@keymail.dev", "code-deadlines")
886 until := map[string]time.Time{}
887 for _, app := range []string{"tmp-a", "kass"} {
888 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/"+app+"/delete", map[string]string{}, token)
889 if status != http.StatusOK {
890 t.Fatalf("delete %s = %d %v", app, status, out)
891 }
892 u, err := time.Parse(time.RFC3339, str(t, out, "restorable_until"))
893 if err != nil {
894 t.Fatal(err)
895 }
896 until[app] = u
897 }
898 tmp, kass := lifeOf(t, fx, "tmp-a"), lifeOf(t, fx, "kass")
899 if want := time.Unix(tmp.DeletedAt, 0).Add(72 * time.Hour); !until["tmp-a"].Equal(want) {
900 t.Errorf("tmp-a restorable_until = %v, want %v", until["tmp-a"], want)
901 }
902 if want := time.Unix(kass.DeletedAt, 0).Add(30 * 24 * time.Hour); !until["kass"].Equal(want) {
903 t.Errorf("kass restorable_until = %v, want %v", until["kass"], want)
904 }
905 c, _ := signInAs(t, fx, "paul@keymail.dev", "code-deadlines-web")
906 _, _, page := get(t, c, fx.u("/trash"))
907 for app, u := range until {
908 if !strings.Contains(page, u.UTC().Format("2006-01-02 15:04 UTC")) {
909 t.Errorf("the Trash page does not show %s's deadline %v", app, u)
910 }
911 }
912}
913
914// Astra plan round 3: the delete confirmation on Settings promises the window
915// the app will actually get, before anything is deleted: 3 days for a
916// temporary app, 30 for a permanent one. The number sits in its own element
917// so the assertion survives any rewording of the sentence around it.
918func TestDeleteConfirmationNamesEachAppsWindow(t *testing.T) {
919 fx := newConsoleFixture(t)
920 tempApp(t, fx, "tmp-a", time.Now().Add(24*time.Hour))
921 c, _ := signInAs(t, fx, "paul@keymail.dev", "code-delete-window")
922 for app, days := range map[string]string{"tmp-a": "3", "kass": "30"} {
923 status, _, page := get(t, c, fx.u("/apps/"+app+"/settings/delete"))
924 if status != http.StatusOK {
925 t.Fatalf("%s delete settings = %d", app, status)
926 }
927 if want := `class="restore-days">` + days + ` days<`; !strings.Contains(page, want) {
928 t.Errorf("%s: the delete confirmation does not promise %s days (want %q in the page)", app, days, want)
929 }
930 }
931}
932