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