package console import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "testing" "time" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/dnsrec" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/doorkey" "github.com/carlosframework/platform/internal/mail" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/release" ) // deletableFixture signs an owner in and gives them a SECOND account — one that // is not ops, holds no apps, and therefore can actually be deleted. Minted // through the bucket and materialized, which is how every real account arrives. func deletableFixture(t *testing.T) (*consoleFixture, *http.Client, string, string) { t.Helper() fx := newConsoleFixture(t) c := newBrowser(t) signUp(t, fx, c, "code-delete") _, sqid, err := domains.MintAccount(context.Background(), fx.rel, "Testco", "paul@keymail.dev", time.Now()) if err != nil { t.Fatal(err) } if n, err := MaterializeAccounts(context.Background(), fx.rel, fx.st); err != nil || n != 1 { t.Fatalf("materialize = %d %v, want 1", n, err) } return fx, c, sessionToken(t, fx, c), sqid } // A deleted account stays deleted across a console restart. // // This is the failure the tombstone exists for: account identity lives in the // bucket, and every console start materializes it back, so a deletion that only // emptied the console's own tables was undone by the next deploy — silently, // and complete with the owner membership the manifest names. func TestDeletedAccountDoesNotComeBack(t *testing.T) { fx, c, csrf, sqid := deletableFixture(t) ctx := context.Background() status, _, body := postGated(t, fx, c, fx.ts.URL+"/a/"+sqid+"/delete", url.Values{"csrf": {csrf}, "confirm": {"Testco"}}) if status != http.StatusSeeOther { t.Fatalf("delete = %d:\n%s", status, body) } if a, _ := fx.st.AccountBySqid(sqid); a != nil { t.Fatal("the account survived its own deletion") } // Invisible, but not forgotten: the console must remember the tombstone // (see DeleteAccount) or the next materialize pass could be tricked into // re-birthing it. if a, deleted, err := fx.st.AccountAnyBySqid(sqid); err != nil || a == nil || !deleted { t.Fatalf("AccountAnyBySqid after delete = %+v %v %v, want a marked row", a, deleted, err) } // The next console start. Nothing may bring it back. if n, err := MaterializeAccounts(ctx, fx.rel, fx.st); err != nil || n != 0 { t.Fatalf("materialize after deletion = %d %v, want 0", n, err) } if a, _ := fx.st.AccountBySqid(sqid); a != nil { t.Fatal("the deleted account was materialized back out of the bucket") } // And no membership was resurrected with it — the manifest's owner field is // cleared by the tombstone precisely so a consumer that has never heard of // deleted_at cannot hand the account back to a former owner. m := readManifest(t, fx.rel, sqid) if !m.Deleted() { t.Fatal("the manifest was not tombstoned") } if m.Owner != "" { t.Fatalf("tombstone kept owner %q", m.Owner) } } // The id a deleted account held is never handed to another account. Sqids ride // in hostnames and in Certificate Transparency; reissuing one would give a new // account a name the world already associates with somebody else. func TestTombstonedAccountKeepsItsIDSpent(t *testing.T) { fx, c, csrf, sqid := deletableFixture(t) ctx := context.Background() before, err := domains.NextAccountID(ctx, fx.rel) if err != nil { t.Fatal(err) } if status, _, body := postGated(t, fx, c, fx.ts.URL+"/a/"+sqid+"/delete", url.Values{"csrf": {csrf}, "confirm": {"Testco"}}); status != http.StatusSeeOther { t.Fatalf("delete = %d:\n%s", status, body) } after, err := domains.NextAccountID(ctx, fx.rel) if err != nil { t.Fatal(err) } if after != before { t.Fatalf("next account id moved from %d to %d after a deletion — the retired id is being reused", before, after) } _, next, err := domains.MintAccount(ctx, fx.rel, "Nextco", "paul@keymail.dev", time.Now()) if err != nil { t.Fatal(err) } if next == sqid { t.Fatalf("a new account was minted onto the retired sqid %q", sqid) } } // A bucket that cannot be written is a deletion that does not happen. Refusing // is the only honest answer: a console-only deletion looks like it worked and // then undoes itself. func TestDeletionRefusedWhenTheBucketIsUnreachable(t *testing.T) { fx, c, csrf, sqid := deletableFixture(t) fx.srv.cfg.Releases = nil status, _, body := postGated(t, fx, c, fx.ts.URL+"/a/"+sqid+"/delete", url.Values{"csrf": {csrf}, "confirm": {"Testco"}}) if status != http.StatusSeeOther { t.Fatalf("delete = %d:\n%s", status, body) } if a, _ := fx.st.AccountBySqid(sqid); a == nil { t.Fatal("the account was deleted locally even though the bucket could not be told") } } func readManifest(t *testing.T, s release.Store, sqid string) domains.AccountManifest { t.Helper() body, _, err := s.Get(context.Background(), AccountManifestKey(sqid)) if err != nil { t.Fatal(err) } var m domains.AccountManifest if err := json.Unmarshal(body, &m); err != nil { t.Fatal(err) } return m } // ---- the Trash page --------------------------------------------------------- // // Restore is the undo button — any member, no passkey. "Delete now" is the // ceremonial end of the feature: owner, the app's own name typed out, a // passkey, and only then is anything destroyed. The tests below are mostly // about what must NOT happen, because every refusal here is the difference // between a recoverable mistake and an unrecoverable one. // trashKass puts the fixture's app in the Trash the way every other door // does: the bucket marker first, then the row, both carrying one clock. func trashKass(t *testing.T, fx *consoleFixture, actor string, now time.Time) { t.Helper() ctx := context.Background() if err := release.TrashApp(ctx, fx.rel, fx.sqid, "kass", actor, now); err != nil { t.Fatal(err) } if err := fx.st.TrashAppRow(OpsAccountID, "kass", now); err != nil { t.Fatal(err) } } // trashPageFixture is the owner signed in with kass already in the Trash. func trashPageFixture(t *testing.T) (*consoleFixture, *http.Client, string) { t.Helper() fx := newConsoleFixture(t) c := newBrowser(t) signUp(t, fx, c, "code-trash") trashKass(t, fx, "paul@keymail.dev", time.Now()) return fx, c, sessionToken(t, fx, c) } // appKeys is everything the bucket still holds for an app — the yardstick for // "nothing was destroyed" and for "everything was". func appKeys(t *testing.T, fx *consoleFixture, app string) []string { t.Helper() keys, err := fx.rel.List(context.Background(), "apps/"+fx.sqid+"/"+app+"/") if err != nil { t.Fatal(err) } return keys } // The page itself: the trashed app, who trashed it, when the platform will // destroy it on its own, and the one thing a restore does not bring back. func TestTrashPageListsWhatIsInIt(t *testing.T) { fx, c, _ := trashPageFixture(t) status, _, body := get(t, c, fx.u("/trash")) if status != http.StatusOK { t.Fatalf("trash page = %d:\n%s", status, body) } tomb, err := release.ReadAppTombstone(context.Background(), fx.rel, fx.sqid, "kass") if err != nil || tomb == nil { t.Fatalf("marker = %+v %v", tomb, err) } purgesAt := tomb.DeletedAt.Add(release.TrashCooloff).UTC().Format("2006-01-02 15:04 UTC") for _, want := range []string{"kass", "paul@keymail.dev", purgesAt, "carlos add"} { if !strings.Contains(body, want) { t.Fatalf("trash page missing %q in:\n%s", want, body) } } // The account home links here — and only while there is something to see. _, _, home := get(t, c, fx.u("/")) if !strings.Contains(home, "Trash (1)") { t.Fatalf("account home has no Trash link:\n%s", home) } if strings.Contains(home, ``) { t.Fatal("a trashed app is still listed among the live ones") } if err := fx.st.RestoreAppRow(OpsAccountID, "kass"); err != nil { t.Fatal(err) } if _, _, home := get(t, c, fx.u("/")); strings.Contains(home, "Trash (") { t.Fatalf("an empty trash still advertises itself:\n%s", home) } } // The page renders when the bucket cannot be read: the actor is unknown, the // row's own date stands in, and nothing about the page is destructive. Only // destruction requires believing the marker. func TestTrashPageSurvivesAnUnreadableMarker(t *testing.T) { fx, c, _ := trashPageFixture(t) ctx := context.Background() key := release.AppTombstoneKey(fx.sqid, "kass") if err := fx.rel.Delete(ctx, key); err != nil { t.Fatal(err) } if _, err := fx.rel.Put(ctx, key, []byte("{ not json"), ""); err != nil { t.Fatal(err) } status, _, body := get(t, c, fx.u("/trash")) if status != http.StatusOK { t.Fatalf("trash page with an unreadable marker = %d:\n%s", status, body) } if !strings.Contains(body, "kass") || !strings.Contains(body, "—") { t.Fatalf("trash page did not degrade to an em-dash actor:\n%s", body) } // The ROW's own date stands in, so the page still says when this will be // destroyed rather than showing the zero time. rows, err := fx.st.TrashedAppsIn(OpsAccountID) if err != nil || len(rows) != 1 { t.Fatalf("TrashedAppsIn = %+v %v", rows, err) } fromRow := rows[0].DeletedAt.Add(release.TrashCooloff).UTC().Format("2006-01-02 15:04 UTC") if !strings.Contains(body, fromRow) { t.Fatalf("trash page did not fall back to the row's date (%s):\n%s", fromRow, body) } } // Neither the page nor either action may tell a stranger this account exists. func TestTrashIsNotAnOracle(t *testing.T) { fx, _, _ := trashPageFixture(t) stranger := newBrowser(t) fx.setUser("stranger@keymail.dev") signUp(t, fx, stranger, "code-trash-stranger") csrf := sessionToken(t, fx, stranger) _, _, missingGet := get(t, stranger, fx.ts.URL+"/a/zzzzz/trash") if _, _, body := get(t, stranger, fx.u("/trash")); body != missingGet { t.Fatalf("the trash page answers a non-member differently from a nonexistent account:\n%q\nvs\n%q", body, missingGet) } _, _, missingPost := postForm(t, stranger, fx.ts.URL+"/a/zzzzz/trash/restore", url.Values{"csrf": {csrf}, "app": {"kass"}}) for _, path := range []string{"/trash/restore", "/trash/delete"} { status, loc, body := postForm(t, stranger, fx.u(path), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) if status != http.StatusNotFound { t.Fatalf("%s as a non-member = %d -> %q, want 404", path, status, loc) } if body != missingPost { t.Fatalf("%s as a non-member answers differently from a nonexistent account:\n%q\nvs\n%q", path, body, missingPost) } } if len(appKeys(t, fx, "kass")) == 0 { t.Fatal("a stranger's post destroyed something") } } // Restore is cheap: any member, no passkey, and it says what it did not do. func TestRestoreNeedsNoPasskey(t *testing.T) { fx, _, _ := trashPageFixture(t) if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } fx.setUser("sam@keymail.dev") c := newBrowser(t) signUp(t, fx, c, "code-sam-restore") csrf := sessionToken(t, fx, c) // postForm, deliberately: a step-up redirect here would be a bug. status, loc, body := postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"kass"}}) if status != http.StatusSeeOther || strings.HasPrefix(loc, fx.fk.srv.URL) { t.Fatalf("restore = %d -> %q, want a plain redirect with no passkey:\n%s", status, loc, body) } if tomb, err := release.ReadAppTombstone(context.Background(), fx.rel, fx.sqid, "kass"); err != nil || tomb != nil { t.Fatalf("the trash marker survived the restore: %+v %v", tomb, err) } if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has { t.Fatalf("HasAppIn after restore = %v %v, want the row live again", has, err) } // The one thing restore does NOT do, said where the person can read it. _, _, page := get(t, c, fx.ts.URL+loc) if !strings.Contains(page, "carlos add") { t.Fatalf("the restore flash does not mention that routes need carlos add:\n%s", page) } auditedBy(t, fx, "kass", "restore", "sam@keymail.dev", "kass") } // A member who is not an owner meets the 404, before a passkey redirect could // confirm anything. func TestDeleteNowIsOwnerOnly(t *testing.T) { fx, _, _ := trashPageFixture(t) if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { t.Fatal(err) } fx.setUser("sam@keymail.dev") c := newBrowser(t) signUp(t, fx, c, "code-sam-purge") csrf := sessionToken(t, fx, c) before := appKeys(t, fx, "kass") status, _, memberBody := postForm(t, c, fx.u("/trash/delete"), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) if status != http.StatusNotFound { t.Fatalf("member deleting permanently = %d, want 404", status) } strangerBody := strangerRefusal(t, fx, "code-zoe-purge", fx.u("/trash/delete"), url.Values{"app": {"kass"}, "confirm": {"kass"}}) if memberBody != strangerBody { t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody) } if len(appKeys(t, fx, "kass")) != len(before) { t.Fatal("a member's refused delete destroyed objects") } if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has { t.Fatalf("HasTrashedAppIn = %v %v, want the row untouched", has, err) } } // The typed name is checked BEFORE the passkey, so a slip costs nothing but a // retry — and destroys nothing. func TestDeleteNowRequiresTheAppsOwnName(t *testing.T) { fx, c, csrf := trashPageFixture(t) before := appKeys(t, fx, "kass") status, loc, body := postForm(t, c, fx.u("/trash/delete"), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kas"}}) if status != http.StatusSeeOther || strings.HasPrefix(loc, fx.fk.srv.URL) { t.Fatalf("mistyped delete = %d -> %q, want a flash and no passkey demand:\n%s", status, loc, body) } if len(appKeys(t, fx, "kass")) != len(before) { t.Fatal("a mistyped delete destroyed objects") } if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has { t.Fatalf("HasTrashedAppIn = %v %v, want the row untouched", has, err) } var stashed int fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed) if stashed != 0 { t.Fatal("a mistyped delete parked a pending action — the passkey was asked for anyway") } } // The whole ceremony, and what is left afterwards: nothing in the bucket, no // row, and a name anyone in the account may claim again. func TestDeleteNowDestroysEverythingAndFreesTheName(t *testing.T) { fx, c, csrf := trashPageFixture(t) // Without the passkey, nothing moves. status, loc, _ := postForm(t, c, fx.u("/trash/delete"), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { t.Fatalf("un-stepped-up delete = %d -> %q, want a redirect into Keymail", status, loc) } if len(appKeys(t, fx, "kass")) == 0 { t.Fatal("objects were destroyed before the passkey confirmation") } // Purge does not announce: by the time it runs, kass's routes and minting // were already stopped at trash time — there is nothing left a box's fast // pass converges on (see handlePurgeApp, ownership.go). before := serial(t, fx) // With it, the held action resumes and lands. status, loc, body := completeStepUp(t, fx, c, loc) if status != http.StatusSeeOther { t.Fatalf("resumed delete = %d:\n%s", status, body) } if keys := appKeys(t, fx, "kass"); len(keys) != 0 { t.Fatalf("the bucket still holds %v", keys) } if got := serial(t, fx); got != before { t.Fatalf("serial after a purge = %d, want unchanged (%d) — purge announces nothing", got, before) } if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || has { t.Fatalf("HasTrashedAppIn after purge = %v %v, want the row gone", has, err) } created, err := fx.st.CreateAppIn(OpsAccountID, "kass", "paul@keymail.dev", time.Now()) if err != nil || !created { t.Fatalf("re-claiming the purged name = %v %v, want it free", created, err) } auditedBy(t, fx, "kass", "purge", "paul@keymail.dev", "kass") if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "free to claim again") { t.Fatalf("the purge flash does not say the name is free:\n%s", page) } } // Delete now takes the app's ledgers and door keys with it (#219 items 1 // and 2): both are keyed by the name the purge frees, outside apps/. The // door-key record is republished at once, empty, rather than at the next // 12-hourly re-sign. func TestDeleteNowSweepsLedgersAndDoorKeys(t *testing.T) { fx, c, csrf := trashPageFixture(t) if _, err := fx.st.DoorKeyInsert(OpsAccountID, "kass", doorkey.Entry{ID: "0123456789abcdef", SHA256: strings.Repeat("a", 64)}); err != nil { t.Fatal(err) } ledgerKey := release.LedgerAppPrefix(fx.sqid, "kass") + "ledgers.json" if _, err := fx.rel.Put(context.Background(), ledgerKey, []byte(`{"schema":1,"public":true}`), ""); err != nil { t.Fatal(err) } status, _, body := postGated(t, fx, c, fx.u("/trash/delete"), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) if status != http.StatusSeeOther { t.Fatalf("delete = %d:\n%s", status, body) } if keys, err := fx.rel.List(context.Background(), release.LedgerAppPrefix(fx.sqid, "kass")); err != nil || len(keys) != 0 { t.Fatalf("ledgers after purge = %v %v, want none", keys, err) } wire, _, err := fx.rel.Get(context.Background(), doorkey.RecordKey(fx.sqid, "kass")) if err != nil { t.Fatalf("door-key record after purge: %v", err) } rec, err := doorkey.Verify(fx.pub, wire, time.Now()) if err != nil { t.Fatal(err) } if len(rec.Keys) != 0 { t.Fatalf("published door keys after purge = %+v, want none", rec.Keys) } } // The production flag is the deletion protection, all the way to the last // door: a trashed app that is flagged production is not destroyed, however // much ceremony is performed at it. func TestDeleteNowRefusesAProductionApp(t *testing.T) { fx := newConsoleFixture(t) ctx := context.Background() // Flagged BEFORE the trash, bucket-direct: the API door refuses to trash a // production app at all, so this is the state that can only arrive by a // flag set after the fact — the impossible one the purge path still guards. if err := release.SetProduction(ctx, fx.rel, fx.priv, release.OpsAccount, "kass", true, "test"); err != nil { t.Fatal(err) } c := newBrowser(t) signUp(t, fx, c, "code-trash-prod") trashKass(t, fx, "paul@keymail.dev", time.Now()) csrf := sessionToken(t, fx, c) before := appKeys(t, fx, "kass") status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) if status != http.StatusSeeOther { t.Fatalf("delete of a production app = %d:\n%s", status, body) } if keys := appKeys(t, fx, "kass"); len(keys) != len(before) { t.Fatalf("a refused delete destroyed objects: %v -> %v", before, keys) } if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has { t.Fatalf("HasTrashedAppIn = %v %v, want the row still trashed", has, err) } // The same sentence the CLI's 409 gives, so an operator meeting both hears // one instruction. if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "the off-switch on the app page") { t.Fatalf("the refusal does not name the off-switch on the app page:\n%s", page) } } // ---- purge and the object store --------------------------------------------- // // An app's object store is the one part of it that lives OUTSIDE the deployment // bucket — a real bucket, in a real cloud account, with a real IAM principal // holding a live key. Sweeping apps/// without tearing that down // first would leave a paid, credentialed estate that no record names, which is // why the box-side auto-purge refuses outright (release.ErrStoreProvisioned) // and why this door — the only one with backend authority — goes first. // armStoreBackend gives the fixture's console an in-memory storage backend and // a deployment that mints carlos-- buckets, which is what makes it a // console that may destroy one. func armStoreBackend(t *testing.T, fx *consoleFixture) *objstore.MemProvisioner { t.Helper() fake := objstore.NewMemProvisioner("eu-west-1") fx.srv.cfg.StoreProvisioner = fake fx.srv.cfg.StoreDeployment = objstore.Deployment{Backend: "mem", Region: "eu-west-1", BucketPrefix: "carlos-"} return fake } // seedStore builds the estate a completed grant leaves behind — a bucket with a // customer object in it, the principal holding its key, and the record naming // both — hand-written because the door that builds it is an operator API away // (apistore.go). crashed=true is the shape a grant that died before its step-6 // Put leaves: bucket, user and key all real, `provisioned` never recorded. It // returns the bucket name, which is also the word this app's destroy door makes // a person type. func seedStore(t *testing.T, fx *consoleFixture, fake *objstore.MemProvisioner, app string, crashed bool) string { t.Helper() ctx := context.Background() bucket := objstore.MintedBucket(fx.srv.cfg.StoreDeployment, fx.sqid, app) if _, err := fake.EnsureBucket(ctx, bucket); err != nil { t.Fatal(err) } fake.Buckets[bucket]["customer/data.bin"] = []byte("the customer's objects") user := objstore.UserName(fx.sqid, app) arn, err := fake.EnsureUser(ctx, user, objstore.UserPath, "arn:aws:s3:::"+bucket, nil) if err != nil { t.Fatal(err) } keyID, _, err := fake.MintKey(ctx, user) if err != nil { t.Fatal(err) } now := time.Now() rec := &objstore.Record{ Declared: &objstore.Declared{EnvPrefix: objstore.DefaultEnvPrefix, At: now, By: "paul@keymail.dev"}, Grant: &objstore.GrantBlock{Backend: "mem", Bucket: bucket, At: now, By: "paul@keymail.dev"}, } if !crashed { rec.Provisioned = &objstore.Provisioned{Bucket: bucket, Region: "eu-west-1", IAMUser: user, UserARN: arn, KeyID: keyID, At: now} // The delivered half: the coordinates a grant writes into the app's // config. Teardown unsets these, so their presence is what "nothing was // destroyed" means on the config side, and their absence is half of what // a completed teardown means. bucketName, regionName, _, _, _ := objstore.EnvNames(objstore.DefaultEnvPrefix) if _, err := appconfig.Apply(ctx, fx.rel, fx.sqid, app, map[string]appconfig.Var{ bucketName: {Kind: "env", Value: bucket}, regionName: {Kind: "env", Value: "eu-west-1"}, }, nil); err != nil { t.Fatal(err) } } putRecord(t, fx, app, rec) return bucket } // storeVarsDelivered reports whether the app's config still holds the store // coordinates seedStore delivered. func storeVarsDelivered(t *testing.T, fx *consoleFixture, app string) bool { t.Helper() bucketName, _, _, _, _ := objstore.EnvNames(objstore.DefaultEnvPrefix) _, ok := appVars(t, fx, app)[bucketName] return ok } // noStoreDestroyAudit asserts nothing claimed to have destroyed a store — the // trail a refusal that ran too late would leave. func noStoreDestroyAudit(t *testing.T, fx *consoleFixture, app string) { t.Helper() events, err := fx.st.Events(OpsAccountID, app, 20) if err != nil { t.Fatal(err) } for _, e := range events { if e.Action == "store-destroy" { t.Fatalf("a refused purge audited a store teardown: %+v", e) } } } // estateStanding asserts the backend still holds everything seedStore built — // the yardstick for "a refusal destroyed nothing". func estateStanding(t *testing.T, fx *consoleFixture, fake *objstore.MemProvisioner, app, bucket, when string) { t.Helper() if len(fake.Buckets[bucket]) == 0 { t.Fatalf("%s: the store bucket %s is empty or gone — objects were destroyed", when, bucket) } if _, ok := fake.Users[objstore.UserName(fx.sqid, app)]; !ok { t.Fatalf("%s: the store's IAM principal was deleted", when) } } // estateGone asserts the whole backend estate is down: no bucket, no principal. func estateGone(t *testing.T, fake *objstore.MemProvisioner, when string) { t.Helper() if len(fake.Buckets) != 0 || len(fake.Users) != 0 { t.Fatalf("%s left an estate standing: buckets %v, users %v", when, fake.Buckets, fake.Users) } } // A console with no storage backend configured cannot tear a store down, so it // must not purge the app either: the prefix sweep would delete store.json — // the ONLY record naming a live bucket and a live key — and nothing would ever // find them again. The refusal names the variable that fixes it. func TestPurgeRefusesAStoreThisConsoleCannotTearDown(t *testing.T) { fx, c, csrf := trashPageFixture(t) fake := armStoreBackend(t, fx) bucket := seedStore(t, fx, fake, "kass", false) fx.srv.cfg.StoreProvisioner = nil // the deployment shape with no backend status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) if status != http.StatusSeeOther { t.Fatalf("purge with no backend = %d:\n%s", status, body) } estateStanding(t, fx, fake, "kass", bucket, "a backend-less purge") if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil { t.Fatalf("the store record did not survive the refusal: %+v", rec) } if len(appKeys(t, fx, "kass")) == 0 { t.Fatal("the app's deployment objects were swept by a refused purge") } if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has { t.Fatalf("HasTrashedAppIn = %v %v, want the row still trashed", has, err) } if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "CARLOS_STORE_BACKEND") { t.Fatalf("the refusal does not name the backend variable:\n%s", page) } } // TestPurgeRefusesNoMailBackendBeforeTouchingTheStore: R5, review // follow-up. The "this app has an email estate but no MailProvisioner" // refusal used to sit AFTER objstore.Teardown had already destroyed the // object store, mirroring the very bug // TestPurgeRefusesAStoreThisConsoleCannotTearDown above already guards // against on the store side — a purge that was always going to be refused // destroyed the store first. Same shape, but for the email-side refusal: // the store must still be standing after it. func TestPurgeRefusesNoMailBackendBeforeTouchingTheStore(t *testing.T) { fx, c, csrf := trashPageFixture(t) fake := armStoreBackend(t, fx) bucket := seedStore(t, fx, fake, "kass", false) // An email estate with no MailProvisioner configured — this deployment // cannot tear it down, so the whole purge must refuse before it // touches anything. now := time.Now() if _, err := mail.Declare(context.Background(), fx.rel, release.OpsAccount, "kass", "KASS_SMTP", "noreply@kass.example.com", "paul@keymail.dev", now); err != nil { t.Fatal(err) } mrec, metag, err := mail.Get(context.Background(), fx.rel, release.OpsAccount, "kass") if err != nil { t.Fatal(err) } mrec.Provisioned = &mail.Provisioned{IAMUser: "u", KeyID: "AKIA1", Active: true, At: now} if _, err := mail.Put(context.Background(), fx.rel, release.OpsAccount, "kass", mrec, metag); err != nil { t.Fatal(err) } fx.srv.cfg.MailProvisioner = nil status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) if status != http.StatusSeeOther { t.Fatalf("purge with no mail backend = %d:\n%s", status, body) } // R5: the store must be untouched — the refusal must have fired BEFORE // objstore.Teardown, not after. estateStanding(t, fx, fake, "kass", bucket, "a purge refused for lacking a mail backend") if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil { t.Fatalf("the store record did not survive the refusal: %+v", rec) } if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "no SES backend configured") { t.Fatalf("the refusal does not name the missing backend:\n%s", page) } } // The whole point of the door: the backend estate comes down FIRST, and only // then is the deployment prefix swept — store.json included, now that it names // nothing. func TestPurgeTearsTheStoreDownBeforeSweepingThePrefix(t *testing.T) { fx, c, csrf := trashPageFixture(t) fake := armStoreBackend(t, fx) seedStore(t, fx, fake, "kass", false) before := serial(t, fx) status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) if status != http.StatusSeeOther { t.Fatalf("purge = %d:\n%s", status, body) } estateGone(t, fake, "a completed purge") if keys := appKeys(t, fx, "kass"); len(keys) != 0 { t.Fatalf("the deployment bucket still holds %v", keys) } if rec := storedRecord(t, fx, "kass"); rec != nil { t.Fatalf("the store record survived the purge: %+v", rec) } // Both halves are audited: the store teardown is a separate, irreversible // act on a separate estate, and an operator reading the trail must see it. auditedBy(t, fx, "kass", "store-destroy", "paul@keymail.dev", "carlos-bab-kass") auditedBy(t, fx, "kass", "purge", "paul@keymail.dev", "kass") if got := serial(t, fx); got != before { t.Fatalf("serial after a purge = %d, want unchanged (%d)", got, before) } if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "free to claim again") { t.Fatalf("no purge flash:\n%s", page) } } // A grant that crashed before recording itself leaves a record with `grant` and // no `provisioned` — over a bucket, a user and a key that are all real. The // guard is on EITHER block for exactly this: reading it as "nothing to tear // down" would sweep the only witness to a live, billing estate. func TestPurgeTearsDownACrashedGrant(t *testing.T) { fx, c, csrf := trashPageFixture(t) fake := armStoreBackend(t, fx) seedStore(t, fx, fake, "kass", true) if rec := storedRecord(t, fx, "kass"); rec.Provisioned != nil { t.Fatal("the crashed-grant fixture recorded a provisioned block") } status, _, body := postGated(t, fx, c, fx.u("/trash/delete"), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) if status != http.StatusSeeOther { t.Fatalf("purge of a crashed grant = %d:\n%s", status, body) } estateGone(t, fake, "a purge over a crashed grant") if keys := appKeys(t, fx, "kass"); len(keys) != 0 { t.Fatalf("the deployment bucket still holds %v", keys) } auditedBy(t, fx, "kass", "store-destroy", "paul@keymail.dev", "carlos-bab-kass") } // The production flag outranks deletion all the way down — and that promise is // only kept if it is checked BEFORE the store comes down. A refusal that // arrives after the teardown has already destroyed a LIVE app's bucket, its // credentials and the variables its hosts are running on, and purge announces // nothing, so those hosts would keep serving dead credentials until the hourly // floor. func TestPurgeRefusesAProductionAppBeforeTouchingItsStore(t *testing.T) { fx := newConsoleFixture(t) ctx := context.Background() // Flagged BEFORE the trash, bucket-direct — the state only a flag set after // the fact can reach, which is exactly the one this ordering protects. if err := release.SetProduction(ctx, fx.rel, fx.priv, release.OpsAccount, "kass", true, "test"); err != nil { t.Fatal(err) } c := newBrowser(t) signUp(t, fx, c, "code-purge-prod-store") trashKass(t, fx, "paul@keymail.dev", time.Now()) csrf := sessionToken(t, fx, c) fake := armStoreBackend(t, fx) bucket := seedStore(t, fx, fake, "kass", false) status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) if status != http.StatusSeeOther { t.Fatalf("purge of a production app with a store = %d:\n%s", status, body) } estateStanding(t, fx, fake, "kass", bucket, "a purge refused for production") if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil { t.Fatalf("the store record was destroyed by a refused purge: %+v", rec) } if !storeVarsDelivered(t, fx, "kass") { t.Fatal("the delivered store variables were unset by a refused purge") } noStoreDestroyAudit(t, fx, "kass") if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has { t.Fatalf("HasTrashedAppIn = %v %v, want the row still trashed", has, err) } if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "the off-switch on the app page") { t.Fatalf("the refusal does not name the off-switch:\n%s", page) } } // "A restore that landed while this form sat open wins" — handlePurgeApp's own // promise, which is worth nothing if the store is already gone by the time the // marker is re-read. The app here is LIVE again: destroying its store would be // destroying a running app's data. func TestPurgeRefusesARestoredAppBeforeTouchingItsStore(t *testing.T) { fx, c, csrf := trashPageFixture(t) fake := armStoreBackend(t, fx) bucket := seedStore(t, fx, fake, "kass", false) // The restore lands while the "Delete now" form sits open. if err := release.RestoreApp(context.Background(), fx.rel, fx.sqid, "kass"); err != nil { t.Fatal(err) } status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) if status != http.StatusSeeOther { t.Fatalf("purge of a restored app with a store = %d:\n%s", status, body) } estateStanding(t, fx, fake, "kass", bucket, "a purge refused for a restore") if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil { t.Fatalf("the store record was destroyed by a refused purge: %+v", rec) } if !storeVarsDelivered(t, fx, "kass") { t.Fatal("the delivered store variables were unset by a refused purge") } noStoreDestroyAudit(t, fx, "kass") if len(appKeys(t, fx, "kass")) == 0 { t.Fatal("the restored app's deployment objects were swept") } if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "not in the trash") { t.Fatalf("the refusal does not say the app is out of the trash:\n%s", page) } } // purgeAdvice must be honest in BOTH directions: the guard wraps // ErrStoreProvisioned for an unreadable store.json too, so this sentence // cannot claim a store certainly exists — and it must still name the way out. func TestPurgeAdviceMapsAStoreRefusalWithoutOverclaiming(t *testing.T) { msg := purgeAdvice(fmt.Errorf("bab/kass: %w", release.ErrStoreProvisioned), "kass") for _, want := range []string{"kass", "object store", "Settings"} { if !strings.Contains(msg, want) { t.Fatalf("purgeAdvice = %q, want it to mention %q", msg, want) } } if !strings.Contains(msg, "may have") { t.Fatalf("purgeAdvice = %q, want it to admit the store may only MAYBE exist "+ "(the guard wraps this sentinel for an unreadable record too)", msg) } } // ---- purge and the email estate (I2, review follow-up) -------------------- // // mail.Teardown had NO production caller anywhere before this fix. A purged // app left a live IAM user holding an active key, its SES identity, its // published DNS, and — worst — the create-only GLOBAL domain claim at // email/domain-claims/.json. Because release.PurgeApp removes // email.json while DomainRecord.Apps still names the deleted app, the // last-referrer branch inside mail.Teardown could never fire again for that // domain once the app was gone — the domain was then denied to every CARLOS // account, including its rightful owner, permanently. // // The proof used here is the claim's own semantics, not an internal peek at // bucket keys: a SECOND, unrelated account trying to ensure the SAME domain // is refused with ErrDomainClaimed before the purge (the claim is doing its // job), and succeeds after (the purge released it). func TestPurgeReleasesTheDomainClaim(t *testing.T) { fx, c, csrf := trashPageFixture(t) fake := mail.NewMemProvisioner("1") writer := dnsrec.NewMemWriter() fx.srv.cfg.MailProvisioner = fake fx.srv.cfg.DNSWriter = writer dep := mail.Deployment{Region: "eu-west-1"} // no PlatformSuffix: a custom // domain keeps this test about the claim gate alone, not the SEPARATE // platform-name-ownership gate (walk.go's platformNameOwnedBy), which // would otherwise refuse the second account for an unrelated reason. ctx := context.Background() now := time.Now() domain := "shared-claim.example.com" // Deliberately no mail.Declare/Enable — kass has a domain record (and // the claim it holds) but no Provisioned credential, exactly the shape // `carlos email domains add` alone leaves. mail.Teardown must still // find and release it: the domain-referrer set, not just a delivered // credential, is what the claim traces back to. if _, _, err := mail.EnsureDomain(ctx, fx.rel, fake, writer, dep, fx.sqid, "kass", domain, now); err != nil { t.Fatalf("EnsureDomain for kass: %v", err) } if _, _, err := mail.EnsureDomain(ctx, fx.rel, fake, writer, dep, "zzzz", "other", domain, now); !errors.Is(err, mail.ErrDomainClaimed) { t.Fatalf("a second account's EnsureDomain before the purge = %v, want ErrDomainClaimed", err) } status, _, body := postGated(t, fx, c, fx.u("/trash/delete"), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) if status != http.StatusSeeOther { t.Fatalf("purge = %d:\n%s", status, body) } auditedBy(t, fx, "kass", "email-teardown", "paul@keymail.dev", "email estate") if _, _, err := mail.EnsureDomain(ctx, fx.rel, fake, writer, dep, "zzzz", "other", domain, now); err != nil { t.Fatalf("EnsureDomain after the purge = %v, want the claim released for anyone to take", err) } } // Spec §9 "Deadline display": a temporary and a permanent app trashed // together show 3 and 30 days, on the Trash page and in the trash API. func TestTrashShowsEachAppsOwnDeadline(t *testing.T) { fx := newConsoleFixture(t) tempApp(t, fx, "tmp-a", time.Now().Add(24*time.Hour)) token := cliToken(t, fx, "paul@keymail.dev", "code-deadlines") until := map[string]time.Time{} for _, app := range []string{"tmp-a", "kass"} { status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/"+app+"/delete", map[string]string{}, token) if status != http.StatusOK { t.Fatalf("delete %s = %d %v", app, status, out) } u, err := time.Parse(time.RFC3339, str(t, out, "restorable_until")) if err != nil { t.Fatal(err) } until[app] = u } tmp, kass := lifeOf(t, fx, "tmp-a"), lifeOf(t, fx, "kass") if want := time.Unix(tmp.DeletedAt, 0).Add(72 * time.Hour); !until["tmp-a"].Equal(want) { t.Errorf("tmp-a restorable_until = %v, want %v", until["tmp-a"], want) } if want := time.Unix(kass.DeletedAt, 0).Add(30 * 24 * time.Hour); !until["kass"].Equal(want) { t.Errorf("kass restorable_until = %v, want %v", until["kass"], want) } c, _ := signInAs(t, fx, "paul@keymail.dev", "code-deadlines-web") _, _, page := get(t, c, fx.u("/trash")) for app, u := range until { if !strings.Contains(page, u.UTC().Format("2006-01-02 15:04 UTC")) { t.Errorf("the Trash page does not show %s's deadline %v", app, u) } } } // Astra plan round 3: the delete confirmation on Settings promises the window // the app will actually get, before anything is deleted: 3 days for a // temporary app, 30 for a permanent one. The number sits in its own element // so the assertion survives any rewording of the sentence around it. func TestDeleteConfirmationNamesEachAppsWindow(t *testing.T) { fx := newConsoleFixture(t) tempApp(t, fx, "tmp-a", time.Now().Add(24*time.Hour)) c, _ := signInAs(t, fx, "paul@keymail.dev", "code-delete-window") for app, days := range map[string]string{"tmp-a": "3", "kass": "30"} { status, _, page := get(t, c, fx.u("/apps/"+app+"/settings/delete")) if status != http.StatusOK { t.Fatalf("%s delete settings = %d", app, status) } if want := `class="restore-days">` + days + ` days<`; !strings.Contains(page, want) { t.Errorf("%s: the delete confirmation does not promise %s days (want %q in the page)", app, days, want) } } }