135 lines
9.9 KiB
Markdown
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.
|