Files
RenewCMS/test/invite_management_test.go
2026-10-06 22:25:23 +02:00

592 lines
25 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"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const manageClientIP = "203.0.113.40"
type inviteManagementFixture struct {
users *mockUserRepo
invites *inviteFakeRepo
mail *mockMailRepo
hasher *stubHasher
router *chi.Mux
token *jwtauth.JWTAuth
}
func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagementFixture {
t.Helper()
validator.InitValidator()
f := &inviteManagementFixture{
users: inviteInviterRepo(""),
invites: newInviteFakeRepo(),
mail: newMockMailRepo(),
hasher: &stubHasher{},
token: jwtauth.New("HS256", []byte("test-secret"), nil),
}
f.invites.users = f.users
f.users.users[9] = user.User{ID: 9, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, Role: user.RoleAdmin}
f.users.users[11] = user.User{ID: 11, Username: "unverified", Email: "unverified@renew.example", IsVerified: false, Role: user.RoleAdmin}
f.users.users[12] = user.User{ID: 12, Username: "stale", Email: "stale@renew.example", IsVerified: true, Role: user.RoleOwner, TokenVersion: 2}
f.users.nextID = 12
getUser := useCases.NewGetUserUseCase(f.users)
handler := userhandler.NewInviteHandler(
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder),
useCases.NewCancelInviteUseCase(f.invites, discardRecorder),
useCases.NewRenewInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder),
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
)
acceptHandler := auth.NewAcceptInviteHandler(
useCases.NewGetInviteUseCase(f.invites, f.hasher),
useCases.NewAcceptInviteUseCase(f.users, f.invites, f.hasher, discardRecorder),
f.token,
true,
)
f.router = chi.NewRouter()
f.router.Get("/v1/auth/invite", acceptHandler.GetInvite)
f.router.Post("/v1/auth/invite/accept", acceptHandler.AcceptInvite)
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.RequireOwner(getUser))
r.Use(middleware.TrustedProxyRealIP(nil))
r.Use(middleware.NewRateLimiter(maxAttempts, time.Hour))
r.Post("/invite", handler.CreateInvite)
r.Delete("/invite/{id}", handler.CancelInvite)
r.Post("/invite/{id}/renew", handler.RenewInvite)
})
})
return f
}
func (f *inviteManagementFixture) send(t *testing.T, method, target string, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, target, strings.NewReader(""))
req.RemoteAddr = manageClientIP + ":4321"
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
f.router.ServeHTTP(rec, req)
return rec
}
func (f *inviteManagementFixture) asOwner(t *testing.T, method, target string) *httptest.ResponseRecorder {
t.Helper()
return f.send(t, method, target, displayNameCookie(t, f.token, 7, 0))
}
func (f *inviteManagementFixture) asUser(t *testing.T, id uint32, version uint32, method, target string) *httptest.ResponseRecorder {
t.Helper()
return f.send(t, method, target, displayNameCookie(t, f.token, id, version))
}
func (f *inviteManagementFixture) accepted(email string) invite.Invite {
at := time.Now().Add(-time.Hour)
seeded := pendingInvite(email)
seeded.AcceptedAt = &at
return f.invites.seed(seeded)
}
func TestInviteManagementHttp(t *testing.T) {
t.Run("The owner cancels a pending invite", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
f.invites.seed(pendingInvite("sam.oliveira@renew.example"))
f.invites.seed(pendingInvite("lea.martin@renew.example"))
f.invites.seed(expiredInvite("old@renew.example"))
f.invites.seed(pendingInvite("third@renew.example"))
before := f.invites.invites[2]
ownerBefore, adminBefore := f.users.users[7], f.users.users[9]
rec := f.asOwner(t, http.MethodDelete, "/v1/user/invite/1")
if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 || rec.Header().Get("Cache-Control") != "" {
t.Fatalf("unexpected response %d %q", rec.Code, rec.Body.String())
}
if _, ok := f.invites.invites[1]; ok || len(f.invites.invites) != 3 || f.invites.invites[2] != before {
t.Errorf("unexpected invite state %+v", f.invites.invites)
}
if len(f.mail.SentEmails) != 0 || len(f.users.users) != 4 || f.users.users[7] != ownerBefore || f.users.users[9] != adminBefore {
t.Errorf("mail or users changed")
}
if strings.Contains(f.asOwner(t, http.MethodGet, "/v1/user/").Body.String(), "sam.oliveira@renew.example") {
t.Errorf("cancelled invite still listed")
}
})
t.Run("A cancelled invite's link stops working and its address can be invited again", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
f.invites.seed(invite.Invite{Email: "sam.oliveira@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)})
f.asOwner(t, http.MethodDelete, "/v1/user/invite/1")
rec := f.send(t, http.MethodGet, "/v1/auth/invite?c=OLD-CODE", nil)
if rec.Code != http.StatusNotFound || rec.Body.String() != inviteInvalidBody {
t.Errorf("expected 404 invalid, got %d %q", rec.Code, rec.Body.String())
}
req := httptest.NewRequest(http.MethodPost, "/v1/user/invite", strings.NewReader(`{"email": "sam.oliveira@renew.example"}`))
req.RemoteAddr = manageClientIP + ":4321"
req.AddCookie(displayNameCookie(t, f.token, 7, 0))
created := httptest.NewRecorder()
f.router.ServeHTTP(created, req)
if created.Code != http.StatusCreated {
t.Errorf("expected 201, got %d", created.Code)
}
})
t.Run("The owner cancels an expired invite", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
f.invites.seed(expiredInvite("old@renew.example"))
rec := f.asOwner(t, http.MethodDelete, "/v1/user/invite/1")
if rec.Code != http.StatusNoContent || len(f.invites.invites) != 0 {
t.Errorf("expected 204 and empty repo, got %d", rec.Code)
}
})
t.Run("Renewing a cancelled invite is not found", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
f.invites.seed(pendingInvite("sam.oliveira@renew.example"))
f.asOwner(t, http.MethodDelete, "/v1/user/invite/1")
rec := f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew")
if rec.Code != http.StatusNotFound || rec.Body.String() != apperr.InviteNotFoundErrorMessage+"\n" || len(f.mail.SentEmails) != 0 {
t.Errorf("unexpected %d %q", rec.Code, rec.Body.String())
}
})
t.Run("An unknown invite is not found, and cancelling twice too", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
f.invites.seed(pendingInvite("sam.oliveira@renew.example"))
rec := f.asOwner(t, http.MethodDelete, "/v1/user/invite/999")
if rec.Code != http.StatusNotFound || rec.Body.String() != apperr.InviteNotFoundErrorMessage+"\n" {
t.Errorf("unexpected %d %q", rec.Code, rec.Body.String())
}
f.asOwner(t, http.MethodDelete, "/v1/user/invite/1")
if again := f.asOwner(t, http.MethodDelete, "/v1/user/invite/1"); again.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", again.Code)
}
})
t.Run("An accepted invite cannot be cancelled or renewed", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
accepted := f.accepted("lea.martin@renew.example")
for _, call := range []struct{ method, path string }{
{http.MethodDelete, "/v1/user/invite/1"},
{http.MethodPost, "/v1/user/invite/1/renew"},
} {
rec := f.asOwner(t, call.method, call.path)
if rec.Code != http.StatusConflict || rec.Body.String() != apperr.InviteAlreadyAcceptedErrorMessage+"\n" {
t.Errorf("%s: unexpected %d %q", call.method, rec.Code, rec.Body.String())
}
}
if f.invites.invites[1].HashedCode != accepted.HashedCode || len(f.mail.SentEmails) != 0 {
t.Errorf("accepted invite changed or mail sent")
}
})
t.Run("The owner renews an expired invite", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
now := time.Now()
for _, filler := range []string{"a@renew.example", "b@renew.example", "c@renew.example"} {
f.invites.seed(pendingInvite(filler))
}
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 9, CreatedAt: now.Add(-5 * 24 * time.Hour), ExpiresAt: now.Add(-2 * 24 * time.Hour)})
rec := f.asOwner(t, http.MethodPost, "/v1/user/invite/4/renew")
if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("unexpected %d %q", rec.Code, rec.Header().Get("Cache-Control"))
}
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") != "lea.martin@renew.example" || decodedString(t, decoded, "status") != "pending" {
t.Errorf("unexpected body %s", rec.Body.String())
}
var id uint32
if err := json.Unmarshal(decoded["id"], &id); err != nil || id != 4 {
t.Errorf("unexpected id %s", decoded["id"])
}
createdAt, err := time.Parse(time.RFC3339Nano, decodedString(t, decoded, "created_at"))
if err != nil {
t.Fatalf("bad created_at: %v", err)
}
expiresAt, err := time.Parse(time.RFC3339Nano, decodedString(t, decoded, "expires_at"))
if err != nil {
t.Fatalf("bad expires_at: %v", err)
}
if time.Since(createdAt) > 5*time.Second || time.Until(createdAt) > 5*time.Second {
t.Errorf("unexpected created_at %s", createdAt)
}
if delta := expiresAt.Sub(createdAt) - invite.Lifetime; delta > 5*time.Second || delta < -5*time.Second {
t.Errorf("unexpected expires_at %s", expiresAt)
}
stored := f.invites.invites[4]
data := inviteMailData(t, f.mail)
if time.Since(stored.CreatedAt) > 5*time.Second || stored.ExpiresAt.Sub(stored.CreatedAt) != invite.Lifetime {
t.Errorf("unexpected dates %+v", stored)
}
if stored.HashedCode != "hashed:"+data.VerificationCode || stored.HashedCode == "hashed:OLD-CODE" || stored.InvitedBy != 9 || stored.AcceptedAt != nil || len(f.invites.invites) != 4 {
t.Errorf("unexpected stored invite %+v", stored)
}
if data.Host != invitePublicURL || data.Email != "lea.martin@renew.example" || data.InvitedBy != "marta" || f.mail.SentEmails[0].Template != "invitation" || f.mail.SentEmails[0].Address != "lea.martin@renew.example" {
t.Errorf("unexpected mail %+v", f.mail.SentEmails[0])
}
})
t.Run("A renewed pending invite moves to the top of the list and the old link dies", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now().Add(-48 * time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
f.invites.seed(invite.Invite{Email: "newer@renew.example", HashedCode: "hashed:OTHER", InvitedBy: 7, CreatedAt: time.Now().Add(-24 * time.Hour), ExpiresAt: time.Now().Add(48 * time.Hour)})
f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew")
list := f.asOwner(t, http.MethodGet, "/v1/user/").Body.String()
if strings.Index(list, "lea.martin@renew.example") > strings.Index(list, "newer@renew.example") {
t.Errorf("renewed invite not listed first: %s", list)
}
if old := f.send(t, http.MethodGet, "/v1/auth/invite?c=OLD-CODE", nil); old.Code != http.StatusNotFound {
t.Errorf("old link expected 404, got %d", old.Code)
}
fresh := f.send(t, http.MethodGet, "/v1/auth/invite?c="+inviteMailData(t, f.mail).VerificationCode, nil)
if fresh.Code != http.StatusOK || !strings.Contains(fresh.Body.String(), "lea.martin@renew.example") {
t.Errorf("new link expected 200, got %d %q", fresh.Code, fresh.Body.String())
}
})
t.Run("Accepting with a cancelled or replaced code is not found and creates no user", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
f.invites.seed(invite.Invite{Email: "sam@renew.example", HashedCode: "hashed:CANCELLED", InvitedBy: 7, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew")
f.asOwner(t, http.MethodDelete, "/v1/user/invite/2")
usersBefore := len(f.users.users)
for _, code := range []string{"OLD-CODE", "CANCELLED"} {
body := `{"code":"` + code + `","username":"newperson","password":"password123"}`
req := httptest.NewRequest(http.MethodPost, "/v1/auth/invite/accept", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
f.router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("%s: expected 404, got %d %q", code, rec.Code, rec.Body.String())
}
}
if len(f.users.users) != usersBefore {
t.Errorf("a user was created")
}
})
t.Run("Renewing an unknown invite is not found", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
rec := f.asOwner(t, http.MethodPost, "/v1/user/invite/999/renew")
if rec.Code != http.StatusNotFound || rec.Body.String() != apperr.InviteNotFoundErrorMessage+"\n" {
t.Errorf("unexpected %d %q", rec.Code, rec.Body.String())
}
})
t.Run("A failed email leaves the invite as it was", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
seeded := f.invites.seed(expiredInvite("lea.martin@renew.example"))
f.mail.fail.failNext("Send")
rec := f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew")
if rec.Code != http.StatusInternalServerError || rec.Body.String() != apperr.InviteNotSentErrorMessage+"\n" {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if f.invites.invites[1] != seeded {
t.Errorf("invite changed: %+v vs %+v", f.invites.invites[1], seeded)
}
if retry := f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew"); retry.Code != http.StatusOK {
t.Errorf("retry expected 200, got %d", retry.Code)
}
})
t.Run("A repository failure on cancel leaves the invite in place", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
seeded := f.invites.seed(pendingInvite("sam.oliveira@renew.example"))
f.invites.fail.failNextWith("Delete", errRepositoryFailure)
rec := f.asOwner(t, http.MethodDelete, "/v1/user/invite/1")
if rec.Code != http.StatusInternalServerError || f.invites.invites[1] != seeded {
t.Errorf("unexpected %d or invite changed", rec.Code)
}
})
t.Run("A repository failure on renew sends no mail and leaves the invite as it was", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
seeded := f.invites.seed(pendingInvite("sam.oliveira@renew.example"))
f.invites.fail.failNextWith("Renew", errRepositoryFailure)
rec := f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew")
if rec.Code != http.StatusInternalServerError || f.invites.invites[1] != seeded || len(f.mail.SentEmails) != 0 {
t.Errorf("unexpected %d, invite or mail changed", rec.Code)
}
})
t.Run("The old code of a pending invite works until it is renewed", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)})
if before := f.send(t, http.MethodGet, "/v1/auth/invite?c=OLD-CODE", nil); before.Code != http.StatusOK {
t.Fatalf("control expected 200, got %d", before.Code)
}
f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew")
if after := f.send(t, http.MethodGet, "/v1/auth/invite?c=OLD-CODE", nil); after.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", after.Code)
}
})
t.Run("Access is checked on both endpoints", func(t *testing.T) {
cases := []struct {
name string
cookie func(f *inviteManagementFixture) *http.Cookie
wantErr int
}{
{"no cookie", func(f *inviteManagementFixture) *http.Cookie { return nil }, http.StatusUnauthorized},
{"stale token_version", func(f *inviteManagementFixture) *http.Cookie { return displayNameCookie(t, f.token, 12, 1) }, http.StatusUnauthorized},
{"unverified user", func(f *inviteManagementFixture) *http.Cookie { return displayNameCookie(t, f.token, 11, 0) }, http.StatusForbidden},
{"admin", func(f *inviteManagementFixture) *http.Cookie { return displayNameCookie(t, f.token, 9, 0) }, http.StatusForbidden},
}
for _, tc := range cases {
for _, call := range []struct{ method, path string }{
{http.MethodDelete, "/v1/user/invite/1"},
{http.MethodPost, "/v1/user/invite/1/renew"},
} {
f := newInviteManagementFixture(t, 100)
seeded := f.invites.seed(pendingInvite("sam.oliveira@renew.example"))
rec := f.send(t, call.method, call.path, tc.cookie(f))
if rec.Code != tc.wantErr || f.invites.invites[1] != seeded || len(f.mail.SentEmails) != 0 {
t.Errorf("%s %s %s: got %d", tc.name, call.method, call.path, rec.Code)
}
}
}
})
t.Run("An admin is refused with the owner message, even on an unknown id", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
rec := f.asUser(t, 9, 0, http.MethodDelete, "/v1/user/invite/999")
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n" {
t.Errorf("unexpected %d %q", rec.Code, rec.Body.String())
}
})
t.Run("An id that is not a uint32 is rejected", func(t *testing.T) {
f := newInviteManagementFixture(t, 100)
for _, ident := range []string{"abc", "-1", "4294967296"} {
for _, call := range []struct{ method, suffix string }{{http.MethodDelete, ""}, {http.MethodPost, "/renew"}} {
rec := f.asOwner(t, call.method, "/v1/user/invite/"+ident+call.suffix)
if rec.Code != http.StatusBadRequest || rec.Body.String() != apperr.IdUint32ErrorMessage+"\n" {
t.Errorf("%s %s: unexpected %d %q", call.method, ident, rec.Code, rec.Body.String())
}
}
}
})
t.Run("Requests over the limit are refused and the counter is shared with inviting", func(t *testing.T) {
f := newInviteManagementFixture(t, 2)
f.invites.seed(pendingInvite("sam.oliveira@renew.example"))
req := httptest.NewRequest(http.MethodPost, "/v1/user/invite", strings.NewReader(`{"email": "new.person@renew.example"}`))
req.RemoteAddr = manageClientIP + ":4321"
req.AddCookie(displayNameCookie(t, f.token, 7, 0))
created := httptest.NewRecorder()
f.router.ServeHTTP(created, req)
if created.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", created.Code)
}
f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew")
rec := f.asOwner(t, http.MethodDelete, "/v1/user/invite/1")
if rec.Code != http.StatusTooManyRequests || rec.Body.String() != apperr.TooManyRequestsErrorMessage+"\n" || rec.Header().Get("Retry-After") == "" {
t.Errorf("unexpected %d %q", rec.Code, rec.Body.String())
}
if _, ok := f.invites.invites[1]; !ok || len(f.mail.SentEmails) != 2 {
t.Errorf("third request changed something")
}
if list := f.asOwner(t, http.MethodGet, "/v1/user/"); list.Code != http.StatusOK {
t.Errorf("list should not be limited, got %d", list.Code)
}
})
t.Run("An admin does not use up the quota", func(t *testing.T) {
f := newInviteManagementFixture(t, 2)
for i := 0; i < 3; i++ {
if rec := f.asUser(t, 9, 0, http.MethodDelete, "/v1/user/invite/1"); rec.Code != http.StatusForbidden {
t.Errorf("request %d: expected 403, got %d", i, rec.Code)
}
}
})
}
type renewRestoreFailRepo struct {
*inviteFakeRepo
calls int
}
func (r *renewRestoreFailRepo) Renew(id uint32, hashedCode string, expiresAt time.Time, createdAt time.Time) (invite.Invite, error) {
r.calls++
if r.calls == 2 {
return invite.Invite{}, errRepositoryFailure
}
return r.inviteFakeRepo.Renew(id, hashedCode, expiresAt, createdAt)
}
func TestManageInviteUseCases(t *testing.T) {
t.Run("Renew restores the previous values when the mail fails", func(t *testing.T) {
users := inviteInviterRepo("Marta")
invites := newInviteFakeRepo()
mail := newMockMailRepo()
seeded := invites.seed(pendingInvite("a@renew.example"))
mail.fail.failNextWith("Send", errors.New("smtp down"))
renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL, discardRecorder)
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7})
if !errors.Is(err, invite.ErrInviteEmailNotSent) || errors.Is(err, errRepositoryFailure) || invites.invites[1] != seeded {
t.Errorf("unexpected result %v", err)
}
})
t.Run("Renew joins the restore failure with the mail failure", func(t *testing.T) {
users := inviteInviterRepo("Marta")
invites := newInviteFakeRepo()
mail := newMockMailRepo()
invites.seed(pendingInvite("a@renew.example"))
mail.fail.failNextWith("Send", errors.New("smtp down"))
renew := useCases.NewRenewInviteUseCase(users, &renewRestoreFailRepo{inviteFakeRepo: invites}, &stubHasher{}, mail, invitePublicURL, discardRecorder)
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7})
if !errors.Is(err, invite.ErrInviteEmailNotSent) || !errors.Is(err, errRepositoryFailure) {
t.Errorf("unexpected result %v", err)
}
})
t.Run("Cancel returns the sentinels", func(t *testing.T) {
invites := newInviteFakeRepo()
invites.seed(pendingInvite("a@renew.example"))
accepted := pendingInvite("b@renew.example")
at := time.Now()
accepted.AcceptedAt = &at
invites.seed(accepted)
cancel := useCases.NewCancelInviteUseCase(invites, discardRecorder)
if err := cancel.CancelInvite(testCtx, 9); !errors.Is(err, invite.ErrInviteNotFound) {
t.Errorf("expected not found, got %v", err)
}
if err := cancel.CancelInvite(testCtx, 2); !errors.Is(err, invite.ErrInviteAlreadyAccepted) {
t.Errorf("expected already accepted, got %v", err)
}
if err := cancel.CancelInvite(testCtx, 1); err != nil {
t.Errorf("unexpected %v", err)
}
})
t.Run("Renew sends nothing when persisting fails", func(t *testing.T) {
users := inviteInviterRepo("Marta")
invites := newInviteFakeRepo()
mail := newMockMailRepo()
seeded := invites.seed(pendingInvite("a@renew.example"))
invites.fail.failNextWith("Renew", errRepositoryFailure)
renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL, discardRecorder)
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7})
if !errors.Is(err, errRepositoryFailure) || len(mail.SentEmails) != 0 || invites.invites[1] != seeded {
t.Errorf("unexpected result %v", err)
}
})
t.Run("Renew names the acting owner by display name", func(t *testing.T) {
users := inviteInviterRepo("Marta L.")
invites := newInviteFakeRepo()
mail := newMockMailRepo()
invites.seed(pendingInvite("a@renew.example"))
renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL, discardRecorder)
if _, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7}); err != nil {
t.Fatalf("unexpected %v", err)
}
if inviteMailData(t, mail).InvitedBy != "Marta L." {
t.Errorf("unexpected inviter name")
}
})
}
func TestInviteRepositoryRenew(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:inviterenew?mode=memory&cache=private"), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
if err := db.AutoMigrate(&entity.Invite{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
repo := persistence.NewInviteRepository(db)
now := time.Now().UTC().Truncate(time.Second)
created, err := repo.Create(invite.Invite{Email: "a@renew.example", HashedCode: "old", InvitedBy: 7, CreatedAt: now.Add(-96 * time.Hour), ExpiresAt: now.Add(-24 * time.Hour)})
if err != nil {
t.Fatalf("create failed: %v", err)
}
accepted := now
acceptedRow := entity.Invite{Email: "b@renew.example", HashedCode: "x", InvitedBy: 7, AcceptedAt: &accepted}
db.Create(&acceptedRow)
t.Run("Renew replaces code hash and dates and keeps the id", func(t *testing.T) {
renewed, err := repo.Renew(created.ID, "new", now.Add(invite.Lifetime), now)
if err != nil || renewed.ID != created.ID || renewed.HashedCode != "new" || !renewed.CreatedAt.Equal(now) || !renewed.ExpiresAt.Equal(now.Add(invite.Lifetime)) || renewed.InvitedBy != 7 {
t.Errorf("unexpected %+v %v", renewed, err)
}
})
t.Run("Renew of an unknown or accepted invite is not found", func(t *testing.T) {
if _, err := repo.Renew(999, "new", now, now); !errors.Is(err, invite.ErrInviteNotFound) {
t.Errorf("expected not found, got %v", err)
}
if _, err := repo.Renew(acceptedRow.ID, "new", now, now); !errors.Is(err, invite.ErrInviteNotFound) {
t.Errorf("expected not found, got %v", err)
}
})
}