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

+8 -1
View File
@@ -3,6 +3,7 @@ package api
import (
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
domainInvite "RenewCMS/internal/domain/invite"
domainMail "RenewCMS/internal/domain/mail"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
@@ -35,6 +36,8 @@ type UseCases struct {
ResetPasswordUseCase *useCases.ResetPasswordUseCase
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
ListUsersUseCase *useCases.ListUsersUseCase
CreateInviteUseCase *useCases.CreateInviteUseCase
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
SendMailUseCase *useCases.SendMailUseCase
CreateImageUseCase *useCases.CreateImageUseCase
DeleteImageUseCase *useCases.DeleteImageUseCase
@@ -44,6 +47,7 @@ type Repositories struct {
ArticleRepo domainArticle.Repository
UserRepo domainUser.Repository
ImageRepo domainImage.Repository
InviteRepo domainInvite.Repository
MailRepo domainMail.Repository
}
@@ -56,7 +60,7 @@ func getDb(dbName string) *gorm.DB {
panic("Unable to open the database: " + err.Error())
}
if err := db.AutoMigrate(&models.Article{}, &models.User{}); err != nil {
if err := db.AutoMigrate(&models.Article{}, &models.User{}, &models.Invite{}); err != nil {
panic("Failed to migrate database: " + err.Error())
}
@@ -68,6 +72,7 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
ArticleRepo: persistence.NewArticleRepository(db),
UserRepo: persistence.NewUserRepository(db),
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir),
InviteRepo: persistence.NewInviteRepository(db),
MailRepo: mailer.NewMailRepository(settings.Smtp),
}
}
@@ -91,6 +96,8 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo),
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo),
DeleteImageUseCase: useCases.NewDeleteImageUseCase(repos.ImageRepo),
+136
View File
@@ -0,0 +1,136 @@
package user
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"time"
)
type InviteHandler struct {
CreateInviteUseCase *useCases.CreateInviteUseCase
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
}
type CreateInviteRequest struct {
Email string `json:"email" validate:"required,email"`
}
type inviteResponse struct {
ID uint32 `json:"id"`
Kind string `json:"kind"`
Email string `json:"email"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type inviteListItem struct {
ID uint32 `json:"id"`
Kind string `json:"kind"`
Email string `json:"email"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
type userListItem struct {
ID uint32 `json:"id"`
Kind string `json:"kind"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
func NewInviteHandler(createInvite *useCases.CreateInviteUseCase, listUsersAndInvites *useCases.ListUsersAndInvitesUseCase) *InviteHandler {
return &InviteHandler{
CreateInviteUseCase: createInvite,
ListUsersAndInvitesUseCase: listUsersAndInvites,
}
}
func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
var request CreateInviteRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
created, err := h.CreateInviteUseCase.CreateInvite(useCases.CreateInviteCommand{
Email: request.Email,
InvitedBy: userId,
})
if err != nil {
writeCreateInviteError(w, err)
return
}
body, err := json.Marshal(inviteResponse{
ID: created.ID,
Kind: useCases.EntryKindInvite,
Email: created.Email,
Status: string(created.StatusAt(time.Now())),
CreatedAt: created.CreatedAt,
ExpiresAt: created.ExpiresAt,
})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(body)
}
func writeCreateInviteError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, invite.ErrEmailHasAccount):
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrPendingInvite):
http.Error(w, apperr.PendingInviteErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrInviteEmailNotSent):
http.Error(w, apperr.InviteNotSentErrorMessage, http.StatusInternalServerError)
case errors.Is(err, user.ErrUserNotFound):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (h *InviteHandler) ListUsers(w http.ResponseWriter, _ *http.Request) {
entries := h.ListUsersAndInvitesUseCase.ListUsersAndInvites()
items := make([]any, 0, len(entries))
for _, entry := range entries {
if entry.Kind == useCases.EntryKindInvite {
items = append(items, inviteListItem{entry.ID, entry.Kind, entry.Email, entry.Status, entry.CreatedAt})
continue
}
items = append(items, userListItem{entry.ID, entry.Kind, entry.Username, entry.DisplayName, entry.Status, entry.CreatedAt})
}
body, err := json.Marshal(items)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
+3
View File
@@ -16,3 +16,6 @@ const VerificationRequiredErrorMessage = "You must verify your account to access
const PasswordResetRequestedMessage = "If an account exists for that email, a password reset link has been sent."
const PasswordResetFailedErrorMessage = "The password reset code is invalid or has expired."
const TooManyRequestsErrorMessage = "Too many requests. Please try again later."
const EmailHasAccountErrorMessage = "This address already has an account."
const PendingInviteErrorMessage = "There's already a pending invite for this address."
const InviteNotSentErrorMessage = "Couldn't send the invite. Try again in a moment."
+9 -2
View File
@@ -88,16 +88,23 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
return r
}
func NewUserRouter(container *UseCases) http.Handler {
func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
r := chi.NewRouter()
userHandler := user.NewHandler(container.UpdateUserProfileUseCase)
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.ListUsersAndInvitesUseCase)
r.Use(jwtauth.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Patch("/me", userHandler.UpdateMe)
r.Get("/", inviteHandler.ListUsers)
r.Group(func(r chi.Router) {
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
r.Post("/invite", inviteHandler.CreateInvite)
})
return r
}
@@ -113,7 +120,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Use(jwtauth.Verifier(container.Token))
r.Mount("/article", NewArticleRouter(container))
})
r.Mount("/user", NewUserRouter(container))
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
return r
+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`.
+41
View File
@@ -0,0 +1,41 @@
package invite
import (
"errors"
"time"
)
var ErrEmailHasAccount = errors.New("email already has an account")
var ErrPendingInvite = errors.New("pending invite already exists")
var ErrInviteNotFound = errors.New("invite not found")
var ErrInviteEmailNotSent = errors.New("invite email not sent")
const Lifetime = 3 * 24 * time.Hour
type Status string
const (
StatusPending Status = "pending"
StatusExpired Status = "expired"
StatusAccepted Status = "accepted"
)
type Invite struct {
ID uint32 `json:"id"`
Email string `json:"email"`
HashedCode string `json:"hashed_code"`
ExpiresAt time.Time `json:"expires_at"`
InvitedBy uint32 `json:"invited_by"`
AcceptedAt *time.Time `json:"accepted_at"`
CreatedAt time.Time `json:"created_at"`
}
func (i Invite) StatusAt(now time.Time) Status {
if i.AcceptedAt != nil {
return StatusAccepted
}
if i.ExpiresAt.After(now) {
return StatusPending
}
return StatusExpired
}
@@ -0,0 +1,7 @@
package invite
type Repository interface {
Create(invite Invite) (Invite, error)
GetAll() []Invite
Delete(id uint32) error
}
@@ -24,14 +24,25 @@ func NewMailRepository(smtp config.SmtpSettings) *MailRepository {
const defaultSubject = "RenewCMS | Action required"
func (m MailRepository) Render(templateName string, data any) (string, string, error) {
subject := defaultSubject
type subjectProvider interface {
Subject() string
}
func subjectFor(templateName string, data any) string {
if provider, ok := data.(subjectProvider); ok {
return provider.Subject()
}
switch templateName {
case "verification":
subject = "Verify your RenewCMS account"
return "Verify your RenewCMS account"
case "password-reset":
subject = "Reset your RenewCMS password"
return "Reset your RenewCMS password"
}
return defaultSubject
}
func (m MailRepository) Render(templateName string, data any) (string, string, error) {
subject := subjectFor(templateName, data)
tmpl, err := template.ParseFS(mailTemplateFiles, "templates/"+templateName+".html")
if err != nil {
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>You're invited to RenewCMS</title>
<style>
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
table { border-collapse: collapse; }
a { color: #111111; }
@media (max-width: 620px) {
.rc-card { padding: 28px 20px !important; }
.rc-btn a { display: block !important; }
}
@media (prefers-color-scheme: dark) {
.rc-bg { background-color: #0a0a0a !important; }
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
.rc-h1, .rc-word { color: #fafafa !important; }
.rc-text { color: #e6e4e0 !important; }
.rc-muted { color: #999999 !important; }
.rc-mark { border: 1px solid #444444 !important; }
.rc-btn td { background-color: #fafafa !important; }
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
.rc-rule { border-color: #333333 !important; }
}
</style>
</head>
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">{{.InvitedBy}} invited you to work on articles in RenewCMS. The invite expires in 3 days.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
<tr>
<td align="center" style="padding:40px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
<tr>
<td style="padding:0 0 20px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">You're invited to RenewCMS</h1>
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">{{.InvitedBy}} invited you to write and publish articles in RenewCMS at {{.Host}}. Accept to choose a username and password. You'll sign in with your own account.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="rc-btn">
<tr>
<td bgcolor="#111111" style="background-color:#111111;">
<a href="{{.Host}}/invite/accept?c={{.VerificationCode}}" target="_blank" style="display:inline-block;padding:14px 24px;border:1px solid #111111;background-color:#111111;color:#ffffff;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:16px;font-weight:600;line-height:20px;text-decoration:none;text-align:center;">Accept invite</a>
</td>
</tr>
</table>
<p class="rc-muted" style="margin:16px 0 0 0;font-size:14px;line-height:22px;color:#666666;">This invite was sent to {{.Email}}. It expires in 3 days and works once.</p>
<p class="rc-muted" style="margin:28px 0 8px 0;font-size:14px;line-height:22px;color:#666666;">If the button doesn't work, copy this address into your browser:</p>
<p class="rc-url" style="margin:0;padding:10px 12px;background-color:#f3f2f0;font-family:'IBM Plex Mono',Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#222222;word-break:break-all;">{{.Host}}/invite/accept?c={{.VerificationCode}}</p>
<p class="rc-text rc-rule" style="margin:28px 0 0 0;padding-top:20px;border-top:1px solid #e6e4e0;font-size:14px;line-height:22px;color:#222222;">Not expecting this? Ignore this email and no account will be created. If the invite has expired, ask {{.InvitedBy}} to send a new one.</p>
</td>
</tr>
<tr>
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,30 @@
package mappers
import (
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/infrastructure/persistence/models"
)
func InviteToDomain(m models.Invite) invite.Invite {
return invite.Invite{
ID: m.ID,
Email: m.Email,
HashedCode: m.HashedCode,
ExpiresAt: m.ExpiresAt,
InvitedBy: m.InvitedBy,
AcceptedAt: m.AcceptedAt,
CreatedAt: m.CreatedAt,
}
}
func InviteToModel(i invite.Invite) models.Invite {
return models.Invite{
ID: i.ID,
Email: i.Email,
HashedCode: i.HashedCode,
ExpiresAt: i.ExpiresAt,
InvitedBy: i.InvitedBy,
AcceptedAt: i.AcceptedAt,
CreatedAt: i.CreatedAt,
}
}
@@ -0,0 +1,13 @@
package models
import "time"
type Invite struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
Email string `gorm:"index;not null"`
HashedCode string `gorm:"not null"`
ExpiresAt time.Time
InvitedBy uint32 `gorm:"not null"`
AcceptedAt *time.Time
CreatedAt time.Time `gorm:"autoCreateTime"`
}
@@ -0,0 +1,46 @@
package persistence
import (
domainInvite "RenewCMS/internal/domain/invite"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
type InviteRepository struct {
db *gorm.DB
}
func NewInviteRepository(db *gorm.DB) *InviteRepository {
return &InviteRepository{db}
}
func (i *InviteRepository) Create(invite domainInvite.Invite) (domainInvite.Invite, error) {
model := mappers.InviteToModel(invite)
model.ID = 0
if err := i.db.Create(&model).Error; err != nil {
return domainInvite.Invite{}, err
}
return mappers.InviteToDomain(model), nil
}
func (i *InviteRepository) GetAll() []domainInvite.Invite {
var invites []entity.Invite
i.db.Model(&entity.Invite{}).Find(&invites)
var domainInvites []domainInvite.Invite
for _, localInvite := range invites {
domainInvites = append(domainInvites, mappers.InviteToDomain(localInvite))
}
return domainInvites
}
func (i *InviteRepository) Delete(id uint32) error {
result := i.db.Delete(&entity.Invite{}, id)
return deleteOrNotFound(result, domainInvite.ErrInviteNotFound)
}
var _ domainInvite.Repository = &InviteRepository{}
@@ -0,0 +1,128 @@
package useCases
import (
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/user"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
const inviteTemplateName = "invitation"
type CreateInviteUseCase struct {
userRepository user.Repository
inviteRepository invite.Repository
hasher hash.Hasher
mailRepository mail.Repository
publicURL string
}
type CreateInviteCommand struct {
Email string
InvitedBy uint32
}
type MailInviteData struct {
Host string
VerificationCode string
Email string
InvitedBy string
}
func (d MailInviteData) Subject() string {
inviter := strings.NewReplacer("\r", " ", "\n", " ").Replace(d.InvitedBy)
return inviter + " invited you to RenewCMS"
}
func NewCreateInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string) *CreateInviteUseCase {
return &CreateInviteUseCase{userRepository, inviteRepository, hasher, mailRepository, publicURL}
}
func (g *CreateInviteUseCase) CreateInvite(command CreateInviteCommand) (invite.Invite, error) {
email := strings.ToLower(command.Email)
now := time.Now()
inviter, err := g.userRepository.Get(command.InvitedBy)
if err != nil {
return invite.Invite{}, err
}
if g.emailHasAccount(email) {
return invite.Invite{}, invite.ErrEmailHasAccount
}
if err := g.discardOpenInvite(email, now); err != nil {
return invite.Invite{}, err
}
rawCode := uuid.NewString()
hashedCode, err := g.hasher.Hash(rawCode)
if err != nil {
return invite.Invite{}, err
}
createdInvite, err := g.inviteRepository.Create(invite.Invite{
Email: email,
HashedCode: hashedCode,
ExpiresAt: now.Add(invite.Lifetime),
InvitedBy: command.InvitedBy,
CreatedAt: now,
})
if err != nil {
return invite.Invite{}, err
}
if err := g.mailRepository.Send(email, inviteTemplateName, MailInviteData{
Host: g.publicURL,
VerificationCode: rawCode,
Email: email,
InvitedBy: inviterName(inviter),
}); err != nil {
sendErr := fmt.Errorf("%w: %w", invite.ErrInviteEmailNotSent, err)
if deleteErr := g.inviteRepository.Delete(createdInvite.ID); deleteErr != nil {
return invite.Invite{}, errors.Join(sendErr, deleteErr)
}
return invite.Invite{}, sendErr
}
return createdInvite, nil
}
func (g *CreateInviteUseCase) emailHasAccount(email string) bool {
for _, existingUser := range g.userRepository.GetAll() {
if strings.EqualFold(existingUser.Email, email) {
return true
}
}
return false
}
func (g *CreateInviteUseCase) discardOpenInvite(email string, now time.Time) error {
for _, existing := range g.inviteRepository.GetAll() {
if existing.Email != email {
continue
}
switch existing.StatusAt(now) {
case invite.StatusPending:
return invite.ErrPendingInvite
case invite.StatusExpired:
if err := g.inviteRepository.Delete(existing.ID); err != nil {
return err
}
}
}
return nil
}
func inviterName(inviter user.User) string {
if inviter.DisplayName != "" {
return inviter.DisplayName
}
return inviter.Username
}
@@ -0,0 +1,68 @@
package useCases
import (
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user"
"sort"
"time"
)
const (
EntryKindUser = "user"
EntryKindInvite = "invite"
)
type ListUsersAndInvitesUseCase struct {
userRepository user.Repository
inviteRepository invite.Repository
}
type DirectoryEntry struct {
ID uint32
Kind string
Username string
DisplayName string
Email string
Status string
CreatedAt time.Time
}
func NewListUsersAndInvitesUseCase(userRepository user.Repository, inviteRepository invite.Repository) *ListUsersAndInvitesUseCase {
return &ListUsersAndInvitesUseCase{userRepository, inviteRepository}
}
func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() []DirectoryEntry {
now := time.Now()
entries := []DirectoryEntry{}
for _, existingUser := range g.userRepository.GetAll() {
entries = append(entries, DirectoryEntry{
ID: existingUser.ID,
Kind: EntryKindUser,
Username: existingUser.Username,
DisplayName: existingUser.DisplayName,
Status: string(invite.StatusAccepted),
CreatedAt: existingUser.CreatedAt,
})
}
for _, existingInvite := range g.inviteRepository.GetAll() {
status := existingInvite.StatusAt(now)
if status == invite.StatusAccepted {
continue
}
entries = append(entries, DirectoryEntry{
ID: existingInvite.ID,
Kind: EntryKindInvite,
Email: existingInvite.Email,
Status: string(status),
CreatedAt: existingInvite.CreatedAt,
})
}
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].CreatedAt.After(entries[j].CreatedAt)
})
return entries
}
+114
View File
@@ -0,0 +1,114 @@
package test
import (
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"net/http"
"os"
"strings"
"testing"
"time"
)
func TestInvitationsQaMailer(t *testing.T) {
repo := mailer.NewMailRepository(config.SmtpSettings{})
t.Run("The invitation renders with the inviter, the address and the accept link", func(t *testing.T) {
data := useCases.MailInviteData{Host: invitePublicURL, VerificationCode: "abc-123", Email: "lea@example.com", InvitedBy: "Ines Duarte"}
subject, body, err := repo.Render("invitation", data)
if err != nil {
t.Fatalf("unexpected error %v", err)
}
if subject != "Ines Duarte invited you to RenewCMS" {
t.Errorf("unexpected subject %q", subject)
}
for _, want := range []string{invitePublicURL + "/invite/accept?c=abc-123", "lea@example.com", "Ines Duarte"} {
if !strings.Contains(body, want) {
t.Errorf("body misses %q", want)
}
}
})
t.Run("The inviter name is HTML-escaped in the body", func(t *testing.T) {
data := useCases.MailInviteData{Host: invitePublicURL, VerificationCode: "abc-123", Email: "lea@example.com", InvitedBy: "<b>x</b>"}
_, body, err := repo.Render("invitation", data)
if err != nil {
t.Fatalf("unexpected error %v", err)
}
if strings.Contains(body, "<b>x</b>") || !strings.Contains(body, "&lt;b&gt;x&lt;/b&gt;") {
t.Errorf("inviter name was not escaped")
}
})
t.Run("Other subjects are unchanged", func(t *testing.T) {
reset, _, err := repo.Render("password-reset", useCases.MailPasswordResetData{Host: invitePublicURL, VerificationCode: "x", Email: "a@b.example"})
if err != nil || reset != "Reset your RenewCMS password" {
t.Errorf("unexpected reset subject %q, %v", reset, err)
}
verification, _, err := repo.Render("verification", useCases.MailValidationData{Host: invitePublicURL, VerificationCode: "x"})
if err != nil || verification != "Verify your RenewCMS account" {
t.Errorf("unexpected verification subject %q, %v", verification, err)
}
})
t.Run("The shipped template is byte-identical to the design export", func(t *testing.T) {
shipped, err := os.ReadFile("../internal/infrastructure/mailer/templates/invitation.html")
if err != nil {
t.Fatal(err)
}
design, err := os.ReadFile("../docs/design/source/emails/invitation.html")
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(shipped, design) {
t.Errorf("template differs from docs/design/source/emails/invitation.html")
}
})
}
func TestInvitationsQaList(t *testing.T) {
t.Run("Invite status follows its age", func(t *testing.T) {
f := newInviteFixture(t, 100)
now := time.Now()
f.invites.seed(inviteAt("old@renew.example", now.Add(-72*time.Hour-time.Minute), now.Add(-time.Minute)))
f.invites.seed(inviteAt("fresh@renew.example", now.Add(-48*time.Hour), now.Add(24*time.Hour)))
acceptedAt := now
accepted := inviteAt("done@renew.example", now.Add(-time.Hour), now.Add(71*time.Hour))
accepted.AcceptedAt = &acceptedAt
f.invites.seed(accepted)
rec := f.list(t)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
statuses := map[string]string{}
for _, item := range decodeList(t, rec.Body.Bytes()) {
if decodedString(t, item, "kind") == "invite" {
statuses[decodedString(t, item, "email")] = decodedString(t, item, "status")
}
}
if len(statuses) != 2 || statuses["old@renew.example"] != "expired" || statuses["fresh@renew.example"] != "pending" {
t.Errorf("unexpected statuses %v", statuses)
}
})
t.Run("The list never carries secrets or user emails", func(t *testing.T) {
f := newInviteFixture(t, 100)
f.invites.seed(pendingInvite("sam@renew.example"))
body := f.list(t).Body.String()
for _, forbidden := range []string{"\"code\"", "hashed_code", "verification_code", "password", "marta@renew.example"} {
if strings.Contains(body, forbidden) {
t.Errorf("list leaks %q", forbidden)
}
}
})
}
+715
View File
@@ -0,0 +1,715 @@
package test
import (
"RenewCMS/api/handlers/auth"
userhandler "RenewCMS/api/handlers/user"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
const invitePublicURL = "https://cms.example.com"
type inviteFakeRepo struct {
invites map[uint32]invite.Invite
nextID uint32
fail failSwitch
}
func newInviteFakeRepo() *inviteFakeRepo {
return &inviteFakeRepo{invites: make(map[uint32]invite.Invite)}
}
func (m *inviteFakeRepo) seed(i invite.Invite) invite.Invite {
m.nextID++
i.ID = m.nextID
m.invites[i.ID] = i
return i
}
func (m *inviteFakeRepo) Create(i invite.Invite) (invite.Invite, error) {
if err := m.fail.trigger("Create"); err != nil {
return invite.Invite{}, err
}
return m.seed(i), nil
}
func (m *inviteFakeRepo) GetAll() []invite.Invite {
ids := make([]uint32, 0, len(m.invites))
for id := range m.invites {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
result := make([]invite.Invite, 0, len(ids))
for _, id := range ids {
result = append(result, m.invites[id])
}
return result
}
func (m *inviteFakeRepo) Delete(id uint32) error {
if err := m.fail.trigger("Delete"); err != nil {
return err
}
if _, ok := m.invites[id]; !ok {
return invite.ErrInviteNotFound
}
delete(m.invites, id)
return nil
}
var _ invite.Repository = &inviteFakeRepo{}
func inviteAt(email string, createdAt time.Time, expiresAt time.Time) invite.Invite {
return invite.Invite{Email: email, HashedCode: "hashed:seed", InvitedBy: 7, CreatedAt: createdAt, ExpiresAt: expiresAt}
}
func pendingInvite(email string) invite.Invite {
now := time.Now()
return inviteAt(email, now.Add(-24*time.Hour), now.Add(48*time.Hour))
}
func expiredInvite(email string) invite.Invite {
now := time.Now()
return inviteAt(email, now.Add(-73*time.Hour), now.Add(-time.Hour))
}
func inviteInviterRepo(displayName string) *mockUserRepo {
repo := newMockUserRepo()
repo.users[7] = user.User{ID: 7, Username: "marta", DisplayName: displayName, Email: "marta@renew.example", IsVerified: true}
repo.nextID = 7
return repo
}
type inviteFixture struct {
users *mockUserRepo
invites *inviteFakeRepo
mail *mockMailRepo
hasher *stubHasher
router *chi.Mux
token *jwtauth.JWTAuth
}
func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture {
t.Helper()
validator.InitValidator()
f := &inviteFixture{
users: inviteInviterRepo(""),
invites: newInviteFakeRepo(),
mail: newMockMailRepo(),
hasher: &stubHasher{},
token: jwtauth.New("HS256", []byte("test-secret"), nil),
}
getUser := useCases.NewGetUserUseCase(f.users)
handler := userhandler.NewInviteHandler(
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL),
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
)
f.router = chi.NewRouter()
f.router.Route("/v1/user", func(r chi.Router) {
r.Use(jwtauth.Verifier(f.token))
r.Use(jwtauth.Authenticator(f.token))
r.Use(middleware.RequireCurrentToken(getUser))
r.Use(middleware.RequireVerifiedUser(getUser))
r.Get("/", handler.ListUsers)
r.Group(func(r chi.Router) {
r.Use(middleware.TrustedProxyRealIP(nil))
r.Use(middleware.NewRateLimiter(maxAttempts, time.Hour))
r.Post("/invite", handler.CreateInvite)
})
})
return f
}
func (f *inviteFixture) do(t *testing.T, method, target, body string, cookie *http.Cookie, remoteAddr string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, target, bytes.NewBufferString(body))
req.RemoteAddr = remoteAddr + ":4321"
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
f.router.ServeHTTP(rec, req)
return rec
}
func (f *inviteFixture) post(t *testing.T, body string) *httptest.ResponseRecorder {
t.Helper()
return f.do(t, http.MethodPost, "/v1/user/invite", body, displayNameCookie(t, f.token, 7, 0), "203.0.113.1")
}
func (f *inviteFixture) list(t *testing.T) *httptest.ResponseRecorder {
t.Helper()
return f.do(t, http.MethodGet, "/v1/user/", "", displayNameCookie(t, f.token, 7, 0), "203.0.113.1")
}
func assertExactKeys(t *testing.T, raw map[string]json.RawMessage, keys ...string) {
t.Helper()
if len(raw) != len(keys) {
t.Errorf("expected exactly keys %v, got %d keys", keys, len(raw))
}
for _, key := range keys {
if _, ok := raw[key]; !ok {
t.Errorf("missing key %q", key)
}
}
}
func decodeList(t *testing.T, body []byte) []map[string]json.RawMessage {
t.Helper()
var items []map[string]json.RawMessage
if err := json.Unmarshal(body, &items); err != nil {
t.Fatalf("failed to decode list %q: %v", string(body), err)
}
return items
}
func inviteMailData(t *testing.T, m *mockMailRepo) useCases.MailInviteData {
t.Helper()
if len(m.SentEmails) != 1 {
t.Fatalf("expected exactly one email, got %d", len(m.SentEmails))
}
data, ok := m.SentEmails[0].Data.(useCases.MailInviteData)
if !ok {
t.Fatalf("unexpected mail data %T", m.SentEmails[0].Data)
}
return data
}
func TestInvitationsHttp(t *testing.T) {
t.Run("A verified user invites a new address", func(t *testing.T) {
f := newInviteFixture(t, 100)
rec := f.post(t, `{"email": "Sam.Oliveira@Renew.example"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
}
decoded := decodeKeys(t, rec.Body.Bytes())
assertExactKeys(t, decoded, "id", "kind", "email", "status", "created_at", "expires_at")
if decodedString(t, decoded, "kind") != "invite" || decodedString(t, decoded, "email") != "sam.oliveira@renew.example" || decodedString(t, decoded, "status") != "pending" {
t.Errorf("unexpected body: %s", rec.Body.String())
}
if len(f.invites.invites) != 1 {
t.Fatalf("expected one stored invite, got %d", len(f.invites.invites))
}
stored := f.invites.invites[1]
data := inviteMailData(t, f.mail)
if stored.Email != "sam.oliveira@renew.example" || stored.InvitedBy != 7 || stored.AcceptedAt != nil {
t.Errorf("unexpected stored invite: %+v", stored)
}
if stored.ExpiresAt.Sub(stored.CreatedAt) != 3*24*time.Hour {
t.Errorf("expected 3 day lifetime, got %v", stored.ExpiresAt.Sub(stored.CreatedAt))
}
if stored.HashedCode == data.VerificationCode || stored.HashedCode != "hashed:"+data.VerificationCode {
t.Errorf("stored code must be the hash of the raw code: %q vs %q", stored.HashedCode, data.VerificationCode)
}
if f.mail.SentEmails[0].Address != "sam.oliveira@renew.example" || f.mail.SentEmails[0].Template != "invitation" {
t.Errorf("unexpected mail: %+v", f.mail.SentEmails[0])
}
if data.Host != invitePublicURL || data.Email != "sam.oliveira@renew.example" || data.InvitedBy != "marta" || data.VerificationCode == "" {
t.Errorf("unexpected mail data: %+v", data)
}
for _, forbidden := range []string{"code", "hashed_code", "verification_code", "password"} {
if _, present := decoded[forbidden]; present {
t.Errorf("forbidden key %q in body", forbidden)
}
}
})
t.Run("The email names the inviter by display name when there is one", func(t *testing.T) {
f := newInviteFixture(t, 100)
f.users.users[7] = user.User{ID: 7, Username: "ines", DisplayName: "Ines Duarte", IsVerified: true}
rec := f.post(t, `{"email": "lea.martin@renew.example"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", rec.Code)
}
if got := inviteMailData(t, f.mail).InvitedBy; got != "Ines Duarte" {
t.Errorf("expected display name, got %q", got)
}
})
invalidBodies := []string{`{"email": "sam.oliveira@renew"}`, `{"email": ""}`, `{}`, `{"email": "not-an-email"}`, `not json`}
for _, body := range invalidBodies {
t.Run("An invalid email is rejected before anything is stored or sent: "+body, func(t *testing.T) {
f := newInviteFixture(t, 100)
rec := f.post(t, body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
if len(f.invites.invites) != 0 || len(f.mail.SentEmails) != 0 {
t.Errorf("nothing may be stored or sent")
}
})
}
t.Run("An address that already has an account is refused", func(t *testing.T) {
f := newInviteFixture(t, 100)
f.users.users[8] = user.User{ID: 8, Username: "jonas", Email: "Jonas@Renew.example"}
rec := f.post(t, `{"email": "jonas@renew.example"}`)
if rec.Code != http.StatusConflict || rec.Body.String() != apperr.EmailHasAccountErrorMessage+"\n" {
t.Errorf("expected 409, got %d: %q", rec.Code, rec.Body.String())
}
if len(f.invites.invites) != 0 || len(f.mail.SentEmails) != 0 {
t.Errorf("nothing may be stored or sent")
}
})
t.Run("An address with a live pending invite is refused", func(t *testing.T) {
f := newInviteFixture(t, 100)
original := f.invites.seed(pendingInvite("sam.oliveira@renew.example"))
rec := f.post(t, `{"email": "sam.oliveira@renew.example"}`)
if rec.Code != http.StatusConflict || rec.Body.String() != apperr.PendingInviteErrorMessage+"\n" {
t.Errorf("expected 409, got %d: %q", rec.Code, rec.Body.String())
}
if len(f.invites.invites) != 1 || f.invites.invites[original.ID] != original {
t.Errorf("original invite must be unchanged")
}
if len(f.mail.SentEmails) != 0 {
t.Errorf("no email may be sent")
}
})
t.Run("An expired invite is replaced by a new one", func(t *testing.T) {
f := newInviteFixture(t, 100)
old := f.invites.seed(expiredInvite("p.huber@gmail.com"))
rec := f.post(t, `{"email": "p.huber@gmail.com"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
}
if len(f.invites.invites) != 1 {
t.Fatalf("expected exactly one invite, got %d", len(f.invites.invites))
}
for _, stored := range f.invites.invites {
if stored.ID == old.ID || stored.HashedCode == old.HashedCode || stored.StatusAt(time.Now()) != invite.StatusPending {
t.Errorf("expected a new pending invite, got %+v", stored)
}
}
if len(f.mail.SentEmails) != 1 || f.mail.SentEmails[0].Address != "p.huber@gmail.com" {
t.Errorf("expected exactly one email to p.huber@gmail.com")
}
})
t.Run("A failed email removes the invite", func(t *testing.T) {
f := newInviteFixture(t, 100)
f.mail.fail.failNext("Send")
rec := f.post(t, `{"email": "lea.martin@renew.example"}`)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != apperr.InviteNotSentErrorMessage+"\n" {
t.Errorf("expected 500, got %d: %q", rec.Code, rec.Body.String())
}
if len(f.invites.invites) != 0 {
t.Errorf("invite must be removed")
}
if again := f.post(t, `{"email": "lea.martin@renew.example"}`); again.Code != http.StatusCreated {
t.Errorf("expected 201 on retry, got %d", again.Code)
}
})
t.Run("A failed email does not restore a replaced expired invite", func(t *testing.T) {
f := newInviteFixture(t, 100)
f.invites.seed(expiredInvite("p.huber@gmail.com"))
f.mail.fail.failNext("Send")
rec := f.post(t, `{"email": "p.huber@gmail.com"}`)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rec.Code)
}
if len(f.invites.invites) != 0 {
t.Errorf("expected no invite for p.huber@gmail.com")
}
})
t.Run("A failed cleanup after a failed email is still reported as 500", func(t *testing.T) {
f := newInviteFixture(t, 100)
f.mail.fail.failNext("Send")
f.invites.fail.failNext("Delete")
rec := f.post(t, `{"email": "lea.martin@renew.example"}`)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != apperr.InviteNotSentErrorMessage+"\n" {
t.Errorf("expected 500, got %d: %q", rec.Code, rec.Body.String())
}
})
t.Run("A storage failure answers a generic 500 and sends nothing", func(t *testing.T) {
f := newInviteFixture(t, 100)
f.invites.fail.failNext("Create")
rec := f.post(t, `{"email": "lea.martin@renew.example"}`)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" {
t.Errorf("expected generic 500, got %d: %q", rec.Code, rec.Body.String())
}
if len(f.mail.SentEmails) != 0 {
t.Errorf("no email may be sent")
}
})
t.Run("Only an authenticated, verified, current-token user can use the endpoints", func(t *testing.T) {
cases := []struct {
name, method, path string
cookie func(f *inviteFixture) *http.Cookie
status int
}{
{"POST with no jwt cookie", http.MethodPost, "/v1/user/invite", func(*inviteFixture) *http.Cookie { return nil }, http.StatusUnauthorized},
{"POST unverified user", http.MethodPost, "/v1/user/invite", func(f *inviteFixture) *http.Cookie { return displayNameCookie(t, f.token, 8, 0) }, http.StatusForbidden},
{"POST stale token_version", http.MethodPost, "/v1/user/invite", func(f *inviteFixture) *http.Cookie { return displayNameCookie(t, f.token, 7, 5) }, http.StatusUnauthorized},
{"GET with no jwt cookie", http.MethodGet, "/v1/user/", func(*inviteFixture) *http.Cookie { return nil }, http.StatusUnauthorized},
{"GET unverified user", http.MethodGet, "/v1/user/", func(f *inviteFixture) *http.Cookie { return displayNameCookie(t, f.token, 8, 0) }, http.StatusForbidden},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newInviteFixture(t, 100)
f.users.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false}
rec := f.do(t, tc.method, tc.path, `{"email": "lea.martin@renew.example"}`, tc.cookie(f), "203.0.113.1")
if rec.Code != tc.status {
t.Errorf("expected %d, got %d", tc.status, rec.Code)
}
if len(f.invites.invites) != 0 || len(f.mail.SentEmails) != 0 {
t.Errorf("nothing may be created or sent")
}
})
}
})
t.Run("The invite endpoint is rate-limited", func(t *testing.T) {
f := newInviteFixture(t, 2)
cookie := displayNameCookie(t, f.token, 7, 0)
ip := "203.0.113.20"
for _, email := range []string{"a@renew.example", "b@renew.example"} {
if rec := f.do(t, http.MethodPost, "/v1/user/invite", `{"email": "`+email+`"}`, cookie, ip); rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", rec.Code)
}
}
rec := f.do(t, http.MethodPost, "/v1/user/invite", `{"email": "c@renew.example"}`, cookie, ip)
if rec.Code != http.StatusTooManyRequests || rec.Body.String() != apperr.TooManyRequestsErrorMessage+"\n" || rec.Header().Get("Retry-After") == "" {
t.Errorf("expected 429 with Retry-After, got %d: %q", rec.Code, rec.Body.String())
}
if len(f.invites.invites) != 2 || len(f.mail.SentEmails) != 2 {
t.Errorf("the 3rd request must not create or send")
}
if got := f.do(t, http.MethodGet, "/v1/user/", "", cookie, ip); got.Code != http.StatusOK {
t.Errorf("GET must not be limited, got %d", got.Code)
}
})
t.Run("The list shows users and open invites, newest first", func(t *testing.T) {
f := newInviteFixture(t, 100)
day := func(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 9, 0, 0, 0, time.UTC) }
f.users.users[7] = user.User{ID: 7, Username: "marta", Email: "marta@renew.example", IsVerified: true, Password: "x", CreatedAt: day(2026, 3, 12)}
f.users.users[8] = user.User{ID: 8, Username: "jonas", Email: "jonas@renew.example", CreatedAt: day(2026, 4, 2)}
f.users.users[9] = user.User{ID: 9, Username: "ines", DisplayName: "Ines Duarte", CreatedAt: day(2026, 6, 18)}
f.invites.seed(inviteAt("p.huber@gmail.com", day(2026, 8, 3), day(2026, 8, 6)))
f.invites.seed(inviteAt("sam.oliveira@renew.example", time.Now().Add(-time.Hour), time.Now().Add(71*time.Hour)))
acceptedAt := day(2026, 4, 1)
accepted := inviteAt("jonas@renew.example", day(2026, 4, 1), day(2026, 4, 4))
accepted.AcceptedAt = &acceptedAt
f.invites.seed(accepted)
rec := f.list(t)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
items := decodeList(t, rec.Body.Bytes())
if len(items) != 5 {
t.Fatalf("expected 5 items, got %d: %s", len(items), rec.Body.String())
}
wantOrder := []struct{ kind, key, value, status string }{
{"invite", "email", "sam.oliveira@renew.example", "pending"},
{"invite", "email", "p.huber@gmail.com", "expired"},
{"user", "username", "ines", "accepted"},
{"user", "username", "jonas", "accepted"},
{"user", "username", "marta", "accepted"},
}
for index, want := range wantOrder {
item := items[index]
if decodedString(t, item, "kind") != want.kind || decodedString(t, item, want.key) != want.value || decodedString(t, item, "status") != want.status {
t.Errorf("item %d unexpected: %v", index, item)
}
if want.kind == "invite" {
assertExactKeys(t, item, "id", "kind", "email", "status", "created_at")
} else {
assertExactKeys(t, item, "id", "kind", "username", "display_name", "status", "created_at")
}
}
if decodedString(t, items[2], "display_name") != "Ines Duarte" {
t.Errorf("expected display name for ines")
}
if strings.Contains(rec.Body.String(), "jonas@renew.example") || strings.Contains(rec.Body.String(), "marta@renew.example") {
t.Errorf("user emails and accepted invites must not be listed")
}
})
t.Run("With nobody invited the list contains only the registered user", func(t *testing.T) {
f := newInviteFixture(t, 100)
rec := f.list(t)
items := decodeList(t, rec.Body.Bytes())
if rec.Code != http.StatusOK || len(items) != 1 || decodedString(t, items[0], "kind") != "user" || decodedString(t, items[0], "username") != "marta" || decodedString(t, items[0], "status") != "accepted" {
t.Errorf("unexpected list: %d %s", rec.Code, rec.Body.String())
}
})
t.Run("An invite turns from pending to expired with time, without any stored status", func(t *testing.T) {
f := newInviteFixture(t, 100)
now := time.Now()
f.invites.seed(inviteAt("late@renew.example", now.Add(-invite.Lifetime-time.Minute), now.Add(-time.Minute)))
items := decodeList(t, f.list(t).Body.Bytes())
if decodedString(t, items[0], "status") != "expired" {
t.Errorf("expected expired, got %v", items[0])
}
})
}
func TestInvitationsDoNotChangeRegistration(t *testing.T) {
validator.InitValidator()
newRegistration := func(users *mockUserRepo, mail *mockMailRepo) *chi.Mux {
hasher := &stubHasher{}
handler := auth.NewLoginHandler(
jwtauth.New("HS256", []byte("test-secret"), nil),
useCases.NewCreateUserUseCase(users, hasher, mail, invitePublicURL),
useCases.NewAuthenticateUserUseCase(users, hasher),
true,
)
r := chi.NewRouter()
r.Post("/v1/auth/register", handler.Register)
r.Get("/v1/auth/registration", handler.RegistrationStatus)
return r
}
send := func(r *chi.Mux, method, target, body string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
r.ServeHTTP(rec, httptest.NewRequest(method, target, bytes.NewBufferString(body)))
return rec
}
t.Run("Registration stays closed for uninvited people", func(t *testing.T) {
users, mail := inviteInviterRepo(""), newMockMailRepo()
invites := newInviteFakeRepo()
invites.seed(pendingInvite("sam.oliveira@renew.example"))
rec := send(newRegistration(users, mail), http.MethodPost, "/v1/auth/register", `{"username":"sam","password":"hunter2example12","email":"sam.oliveira@renew.example"}`)
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.RegistrationClosedErrorMessage+"\n" {
t.Errorf("expected 403, got %d: %q", rec.Code, rec.Body.String())
}
if len(users.users) != 1 || len(mail.SentEmails) != 0 {
t.Errorf("no user may be created and no email sent")
}
})
t.Run("Registration on an empty instance is unchanged", func(t *testing.T) {
users, mail := newMockUserRepo(), newMockMailRepo()
invites := newInviteFakeRepo()
rec := send(newRegistration(users, mail), http.MethodPost, "/v1/auth/register", `{"username":"bob","password":"hunter2example12","email":"bob@example.com"}`)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if len(mail.SentEmails) != 1 || mail.SentEmails[0].Template != "verification" || len(invites.invites) != 0 {
t.Errorf("expected one verification email and no invite")
}
})
t.Run("Inviting someone never changes GET /v1/auth/registration", func(t *testing.T) {
users := inviteInviterRepo("")
invites := newInviteFakeRepo()
invites.seed(pendingInvite("sam.oliveira@renew.example"))
rec := send(newRegistration(users, newMockMailRepo()), http.MethodGet, "/v1/auth/registration", "")
var status map[string]bool
if err := json.Unmarshal(rec.Body.Bytes(), &status); err != nil {
t.Fatalf("failed to decode %q: %v", rec.Body.String(), err)
}
open, present := status["open"]
if rec.Code != http.StatusOK || len(status) != 1 || !present || open {
t.Errorf("expected registration reported closed, got %d: %s", rec.Code, rec.Body.String())
}
})
}
func TestCreateInviteUseCase(t *testing.T) {
newUseCase := func() (*useCases.CreateInviteUseCase, *mockUserRepo, *inviteFakeRepo, *mockMailRepo, *stubHasher) {
users, invites, mail, hasher := inviteInviterRepo("Marta A"), newInviteFakeRepo(), newMockMailRepo(), &stubHasher{}
return useCases.NewCreateInviteUseCase(users, invites, hasher, mail, invitePublicURL), users, invites, mail, hasher
}
t.Run("lower-cases the email and returns the stored invite", func(t *testing.T) {
uc, _, _, _, _ := newUseCase()
got, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "A@B.Example", InvitedBy: 7})
if err != nil || got.Email != "a@b.example" || got.ID == 0 {
t.Fatalf("unexpected result %+v, %v", got, err)
}
})
t.Run("unknown inviter", func(t *testing.T) {
uc, _, invites, _, _ := newUseCase()
_, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 99})
if !errors.Is(err, user.ErrUserNotFound) || len(invites.invites) != 0 {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
})
t.Run("existing account is refused case-insensitively", func(t *testing.T) {
uc, users, _, _, _ := newUseCase()
users.users[8] = user.User{ID: 8, Email: "Jonas@Renew.example"}
_, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "jonas@renew.example", InvitedBy: 7})
if !errors.Is(err, invite.ErrEmailHasAccount) {
t.Errorf("expected ErrEmailHasAccount, got %v", err)
}
})
t.Run("pending invite is refused", func(t *testing.T) {
uc, _, invites, _, _ := newUseCase()
invites.seed(pendingInvite("a@b.example"))
_, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
if !errors.Is(err, invite.ErrPendingInvite) {
t.Errorf("expected ErrPendingInvite, got %v", err)
}
})
t.Run("accepted invites and other addresses do not block", func(t *testing.T) {
uc, _, invites, _, _ := newUseCase()
acceptedAt := time.Now()
accepted := pendingInvite("a@b.example")
accepted.AcceptedAt = &acceptedAt
invites.seed(accepted)
invites.seed(pendingInvite("other@b.example"))
if _, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7}); err != nil {
t.Errorf("unexpected error %v", err)
}
if len(invites.invites) != 3 {
t.Errorf("expected accepted and other invites to remain, got %d", len(invites.invites))
}
})
t.Run("hash failure stores and sends nothing", func(t *testing.T) {
uc, _, invites, mail, hasher := newUseCase()
hasher.hashErr = errors.New("hash boom")
_, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
if err == nil || len(invites.invites) != 0 || len(mail.SentEmails) != 0 {
t.Errorf("expected failure without side effects, got %v", err)
}
})
t.Run("expired invite deletion failure aborts", func(t *testing.T) {
uc, _, invites, mail, _ := newUseCase()
invites.seed(expiredInvite("a@b.example"))
invites.fail.failNext("Delete")
_, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
if !errors.Is(err, errRepositoryFailure) || len(mail.SentEmails) != 0 {
t.Errorf("expected repository failure, got %v", err)
}
})
t.Run("mail failure joins the cleanup failure", func(t *testing.T) {
uc, _, invites, mail, _ := newUseCase()
mail.fail.failNextWith("Send", errors.New("smtp down"))
invites.fail.failNextWith("Delete", errors.New("cleanup down"))
_, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
if !errors.Is(err, invite.ErrInviteEmailNotSent) || !strings.Contains(err.Error(), "smtp down") || !strings.Contains(err.Error(), "cleanup down") {
t.Errorf("unexpected error %v", err)
}
})
}
func TestInviteStatusAt(t *testing.T) {
now := time.Now()
accepted := now
cases := []struct {
name string
in invite.Invite
want invite.Status
}{
{"pending", invite.Invite{ExpiresAt: now.Add(time.Minute)}, invite.StatusPending},
{"expired", invite.Invite{ExpiresAt: now.Add(-time.Minute)}, invite.StatusExpired},
{"expiring exactly now is expired", invite.Invite{ExpiresAt: now}, invite.StatusExpired},
{"accepted wins over expiry", invite.Invite{ExpiresAt: now.Add(-time.Minute), AcceptedAt: &accepted}, invite.StatusAccepted},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.in.StatusAt(now); got != tc.want {
t.Errorf("expected %s, got %s", tc.want, got)
}
})
}
}
func TestInvitationEmailRendering(t *testing.T) {
repo := mailer.NewMailRepository(config.SmtpSettings{})
data := useCases.MailInviteData{Host: invitePublicURL, VerificationCode: "abc-123", Email: "sam@renew.example", InvitedBy: "Ines\nDuarte"}
t.Run("subject is built from the inviter and the link carries the raw code", func(t *testing.T) {
subject, body, err := repo.Render("invitation", data)
if err != nil {
t.Fatalf("unexpected error %v", err)
}
if subject != "Ines Duarte invited you to RenewCMS" {
t.Errorf("unexpected subject %q", subject)
}
if !strings.Contains(body, invitePublicURL+"/invite/accept?c=abc-123") || !strings.Contains(body, "sam@renew.example") {
t.Errorf("unexpected body")
}
})
t.Run("other templates keep their subjects", func(t *testing.T) {
subject, _, _ := repo.Render("verification", useCases.MailValidationData{Host: invitePublicURL, VerificationCode: "x"})
if subject != "Verify your RenewCMS account" {
t.Errorf("unexpected subject %q", subject)
}
})
}
func TestInvitationsHandlerUsesPublicUserSafeShape(t *testing.T) {
f := newInviteFixture(t, 100)
rec := f.post(t, `{"email": "x@renew.example"}`)
for _, forbidden := range []string{"hashed", "invited_by", "accepted_at", "token_version"} {
if strings.Contains(rec.Body.String(), forbidden) {
t.Errorf("body leaks %q", forbidden)
}
}
}
func TestListUsersAndInvitesUseCase(t *testing.T) {
t.Run("empty repositories give an empty non-nil list", func(t *testing.T) {
got := useCases.NewListUsersAndInvitesUseCase(newMockUserRepo(), newInviteFakeRepo()).ListUsersAndInvites()
if got == nil || len(got) != 0 {
t.Errorf("expected empty non-nil slice, got %#v", got)
}
})
}