Feature: A verified user can invite another person by email, and the users list shows users and open invites As a member of the shared RenewCMS workspace I want to invite someone by email and see who has an account or a pending invite So that they can later create their own account with exactly the same access, while registration stays closed to everyone who was not invited Background: Given the workspace is shared: every verified user sees the same articles and the same list, roles are owner and admin (features/user_roles.feature), and there is no way to remove a user And POST /v1/user/invite and GET /v1/user/ are mounted in NewUserRouter, inside the group that requires a valid jwt cookie, a current token_version and a verified user And POST /v1/user/invite additionally requires the owner role (403 apperr.OwnerRequiredErrorMessage for an admin); GET /v1/user/ is open to every verified user And wherever a scenario has verified user id 7 post an invite, user 7 is the owner And an invite has an id, an email stored in lower case, a hashed code, an expires_at equal to its creation time plus 3 days, an invited_by user id, a nullable accepted_at and a created_at And the raw invite code is a uuid, hashed with the same hash.Hasher used for verification codes, and only the hash is stored And an invite's status is derived and never stored: pending when accepted_at is empty and expires_at is in the future, expired when accepted_at is empty and expires_at is in the past, accepted when accepted_at is set And the users list item shape is: id, kind ("user" or "invite"), status, created_at, plus username, display_name, email and role for a user, plus email for an invite; neither the code, its hash, invited_by nor password fields ever appear And error responses are plain text from http.Error, success responses are JSON And the invite email uses the template "invitation" with data Host (the configured PublicURL), VerificationCode (the raw code), Email (the invited address) and InvitedBy (the inviter's display name, or their username when the display name is empty) And the email subject is " invited you to RenewCMS" And the link in the email is /invite/accept?c= And POST /v1/user/invite is rate-limited per client IP with the auth rate limiter (AUTH_RATE_LIMIT_MAX_ATTEMPTS, AUTH_RATE_LIMIT_WINDOW_SECONDS), independently counted from /login and /password-reset Scenario: A verified user invites a new address Given a user repository containing verified user id 7, who is the owner, username "marta", display_name "" And an empty invite repository And a valid jwt cookie for user id 7 When the client sends POST /v1/user/invite with body {"email": "Sam.Oliveira@Renew.example"} Then the response status is 201 And the response body is a JSON object with exactly the keys id, kind, email, status, created_at and expires_at, with kind "invite", email "sam.oliveira@renew.example" and status "pending" And the response body contains no key named code, hashed_code, verification_code or password And the invite repository holds one invite with email "sam.oliveira@renew.example", invited_by 7, accepted_at empty and expires_at 3 days after created_at And the stored code is the hash of the raw code, not the raw code And exactly one email was sent to "sam.oliveira@renew.example" with template "invitation" And that email's Host is the configured PublicURL, its VerificationCode is the raw code, its Email is "sam.oliveira@renew.example" and its InvitedBy is "marta" Scenario: The email names the inviter by display name when there is one Given verified user id 7, username "ines", display_name "Ines Duarte" When the client sends POST /v1/user/invite with body {"email": "lea.martin@renew.example"} Then the response status is 201 And the email's InvitedBy is "Ines Duarte" Scenario Outline: An invalid email is rejected before anything is stored or sent Given verified user id 7 and a valid jwt cookie for user id 7 When the client sends POST /v1/user/invite with body Then the response status is And the invite repository is still empty And no email was sent Examples: | body | status | | {"email": "sam.oliveira@renew"} | 400 | | {"email": ""} | 400 | | {} | 400 | | {"email": "not-an-email"} | 400 | | not json | 400 | Scenario: An address that already has an account is refused Given verified user id 7 and a user "jonas" with email "Jonas@Renew.example" When the client sends POST /v1/user/invite with body {"email": "jonas@renew.example"} Then the response status is 409 And the response body is "This address already has an account." And the invite repository is still empty And no email was sent Scenario: An address with a live pending invite is refused Given verified user id 7 and a pending invite for "sam.oliveira@renew.example" expiring in 2 days When the client sends POST /v1/user/invite with body {"email": "sam.oliveira@renew.example"} Then the response status is 409 And the response body is "There's already a pending invite for this address." And the invite repository still holds only the original invite, unchanged And no email was sent Scenario: An expired invite is replaced by a new one Given verified user id 7 and an expired invite for "p.huber@gmail.com" that expired 1 hour ago When the client sends POST /v1/user/invite with body {"email": "p.huber@gmail.com"} Then the response status is 201 And the invite repository holds exactly one invite for "p.huber@gmail.com", pending, with a new code, and the expired one no longer exists And exactly one email was sent to "p.huber@gmail.com" Scenario: A failed email removes the invite Given verified user id 7 and the mail repository is set to fail its next Send When the client sends POST /v1/user/invite with body {"email": "lea.martin@renew.example"} Then the response status is 500 And the response body is "Couldn't send the invite. Try again in a moment." And the invite repository is empty When the failure is cleared and the client sends the same request again Then the response status is 201 Scenario: A failed email does not restore a replaced expired invite Given an expired invite for "p.huber@gmail.com" and the mail repository set to fail its next Send When the client sends POST /v1/user/invite with body {"email": "p.huber@gmail.com"} Then the response status is 500 And the invite repository holds no invite for "p.huber@gmail.com" Scenario Outline: Only an authenticated, verified, current-token user can use the endpoints Given the request is made When the client sends Then the response status is And no invite was created and no email was sent Examples: | credentials | method | path | status | | with no jwt cookie | POST | /v1/user/invite | 401 | | with a jwt cookie of an unverified user | POST | /v1/user/invite | 403 | | with a jwt cookie whose token_version is stale | POST | /v1/user/invite | 401 | | with a jwt cookie of a verified admin | POST | /v1/user/invite | 403 | | with no jwt cookie | GET | /v1/user/ | 401 | | with a jwt cookie of an unverified user | GET | /v1/user/ | 403 | Scenario: The invite endpoint is rate-limited Given AUTH_RATE_LIMIT_MAX_ATTEMPTS is 2 for a window covering the whole test When the client at IP "203.0.113.20" sends 2 valid POST /v1/user/invite requests And then sends a 3rd Then the 3rd response is 429 with body apperr.TooManyRequestsErrorMessage and a Retry-After header And no invite was created and no email was sent for the 3rd request And GET /v1/user/ from the same client is not rejected with 429 Scenario: The list shows users and open invites, newest first Given verified user id 7 "marta" created 12 Mar 2026 And user "jonas" created 2 Apr 2026 And user "ines" with display_name "Ines Duarte" created 18 Jun 2026 And an expired invite for "p.huber@gmail.com" created 3 Aug 2026 And a pending invite for "sam.oliveira@renew.example" created 27 Sep 2026 And an accepted invite for "jonas@renew.example" created 1 Apr 2026 When the client sends GET /v1/user/ with a valid jwt cookie for user id 7 Then the response status is 200 And the response body is a JSON array of exactly 5 items in this order: the pending invite for "sam.oliveira@renew.example", the expired invite for "p.huber@gmail.com", user "ines", user "jonas", user "marta" And the accepted invite is not listed And the invite items have exactly the keys id, kind, email, status, created_at, with kind "invite" and status "pending" or "expired" And the user items have exactly the keys id, kind, username, display_name, email, role, status, created_at, with kind "user" and status "accepted" And each user item has its own email and a role And no item has a key named password, password_reset_code, verification_code, verification_expiration, password_reset_expiration, token_version, code, hashed_code, invited_by or accepted_at Scenario: With nobody invited the list contains only the registered user Given only verified user id 7 "marta" When the client sends GET /v1/user/ with a valid jwt cookie for user id 7 Then the response status is 200 And the response body is a JSON array of exactly one item, kind "user", username "marta", status "accepted" Scenario: An invite turns from pending to expired with time, without any stored status Given an invite created 3 days and 1 minute ago that has not been accepted When the client sends GET /v1/user/ Then that invite's status is "expired" Scenario: Registration stays closed for uninvited people Given a user repository containing one verified user And a pending invite for "sam.oliveira@renew.example" When the client sends POST /v1/auth/register with username "sam", password "hunter2example12", email "sam.oliveira@renew.example" Then the response status is 403 And the response body is exactly apperr.RegistrationClosedErrorMessage And no user was created and no email was sent Scenario: Registration on an empty instance is unchanged Given the user repository is empty When the client sends POST /v1/auth/register with username "bob", password "hunter2example12", email "bob@example.com" Then the response status is 200 And a verification email is sent with template "verification" And no invite exists Scenario: Inviting someone never changes GET /v1/auth/registration Given a user repository containing one verified user When a pending invite exists Then GET /v1/auth/registration still reports registration closed