Files
RenewCMS/features/request_password_reset.qa.md
T

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

  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

  1. Call validator.InitValidator() once.
  2. 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.
  3. 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".
  4. 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)

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

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