Files
RenewCMS/test/invitations_test.go
T

751 lines
28 KiB
Go

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
users *mockUserRepo
}
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
}
func (m *inviteFakeRepo) Renew(id uint32, hashedCode string, expiresAt time.Time, createdAt time.Time) (invite.Invite, error) {
if err := m.fail.trigger("Renew"); err != nil {
return invite.Invite{}, err
}
stored, ok := m.invites[id]
if !ok || stored.AcceptedAt != nil {
return invite.Invite{}, invite.ErrInviteNotFound
}
stored.HashedCode = hashedCode
stored.ExpiresAt = expiresAt
stored.CreatedAt = createdAt
m.invites[id] = stored
return stored, nil
}
func (m *inviteFakeRepo) AcceptWithUser(id uint32, at time.Time, newUser user.User) (user.User, error) {
if err := m.fail.trigger("MarkAccepted"); err != nil {
return user.User{}, err
}
stored, ok := m.invites[id]
if !ok || stored.AcceptedAt != nil {
return user.User{}, invite.ErrInviteNotFound
}
created, err := m.users.Create(newUser)
if err != nil {
return user.User{}, err
}
stored.AcceptedAt = &at
m.invites[id] = stored
return created, 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, Role: user.RoleOwner}
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),
nil,
nil,
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", "email", "role", "status", "created_at")
}
}
if decodedString(t, items[2], "display_name") != "Ines Duarte" {
t.Errorf("expected display name for ines")
}
if decodedString(t, items[3], "email") != "jonas@renew.example" || decodedString(t, items[4], "email") != "marta@renew.example" {
t.Errorf("expected user emails in the list")
}
})
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, discardRecorder),
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)
}
})
}