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)
}
}
}