Feature: A logged-in user verifies their account with the emailed code As the maintainer of RenewCMS I want a logged-in user to confirm ownership of their account by submitting the verification code that CreateUser already emailed them, have a wrong or expired code refused without changing anything, have the stored code cleared the moment it is successfully used so it cannot be replayed, and have a token that carries no usable user_id answered with 401 instead of a panic So that account verification is a real security check, not a formality, and a malformed or hand-crafted token can never crash the server Background: Given CreateUserUseCase.CreateUser already stores a bcrypt hash of a random verification code on the new user, alongside VerificationExpiration set to two hours after creation, and emails the raw code to the user (features/registration_gate_and_verification_email.feature) And this slice adds POST /v1/auth/verify, mounted in api/router.go inside a group that applies jwtauth.Verifier and jwtauth.Authenticator to the auth router, so a request with no jwt cookie, an expired jwt, or a jwt with a bad signature never reaches the handler and is answered 401 by jwtauth.Authenticator itself, as already happens for /v1/article routes And the request body is decoded into a VerifyCredentials struct with one field, Code, validated required by validator.Validate.Struct before anything else And a new use case, VerifyUserUseCase (internal/infrastructure/useCases/VerifyUser.go), is injected into auth.Handler as VerifyUseCase; it depends only on user.Repository and hash.Hasher and exposes Verify(userId uint32, code string) (user.User, error) And VerifyUserUseCase.Verify loads the user by id, refuses with the new sentinel user.ErrInvalidVerificationCode when the stored VerificationCode is empty (already used, or never set) or hash.Hasher.Compare finds the submitted code does not match the stored hash, refuses with the new sentinel user.ErrVerificationCodeExpired when time.Now() is after the stored VerificationExpiration, and otherwise calls a new user.Repository method, ClearVerificationCode(userId uint32) (user.User, error), that sets IsVerified true and VerificationCode "" (stored as SQL NULL, the same convention as PasswordResetCode in features/unverified_users_and_reregistration.feature) in one write And today, auth.Handler.IsVerified reads token.PrivateClaims()["user_id"].(float64) with a bare type assertion that panics if the claim is absent or not a number; this slice does not touch IsVerified, but the new Verify handler extracts user_id from the already-verified token's claims safely, and answers 401 with a new apperr.UnauthorizedErrorMessage constant whenever the claim is missing, is not a number, or does not match any user in the repository, instead of panicking or trusting the value And a wrong or expired code is mapped to 403 with a new apperr.VerificationFailedErrorMessage constant, the same status family Login already uses for a rejected credential And on success the handler responds 200 with the updated user as JSON (is_verified true, verification_code "") Scenario: A logged-in user verifies their account with the correct emailed code Given a user repository containing user id 7, username "alice", is_verified false, whose stored VerificationCode is the bcrypt hash of "abc123" and whose VerificationExpiration is one hour from now And a valid jwt cookie for user id 7 When the client sends POST /v1/auth/verify with body {"code": "abc123"} Then the response status is 200 And the response body is the user as JSON with is_verified true and verification_code "" And the user repository's record for id 7 now has is_verified true and an empty verification_code Scenario: A wrong code is refused and nothing changes Given a user repository containing user id 7, username "alice", is_verified false, whose stored VerificationCode is the bcrypt hash of "abc123" and whose VerificationExpiration is one hour from now And a valid jwt cookie for user id 7 When the client sends POST /v1/auth/verify with body {"code": "wrong-code"} Then the response status is 403 And the response body is "The verification code is invalid or has expired." And the user repository's record for id 7 still has is_verified false and the same stored verification_code Scenario: An expired code is refused and nothing changes Given a user repository containing user id 7, username "alice", is_verified false, whose stored VerificationCode is the bcrypt hash of "abc123" and whose VerificationExpiration is one hour in the past And a valid jwt cookie for user id 7 When the client sends POST /v1/auth/verify with body {"code": "abc123"} Then the response status is 403 And the response body is "The verification code is invalid or has expired." And the user repository's record for id 7 still has is_verified false and the same stored verification_code Scenario: The code is cleared once used, so it cannot be replayed Given a user repository containing user id 7, username "alice", is_verified false, whose stored VerificationCode is the bcrypt hash of "abc123" and whose VerificationExpiration is one hour from now And a valid jwt cookie for user id 7 When the client sends POST /v1/auth/verify with body {"code": "abc123"} Then the response status is 200 When the client sends POST /v1/auth/verify again with body {"code": "abc123"} Then the response status is 403 And the response body is "The verification code is invalid or has expired." Scenario Outline: A token without a valid user_id returns 401 instead of crashing Given a user repository containing user id 7, username "alice", is_verified false, whose stored VerificationCode is the bcrypt hash of "abc123" and whose VerificationExpiration is one hour from now And When the client sends POST /v1/auth/verify with body {"code": "abc123"} Then the response status is 401 And the response body is the new apperr.UnauthorizedErrorMessage text And the handler does not panic And the user repository's record for id 7 still has is_verified false Examples: | token description | | a validly signed jwt cookie whose claims carry no "user_id" key at all | | a validly signed jwt cookie whose "user_id" claim is the string "seven" | | a validly signed jwt cookie whose "user_id" claim is the number 999, which does not match any user | Scenario: The use case itself enforces the rule, independently of any handler Given a user.Repository fake containing user id 3 with VerificationCode equal to the bcrypt hash of "right-code" and VerificationExpiration one hour from now And a VerifyUserUseCase built directly over that fake and a hasher When Verify is called with userId 3 and code "wrong-code" Then the call returns user.ErrInvalidVerificationCode, asserted with errors.Is And the fake repository's ClearVerificationCode method was never invoked Scenario: The use case rejects an expired code independently of any handler Given a user.Repository fake containing user id 3 with VerificationCode equal to the bcrypt hash of "right-code" and VerificationExpiration one hour in the past And a VerifyUserUseCase built directly over that fake and a hasher When Verify is called with userId 3 and code "right-code" Then the call returns user.ErrVerificationCodeExpired, asserted with errors.Is And the fake repository's ClearVerificationCode method was never invoked Scenario: The use case succeeds and clears the code independently of any handler Given a user.Repository fake containing user id 3 with VerificationCode equal to the bcrypt hash of "right-code" and VerificationExpiration one hour from now And a VerifyUserUseCase built directly over that fake and a hasher When Verify is called with userId 3 and code "right-code" Then the call returns a user with IsVerified true and VerificationCode "" And the fake repository's ClearVerificationCode method was invoked exactly once with userId 3