feat: invite management API: DELETE /v1/user/invite/:id (204, cancels a pending invite) and POST /v1/user/invite/:id/renew (200, new code, expires_at = now+3d, created_at = now, resends the invitation email, old link dead)
This commit is contained in:
1 parent
461e93e3b5
commit
153fd8719a
18 files changed
+1147
-44
No files matched your search
@@ -0,0 +1,205 @@
|
||||
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=<the code from that invite>
|
||||
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=<the old code>
|
||||
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 <Host>/invite/accept?c=<new raw code>
|
||||
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 <caller> sends <method> <path>
|
||||
Then the response status is <status>
|
||||
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 <method> <path>
|
||||
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
|
||||
Reference in new issue
Block a user