Feature: The owner can cancel a pending invite and renew a pending or expired one As the owner of the shared RenewCMS workspace I want to cancel an invite I no longer want, or renew one that is stale or expired So that only the people I currently intend to invite can create an account Background: Given the invite domain, the invitation email and the roles are those of features/invitations.feature and features/user_roles.feature And DELETE /v1/user/invite/{id} and POST /v1/user/invite/{id}/renew are mounted in NewUserRouter, in the same group as POST /v1/user/invite: valid jwt cookie, current token_version, verified user, owner role, trusted-proxy real IP, then the auth rate limiter And the checks run in this order: 401, 403 unverified, 403 not owner, 429, 400 invalid id, 404, 409, then the action And user 7 "marta" is the verified owner and user 9 "jonas" is a verified admin And error responses are plain text from http.Error, and the messages are: | case | status | body | | unknown invite | 404 | Invite not found | | invite already accepted | 409 | This invite has already been accepted. | | id is not a uint32 | 400 | apperr.IdUint32ErrorMessage | | not the owner | 403 | Only the owner can do this. | | no valid session | 401 | apperr.UnauthorizedErrorMessage | | rate limited | 429 | apperr.TooManyRequestsErrorMessage | | renew email not sent | 500 | Couldn't send the invite. Try again in a moment. | And the renew response has the same shape as the create response: exactly the keys id, kind ("invite"), email, status, created_at and expires_at Rule: Cancelling an invite Scenario: The owner cancels a pending invite Given an invite id 3 for "sam.oliveira@renew.example", pending, created by user 7 And a valid jwt cookie for user 7 When the client sends DELETE /v1/user/invite/3 Then the response status is 204 And the response body is empty And the invite repository no longer holds invite 3 And no email was sent And GET /v1/user/ no longer lists the invite Scenario: A cancelled invite's link stops working Given invite 3 was cancelled by the owner When the client sends GET /v1/auth/invite?c= Then the response status is 404 And the response body is "This invite link doesn't work." And accepting the invite with that code returns 404, creates no user and sets no cookie Scenario: A cancelled address can be invited again Given invite 3 for "sam.oliveira@renew.example" was cancelled When the owner sends POST /v1/user/invite for that address Then the response status is 201 Scenario: The owner cancels an expired invite Given invite 4 is expired When the owner sends DELETE /v1/user/invite/4 Then the response status is 204 And the invite repository no longer holds invite 4 Scenario: Other invites and users are untouched Given invites 3 and 4 and users 7 and 9 When the owner cancels invite 3 Then invite 4 and users 7 and 9 are unchanged Scenario: An unknown invite is not found When the owner sends DELETE /v1/user/invite/999 Then the response status is 404 And the response body is "Invite not found" Scenario: Cancelling twice is not found Given the owner cancelled invite 3 When the owner sends DELETE /v1/user/invite/3 again Then the response status is 404 Scenario: An accepted invite cannot be cancelled Given invite 5 was accepted and its user exists When the owner sends DELETE /v1/user/invite/5 Then the response status is 409 And the response body is "This invite has already been accepted." And invite 5 and its user remain Rule: Renewing an invite Scenario: The owner renews an expired invite Given invite 4 for "lea.martin@renew.example", created 5 days ago, expired 2 days ago, invited_by 7, stored code hash "hashed:OLD-CODE" And a valid jwt cookie for user 7 When the client sends POST /v1/user/invite/4/renew Then the response status is 200 And the response has Cache-Control "no-store" And the response id is 4, kind "invite", email "lea.martin@renew.example" and status "pending" And created_at is now and expires_at is 3 days after created_at, each within 5 seconds And the response body has no key named code, hashed_code, invited_by or accepted_at And the invite repository holds exactly one invite for that address, with id 4, a new code hash, invited_by 7 and accepted_at empty Scenario: The owner renews a pending invite Given invite 3 is pending When the owner sends POST /v1/user/invite/3/renew Then the response status is 200 And invite 3 has a new code hash and created_at now Scenario: A renewed invite moves to the top of the users list Given invite 3 is pending and older than invite 6 When the owner renews invite 3 Then GET /v1/user/ lists invite 3 before invite 6 Scenario: The old link is dead and the new link works Given invite 4 was renewed and the raw code in the new email is NEW-CODE When the client sends GET /v1/auth/invite?c= Then the response status is 404 And accepting with the old code returns 404 and creates no user And GET /v1/auth/invite?c=NEW-CODE returns 200 with {"email": "lea.martin@renew.example"} And this holds when the renewed invite was pending as well as expired Scenario: Renewing sends the invitation email again When the owner renews invite 4 Then exactly one email was sent, with template "invitation" And its Host is the configured PublicURL, its Email is the invited address, its InvitedBy is the acting owner's display name or, when empty, username And its VerificationCode is the new raw code And the link in it is /invite/accept?c= And the invite's invited_by is unchanged Scenario: An unknown or cancelled invite is not found When the owner sends POST /v1/user/invite/999/renew Then the response status is 404 And the response body is "Invite not found" And renewing a cancelled invite also returns 404 Scenario: An accepted invite cannot be renewed Given invite 5 was accepted When the owner sends POST /v1/user/invite/5/renew Then the response status is 409 And the response body is "This invite has already been accepted." And invite 5 is unchanged and no email was sent Scenario: A failed email leaves the invite as it was Given expired invite 4 and an email repository whose next send fails When the owner sends POST /v1/user/invite/4/renew Then the response status is 500 And the response body is "Couldn't send the invite. Try again in a moment." And invite 4 keeps its old code hash, expires_at and created_at, and is still expired And a pending invite whose renew mail fails keeps its old link valid And a retry of the same request returns 200 Rule: Only the owner can manage invites Scenario Outline: Access is checked on both endpoints Given invite 3 is pending When sends Then the response status is And invite 3 is unchanged And no email was sent Examples: | caller | method | path | status | | a client with no cookie | DELETE | /v1/user/invite/3 | 401 | | a client with no cookie | POST | /v1/user/invite/3/renew | 401 | | a stale token_version | DELETE | /v1/user/invite/3 | 401 | | a stale token_version | POST | /v1/user/invite/3/renew | 401 | | an unverified user | DELETE | /v1/user/invite/3 | 403 | | an unverified user | POST | /v1/user/invite/3/renew | 403 | | verified admin 9 | DELETE | /v1/user/invite/3 | 403 | | verified admin 9 | POST | /v1/user/invite/3/renew | 403 | Scenario: An admin is refused with the owner message, even on an unknown id When admin 9 sends DELETE /v1/user/invite/999 Then the response status is 403 And the response body is "Only the owner can do this." Scenario Outline: An id that is not a uint32 is rejected When the owner sends Then the response status is 400 And the response body is apperr.IdUint32ErrorMessage Examples: | method | path | | DELETE | /v1/user/invite/abc | | DELETE | /v1/user/invite/-1 | | DELETE | /v1/user/invite/4294967296 | | POST | /v1/user/invite/abc/renew | | POST | /v1/user/invite/-1/renew | | POST | /v1/user/invite/4294967296/renew| Rule: Rate limiting Scenario: Requests over the limit are refused Given the auth rate limiter allows 2 requests per window When the owner sends three valid requests to DELETE or renew from IP 203.0.113.40 Then the third response is 429 with apperr.TooManyRequestsErrorMessage and a Retry-After header And the third request changes nothing and sends no email And GET /v1/user/ from the same address is not 429 Scenario: An admin does not use up the quota When admin 9 sends three requests from one IP Then all three responses are 403, not 429 Scenario: The counter is shared with inviting Given the auth rate limiter allows 2 requests per window When the owner sends POST /v1/user/invite, then renews an invite, then sends DELETE /v1/user/invite/3 Then the DELETE returns 429 Rule: Existing behaviour is unchanged Scenario: The invitation and acceptance features still hold Then every scenario of features/invitations.feature and features/accept_invitation.feature still passes