Files
RenewCMS/features/accept_invitation.feature
T

161 lines
9.8 KiB
Gherkin

Feature: An invited person accepts their invitation and gets a verified account
As a person who received an invitation email
I want to open the link, see which address it was sent to, choose a username and password,
and land logged in
So that I get an account with exactly the same access as everyone else, while registration
stays closed to anyone who was not invited
Background:
Given GET /v1/auth/invite and POST /v1/auth/invite/accept are mounted in NewAuthRouter and are
public: no jwt cookie is required and an existing jwt cookie is ignored
And both are rate-limited per client IP with the auth rate limiter (AUTH_RATE_LIMIT_MAX_ATTEMPTS,
AUTH_RATE_LIMIT_WINDOW_SECONDS), counted independently of /login, /password-reset and
POST /v1/user/invite
And an invite is looked up by comparing the raw code against the stored hashed code with the
same hash.Hasher used to create it, and the raw code is never stored
And an invite is usable only when accepted_at is empty and expires_at is in the future
And a code that matches no invite, matches an accepted invite, or is missing or empty answers
404 with body apperr.InviteInvalidErrorMessage "This invite link doesn't work."
And a code that matches an unaccepted invite whose expires_at is in the past answers 410 with
body apperr.InviteExpiredErrorMessage "This invite has expired."
And the POST body has the validate tags code required, username required,min=3,max=20 and
password required,min=8, checked with validator.Validate.Struct before any lookup, and a
failure answers 400 with the validator's text; an unparseable body answers 400 with
apperr.BodyErrorMessage
And error responses are plain text from http.Error, success responses are JSON
And accepting creates a user with the invite's email (already lower case), the chosen username,
the hashed password, is_verified true, an empty verification code, and an empty display name
And the created user is an admin: same access to articles, the users list and Settings, but
cannot invite
And the jwt cookie is set exactly as registration sets it: HttpOnly, path "/", 24 hours, with
claims user_id and token_version, and Secure in production
Scenario: The invited email is read from a valid code
Given a pending invite for "lea.martin@renew.example" whose raw code is "3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42"
When the client sends GET /v1/auth/invite?c=3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42
Then the response status is 200
And the response body is a JSON object with exactly the key email, with value "lea.martin@renew.example"
And the response has Cache-Control "no-store"
And the invite is unchanged
Scenario Outline: A link that does not work is reported without saying why
Given a pending invite for "lea.martin@renew.example", an accepted invite for "sam@renew.example"
and an expired invite for "p.huber@gmail.com", each with its own raw code
When the client sends GET /v1/auth/invite<query>
Then the response status is <status>
And the response body is "<body>"
Examples:
| query | status | body |
| ?c=not-a-known-code | 404 | This invite link doesn't work. |
| ?c=<accepted invite code> | 404 | This invite link doesn't work. |
| ?c= | 404 | This invite link doesn't work. |
| | 404 | This invite link doesn't work. |
| ?c=<expired invite code> | 410 | This invite has expired. |
Scenario: Accepting creates a verified user, logs them in and closes the invite
Given a verified user "marta" and a pending invite for "Lea.Martin@Renew.example" created at
invitation time, with raw code "3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42"
When the client sends POST /v1/auth/invite/accept with body
{"code": "3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42", "username": "lea", "password": "limewash2026"}
Then the response status is 200
And the response body is a JSON object {"message": "Invite accepted! HTTPonly jwt cookie created"}
And a jwt cookie is set, and decoding it gives the new user's id and token_version
And the user repository holds a user "lea" with email "lea.martin@renew.example", is_verified
true, an empty verification_code and a stored password that is a hash, not "limewash2026"
And the invite now has accepted_at set and its status is "accepted"
And no email was sent
Scenario: The new account works like any other account
Given the invite from the previous scenario was accepted and the jwt cookie was kept
When the client sends GET /v1/auth/me and GET /v1/user/ with that cookie
Then both responses are 200
And the GET /v1/user/ list shows user "lea" with status "accepted" and no invite for
"lea.martin@renew.example"
Scenario: An accepted code cannot be used again
Given an invite that was just accepted by "lea"
When the client sends POST /v1/auth/invite/accept with the same code, username "lea2" and
password "limewash2026"
Then the response status is 404
And the response body is "This invite link doesn't work."
And no user "lea2" exists and no jwt cookie is set
When the client sends GET /v1/auth/invite with the same code
Then the response status is 404
Scenario Outline: A code that cannot be used creates nothing
Given <state>
When the client sends POST /v1/auth/invite/accept with a body using <code> and username "lea" and password "limewash2026"
Then the response status is <status>
And the response body is "<body>"
And no user was created, no jwt cookie is set and no invite changed
Examples:
| state | code | status | body |
| no invite has that code | an unknown one | 404 | This invite link doesn't work. |
| an invite that expired an hour ago | its code | 410 | This invite has expired. |
Scenario: A taken username is refused and the invite stays usable
Given a verified user "marta" and a pending invite for "lea.martin@renew.example"
When the client sends POST /v1/auth/invite/accept with username "marta" and password "limewash2026"
Then the response status is 409
And the response body is "That username is taken. Try another."
And no jwt cookie is set and the invite is still pending
When the client resends with username "lea"
Then the response status is 200
Scenario: An address that got an account in the meantime is refused
Given a pending invite for "lea.martin@renew.example" and an unverified user "leam" with email "Lea.Martin@Renew.example"
When the client sends POST /v1/auth/invite/accept with username "lea" and password "limewash2026"
Then the response status is 409
And the response body is exactly apperr.EmailHasAccountErrorMessage
And no user "lea" was created, no jwt cookie is set and the invite is still pending
Scenario Outline: An invalid body is rejected before the code is looked up
Given a pending invite with a known code
When the client sends POST /v1/auth/invite/accept with body <body>
Then the response status is 400
And no user was created and the invite is still pending
Examples:
| body |
| {"code": "<code>", "username": "le", "password": "limewash2026"} |
| {"code": "<code>", "username": "abcdefghijklmnopqrstu", "password": "limewash2026"} |
| {"code": "<code>", "username": "lea", "password": "lime"} |
| {"code": "<code>", "password": "limewash2026"} |
| {"username": "lea", "password": "limewash2026"} |
| not json |
Scenario: An existing jwt cookie is ignored and replaced
Given a valid jwt cookie for verified user "marta" and a pending invite for "lea.martin@renew.example"
When the client sends POST /v1/auth/invite/accept with that cookie and username "lea", password "limewash2026"
Then the response status is 200
And the Set-Cookie jwt carries the new user's id, not marta's
Scenario: A failure while accepting rolls back and leaves the invite pending and no user behind
Given a pending invite and the accept operation of the invite repository set to fail
When the client sends POST /v1/auth/invite/accept with username "lea" and password "limewash2026"
Then the response status is 500
And the user insert and the invite update are rolled back together
And no user "lea" exists, no jwt cookie is set and the invite is still pending
Scenario: The accept endpoints are 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.30" sends 2 requests to POST /v1/auth/invite/accept and then a 3rd
Then the 3rd response is 429 with body apperr.TooManyRequestsErrorMessage and a Retry-After header
And GET /v1/auth/registration from the same client is not rejected with 429
Scenario: Registration stays closed for everyone else
Given a verified user and an accepted invite
When the client sends POST /v1/auth/register with username "sam", password "hunter2example12",
email "sam@renew.example"
Then the response status is 403
And the response body is exactly apperr.RegistrationClosedErrorMessage
And GET /v1/auth/registration still reports {"open": false}
Scenario: The invite response never leaks the code or other invite data
Given a pending invite
When the client sends GET /v1/auth/invite with its code
Then the body contains no key named code, hashed_code, invited_by, expires_at, id or accepted_at