feat(API): user invitations and emails
This commit is contained in:
1 parent
7aae704d0a
commit
acdef18b37
17 files changed
+1687
-7
No files matched your search
@@ -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
|
||||
@@ -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`.
|
||||
Reference in new issue
Block a user