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