8.3 KiB
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
mockUserRepo: add the new repository methods for setting, clearing and confirming a pending change.- Each honours
fail.triggerand returnsuser.ErrUserNotFoundfor an unknown id. - Confirm checks "email not held by another user" and returns
user.ErrEmailUnavailable. - Add call counters, in the style of
clearVerificationCodeCalls.
- Each honours
- Seed fixtures with
stubHasherhashes:EmailChangeCode: "hashed:abc123"stands for the raw codeabc123. The password "limewash2026" is seeded as"hashed:limewash2026". - A reusable helper builds the real
RequestEmailChangeUseCase,ResendEmailChangeUseCase,CancelEmailChangeUseCaseandConfirmEmailChangeUseCaseovermockUserRepo,stubHasher,mockMailRepo, the audit fake andpublicURL "https://cms.example.com".
Setup shared by every case
- Call
validator.InitValidator()once. - Handler checks build the handler from the use cases via its
NewHandler/named constructor. Mount the four routes on a freshchi.NewRouter()that mirrors theNewAuthRoutersession group:middleware.RequireSession,authtoken.Verifier,AuditContext,RequireSignedIn,authtoken.Authenticator,RequireCurrentToken,RequireVerifiedUser. Add the sharedmiddleware.NewUserRateLimiter(5, 15*time.Minute)in front of change and resend only. - Use a test
authtokeninstance and mint jwt cookies carryinguser_idandtoken_version. Seed user 7 "marta" (IsVerified: true). - No live server, real SQLite file or SMTP.
Use-case checks
- Request, valid. Assert:
- nil error.
PendingEmailset, andusers.emailunchanged.- Stored code equals
"hashed:"+raw, and the expiration is within 5 s of now+2h. SentEmailshas exactly two entries.- Entry 1 is
verify-new-emailto the new address; its data (type-assertuseCases.MailVerifyNewEmailData) carries Host, Username and the raw code. - Entry 2 is
email-change-noticeto the old address, with NewEmail in its data. - One audit event
user.request_email_change, success,Securitytrue, with no@in its Params or Changes.
- 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.
- Wrong password gives
- Taken address, matched case-insensitively. Assert the same return value and the same stored
PendingEmailas case 8, only the notice mail sent (none to the other user), and the same audit event shape. - 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. - Resend. Assert:
- A new code and expiry, and the old raw code now fails
stubHasher.Compare. - One
verify-new-emailmail and no notice. ErrNoPendingEmailChangewhen nothing is pending.- Nothing sent for a taken address.
- A new code and expiry, and the old raw code now fails
- Cancel. Assert fields cleared, nil error when nothing is pending, and an audit event only when something was cancelled.
- Confirm, success. Assert:
users.emailis the new address, the pending fields are cleared, andTokenVersionis unchanged.- A second confirm returns
ErrInvalidEmailChangeCode. - The
user.confirm_email_changeevent is recorded.
- Confirm, failures. Assert:
- Expired, wrong, other user's, cancelled and none pending all return
ErrInvalidEmailChangeCode. - Taken returns
ErrEmailUnavailableand clears the pending change.
- Expired, wrong, other user's, cancelled and none pending all return
HTTP checks
- Request, valid and refused. Using the examples table of the feature, assert the status and exact body and that
SentEmailsis empty on refusals. The text for the 400 cases is the validator message; assert it equalsvalidator.Validate.Structoutput. - Taken-address response parity. Compare status, body and headers except Date between case 8 and case 10 using
reflect.DeepEqualon the header map minus Date. - 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.".
- Rate limit. Send 5 valid-or-wrong-password requests mixed with resends, then assert the sixth is 429 with a numeric
Retry-Afterand the body "Too many requests. Please try again later.". Assert DELETE and confirm still answer. - Resend, cancel and confirm statuses and bodies exactly as in the feature (200 JSON, 404
no_pending_email_change, 204 empty, 403invalid_email_change_code, 409email_unavailable). GET /auth/mereturnspending_emailas null, then as the address after a request.
Mailer checks (no SMTP)
- Render
verify-new-emailandemail-change-noticewithMailRepository.Renderand fake data.- Assert the link strings, the subjects, and that no
{{remains. - Assert the HTML escapes a username such as
<b>.
- Assert the link strings, the subjects, and that no
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
enandfr, 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; switchingen,fr,enrestores 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_passwordis 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.tssetups: 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.