fix(e2e): hard-delete disposable accounts after each test (BUNYIP-246) #268

Merged
nrupard merged 2 commits from fix/bunyip-246-e2e-account-hard-delete into main 2026-06-29 16:22:10 +02:00
Owner

What

Disposable e2e test accounts now physically leave the database after each test, instead of accumulating on staging (several hundred had piled up). Implements BUNYIP-246.

Root cause (two layers, both worse than the issue assumed)

  1. The per-test self-delete was a NO-OP. deleteMe issued DELETE /v1/users/me with no request body, but the endpoint requires a DeleteAccountRequest (password), so it returned 400 and never deleted the account - not even soft. Disposable accounts were live rows that nothing ever removed.
  2. Even a successful DELETE /v1/users/me only SOFT-deletes (UPDATE users SET deleted_at = NOW()); the row persists forever. Admin delete is also soft. The only hard delete was the host-run bootstrap CLI (two fixed emails).

Change

  • bunyip-api: DELETE /v1/users/me accepts ?purge=1, honoured ONLY when the environment is non-production AND BUNYIP_E2E_BOOTSTRAP_ALLOW=true (Config::e2e_purge_enabled, mirroring the bunyip-e2e-bootstrap guards). Production ignores the flag and soft-deletes exactly as before. The purge still requires the same password / TOTP ownership proof as a normal delete, then short-circuits the downstream cascade (disposables have no Stripe customer / OIDC sessions / connected apps).
  • bunyip-domain: UserRepository::hard_delete / hard_delete_by_email / hard_delete_stale_disposable. Each drops the actor's audit_logs rows in the same transaction first - audit_logs.actor_id references users(id) with NO ON DELETE cascade, so a bare DELETE FROM users FK-violates once the account has any history (register / login / reset all write audit rows). This is the FK reality the issue's "the bootstrap deletes by email successfully, so the FK path resolves" assumption missed. bunyip-e2e-bootstrap --cleanup now routes through hard_delete_by_email (no more inline DELETE FROM users in the binary).
  • bunyip-api: a background reaper (spawned only when e2e_purge_enabled) hourly hard-deletes any disposable row (email matching the +e2e- subaddress marker) older than 6h, a safety net for a crashed run whose finally never ran. Never spawned in production.
  • e2e: deleteMe(ctx, password) sends ?purge=1 plus the required password body; the three disposable specs (password-reset, magic-link, change-email) pass the account password.
  • docs: dev-docs/e2e.md + e2e/README.md document the flag, the reaper, the non-prod gating, and that production keeps soft-delete.

Safety

Five gates before any hard delete: authenticated user, password verified, TOTP (when enabled), non-production environment, and BUNYIP_E2E_BOOTSTRAP_ALLOW=true. Production never sets the flag, so ?purge is ignored and the reaper is never spawned - prod behavior is unchanged (soft delete + account recovery preserved).

Operator step (required for the purge + reaper to activate on staging)

Set BUNYIP_E2E_BOOTSTRAP_ALLOW=true in the running staging bunyip-api container env (not just for the one-off just e2e-bootstrap invocation).

Out of scope

The existing several-hundred backlog (prevent-future-only per the issue decision). An operator can clear it via bunyip-e2e-bootstrap or manual SQL on staging.

Verification

just check-container green: fmt + clippy -D warnings + cargo test --workspace --lib (incl. the new e2e_env_allows_purge gate test asserting production / empty environments forbid the purge). tsc --noEmit clean on the e2e suite. End-to-end hard-delete is exercised by the three disposable specs themselves once staging has the flag set.

#BUNYIP-246

🤖 Generated with Claude Code

## What Disposable e2e test accounts now physically leave the database after each test, instead of accumulating on staging (several hundred had piled up). Implements BUNYIP-246. ## Root cause (two layers, both worse than the issue assumed) 1. The per-test self-delete was a NO-OP. `deleteMe` issued `DELETE /v1/users/me` with no request body, but the endpoint requires a `DeleteAccountRequest` (password), so it returned 400 and never deleted the account - not even soft. Disposable accounts were live rows that nothing ever removed. 2. Even a successful `DELETE /v1/users/me` only SOFT-deletes (`UPDATE users SET deleted_at = NOW()`); the row persists forever. Admin delete is also soft. The only hard delete was the host-run bootstrap CLI (two fixed emails). ## Change - bunyip-api: `DELETE /v1/users/me` accepts `?purge=1`, honoured ONLY when the environment is non-production AND `BUNYIP_E2E_BOOTSTRAP_ALLOW=true` (`Config::e2e_purge_enabled`, mirroring the `bunyip-e2e-bootstrap` guards). Production ignores the flag and soft-deletes exactly as before. The purge still requires the same password / TOTP ownership proof as a normal delete, then short-circuits the downstream cascade (disposables have no Stripe customer / OIDC sessions / connected apps). - bunyip-domain: `UserRepository::hard_delete` / `hard_delete_by_email` / `hard_delete_stale_disposable`. Each drops the actor's `audit_logs` rows in the same transaction first - `audit_logs.actor_id` references `users(id)` with NO `ON DELETE` cascade, so a bare `DELETE FROM users` FK-violates once the account has any history (register / login / reset all write audit rows). This is the FK reality the issue's "the bootstrap deletes by email successfully, so the FK path resolves" assumption missed. `bunyip-e2e-bootstrap --cleanup` now routes through `hard_delete_by_email` (no more inline `DELETE FROM users` in the binary). - bunyip-api: a background reaper (spawned only when `e2e_purge_enabled`) hourly hard-deletes any disposable row (email matching the `+e2e-` subaddress marker) older than 6h, a safety net for a crashed run whose `finally` never ran. Never spawned in production. - e2e: `deleteMe(ctx, password)` sends `?purge=1` plus the required password body; the three disposable specs (`password-reset`, `magic-link`, `change-email`) pass the account password. - docs: `dev-docs/e2e.md` + `e2e/README.md` document the flag, the reaper, the non-prod gating, and that production keeps soft-delete. ## Safety Five gates before any hard delete: authenticated user, password verified, TOTP (when enabled), non-production environment, and `BUNYIP_E2E_BOOTSTRAP_ALLOW=true`. Production never sets the flag, so `?purge` is ignored and the reaper is never spawned - prod behavior is unchanged (soft delete + account recovery preserved). ## Operator step (required for the purge + reaper to activate on staging) Set `BUNYIP_E2E_BOOTSTRAP_ALLOW=true` in the running staging bunyip-api container env (not just for the one-off `just e2e-bootstrap` invocation). ## Out of scope The existing several-hundred backlog (prevent-future-only per the issue decision). An operator can clear it via `bunyip-e2e-bootstrap` or manual SQL on staging. ## Verification `just check-container` green: fmt + clippy `-D warnings` + `cargo test --workspace --lib` (incl. the new `e2e_env_allows_purge` gate test asserting production / empty environments forbid the purge). `tsc --noEmit` clean on the e2e suite. End-to-end hard-delete is exercised by the three disposable specs themselves once staging has the flag set. #BUNYIP-246 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(e2e): hard-delete disposable accounts after each test + reaper
Some checks failed
E2E / Playwright against deployment (pull_request) Successful in 34s
Check / fmt + clippy + build + tests (pull_request) Has been cancelled
9c7a38ce34
The e2e disposable-account self-delete was a no-op: deleteMe issued DELETE /v1/users/me with no request body, so the endpoint (which requires a password) returned 400 and never deleted the account - not even soft. Disposable accounts therefore accumulated as live rows on staging. Even a successful delete only SOFT-deletes (sets deleted_at), leaving the row in place forever.

Add an opt-in hard purge so disposable test accounts physically leave the database after each test, plus a non-production reaper as a safety net for crashed runs.

- bunyip-api: DELETE /v1/users/me accepts ?purge=1, honoured only when the environment is non-production AND BUNYIP_E2E_BOOTSTRAP_ALLOW=true (Config::e2e_purge_enabled, mirroring the bootstrap-tool guards). Production ignores the flag and soft-deletes as before. The purge still requires the same password / TOTP ownership proof as a normal delete.
- bunyip-domain: UserRepository::hard_delete / hard_delete_by_email / hard_delete_stale_disposable. Each drops the actor's audit_logs rows in the same transaction first, because audit_logs.actor_id references users(id) with no ON DELETE cascade, so a bare DELETE FROM users FK-violates once the account has any history. bunyip-e2e-bootstrap --cleanup now routes through hard_delete_by_email instead of its own inline DELETE.
- bunyip-api: a background reaper (spawned only when e2e_purge_enabled) hourly hard-deletes disposable rows (email matching the +e2e- subaddress marker) older than 6h, covering a crashed run whose finally never ran. Never spawned in production.
- e2e: deleteMe sends ?purge=1 plus the required password body; the three disposable specs pass the account password.
- docs: dev-docs/e2e.md + e2e/README.md document the flag, the reaper, the non-prod gating, and that production keeps soft-delete.

#BUNYIP-246

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nrupard scheduled this pull request to auto merge when all checks succeed 2026-06-29 15:57:01 +02:00
docs: note hard_delete non-cascade FK limitation (review follow-up)
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 33s
Check / fmt + clippy + build + tests (pull_request) Successful in 16m6s
Create release / Create release from merged PR (pull_request) Has been skipped
e15bc1953a
Code review flagged that hard_delete* clears only audit_logs, while other tables reference users(id) without ON DELETE cascade (oauth_authorization_codes.user_id, admin_notifications.user_id, the admin *_by columns). Verified the current disposable specs (password-reset, magic-link, change-email) populate only audit_logs, so the shipped code is correct; the gap is latent. Document the limitation on the method so a future disposable spec that does an OIDC authorize or submits feedback knows to extend hard_delete (or add CASCADE/SET NULL to those FKs) rather than hit a confusing FK violation - including that a single blocking row would stall the bulk reaper sweep.

#BUNYIP-246

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nrupard scheduled this pull request to auto merge when all checks succeed 2026-06-29 16:08:19 +02:00
nrupard deleted branch fix/bunyip-246-e2e-account-hard-delete 2026-06-29 16:22:10 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!268
No description provided.