Files
RenewCMS/features/reset_password.qa.md
T

7.6 KiB

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

  1. Call validator.InitValidator() once.
  2. Use-case-level checks build useCases.NewResetPasswordUseCase(repo, &stubHasher{}) where repo is a mockUserRepo pre-populated directly via repo.users[id] = user.User{...}.
  3. 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.
  4. 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)

  1. 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".
  2. 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.
  3. 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.
  4. 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.
  5. 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)

  1. 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{}.
  2. 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).
  3. 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).
  4. 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.
  5. 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.
  6. 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.
  7. 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.