carlos / platform Public

feat(console): deleted_at on apps and accounts — trash state, tombstone memory, filtered doors

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8e2d97ec850a93c0f128cd3100111a856d8e4074 parent 12d6334
4 files changed, +397 −12
  • internal/console/deletion_test.go +6 −0
  • internal/console/ownership.go +25 −3
  • internal/console/store.go +170 −9
  • internal/console/store_test.go +196 −0
diff --git a/internal/console/deletion_test.go b/internal/console/deletion_test.go
index 5fdb21e04..3d4c42e39 100644
--- a/internal/console/deletion_test.go
+++ b/internal/console/deletion_test.go
@@ -48,6 +48,12 @@ func TestDeletedAccountDoesNotComeBack(t *testing.T) {
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 {
diff --git a/internal/console/ownership.go b/internal/console/ownership.go
index 54a46c8b6..a98d66983 100644
--- a/internal/console/ownership.go
+++ b/internal/console/ownership.go
@@ -144,7 +144,7 @@ func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/a/"+acct.Sqid+"/", http.StatusSeeOther)
return
}
- if err := s.store.DeleteAccount(acct.ID); err != nil {
+ if err := s.store.DeleteAccount(acct.ID, time.Now()); err != nil {
log.Printf("console: delete account: %v", err)
s.setFlash(w, "err", deletionAdvice(err))
http.Redirect(w, r, "/a/"+acct.Sqid+"/", http.StatusSeeOther)
@@ -168,6 +168,14 @@ func (s *Server) tombstoneAccount(ctx context.Context, acct *Account) error {
// AppCount reports how many apps are homed in an account. Read-only pre-flight
// for deletion; DeleteAccount re-checks it inside its own transaction, which is
// the check that actually binds.
+//
+// Deliberately UNFILTERED by deleted_at, unlike AppsIn/HasAppIn: a trashed
+// app's payload — its objects, routes, everything that made it expensive in
+// the first place — still exists until purge, so account deletion must keep
+// refusing while trashed rows remain. Counting only live apps would let an
+// owner trash every app, delete the account out from under them, and strand
+// a name-reservation (and, before purge, real bucket state) with no account
+// left to hold it accountable.
func (s *Store) AppCount(accountID int64) (int, error) {
var n int
err := s.db.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ?`, accountID).Scan(&n)
@@ -249,9 +257,21 @@ func (s *Store) TransferOwnership(accountID int64, from, to string, now time.Tim
// homed here — an account row is cheap to delete and an app's objects, routes
// and domains are not, so the two are never destroyed by one click.
//
+// The accounts ROW is MARKED (deleted_at set), never removed — this is the
+// console's own path, and the identical resurrection hole the spec's §8
+// describes for materialization exists here too if the row simply vanishes:
+// account identity is minted into the bucket and re-read into this table on
+// every start (MaterializeAccounts), so a console that forgets a sqid was
+// ever seen would let anyone with bucket write access rewrite the manifest
+// un-tombstoned, name themselves owner, and get the account BACK on the next
+// restart — same id, same sqid, same platform hostnames. Keeping the marked
+// row is what lets AccountAnyBySqid answer "seen, and deleted" instead of
+// "never seen", which is the only way materialization's birth-only adoption
+// rule stays safe. An account id is spent once; there is no un-deleting one.
+//
// Audit rows are deliberately left behind: they are the record of what was
// done, and a deletion is not a reason to lose it.
-func (s *Store) DeleteAccount(accountID int64) error {
+func (s *Store) DeleteAccount(accountID int64, now time.Time) error {
if accountID == OpsAccountID {
return fmt.Errorf("console: the ops account cannot be deleted")
}
@@ -270,12 +290,14 @@ func (s *Store) DeleteAccount(accountID int64) error {
for _, stmt := range []string{
`DELETE FROM memberships WHERE account_id = ?`,
`DELETE FROM invites WHERE account_id = ?`,
- `DELETE FROM accounts WHERE id = ?`,
} {
if _, err := tx.Exec(stmt, accountID); err != nil {
return err
}
}
+ if _, err := tx.Exec(`UPDATE accounts SET deleted_at = ? WHERE id = ?`, now.Unix(), accountID); err != nil {
+ return err
+ }
// Anyone whose landing spot was this account gets sent back to the picker
// rather than to a 404 they cannot explain.
if _, err := tx.Exec(`UPDATE users SET last_account = 0 WHERE last_account = ?`, accountID); err != nil {
diff --git a/internal/console/store.go b/internal/console/store.go
index 7c96ae37e..ee6da2fc7 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -199,6 +199,13 @@ func (s *Store) migrate() error {
// ops, which is where every pre-accounts app lived — so no history is
// lost and none of it lands in somebody else's account.
`ALTER TABLE audit ADD COLUMN account_id INTEGER NOT NULL DEFAULT 1;`,
+ // Trash state for apps (30-day cooloff; the row IS the name
+ // reservation, so it survives trash and dies at purge) and tombstone
+ // MEMORY for accounts: a marked account row is never re-materialized,
+ // which is what stops a rewritten bucket manifest resurrecting a
+ // deleted account — see MaterializeAccounts.
+ `ALTER TABLE apps ADD COLUMN deleted_at INTEGER NOT NULL DEFAULT 0;`,
+ `ALTER TABLE accounts ADD COLUMN deleted_at INTEGER NOT NULL DEFAULT 0;`,
} {
if _, err := s.db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -249,10 +256,15 @@ func (s *Store) migrateAppsKey() error {
account_id INTEGER NOT NULL DEFAULT 1,
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
+ deleted_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (account_id, app)
);`,
- `INSERT INTO apps_new (app, account_id, created_by, created_at)
- SELECT app, account_id, created_by, created_at FROM apps;`,
+ // The ALTERs above run before migrateAppsKey in migrate(), so
+ // deleted_at already exists on the old table by the time this rebuild
+ // copies it — a row already trashed when the rebuild runs must not
+ // have its trash state silently dropped.
+ `INSERT INTO apps_new (app, account_id, created_by, created_at, deleted_at)
+ SELECT app, account_id, created_by, created_at, deleted_at FROM apps;`,
`DROP TABLE apps;`,
`ALTER TABLE apps_new RENAME TO apps;`,
} {
@@ -644,10 +656,15 @@ ON CONFLICT(account_id, address) DO UPDATE SET role = excluded.role`,
// AccountBySqid returns the account with that public identity, or (nil, nil)
// when there is none — "no such account" is an answer, not an error, and the
// caller renders it as the same 404 a nonexistent page gets.
+//
+// A marked (deleted) row is filtered out here: a tombstoned account must be
+// invisible everywhere a console door looks one up, exactly like a row that
+// was never inserted. AccountAnyBySqid is the one exception, and it exists
+// for a different question — "has this sqid ever been seen" — not this one.
func (s *Store) AccountBySqid(sqid string) (*Account, error) {
var a Account
var created int64
- err := s.db.QueryRow(`SELECT id, sqid, name, created_at FROM accounts WHERE sqid = ?`, sqid).
+ err := s.db.QueryRow(`SELECT id, sqid, name, created_at FROM accounts WHERE sqid = ? AND deleted_at = 0`, sqid).
Scan(&a.ID, &a.Sqid, &a.Name, &created)
if err == sql.ErrNoRows {
return nil, nil
@@ -663,7 +680,7 @@ func (s *Store) AccountBySqid(sqid string) (*Account, error) {
func (s *Store) AccountByID(id int64) (*Account, error) {
var a Account
var created int64
- err := s.db.QueryRow(`SELECT id, sqid, name, created_at FROM accounts WHERE id = ?`, id).
+ err := s.db.QueryRow(`SELECT id, sqid, name, created_at FROM accounts WHERE id = ? AND deleted_at = 0`, id).
Scan(&a.ID, &a.Sqid, &a.Name, &created)
if err == sql.ErrNoRows {
return nil, nil
@@ -675,6 +692,30 @@ func (s *Store) AccountByID(id int64) (*Account, error) {
return &a, nil
}
+// AccountAnyBySqid is AccountBySqid WITHOUT the deleted_at filter — it sees a
+// marked row instead of pretending it does not exist. deleted reports which
+// case this was, so the account struct itself needs no new field for it.
+//
+// The ONLY caller is meant to be materialization: the whole point of keeping
+// a tombstoned row (see DeleteAccount) is that the pass which decides whether
+// to re-birth an account from its bucket manifest must be able to tell "never
+// seen" from "seen, and deleted" — every other door in the console asks the
+// filtered question instead.
+func (s *Store) AccountAnyBySqid(sqid string) (*Account, bool, error) {
+ var a Account
+ var created, deletedAt int64
+ err := s.db.QueryRow(`SELECT id, sqid, name, created_at, deleted_at FROM accounts WHERE sqid = ?`, sqid).
+ Scan(&a.ID, &a.Sqid, &a.Name, &created, &deletedAt)
+ if err == sql.ErrNoRows {
+ return nil, false, nil
+ }
+ if err != nil {
+ return nil, false, err
+ }
+ a.CreatedAt = time.Unix(created, 0).UTC()
+ return &a, deletedAt != 0, nil
+}
+
// AccountsFor returns the accounts address is a member of, in creation order
// — the switcher's list, and the "your first membership" fallback when there
// is no MRU history. Ordered by created_at then id so two accounts created in
@@ -682,7 +723,7 @@ func (s *Store) AccountByID(id int64) (*Account, error) {
func (s *Store) AccountsFor(address string) ([]Account, error) {
rows, err := s.db.Query(`SELECT a.id, a.sqid, a.name, a.created_at, m.role
FROM accounts a JOIN memberships m ON m.account_id = a.id
-WHERE m.address = ? ORDER BY a.created_at, a.id`, address)
+WHERE m.address = ? AND a.deleted_at = 0 ORDER BY a.created_at, a.id`, address)
if err != nil {
return nil, err
}
@@ -920,6 +961,54 @@ FROM invites WHERE account_id = ? AND accepted_at = 0 ORDER BY created_at, addre
return out, rows.Err()
}
+// ReapAccount is materialization's verb for a tombstoned bucket manifest
+// found on a LATER pass than the one that first materialized the account
+// (see MaterializeAccounts §8 of the deletion-policy spec): memberships,
+// invites and apps rows are deleted outright — this account's authority and
+// its name-reservations are gone — but the accounts ROW itself is marked,
+// never removed, for the identical reason DeleteAccount marks it: deleting
+// the row forgets the sqid was ever seen, and a console that forgets is one
+// where a rewritten manifest un-tombstoning itself would re-birth the
+// account, same id, same sqid, with whoever wrote the manifest as owner. A
+// marked row closes that: AccountAnyBySqid always sees it, and nothing ever
+// re-materializes over it.
+//
+// An account tombstoned while still holding app rows was tombstoned by
+// something other than the console's own guarded path (DeleteAccount refuses
+// while apps remain) — so those rows are reaped with it rather than left
+// stranded pointing at a dead account.
+//
+// One transaction: a crash partway through must never leave memberships live
+// against an account whose row is already marked, which would be access with
+// nothing left to be a member OF.
+func (s *Store) ReapAccount(accountID int64, now time.Time) error {
+ if accountID == OpsAccountID {
+ return fmt.Errorf("console: the ops account cannot be reaped")
+ }
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ for _, stmt := range []string{
+ `DELETE FROM memberships WHERE account_id = ?`,
+ `DELETE FROM invites WHERE account_id = ?`,
+ `DELETE FROM apps WHERE account_id = ?`,
+ } {
+ if _, err := tx.Exec(stmt, accountID); err != nil {
+ return err
+ }
+ }
+ if _, err := tx.Exec(`UPDATE users SET last_account = 0 WHERE last_account = ?`, accountID); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(`UPDATE accounts SET deleted_at = ? WHERE id = ? AND deleted_at = 0`,
+ now.Unix(), accountID); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
// ---- apps (claim-once names) ------------------------------------------------
// OpsAccountID is the built-in operator account's id, pinned by registry
@@ -946,9 +1035,13 @@ VALUES (?, ?, ?, ?) ON CONFLICT(account_id, app) DO NOTHING`,
return n > 0, err
}
-// AppsIn lists an account's claimed app names, sorted.
+// AppsIn lists an account's claimed app names, sorted. Trashed apps are
+// filtered out — every door this feeds (API resolution, ship claim, account
+// page listings) is a door a trashed app must vanish from automatically,
+// without each caller having to remember to ask. TrashedAppsIn is the
+// dedicated listing for the Trash page itself.
func (s *Store) AppsIn(accountID int64) ([]string, error) {
- rows, err := s.db.Query(`SELECT app FROM apps WHERE account_id = ? ORDER BY app`, accountID)
+ rows, err := s.db.Query(`SELECT app FROM apps WHERE account_id = ? AND deleted_at = 0 ORDER BY app`, accountID)
if err != nil {
return nil, err
}
@@ -964,10 +1057,78 @@ func (s *Store) AppsIn(accountID int64) ([]string, error) {
return out, rows.Err()
}
-// HasAppIn reports whether an account already claims an app name.
+// HasAppIn reports whether an account already claims a LIVE app name.
+// Filtered the same way as AppsIn: a trashed name still occupies the row (see
+// CreateAppIn / the name-reservation rule) but must read as absent to a caller
+// asking "is this name in use right now".
func (s *Store) HasAppIn(accountID int64, app string) (bool, error) {
var n int
- err := s.db.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ? AND app = ?`,
+ err := s.db.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ? AND app = ? AND deleted_at = 0`,
+ accountID, app).Scan(&n)
+ return n > 0, err
+}
+
+// TrashAppRow marks an app trashed. Trashing twice keeps the FIRST timestamp
+// (guarded by `deleted_at = 0`) — the cooloff clock starts once, and a second
+// trash call (racing UI, a retried request) must not push the purge deadline
+// out again.
+func (s *Store) TrashAppRow(accountID int64, app string, now time.Time) error {
+ _, err := s.db.Exec(`UPDATE apps SET deleted_at = ? WHERE account_id = ? AND app = ? AND deleted_at = 0`,
+ now.Unix(), accountID, app)
+ return err
+}
+
+// RestoreAppRow un-trashes an app. Restoring something that was never trashed
+// is harmless — deleted_at was already 0 — so there is no guard to fight a
+// race with.
+func (s *Store) RestoreAppRow(accountID int64, app string) error {
+ _, err := s.db.Exec(`UPDATE apps SET deleted_at = 0 WHERE account_id = ? AND app = ?`, accountID, app)
+ return err
+}
+
+// PurgeAppRow deletes the row outright — the one place trash state actually
+// ends. The row IS the name reservation (see CreateAppIn), so this is also
+// the moment the name frees for reuse; nothing before it may delete the row.
+func (s *Store) PurgeAppRow(accountID int64, app string) error {
+ _, err := s.db.Exec(`DELETE FROM apps WHERE account_id = ? AND app = ?`, accountID, app)
+ return err
+}
+
+// TrashedApp is one app in an account's Trash: past-tense on purpose, since a
+// trashed row is inert until restored or purged.
+type TrashedApp struct {
+ App string
+ DeletedAt time.Time
+}
+
+// TrashedAppsIn lists an account's trashed apps, sorted by name — the Trash
+// page's listing, and the mirror image of AppsIn's "live only" filter.
+func (s *Store) TrashedAppsIn(accountID int64) ([]TrashedApp, error) {
+ rows, err := s.db.Query(`SELECT app, deleted_at FROM apps WHERE account_id = ? AND deleted_at != 0 ORDER BY app`,
+ accountID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []TrashedApp
+ for rows.Next() {
+ var t TrashedApp
+ var deletedAt int64
+ if err := rows.Scan(&t.App, &deletedAt); err != nil {
+ return nil, err
+ }
+ t.DeletedAt = time.Unix(deletedAt, 0).UTC()
+ out = append(out, t)
+ }
+ return out, rows.Err()
+}
+
+// HasTrashedAppIn reports whether an account holds a TRASHED row for app —
+// the check the claim-once door uses to explain a refusal ("that name is in
+// the trash") rather than just saying no.
+func (s *Store) HasTrashedAppIn(accountID int64, app string) (bool, error) {
+ var n int
+ err := s.db.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ? AND app = ? AND deleted_at != 0`,
accountID, app).Scan(&n)
return n > 0, err
}
diff --git a/internal/console/store_test.go b/internal/console/store_test.go
index 4becf8bfd..3b778b001 100644
--- a/internal/console/store_test.go
+++ b/internal/console/store_test.go
@@ -406,6 +406,202 @@ func TestAlertSeenRoundTrip(t *testing.T) {
}
}
+// TestTrashAppRowRoundTrip: a trashed app vanishes from the live listings but
+// stays visible — and its name stays claimed — in the trashed ones, until
+// restore or purge.
+func TestTrashAppRowRoundTrip(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ acct := testAccountID()
+ if created, err := s.CreateAppIn(acct, "zephyr", "paul@keymail.dev", now); err != nil || !created {
+ t.Fatalf("CreateAppIn = %v %v, want true nil", created, err)
+ }
+ if err := s.TrashAppRow(acct, "zephyr", now); err != nil {
+ t.Fatal(err)
+ }
+ // Gone from the live doors.
+ if apps, err := s.AppsIn(acct); err != nil || len(apps) != 0 {
+ t.Fatalf("AppsIn after trash = %v %v, want empty", apps, err)
+ }
+ if has, err := s.HasAppIn(acct, "zephyr"); err != nil || has {
+ t.Fatalf("HasAppIn after trash = %v %v, want false", has, err)
+ }
+ // Still visible via the trash doors.
+ if has, err := s.HasTrashedAppIn(acct, "zephyr"); err != nil || !has {
+ t.Fatalf("HasTrashedAppIn after trash = %v %v, want true", has, err)
+ }
+ trashed, err := s.TrashedAppsIn(acct)
+ if err != nil || len(trashed) != 1 || trashed[0].App != "zephyr" {
+ t.Fatalf("TrashedAppsIn = %+v %v, want one zephyr row", trashed, err)
+ }
+ if !trashed[0].DeletedAt.Equal(time.Unix(now.Unix(), 0).UTC()) {
+ t.Fatalf("TrashedAppsIn DeletedAt = %v, want %v", trashed[0].DeletedAt, now)
+ }
+ // The name is still claimed — claim-once still refuses it. The PK row
+ // exists; this is not a fresh claim succeeding by accident.
+ if created, err := s.CreateAppIn(acct, "zephyr", "bob@keymail.dev", now); err != nil || created {
+ t.Fatalf("CreateAppIn on a trashed name = %v %v, want false nil", created, err)
+ }
+ // Trashing again does NOT move the cooloff clock.
+ later := now.Add(time.Hour)
+ if err := s.TrashAppRow(acct, "zephyr", later); err != nil {
+ t.Fatal(err)
+ }
+ trashed, err = s.TrashedAppsIn(acct)
+ if err != nil || len(trashed) != 1 || !trashed[0].DeletedAt.Equal(time.Unix(now.Unix(), 0).UTC()) {
+ t.Fatalf("second TrashAppRow moved the timestamp: %+v %v", trashed, err)
+ }
+ // Restore: visible again everywhere live, gone from trash.
+ if err := s.RestoreAppRow(acct, "zephyr"); err != nil {
+ t.Fatal(err)
+ }
+ if apps, err := s.AppsIn(acct); err != nil || len(apps) != 1 || apps[0] != "zephyr" {
+ t.Fatalf("AppsIn after restore = %v %v, want [zephyr]", apps, err)
+ }
+ if has, err := s.HasTrashedAppIn(acct, "zephyr"); err != nil || has {
+ t.Fatalf("HasTrashedAppIn after restore = %v %v, want false", has, err)
+ }
+ // Purge: the row is gone outright, and the name frees for reclaiming.
+ if err := s.TrashAppRow(acct, "zephyr", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.PurgeAppRow(acct, "zephyr"); err != nil {
+ t.Fatal(err)
+ }
+ if has, err := s.HasTrashedAppIn(acct, "zephyr"); err != nil || has {
+ t.Fatalf("HasTrashedAppIn after purge = %v %v, want false", has, err)
+ }
+ if created, err := s.CreateAppIn(acct, "zephyr", "carol@keymail.dev", now); err != nil || !created {
+ t.Fatalf("CreateAppIn after purge = %v %v, want true nil — the name should be free", created, err)
+ }
+}
+
+// TestAppCountCountsTrashedRows: account deletion's pre-flight must keep
+// refusing while a trashed app's payload still exists, so the count is
+// deliberately unfiltered by deleted_at.
+func TestAppCountCountsTrashedRows(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ acct := testAccountID()
+ if _, err := s.CreateAppIn(acct, "kass", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.TrashAppRow(acct, "kass", now); err != nil {
+ t.Fatal(err)
+ }
+ if n, err := s.AppCount(acct); err != nil || n != 1 {
+ t.Fatalf("AppCount with only a trashed row = %d %v, want 1", n, err)
+ }
+}
+
+// TestDeleteAccountMarksNotRemoves: the console must remember tombstones —
+// deleting the row would forget the account was ever seen and re-arm the
+// resurrection attack the spec's §8 describes (see DeleteAccount's comment).
+func TestDeleteAccountMarksNotRemoves(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ id := testAccountID()
+ sqid := "test-del-1"
+ if err := s.CreateAccount(id, sqid, "Testco", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.DeleteAccount(id, now); err != nil {
+ t.Fatal(err)
+ }
+ // Invisible via every filtered door.
+ if a, err := s.AccountBySqid(sqid); err != nil || a != nil {
+ t.Fatalf("AccountBySqid after delete = %+v %v, want nil nil", a, err)
+ }
+ if a, err := s.AccountByID(id); err != nil || a != nil {
+ t.Fatalf("AccountByID after delete = %+v %v, want nil nil", a, err)
+ }
+ // But the console still remembers it: the row is marked, not gone.
+ a, deleted, err := s.AccountAnyBySqid(sqid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if a == nil {
+ t.Fatal("AccountAnyBySqid after delete = nil, want the marked row")
+ }
+ if !deleted {
+ t.Fatal("AccountAnyBySqid after delete reports deleted=false")
+ }
+ if a.ID != id || a.Sqid != sqid || a.Name != "Testco" {
+ t.Fatalf("AccountAnyBySqid = %+v, want id=%d sqid=%s name=Testco", a, id, sqid)
+ }
+}
+
+func TestDeleteAccountRefusesOps(t *testing.T) {
+ s := openTestStore(t)
+ if err := s.DeleteAccount(OpsAccountID, time.Now()); err == nil {
+ t.Fatal("DeleteAccount(ops) succeeded, want a refusal")
+ }
+}
+
+// TestReapAccountClearsAndMarks: materialization's verb for a manifest
+// tombstoned on a LATER pass — memberships, invites and apps rows are
+// deleted outright, last_account references are cleared, and the accounts
+// row is marked, never removed, for the same tombstone-memory reason
+// DeleteAccount marks it.
+func TestReapAccountClearsAndMarks(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ id := testAccountID()
+ sqid := "test-reap-1"
+ if err := s.CreateAccount(id, sqid, "Reapco", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.AddMember(id, "bob@keymail.dev", RoleMember, now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.CreateAppIn(id, "leftover", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.CreateInvite(id, "carol@keymail.dev", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetLastAccount("bob@keymail.dev", id); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := s.ReapAccount(id, now); err != nil {
+ t.Fatal(err)
+ }
+
+ if role, err := s.Role(id, "paul@keymail.dev"); err != nil || role != "" {
+ t.Fatalf("Role(owner) after reap = %q %v, want empty", role, err)
+ }
+ if role, err := s.Role(id, "bob@keymail.dev"); err != nil || role != "" {
+ t.Fatalf("Role(member) after reap = %q %v, want empty", role, err)
+ }
+ if apps, err := s.AppsIn(id); err != nil || len(apps) != 0 {
+ t.Fatalf("AppsIn after reap = %v %v, want empty", apps, err)
+ }
+ if trashed, err := s.TrashedAppsIn(id); err != nil || len(trashed) != 0 {
+ t.Fatalf("TrashedAppsIn after reap = %v %v, want empty — reap deletes app rows, it does not trash them", trashed, err)
+ }
+ if invites, err := s.PendingInvites(id); err != nil || len(invites) != 0 {
+ t.Fatalf("PendingInvites after reap = %v %v, want empty", invites, err)
+ }
+ if last, err := s.LastAccount("bob@keymail.dev"); err != nil || last != 0 {
+ t.Fatalf("LastAccount after reap = %d %v, want 0", last, err)
+ }
+ if a, err := s.AccountBySqid(sqid); err != nil || a != nil {
+ t.Fatalf("AccountBySqid after reap = %+v %v, want nil nil", a, err)
+ }
+ a, deleted, err := s.AccountAnyBySqid(sqid)
+ if err != nil || a == nil || !deleted {
+ t.Fatalf("AccountAnyBySqid after reap = %+v %v %v, want a marked row", a, deleted, err)
+ }
+}
+
+func TestReapAccountRefusesOps(t *testing.T) {
+ s := openTestStore(t)
+ if err := s.ReapAccount(OpsAccountID, time.Now()); err == nil {
+ t.Fatal("ReapAccount(ops) succeeded, want a refusal")
+ }
+}
+
func TestCreateAppClaimOnce(t *testing.T) {
s := openTestStore(t)
now := time.Now()