Files
RenewCMS/features/request_password_reset.qa.md

135 lines
9.9 KiB
Markdown

# QA procedure: request_password_reset
Package: `test` (black-box), extending `test/user_test.go` (`mockUserRepo`, `stubHasher`) and
`test/mail_test.go` (`mockMailRepo`, `recordedMail`, `failSwitch`).
## Test infrastructure additions needed first
1. Update `mockUserRepo.UpdatePasswordResetCode` to the new three-argument signature
`UpdatePasswordResetCode(userId uint32, code string, expiration time.Time) (user.User, error)`:
honor `m.fail.trigger("UpdatePasswordResetCode")`, look the user up (return `user.ErrUserNotFound`
if absent), set both `PasswordResetCode = code` and `PasswordResetExpiration = expiration`, store
it back, return it. Track a call counter and the last `(userId, code, expiration)` triple (mirroring
the existing `clearVerificationCodeCalls`/`clearVerificationCodeUserIds` fields) so scenarios can
assert it was or wasn't invoked and with what arguments.
2. Update every existing caller of the old two-argument signature to pass an expiration: this is a
breaking, cross-cutting change reaching `internal/infrastructure/useCases/UpdateUser.go`,
`internal/infrastructure/persistence/repositories/userRepository.go`,
`internal/domain/user/userRepositoryInterface.go`, `test/hasher_test.go` (the
`UpdatePasswordResetCode` case in `TestUpdateUserUseCaseHashesUpdatedValues`),
`test/write_not_found_errors_test.go`, and `test/use_case_baseline_test.go`. None of these are new
to this slice; note them here so the coder updates all call sites together instead of missing one.
3. Add `PasswordResetExpiration time.Time` to `user.User`, `models.User` and both mapper functions,
the same shape as `VerificationExpiration` (a plain, non-nullable column with no explicit `gorm`
tag beyond the struct field).
4. `stubHasher.Compare(hashed, value)` returns `nil` only when `hashed == "hashed:"+value`, so a
scenario asserting "the stored code no longer matches the first raw code but matches the second"
can call `stubHasher{}.Compare(repo.users[id].PasswordResetCode, "code-1")` and expect an error,
then `.Compare(..., secondRawCode)` and expect `nil`.
## Capturing logged failures
8a. `RequestPasswordReset` now logs a hash, store or mail-send failure via the stdlib `log`
package instead of only swallowing it. Any scenario that asserts on this redirects the
global logger's output for the duration of the call: `var buf bytes.Buffer`,
`log.SetOutput(&buf)`, then `t.Cleanup(func() { log.SetOutput(os.Stderr) })` before invoking
`RequestPasswordReset`. After the call, assert `buf.String()` is non-empty and contains a
substring identifying which failure occurred (e.g. `"hash"`, `"UpdatePasswordResetCode"` /
`"store"`, or `"Send"` / `"mail"`) — the exact wording is an implementation detail, only that
a line was written and it's distinguishable per failure kind.
## Setup shared by every case
5. Call `validator.InitValidator()` once.
6. Use-case-level checks build
`useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, "https://cms.example.com")`
where `repo` is a `mockUserRepo` and `mailRepo` is a `mockMailRepo`. Use `mailRepo.fail.failNext("Send")`
to force the next `Send` call to fail.
7. HTTP-level checks wire `mockUserRepo`, `mockMailRepo`,
`useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, publicURL)` and the other
existing use cases into `auth.NewHandler(...)` (extended with the new
`RequestPasswordResetUseCase` parameter) with a test `jwtauth.New("HS256", []byte("test-secret"),
nil)`. Mount `POST /v1/auth/password-reset` unprotected on a fresh `chi.NewRouter()` mirroring the
public group `NewAuthRouter` already uses for `/register`, `/login` and `/logout`. No live server,
no real SMTP. Default `publicURL` for cases that don't care about its value is
`"https://cms.example.com"`.
8. The expected response body for every "generic response" assertion is the exact JSON
`{"message":"If an account exists for that email, a password reset link has been sent."}`; decode
both the expected and actual bodies (or compare the raw bytes exactly, matching how `Login` and
`Register`'s success bodies are asserted elsewhere) rather than doing a loose substring check.
## Use-case-level checks (no HTTP)
9. Repo pre-populated with one user, id 3, email "alice@example.com". Call
`RequestPasswordReset("alice@example.com")`. Assert the return value is `nil`. Assert
`UpdatePasswordResetCode` was invoked exactly once with `userId == 3`, a `code` matching
`"hashed:" + <raw code>` and an `expiration` within 5 seconds of `time.Now().Add(time.Hour)`.
Assert `mailRepo.SentEmails` has exactly one entry: `Address == "alice@example.com"`,
`Template == "passwordReset"`, and its `Data` (asserted via a type assertion to
`useCases.MailPasswordResetData`) has `VerificationCode` equal to the same raw code that hashes to
the stored value, and `Host == "https://cms.example.com"`.
10. On an empty repo, call `RequestPasswordReset("nobody@example.com")`. Assert the return value is
`nil`. Assert `UpdatePasswordResetCode`'s call counter is still 0. Assert `mailRepo.SentEmails` is
empty.
11. Repo pre-populated with user id 3, email "alice@example.com". Set `mailRepo.fail.failNext("Send")`.
Using the log-capture setup from step 8a, call `RequestPasswordReset("alice@example.com")`.
Assert the return value is `nil` (not the mail repository's failure). Assert
`UpdatePasswordResetCode` was still invoked exactly once (the store happens before the send).
Assert the captured log buffer is non-empty and its content identifies a mail-send failure.
12. Repo pre-populated with two unverified users, "alice" and "bob" (`IsVerified: false`). Call
`RequestPasswordReset` for alice's email. Assert the return value is `nil` and exactly one email
was sent, showing IsVerified plays no role in the use case's behavior.
12a. Repo pre-populated with user id 3, email "alice@example.com". Build the use case with a
`stubHasher{hashErr: errStubHashFailure}` so `Hash` fails. Using the log-capture setup from
step 8a, call `RequestPasswordReset("alice@example.com")`. Assert the return value is `nil`,
`UpdatePasswordResetCode`'s call counter is 0, `mailRepo.SentEmails` is empty, and the captured
log buffer is non-empty and identifies a hashing failure.
12b. Repo pre-populated with user id 3, email "alice@example.com". Set
`repo.fail.failNext("UpdatePasswordResetCode")`. Using the log-capture setup from step 8a, call
`RequestPasswordReset("alice@example.com")`. Assert the return value is `nil`,
`mailRepo.SentEmails` is empty, and the captured log buffer is non-empty and identifies a
storage failure.
## HTTP-level checks (fakes, per the shared setup above)
13. Seed `mockUserRepo` with user "alice", email "alice@example.com", `IsVerified: true`.
`POST /v1/auth/password-reset` with `{"email":"alice@example.com"}` → assert status `200`, body
exactly matches the generic JSON from step 8, and `mockMailRepo.SentEmails` has exactly one entry
with `Address == "alice@example.com"` and `Template == "passwordReset"`.
14. On an empty `mockUserRepo`, `POST /v1/auth/password-reset` with `{"email":"nobody@example.com"}` →
assert status `200`, body byte-for-byte identical to step 13's body, and
`mockMailRepo.SentEmails` is empty.
15. Build the handler with `publicURL = "https://cms.example.com"`. Seed a user "alice",
email "alice@example.com". `POST /v1/auth/password-reset` with `{"email":"alice@example.com"}` →
assert the recorded mail's `Data.Host` equals `"https://cms.example.com"`.
16. Seed a user "alice", email "alice@example.com". `POST /v1/auth/password-reset` with her email →
assert `mockUserRepo`'s stored record has `PasswordResetExpiration` within 5 seconds of
`time.Now().Add(time.Hour)`.
17. Seed a user "alice", email "alice@example.com". `POST /v1/auth/password-reset` with her email
twice in a row. Assert `mockMailRepo.SentEmails` has exactly two entries and their
`VerificationCode` data values differ. Assert the repo's stored `PasswordResetCode` no longer
matches the first email's raw code via `stubHasher{}.Compare` (expect an error) but does match
the second email's raw code (expect `nil`).
18. Seed a user "alice", email "alice@example.com". Set `mailRepo.fail.failNext("Send")`. Using the
log-capture setup from step 8a, `POST /v1/auth/password-reset` with her email → assert status
`200`, body identical to step 13's body, no `Set-Cookie: jwt=...` header present, and the
captured log buffer is non-empty and identifies a mail-send failure.
19. Seed a user "bob", email "bob@example.com", `IsVerified: false`. `POST /v1/auth/password-reset`
with his email → assert status `200`, body identical to step 13's body, and exactly one email was
sent to "bob@example.com" with template "passwordReset".
20. Table-driven malformed-request cases against a `mockUserRepo` seeded with "alice" so a leak would
be visible if it occurred: `{}` (missing email) and `{"email":"not-an-email"}` → assert status
`400`. A non-JSON body `not-json` → assert status `400`. For all three, assert no email was sent.
## Manual verification (not exercised by the Go test)
- The real SMTP send and the rendered `passwordReset.html` link are never exercised automatically;
verify by hand against a real or sandboxed SMTP server that the link matches `PUBLIC_URL` from the
deployed `.env` and that visiting `/register/reset/validate?c=...&email=...` reaches the SPA.
- This slice does not add the confirmation endpoint that consumes the code, nor any SPA page/route
for it; both are out of scope and unverified here.
- The response-time difference between an existing and a non-existing email (hashing, storing and
emailing only happen when a user is found) is a known, unaddressed timing side channel; this slice
only equalizes the HTTP status and body, not response latency, and no automated timing assertion is
included since the suite has no precedent for timing-sensitive tests.