647 lines
24 KiB
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)
|
|
}
|
|
})
|
|
}
|