592 lines
25 KiB
Go
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),
|
|
useCases.NewCancelInviteUseCase(f.invites),
|
|
useCases.NewRenewInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL),
|
|
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
|
|
)
|
|
acceptHandler := auth.NewAcceptInviteHandler(
|
|
useCases.NewGetInviteUseCase(f.invites, f.hasher),
|
|
useCases.NewAcceptInviteUseCase(f.users, f.invites, f.hasher),
|
|
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)
|
|
|
|
_, err := renew.RenewInvite(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)
|
|
|
|
_, err := renew.RenewInvite(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)
|
|
|
|
if err := cancel.CancelInvite(9); !errors.Is(err, invite.ErrInviteNotFound) {
|
|
t.Errorf("expected not found, got %v", err)
|
|
}
|
|
if err := cancel.CancelInvite(2); !errors.Is(err, invite.ErrInviteAlreadyAccepted) {
|
|
t.Errorf("expected already accepted, got %v", err)
|
|
}
|
|
if err := cancel.CancelInvite(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)
|
|
|
|
_, err := renew.RenewInvite(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)
|
|
|
|
if _, err := renew.RenewInvite(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)
|
|
}
|
|
})
|
|
}
|