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:
@@ -0,0 +1,127 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
email string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid email", "user@example.com", false},
|
||||
{"valid email with subdomain", "user@mail.example.com", false},
|
||||
{"valid email with plus", "user+tag@example.com", false},
|
||||
{"valid email with dots", "user.name@example.com", false},
|
||||
{"valid email with numbers", "user123@example.com", false},
|
||||
{"valid email with dash", "user-name@example.com", false},
|
||||
{"empty email", "", true},
|
||||
{"missing @", "userexample.com", true},
|
||||
{"missing domain", "user@", true},
|
||||
{"missing local part", "@example.com", true},
|
||||
{"invalid format", "string", true},
|
||||
{"double @", "user@@example.com", true},
|
||||
{"spaces in email", "user name@example.com", true},
|
||||
{"too long email", strings.Repeat("a", 250) + "@example.com", true},
|
||||
{"too long local part", strings.Repeat("a", 65) + "@example.com", true},
|
||||
{"no TLD", "user@example", false},
|
||||
{"with whitespace", " user@example.com ", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateEmail(tt.email)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateEmail(%q) error = %v, wantErr %v", tt.email, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePassword(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pwd string
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{"valid strong password", "Passw0rd!", false, ""},
|
||||
{"valid with symbols", "MyP@ssw0rd#2024", false, ""},
|
||||
{"valid with mixed case", "Str0ng!Pass", false, ""},
|
||||
{"empty password", "", true, "password is required"},
|
||||
{"too short", "Pass1!", true, "at least 8 characters"},
|
||||
{"no uppercase", "password1!", true, "uppercase letter"},
|
||||
{"no lowercase", "PASSWORD1!", true, "lowercase letter"},
|
||||
{"no digit", "Password!", true, "digit"},
|
||||
{"no special char", "Password1", true, "special character"},
|
||||
{"only letters", "PasswordPassword", true, "digit"},
|
||||
{"only numbers", "12345678", true, "uppercase letter"},
|
||||
{"7 chars valid format", "Passw0!", true, "at least 8 characters"},
|
||||
{"exactly 8 chars", "Passw0rd!", false, ""},
|
||||
{"very long password", strings.Repeat("Aa1!", 32), false, ""},
|
||||
{"too long password", strings.Repeat("a", 129), true, "must not exceed 128 characters"},
|
||||
{"unicode special chars", "Pässw0rd!", false, ""},
|
||||
{"spaces do not count as special", "Pass word1", true, "special character"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidatePassword(tt.pwd)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidatePassword(%q) error = %v, wantErr %v", tt.pwd, err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr && err != nil && tt.errMsg != "" {
|
||||
if !strings.Contains(err.Error(), tt.errMsg) {
|
||||
t.Errorf("ValidatePassword(%q) error = %v, want error containing %q", tt.pwd, err, tt.errMsg)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTimezone(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
timezone string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid UTC", "UTC", false},
|
||||
{"valid America/New_York", "America/New_York", false},
|
||||
{"valid Europe/Madrid", "Europe/Madrid", false},
|
||||
{"valid Asia/Tokyo", "Asia/Tokyo", false},
|
||||
{"valid Europe/London", "Europe/London", false},
|
||||
{"valid Australia/Sydney", "Australia/Sydney", false},
|
||||
{"valid with spaces trimmed", " UTC ", false},
|
||||
{"empty timezone", "", true},
|
||||
{"invalid timezone", "string", true},
|
||||
{"invalid format", "Invalid/Timezone", true},
|
||||
{"numeric timezone", "GMT+1", true},
|
||||
{"partial timezone", "America", true},
|
||||
{"lowercase valid", "utc", true},
|
||||
{"typo in timezone", "America/New_Yorkkk", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateTimezone(tt.timezone)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateTimezone(%q) error = %v, wantErr %v", tt.timezone, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRegistration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
email string
|
||||
password string
|
||||
timezone string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"valid registration",
|
||||
"user@example.com",
|
||||
"Passw0rd!",
|
||||
"UTC",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"valid with complex email",
|
||||
"user.name+tag@example.co.uk",
|
||||
"MyS3cur3P@ss",
|
||||
"Europe/Madrid",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"invalid email",
|
||||
"invalid-email",
|
||||
"Passw0rd!",
|
||||
"UTC",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid password",
|
||||
"user@example.com",
|
||||
"weak",
|
||||
"UTC",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid timezone",
|
||||
"user@example.com",
|
||||
"Passw0rd!",
|
||||
"InvalidTZ",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"all invalid",
|
||||
"not-an-email",
|
||||
"weak",
|
||||
"bad-tz",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"empty email",
|
||||
"",
|
||||
"Passw0rd!",
|
||||
"UTC",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"empty password",
|
||||
"user@example.com",
|
||||
"",
|
||||
"UTC",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"empty timezone",
|
||||
"user@example.com",
|
||||
"Passw0rd!",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateRegistration(tt.email, tt.password, tt.timezone)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateRegistration() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationError(t *testing.T) {
|
||||
err := ValidationError{
|
||||
Field: "email",
|
||||
Message: "invalid format",
|
||||
}
|
||||
|
||||
expected := "email: invalid format"
|
||||
if err.Error() != expected {
|
||||
t.Errorf("ValidationError.Error() = %q, want %q", err.Error(), expected)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user