Files
RenewCMS/features/account_change_email.feature
T

293 lines
22 KiB
Gherkin

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