Files
apocapoc-api/internal/shared/validation/validator.go
T
david 7768037724 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
2025-11-27 00:17:18 +01:00

128 lines
3.0 KiB
Go

package validation
import (
"fmt"
"regexp"
"strings"
"time"
"unicode"
)
// RFC 5322 compliant email regex (simplified but robust)
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9.!#$%&'*+/=?^_` + "`" + `{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$`)
type ValidationError struct {
Field string
Message string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
func ValidateEmail(email string) error {
email = strings.TrimSpace(email)
if email == "" {
return ValidationError{Field: "email", Message: "email is required"}
}
if len(email) > 254 {
return ValidationError{Field: "email", Message: "email must not exceed 254 characters"}
}
if !emailRegex.MatchString(email) {
return ValidationError{Field: "email", Message: "invalid email format"}
}
parts := strings.Split(email, "@")
if len(parts[0]) > 64 {
return ValidationError{Field: "email", Message: "email local part must not exceed 64 characters"}
}
return nil
}
func ValidatePassword(password string) error {
if password == "" {
return ValidationError{Field: "password", Message: "password is required"}
}
if len(password) < 8 {
return ValidationError{Field: "password", Message: "password must be at least 8 characters long"}
}
if len(password) > 128 {
return ValidationError{Field: "password", Message: "password must not exceed 128 characters"}
}
var (
hasUpper bool
hasLower bool
hasDigit bool
hasSpecial bool
)
for _, char := range password {
switch {
case unicode.IsUpper(char):
hasUpper = true
case unicode.IsLower(char):
hasLower = true
case unicode.IsDigit(char):
hasDigit = true
case unicode.IsPunct(char) || unicode.IsSymbol(char):
hasSpecial = true
}
}
if !hasUpper {
return ValidationError{Field: "password", Message: "password must contain at least one uppercase letter"}
}
if !hasLower {
return ValidationError{Field: "password", Message: "password must contain at least one lowercase letter"}
}
if !hasDigit {
return ValidationError{Field: "password", Message: "password must contain at least one digit"}
}
if !hasSpecial {
return ValidationError{Field: "password", Message: "password must contain at least one special character"}
}
return nil
}
func ValidateTimezone(timezone string) error {
timezone = strings.TrimSpace(timezone)
if timezone == "" {
return ValidationError{Field: "timezone", Message: "timezone is required"}
}
_, err := time.LoadLocation(timezone)
if err != nil {
return ValidationError{Field: "timezone", Message: "invalid timezone, must be a valid IANA timezone (e.g., 'America/New_York', 'Europe/Madrid', 'UTC')"}
}
return nil
}
func ValidateRegistration(email, password, timezone string) error {
if err := ValidateEmail(email); err != nil {
return err
}
if err := ValidatePassword(password); err != nil {
return err
}
if err := ValidateTimezone(timezone); err != nil {
return err
}
return nil
}