303 lines
9.2 KiB
Go
303 lines
9.2 KiB
Go
package test
|
|
|
|
import (
|
|
"RenewCMS/api/handlers/auth"
|
|
"RenewCMS/api/pkg/apperr"
|
|
validator "RenewCMS/api/pkg/validator"
|
|
"RenewCMS/internal/domain/user"
|
|
"RenewCMS/internal/infrastructure/hashing"
|
|
"RenewCMS/internal/infrastructure/useCases"
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/jwtauth/v5"
|
|
)
|
|
|
|
var errStubHashFailure = errors.New("stub hash failure")
|
|
|
|
type spyHasher struct {
|
|
stubHasher
|
|
compareCalls int
|
|
}
|
|
|
|
func (s *spyHasher) Compare(hashed string, value string) error {
|
|
s.compareCalls++
|
|
return s.stubHasher.Compare(hashed, value)
|
|
}
|
|
|
|
func TestCreateUserUseCaseHashesPasswordAndVerificationCode(t *testing.T) {
|
|
t.Run("stores hashed password and hashed verification code, returns raw code", func(t *testing.T) {
|
|
repo := newMockUserRepo()
|
|
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com")
|
|
|
|
result, err := uc.CreateUser(useCases.CreateUserCommand{
|
|
Username: "alice",
|
|
Password: "s3cretPassw0rd",
|
|
Email: "alice@example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
|
|
stored := repo.users[result.ID]
|
|
if stored.Password != "hashed:s3cretPassw0rd" {
|
|
t.Errorf("expected stored password %q, got %q", "hashed:s3cretPassw0rd", stored.Password)
|
|
}
|
|
if stored.VerificationCode == "" || stored.VerificationCode == result.VerificationCode {
|
|
t.Errorf("expected stored verification code to be hashed, got %q", stored.VerificationCode)
|
|
}
|
|
if stored.VerificationCode != "hashed:"+result.VerificationCode {
|
|
t.Errorf("expected stored verification code %q, got %q", "hashed:"+result.VerificationCode, stored.VerificationCode)
|
|
}
|
|
})
|
|
|
|
t.Run("a hashing failure persists nothing", func(t *testing.T) {
|
|
repo := newMockUserRepo()
|
|
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{hashErr: errStubHashFailure}, newMockMailRepo(), "https://cms.example.com")
|
|
|
|
_, err := uc.CreateUser(useCases.CreateUserCommand{
|
|
Username: "alice",
|
|
Password: "s3cretPassw0rd",
|
|
Email: "alice@example.com",
|
|
})
|
|
|
|
if !errors.Is(err, errStubHashFailure) {
|
|
t.Errorf("expected the hashing error, got %v", err)
|
|
}
|
|
if len(repo.users) != 0 {
|
|
t.Errorf("expected no user stored, got %d", len(repo.users))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAuthenticateUserUseCase(t *testing.T) {
|
|
newRepo := func() *mockUserRepo {
|
|
repo := newMockUserRepo()
|
|
repo.users[1] = user.User{ID: 1, Username: "alice", Password: "hashed:hunter2example"}
|
|
return repo
|
|
}
|
|
|
|
t.Run("correct password returns the user with no error", func(t *testing.T) {
|
|
repo := newRepo()
|
|
uc := useCases.NewAuthenticateUserUseCase(repo, &stubHasher{}, discardRecorder)
|
|
|
|
result, err := uc.Authenticate(testCtx, "alice", "hunter2example")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if result.ID != 1 {
|
|
t.Errorf("expected user id 1, got %d", result.ID)
|
|
}
|
|
})
|
|
|
|
t.Run("wrong password returns user.ErrInvalidCredentials", func(t *testing.T) {
|
|
repo := newRepo()
|
|
uc := useCases.NewAuthenticateUserUseCase(repo, &stubHasher{}, discardRecorder)
|
|
|
|
_, err := uc.Authenticate(testCtx, "alice", "wrongpassword12")
|
|
if !errors.Is(err, user.ErrInvalidCredentials) {
|
|
t.Errorf("expected user.ErrInvalidCredentials, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("unknown username never calls the hasher", func(t *testing.T) {
|
|
repo := newMockUserRepo()
|
|
hasher := &spyHasher{}
|
|
uc := useCases.NewAuthenticateUserUseCase(repo, hasher, discardRecorder)
|
|
|
|
_, err := uc.Authenticate(testCtx, "bob", "hunter2example")
|
|
if !errors.Is(err, user.ErrUserNotFound) {
|
|
t.Errorf("expected user.ErrUserNotFound, got %v", err)
|
|
}
|
|
if hasher.compareCalls != 0 {
|
|
t.Errorf("expected the hasher's Compare to never be called, got %d calls", hasher.compareCalls)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBcryptHasherHashAndCompare(t *testing.T) {
|
|
hasher := hashing.NewBcryptHasher()
|
|
|
|
t.Run("hashing then comparing the same value succeeds", func(t *testing.T) {
|
|
hashed, err := hasher.Hash("hunter2example")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if err := hasher.Compare(hashed, "hunter2example"); err != nil {
|
|
t.Errorf("expected the comparison to succeed, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("comparing against the wrong value fails", func(t *testing.T) {
|
|
hashed, err := hasher.Hash("hunter2example")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if err := hasher.Compare(hashed, "wrongpassword12"); err == nil {
|
|
t.Errorf("expected the comparison to fail")
|
|
}
|
|
})
|
|
|
|
t.Run("hashing a value over 72 bytes fails", func(t *testing.T) {
|
|
tooLong := strings.Repeat("a", 73)
|
|
if _, err := hasher.Hash(tooLong); err == nil {
|
|
t.Errorf("expected an error for a value over 72 bytes")
|
|
}
|
|
})
|
|
}
|
|
|
|
func newHasherRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux {
|
|
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
|
loginHandler := auth.NewLoginHandler(
|
|
token,
|
|
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
|
|
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
|
true,
|
|
)
|
|
|
|
r := chi.NewRouter()
|
|
r.Route("/v1/auth", func(r chi.Router) {
|
|
r.Post("/login", loginHandler.Login)
|
|
r.Post("/register", loginHandler.Register)
|
|
})
|
|
return r
|
|
}
|
|
|
|
func newRealHasherRouter(userRepo *mockUserRepo) *chi.Mux {
|
|
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
|
hasher := hashing.NewBcryptHasher()
|
|
loginHandler := auth.NewLoginHandler(
|
|
token,
|
|
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
|
|
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
|
|
true,
|
|
)
|
|
|
|
r := chi.NewRouter()
|
|
r.Route("/v1/auth", func(r chi.Router) {
|
|
r.Post("/login", loginHandler.Login)
|
|
r.Post("/register", loginHandler.Register)
|
|
})
|
|
return r
|
|
}
|
|
|
|
func TestLoginOverHttpCannotDistinguishBadPasswordFromUnknownUsername(t *testing.T) {
|
|
validator.InitValidator()
|
|
|
|
newAliceRepo := func() *mockUserRepo {
|
|
repo := newMockUserRepo()
|
|
hashed, err := hashing.NewBcryptHasher().Hash("hunter2example")
|
|
if err != nil {
|
|
t.Fatalf("failed to hash password: %v", err)
|
|
}
|
|
repo.users[1] = user.User{ID: 1, Username: "alice", Password: hashed}
|
|
return repo
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
username string
|
|
password string
|
|
}{
|
|
{"wrong password for existing user", "alice", "wrongpassword12"},
|
|
{"nonexistent username", "bob", "hunter2example"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
router := newRealHasherRouter(newAliceRepo())
|
|
body, err := json.Marshal(map[string]string{"username": tc.username, "password": tc.password})
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal body: %v", err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("expected status 403, got %d", rec.Code)
|
|
}
|
|
if rec.Body.String() != apperr.LogsErrorMessage+"\n" {
|
|
t.Errorf("unexpected body: %q", rec.Body.String())
|
|
}
|
|
if rec.Header().Get("Set-Cookie") != "" {
|
|
t.Errorf("expected no Set-Cookie header, got %q", rec.Header().Get("Set-Cookie"))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRegisteringAUserStoresTheHashedPasswordNeverThePlaintext(t *testing.T) {
|
|
validator.InitValidator()
|
|
|
|
repo := newMockUserRepo()
|
|
router := newRealHasherRouter(repo)
|
|
|
|
body, err := json.Marshal(map[string]string{
|
|
"username": "alice",
|
|
"password": "s3cretPassw0rd",
|
|
"email": "alice@example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal body: %v", err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/auth/register", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec.Header().Get("Set-Cookie") == "" {
|
|
t.Errorf("expected a jwt cookie to be set")
|
|
}
|
|
|
|
stored, err := repo.GetByUsername("alice")
|
|
if err != nil {
|
|
t.Fatalf("expected the user to be stored, got %v", err)
|
|
}
|
|
if stored.Password == "s3cretPassw0rd" {
|
|
t.Errorf("expected the stored password to not be the plaintext")
|
|
}
|
|
|
|
hasher := hashing.NewBcryptHasher()
|
|
if err := hasher.Compare(stored.Password, "s3cretPassw0rd"); err != nil {
|
|
t.Errorf("expected the stored password to match the plaintext once compared, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAHashingFailureDuringRegistrationSurfacesAs500AndSetsNoCookie(t *testing.T) {
|
|
validator.InitValidator()
|
|
|
|
repo := newMockUserRepo()
|
|
router := newHasherRouter(repo, &stubHasher{hashErr: errStubHashFailure})
|
|
|
|
body, err := json.Marshal(map[string]string{
|
|
"username": "alice",
|
|
"password": "s3cretPassw0rd",
|
|
"email": "alice@example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal body: %v", err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/auth/register", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("expected status 500, got %d", rec.Code)
|
|
}
|
|
if rec.Header().Get("Set-Cookie") != "" {
|
|
t.Errorf("expected no Set-Cookie header, got %q", rec.Header().Get("Set-Cookie"))
|
|
}
|
|
}
|