feat(API): user invitations and emails

This commit is contained in:
fsylvain committed 2026-09-29 07:15:17 +02:00
1 parent 7aae704d0a
commit acdef18b37
17 files changed
+1687 -7

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
# QA procedure: invitations
Package: `test` (black-box). New fakes must have unique type names across the package:
`mockInviteRepo` (implements `invite.Repository` with an in-memory slice and a `failSwitch`),
plus a `fixedClock` only if the use case takes one. Reuse `mockUserRepo` and `stubHasher`
(`user_test.go`) and `mockMailRepo` (`mail_test.go`; `SentEmails` entries expose `Address`,
`Template`, `Data`; `fail.failNext("Send")`).
## Setup shared by every case
1. Call `validator.InitValidator()` once.
2. Use cases: `useCases.NewCreateInviteUseCase(userRepo, inviteRepo, &stubHasher{}, mailRepo,
"https://cms.example.com")` and `useCases.NewListUsersAndInvitesUseCase(userRepo, inviteRepo)`
(final names are the coder's; constructors take only domain interfaces).
3. Handler: `user.NewHandler(updateProfile, createInvite, listUsersAndInvites)` mounted on a fresh
`chi.NewRouter()` mirroring `NewUserRouter`: `jwtauth.Verifier` + `jwtauth.Authenticator` with
`jwtauth.New("HS256", []byte("test-secret"), nil)`, `RequireCurrentToken(getUser)`,
`RequireVerifiedUser(getUser)`, then `POST /invite` and `GET /`. For the rate-limit case
wrap only `POST /invite` in `TrustedProxyRealIP(nil)` + `NewRateLimiter(2, time.Minute)`.
Requests via `httptest`; jwt cookie built with the `user_id` and `token_version` claims as in
`test/verified_account_required_http_test.go`. No live server, no real SQLite, no real SMTP.
4. Seed users: verified id 7 "marta"; unverified id 8 for the 403 cases. Time is real: build
invites with `ExpiresAt`/`CreatedAt` relative to `time.Now()` and compare with a 5 s tolerance.
## POST /v1/user/invite
5. Body `{"email":"Sam.Oliveira@Renew.example"}`, cookie for 7 -> `201`. Decode into
`map[string]json.RawMessage`: the key set is exactly `id`, `kind`, `email`, `status`,
`created_at`, `expires_at`, with `kind`="invite", `email`="sam.oliveira@renew.example",
`status`="pending"; none of `code`, `hashed_code`, `verification_code`, `password`.
`inviteRepo` holds one invite: lower-case email, `InvitedBy==7`, `AcceptedAt` nil,
`abs(ExpiresAt - (CreatedAt+72h)) < 5s`, stored code == `"hashed:"+rawCode` (stubHasher) where
`rawCode` is taken from the mail data; the stored code is not equal to `rawCode`.
6. `mailRepo.SentEmails` has exactly one entry: `Address`, `Template=="invitation"`, data of the
invitation mail type with `Host=="https://cms.example.com"`, `Email` the invited address,
`InvitedBy=="marta"` and a uuid-shaped `VerificationCode`.
7. Inviter with `DisplayName:"Ines Duarte"` -> `InvitedBy=="Ines Duarte"`. Inviter with an
empty display name -> username.
8. Table for 400 (`{"email":"sam.oliveira@renew"}`, `{"email":""}`, `{}`, `{"email":"not-an-email"}`,
`not json`): status `400`, `inviteRepo` empty, `SentEmails` empty.
9. Existing user with email `"Jonas@Renew.example"`, invite `jonas@renew.example` -> `409`, body exactly the new
apperr constant "This address already has an account.", no invite, no mail. This case pins that
the match is case-insensitive.
10. Seed a pending invite (expires in 2 days) for the address -> `409`, body "There's already a pending
invite for this address.", the seeded invite unchanged (same code hash and expiry), no mail.
11. Seed an expired invite (expired 1 h ago) -> `201`; `inviteRepo` has exactly one invite for the address, with
a different code hash and an expiry about 3 days out; one mail sent.
12. `mailRepo.fail.failNext("Send")` -> `500`, body "Couldn't send the invite. Try again in a moment.",
`inviteRepo` empty. Repeat the same request without re-arming -> `201`. With a seeded expired invite and a
failing send -> `500` and no invite for that address remains.
13. Auth table (`POST`): no cookie -> `401`; cookie of unverified user 8 -> `403`; cookie with stale
`token_version` -> `401`. Each asserts no invite and no mail.
14. Rate limit: limiter of 2 per minute, `RemoteAddr` `203.0.113.20:1234`, three valid POSTs -> `201`,
`201`, `429`; third body is exactly `apperr.TooManyRequestsErrorMessage`, has `Retry-After`, and adds no
invite or mail. `GET /` from the same address afterwards is not `429`.
## GET /v1/user/
15. Seed per the feature scenario (three users, an expired, a pending and an accepted invite, with the
stated `CreatedAt` values). `200`; decode as `[]map[string]json.RawMessage`. Assert length 5, order
(pending sam, expired p.huber, ines, jonas, marta), key sets exactly as specified for user and invite
items, accepted invite absent, and none of the forbidden keys anywhere. Also assert the raw body string
contains no user email address.
16. Only user 7 -> array with exactly one `kind:"user"` item, status `accepted`.
17. Invite created 3 days + 1 minute ago -> `status:"expired"`; created 2 days ago -> `pending`;
with `AcceptedAt` set -> not listed.
18. Auth table (`GET`): no cookie -> `401`; unverified -> `403`.
## Use-case level (no HTTP)
19. `CreateInvite` on a repository holding a user with the same email -> `errors.Is(err, invite.ErrEmailHasAccount)`;
live pending invite -> `errors.Is(err, invite.ErrPendingInvite)` (sentinel names are the coder's).
A mail failure wraps the mail repository error and the `Delete` of the invite is called
(if `Delete` also fails, both errors are joined, as `CreateUser` does).
## Mailer (real embedded templates, no SMTP)
20. `mailer.MailRepository{}.Render("invitation", data)` with `Host:"https://cms.example.com"`,
`VerificationCode:"abc-123"`, `Email:"lea@example.com"`, `InvitedBy:"Ines Duarte"` returns subject
`"Ines Duarte invited you to RenewCMS"` and a body containing
`https://cms.example.com/invite/accept?c=abc-123`, `lea@example.com` and `Ines Duarte`.
An `InvitedBy` of `<b>x</b>` is HTML-escaped in body. Subjects for `verification` and `password-reset` are
unchanged, and an unknown template still gets the default subject.
21. `internal/infrastructure/mailer/templates/invitation.html` is byte-identical to
`docs/design/source/emails/invitation.html` (read both files and compare).
## Regression: registration (extends registration_gate_and_verification_email.qa.md)
22. With one verified user and a pending invite for `sam.oliveira@renew.example`, `POST /v1/auth/register` with that
email -> `403`, body exactly `apperr.RegistrationClosedErrorMessage`, no user created, no mail sent.
23. Empty user repository -> `POST /v1/auth/register` still `200` with a `jwt` cookie and one `verification`
mail, and `inviteRepo` untouched. `GET /v1/auth/registration` still reports closed when a verified user
and a pending invite exist.
## Manual verification (not exercised by the Go tests)
- Start the app once against a fresh SQLite file: the `invites` table exists (model registered in
`AutoMigrate` in `api/dependecyInjection.go`) and inviting persists across a restart.
- Send a real invitation through a real or sandboxed SMTP server: the subject line, the rendering in a mail
client (light and dark) and that the link `{PUBLIC_URL}/invite/accept?c=...` carries the raw code.
The screen the link opens is not designed and is out of scope, so the link will not resolve in the SPA yet.
- SPA copy for the 429 ("Too many invites. Wait a moment, then try again.") is a note for the later SPA
slice and is not tested here. The SPA keys list rows by kind and id, since a user and an invite can share
a numeric id.
- Layering check by review: use cases import only `internal/domain/*`; the mailer subject logic lives in
`internal/infrastructure/mailer`.