236 lines
17 KiB
Gherkin
236 lines
17 KiB
Gherkin
Feature: Delete my own account and my personal data
|
|
|
|
As a verified RenewCMS user
|
|
I want to delete my account with my password and a typed confirmation
|
|
So that I can exercise my right to erasure (GDPR art. 17) while the content I wrote stays on the site
|
|
without my name, and the workspace is never left without an Owner or an administrator
|
|
|
|
Background:
|
|
Given the design of record is docs/design/screens/account.md "Delete account", the board
|
|
"RenewCMS Screens Account" group "Delete account" (10 states) and the board "RenewCMS Screens Auth"
|
|
group "Account deleted" (1 state)
|
|
And RemoveUserUseCase and DELETE /v1/user/{id} are not reused, because they refuse the caller as the
|
|
target (role.ErrOwnRole), require users.remove and a covering role, never remove a Locked role (the
|
|
Owner), take no password and only delete the user row; a new DeleteOwnAccountUseCase is added and
|
|
RemoveUserUseCase is not changed
|
|
And DELETE /v1/user/me is added to NewUserRouter behind RequireSession, Verifier, AuditContext,
|
|
RequireSignedIn, Authenticator, RequireCurrentToken and RequireVerifiedUser, with a per-user limiter of
|
|
5 attempts per 15 minutes (429 "Too many requests. Please try again later." with Retry-After); an API
|
|
key gets 403 "This action needs a real session."
|
|
And the body is {"current_password": "...", "confirmation": "DELETE"} validated with the tags
|
|
current_password "required" and confirmation "required,eq=DELETE" (exact, case-sensitive) through
|
|
validator.Validate.Struct; the board sends the password only, so confirmation is an addition
|
|
And password checking uses the domain hash.Hasher
|
|
And the check order is: body validation 400, wrong password 403 "wrong_password", last Owner or
|
|
administrator 409 "last_admin", then one atomic removal
|
|
And "last administrator" means: the user holds the built-in Owner role and no other verified user does
|
|
(Owner variant), or the user holds the built-in Admin role and no other verified user holds Owner or
|
|
Admin (Admin variant); other roles are never blocked; the 409 carries the header X-Last-Role with
|
|
"Owner" or "Admin"
|
|
And one atomic persistence operation (owned by persistence; the use case sees only a domain interface)
|
|
does, or does none of:
|
|
| effect | result |
|
|
| user | the users row is deleted; every jwt of the user stops working because RequireCurrentToken cannot find it |
|
|
| API keys | all the user's api_keys rows are deleted |
|
|
| author | the linked author is deleted if no article credits it; otherwise it is scrubbed in place: name "Deleted user", slug "deleted-user-<authorId>", empty bio, no avatar, website or social links, user_id NULL |
|
|
| content | articles (created_by), entries (created_by, updated_by) and images (uploaded_by_id) keep the old id and are not touched |
|
|
| audit events | the user's past events lose their name (actor and target names become "Deleted user") and their ip and user_agent become null; the events stay |
|
|
| re-check | the role holders are counted again inside the same operation; a change since the check answers 409 "account_not_deletable" and nothing is removed |
|
|
And after the commit the use case revokes the current session id, then sends the template
|
|
"account-deleted" once to the address the account had (data: Host only), and the handler clears the
|
|
jwt cookie (Max-Age 0, same attributes as logout)
|
|
And a failed send is logged and does not undo the deletion, and the response is still 204
|
|
And the audit event user.delete_account is recorded after the removal: actor and target id of the
|
|
deleted user, both named "Deleted user", category users, security true, success; its params hold no email
|
|
And a failed attempt records user.delete_account with outcome failed, security true and the reason
|
|
"Wrong password." or "Last Owner." / "Last administrator." on the user's own events
|
|
And the success response is 204 with no body and an expired jwt cookie
|
|
And the layering rule holds and no comment is added anywhere
|
|
|
|
Rule: The endpoint
|
|
|
|
Background:
|
|
Given the users "marta" (id 7, Writer), "jonas" (id 2, Admin) and "ines" (id 3, Owner)
|
|
|
|
Scenario: A Writer deletes their account
|
|
Given marta has the API keys 1 and 2, an author profile credited on 3 articles, 2 entries, 4 uploaded
|
|
images and 12 audit events
|
|
And her email was "marta@renew.example"
|
|
When marta sends DELETE /v1/user/me with {"current_password": "limewash2026", "confirmation": "DELETE"}
|
|
Then the response is 204 with no body and a Set-Cookie that expires jwt
|
|
And user 7 no longer exists, API keys 1 and 2 no longer exist
|
|
And her author row remains with name "Deleted user", slug "deleted-user-<id>", user_id null and no bio
|
|
And the 3 articles, 2 entries and 4 images are unchanged
|
|
And her 12 events show "Deleted user" and have a null ip and user_agent
|
|
And her old jwt on GET /v1/auth/me answers 401
|
|
And one mail "account-deleted" with Host only is sent to "marta@renew.example"
|
|
And one audit event user.delete_account is recorded
|
|
|
|
Scenario: An author profile that credits no article is deleted
|
|
Given marta's author profile credits nothing
|
|
When she deletes her account
|
|
Then the author row is gone
|
|
|
|
Scenario Outline: Refused requests change nothing
|
|
When marta sends DELETE /v1/user/me with <body>
|
|
Then the response is <status> with the body "<text>"
|
|
And user 7, her API keys, her author and her events are unchanged, and no mail is sent
|
|
|
|
Examples:
|
|
| body | status | text |
|
|
| {"current_password": "limewash2026", "confirmation": "delete"} | 400 | the validator message for confirmation |
|
|
| {"current_password": "limewash2026", "confirmation": ""} | 400 | the validator message for confirmation |
|
|
| {"current_password": "", "confirmation": "DELETE"} | 400 | the validator message for current_password |
|
|
| not-json | 400 | The request cannot be processed due to a mismatch in the format of the body. |
|
|
| {"current_password": "nope", "confirmation": "DELETE"} | 403 | wrong_password |
|
|
|
|
Scenario Outline: The last Owner or administrator is blocked
|
|
Given the caller holds <role> and <others>
|
|
When the caller sends a valid DELETE /v1/user/me
|
|
Then the response is <result>
|
|
|
|
Examples:
|
|
| role | others | result |
|
|
| Owner | no other Owner, one Admin | 409 "last_admin" with X-Last-Role: Owner |
|
|
| Owner | no other Owner, no Admin | 409 "last_admin" with X-Last-Role: Owner |
|
|
| Owner | another verified Owner | 204 |
|
|
| Admin | no Owner and no other Admin | 409 "last_admin" with X-Last-Role: Admin |
|
|
| Admin | one Owner | 204 |
|
|
| Admin | another verified Admin and no Owner | 204 |
|
|
| Admin | only an unverified Admin and no Owner | 409 "last_admin" with X-Last-Role: Admin |
|
|
| Writer | no Owner and no Admin | 204 |
|
|
| a custom role with every admin permission | no Owner and no Admin | 204 |
|
|
|
|
Scenario: A blocked attempt leaves everything and records a failed event
|
|
Given the caller is the last Owner
|
|
When the caller sends a valid DELETE /v1/user/me
|
|
Then the user, keys, author and events are unchanged and no mail is sent
|
|
And one audit event user.delete_account is recorded with outcome failed, security true and the reason "Last Owner."
|
|
|
|
Scenario: A concurrent role change stops the removal
|
|
Given the check passed and another user's role changed before the atomic removal
|
|
When the removal runs
|
|
Then the response is 409 "account_not_deletable" and nothing was removed
|
|
|
|
Scenario: A mail failure does not undo the deletion
|
|
Given the mail repository fails on Send
|
|
When marta deletes her account
|
|
Then the response is 204, the user is gone and the process log names the failed send
|
|
|
|
Scenario: A failure in the removal rolls everything back
|
|
Given deleting the API keys fails inside the operation
|
|
When marta deletes her account
|
|
Then the response is 500 "Internal server error" and user 7, her author and her events are unchanged, and no mail is sent
|
|
|
|
Scenario: Rate limited after five attempts
|
|
Given marta made five attempts in the last 15 minutes
|
|
When she sends one more
|
|
Then the response is 429 with a Retry-After header, and nothing is checked or changed
|
|
|
|
Scenario Outline: Only a real verified session may delete
|
|
When <caller> sends DELETE /v1/user/me
|
|
Then the response is <status>
|
|
|
|
Examples:
|
|
| caller | status |
|
|
| nobody | 401 |
|
|
| an API key bearer | 403 |
|
|
| an unverified user | 403 |
|
|
|
|
Scenario: DELETE /v1/user/{id} is unchanged
|
|
Given the scenarios of the users_row_actions feature
|
|
Then all still pass, and DELETE /v1/user/me is not routed to the {id} handler
|
|
|
|
Rule: Mail template
|
|
|
|
Scenario: account-deleted renders without personal data
|
|
When the mailer renders "account-deleted" with Host https://cms.example.com
|
|
Then the subject is "Your RenewCMS account has been deleted"
|
|
And the HTML has no button, no username and no address, and no unreplaced {{ }} action
|
|
|
|
Rule: Danger zone and the delete dialog (SPA)
|
|
|
|
Background:
|
|
Given the Danger zone card is the last card of /profile with the title "Danger zone", the text "Delete
|
|
your account and your personal data. This can't be undone." and the button "Delete my account"
|
|
And a dialog shows the design's content: "Delete your account?", "This permanently deletes your
|
|
account. It can't be undone.", the list "Your username, display name and email address", "Your author
|
|
profile and API keys", "Your sessions: you'll be logged out everywhere", the section "Your articles
|
|
and media stay" / "They stay on the site, credited to "Deleted user". Your name is removed from
|
|
them.", the link "Export my data first", the fields "Current password" and "Type DELETE to
|
|
confirm", and the actions "Keep my account" and "Delete my account"
|
|
And every new SPA string (the Danger zone card, the dialog and its blocked variants, errors, and the
|
|
Login notice "Your account has been deleted" with its text) is added to the i18n catalogue
|
|
(messages.ts, features/spa_internationalization.feature) in English and in French under the same
|
|
keys, with the design's English wording verbatim
|
|
And the French wording is an assumption: docs/design has no French copy, so the coder authors it and
|
|
flags it for the user's review
|
|
And the account-deleted mail is English only, because the existing mailer does not localise
|
|
(templates hard-code lang="en" and the subjects are fixed English strings)
|
|
And every state is a Vitest spec and a `task visual:check -- account` row, named as in the board
|
|
|
|
Scenario Outline: Dialog states
|
|
Given the user opened the dialog on /profile
|
|
When <trigger>
|
|
Then the state "<state>" shows <copy>
|
|
|
|
Examples:
|
|
| state | trigger | copy |
|
|
| Danger zone | the page loads | the card only; no request |
|
|
| Confirm: empty | the dialog opens | "Delete my account" disabled |
|
|
| Confirm: typing | a password and "DEL" are entered | still disabled |
|
|
| Confirm: ready | a password and exactly "DELETE" are entered | enabled; "delete", "Delete" and "DELETE " (trailing space) keep it disabled |
|
|
| Wrong password (403) | DELETE answers 403 "wrong_password" | "That password isn't correct. Try again." under the password, password cleared and focused, the word kept |
|
|
| Deleting | DELETE unresolved | "Deleting…", fields and "Keep my account" disabled, no close |
|
|
| Rate limited (429) | DELETE answers 429 | "Too many requests. Try again in a few minutes.", Confirm stays enabled |
|
|
| Server error | DELETE answers 500 or fails | "Couldn't delete your account. Nothing was deleted. Try again in a moment.", values kept |
|
|
| Blocked: last administrator | DELETE answers 409 "last_admin" with X-Last-Role Admin | "You can't delete your account yet", "You are the last administrator. RenewCMS needs at least one. Promote another user first, then come back to delete your account.", "Back to profile", "Go to Users" |
|
|
| Blocked: last Owner (Vitest only, no board frame) | the same with X-Last-Role Owner | "You can't delete your account yet", "You are the last Owner. RenewCMS needs at least one. Transfer ownership or promote another user first, then come back to delete your account.", "Back to profile", "Go to Users" |
|
|
| Blocked: cannot be deleted | DELETE answers 409 "account_not_deletable" | "This account can't be deleted", "RenewCMS can't delete this account right now. Ask an administrator to help, then try again.", "Back to profile" (no "Go to Users") |
|
|
|
|
Scenario Outline: Blocked dialogs cannot be dismissed
|
|
Given a "Blocked" state is shown
|
|
When the user <action>
|
|
Then the dialog stays open and shows no close button
|
|
|
|
Examples:
|
|
| action |
|
|
| presses Escape |
|
|
| clicks the backdrop |
|
|
|
|
Scenario: A blocked dialog always offers a next step
|
|
Then "Back to profile" closes the dialog, and "Go to Users" navigates to /users
|
|
|
|
Scenario: Export my data first
|
|
Given the dialog is open
|
|
When the user activates "Export my data first"
|
|
Then the download of slice account_export_data starts without closing the dialog
|
|
|
|
Scenario: Success signs out and redirects with the one-time message
|
|
Given the user confirmed with a valid password and "DELETE"
|
|
When DELETE /v1/user/me answers 204
|
|
Then the session store is cleared without calling POST /v1/auth/logout and the router goes to /login?reason=account-deleted
|
|
And the Login screen shows a success notice (icon circle-check) "Your account has been deleted" /
|
|
"Your articles and media were kept without your name."
|
|
And the query is removed from the URL once consumed, so a reload shows the plain Login with no notice
|
|
And the leave-page confirmation of Profile does not open
|
|
|
|
Scenario: The one-time message needs the exact reason
|
|
When a visitor opens /login?reason=anything-else
|
|
Then no account-deleted notice is shown
|
|
|
|
Scenario: Deleted creators are named "Deleted user" in the SPA
|
|
Given an entry whose created_by is 7 and GET /v1/user/lookup does not return user 7
|
|
Then the screen that names its creator shows "Deleted user"
|
|
|
|
Rule: Documentation and privacy
|
|
|
|
Scenario: The docs describe the feature
|
|
Then docs/api.md documents DELETE /v1/user/me: body, 204, 400, 403 wrong_password, 409 last_admin with
|
|
X-Last-Role, 409 account_not_deletable, 429
|
|
And docs/privacy.md:
|
|
- replaces the Erasure bullet and the "No way to delete a verified user" Gap with the self-service flow;
|
|
- states what is kept (articles, entries, media, anonymised author and activity events) and what is removed;
|
|
- adds the account-deleted mail to "What leaves the server";
|
|
- leaves the Gaps entry about soft-deleted rows and the failed-login username entry intact.
|