feat: invitation accept screen and api
This commit is contained in:
1 parent
310cfd528e
commit
55525c06d6
23 files changed
+2043
-11
No files matched your search
@@ -0,0 +1,158 @@
|
||||
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 has the same access as any other verified user: no roles, no ownership
|
||||
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 after the user is created leaves the invite pending and no user behind
|
||||
Given a pending invite and the invite repository set to fail marking it accepted
|
||||
When the client sends POST /v1/auth/invite/accept with username "lea" and password "limewash2026"
|
||||
Then the response status is 500
|
||||
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
|
||||
Reference in new issue
Block a user