109 lines
7.6 KiB
Markdown
109 lines
7.6 KiB
Markdown
# 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.
|