feat(API): user invitations and emails
This commit is contained in:
1 parent
7aae704d0a
commit
acdef18b37
17 files changed
+1687
-7
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
Feature: A verified user can invite another person by email, and the users list shows users and open invites
|
||||
|
||||
As a member of the shared RenewCMS workspace
|
||||
I want to invite someone by email and see who has an account or a pending invite
|
||||
So that they can later create their own account with exactly the same access, while
|
||||
registration stays closed to everyone who was not invited
|
||||
|
||||
Background:
|
||||
Given the workspace is shared: there are no roles, no ownership and no way to remove a user
|
||||
And POST /v1/user/invite and GET /v1/user/ are mounted in NewUserRouter, inside the group that
|
||||
requires a valid jwt cookie, a current token_version and a verified user
|
||||
And an invite has an id, an email stored in lower case, a hashed code, an expires_at equal to
|
||||
its creation time plus 3 days, an invited_by user id, a nullable accepted_at and a created_at
|
||||
And the raw invite code is a uuid, hashed with the same hash.Hasher used for verification codes,
|
||||
and only the hash is stored
|
||||
And an invite's status is derived and never stored: pending when accepted_at is empty and
|
||||
expires_at is in the future, expired when accepted_at is empty and expires_at is in the past,
|
||||
accepted when accepted_at is set
|
||||
And the users list item shape is: id, kind ("user" or "invite"), status, created_at, plus
|
||||
username and display_name for a user, plus email for an invite; a user is never listed with
|
||||
an email, and neither the code, its hash, invited_by nor password fields ever appear
|
||||
And error responses are plain text from http.Error, success responses are JSON
|
||||
And the invite email uses the template "invitation" with data Host (the configured PublicURL),
|
||||
VerificationCode (the raw code), Email (the invited address) and InvitedBy (the inviter's
|
||||
display name, or their username when the display name is empty)
|
||||
And the email subject is "<InvitedBy> invited you to RenewCMS"
|
||||
And the link in the email is <Host>/invite/accept?c=<raw code>
|
||||
And POST /v1/user/invite is rate-limited per client IP with the auth rate limiter
|
||||
(AUTH_RATE_LIMIT_MAX_ATTEMPTS, AUTH_RATE_LIMIT_WINDOW_SECONDS), independently counted from
|
||||
/login and /password-reset
|
||||
|
||||
Scenario: A verified user invites a new address
|
||||
Given a user repository containing verified user id 7, username "marta", display_name ""
|
||||
And an empty invite repository
|
||||
And a valid jwt cookie for user id 7
|
||||
When the client sends POST /v1/user/invite with body {"email": "Sam.Oliveira@Renew.example"}
|
||||
Then the response status is 201
|
||||
And the response body is a JSON object with exactly the keys id, kind, email, status, created_at
|
||||
and expires_at, with kind "invite", email "sam.oliveira@renew.example" and status "pending"
|
||||
And the response body contains no key named code, hashed_code, verification_code or password
|
||||
And the invite repository holds one invite with email "sam.oliveira@renew.example",
|
||||
invited_by 7, accepted_at empty and expires_at 3 days after created_at
|
||||
And the stored code is the hash of the raw code, not the raw code
|
||||
And exactly one email was sent to "sam.oliveira@renew.example" with template "invitation"
|
||||
And that email's Host is the configured PublicURL, its VerificationCode is the raw code,
|
||||
its Email is "sam.oliveira@renew.example" and its InvitedBy is "marta"
|
||||
|
||||
Scenario: The email names the inviter by display name when there is one
|
||||
Given verified user id 7, username "ines", display_name "Ines Duarte"
|
||||
When the client sends POST /v1/user/invite with body {"email": "lea.martin@renew.example"}
|
||||
Then the response status is 201
|
||||
And the email's InvitedBy is "Ines Duarte"
|
||||
|
||||
Scenario Outline: An invalid email is rejected before anything is stored or sent
|
||||
Given verified user id 7 and a valid jwt cookie for user id 7
|
||||
When the client sends POST /v1/user/invite with body <body>
|
||||
Then the response status is <status>
|
||||
And the invite repository is still empty
|
||||
And no email was sent
|
||||
|
||||
Examples:
|
||||
| body | status |
|
||||
| {"email": "sam.oliveira@renew"} | 400 |
|
||||
| {"email": ""} | 400 |
|
||||
| {} | 400 |
|
||||
| {"email": "not-an-email"} | 400 |
|
||||
| not json | 400 |
|
||||
|
||||
Scenario: An address that already has an account is refused
|
||||
Given verified user id 7 and a user "jonas" with email "Jonas@Renew.example"
|
||||
When the client sends POST /v1/user/invite with body {"email": "jonas@renew.example"}
|
||||
Then the response status is 409
|
||||
And the response body is "This address already has an account."
|
||||
And the invite repository is still empty
|
||||
And no email was sent
|
||||
|
||||
Scenario: An address with a live pending invite is refused
|
||||
Given verified user id 7 and a pending invite for "sam.oliveira@renew.example" expiring in 2 days
|
||||
When the client sends POST /v1/user/invite with body {"email": "sam.oliveira@renew.example"}
|
||||
Then the response status is 409
|
||||
And the response body is "There's already a pending invite for this address."
|
||||
And the invite repository still holds only the original invite, unchanged
|
||||
And no email was sent
|
||||
|
||||
Scenario: An expired invite is replaced by a new one
|
||||
Given verified user id 7 and an expired invite for "p.huber@gmail.com" that expired 1 hour ago
|
||||
When the client sends POST /v1/user/invite with body {"email": "p.huber@gmail.com"}
|
||||
Then the response status is 201
|
||||
And the invite repository holds exactly one invite for "p.huber@gmail.com", pending, with a
|
||||
new code, and the expired one no longer exists
|
||||
And exactly one email was sent to "p.huber@gmail.com"
|
||||
|
||||
Scenario: A failed email removes the invite
|
||||
Given verified user id 7 and the mail repository is set to fail its next Send
|
||||
When the client sends POST /v1/user/invite with body {"email": "lea.martin@renew.example"}
|
||||
Then the response status is 500
|
||||
And the response body is "Couldn't send the invite. Try again in a moment."
|
||||
And the invite repository is empty
|
||||
When the failure is cleared and the client sends the same request again
|
||||
Then the response status is 201
|
||||
|
||||
Scenario: A failed email does not restore a replaced expired invite
|
||||
Given an expired invite for "p.huber@gmail.com" and the mail repository set to fail its next Send
|
||||
When the client sends POST /v1/user/invite with body {"email": "p.huber@gmail.com"}
|
||||
Then the response status is 500
|
||||
And the invite repository holds no invite for "p.huber@gmail.com"
|
||||
|
||||
Scenario Outline: Only an authenticated, verified, current-token user can use the endpoints
|
||||
Given the request is made <credentials>
|
||||
When the client sends <method> <path>
|
||||
Then the response status is <status>
|
||||
And no invite was created and no email was sent
|
||||
|
||||
Examples:
|
||||
| credentials | method | path | status |
|
||||
| with no jwt cookie | POST | /v1/user/invite | 401 |
|
||||
| with a jwt cookie of an unverified user | POST | /v1/user/invite | 403 |
|
||||
| with a jwt cookie whose token_version is stale | POST | /v1/user/invite | 401 |
|
||||
| with no jwt cookie | GET | /v1/user/ | 401 |
|
||||
| with a jwt cookie of an unverified user | GET | /v1/user/ | 403 |
|
||||
|
||||
Scenario: The invite endpoint is rate-limited
|
||||
Given AUTH_RATE_LIMIT_MAX_ATTEMPTS is 2 for a window covering the whole test
|
||||
When the client at IP "203.0.113.20" sends 2 valid POST /v1/user/invite requests
|
||||
And then sends a 3rd
|
||||
Then the 3rd response is 429 with body apperr.TooManyRequestsErrorMessage and a Retry-After header
|
||||
And no invite was created and no email was sent for the 3rd request
|
||||
And GET /v1/user/ from the same client is not rejected with 429
|
||||
|
||||
Scenario: The list shows users and open invites, newest first
|
||||
Given verified user id 7 "marta" created 12 Mar 2026
|
||||
And user "jonas" created 2 Apr 2026
|
||||
And user "ines" with display_name "Ines Duarte" created 18 Jun 2026
|
||||
And an expired invite for "p.huber@gmail.com" created 3 Aug 2026
|
||||
And a pending invite for "sam.oliveira@renew.example" created 27 Sep 2026
|
||||
And an accepted invite for "jonas@renew.example" created 1 Apr 2026
|
||||
When the client sends GET /v1/user/ with a valid jwt cookie for user id 7
|
||||
Then the response status is 200
|
||||
And the response body is a JSON array of exactly 5 items in this order: the pending invite for
|
||||
"sam.oliveira@renew.example", the expired invite for "p.huber@gmail.com", user "ines", user
|
||||
"jonas", user "marta"
|
||||
And the accepted invite is not listed
|
||||
And the invite items have exactly the keys id, kind, email, status, created_at, with kind
|
||||
"invite" and status "pending" or "expired"
|
||||
And the user items have exactly the keys id, kind, username, display_name, status, created_at,
|
||||
with kind "user" and status "accepted"
|
||||
And no user item has an email key, and no item has a key named password, password_reset_code,
|
||||
verification_code, verification_expiration, password_reset_expiration, token_version,
|
||||
code, hashed_code, invited_by or accepted_at
|
||||
|
||||
Scenario: With nobody invited the list contains only the registered user
|
||||
Given only verified user id 7 "marta"
|
||||
When the client sends GET /v1/user/ with a valid jwt cookie for user id 7
|
||||
Then the response status is 200
|
||||
And the response body is a JSON array of exactly one item, kind "user", username "marta",
|
||||
status "accepted"
|
||||
|
||||
Scenario: An invite turns from pending to expired with time, without any stored status
|
||||
Given an invite created 3 days and 1 minute ago that has not been accepted
|
||||
When the client sends GET /v1/user/
|
||||
Then that invite's status is "expired"
|
||||
|
||||
Scenario: Registration stays closed for uninvited people
|
||||
Given a user repository containing one verified user
|
||||
And a pending invite for "sam.oliveira@renew.example"
|
||||
When the client sends POST /v1/auth/register with username "sam", password "hunter2example12",
|
||||
email "sam.oliveira@renew.example"
|
||||
Then the response status is 403
|
||||
And the response body is exactly apperr.RegistrationClosedErrorMessage
|
||||
And no user was created and no email was sent
|
||||
|
||||
Scenario: Registration on an empty instance is unchanged
|
||||
Given the user repository is empty
|
||||
When the client sends POST /v1/auth/register with username "bob", password "hunter2example12",
|
||||
email "bob@example.com"
|
||||
Then the response status is 200
|
||||
And a verification email is sent with template "verification"
|
||||
And no invite exists
|
||||
|
||||
Scenario: Inviting someone never changes GET /v1/auth/registration
|
||||
Given a user repository containing one verified user
|
||||
When a pending invite exists
|
||||
Then GET /v1/auth/registration still reports registration closed
|
||||
@@ -0,0 +1,99 @@
|
||||
# QA procedure: invitations
|
||||
|
||||
Package: `test` (black-box). New fakes must have unique type names across the package:
|
||||
`mockInviteRepo` (implements `invite.Repository` with an in-memory slice and a `failSwitch`),
|
||||
plus a `fixedClock` only if the use case takes one. Reuse `mockUserRepo` and `stubHasher`
|
||||
(`user_test.go`) and `mockMailRepo` (`mail_test.go`; `SentEmails` entries expose `Address`,
|
||||
`Template`, `Data`; `fail.failNext("Send")`).
|
||||
|
||||
## Setup shared by every case
|
||||
1. Call `validator.InitValidator()` once.
|
||||
2. Use cases: `useCases.NewCreateInviteUseCase(userRepo, inviteRepo, &stubHasher{}, mailRepo,
|
||||
"https://cms.example.com")` and `useCases.NewListUsersAndInvitesUseCase(userRepo, inviteRepo)`
|
||||
(final names are the coder's; constructors take only domain interfaces).
|
||||
3. Handler: `user.NewHandler(updateProfile, createInvite, listUsersAndInvites)` mounted on a fresh
|
||||
`chi.NewRouter()` mirroring `NewUserRouter`: `jwtauth.Verifier` + `jwtauth.Authenticator` with
|
||||
`jwtauth.New("HS256", []byte("test-secret"), nil)`, `RequireCurrentToken(getUser)`,
|
||||
`RequireVerifiedUser(getUser)`, then `POST /invite` and `GET /`. For the rate-limit case
|
||||
wrap only `POST /invite` in `TrustedProxyRealIP(nil)` + `NewRateLimiter(2, time.Minute)`.
|
||||
Requests via `httptest`; jwt cookie built with the `user_id` and `token_version` claims as in
|
||||
`test/verified_account_required_http_test.go`. No live server, no real SQLite, no real SMTP.
|
||||
4. Seed users: verified id 7 "marta"; unverified id 8 for the 403 cases. Time is real: build
|
||||
invites with `ExpiresAt`/`CreatedAt` relative to `time.Now()` and compare with a 5 s tolerance.
|
||||
|
||||
## POST /v1/user/invite
|
||||
5. Body `{"email":"Sam.Oliveira@Renew.example"}`, cookie for 7 -> `201`. Decode into
|
||||
`map[string]json.RawMessage`: the key set is exactly `id`, `kind`, `email`, `status`,
|
||||
`created_at`, `expires_at`, with `kind`="invite", `email`="sam.oliveira@renew.example",
|
||||
`status`="pending"; none of `code`, `hashed_code`, `verification_code`, `password`.
|
||||
`inviteRepo` holds one invite: lower-case email, `InvitedBy==7`, `AcceptedAt` nil,
|
||||
`abs(ExpiresAt - (CreatedAt+72h)) < 5s`, stored code == `"hashed:"+rawCode` (stubHasher) where
|
||||
`rawCode` is taken from the mail data; the stored code is not equal to `rawCode`.
|
||||
6. `mailRepo.SentEmails` has exactly one entry: `Address`, `Template=="invitation"`, data of the
|
||||
invitation mail type with `Host=="https://cms.example.com"`, `Email` the invited address,
|
||||
`InvitedBy=="marta"` and a uuid-shaped `VerificationCode`.
|
||||
7. Inviter with `DisplayName:"Ines Duarte"` -> `InvitedBy=="Ines Duarte"`. Inviter with an
|
||||
empty display name -> username.
|
||||
8. Table for 400 (`{"email":"sam.oliveira@renew"}`, `{"email":""}`, `{}`, `{"email":"not-an-email"}`,
|
||||
`not json`): status `400`, `inviteRepo` empty, `SentEmails` empty.
|
||||
9. Existing user with email `"Jonas@Renew.example"`, invite `jonas@renew.example` -> `409`, body exactly the new
|
||||
apperr constant "This address already has an account.", no invite, no mail. This case pins that
|
||||
the match is case-insensitive.
|
||||
10. Seed a pending invite (expires in 2 days) for the address -> `409`, body "There's already a pending
|
||||
invite for this address.", the seeded invite unchanged (same code hash and expiry), no mail.
|
||||
11. Seed an expired invite (expired 1 h ago) -> `201`; `inviteRepo` has exactly one invite for the address, with
|
||||
a different code hash and an expiry about 3 days out; one mail sent.
|
||||
12. `mailRepo.fail.failNext("Send")` -> `500`, body "Couldn't send the invite. Try again in a moment.",
|
||||
`inviteRepo` empty. Repeat the same request without re-arming -> `201`. With a seeded expired invite and a
|
||||
failing send -> `500` and no invite for that address remains.
|
||||
13. Auth table (`POST`): no cookie -> `401`; cookie of unverified user 8 -> `403`; cookie with stale
|
||||
`token_version` -> `401`. Each asserts no invite and no mail.
|
||||
14. Rate limit: limiter of 2 per minute, `RemoteAddr` `203.0.113.20:1234`, three valid POSTs -> `201`,
|
||||
`201`, `429`; third body is exactly `apperr.TooManyRequestsErrorMessage`, has `Retry-After`, and adds no
|
||||
invite or mail. `GET /` from the same address afterwards is not `429`.
|
||||
|
||||
## GET /v1/user/
|
||||
15. Seed per the feature scenario (three users, an expired, a pending and an accepted invite, with the
|
||||
stated `CreatedAt` values). `200`; decode as `[]map[string]json.RawMessage`. Assert length 5, order
|
||||
(pending sam, expired p.huber, ines, jonas, marta), key sets exactly as specified for user and invite
|
||||
items, accepted invite absent, and none of the forbidden keys anywhere. Also assert the raw body string
|
||||
contains no user email address.
|
||||
16. Only user 7 -> array with exactly one `kind:"user"` item, status `accepted`.
|
||||
17. Invite created 3 days + 1 minute ago -> `status:"expired"`; created 2 days ago -> `pending`;
|
||||
with `AcceptedAt` set -> not listed.
|
||||
18. Auth table (`GET`): no cookie -> `401`; unverified -> `403`.
|
||||
|
||||
## Use-case level (no HTTP)
|
||||
19. `CreateInvite` on a repository holding a user with the same email -> `errors.Is(err, invite.ErrEmailHasAccount)`;
|
||||
live pending invite -> `errors.Is(err, invite.ErrPendingInvite)` (sentinel names are the coder's).
|
||||
A mail failure wraps the mail repository error and the `Delete` of the invite is called
|
||||
(if `Delete` also fails, both errors are joined, as `CreateUser` does).
|
||||
|
||||
## Mailer (real embedded templates, no SMTP)
|
||||
20. `mailer.MailRepository{}.Render("invitation", data)` with `Host:"https://cms.example.com"`,
|
||||
`VerificationCode:"abc-123"`, `Email:"lea@example.com"`, `InvitedBy:"Ines Duarte"` returns subject
|
||||
`"Ines Duarte invited you to RenewCMS"` and a body containing
|
||||
`https://cms.example.com/invite/accept?c=abc-123`, `lea@example.com` and `Ines Duarte`.
|
||||
An `InvitedBy` of `<b>x</b>` is HTML-escaped in body. Subjects for `verification` and `password-reset` are
|
||||
unchanged, and an unknown template still gets the default subject.
|
||||
21. `internal/infrastructure/mailer/templates/invitation.html` is byte-identical to
|
||||
`docs/design/source/emails/invitation.html` (read both files and compare).
|
||||
|
||||
## Regression: registration (extends registration_gate_and_verification_email.qa.md)
|
||||
22. With one verified user and a pending invite for `sam.oliveira@renew.example`, `POST /v1/auth/register` with that
|
||||
email -> `403`, body exactly `apperr.RegistrationClosedErrorMessage`, no user created, no mail sent.
|
||||
23. Empty user repository -> `POST /v1/auth/register` still `200` with a `jwt` cookie and one `verification`
|
||||
mail, and `inviteRepo` untouched. `GET /v1/auth/registration` still reports closed when a verified user
|
||||
and a pending invite exist.
|
||||
|
||||
## Manual verification (not exercised by the Go tests)
|
||||
- Start the app once against a fresh SQLite file: the `invites` table exists (model registered in
|
||||
`AutoMigrate` in `api/dependecyInjection.go`) and inviting persists across a restart.
|
||||
- Send a real invitation through a real or sandboxed SMTP server: the subject line, the rendering in a mail
|
||||
client (light and dark) and that the link `{PUBLIC_URL}/invite/accept?c=...` carries the raw code.
|
||||
The screen the link opens is not designed and is out of scope, so the link will not resolve in the SPA yet.
|
||||
- SPA copy for the 429 ("Too many invites. Wait a moment, then try again.") is a note for the later SPA
|
||||
slice and is not tested here. The SPA keys list rows by kind and id, since a user and an invite can share
|
||||
a numeric id.
|
||||
- Layering check by review: use cases import only `internal/domain/*`; the mailer subject logic lives in
|
||||
`internal/infrastructure/mailer`.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "<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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user