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), 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) } }) }