206 lines
10 KiB
Gherkin
206 lines
10 KiB
Gherkin
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
|