refac: reamaining auth surface rewrite, T0
This commit is contained in:
1 parent
6d510d2805
commit
aa865af2e9
50 files changed
+3723
-149
No files matched your search
@@ -0,0 +1,111 @@
|
||||
# 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`.
|
||||
|
||||
## 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")`.
|
||||
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).
|
||||
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.
|
||||
|
||||
## 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")`.
|
||||
`POST /v1/auth/password-reset` with her email → assert status `200`, body identical to step 13's
|
||||
body, and no `Set-Cookie: jwt=...` header present.
|
||||
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.
|
||||
Reference in new issue
Block a user