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,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