161 lines
9.8 KiB
Gherkin
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
|