feat(console): deleted_at on apps and accounts — trash state, tombstone memory, filtered doors
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+397
−12
internal/console/deletion_test.go+6 −0internal/console/ownership.go+25 −3internal/console/store.go+170 −9internal/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() |