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
@@ -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