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

+183
View File
@@ -0,0 +1,183 @@
Feature: A verified user can invite another person by email, and the users list shows users and open invites
As a member of the shared RenewCMS workspace
I want to invite someone by email and see who has an account or a pending invite
So that they can later create their own account with exactly the same access, while
registration stays closed to everyone who was not invited
Background:
Given the workspace is shared: there are no roles, no ownership and no way to remove a user
And POST /v1/user/invite and GET /v1/user/ are mounted in NewUserRouter, inside the group that
requires a valid jwt cookie, a current token_version and a verified user
And an invite has an id, an email stored in lower case, a hashed code, an expires_at equal to
its creation time plus 3 days, an invited_by user id, a nullable accepted_at and a created_at
And the raw invite code is a uuid, hashed with the same hash.Hasher used for verification codes,
and only the hash is stored
And an invite's status is derived and never stored: pending when accepted_at is empty and
expires_at is in the future, expired when accepted_at is empty and expires_at is in the past,
accepted when accepted_at is set
And the users list item shape is: id, kind ("user" or "invite"), status, created_at, plus
username and display_name for a user, plus email for an invite; a user is never listed with
an email, and neither the code, its hash, invited_by nor password fields ever appear
And error responses are plain text from http.Error, success responses are JSON
And the invite email uses the template "invitation" with data Host (the configured PublicURL),
VerificationCode (the raw code), Email (the invited address) and InvitedBy (the inviter's
display name, or their username when the display name is empty)
And the email subject is "<InvitedBy> invited you to RenewCMS"
And the link in the email is <Host>/invite/accept?c=<raw code>
And POST /v1/user/invite is rate-limited per client IP with the auth rate limiter
(AUTH_RATE_LIMIT_MAX_ATTEMPTS, AUTH_RATE_LIMIT_WINDOW_SECONDS), independently counted from
/login and /password-reset
Scenario: A verified user invites a new address
Given a user repository containing verified user id 7, username "marta", display_name ""
And an empty invite repository
And a valid jwt cookie for user id 7
When the client sends POST /v1/user/invite with body {"email": "Sam.Oliveira@Renew.example"}
Then the response status is 201
And the response body is a JSON object with exactly the keys id, kind, email, status, created_at
and expires_at, with kind "invite", email "sam.oliveira@renew.example" and status "pending"
And the response body contains no key named code, hashed_code, verification_code or password
And the invite repository holds one invite with email "sam.oliveira@renew.example",
invited_by 7, accepted_at empty and expires_at 3 days after created_at
And the stored code is the hash of the raw code, not the raw code
And exactly one email was sent to "sam.oliveira@renew.example" with template "invitation"
And that email's Host is the configured PublicURL, its VerificationCode is the raw code,
its Email is "sam.oliveira@renew.example" and its InvitedBy is "marta"
Scenario: The email names the inviter by display name when there is one
Given verified user id 7, username "ines", display_name "Ines Duarte"
When the client sends POST /v1/user/invite with body {"email": "lea.martin@renew.example"}
Then the response status is 201
And the email's InvitedBy is "Ines Duarte"
Scenario Outline: An invalid email is rejected before anything is stored or sent
Given verified user id 7 and a valid jwt cookie for user id 7
When the client sends POST /v1/user/invite with body <body>
Then the response status is <status>
And the invite repository is still empty
And no email was sent
Examples:
| body | status |
| {"email": "sam.oliveira@renew"} | 400 |
| {"email": ""} | 400 |
| {} | 400 |
| {"email": "not-an-email"} | 400 |
| not json | 400 |
Scenario: An address that already has an account is refused
Given verified user id 7 and a user "jonas" with email "Jonas@Renew.example"
When the client sends POST /v1/user/invite with body {"email": "jonas@renew.example"}
Then the response status is 409
And the response body is "This address already has an account."
And the invite repository is still empty
And no email was sent
Scenario: An address with a live pending invite is refused
Given verified user id 7 and a pending invite for "sam.oliveira@renew.example" expiring in 2 days
When the client sends POST /v1/user/invite with body {"email": "sam.oliveira@renew.example"}
Then the response status is 409
And the response body is "There's already a pending invite for this address."
And the invite repository still holds only the original invite, unchanged
And no email was sent
Scenario: An expired invite is replaced by a new one
Given verified user id 7 and an expired invite for "p.huber@gmail.com" that expired 1 hour ago
When the client sends POST /v1/user/invite with body {"email": "p.huber@gmail.com"}
Then the response status is 201
And the invite repository holds exactly one invite for "p.huber@gmail.com", pending, with a
new code, and the expired one no longer exists
And exactly one email was sent to "p.huber@gmail.com"
Scenario: A failed email removes the invite
Given verified user id 7 and the mail repository is set to fail its next Send
When the client sends POST /v1/user/invite with body {"email": "lea.martin@renew.example"}
Then the response status is 500
And the response body is "Couldn't send the invite. Try again in a moment."
And the invite repository is empty
When the failure is cleared and the client sends the same request again
Then the response status is 201
Scenario: A failed email does not restore a replaced expired invite
Given an expired invite for "p.huber@gmail.com" and the mail repository set to fail its next Send
When the client sends POST /v1/user/invite with body {"email": "p.huber@gmail.com"}
Then the response status is 500
And the invite repository holds no invite for "p.huber@gmail.com"
Scenario Outline: Only an authenticated, verified, current-token user can use the endpoints
Given the request is made <credentials>
When the client sends <method> <path>
Then the response status is <status>
And no invite was created and no email was sent
Examples:
| credentials | method | path | status |
| with no jwt cookie | POST | /v1/user/invite | 401 |
| with a jwt cookie of an unverified user | POST | /v1/user/invite | 403 |
| with a jwt cookie whose token_version is stale | POST | /v1/user/invite | 401 |
| with no jwt cookie | GET | /v1/user/ | 401 |
| with a jwt cookie of an unverified user | GET | /v1/user/ | 403 |
Scenario: The invite endpoint is rate-limited
Given AUTH_RATE_LIMIT_MAX_ATTEMPTS is 2 for a window covering the whole test
When the client at IP "203.0.113.20" sends 2 valid POST /v1/user/invite requests
And then sends a 3rd
Then the 3rd response is 429 with body apperr.TooManyRequestsErrorMessage and a Retry-After header
And no invite was created and no email was sent for the 3rd request
And GET /v1/user/ from the same client is not rejected with 429
Scenario: The list shows users and open invites, newest first
Given verified user id 7 "marta" created 12 Mar 2026
And user "jonas" created 2 Apr 2026
And user "ines" with display_name "Ines Duarte" created 18 Jun 2026
And an expired invite for "p.huber@gmail.com" created 3 Aug 2026
And a pending invite for "sam.oliveira@renew.example" created 27 Sep 2026
And an accepted invite for "jonas@renew.example" created 1 Apr 2026
When the client sends GET /v1/user/ with a valid jwt cookie for user id 7
Then the response status is 200
And the response body is a JSON array of exactly 5 items in this order: the pending invite for
"sam.oliveira@renew.example", the expired invite for "p.huber@gmail.com", user "ines", user
"jonas", user "marta"
And the accepted invite is not listed
And the invite items have exactly the keys id, kind, email, status, created_at, with kind
"invite" and status "pending" or "expired"
And the user items have exactly the keys id, kind, username, display_name, status, created_at,
with kind "user" and status "accepted"
And no user item has an email key, and no item has a key named password, password_reset_code,
verification_code, verification_expiration, password_reset_expiration, token_version,
code, hashed_code, invited_by or accepted_at
Scenario: With nobody invited the list contains only the registered user
Given only verified user id 7 "marta"
When the client sends GET /v1/user/ with a valid jwt cookie for user id 7
Then the response status is 200
And the response body is a JSON array of exactly one item, kind "user", username "marta",
status "accepted"
Scenario: An invite turns from pending to expired with time, without any stored status
Given an invite created 3 days and 1 minute ago that has not been accepted
When the client sends GET /v1/user/
Then that invite's status is "expired"
Scenario: Registration stays closed for uninvited people
Given a user repository containing one verified user
And a pending invite for "sam.oliveira@renew.example"
When the client sends POST /v1/auth/register with username "sam", password "hunter2example12",
email "sam.oliveira@renew.example"
Then the response status is 403
And the response body is exactly apperr.RegistrationClosedErrorMessage
And no user was created and no email was sent
Scenario: Registration on an empty instance is unchanged
Given the user repository is empty
When the client sends POST /v1/auth/register with username "bob", password "hunter2example12",
email "bob@example.com"
Then the response status is 200
And a verification email is sent with template "verification"
And no invite exists
Scenario: Inviting someone never changes GET /v1/auth/registration
Given a user repository containing one verified user
When a pending invite exists
Then GET /v1/auth/registration still reports registration closed
+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`.