Files
RenewCMS/features/request_password_reset.feature
T

178 lines
12 KiB
Gherkin

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 "passwordReset" (the already-existing
internal/infrastructure/mailer/templates/passwordReset.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 "passwordReset"
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 "passwordReset"
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 <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 "passwordReset" 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