feat: change email, export my data, delete account (with data anonymization)
This commit is contained in:
1 parent
bb191c389a
commit
10a66cd979
123 files changed
+10789
-76
No files matched your search
@@ -0,0 +1,292 @@
|
||||
Feature: Change my email address from Profile, verified by a link sent to the new address
|
||||
|
||||
As a verified RenewCMS user
|
||||
I want to change my email address with my current password and a link sent to the new address
|
||||
So that I keep control of my account address and nobody can take it over without my password
|
||||
or without access to the new inbox
|
||||
|
||||
Background:
|
||||
Given the design of record is docs/design/screens/account.md "Change email" and "Verification landing",
|
||||
docs/design/routes.md "Account", the board "RenewCMS Screens Account" group "Change email" (14 states)
|
||||
and the board "RenewCMS Screens Auth" group "Verify new email" (7 states)
|
||||
And four endpoints are added under /v1/auth, each behind RequireSession, authtoken.Verifier,
|
||||
AuditContext, RequireSignedIn, authtoken.Authenticator, RequireCurrentToken and RequireVerifiedUser,
|
||||
so a missing or invalid jwt cookie gives 401 "You must be logged in to access this resource.",
|
||||
an API key gives 403 "This action needs a real session." and an unverified user gives
|
||||
403 "You must verify your account to access this resource.":
|
||||
| method | path | body | success |
|
||||
| POST | /v1/auth/email/change | {"new_email": "...", "current_password": "..."} | 200 {"pending_email": "<new>"} |
|
||||
| POST | /v1/auth/email/resend | none | 200 {"pending_email": "<new>"} |
|
||||
| DELETE | /v1/auth/email/change | none | 204, no body |
|
||||
| POST | /v1/auth/email/confirm | {"code": "<raw code>"} | 200 {"email": "<new>"} |
|
||||
And POST change and POST resend share one per-user limiter of 5 attempts per 15 minutes
|
||||
(middleware.NewUserRateLimiter); the sixth answers 429 "Too many requests. Please try again later."
|
||||
with a Retry-After header; DELETE and confirm are not limited
|
||||
And the request struct uses validate tags checked with validator.Validate.Struct:
|
||||
new_email "required,email" (same email rule as the invite request, extended in validator.InitValidator)
|
||||
and current_password "required"; there is no hand-written validation in the handler
|
||||
And error bodies are plain text from http.Error, snake_case codes are the whole body:
|
||||
403 "wrong_password", 400 "same_email", 404 "no_pending_email_change", 403 "invalid_email_change_code",
|
||||
409 "email_unavailable"; success bodies are JSON
|
||||
And the user domain gains PendingEmail, EmailChangeCode (bcrypt hash of a random UUID, like the reset
|
||||
code) and EmailChangeExpiration on user.User and the users table (portable across SQLite, MySQL and
|
||||
PostgreSQL), the sentinels ErrWrongPassword, ErrSameEmail, ErrNoPendingEmailChange,
|
||||
ErrInvalidEmailChangeCode and ErrEmailUnavailable, and repository methods that set, clear and
|
||||
confirm a pending change; confirming is one atomic step that re-checks the address is free
|
||||
And a use case (RequestEmailChange, ResendEmailChange, CancelEmailChange, ConfirmEmailChange) owns
|
||||
every rule; the handler never touches a repository, GORM or the mailer
|
||||
And password checking uses the domain hash.Hasher (never bcrypt in the handler)
|
||||
And the link code lives 2 hours and works once; users.email is untouched until confirmation, so the
|
||||
old address keeps receiving password-reset links and the password and username keep working
|
||||
And GET /v1/auth/me gains "pending_email" (string or null) so the Pending state survives a reload
|
||||
And three mail templates are built from docs/design/source/emails and added to
|
||||
internal/infrastructure/mailer/templates:
|
||||
| template | to | data | subject |
|
||||
| verify-new-email | the new address | Host, Username, VerificationCode | Confirm your new RenewCMS email address |
|
||||
| email-change-notice| the old address | Host, Username, NewEmail | Your RenewCMS email address change was requested |
|
||||
And Host is PUBLIC_URL and Username is the account's username
|
||||
And the audit actions user.request_email_change, user.resend_email_change, user.cancel_email_change and
|
||||
user.confirm_email_change are added (category users, source web); request, resend and confirm are
|
||||
security events; no email address is written into an event
|
||||
And the layering rule holds and no comment is added anywhere
|
||||
|
||||
Rule: Requesting a change
|
||||
|
||||
Background:
|
||||
Given the user "marta" (id 7, verified) has email "marta@renew.example" and the password "limewash2026"
|
||||
|
||||
Scenario: A valid request stores a pending change and sends two mails
|
||||
When marta sends POST /v1/auth/email/change with
|
||||
{"new_email": "marta.alves@atelier-renew.example", "current_password": "limewash2026"}
|
||||
Then the response is 200 with {"pending_email": "marta.alves@atelier-renew.example"}
|
||||
And users.email is still "marta@renew.example"
|
||||
And PendingEmail is "marta.alves@atelier-renew.example", the stored code is a hash (never the raw
|
||||
code) and the expiration is within 5 seconds of now plus 2 hours
|
||||
And one mail is sent to "marta.alves@atelier-renew.example" with template "verify-new-email" and
|
||||
data Host, Username "marta" and the raw VerificationCode
|
||||
And one mail is sent to "marta@renew.example" with template "email-change-notice" and data Host,
|
||||
Username "marta" and NewEmail "marta.alves@atelier-renew.example"
|
||||
And one audit event user.request_email_change is recorded: actor marta, target user 7, success,
|
||||
security, with no address in its params
|
||||
|
||||
Scenario Outline: A refused request changes nothing and sends nothing
|
||||
When marta sends POST /v1/auth/email/change with <body>
|
||||
Then the response is <status> with the body "<text>"
|
||||
And PendingEmail is empty and no mail is sent
|
||||
|
||||
Examples:
|
||||
| body | status | text |
|
||||
| {"new_email": "marta.alves@atelier", "current_password": "limewash2026"} | 400 | the validator message for new_email |
|
||||
| {"new_email": "", "current_password": "limewash2026"} | 400 | the validator message for new_email |
|
||||
| {"new_email": "marta.alves@atelier-renew.example", "current_password": ""} | 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. |
|
||||
| {"new_email": "MARTA@renew.example", "current_password": "limewash2026"} | 400 | same_email |
|
||||
| {"new_email": "marta.alves@atelier-renew.example", "current_password": "nope"} | 403 | wrong_password |
|
||||
|
||||
Scenario: A wrong password records a failed security event
|
||||
When marta sends the request with the password "nope"
|
||||
Then one audit event user.request_email_change is recorded with outcome failed, security true and
|
||||
failure reason "Wrong password."
|
||||
|
||||
Scenario: An address already in use answers like success and sends no verification mail
|
||||
Given another user has the email "ines@renew.example"
|
||||
When marta sends POST /v1/auth/email/change with new_email "Ines@Renew.example"
|
||||
Then the status, the body and every header except Date are identical to the valid request
|
||||
And a pending change for "Ines@Renew.example" is stored so a reload cannot tell the difference
|
||||
And no mail is sent to "ines@renew.example"
|
||||
And the email-change-notice is still sent to "marta@renew.example"
|
||||
|
||||
Scenario: A new request replaces the previous link
|
||||
Given marta requested a change to "a@renew.example" and received code 1
|
||||
When she requests "b@renew.example"
|
||||
Then only code 2 confirms, code 1 answers 403 "invalid_email_change_code", and PendingEmail is "b@renew.example"
|
||||
|
||||
Scenario: A failed send is logged and the response is unchanged
|
||||
Given the mail repository fails on Send with "smtp: connection refused"
|
||||
When marta sends a valid request
|
||||
Then the response is 200 with the same body, the pending change is stored, and the process log names
|
||||
the failed send and the error but not the raw code
|
||||
And a 500 would reveal whether the address is taken, which is why the failure is not surfaced
|
||||
|
||||
Scenario: The sixth request in 15 minutes is rate limited
|
||||
Given marta made five requests or resends in the last 15 minutes
|
||||
When she sends one more
|
||||
Then the response is 429 "Too many requests. Please try again later." with a Retry-After header
|
||||
And no mail is sent and nothing is stored
|
||||
|
||||
Rule: Resending and cancelling while pending
|
||||
|
||||
Scenario: Resend issues a new code and invalidates the old one
|
||||
Given marta has a pending change to "marta.alves@atelier-renew.example" with code 1
|
||||
When she sends POST /v1/auth/email/resend
|
||||
Then the response is 200 with {"pending_email": "marta.alves@atelier-renew.example"}
|
||||
And one mail with template "verify-new-email" carries a new code, the expiration is reset to now plus 2 hours
|
||||
And code 1 answers 403 "invalid_email_change_code"
|
||||
And no email-change-notice is sent again
|
||||
And one audit event user.resend_email_change is recorded
|
||||
|
||||
Scenario: Resend for a taken address sends nothing
|
||||
Given marta's pending address belongs to another user
|
||||
When she sends POST /v1/auth/email/resend
|
||||
Then the response is 200 with the same body and no mail is sent
|
||||
|
||||
Scenario: Resend with nothing pending
|
||||
When marta sends POST /v1/auth/email/resend
|
||||
Then the response is 404 "no_pending_email_change"
|
||||
|
||||
Scenario: Cancel clears the pending change
|
||||
Given marta has a pending change
|
||||
When she sends DELETE /v1/auth/email/change
|
||||
Then the response is 204 and PendingEmail, code and expiration are empty
|
||||
And the old link answers 403 "invalid_email_change_code"
|
||||
And one audit event user.cancel_email_change is recorded
|
||||
|
||||
Scenario: Cancel with nothing pending is harmless
|
||||
When marta sends DELETE /v1/auth/email/change
|
||||
Then the response is 204 and no audit event is recorded
|
||||
|
||||
Rule: Confirming from the link
|
||||
|
||||
Scenario: A valid code changes the email and is single use
|
||||
Given marta has a pending change to "marta.alves@atelier-renew.example" with a live code
|
||||
When she sends POST /v1/auth/email/confirm with that code
|
||||
Then the response is 200 with {"email": "marta.alves@atelier-renew.example"}
|
||||
And users.email is "marta.alves@atelier-renew.example", the pending fields are empty
|
||||
And her session keeps working (token_version is unchanged)
|
||||
And one audit event user.confirm_email_change is recorded
|
||||
When she sends the same code again
|
||||
Then the response is 403 "invalid_email_change_code"
|
||||
|
||||
Scenario Outline: A code that does not work answers one 403
|
||||
Given <setup>
|
||||
When marta confirms with <code>
|
||||
Then the response is 403 "invalid_email_change_code" and users.email is unchanged
|
||||
|
||||
Examples:
|
||||
| setup | code |
|
||||
| nothing is pending | any code |
|
||||
| the code expired 1 second ago | the right code |
|
||||
| a pending change exists | a wrong code |
|
||||
| the code was issued to another user | the right code of ines |
|
||||
| the change was cancelled | the old code |
|
||||
|
||||
Scenario: The address was taken in the meantime
|
||||
Given marta has a live code for "ines@renew.example" and the address now belongs to another user
|
||||
When she confirms
|
||||
Then the response is 409 "email_unavailable", users.email is unchanged and the pending change is cleared
|
||||
And the body is the same for an address that was always taken at request time
|
||||
|
||||
Scenario: An empty code is refused before any lookup
|
||||
When marta sends {"code": ""}
|
||||
Then the response is 400 with the validator message and nothing changes
|
||||
|
||||
Rule: Profile, the Account card (SPA)
|
||||
|
||||
Background:
|
||||
Given /profile (docs/design/screens/profile.md, features/profile_page.feature) shows the Email
|
||||
field and a link "Change email"; the order of the cards is Display name, Account, Your role,
|
||||
Author profile, Password, Your data, Danger zone
|
||||
And the session carries pending_email from GET /v1/auth/me
|
||||
And the global handling of 403 in useApi passes a 403 with body "wrong_password" through to the
|
||||
caller; the form reads the body text and never the status alone
|
||||
And every new SPA string (the Account card states, toasts and errors, and the verification landing
|
||||
states) is added to the i18n catalogue (messages.ts, features/spa_internationalization.feature) in
|
||||
English and in French under the same keys, and the English wording is the design's 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 mail templates verify-new-email and email-change-notice are English only, because the existing
|
||||
mailer does not localise (verification, password-reset and invitation hard-code lang="en" and the
|
||||
subjects are fixed English strings)
|
||||
And every state below is one Vitest spec and one row of `task visual:check -- account`, named as in
|
||||
the board, using the sample data of the frame (marta, marta@renew.example,
|
||||
marta.alves@atelier-renew.example)
|
||||
|
||||
Scenario Outline: Form states
|
||||
Given the user is on /profile
|
||||
When <trigger>
|
||||
Then the state "<state>" is visible with <copy>
|
||||
And <requests>
|
||||
|
||||
Examples:
|
||||
| state | trigger | copy | requests |
|
||||
| Default | the page loads, nothing pending | the Email field and the link "Change email" | none |
|
||||
| Form open | "Change email" is activated | "New email address", "Current password", the hint "We'll send a verification link to the new address. Your email stays the same until you open it.", "Cancel", "Send verification link"; focus on the email field | none |
|
||||
| Invalid format | "marta.alves@atelier" is typed and the field blurs or is submitted | "Enter a full email address, like name@example.com." | none |
|
||||
| Same as current | "marta@renew.example" is submitted | "That's already your email address." | none |
|
||||
| Password missing | submitted with an empty password | "Enter your current password.", focus on the password | none |
|
||||
| Wrong password (403) | POST answers 403 "wrong_password" | "That password isn't correct. Try again." under the password, password cleared and focused | one POST |
|
||||
| Sending | POST has not resolved | fields and button disabled, button reads "Sending…" | one POST |
|
||||
| Rate limited (429) | POST answers 429 | "Too many requests. Try again in a few minutes.", "Send verification link" disabled | one POST |
|
||||
| Server or network error | POST answers 500 or fails | "Something went wrong on our side. Nothing was changed. Try again in a moment." and the typed values kept | one POST |
|
||||
|
||||
Scenario: Pending, from a successful request
|
||||
When POST /v1/auth/email/change answers 200 with pending_email "marta.alves@atelier-renew.example"
|
||||
Then the form closes and the Email field still shows "marta@renew.example"
|
||||
And the card shows "Waiting for verification", "We sent a link to marta.alves@atelier-renew.example. Until you open it, you keep logging in with marta@renew.example.", "Resend link" and "Cancel change"
|
||||
And a toast "Verification link sent" / "Check marta.alves@atelier-renew.example. The link works once, within 2 hours." appears
|
||||
And reloading /profile with pending_email set shows the same Pending card without a toast
|
||||
|
||||
Scenario Outline: Pending actions
|
||||
Given the Pending card is shown
|
||||
When <trigger>
|
||||
Then <outcome>
|
||||
|
||||
Examples:
|
||||
| trigger | outcome |
|
||||
| "Resend link" is activated, in flight | state "Pending: resending": "Sending…" and the button disabled |
|
||||
| the resend answers 200 | state "Pending: resent": toast "Link sent again" / "The previous link no longer works." |
|
||||
| the resend answers 429 with Retry-After 270 | state "Pending: resend rate limited (429)": "Resend in 4:30" disabled, "Too many requests. Try again in a few minutes."; the count runs down from the header, not the wall clock |
|
||||
| "Cancel change" is activated | DELETE /v1/auth/email/change with no confirmation step, then state "Cancelled": the Default state and toast "Email change cancelled" / "You keep logging in with marta@renew.example." |
|
||||
|
||||
Rule: The verification landing /account/email/confirm?c=CODE (SPA)
|
||||
|
||||
Background:
|
||||
Given the route is a session route inside AuthCard; without a session it shows the inline login as
|
||||
/register/validate does, then continues the confirmation
|
||||
And the AuthCard states are those of the board group "Verify new email"
|
||||
|
||||
Scenario Outline: Landing states
|
||||
Given the user opens /account/email/confirm<query>
|
||||
When <trigger>
|
||||
Then the state "<state>" shows <copy>
|
||||
|
||||
Examples:
|
||||
| query | trigger | state | copy |
|
||||
| ?c=abc123 | POST /v1/auth/email/confirm unresolved | Confirming | "Confirming your new email…" |
|
||||
| ?c=abc123 | the POST answers 200 {"email": "marta.alves@atelier-renew.example"} | Success | "Your email address has changed", "Your account now uses marta.alves@atelier-renew.example. Use it the next time you log in.", "Go to profile"; toast "Email address changed" / "Your account now uses marta.alves@atelier-renew.example."; the session email is replaced |
|
||||
| ?c=abc123 | the POST answers 403 | Invalid or expired (403) | "This link doesn't work", "It may have expired (links last 2 hours), been cancelled or already been used.", "Request a new link from your profile.", "Go to profile" |
|
||||
| ?c=abc123 | the POST answers 409 "email_unavailable" | Address unavailable (409) | "We couldn't change your email address", "This address can't be used for your account. Go back to your profile and try a different one.", "Go to profile" |
|
||||
| ?c=abc123 | no session (401 from /auth/me) | No session (401) | "Log in to confirm your new email", "You opened this link somewhere you're not logged in. Log in here and we'll confirm the change right after.", "Log in and confirm" |
|
||||
| | no c parameter | Missing code | "This link is incomplete", "The confirmation code is missing. Open the link from your email again, or copy the full address into your browser.", "Go to log in"; no request is sent |
|
||||
| ?c=abc123 | the POST answers 500 or fails | Server error | "Couldn't confirm the change", "Something went wrong on our side. Nothing was changed. Try again in a moment.", "Try again", which sends the POST again |
|
||||
|
||||
Scenario: Log in and confirm
|
||||
Given the "No session (401)" state is shown
|
||||
When the user logs in inline with valid credentials
|
||||
Then POST /v1/auth/email/confirm is sent once with the code and the Success state follows
|
||||
And the code is not written to the URL history or logs by the SPA
|
||||
|
||||
Scenario: A confirmation is not repeated by reload noise
|
||||
Given the Success state was reached
|
||||
When the user reloads the page
|
||||
Then the POST is sent again and the "Invalid or expired (403)" state is shown, because the code is single use
|
||||
|
||||
Rule: Mail templates
|
||||
|
||||
Scenario Outline: Each template renders from its data
|
||||
When the mailer renders "<template>" with <data>
|
||||
Then the HTML contains <contains> and no unreplaced {{ }} action
|
||||
And the subject is "<subject>"
|
||||
|
||||
Examples:
|
||||
| template | data | contains | subject |
|
||||
| verify-new-email | Host https://cms.example.com, Username marta, VerificationCode abc | the link https://cms.example.com/account/email/confirm?c=abc | Confirm your new RenewCMS email address |
|
||||
| email-change-notice | Host https://cms.example.com, Username marta, NewEmail new@renew.example | new@renew.example and the link https://cms.example.com/profile | Your RenewCMS email address change was requested |
|
||||
|
||||
Rule: Documentation and privacy
|
||||
|
||||
Scenario: The docs describe the feature
|
||||
Then docs/api.md documents the four endpoints, their bodies, statuses and the pending_email field of /auth/me
|
||||
And docs/privacy.md says users can change their email themselves (art. 16), lists the three new users
|
||||
columns in the stored-data table, and removes "Changing an email address currently needs a database edit"
|
||||
and the email half of "No self-service export or email change" from Gaps
|
||||
@@ -0,0 +1,110 @@
|
||||
# QA procedure: account_change_email
|
||||
|
||||
Package: `test` (black-box), extending `test/user_test.go` (`mockUserRepo`, `stubHasher`) and `test/mail_test.go` (`mockMailRepo`, `recordedMail`, `failSwitch`). Reuse the existing recording audit fake. Fake type names must be unique across the package.
|
||||
|
||||
## Test infrastructure additions needed first
|
||||
|
||||
1. `mockUserRepo`: add the new repository methods for setting, clearing and confirming a pending change.
|
||||
- Each honours `fail.trigger` and returns `user.ErrUserNotFound` for an unknown id.
|
||||
- Confirm checks "email not held by another user" and returns `user.ErrEmailUnavailable`.
|
||||
- Add call counters, in the style of `clearVerificationCodeCalls`.
|
||||
2. Seed fixtures with `stubHasher` hashes: `EmailChangeCode: "hashed:abc123"` stands for the raw code `abc123`. The password "limewash2026" is seeded as `"hashed:limewash2026"`.
|
||||
3. A reusable helper builds the real `RequestEmailChangeUseCase`, `ResendEmailChangeUseCase`, `CancelEmailChangeUseCase` and `ConfirmEmailChangeUseCase` over `mockUserRepo`, `stubHasher`, `mockMailRepo`, the audit fake and `publicURL "https://cms.example.com"`.
|
||||
|
||||
## Setup shared by every case
|
||||
|
||||
4. Call `validator.InitValidator()` once.
|
||||
5. Handler checks build the handler from the use cases via its `NewHandler`/named constructor. Mount the four routes on a fresh `chi.NewRouter()` that mirrors the `NewAuthRouter` session group: `middleware.RequireSession`, `authtoken.Verifier`, `AuditContext`, `RequireSignedIn`, `authtoken.Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`. Add the shared `middleware.NewUserRateLimiter(5, 15*time.Minute)` in front of change and resend only.
|
||||
6. Use a test `authtoken` instance and mint jwt cookies carrying `user_id` and `token_version`. Seed user 7 "marta" (`IsVerified: true`).
|
||||
7. No live server, real SQLite file or SMTP.
|
||||
|
||||
## Use-case checks
|
||||
|
||||
8. Request, valid. Assert:
|
||||
- nil error.
|
||||
- `PendingEmail` set, and `users.email` unchanged.
|
||||
- Stored code equals `"hashed:"+raw`, and the expiration is within 5 s of now+2h.
|
||||
- `SentEmails` has exactly two entries.
|
||||
- Entry 1 is `verify-new-email` to the new address; its data (type-assert `useCases.MailVerifyNewEmailData`) carries Host, Username and the raw code.
|
||||
- Entry 2 is `email-change-notice` to the old address, with NewEmail in its data.
|
||||
- One audit event `user.request_email_change`, success, `Security` true, with no `@` in its Params or Changes.
|
||||
9. Request errors are table-driven, each asserted with `errors.Is`:
|
||||
- Wrong password gives `ErrWrongPassword`, with no mail, nothing stored and one failed security event.
|
||||
- Same address in different case gives `ErrSameEmail`.
|
||||
10. Taken address, matched case-insensitively. Assert the same return value and the same stored `PendingEmail` as case 8, only the notice mail sent (none to the other user), and the same audit event shape.
|
||||
11. Mail failure via `mailRepo.fail.failNext("Send")`. Capture the log (`log.SetOutput(&buf)` with a cleanup). Assert nil error, the pending change stored, a log line naming the failure, and neither the raw code nor its hash in the log.
|
||||
12. Resend. Assert:
|
||||
- A new code and expiry, and the old raw code now fails `stubHasher.Compare`.
|
||||
- One `verify-new-email` mail and no notice.
|
||||
- `ErrNoPendingEmailChange` when nothing is pending.
|
||||
- Nothing sent for a taken address.
|
||||
13. Cancel. Assert fields cleared, nil error when nothing is pending, and an audit event only when something was cancelled.
|
||||
14. Confirm, success. Assert:
|
||||
- `users.email` is the new address, the pending fields are cleared, and `TokenVersion` is unchanged.
|
||||
- A second confirm returns `ErrInvalidEmailChangeCode`.
|
||||
- The `user.confirm_email_change` event is recorded.
|
||||
15. Confirm, failures. Assert:
|
||||
- Expired, wrong, other user's, cancelled and none pending all return `ErrInvalidEmailChangeCode`.
|
||||
- Taken returns `ErrEmailUnavailable` and clears the pending change.
|
||||
|
||||
## HTTP checks
|
||||
|
||||
16. Request, valid and refused. Using the examples table of the feature, assert the status and exact body and that `SentEmails` is empty on refusals. The text for the 400 cases is the validator message; assert it equals `validator.Validate.Struct` output.
|
||||
17. Taken-address response parity. Compare status, body and headers except Date between case 8 and case 10 using `reflect.DeepEqual` on the header map minus Date.
|
||||
18. Auth gates, for each of the four routes:
|
||||
- No cookie gives 401 "You must be logged in to access this resource.".
|
||||
- A bearer API key gives 403 "This action needs a real session.".
|
||||
- An unverified user gives 403 "You must verify your account to access this resource.".
|
||||
19. Rate limit. Send 5 valid-or-wrong-password requests mixed with resends, then assert the sixth is 429 with a numeric `Retry-After` and the body "Too many requests. Please try again later.". Assert DELETE and confirm still answer.
|
||||
20. Resend, cancel and confirm statuses and bodies exactly as in the feature (200 JSON, 404 `no_pending_email_change`, 204 empty, 403 `invalid_email_change_code`, 409 `email_unavailable`).
|
||||
21. `GET /auth/me` returns `pending_email` as null, then as the address after a request.
|
||||
|
||||
## Mailer checks (no SMTP)
|
||||
|
||||
22. Render `verify-new-email` and `email-change-notice` with `MailRepository.Render` and fake data.
|
||||
- Assert the link strings, the subjects, and that no `{{` remains.
|
||||
- Assert the HTML escapes a username such as `<b>`.
|
||||
|
||||
## Vitest (SPA), `web/src/**/__tests__/`
|
||||
|
||||
Mock `/v1` with the response in the second column. One spec per state, named with the design state name. 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 interpolation placeholders (new address, old address, countdown).
|
||||
- French render: for each state with `setLocale('fr')`, assert the French text is shown and no English string remains; switching `en`, `fr`, `en` restores the English copy.
|
||||
- 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 "Change email" link, the form labels and hint, the four field errors, "Sending…", "Waiting for verification" and its text, "Resend link", "Cancel change", "Resend in {time}", the three toast pairs (sent, resent, cancelled), the 429 and server notices, and all seven landing states (title, text, button).
|
||||
|
||||
| State | Mocked /v1 |
|
||||
|---|---|
|
||||
| Default, Form open | `GET /auth/me` with pending_email null |
|
||||
| Invalid format, Same as current, Password missing | none (no request asserted) |
|
||||
| Wrong password (403) | `POST /auth/email/change` 403 "wrong_password" |
|
||||
| Sending | POST pending forever |
|
||||
| Pending, then reload | POST 200; `GET /auth/me` with pending_email |
|
||||
| Pending: resending / resent / resend rate limited | `POST /auth/email/resend` unresolved / 200 / 429 with Retry-After 270 |
|
||||
| Cancelled | `DELETE /auth/email/change` 204 |
|
||||
| Rate limited (429), Server or network error | POST 429; POST 500 and a rejected fetch |
|
||||
| Landing: all seven states | `POST /auth/email/confirm` per the feature table; the 401 state uses `GET /auth/me` 401 and then an inline login |
|
||||
|
||||
Also assert:
|
||||
- The 403 `wrong_password` is not treated as a global unverified redirect.
|
||||
- Typed values are kept in the 429 and 500 states.
|
||||
- The code is read from `?c=` only.
|
||||
|
||||
## Visual
|
||||
|
||||
Run `task visual:check -- account` and `task visual:check -- auth`. Every capture must be within 10 % at desktop and mobile, light and dark. No `exception` without the user's `approvedBy`.
|
||||
- Board data to reuse in `web/visual/states.ts` setups: username marta, old email marta@renew.example, new email marta.alves@atelier-renew.example, resend countdown 4:30 (Retry-After 270).
|
||||
- States: the 14 "Change email" states and the 7 "Verify new email" states of the Auth board.
|
||||
- Toast states (Pending, Pending: resent, Cancelled) capture the toast.
|
||||
- None of these is clock-sensitive.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- Real SMTP delivery and rendering of both mails in a real client and in dark mode.
|
||||
- Opening the link from a different browser to see the inline login, then the confirmation.
|
||||
- Keyboard and focus order: the email field on open, password focus after 403, focus return after Cancel.
|
||||
- Hover and motion of the toasts.
|
||||
- Real embedded frontend: open `/account/email/confirm?c=...` on the built binary.
|
||||
@@ -0,0 +1,235 @@
|
||||
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.
|
||||
@@ -0,0 +1,101 @@
|
||||
# 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
|
||||
|
||||
5. Call `validator.InitValidator()` once.
|
||||
6. Build `useCases.NewDeleteOwnAccountUseCase(...)` over the fakes, `stubHasher`, `mockMailRepo`, a clock, `publicURL "https://cms.example.com"` and the audit fake.
|
||||
7. 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`.
|
||||
8. Seed marta (id 7, Writer, "hashed:limewash2026"), jonas (2, Admin), ines (3, Owner). No live server, real SMTP or real SQLite.
|
||||
|
||||
## Use-case checks
|
||||
|
||||
9. 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.
|
||||
10. Password. A wrong password returns `user.ErrWrongPassword` (`errors.Is`), changes nothing, sends nothing and records one failed security event.
|
||||
11. 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.
|
||||
12. Concurrent role change. The fake changes a holder between the check and the operation. Assert `ErrAccountNotDeletable` and an untouched state.
|
||||
13. 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.
|
||||
14. Mail failure with the log captured. Assert nil error, the user gone, and a log line naming the failed send.
|
||||
15. Order of checks. A wrong password plus a last-Owner caller returns `ErrWrongPassword`, not last_admin (no role probing without the password).
|
||||
|
||||
## HTTP checks
|
||||
|
||||
16. 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.
|
||||
17. 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.
|
||||
18. Last role: 409, body `last_admin`, header `X-Last-Role` equal to `Owner` or `Admin`.
|
||||
19. `account_not_deletable` gives 409 with that body.
|
||||
20. Rate limit. Make 5 attempts (mix wrong-password and blocked), then assert the sixth is 429 with `Retry-After` and that no state changed.
|
||||
21. Auth gates: 401, API key 403 and unverified 403, with exact bodies.
|
||||
22. 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.
|
||||
23. 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`)
|
||||
|
||||
24. 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`.
|
||||
@@ -0,0 +1,135 @@
|
||||
Feature: Download a copy of my personal data as a ZIP of JSON files
|
||||
|
||||
As a verified RenewCMS user
|
||||
I want one click on Profile to download the personal data RenewCMS holds about me
|
||||
So that I can exercise my right of access and portability (GDPR art. 15 and 20) without asking an admin
|
||||
|
||||
Background:
|
||||
Given the design of record is docs/design/screens/account.md "Your data", direct flow only, and the
|
||||
board "RenewCMS Screens Account" group "Your data"
|
||||
And the emailed-link variant, export-ready.html and /account/export/ are out of scope
|
||||
And GET /v1/user/me/export is added to NewUserRouter behind RequireSession, Verifier, AuditContext,
|
||||
RequireSignedIn, Authenticator, RequireCurrentToken and RequireVerifiedUser, so an API key gets
|
||||
403 "This action needs a real session." and no cookie gets 401
|
||||
And the route allows one successful export per user per 15 minutes; a second attempt answers 429
|
||||
"Too many requests. Please try again later." with a Retry-After header in seconds; an export that
|
||||
fails with a 500 does not consume the slot
|
||||
And the success response is 200, Content-Type application/zip, Cache-Control no-store and
|
||||
Content-Disposition attachment; filename="renewcms-export-<username>-<YYYY-MM-DD>.zip", with the
|
||||
username reduced to [a-z0-9-] and the date in UTC from the use case's clock
|
||||
And the ZIP holds exactly four UTF-8 JSON files, snake_case fields, pretty-printed:
|
||||
| file | content |
|
||||
| profile.json | id, username, display_name, email, pending_email, role {id, name}, is_verified, created_at, updated_at, author (the author profile or null), api_keys [{id, name, prefix, created_at, last_used_at, revoked_at}] |
|
||||
| authored-content.json | articles [{id, title, body, slug, site_id, is_online, created_at, updated_at, authors}] and entries [{id, content_type, data, created_at, updated_at}] created by the user or credited to the user's author |
|
||||
| uploaded-media.json | images [{id, filename, mime, size, hash, uploaded_at}] uploaded by the user: references only, no bytes |
|
||||
| account-activity.json | events recorded with the user as actor: id, created_at, action, category, target, outcome, security, source, ip, user_agent, params |
|
||||
And the ZIP never contains the password hash, any verification, reset or email-change code, the token
|
||||
version, an API-key hash, or other people's data
|
||||
And soft-deleted articles and images are excluded
|
||||
And a use case (ExportUserData) reads through domain repositories (user, role, author, apikey, article,
|
||||
entry, image, audit) and writes the archive with the stdlib; the handler streams the result; the
|
||||
archive is built in memory first so a failure never sends a half file
|
||||
And one audit event user.export_data is recorded on success (actor and target the user, category users)
|
||||
And the layering rule holds and no comment is added anywhere
|
||||
|
||||
Rule: The endpoint
|
||||
|
||||
Background:
|
||||
Given the user "marta" (id 7, verified) with 2 articles, 1 entry, 3 uploaded images and 5 audit events as actor
|
||||
|
||||
Scenario: A first export returns the archive
|
||||
When marta sends GET /v1/user/me/export on 2026-10-29
|
||||
Then the response is 200 with Content-Disposition attachment; filename="renewcms-export-marta-2026-10-29.zip"
|
||||
And the ZIP has exactly the four files above
|
||||
And profile.json has username "marta", email, role name and no key named password, code or token_version
|
||||
And authored-content.json has 2 articles and 1 entry
|
||||
And uploaded-media.json has 3 references and no file bytes
|
||||
And account-activity.json has the 5 events plus nothing recorded for anonymous failed logins
|
||||
And one audit event user.export_data is recorded
|
||||
|
||||
Scenario: A second export within 15 minutes is refused
|
||||
Given marta exported 3 minutes ago
|
||||
When she sends GET /v1/user/me/export
|
||||
Then the response is 429 "Too many requests. Please try again later." with a numeric Retry-After of about 720
|
||||
And no archive is built and no audit event is recorded
|
||||
|
||||
Scenario: The slot is per user and expires
|
||||
Given marta exported 16 minutes ago, and ines exported 1 minute ago
|
||||
Then marta's request succeeds and ines's request answers 429
|
||||
|
||||
Scenario: A failed export does not consume the slot
|
||||
Given a repository fails while marta's data is being read
|
||||
When she sends GET /v1/user/me/export
|
||||
Then the response is 500 with a plain text body, no partial archive is sent and no audit event is recorded
|
||||
When the failure is gone and she sends the request again
|
||||
Then the response is 200
|
||||
|
||||
Scenario Outline: Only a real verified session may export
|
||||
When <caller> sends GET /v1/user/me/export
|
||||
Then the response is <status> with the body "<text>"
|
||||
|
||||
Examples:
|
||||
| caller | status | text |
|
||||
| nobody (no cookie) | 401 | You must be logged in to access this resource. |
|
||||
| an API key bearer | 403 | This action needs a real session. |
|
||||
| an unverified user | 403 | You must verify your account to access this resource. |
|
||||
|
||||
Scenario: Someone else's data is never included
|
||||
Given ines has articles, entries, images and events
|
||||
When marta exports
|
||||
Then no record of ines appears in any file, including events where ines is the target of marta's action
|
||||
|
||||
Scenario: A user with a linked author profile
|
||||
Given marta has an author profile credited on article 4 which marta did not create
|
||||
When she exports
|
||||
Then profile.json has the author profile and authored-content.json includes article 4
|
||||
|
||||
Rule: Your data card (SPA)
|
||||
|
||||
Background:
|
||||
Given the Your data card follows the Account and Password cards, with the title "Your data", the text
|
||||
"Download a copy of the personal data RenewCMS holds about you, as JSON files in a ZIP." and the
|
||||
four content rows Profile, Authored content, Uploaded media, Account activity with the design's
|
||||
one-line descriptions
|
||||
And the download is a fetch of GET /v1/user/me/export read as a blob and saved with the filename
|
||||
from Content-Disposition
|
||||
And every new SPA string of the card (title, text, content rows, button, states, toasts and errors) 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 ZIP file names and JSON keys are not translated
|
||||
And every state is a Vitest spec and a `task visual:check -- account` row, named as in the board
|
||||
|
||||
Scenario Outline: States
|
||||
Given the user is on /profile
|
||||
When <trigger>
|
||||
Then the state "<state>" shows <copy>
|
||||
|
||||
Examples:
|
||||
| state | trigger | copy |
|
||||
| Direct (recommended): default | the page loads | "Download my data", "Ready in a few seconds." |
|
||||
| Direct: preparing | "Download my data" is activated, GET unresolved | "Preparing download…", button disabled with a spinner |
|
||||
| Direct: started | the GET answers 200 with the attachment | the browser download starts, then a toast "Download started" / "renewcms-export-marta-2026-10-29.zip is in your downloads."; the card returns to the default |
|
||||
| Direct: rate limited (429) | the GET answers 429 with Retry-After | "You can export once every 15 minutes", "Your last export was at 09:41. Try again in 12 minutes.", "Available in 12:00" |
|
||||
| Direct: error | the GET answers 500 or fails | "Couldn't prepare your data", "Nothing was changed. Try again in a moment.", button enabled to retry |
|
||||
|
||||
Scenario: The rate limited state is clock-sensitive and is skipped
|
||||
Then web/visual/states.ts marks "Direct: rate limited (429)" with skip 'clock-sensitive', no visual
|
||||
check exists for it, and no Vitest spec freezes the system date; the Vitest spec asserts only the
|
||||
copy driven by a mocked Retry-After header
|
||||
|
||||
Scenario: The async states are out of scope and carry exception entries
|
||||
Then the board states "Alternative, emailed link: default", "Alternative: requested", "Alternative: already in progress"
|
||||
and "Alternative: error" are not built in the SPA
|
||||
And web/visual/states.ts lists each of them with an exception entry so `task visual:check -- account`
|
||||
does not fail on a missing setup
|
||||
And each exception needs the user's explicit approval recorded as approvedBy
|
||||
|
||||
Rule: Documentation and privacy
|
||||
|
||||
Scenario: The docs describe the feature
|
||||
Then docs/api.md documents GET /v1/user/me/export with the headers, the four files and the 429
|
||||
And docs/privacy.md replaces "there is no self-service export yet" with the Profile download, lists
|
||||
the four files, notes that anonymous failed-login events and soft-deleted rows are not included,
|
||||
and removes the export half of the "No self-service export or email change" gap
|
||||
@@ -0,0 +1,72 @@
|
||||
# QA procedure: account_export_data
|
||||
|
||||
Package: `test` (black-box), extending `test/user_test.go` (`mockUserRepo`), the existing article, entry, image, author, api-key, role and audit fakes (reuse; do not duplicate type names), and `stubHasher`.
|
||||
|
||||
## Test infrastructure additions needed first
|
||||
|
||||
1. Fakes: add the list-by-creator and list-by-uploader methods to the article, entry and image fakes, and an actor-filtered list to the audit fake (honouring `audit.Filter.Actor`).
|
||||
2. A `zipEntries(t, body []byte) map[string][]byte` helper that reads the archive with `archive/zip` and fails the test if there are directory entries or duplicate names.
|
||||
3. A fixed clock for the use case (`func() time.Time`) so the filename date is deterministic.
|
||||
|
||||
## Setup shared by every case
|
||||
|
||||
4. Call `validator.InitValidator()` once.
|
||||
5. Build `useCases.NewExportUserDataUseCase(...)` over the fakes. Mount `GET /v1/user/me/export` on a fresh `chi.NewRouter()` with the session chain of `NewUserRouter` and the per-user success-only limiter (one per 15 minutes). The limiter takes a clock, or the tests drive the window with two `httptest` calls and a short injected window for the expiry case.
|
||||
6. Seed marta (id 7), ines (id 8), articles, entries, images, audit events and an author per the feature. No live server, real SMTP or real SQLite.
|
||||
|
||||
## Use-case checks
|
||||
|
||||
7. Run the use case for marta. Assert:
|
||||
- Exactly four entries with the expected names.
|
||||
- Each parses as JSON, with the field names of the feature.
|
||||
- `profile.json` has no key containing `password`, `code`, `token` or `hash` (walk the decoded JSON recursively).
|
||||
- The API-key rows have neither the hashed key nor the raw key.
|
||||
8. Scope. Assert that ines's rows never appear. Assert article 4 (credited to marta's author, not created by her) is included. Assert that soft-deleted rows are excluded and that anonymous failed-login events are excluded.
|
||||
9. Empty user. Assert four files, each with empty arrays (never null) and `author` null.
|
||||
10. A failing fake repository gives a non-nil error and no archive bytes.
|
||||
11. The audit event `user.export_data` is recorded only on success.
|
||||
|
||||
## HTTP checks
|
||||
|
||||
12. Success. Assert the status, `Content-Type: application/zip`, `Cache-Control: no-store`, and `Content-Disposition` equal to `attachment; filename="renewcms-export-marta-2026-10-29.zip"`. Also assert the archive opens.
|
||||
13. A username with unusual characters gives a sanitised filename.
|
||||
14. Rate limit. A second call within 15 minutes gives 429 with a numeric `Retry-After` (<= 900 and > 0) and the exact body "Too many requests. Please try again later.". Another user is unaffected. A call after the window gives 200.
|
||||
15. A 500 does not consume the slot (fail the fake once, then succeed).
|
||||
16. Auth gates: 401, API key 403 and unverified 403, with exact bodies.
|
||||
|
||||
## 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 (filename, minutes, countdown).
|
||||
- French render: for each state with `setLocale('fr')`, assert the French text is shown and no English string remains; switching `en`, `fr`, `en` restores the English copy.
|
||||
- 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 card title and text, the four content rows (title and description), "Download my data", "Ready in a few seconds.", "Preparing download…", the "Download started" toast pair, the rate-limit title, text and "Available in {time}", and the error title and text.
|
||||
|
||||
| State | Mocked /v1 |
|
||||
|---|---|
|
||||
| Direct (recommended): default | none |
|
||||
| Direct: preparing | `GET /user/me/export` pending |
|
||||
| Direct: started | 200, `application/zip`, the Content-Disposition header; assert the toast text and that a download was triggered with that filename |
|
||||
| Direct: rate limited (429) | 429 with Retry-After 720; assert the copy and the "Available in 12:00" label, no date mocking |
|
||||
| Direct: error | 500 and a rejected fetch |
|
||||
|
||||
- Also assert that a second click while preparing sends no second request.
|
||||
- Also assert that focus returns to the button after an error.
|
||||
|
||||
## Visual
|
||||
|
||||
`task visual:check -- account`. Board data to reuse: username marta, the filename `renewcms-export-marta-2026-10-29.zip` in the toast, and the Retry-After of 720.
|
||||
- Checked states: "Direct (recommended): default", "Direct: preparing", "Direct: started", "Direct: error".
|
||||
- `skip: clock-sensitive`: "Direct: rate limited (429)".
|
||||
- Async states ("Alternative, emailed link: default", "Alternative: requested", "Alternative: already in progress", "Alternative: error"): the user accepted the recommendation to skip them in `web/visual/states.ts` through `exception` entries. The `approvedBy` field of each exception must still be recorded as the user at coder time; the coder must not invent an approver, and the entries are not valid until it is set.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- Open a real downloaded ZIP and read all four files.
|
||||
- Download in Firefox and Chromium and check the saved filename.
|
||||
- Large account: time a user with thousands of events.
|
||||
- The 15-minute window against a real clock and a restart.
|
||||
- Keyboard: the button and the toast announce "Download started".
|
||||
Reference in new issue
Block a user