# 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:" + ` 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.