7.6 KiB
7.6 KiB
QA procedure: reset_password
Package: test (black-box), extending test/user_test.go (mockUserRepo, stubHasher).
Test infrastructure additions needed first
- Add a
ResetPassword(userId uint32, hashedPassword string) (user.User, error)method tomockUserRepo, mirroring theClearVerificationCode/UpdatePasswordResetCodepattern: honorm.fail.trigger("ResetPassword"), look the user up (returnuser.ErrUserNotFoundif absent), setPassword = hashedPassword,PasswordResetCode = ""andPasswordResetExpiration = 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 existingclearVerificationCodeCallsfields, so scenarios can assert it was or wasn't invoked and with what arguments. stubHasher.Compare(hashed, value)returnsnilonly whenhashed == "hashed:"+valueand an error otherwise, andstubHasher.Hash(value)returns"hashed:"+value, so seedPasswordResetCodein 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
- Call
validator.InitValidator()once. - Use-case-level checks build
useCases.NewResetPasswordUseCase(repo, &stubHasher{})whererepois amockUserRepopre-populated directly viarepo.users[id] = user.User{...}. - HTTP-level checks wire
mockUserRepo,useCases.NewResetPasswordUseCase(repo, &stubHasher{})and the other existing use cases intoauth.NewHandler(...)(extended with the newResetPasswordUseCaseparameter) with a testjwtauth.New("HS256", []byte("test-secret"), nil). MountPOST /v1/auth/password-reset/confirmunprotected on a freshchi.NewRouter()mirroring the public groupNewAuthRouteralready uses for/register,/login,/logoutand/password-reset. No live server, no real SMTP. - The expected response body for every "generic refusal" assertion is the exact plain-text value
of
apperr.PasswordResetFailedErrorMessage, compared byte-for-byte viahttp.Error's output (trailing newline included, the same waytest/account_verification_http_test.gocomparesapperr.VerificationFailedErrorMessage).
Use-case-level checks (no HTTP)
- Repo has user id 3, email "alice@example.com",
PasswordResetCode: "hashed:reset-code-1",PasswordResetExpiration: time.Now().Add(time.Hour). CallResetPassword("alice@example.com", "reset-code-1", "newpassword1"). Assert the return value isnil. Assert the fake'sResetPasswordmethod was invoked exactly once withuserId == 3and a hash equal to"hashed:newpassword1". - Same seed. Call
ResetPassword("alice@example.com", "wrong-code", "newpassword1"). Asserterrors.Is(err, user.ErrInvalidPasswordResetCode). Assert the fake'sResetPasswordmethod was never invoked. - Same seed but
PasswordResetExpiration: time.Now().Add(-time.Hour). CallResetPassword("alice@example.com", "reset-code-1", "newpassword1"). Asserterrors.Is(err, user.ErrPasswordResetCodeExpired). Assert the fake'sResetPasswordmethod was never invoked. - On an empty repo, call
ResetPassword("nobody@example.com", "any-code", "newpassword1"). Asserterrors.Is(err, user.ErrInvalidPasswordResetCode). Assert the fake'sResetPasswordmethod was never invoked. - Repo has user id 3, email "alice@example.com",
PasswordResetCode: "". CallResetPassword("alice@example.com", "anything", "newpassword1"). Asserterrors.Is(err, user.ErrInvalidPasswordResetCode). Assert the fake'sResetPasswordmethod was never invoked. 11b. Repo has user id 3, email "alice@example.com". BuildUpdateUserUseCaseover it with a hasher that countsHashcalls. CallUpdatePasswordResetCode(3, "", time.Time{}). AssertHashwas never invoked and the storedPasswordResetCodefor id 3 is"".
HTTP-level checks (fakes, per the shared setup above)
- Seed
mockUserRepowith user "alice", email "alice@example.com",PasswordResetCode: "hashed:reset-code-1",PasswordResetExpiration: time.Now().Add(time.Hour).POST /v1/auth/password-reset/confirmwith{"email":"alice@example.com","code":"reset-code-1","password":"newpassword1"}→ assert status200, body exactly{"message":"Your password has been reset. You can now log in."}, and noSet-Cookie: jwt=...header present. Then assert the repo's record for alice hasPassword == "hashed:newpassword1",PasswordResetCode == ""andPasswordResetExpirationequal totime.Time{}. - Immediately repeat step 12's exact request on the same router/repo (the code was already
cleared) → assert status
403, body equal toapperr.PasswordResetFailedErrorMessage, and the repo's record for alice still hasPassword == "hashed:newpassword1"(the value set by the first request, unchanged by the second). - Seed a fresh
mockUserRepo/router with user "alice" as in step 12.POST /v1/auth/password-reset/confirmwith{"email":"alice@example.com","code":"wrong-code","password":"newpassword1"}→ assert status403, body equal toapperr.PasswordResetFailedErrorMessage, and the repo's record for alice is unchanged (PasswordResetCodestill"hashed:reset-code-1",Passwordstill its original seeded value). - Seed with
PasswordResetExpiration: time.Now().Add(-time.Hour).POST /v1/auth/password-reset/confirmwith the correct code → assert status403, body equal toapperr.PasswordResetFailedErrorMessage, and the record is unchanged. - On an empty
mockUserRepo,POST /v1/auth/password-reset/confirmwith{"email":"nobody@example.com","code":"any-code","password":"newpassword1"}→ assert status403and body byte-for-byte identical to step 14's body. - Seed a user "alice" with
PasswordResetCode: "".POST /v1/auth/password-reset/confirmwith{"email":"alice@example.com","code":"anything","password":"newpassword1"}→ assert status403, body equal toapperr.PasswordResetFailedErrorMessage. - Table-driven over the five malformed/too-short bodies from the feature's Examples table, each
against a
mockUserReposeeded 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":"",...}, apasswordshorter than 8 characters, and the non-JSON bodynot-json→ assert status400for all five, and assert the repo's record for alice is unchanged in every case (in particularResetPassword'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 callingPOST /v1/auth/password-reset/confirmis 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 infeatures/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.