feat(API): user invitations and emails
This commit is contained in:
1 parent
7aae704d0a
commit
acdef18b37
17 files changed
+1687
-7
No files matched your search
@@ -0,0 +1,183 @@
|
||||
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: there are no roles, no ownership and 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 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 and display_name for a user, plus email for an invite; a user is never listed with
|
||||
an email, and 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, 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 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, status, created_at,
|
||||
with kind "user" and status "accepted"
|
||||
And no user item has an email key, 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
|
||||
Reference in new issue
Block a user