Files
RenewCMS/test/accept_invitation_test.go

647 lines
24 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"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/v3/transform"
)
const (
acceptCode = "3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42"
acceptEmail = "lea.martin@renew.example"
acceptPassword = "limewash2026"
acceptedInviteMsg = `{"message":"Invite accepted! HTTPonly jwt cookie created"}`
inviteInvalidBody = apperr.InviteInvalidErrorMessage + "\n"
inviteExpiredBody = apperr.InviteExpiredErrorMessage + "\n"
usernameTakenBody = apperr.UsernameTakenErrorMessage + "\n"
emailHasAccountBody = apperr.EmailHasAccountErrorMessage + "\n"
)
type acceptInviteFixture struct {
users *mockUserRepo
invites *inviteFakeRepo
mail *mockMailRepo
hasher *stubHasher
router *chi.Mux
token *jwtauth.JWTAuth
}
func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture {
t.Helper()
validator.InitValidator()
f := &acceptInviteFixture{
users: inviteInviterRepo(""),
invites: newInviteFakeRepo(),
mail: newMockMailRepo(),
hasher: &stubHasher{},
token: jwtauth.New("HS256", []byte("test-secret"), nil),
}
f.invites.users = f.users
getUser := useCases.NewGetUserUseCase(f.users)
acceptHandler := auth.NewAcceptInviteHandler(
useCases.NewGetInviteUseCase(f.invites, f.hasher),
useCases.NewAcceptInviteUseCase(f.users, f.invites, f.hasher, discardRecorder),
f.token,
true,
)
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()))
listHandler := userhandler.NewInviteHandler(
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder),
nil,
nil,
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
)
registerHandler := auth.NewLoginHandler(
f.token,
useCases.NewCreateUserUseCase(f.users, f.hasher, f.mail, invitePublicURL, discardRecorder),
useCases.NewAuthenticateUserUseCase(f.users, f.hasher, discardRecorder),
true,
)
f.router = chi.NewRouter()
f.router.Route("/v1/auth", func(r chi.Router) {
r.Post("/register", registerHandler.Register)
r.Get("/registration", registerHandler.RegistrationStatus)
r.Group(func(r chi.Router) {
r.Use(middleware.TrustedProxyRealIP(nil))
r.Use(middleware.NewRateLimiter(maxAttempts, time.Hour))
r.Get("/invite", acceptHandler.GetInvite)
r.Post("/invite/accept", acceptHandler.AcceptInvite)
})
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(f.token))
r.Use(jwtauth.Authenticator(f.token))
r.Use(middleware.RequireCurrentToken(getUser))
r.Get("/me", meHandler.Me)
})
})
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("/", listHandler.ListUsers)
})
return f
}
func (f *acceptInviteFixture) seedWithCode(base invite.Invite, code string) invite.Invite {
base.HashedCode = "hashed:" + code
return f.invites.seed(base)
}
func (f *acceptInviteFixture) seedPending() invite.Invite {
return f.seedWithCode(pendingInvite(acceptEmail), acceptCode)
}
func (f *acceptInviteFixture) do(method, target, body string, cookie *http.Cookie, remoteAddr string) *httptest.ResponseRecorder {
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 *acceptInviteFixture) get(target string) *httptest.ResponseRecorder {
return f.do(http.MethodGet, target, "", nil, "203.0.113.1")
}
func (f *acceptInviteFixture) accept(body string) *httptest.ResponseRecorder {
return f.do(http.MethodPost, "/v1/auth/invite/accept", body, nil, "203.0.113.1")
}
func acceptBody(code, username, password string) string {
return `{"code": "` + code + `", "username": "` + username + `", "password": "` + password + `"}`
}
func jwtCookieOf(rec *httptest.ResponseRecorder) *http.Cookie {
for _, cookie := range rec.Result().Cookies() {
if cookie.Name == "jwt" {
return cookie
}
}
return nil
}
func (f *acceptInviteFixture) userNamed(username string) (user.User, bool) {
for _, u := range f.users.users {
if u.Username == username {
return u, true
}
}
return user.User{}, false
}
func assertNoAcceptSideEffects(t *testing.T, f *acceptInviteFixture, rec *httptest.ResponseRecorder, usersBefore int) {
t.Helper()
if len(f.users.users) != usersBefore {
t.Errorf("no user may be created, have %d want %d", len(f.users.users), usersBefore)
}
if jwtCookieOf(rec) != nil {
t.Errorf("no jwt cookie may be set")
}
for _, stored := range f.invites.invites {
if stored.AcceptedAt != nil {
t.Errorf("no invite may be accepted, got %+v", stored)
}
}
}
func TestAcceptInvitationHttp(t *testing.T) {
t.Run("The invited email is read from a valid code", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
stored := f.seedPending()
rec := f.get("/v1/auth/invite?c=" + acceptCode)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
decoded := decodeKeys(t, rec.Body.Bytes())
assertExactKeys(t, decoded, "email")
if decodedString(t, decoded, "email") != acceptEmail {
t.Errorf("unexpected body %s", rec.Body.String())
}
if rec.Header().Get("Cache-Control") != "no-store" {
t.Errorf("expected no-store, got %q", rec.Header().Get("Cache-Control"))
}
if f.invites.invites[stored.ID] != stored {
t.Errorf("invite must be unchanged")
}
})
t.Run("A link that does not work is reported without saying why", func(t *testing.T) {
cases := []struct {
name, query string
status int
body string
}{
{"unknown code", "?c=not-a-known-code", http.StatusNotFound, inviteInvalidBody},
{"accepted code", "?c=accepted-code", http.StatusNotFound, inviteInvalidBody},
{"accepted and expired code", "?c=accepted-expired-code", http.StatusNotFound, inviteInvalidBody},
{"empty code", "?c=", http.StatusNotFound, inviteInvalidBody},
{"no query", "", http.StatusNotFound, inviteInvalidBody},
{"expired code", "?c=expired-code", http.StatusGone, inviteExpiredBody},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedWithCode(pendingInvite(acceptEmail), acceptCode)
acceptedAt := time.Now().Add(-time.Hour)
accepted := pendingInvite("sam@renew.example")
accepted.AcceptedAt = &acceptedAt
f.seedWithCode(accepted, "accepted-code")
acceptedExpired := expiredInvite("old@renew.example")
acceptedExpired.AcceptedAt = &acceptedAt
f.seedWithCode(acceptedExpired, "accepted-expired-code")
f.seedWithCode(expiredInvite("p.huber@gmail.com"), "expired-code")
rec := f.get("/v1/auth/invite" + tc.query)
if rec.Code != tc.status || rec.Body.String() != tc.body {
t.Errorf("expected %d %q, got %d %q", tc.status, tc.body, rec.Code, rec.Body.String())
}
})
}
})
t.Run("Accepting creates a verified user, logs them in and closes the invite", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
stored := f.seedWithCode(pendingInvite("Lea.Martin@Renew.example"), acceptCode)
stored.Email = "lea.martin@renew.example"
f.invites.invites[stored.ID] = stored
rec := f.accept(acceptBody(acceptCode, "lea", acceptPassword))
if rec.Code != http.StatusOK || rec.Body.String() != acceptedInviteMsg {
t.Fatalf("expected 200 message, got %d: %s", rec.Code, rec.Body.String())
}
created, ok := f.userNamed("lea")
if !ok {
t.Fatalf("user lea must exist")
}
if created.Email != acceptEmail || !created.IsVerified || created.VerificationCode != "" || created.DisplayName != "" {
t.Errorf("unexpected user %+v", created)
}
if created.Password == acceptPassword || created.Password != "hashed:"+acceptPassword {
t.Errorf("password must be stored hashed, got %q", created.Password)
}
if got := f.invites.invites[stored.ID]; got.AcceptedAt == nil || got.StatusAt(time.Now()) != invite.StatusAccepted {
t.Errorf("invite must be accepted, got %+v", got)
}
if len(f.mail.SentEmails) != 0 {
t.Errorf("no email may be sent")
}
cookie := jwtCookieOf(rec)
if cookie == nil || !cookie.HttpOnly || !cookie.Secure || cookie.Path != "/" {
t.Fatalf("expected an HttpOnly Secure jwt cookie on /, got %+v", cookie)
}
if lifetime := time.Until(cookie.Expires); lifetime < 23*time.Hour || lifetime > 24*time.Hour+time.Minute {
t.Errorf("expected a 24 hour cookie, got %v", lifetime)
}
decoded, err := f.token.Decode(cookie.Value)
if err != nil {
t.Fatalf("cookie does not decode: %v", err)
}
claims := map[string]interface{}{}
_ = transform.AsMap(decoded, claims)
if claims["user_id"] != float64(created.ID) || claims["token_version"] != float64(created.TokenVersion) {
t.Errorf("unexpected claims %v", claims)
}
})
t.Run("The new account works like any other account", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
cookie := jwtCookieOf(f.accept(acceptBody(acceptCode, "lea", acceptPassword)))
me := f.do(http.MethodGet, "/v1/auth/me", "", cookie, "203.0.113.1")
list := f.do(http.MethodGet, "/v1/user/", "", cookie, "203.0.113.1")
if me.Code != http.StatusOK || list.Code != http.StatusOK {
t.Fatalf("expected 200 and 200, got %d and %d", me.Code, list.Code)
}
items := decodeList(t, list.Body.Bytes())
var leaStatus string
for _, item := range items {
if decodedString(t, item, "kind") == "invite" {
t.Errorf("no invite may be listed: %v", item)
}
if decodedString(t, item, "kind") == "user" && decodedString(t, item, "username") == "lea" {
leaStatus = decodedString(t, item, "status")
}
}
if leaStatus != "accepted" {
t.Errorf("expected lea listed as accepted, got %q", leaStatus)
}
})
t.Run("An accepted code cannot be used again", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
f.accept(acceptBody(acceptCode, "lea", acceptPassword))
usersBefore := len(f.users.users)
rec := f.accept(acceptBody(acceptCode, "lea2", acceptPassword))
if rec.Code != http.StatusNotFound || rec.Body.String() != inviteInvalidBody {
t.Errorf("expected 404, got %d: %q", rec.Code, rec.Body.String())
}
if len(f.users.users) != usersBefore || jwtCookieOf(rec) != nil {
t.Errorf("nothing may be created")
}
if _, exists := f.userNamed("lea2"); exists {
t.Errorf("lea2 must not exist")
}
if got := f.get("/v1/auth/invite?c=" + acceptCode); got.Code != http.StatusNotFound {
t.Errorf("expected 404 on GET, got %d", got.Code)
}
})
t.Run("A code that cannot be used creates nothing", func(t *testing.T) {
cases := []struct {
name string
seed func(f *acceptInviteFixture)
code string
status int
body string
}{
{"no invite has that code", func(f *acceptInviteFixture) { f.seedPending() }, "unknown-code", http.StatusNotFound, inviteInvalidBody},
{"an invite that expired an hour ago", func(f *acceptInviteFixture) { f.seedWithCode(expiredInvite(acceptEmail), acceptCode) }, acceptCode, http.StatusGone, inviteExpiredBody},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
tc.seed(f)
usersBefore := len(f.users.users)
rec := f.accept(acceptBody(tc.code, "lea", acceptPassword))
if rec.Code != tc.status || rec.Body.String() != tc.body {
t.Errorf("expected %d %q, got %d %q", tc.status, tc.body, rec.Code, rec.Body.String())
}
assertNoAcceptSideEffects(t, f, rec, usersBefore)
})
}
})
t.Run("A taken username is refused and the invite stays usable", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
usersBefore := len(f.users.users)
rec := f.accept(acceptBody(acceptCode, "marta", acceptPassword))
if rec.Code != http.StatusConflict || rec.Body.String() != usernameTakenBody {
t.Errorf("expected 409, got %d: %q", rec.Code, rec.Body.String())
}
assertNoAcceptSideEffects(t, f, rec, usersBefore)
if retry := f.accept(acceptBody(acceptCode, "lea", acceptPassword)); retry.Code != http.StatusOK {
t.Errorf("expected 200 on retry, got %d: %s", retry.Code, retry.Body.String())
}
})
t.Run("An address that got an account in the meantime is refused", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
f.users.users[8] = user.User{ID: 8, Username: "leam", Email: "Lea.Martin@Renew.example"}
usersBefore := len(f.users.users)
rec := f.accept(acceptBody(acceptCode, "lea", acceptPassword))
if rec.Code != http.StatusConflict || rec.Body.String() != emailHasAccountBody {
t.Errorf("expected 409, got %d: %q", rec.Code, rec.Body.String())
}
assertNoAcceptSideEffects(t, f, rec, usersBefore)
})
t.Run("An invalid body is rejected before the code is looked up", func(t *testing.T) {
bodies := []string{
acceptBody(acceptCode, "le", acceptPassword),
acceptBody(acceptCode, "abcdefghijklmnopqrstu", acceptPassword),
acceptBody(acceptCode, "lea", "lime"),
`{"code": "` + acceptCode + `", "password": "limewash2026"}`,
`{"username": "lea", "password": "limewash2026"}`,
`not json`,
}
for _, body := range bodies {
t.Run(body, func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
usersBefore := len(f.users.users)
rec := f.accept(body)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
assertNoAcceptSideEffects(t, f, rec, usersBefore)
if f.hasher.compareCalls != 0 {
t.Errorf("the code must not be looked up")
}
})
}
})
t.Run("An existing jwt cookie is ignored and replaced", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
martaCookie := displayNameCookie(t, f.token, 7, 0)
rec := f.do(http.MethodPost, "/v1/auth/invite/accept", acceptBody(acceptCode, "lea", acceptPassword), martaCookie, "203.0.113.1")
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
created, _ := f.userNamed("lea")
cookie := jwtCookieOf(rec)
decoded, err := f.token.Decode(cookie.Value)
if err != nil {
t.Fatalf("cookie does not decode: %v", err)
}
claims := map[string]interface{}{}
_ = transform.AsMap(decoded, claims)
if claims["user_id"] != float64(created.ID) || created.ID == 7 {
t.Errorf("cookie must carry the new user's id, got %v", claims["user_id"])
}
})
t.Run("A failure while accepting leaves the invite pending and no user behind", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
stored := f.seedPending()
f.invites.fail.failNext("MarkAccepted")
usersBefore := len(f.users.users)
rec := f.accept(acceptBody(acceptCode, "lea", acceptPassword))
if rec.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d: %s", rec.Code, rec.Body.String())
}
assertNoAcceptSideEffects(t, f, rec, usersBefore)
if f.invites.invites[stored.ID] != stored {
t.Errorf("invite must be unchanged")
}
})
t.Run("The accept endpoints are rate-limited", func(t *testing.T) {
f := newAcceptInviteFixture(t, 2)
ip := "203.0.113.30"
for range 2 {
f.do(http.MethodPost, "/v1/auth/invite/accept", acceptBody("nope", "lea", acceptPassword), nil, ip)
}
rec := f.do(http.MethodPost, "/v1/auth/invite/accept", acceptBody("nope", "lea", acceptPassword), nil, 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 other := f.do(http.MethodGet, "/v1/auth/registration", "", nil, ip); other.Code == http.StatusTooManyRequests {
t.Errorf("registration status must not be limited")
}
})
t.Run("Registration stays closed for everyone else", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
acceptedAt := time.Now()
accepted := pendingInvite("sam@renew.example")
accepted.AcceptedAt = &acceptedAt
f.seedWithCode(accepted, "accepted-code")
rec := f.do(http.MethodPost, "/v1/auth/register", `{"username":"sam","password":"hunter2example12","email":"sam@renew.example"}`, nil, "203.0.113.1")
status := f.do(http.MethodGet, "/v1/auth/registration", "", nil, "203.0.113.1")
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.RegistrationClosedErrorMessage+"\n" {
t.Errorf("expected 403, got %d: %q", rec.Code, rec.Body.String())
}
if strings.ReplaceAll(status.Body.String(), " ", "") != `{"open":false}` {
t.Errorf("expected registration closed, got %s", status.Body.String())
}
})
t.Run("The invite response never leaks the code or other invite data", func(t *testing.T) {
f := newAcceptInviteFixture(t, 100)
f.seedPending()
rec := f.get("/v1/auth/invite?c=" + acceptCode)
decoded := decodeKeys(t, rec.Body.Bytes())
for _, forbidden := range []string{"code", "hashed_code", "invited_by", "expires_at", "id", "accepted_at"} {
if _, present := decoded[forbidden]; present {
t.Errorf("forbidden key %q in body", forbidden)
}
if strings.Contains(rec.Body.String(), `"`+forbidden+`"`) {
t.Errorf("forbidden name %q in raw body %s", forbidden, rec.Body.String())
}
}
if strings.Contains(rec.Body.String(), acceptCode) {
t.Errorf("raw code leaked in body %s", rec.Body.String())
}
})
}
func TestAcceptInviteUseCase(t *testing.T) {
newUseCase := func() (*useCases.AcceptInviteUseCase, *mockUserRepo, *inviteFakeRepo, *stubHasher) {
users, invites, hasher := inviteInviterRepo(""), newInviteFakeRepo(), &stubHasher{}
invites.users = users
return useCases.NewAcceptInviteUseCase(users, invites, hasher, discardRecorder), users, invites, hasher
}
command := useCases.AcceptInviteCommand{Code: acceptCode, Username: "lea", Password: acceptPassword}
t.Run("unknown code is invalid", func(t *testing.T) {
uc, _, _, _ := newUseCase()
if _, err := uc.AcceptInvite(testCtx, command); !errors.Is(err, invite.ErrInviteInvalid) {
t.Errorf("expected ErrInviteInvalid, got %v", err)
}
})
t.Run("empty code is invalid without hashing", func(t *testing.T) {
uc, _, invites, hasher := newUseCase()
invites.seed(pendingInvite(acceptEmail))
if _, err := uc.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Username: "lea", Password: acceptPassword}); !errors.Is(err, invite.ErrInviteInvalid) || hasher.compareCalls != 0 {
t.Errorf("expected ErrInviteInvalid, got %v", err)
}
})
t.Run("expired code is expired", func(t *testing.T) {
uc, _, invites, _ := newUseCase()
stored := expiredInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
if _, err := uc.AcceptInvite(testCtx, command); !errors.Is(err, invite.ErrInviteExpired) {
t.Errorf("expected ErrInviteExpired, got %v", err)
}
})
t.Run("taken username", func(t *testing.T) {
uc, users, invites, _ := newUseCase()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
_, err := uc.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Code: acceptCode, Username: "marta", Password: acceptPassword})
if !errors.Is(err, user.ErrUsernameTaken) || len(users.users) != 1 {
t.Errorf("expected ErrUsernameTaken, got %v", err)
}
})
t.Run("username lookup failure is returned", func(t *testing.T) {
uc, users, invites, _ := newUseCase()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
users.fail.failNext("GetByUsername")
if _, err := uc.AcceptInvite(testCtx, command); !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected repository failure, got %v", err)
}
})
t.Run("email that has an account", func(t *testing.T) {
uc, users, invites, _ := newUseCase()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
users.users[8] = user.User{ID: 8, Username: "leam", Email: "LEA.martin@renew.example"}
if _, err := uc.AcceptInvite(testCtx, command); !errors.Is(err, invite.ErrEmailHasAccount) {
t.Errorf("expected ErrEmailHasAccount, got %v", err)
}
})
t.Run("an address that got an account because the invite was just accepted is invalid", func(t *testing.T) {
users, invites, hasher := inviteInviterRepo(""), newInviteFakeRepo(), &stubHasher{}
invites.users = users
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
seeded := invites.seed(stored)
racing := &atomicInviteRepoDecorator{Repository: invites, afterGetAll: func() {
acceptedAt := time.Now()
seeded.AcceptedAt = &acceptedAt
invites.invites[seeded.ID] = seeded
users.users[8] = user.User{ID: 8, Username: "lea2", Email: acceptEmail}
}}
uc := useCases.NewAcceptInviteUseCase(users, racing, hasher, discardRecorder)
if _, err := uc.AcceptInvite(testCtx, command); !errors.Is(err, invite.ErrInviteInvalid) {
t.Errorf("expected ErrInviteInvalid, got %v", err)
}
})
t.Run("hash failure creates nothing", func(t *testing.T) {
uc, users, invites, hasher := newUseCase()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
hasher.hashErr = errors.New("hash boom")
if _, err := uc.AcceptInvite(testCtx, command); err == nil || len(users.users) != 1 {
t.Errorf("expected failure without a user, got %v", err)
}
})
t.Run("user creation failure leaves the invite pending", func(t *testing.T) {
uc, users, invites, _ := newUseCase()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
seeded := invites.seed(stored)
users.fail.failNext("Create")
if _, err := uc.AcceptInvite(testCtx, command); !errors.Is(err, errRepositoryFailure) || invites.invites[seeded.ID].AcceptedAt != nil {
t.Errorf("expected repository failure and a pending invite, got %v", err)
}
})
t.Run("accept failure leaves no user and no delete call", func(t *testing.T) {
uc, users, invites, _ := newUseCase()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
seeded := invites.seed(stored)
invites.fail.failNextWith("MarkAccepted", errors.New("mark down"))
users.fail.failNextWith("Delete", errors.New("delete down"))
_, err := uc.AcceptInvite(testCtx, command)
if err == nil || !strings.Contains(err.Error(), "mark down") || strings.Contains(err.Error(), "delete down") {
t.Errorf("unexpected error %v", err)
}
if len(users.users) != 1 || invites.invites[seeded.ID].AcceptedAt != nil {
t.Errorf("expected no user and a pending invite")
}
})
t.Run("an invite that was taken meanwhile is invalid", func(t *testing.T) {
uc, users, invites, _ := newUseCase()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
invites.fail.failNextWith("MarkAccepted", invite.ErrInviteNotFound)
if _, err := uc.AcceptInvite(testCtx, command); !errors.Is(err, invite.ErrInviteInvalid) || len(users.users) != 1 {
t.Errorf("expected ErrInviteInvalid, got %v", err)
}
})
}
func TestGetInviteUseCase(t *testing.T) {
t.Run("returns the usable invite", func(t *testing.T) {
invites, hasher := newInviteFakeRepo(), &stubHasher{}
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
got, err := useCases.NewGetInviteUseCase(invites, hasher).GetInvite(acceptCode)
if err != nil || got.Email != acceptEmail {
t.Errorf("unexpected result %+v, %v", got, err)
}
})
}