# QA procedure: reset_password Package: `test` (black-box), extending `test/user_test.go` (`mockUserRepo`, `stubHasher`). ## Test infrastructure additions needed first 1. Add a `ResetPassword(userId uint32, hashedPassword string) (user.User, error)` method to `mockUserRepo`, mirroring the `ClearVerificationCode`/`UpdatePasswordResetCode` pattern: honor `m.fail.trigger("ResetPassword")`, look the user up (return `user.ErrUserNotFound` if absent), set `Password = hashedPassword`, `PasswordResetCode = ""` and `PasswordResetExpiration = time.Time{}`, store it back, return it. Track a call counter and the last `(userId, hashedPassword)` pair (e.g. `resetPasswordCalls`, `lastResetPasswordUserId`, `lastResetPasswordHash`), the same shape as the existing `clearVerificationCodeCalls` fields, so scenarios can assert it was or wasn't invoked and with what arguments. 2. `stubHasher.Compare(hashed, value)` returns `nil` only when `hashed == "hashed:"+value` and an error otherwise, and `stubHasher.Hash(value)` returns `"hashed:"+value`, so seed `PasswordResetCode` in fixtures as `"hashed:reset-code-1"` to represent "the bcrypt hash of reset-code-1", and submit `"reset-code-1"` as the matching code. ## Setup shared by every case 3. Call `validator.InitValidator()` once. 4. Use-case-level checks build `useCases.NewResetPasswordUseCase(repo, &stubHasher{})` where `repo` is a `mockUserRepo` pre-populated directly via `repo.users[id] = user.User{...}`. 5. HTTP-level checks wire `mockUserRepo`, `useCases.NewResetPasswordUseCase(repo, &stubHasher{})` and the other existing use cases into `auth.NewHandler(...)` (extended with the new `ResetPasswordUseCase` parameter) with a test `jwtauth.New("HS256", []byte("test-secret"), nil)`. Mount `POST /v1/auth/password-reset/confirm` unprotected on a fresh `chi.NewRouter()` mirroring the public group `NewAuthRouter` already uses for `/register`, `/login`, `/logout` and `/password-reset`. No live server, no real SMTP. 6. The expected response body for every "generic refusal" assertion is the exact plain-text value of `apperr.PasswordResetFailedErrorMessage`, compared byte-for-byte via `http.Error`'s output (trailing newline included, the same way `test/account_verification_http_test.go` compares `apperr.VerificationFailedErrorMessage`). ## Use-case-level checks (no HTTP) 7. Repo has user id 3, email "alice@example.com", `PasswordResetCode: "hashed:reset-code-1"`, `PasswordResetExpiration: time.Now().Add(time.Hour)`. Call `ResetPassword("alice@example.com", "reset-code-1", "newpassword1")`. Assert the return value is `nil`. Assert the fake's `ResetPassword` method was invoked exactly once with `userId == 3` and a hash equal to `"hashed:newpassword1"`. 8. Same seed. Call `ResetPassword("alice@example.com", "wrong-code", "newpassword1")`. Assert `errors.Is(err, user.ErrInvalidPasswordResetCode)`. Assert the fake's `ResetPassword` method was never invoked. 9. Same seed but `PasswordResetExpiration: time.Now().Add(-time.Hour)`. Call `ResetPassword("alice@example.com", "reset-code-1", "newpassword1")`. Assert `errors.Is(err, user.ErrPasswordResetCodeExpired)`. Assert the fake's `ResetPassword` method was never invoked. 10. On an empty repo, call `ResetPassword("nobody@example.com", "any-code", "newpassword1")`. Assert `errors.Is(err, user.ErrInvalidPasswordResetCode)`. Assert the fake's `ResetPassword` method was never invoked. 11. Repo has user id 3, email "alice@example.com", `PasswordResetCode: ""`. Call `ResetPassword("alice@example.com", "anything", "newpassword1")`. Assert `errors.Is(err, user.ErrInvalidPasswordResetCode)`. Assert the fake's `ResetPassword` method was never invoked. 11b. Repo has user id 3, email "alice@example.com". Build `UpdateUserUseCase` over it with a hasher that counts `Hash` calls. Call `UpdatePasswordResetCode(3, "", time.Time{})`. Assert `Hash` was never invoked and the stored `PasswordResetCode` for id 3 is `""`. ## HTTP-level checks (fakes, per the shared setup above) 12. Seed `mockUserRepo` with user "alice", email "alice@example.com", `PasswordResetCode: "hashed:reset-code-1"`, `PasswordResetExpiration: time.Now().Add(time.Hour)`. `POST /v1/auth/password-reset/confirm` with `{"email":"alice@example.com","code":"reset-code-1","password":"newpassword1"}` → assert status `200`, body exactly `{"message":"Your password has been reset. You can now log in."}`, and no `Set-Cookie: jwt=...` header present. Then assert the repo's record for alice has `Password == "hashed:newpassword1"`, `PasswordResetCode == ""` and `PasswordResetExpiration` equal to `time.Time{}`. 13. Immediately repeat step 12's exact request on the same router/repo (the code was already cleared) → assert status `403`, body equal to `apperr.PasswordResetFailedErrorMessage`, and the repo's record for alice still has `Password == "hashed:newpassword1"` (the value set by the first request, unchanged by the second). 14. Seed a fresh `mockUserRepo`/router with user "alice" as in step 12. `POST /v1/auth/password-reset/confirm` with `{"email":"alice@example.com","code":"wrong-code","password":"newpassword1"}` → assert status `403`, body equal to `apperr.PasswordResetFailedErrorMessage`, and the repo's record for alice is unchanged (`PasswordResetCode` still `"hashed:reset-code-1"`, `Password` still its original seeded value). 15. Seed with `PasswordResetExpiration: time.Now().Add(-time.Hour)`. `POST /v1/auth/password-reset/confirm` with the correct code → assert status `403`, body equal to `apperr.PasswordResetFailedErrorMessage`, and the record is unchanged. 16. On an empty `mockUserRepo`, `POST /v1/auth/password-reset/confirm` with `{"email":"nobody@example.com","code":"any-code","password":"newpassword1"}` → assert status `403` and body byte-for-byte identical to step 14's body. 17. Seed a user "alice" with `PasswordResetCode: ""`. `POST /v1/auth/password-reset/confirm` with `{"email":"alice@example.com","code":"anything","password":"newpassword1"}` → assert status `403`, body equal to `apperr.PasswordResetFailedErrorMessage`. 18. Table-driven over the five malformed/too-short bodies from the feature's Examples table, each against a `mockUserRepo` seeded with "alice" (`PasswordResetCode: "hashed:reset-code-1"`, unexpired) so a leak would be visible if it occurred: `{}`, `{"email":"not-an-email",...}`, `{"email":"alice@example.com","code":"",...}`, a `password` shorter than 8 characters, and the non-JSON body `not-json` → assert status `400` for all five, and assert the repo's record for alice is unchanged in every case (in particular `ResetPassword`'s call counter stays at 0). ## Manual verification (not exercised by the Go test) - The SPA page behind `/register/reset/validate?c=...&email=...` actually reading the query parameters, prompting for a new password, and calling `POST /v1/auth/password-reset/confirm` is out of scope for this slice's automated test and should be checked by hand against a running dev build. - The response-time difference between an unknown email (short-circuits at `GetByEmail`) and a known email with a wrong code (also compares a hash) is a known, unaddressed timing side channel; this slice only equalizes the HTTP status and body, not response latency, matching the same caveat already recorded in `features/request_password_reset.qa.md`. - This slice does not log the user in on a successful reset; verify by hand, if desired, that the SPA redirects to the login page rather than assuming an authenticated session after a successful reset.