190 lines
11 KiB
Gherkin
190 lines
11 KiB
Gherkin
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 "<InvitedBy> invited you to RenewCMS"
|
|
And the link in the email is <Host>/invite/accept?c=<raw code>
|
|
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 <body>
|
|
Then the response status is <status>
|
|
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 <credentials>
|
|
When the client sends <method> <path>
|
|
Then the response status is <status>
|
|
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
|