From acdef18b378cfcf9348e4bcd2cdbbcbbf8c182e9 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Tue, 29 Sep 2026 07:15:17 +0200 Subject: [PATCH] feat(API): user invitations and emails --- api/dependecyInjection.go | 9 +- api/handlers/user/inviteHandler.go | 136 ++++ api/pkg/apperr/messages.go | 3 + api/router.go | 11 +- features/invitations.feature | 183 +++++ features/invitations.qa.md | 99 +++ internal/domain/invite/inviteDomain.go | 41 + .../invite/inviteRepositoryInterface.go | 7 + .../infrastructure/mailer/mailRepository.go | 19 +- .../mailer/templates/invitation.html | 72 ++ .../persistence/mappers/inviteMapper.go | 30 + .../persistence/models/inviteModel.go | 13 + .../repositories/inviteRepository.go | 46 ++ .../infrastructure/useCases/CreateInvite.go | 128 ++++ .../useCases/ListUsersAndInvites.go | 68 ++ test/invitations_qa_test.go | 114 +++ test/invitations_test.go | 715 ++++++++++++++++++ 17 files changed, 1687 insertions(+), 7 deletions(-) create mode 100644 api/handlers/user/inviteHandler.go create mode 100644 features/invitations.feature create mode 100644 features/invitations.qa.md create mode 100644 internal/domain/invite/inviteDomain.go create mode 100644 internal/domain/invite/inviteRepositoryInterface.go create mode 100644 internal/infrastructure/mailer/templates/invitation.html create mode 100644 internal/infrastructure/persistence/mappers/inviteMapper.go create mode 100644 internal/infrastructure/persistence/models/inviteModel.go create mode 100644 internal/infrastructure/persistence/repositories/inviteRepository.go create mode 100644 internal/infrastructure/useCases/CreateInvite.go create mode 100644 internal/infrastructure/useCases/ListUsersAndInvites.go create mode 100644 test/invitations_qa_test.go create mode 100644 test/invitations_test.go diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index 5eb4167..a156128 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -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), diff --git a/api/handlers/user/inviteHandler.go b/api/handlers/user/inviteHandler.go new file mode 100644 index 0000000..0e14b38 --- /dev/null +++ b/api/handlers/user/inviteHandler.go @@ -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) +} diff --git a/api/pkg/apperr/messages.go b/api/pkg/apperr/messages.go index 2f221db..f941134 100644 --- a/api/pkg/apperr/messages.go +++ b/api/pkg/apperr/messages.go @@ -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." diff --git a/api/router.go b/api/router.go index 7a4cd8e..985c8da 100644 --- a/api/router.go +++ b/api/router.go @@ -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 diff --git a/features/invitations.feature b/features/invitations.feature new file mode 100644 index 0000000..a104b39 --- /dev/null +++ b/features/invitations.feature @@ -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 " invited you to RenewCMS" + And the link in the email is /invite/accept?c= + 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 + Then the response status is + 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 + When the client sends + Then the response status is + 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 diff --git a/features/invitations.qa.md b/features/invitations.qa.md new file mode 100644 index 0000000..7542fea --- /dev/null +++ b/features/invitations.qa.md @@ -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 `x` 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`. diff --git a/internal/domain/invite/inviteDomain.go b/internal/domain/invite/inviteDomain.go new file mode 100644 index 0000000..7b9a372 --- /dev/null +++ b/internal/domain/invite/inviteDomain.go @@ -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 +} diff --git a/internal/domain/invite/inviteRepositoryInterface.go b/internal/domain/invite/inviteRepositoryInterface.go new file mode 100644 index 0000000..5d4c3ac --- /dev/null +++ b/internal/domain/invite/inviteRepositoryInterface.go @@ -0,0 +1,7 @@ +package invite + +type Repository interface { + Create(invite Invite) (Invite, error) + GetAll() []Invite + Delete(id uint32) error +} diff --git a/internal/infrastructure/mailer/mailRepository.go b/internal/infrastructure/mailer/mailRepository.go index 9dc4d43..f1df97e 100644 --- a/internal/infrastructure/mailer/mailRepository.go +++ b/internal/infrastructure/mailer/mailRepository.go @@ -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 { diff --git a/internal/infrastructure/mailer/templates/invitation.html b/internal/infrastructure/mailer/templates/invitation.html new file mode 100644 index 0000000..4251d91 --- /dev/null +++ b/internal/infrastructure/mailer/templates/invitation.html @@ -0,0 +1,72 @@ + + + + + + + + You're invited to RenewCMS + + + +
{{.InvitedBy}} invited you to work on articles in RenewCMS. The invite expires in 3 days.
+ + + + + + + diff --git a/internal/infrastructure/persistence/mappers/inviteMapper.go b/internal/infrastructure/persistence/mappers/inviteMapper.go new file mode 100644 index 0000000..5782eee --- /dev/null +++ b/internal/infrastructure/persistence/mappers/inviteMapper.go @@ -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, + } +} diff --git a/internal/infrastructure/persistence/models/inviteModel.go b/internal/infrastructure/persistence/models/inviteModel.go new file mode 100644 index 0000000..74a7b65 --- /dev/null +++ b/internal/infrastructure/persistence/models/inviteModel.go @@ -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"` +} diff --git a/internal/infrastructure/persistence/repositories/inviteRepository.go b/internal/infrastructure/persistence/repositories/inviteRepository.go new file mode 100644 index 0000000..b7e8eb2 --- /dev/null +++ b/internal/infrastructure/persistence/repositories/inviteRepository.go @@ -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{} diff --git a/internal/infrastructure/useCases/CreateInvite.go b/internal/infrastructure/useCases/CreateInvite.go new file mode 100644 index 0000000..d2fee3b --- /dev/null +++ b/internal/infrastructure/useCases/CreateInvite.go @@ -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 +} diff --git a/internal/infrastructure/useCases/ListUsersAndInvites.go b/internal/infrastructure/useCases/ListUsersAndInvites.go new file mode 100644 index 0000000..20c9816 --- /dev/null +++ b/internal/infrastructure/useCases/ListUsersAndInvites.go @@ -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 +} diff --git a/test/invitations_qa_test.go b/test/invitations_qa_test.go new file mode 100644 index 0000000..31dcaf6 --- /dev/null +++ b/test/invitations_qa_test.go @@ -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: "x"} + + _, body, err := repo.Render("invitation", data) + + if err != nil { + t.Fatalf("unexpected error %v", err) + } + if strings.Contains(body, "x") || !strings.Contains(body, "<b>x</b>") { + 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) + } + } + }) +} diff --git a/test/invitations_test.go b/test/invitations_test.go new file mode 100644 index 0000000..75585a0 --- /dev/null +++ b/test/invitations_test.go @@ -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) + } + }) +}