Feature: Anyone can request a password reset, and the response never reveals whether the email exists As the maintainer of RenewCMS I want POST /v1/auth/password-reset to always answer with the same status and body regardless of whether the submitted email belongs to an account, and, when it does, to email that account a reset code that expires one hour after it was generated So that the endpoint cannot be used to discover which email addresses are registered, and a legitimate reset link always uses the server's own configured public URL Background: Given POST /v1/auth/password-reset is public and mounted alongside /register, /login and /logout in NewAuthRouter, outside the jwt-protected group And the request body is decoded into a RequestPasswordResetCredentials struct with one field, Email, validated required,email by validator.Validate.Struct before anything else And a decode failure or a validation failure (missing or malformed email) is answered exactly like every other auth route: apperr.BodyErrorMessage with 400 for a decode failure, or the validator's error text with 400 for a validation failure; only the "does this email exist" question is hidden, not the shape of the request itself And this slice adds a new use case, RequestPasswordResetUseCase (internal/infrastructure/useCases/RequestPasswordReset.go), injected into auth.Handler as RequestPasswordResetUseCase; its constructor takes user.Repository, hash.Hasher, mail.Repository and the configured public URL string, the same shape as CreateUserUseCase, with the public URL coming from config.Settings.PublicURL through api/dependecyInjection.go And RequestPasswordReset(email string) error looks the user up with user.Repository.GetByEmail; when no user has that email, or GetByEmail returns any other error, it does nothing further and returns nil And when a user is found, RequestPasswordReset generates a random raw code with uuid.NewString() (the same generator CreateUser uses for the verification code), hashes it with hash.Hasher.Hash, and stores the hash together with a fresh expiration of time.Now().Add(1 * time.Hour) by calling user.Repository.UpdatePasswordResetCode(userId uint32, code string, expiration time.Time) (user.User, error) — this slice adds the expiration parameter to that existing repository method and to UpdateUserUseCase.UpdatePasswordResetCode, which gains the same parameter and passes it straight through, and adds a new PasswordResetExpiration time.Time field to user.User, to internal/infrastructure/persistence/models.User (a plain, non-nullable column, the same shape as VerificationExpiration) and to the UserToDomain/UserToModel mappers; no new entry is needed in api/dependecyInjection.go's AutoMigrate call because models.User is already migrated there And storing a new code and expiration always overwrites whatever was stored before, so only the most recently requested code and expiration are ever valid; a user who requests a reset twice can only use the second code, and the first one no longer matches once overwritten And after storing succeeds, RequestPasswordReset calls mail.Repository.Send with the user's email, template "password-reset" (the already-existing internal/infrastructure/mailer/templates/password-reset.html, which renders a link from "{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}"), and a MailPasswordResetData value whose Host is the injected public URL, whose VerificationCode is the raw (unhashed) code, and whose Email is the user's email And RequestPasswordReset never returns a non-nil error to its caller: a failure to hash the code, to store it, or to send the email is swallowed the same way a not-found email is, so the handler can never tell those cases apart either — sending the request always looks the same to the caller regardless of whether the email exists or whether the mail send happened to succeed And a failure to hash the code, to store it, or to send the email is now also written to the process log via the stdlib log package (the same convention internal/infrastructure/mailer/mailRepository.go already uses for its own send failures); this is in addition to, not instead of, always returning nil to the caller — the HTTP response is unaffected by whether a failure occurred or was logged And a reset applies the same way regardless of the user's IsVerified status: an unverified user can request and receive a password reset email exactly like a verified one, since resetting a password is unrelated to email verification And auth.Handler.RequestPasswordReset calls RequestPasswordResetUseCase.RequestPasswordReset and then always responds 200 with the same body, {"message": "If an account exists for that email, a password reset link has been sent."}, never sets or clears the jwt cookie, and never branches on the use case's return value And this slice does not add the confirmation endpoint that consumes the code (the page behind "/register/reset/validate?c=...&email=..." and the POST call it would make); only the request step is in scope Scenario: Requesting a reset for an existing email sends the reset email and returns the generic message Given the user repository contains a user "alice" with email "alice@example.com" and IsVerified true When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"} Then the response status is 200 And the response body is {"message": "If an account exists for that email, a password reset link has been sent."} And exactly one email was sent to "alice@example.com" using template "password-reset" And that email's data has VerificationCode equal to the raw code stored in the repository's hashed PasswordResetCode for alice, not the hash itself, and Email equal to "alice@example.com" Scenario: Requesting a reset for an email with no account returns the identical response and sends no email Given the user repository is empty When the client sends POST /v1/auth/password-reset with body {"email": "nobody@example.com"} Then the response status is 200 And the response body is {"message": "If an account exists for that email, a password reset link has been sent."} And no email was sent Scenario: The reset email's Host reflects the injected public URL Given a config.Settings value with PublicURL "https://cms.example.com" And the auth handler is built from that settings value And the user repository contains a user "alice" with email "alice@example.com" When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"} Then the email sent to "alice@example.com" has data.Host equal to "https://cms.example.com" Scenario: The stored reset code expires one hour after creation Given the user repository contains a user "alice" with email "alice@example.com" When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"} Then alice's stored PasswordResetExpiration is within 5 seconds of time.Now().Add(1 * time.Hour) Scenario: A second request overwrites the first code, invalidating it Given the user repository contains a user "alice" with email "alice@example.com" When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"} Then exactly one email has been sent, carrying raw code "code-1" in its VerificationCode data When the client sends POST /v1/auth/password-reset again with body {"email": "alice@example.com"} Then exactly two emails have been sent in total, and the second one's VerificationCode data differs from "code-1" And alice's stored, hashed PasswordResetCode no longer matches "code-1" but matches the raw code from the second email Scenario: A mail-send failure for an existing user still returns the same generic response Given the user repository contains a user "alice" with email "alice@example.com" And the mail repository is set to fail on its next Send call When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"} Then the response status is 200 And the response body is {"message": "If an account exists for that email, a password reset link has been sent."} And no jwt cookie is set And a failure was written to the process log naming the mail-send failure Scenario: An unverified user can request a password reset exactly like a verified one Given the user repository contains a user "bob" with email "bob@example.com" and IsVerified false When the client sends POST /v1/auth/password-reset with body {"email": "bob@example.com"} Then the response status is 200 And the response body is {"message": "If an account exists for that email, a password reset link has been sent."} And exactly one email was sent to "bob@example.com" using template "password-reset" Scenario Outline: A malformed request is still rejected, since this is not an enumeration concern When the client sends POST /v1/auth/password-reset with body Then the response status is 400 Examples: | body | | {} | | {"email": "not-an-email"} | | not-json | Scenario: The use case itself hashes the code, stores it with a one-hour expiration, and sends the email Given a user.Repository fake containing user id 3, email "alice@example.com" And a RequestPasswordResetUseCase built directly over that fake, a hasher, a mail.Repository fake and public URL "https://cms.example.com" When RequestPasswordReset is called with "alice@example.com" Then the call returns nil And the fake repository's UpdatePasswordResetCode method was invoked exactly once with userId 3, a hashed code, and an expiration within 5 seconds of time.Now().Add(1 * time.Hour) And exactly one email was sent to "alice@example.com" using template "password-reset" whose data carries the same raw code that was hashed, and Host "https://cms.example.com" Scenario: The use case swallows an unknown email and returns nil without touching the repository or mailer Given an empty user.Repository fake And a RequestPasswordResetUseCase built directly over that fake, a hasher, and a mail.Repository fake When RequestPasswordReset is called with "nobody@example.com" Then the call returns nil And the fake repository's UpdatePasswordResetCode method was never invoked And no email was sent Scenario: The use case swallows a mail repository failure and still returns nil Given a user.Repository fake containing user id 3, email "alice@example.com" And a RequestPasswordResetUseCase built directly over that fake, a hasher, and a mail.Repository fake set to fail on its next Send call When RequestPasswordReset is called with "alice@example.com" Then the call returns nil And the fake repository's UpdatePasswordResetCode method was invoked exactly once with userId 3 And a failure was written to the process log naming the mail-send failure Scenario: The use case logs and swallows a hashing failure Given a user.Repository fake containing user id 3, email "alice@example.com" And a RequestPasswordResetUseCase built directly over that fake, a hasher set to fail on its next Hash call, and a mail.Repository fake When RequestPasswordReset is called with "alice@example.com" Then the call returns nil And the fake repository's UpdatePasswordResetCode method was never invoked And no email was sent And a failure was written to the process log naming the hashing failure Scenario: The use case logs and swallows a repository failure while storing the reset code Given a user.Repository fake containing user id 3, email "alice@example.com", set to fail on its next UpdatePasswordResetCode call And a RequestPasswordResetUseCase built directly over that fake, a hasher, and a mail.Repository fake When RequestPasswordReset is called with "alice@example.com" Then the call returns nil And no email was sent And a failure was written to the process log naming the storage failure