Add robust input validation system and fix test suite

- Add comprehensive validation package with email (RFC 5322), password strength, and IANA timezone validation
- Implement strict password requirements: min 8 chars, uppercase, lowercase, digit, special character
- Integrate validation into RegisterUserHandler with complete test coverage (59 validation tests + 25 handler tests)
- Fix pre-existing test failures:
  - Remove tests for non-existent HabitEntry.DeletedAt and Delete() methods
  - Replace deprecated HabitTypeQuantity with HabitTypeValue
  - Add missing FindByHabitIDAndDateRange mock implementation
- Remove hardcoded localhost:8080 from Swagger config for self-hosted flexibility
This commit is contained in:
2025-11-27 00:17:18 +01:00
parent 95088a8162
commit 7768037724
9 changed files with 673 additions and 39 deletions
+3 -11
View File
@@ -7,6 +7,7 @@ import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/services"
"apocapoc-api/internal/shared/errors"
"apocapoc-api/internal/shared/validation"
)
type RegisterUserCommand struct {
@@ -28,11 +29,7 @@ func NewRegisterUserHandler(userRepo repositories.UserRepository, passwordHasher
}
func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserCommand) (string, error) {
if cmd.Email == "" || cmd.Password == "" {
return "", errors.ErrInvalidInput
}
if len(cmd.Password) < 8 {
if err := validation.ValidateRegistration(cmd.Email, cmd.Password, cmd.Timezone); err != nil {
return "", errors.ErrInvalidInput
}
@@ -46,12 +43,7 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman
return "", err
}
timezone := cmd.Timezone
if timezone == "" {
timezone = "UTC"
}
user := entities.NewUser(cmd.Email, hashedPassword, timezone)
user := entities.NewUser(cmd.Email, hashedPassword, cmd.Timezone)
if err := h.userRepo.Create(ctx, user); err != nil {
return "", err
@@ -0,0 +1,324 @@
package commands
import (
"context"
"errors"
"testing"
"apocapoc-api/internal/domain/entities"
appErrors "apocapoc-api/internal/shared/errors"
)
type mockUserRepo struct {
findByEmailFunc func(ctx context.Context, email string) (*entities.User, error)
createFunc func(ctx context.Context, user *entities.User) error
}
func (m *mockUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
if m.findByEmailFunc != nil {
return m.findByEmailFunc(ctx, email)
}
return nil, appErrors.ErrNotFound
}
func (m *mockUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) {
return nil, nil
}
func (m *mockUserRepo) Create(ctx context.Context, user *entities.User) error {
if m.createFunc != nil {
return m.createFunc(ctx, user)
}
return nil
}
func (m *mockUserRepo) Update(ctx context.Context, user *entities.User) error {
return nil
}
type mockPasswordHasher struct {
hashFunc func(password string) (string, error)
}
func (m *mockPasswordHasher) Hash(password string) (string, error) {
if m.hashFunc != nil {
return m.hashFunc(password)
}
return "hashed_" + password, nil
}
func (m *mockPasswordHasher) Compare(hashedPassword, password string) error {
return nil
}
func TestRegisterUserHandler_Success(t *testing.T) {
var createdUser *entities.User
repo := &mockUserRepo{
createFunc: func(ctx context.Context, user *entities.User) error {
user.ID = "test-user-id-123"
createdUser = user
return nil
},
}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher)
cmd := RegisterUserCommand{
Email: "test@example.com",
Password: "Secure123!",
Timezone: "UTC",
}
userID, err := handler.Handle(context.Background(), cmd)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if userID == "" {
t.Error("expected user ID, got empty string")
}
if createdUser == nil {
t.Fatal("expected user to be created")
}
if createdUser.Email != cmd.Email {
t.Errorf("expected email %q, got %q", cmd.Email, createdUser.Email)
}
if createdUser.Timezone != cmd.Timezone {
t.Errorf("expected timezone %q, got %q", cmd.Timezone, createdUser.Timezone)
}
}
func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher)
tests := []struct {
name string
email string
}{
{"empty email", ""},
{"invalid format", "not-an-email"},
{"missing @", "testexample.com"},
{"missing domain", "test@"},
{"spaces in email", "test user@example.com"},
{"too long local part", string(make([]byte, 65)) + "@example.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := RegisterUserCommand{
Email: tt.email,
Password: "Secure123!",
Timezone: "UTC",
}
_, err := handler.Handle(context.Background(), cmd)
if err != appErrors.ErrInvalidInput {
t.Errorf("expected ErrInvalidInput, got %v", err)
}
})
}
}
func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher)
tests := []struct {
name string
password string
}{
{"empty password", ""},
{"too short", "Short1!"},
{"no uppercase", "secure123!"},
{"no lowercase", "SECURE123!"},
{"no digit", "SecurePass!"},
{"no special char", "SecurePass1"},
{"only letters", "OnlyLetters"},
{"only numbers", "12345678"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := RegisterUserCommand{
Email: "test@example.com",
Password: tt.password,
Timezone: "UTC",
}
_, err := handler.Handle(context.Background(), cmd)
if err != appErrors.ErrInvalidInput {
t.Errorf("expected ErrInvalidInput, got %v", err)
}
})
}
}
func TestRegisterUserHandler_InvalidTimezone(t *testing.T) {
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher)
tests := []struct {
name string
timezone string
}{
{"empty timezone", ""},
{"invalid timezone", "InvalidTimezone"},
{"numeric format", "GMT+1"},
{"partial timezone", "America"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := RegisterUserCommand{
Email: "test@example.com",
Password: "Secure123!",
Timezone: tt.timezone,
}
_, err := handler.Handle(context.Background(), cmd)
if err != appErrors.ErrInvalidInput {
t.Errorf("expected ErrInvalidInput, got %v", err)
}
})
}
}
func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
existingUser := entities.NewUser("test@example.com", "hashed", "UTC")
repo := &mockUserRepo{
findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) {
return existingUser, nil
},
}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher)
cmd := RegisterUserCommand{
Email: "test@example.com",
Password: "Secure123!",
Timezone: "UTC",
}
_, err := handler.Handle(context.Background(), cmd)
if err != appErrors.ErrAlreadyExists {
t.Errorf("expected ErrAlreadyExists, got %v", err)
}
}
func TestRegisterUserHandler_PasswordHashingError(t *testing.T) {
expectedErr := errors.New("hashing error")
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{
hashFunc: func(password string) (string, error) {
return "", expectedErr
},
}
handler := NewRegisterUserHandler(repo, hasher)
cmd := RegisterUserCommand{
Email: "test@example.com",
Password: "Secure123!",
Timezone: "UTC",
}
_, err := handler.Handle(context.Background(), cmd)
if err != expectedErr {
t.Errorf("expected hashing error, got %v", err)
}
}
func TestRegisterUserHandler_RepositoryError(t *testing.T) {
expectedErr := errors.New("repository error")
repo := &mockUserRepo{
createFunc: func(ctx context.Context, user *entities.User) error {
return expectedErr
},
}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher)
cmd := RegisterUserCommand{
Email: "test@example.com",
Password: "Secure123!",
Timezone: "UTC",
}
_, err := handler.Handle(context.Background(), cmd)
if err != expectedErr {
t.Errorf("expected repository error, got %v", err)
}
}
func TestRegisterUserHandler_EdgeCases(t *testing.T) {
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher)
tests := []struct {
name string
cmd RegisterUserCommand
wantErr error
}{
{
"email with plus addressing",
RegisterUserCommand{
Email: "user+tag@example.com",
Password: "Secure123!",
Timezone: "UTC",
},
nil,
},
{
"email with subdomain",
RegisterUserCommand{
Email: "user@mail.example.com",
Password: "Secure123!",
Timezone: "UTC",
},
nil,
},
{
"complex timezone",
RegisterUserCommand{
Email: "user@example.com",
Password: "Secure123!",
Timezone: "America/Argentina/Buenos_Aires",
},
nil,
},
{
"password with unicode",
RegisterUserCommand{
Email: "user@example.com",
Password: "Sëcure123!",
Timezone: "UTC",
},
nil,
},
{
"very long valid password",
RegisterUserCommand{
Email: "user@example.com",
Password: "ValidP@ss1" + string(make([]byte, 100)),
Timezone: "UTC",
},
nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := handler.Handle(context.Background(), tt.cmd)
if err != tt.wantErr {
t.Errorf("expected error %v, got %v", tt.wantErr, err)
}
})
}
}
@@ -24,7 +24,7 @@ func (m *mockHabitRepoWithFindByID) FindByID(ctx context.Context, id string) (*e
func TestGetHabitByIDHandler_ReturnsHabitSuccessfully(t *testing.T) {
targetValue := 5.0
habit := entities.NewHabit("user-123", "Drink Water", value_objects.HabitTypeQuantity, value_objects.FrequencyDaily, true)
habit := entities.NewHabit("user-123", "Drink Water", value_objects.HabitTypeValue, value_objects.FrequencyDaily, true)
habit.ID = "habit-1"
habit.TargetValue = &targetValue
@@ -53,8 +53,8 @@ func TestGetHabitByIDHandler_ReturnsHabitSuccessfully(t *testing.T) {
t.Errorf("Expected name 'Drink Water', got %s", result.Name)
}
if result.Type != string(value_objects.HabitTypeQuantity) {
t.Errorf("Expected type %s, got %s", value_objects.HabitTypeQuantity, result.Type)
if result.Type != string(value_objects.HabitTypeValue) {
t.Errorf("Expected type %s, got %s", value_objects.HabitTypeValue, result.Type)
}
if result.TargetValue == nil || *result.TargetValue != 5.0 {
@@ -20,6 +20,10 @@ func (m *mockEntryRepoWithFindByHabitID) FindByHabitID(ctx context.Context, habi
return m.entries, nil
}
func (m *mockEntryRepoWithFindByHabitID) FindByHabitIDAndDateRange(ctx context.Context, habitID string, from, to time.Time) ([]*entities.HabitEntry, error) {
return m.entries, nil
}
func TestGetHabitEntriesHandler_ReturnsEntriesSuccessfully(t *testing.T) {
habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false)
habit.ID = "habit-1"
@@ -64,7 +64,7 @@ func TestGetUserHabitsHandler_ReturnsEmptyListForUserWithNoHabits(t *testing.T)
func TestGetUserHabitsHandler_IncludesAllHabitFields(t *testing.T) {
targetValue := 5.0
habit := entities.NewHabit("user-123", "Drink Water", value_objects.HabitTypeQuantity, value_objects.FrequencyDaily, true)
habit := entities.NewHabit("user-123", "Drink Water", value_objects.HabitTypeValue, value_objects.FrequencyDaily, true)
habit.ID = "habit-1"
habit.TargetValue = &targetValue
@@ -92,8 +92,8 @@ func TestGetUserHabitsHandler_IncludesAllHabitFields(t *testing.T) {
t.Errorf("Expected name 'Drink Water', got %s", result.Name)
}
if result.Type != string(value_objects.HabitTypeQuantity) {
t.Errorf("Expected type %s, got %s", value_objects.HabitTypeQuantity, result.Type)
if result.Type != string(value_objects.HabitTypeValue) {
t.Errorf("Expected type %s, got %s", value_objects.HabitTypeValue, result.Type)
}
if result.Frequency != string(value_objects.FrequencyDaily) {