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 Then the response status is And the response body is "" Examples: | query | status | body | | ?c=not-a-known-code | 404 | This invite link doesn't work. | | ?c= | 404 | This invite link doesn't work. | | ?c= | 404 | This invite link doesn't work. | | | 404 | This invite link doesn't work. | | ?c= | 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 When the client sends POST /v1/auth/invite/accept with a body using and username "lea" and password "limewash2026" Then the response status is And the response body is "" 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 Then the response status is 400 And no user was created and the invite is still pending Examples: | body | | {"code": "", "username": "le", "password": "limewash2026"} | | {"code": "", "username": "abcdefghijklmnopqrstu", "password": "limewash2026"} | | {"code": "", "username": "lea", "password": "lime"} | | {"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