Files
RenewCMS/features/account_delete.qa.md
T

8.0 KiB

QA procedure: account_delete

Package: test (black-box), extending test/user_test.go (mockUserRepo, stubHasher), test/mail_test.go (mockMailRepo, failSwitch) and the existing role, author, api-key and audit fakes. Reuse them and keep new type names unique.

Test infrastructure additions needed first

  1. A fake of the atomic domain operation behind DeleteOwnAccountUseCase that mutates the in-memory fakes together.
    • It honours fail.trigger at each step ("DeleteAPIKeys", "ScrubAuthor", "AnonymiseEvents", "DeleteUser").
    • On any failure it restores a snapshot, so rollback is observable.
  2. The role fake: seed built-in Owner, Admin, Writer and a custom role with all admin permissions. Add a CountHolders-style method (or whichever method the coder adds to the domain interface) returning verified holders only.
  3. The audit fake: record events and support the anonymise method; assert the rewritten events by id.
  4. The session revocation: reuse the authtoken revocation fake; record revoked ids.

Setup shared by every case

  1. Call validator.InitValidator() once.
  2. Build useCases.NewDeleteOwnAccountUseCase(...) over the fakes, stubHasher, mockMailRepo, a clock, publicURL "https://cms.example.com" and the audit fake.
  3. Handler checks mount DELETE /v1/user/me on a fresh chi.NewRouter() with the NewUserRouter session chain and the 5 per 15 minutes limiter. Also mount DELETE /{id} behind its permission middleware, to prove the routing order. Mint jwt cookies with user_id and token_version.
  4. Seed marta (id 7, Writer, "hashed:limewash2026"), jonas (2, Admin), ines (3, Owner). No live server, real SMTP or real SQLite.

Use-case checks

  1. Happy path for a Writer. Assert:
    • The user is gone, keys 1 and 2 are gone.
    • The author row is scrubbed (name, slug deleted-user-<id>, bio "", avatar/website nil, links empty, user_id nil) when it credits 3 articles.
    • The author is deleted when it credits none.
    • Articles, entries and images are byte-for-byte unchanged (reflect.DeepEqual before and after).
    • Her 12 events are anonymised and the other users' events are untouched.
    • The session id is revoked.
    • Exactly one account-deleted mail goes to the old address, with data useCases.MailAccountDeletedData{Host} and no other field.
    • Exactly one user.delete_account event, with actor and target name "Deleted user" and no @ in the params.
  2. Password. A wrong password returns user.ErrWrongPassword (errors.Is), changes nothing, sends nothing and records one failed security event.
  3. Last-role matrix. Table-driven from the feature (9 rows):
    • Owner/Admin blocked rows return user.ErrLastAdmin carrying the role label "Owner" or "Admin" (assert with errors.As on whatever the coder exports).
    • Unverified holders do not count.
    • A custom all-permission role does not count and is not blocked.
    • Allowed rows delete.
  4. Concurrent role change. The fake changes a holder between the check and the operation. Assert ErrAccountNotDeletable and an untouched state.
  5. Rollback. For each step failure in case 1, assert that all fakes equal their pre-state, no mail is sent, no revocation is recorded and the error is returned.
  6. Mail failure with the log captured. Assert nil error, the user gone, and a log line naming the failed send.
  7. Order of checks. A wrong password plus a last-Owner caller returns ErrWrongPassword, not last_admin (no role probing without the password).

HTTP checks

  1. Success. Assert 204, an empty body, and a Set-Cookie for jwt with Max-Age=0 or an expired date. A follow-up GET with the old cookie through the real middleware chain gives 401.
  2. Refusals table of the feature: status and exact body for each row. For the 400 rows, compare to the validator.Validate.Struct message. Assert the fakes are unchanged.
  3. Last role: 409, body last_admin, header X-Last-Role equal to Owner or Admin.
  4. account_not_deletable gives 409 with that body.
  5. Rate limit. Make 5 attempts (mix wrong-password and blocked), then assert the sixth is 429 with Retry-After and that no state changed.
  6. Auth gates: 401, API key 403 and unverified 403, with exact bodies.
  7. Routing. DELETE /v1/user/me reaches the new handler, never removeHandler. DELETE /v1/user/7?expected_role_id=3 still behaves as features/users_row_actions.qa.md says.
  8. Mail rendering. Render account-deleted with the real MailRepository.Render. Assert the subject, no @, no username, no button, no leftover {{.

Persistence (real in-memory SQLite, same models as api/dependecyInjection.go)

  1. One test runs the real atomic operation. Assert the user, the keys, the scrub-or-delete of the author, the audit anonymisation, a forced mid-way failure leaving everything intact, and the portability of the SQL (no driver-specific syntax). If a real-DB test already exists for MySQL/PostgreSQL (task test:mysql/test:postgres), run the same test there.

Vitest (SPA)

Strings live in messages.ts in English and French under the same keys.

I18n checks (extend the existing web/src/i18n/*.spec.ts style, e.g. spa.spec.ts):

  • Key parity: every new key exists in both en and fr, with no empty value and the same placeholders.
  • French render: for the Danger zone, the dialog, each Blocked variant and the Login notice with setLocale('fr'), assert the French text is shown and no English string remains; switching en, fr, en restores the English copy.
  • The confirmation word stays the literal "DELETE" in both languages, and the French dialog label tells the user to type it.
  • English values equal the design copy verbatim.

Flagged assumption: the French wording is authored by the coder because docs/design has none. French strings to author, for the user's review: the Danger zone title, text and button; the dialog title, text, the three list items, the "Your articles and media stay" heading and text, "Export my data first", the two field labels, "Keep my account", "Delete my account", "Deleting…"; the wrong-password, 429 and server errors; the three Blocked variants (Owner, administrator, cannot be deleted) with their buttons; and the Login notice title and text.

State Mocked /v1
Danger zone, Confirm: empty/typing/ready none
Wrong password (403) DELETE /user/me 403 "wrong_password"
Deleting DELETE pending
Rate limited (429), Server error 429; 500 and a rejected fetch
Blocked: last administrator 409 "last_admin" + X-Last-Role: Admin
Blocked: last Owner 409 "last_admin" + X-Last-Role: Owner
Blocked: cannot be deleted 409 "account_not_deletable"
Success 204; assert no POST /auth/logout, session.clear(), router at /login?reason=account-deleted

Also assert:

  • Confirm enablement for "DELETE", "delete", "DELETE ", "".
  • A blocked dialog ignores Escape and the backdrop and has no close button.
  • The Login notice appears for reason=account-deleted only, and the query is gone after it is consumed.
  • Typed values are kept on 429 and 500.
  • The "Deleted user" fallback where a creator is missing from the lookup.

Visual

task visual:check -- account and -- auth.

  • Board data to reuse: password mask ••••••••••, the typed word "DEL" in Confirm: typing and "DELETE" elsewhere, and the Auth board's "Account deleted" notice.
  • States: all 10 delete states of the Account board and "Account deleted" on the Auth board. The Owner variant has no frame.
  • None is clock-sensitive.

Manual verification

  • Real SMTP: the account-deleted mail arrives once at the old address, and the link-free rendering in a real client.
  • Delete a real account in a browser against a real database file: log back in as another user and check an article byline reads "Deleted user", and Activity shows "Deleted user".
  • On the embedded frontend, check that after deletion /login?reason=account-deleted shows the notice once.
  • Keyboard and focus: the dialog focus trap, the focus on the cleared password after 403, Tab order in the blocked dialog.
  • MySQL and PostgreSQL via task test:mysql / task test:postgres.