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
|
||||
@@ -0,0 +1,71 @@
|
||||
# QA procedure: invite management
|
||||
|
||||
Package: `test` (black-box). Reuse `mockUserRepo`, `stubHasher`, `mockMailRepo` and `mockInviteRepo`.
|
||||
Extend `mockInviteRepo` with the new renew method and a `failSwitch` entry for it (unique type names
|
||||
across the package). No live server, no real SQLite (except case 27), no real SMTP.
|
||||
|
||||
## Setup shared by every case
|
||||
1. Call `validator.InitValidator()` once.
|
||||
2. Use cases over domain interfaces only: `CreateInvite`, `ListUsersAndInvites`, `GetInvite`, the accept
|
||||
use case, plus the new cancel and renew use cases (final names are the coder's).
|
||||
3. Handler built with `NewHandler(...)` on a fresh `chi.NewRouter()` mirroring `NewUserRouter`:
|
||||
`jwtauth.Verifier` + `jwtauth.Authenticator` (`jwtauth.New("HS256", []byte("test-secret"), nil)`),
|
||||
`RequireCurrentToken`, `RequireVerifiedUser`, `GET /`, then a group with `RequireOwner`,
|
||||
`TrustedProxyRealIP(nil)` and `NewRateLimiter` containing `POST /invite`, `DELETE /invite/{id}` and
|
||||
`POST /invite/{id}/renew`. Mount `GET /invite` and `POST /invite/accept` from the auth handler for the
|
||||
old-link cases.
|
||||
4. Seed users: owner 7 "marta", unverified 8, admin 9. Invite hashes are `"hashed:"+raw`. Times are
|
||||
relative to `time.Now()`, compared with a 5 s tolerance.
|
||||
|
||||
## DELETE /v1/user/invite/{id}
|
||||
5. Pending invite, owner cookie -> `204`, empty body, invite gone, no mail, absent from `GET /`.
|
||||
6. After cancel: `GET /invite?c=<code>` -> `404`; accept -> `404`, no user, no cookie; re-invite the address -> `201`.
|
||||
7. Expired invite -> `204`.
|
||||
8. Unknown id -> `404` "Invite not found"; second delete -> `404`.
|
||||
9. Accepted invite -> `409` "This invite has already been accepted."; invite and user remain.
|
||||
10. Other invites and users unchanged after a cancel.
|
||||
11. Repository delete failure -> `500`, invite still present.
|
||||
|
||||
## POST /v1/user/invite/{id}/renew
|
||||
12. Expired invite -> `200`, `Cache-Control: no-store`; key set exactly `id`, `kind`, `email`, `status`,
|
||||
`created_at`, `expires_at`; no `code`, `hashed_code`, `invited_by`, `accepted_at`.
|
||||
13. Stored invite: same id, new hash, `invited_by` 7, `accepted_at` nil, `created_at` about now,
|
||||
`expires_at` = `created_at` + 72h.
|
||||
14. Pending invite renews with `200`, new hash.
|
||||
15. Mail: exactly one entry, template `invitation`, `Host`, `Email`, `InvitedBy` by display name or username
|
||||
of the acting owner, `VerificationCode` = raw new code.
|
||||
16. `GET /` lists the renewed invite first.
|
||||
17. Old code: `GET /invite` and accept -> `404`, no user; new code -> `200` with the email. Control checks before
|
||||
the renew show the old code worked. Same for pending.
|
||||
18. Unknown id -> `404`; cancelled id -> `404`.
|
||||
19. Accepted -> `409`, all fields unchanged, no mail.
|
||||
20. `mailRepo.fail.failNext("Send")` -> `500` "Couldn't send the invite. Try again in a moment."; invite hash,
|
||||
expiry and created_at unchanged; retry -> `200`.
|
||||
21. Renew persistence failure -> `500`, no mail, invite unchanged.
|
||||
|
||||
## Access and ids (table over both endpoints)
|
||||
22. No cookie -> `401`; stale `token_version` -> `401`; unverified -> `403`; admin -> `403` with body
|
||||
`apperr.OwnerRequiredErrorMessage`; admin on an unknown id -> `403`. Each: invite unchanged, no mail.
|
||||
23. Ids `abc`, `-1`, `4294967296` -> `400` `apperr.IdUint32ErrorMessage` on both endpoints.
|
||||
|
||||
## Rate limit
|
||||
24. Limiter of 2, `RemoteAddr` `203.0.113.40:1234`: third request -> `429`, `apperr.TooManyRequestsErrorMessage`,
|
||||
`Retry-After`, no change, no mail. `GET /` from the same address is not `429`.
|
||||
25. Admin's three requests -> `403`, `403`, `403`.
|
||||
26. Shared counter: `POST /invite`, renew, then `DELETE` -> `429`.
|
||||
|
||||
## Use case and persistence
|
||||
27. Use case level:
|
||||
- unknown id -> `errors.Is(err, ErrInviteNotFound)`; accepted -> the accepted sentinel (name is the coder's);
|
||||
- success returns the same id, a new hash, `CreatedAt` about now, `ExpiresAt` = `CreatedAt` + `invite.Lifetime`;
|
||||
- mail failure wraps `ErrInviteEmailNotSent` and restores the prior values;
|
||||
- the mail logic is shared with `CreateInvite`.
|
||||
28. Persistence on in-memory SQLite: the renew method updates hash, expiry and created_at by id, returns
|
||||
`ErrInviteNotFound` when the row is missing or already accepted.
|
||||
|
||||
## Manual
|
||||
Renew against a real or sandboxed SMTP server: one mail arrives, the new link works, the old mail's link is dead.
|
||||
Persistence across a restart on a fresh SQLite file. Review layering and that no comments were introduced.
|
||||
|
||||
## Out of scope
|
||||
SPA handling of 404, 409 and 429 on these endpoints: this slice is API-only.
|
||||
Reference in new issue
Block a user