9.9 KiB
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
- Update
mockUserRepo.UpdatePasswordResetCodeto the new three-argument signatureUpdatePasswordResetCode(userId uint32, code string, expiration time.Time) (user.User, error): honorm.fail.trigger("UpdatePasswordResetCode"), look the user up (returnuser.ErrUserNotFoundif absent), set bothPasswordResetCode = codeandPasswordResetExpiration = expiration, store it back, return it. Track a call counter and the last(userId, code, expiration)triple (mirroring the existingclearVerificationCodeCalls/clearVerificationCodeUserIdsfields) so scenarios can assert it was or wasn't invoked and with what arguments. - 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(theUpdatePasswordResetCodecase inTestUpdateUserUseCaseHashesUpdatedValues),test/write_not_found_errors_test.go, andtest/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. - Add
PasswordResetExpiration time.Timetouser.User,models.Userand both mapper functions, the same shape asVerificationExpiration(a plain, non-nullable column with no explicitgormtag beyond the struct field). stubHasher.Compare(hashed, value)returnsnilonly whenhashed == "hashed:"+value, so a scenario asserting "the stored code no longer matches the first raw code but matches the second" can callstubHasher{}.Compare(repo.users[id].PasswordResetCode, "code-1")and expect an error, then.Compare(..., secondRawCode)and expectnil.
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
- Call
validator.InitValidator()once. - Use-case-level checks build
useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, "https://cms.example.com")whererepois amockUserRepoandmailRepois amockMailRepo. UsemailRepo.fail.failNext("Send")to force the nextSendcall to fail. - HTTP-level checks wire
mockUserRepo,mockMailRepo,useCases.NewRequestPasswordResetUseCase(repo, &stubHasher{}, mailRepo, publicURL)and the other existing use cases intoauth.NewHandler(...)(extended with the newRequestPasswordResetUseCaseparameter) with a testjwtauth.New("HS256", []byte("test-secret"), nil). MountPOST /v1/auth/password-resetunprotected on a freshchi.NewRouter()mirroring the public groupNewAuthRouteralready uses for/register,/loginand/logout. No live server, no real SMTP. DefaultpublicURLfor cases that don't care about its value is"https://cms.example.com". - 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 howLoginandRegister's success bodies are asserted elsewhere) rather than doing a loose substring check.
Use-case-level checks (no HTTP)
- Repo pre-populated with one user, id 3, email "alice@example.com". Call
RequestPasswordReset("alice@example.com"). Assert the return value isnil. AssertUpdatePasswordResetCodewas invoked exactly once withuserId == 3, acodematching"hashed:" + <raw code>and anexpirationwithin 5 seconds oftime.Now().Add(time.Hour). AssertmailRepo.SentEmailshas exactly one entry:Address == "alice@example.com",Template == "passwordReset", and itsData(asserted via a type assertion touseCases.MailPasswordResetData) hasVerificationCodeequal to the same raw code that hashes to the stored value, andHost == "https://cms.example.com". - On an empty repo, call
RequestPasswordReset("nobody@example.com"). Assert the return value isnil. AssertUpdatePasswordResetCode's call counter is still 0. AssertmailRepo.SentEmailsis empty. - Repo pre-populated with user id 3, email "alice@example.com". Set
mailRepo.fail.failNext("Send"). Using the log-capture setup from step 8a, callRequestPasswordReset("alice@example.com"). Assert the return value isnil(not the mail repository's failure). AssertUpdatePasswordResetCodewas 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. - Repo pre-populated with two unverified users, "alice" and "bob" (
IsVerified: false). CallRequestPasswordResetfor alice's email. Assert the return value isniland 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 astubHasher{hashErr: errStubHashFailure}soHashfails. Using the log-capture setup from step 8a, callRequestPasswordReset("alice@example.com"). Assert the return value isnil,UpdatePasswordResetCode's call counter is 0,mailRepo.SentEmailsis 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". Setrepo.fail.failNext("UpdatePasswordResetCode"). Using the log-capture setup from step 8a, callRequestPasswordReset("alice@example.com"). Assert the return value isnil,mailRepo.SentEmailsis empty, and the captured log buffer is non-empty and identifies a storage failure.
HTTP-level checks (fakes, per the shared setup above)
- Seed
mockUserRepowith user "alice", email "alice@example.com",IsVerified: true.POST /v1/auth/password-resetwith{"email":"alice@example.com"}→ assert status200, body exactly matches the generic JSON from step 8, andmockMailRepo.SentEmailshas exactly one entry withAddress == "alice@example.com"andTemplate == "passwordReset". - On an empty
mockUserRepo,POST /v1/auth/password-resetwith{"email":"nobody@example.com"}→ assert status200, body byte-for-byte identical to step 13's body, andmockMailRepo.SentEmailsis empty. - Build the handler with
publicURL = "https://cms.example.com". Seed a user "alice", email "alice@example.com".POST /v1/auth/password-resetwith{"email":"alice@example.com"}→ assert the recorded mail'sData.Hostequals"https://cms.example.com". - Seed a user "alice", email "alice@example.com".
POST /v1/auth/password-resetwith her email → assertmockUserRepo's stored record hasPasswordResetExpirationwithin 5 seconds oftime.Now().Add(time.Hour). - Seed a user "alice", email "alice@example.com".
POST /v1/auth/password-resetwith her email twice in a row. AssertmockMailRepo.SentEmailshas exactly two entries and theirVerificationCodedata values differ. Assert the repo's storedPasswordResetCodeno longer matches the first email's raw code viastubHasher{}.Compare(expect an error) but does match the second email's raw code (expectnil). - 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-resetwith her email → assert status200, body identical to step 13's body, noSet-Cookie: jwt=...header present, and the captured log buffer is non-empty and identifies a mail-send failure. - Seed a user "bob", email "bob@example.com",
IsVerified: false.POST /v1/auth/password-resetwith his email → assert status200, body identical to step 13's body, and exactly one email was sent to "bob@example.com" with template "passwordReset". - Table-driven malformed-request cases against a
mockUserReposeeded with "alice" so a leak would be visible if it occurred:{}(missing email) and{"email":"not-an-email"}→ assert status400. A non-JSON bodynot-json→ assert status400. For all three, assert no email was sent.
Manual verification (not exercised by the Go test)
- The real SMTP send and the rendered
passwordReset.htmllink are never exercised automatically; verify by hand against a real or sandboxed SMTP server that the link matchesPUBLIC_URLfrom the deployed.envand 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.