Files
juanatsap 6c7595b041 feat: add tag-based validation system with reflection caching
Implement a declarative struct tag validation system for Go:

- Add validator.go with sync.Map caching for reflection metadata
- Add rules.go with 11 built-in validation rules (required, email,
  pattern, honeypot, timing, etc.)
- Add errors.go with FieldError and ValidationErrors types
- Update ContactFormRequest with validate tags
- Add ValidateContactFormV2() using the new tag-based validator

Rules implemented:
- required/optional: field presence validation
- trim/sanitize: automatic value transformations
- min/max: UTF-8 aware length validation
- email: RFC 5322 email format validation
- pattern: predefined regex patterns (name, subject, company)
- no_injection: email header injection prevention
- honeypot: bot trap (must be empty)
- timing: timestamp validation for bot detection

Documentation:
- docs/go-validation-system.md: complete validation guide
- docs/go-template-system.md: template manager documentation
- docs/go-routes-api.md: routes and API reference
- docs/README.md: documentation index
2025-12-06 15:20:45 +00:00

668 lines
16 KiB
Go

package validation
import (
"strings"
"testing"
"time"
)
// ==============================================================================
// STRUCT TAG VALIDATOR TESTS
// ==============================================================================
func TestValidatorV2_ValidForm(t *testing.T) {
req := &ContactFormRequest{
Name: "John Smith",
Email: "john@example.com",
Company: "Acme Corp",
Subject: "Inquiry",
Message: "Hello, I have a question.",
Honeypot: "",
Timestamp: time.Now().Unix() - 10, // 10 seconds ago
}
err := ValidateContactFormV2(req)
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
// Verify transformations were applied
if req.Name != "John Smith" {
t.Errorf("Name should be trimmed")
}
}
func TestValidatorV2_RequiredFields(t *testing.T) {
tests := []struct {
name string
req *ContactFormRequest
wantField string
}{
{
name: "Missing name",
req: &ContactFormRequest{
Name: "",
Email: "test@example.com",
Subject: "Test",
Message: "Test message",
},
wantField: "name",
},
{
name: "Missing email",
req: &ContactFormRequest{
Name: "John",
Email: "",
Subject: "Test",
Message: "Test message",
},
wantField: "email",
},
{
name: "Missing subject",
req: &ContactFormRequest{
Name: "John",
Email: "test@example.com",
Subject: "",
Message: "Test message",
},
wantField: "subject",
},
{
name: "Missing message",
req: &ContactFormRequest{
Name: "John",
Email: "test@example.com",
Subject: "Test",
Message: "",
},
wantField: "message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateContactFormV2(tt.req)
if err == nil {
t.Errorf("Expected validation error for missing field")
return
}
verrs, ok := err.(ValidationErrors)
if !ok {
t.Errorf("Expected ValidationErrors, got %T", err)
return
}
if !hasFieldError(verrs, tt.wantField, "required") {
t.Errorf("Expected required error for field %s, got: %v", tt.wantField, verrs)
}
})
}
}
func TestValidatorV2_MaxLength(t *testing.T) {
tests := []struct {
name string
req *ContactFormRequest
wantField string
wantTag string
}{
{
name: "Name too long",
req: &ContactFormRequest{
Name: strings.Repeat("a", 101),
Email: "test@example.com",
Subject: "Test",
Message: "Test message",
},
wantField: "name",
wantTag: "max",
},
{
name: "Email too long",
req: &ContactFormRequest{
Name: "John",
Email: strings.Repeat("a", 250) + "@example.com",
Subject: "Test",
Message: "Test message",
},
wantField: "email",
wantTag: "max",
},
{
name: "Subject too long",
req: &ContactFormRequest{
Name: "John",
Email: "test@example.com",
Subject: strings.Repeat("a", 201),
Message: "Test message",
},
wantField: "subject",
wantTag: "max",
},
{
name: "Message too long",
req: &ContactFormRequest{
Name: "John",
Email: "test@example.com",
Subject: "Test",
Message: strings.Repeat("a", 5001),
},
wantField: "message",
wantTag: "max",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateContactFormV2(tt.req)
if err == nil {
t.Errorf("Expected validation error for field too long")
return
}
verrs, ok := err.(ValidationErrors)
if !ok {
t.Errorf("Expected ValidationErrors, got %T", err)
return
}
if !hasFieldError(verrs, tt.wantField, tt.wantTag) {
t.Errorf("Expected %s error for field %s, got: %v", tt.wantTag, tt.wantField, verrs)
}
})
}
}
func TestValidatorV2_EmailValidation(t *testing.T) {
tests := []struct {
name string
email string
valid bool
}{
{"Valid email", "test@example.com", true},
{"Valid with subdomain", "test@mail.example.com", true},
{"Invalid - no @", "testexample.com", false},
{"Invalid - no domain", "test@", false},
{"Invalid - no TLD", "test@example", false},
{"Invalid - spaces", "test @example.com", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &ContactFormRequest{
Name: "John",
Email: tt.email,
Subject: "Test",
Message: "Test message",
}
err := ValidateContactFormV2(req)
if tt.valid {
if err != nil {
verrs, ok := err.(ValidationErrors)
if ok && hasFieldError(verrs, "email", "email") {
t.Errorf("Expected valid email, got error: %v", err)
}
}
} else {
if err == nil {
t.Errorf("Expected validation error for invalid email")
return
}
verrs, ok := err.(ValidationErrors)
if !ok {
t.Errorf("Expected ValidationErrors, got %T", err)
return
}
if !hasFieldError(verrs, "email", "email") {
t.Errorf("Expected email error, got: %v", verrs)
}
}
})
}
}
func TestValidatorV2_PatternValidation(t *testing.T) {
tests := []struct {
name string
field string
value string
wantError bool
}{
// Name patterns
{"Valid name", "name", "John Smith", false},
{"Valid hyphenated name", "name", "Anne-Marie", false},
{"Valid apostrophe name", "name", "O'Connor", false},
{"Invalid name with numbers", "name", "John123", true},
{"Invalid name with symbols", "name", "John@Smith", true},
// Subject patterns
{"Valid subject", "subject", "Hello World", false},
{"Valid subject with punctuation", "subject", "Question #123", false},
{"Invalid subject with HTML", "subject", "<script>alert(1)</script>", true},
// Company patterns
{"Valid company", "company", "Acme Corp", false},
{"Valid company with &", "company", "Smith & Sons", false},
{"Invalid company with symbols", "company", "Company$$$", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &ContactFormRequest{
Name: "John",
Email: "test@example.com",
Subject: "Test",
Message: "Test message",
}
// Set the field being tested
switch tt.field {
case "name":
req.Name = tt.value
case "subject":
req.Subject = tt.value
case "company":
req.Company = tt.value
}
err := ValidateContactFormV2(req)
if tt.wantError {
if err == nil {
t.Errorf("Expected validation error for invalid pattern")
return
}
verrs, ok := err.(ValidationErrors)
if !ok {
t.Errorf("Expected ValidationErrors, got %T", err)
return
}
if !hasFieldError(verrs, tt.field, "pattern") {
t.Errorf("Expected pattern error for field %s, got: %v", tt.field, verrs)
}
} else {
if err != nil {
verrs, ok := err.(ValidationErrors)
if ok && hasFieldError(verrs, tt.field, "pattern") {
t.Errorf("Expected valid pattern, got error: %v", err)
}
}
}
})
}
}
func TestValidatorV2_EmailInjection(t *testing.T) {
injectionTests := []struct {
name string
field string
value string
}{
{"Name injection", "name", "John\nBcc: evil@example.com"},
{"Email injection", "email", "test@example.com\nBcc: evil@example.com"},
{"Subject injection", "subject", "Test\r\nBcc: evil@example.com"},
{"Name with Bcc header", "name", "Bcc: evil@example.com"},
}
for _, tt := range injectionTests {
t.Run(tt.name, func(t *testing.T) {
req := &ContactFormRequest{
Name: "John",
Email: "test@example.com",
Subject: "Test",
Message: "Test message",
}
// Set the field being tested
switch tt.field {
case "name":
req.Name = tt.value
case "email":
req.Email = tt.value
case "subject":
req.Subject = tt.value
}
err := ValidateContactFormV2(req)
if err == nil {
t.Errorf("Expected validation error for injection attempt")
return
}
verrs, ok := err.(ValidationErrors)
if !ok {
t.Errorf("Expected ValidationErrors, got %T", err)
return
}
if !hasFieldError(verrs, tt.field, "no_injection") {
t.Errorf("Expected no_injection error for field %s, got: %v", tt.field, verrs)
}
})
}
}
func TestValidatorV2_Honeypot(t *testing.T) {
tests := []struct {
name string
honeypot string
wantError bool
}{
{"Empty honeypot (human)", "", false},
{"Filled honeypot (bot)", "http://evil.com", true},
{"Any value (bot)", "test", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &ContactFormRequest{
Name: "John",
Email: "test@example.com",
Subject: "Test",
Message: "Test message",
Honeypot: tt.honeypot,
}
err := ValidateContactFormV2(req)
if tt.wantError {
if err == nil {
t.Errorf("Expected validation error for filled honeypot")
return
}
verrs, ok := err.(ValidationErrors)
if !ok {
t.Errorf("Expected ValidationErrors, got %T", err)
return
}
if !hasFieldError(verrs, "website", "honeypot") {
t.Errorf("Expected honeypot error, got: %v", verrs)
}
} else {
if err != nil {
verrs, ok := err.(ValidationErrors)
if ok && hasFieldError(verrs, "website", "honeypot") {
t.Errorf("Expected valid honeypot, got error: %v", err)
}
}
}
})
}
}
func TestValidatorV2_Timing(t *testing.T) {
now := time.Now().Unix()
tests := []struct {
name string
timestamp int64
wantError bool
}{
{"Valid timing (10 seconds ago)", now - 10, false},
{"Valid timing (1 hour ago)", now - 3600, false},
{"Too fast (instant)", now, true},
{"Too fast (1 second)", now - 1, true},
{"Too old (25 hours)", now - 90000, true},
{"Future timestamp", now + 100, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &ContactFormRequest{
Name: "John",
Email: "test@example.com",
Subject: "Test",
Message: "Test message",
Timestamp: tt.timestamp,
}
err := ValidateContactFormV2(req)
if tt.wantError {
if err == nil {
t.Errorf("Expected validation error for timing")
return
}
verrs, ok := err.(ValidationErrors)
if !ok {
t.Errorf("Expected ValidationErrors, got %T", err)
return
}
if !hasFieldError(verrs, "timestamp", "timing") {
t.Errorf("Expected timing error, got: %v", verrs)
}
} else {
if err != nil {
verrs, ok := err.(ValidationErrors)
if ok && hasFieldError(verrs, "timestamp", "timing") {
t.Errorf("Expected valid timing, got error: %v", err)
}
}
}
})
}
}
func TestValidatorV2_TrimTransform(t *testing.T) {
req := &ContactFormRequest{
Name: " John Smith ",
Email: " test@example.com ",
Company: " Acme Corp ",
Subject: " Test Subject ",
Message: " Test message ",
Timestamp: time.Now().Unix() - 10,
}
err := ValidateContactFormV2(req)
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
// Verify all fields were trimmed
if req.Name != "John Smith" {
t.Errorf("Name not trimmed: %q", req.Name)
}
if req.Email != "test@example.com" {
t.Errorf("Email not trimmed: %q", req.Email)
}
if req.Company != "Acme Corp" {
t.Errorf("Company not trimmed: %q", req.Company)
}
if req.Subject != "Test Subject" {
t.Errorf("Subject not trimmed: %q", req.Subject)
}
if req.Message != "Test message" {
t.Errorf("Message not trimmed: %q", req.Message)
}
}
func TestValidatorV2_SanitizeTransform(t *testing.T) {
req := &ContactFormRequest{
Name: "John",
Email: "test@example.com",
Subject: "Test",
Message: "Test\nmessage\rwith\r\nnewlines",
Timestamp: time.Now().Unix() - 10,
}
err := ValidateContactFormV2(req)
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
// Verify message was sanitized (newlines removed)
if strings.Contains(req.Message, "\n") || strings.Contains(req.Message, "\r") {
t.Errorf("Message not sanitized (still contains newlines): %q", req.Message)
}
}
func TestValidatorV2_OptionalField(t *testing.T) {
// Company is optional - should pass with empty value
req := &ContactFormRequest{
Name: "John",
Email: "test@example.com",
Subject: "Test",
Message: "Test message",
Company: "", // Optional field
Timestamp: time.Now().Unix() - 10,
}
err := ValidateContactFormV2(req)
if err != nil {
t.Errorf("Expected no error for optional field, got: %v", err)
}
// Company should still validate pattern if provided
req.Company = "Acme$$$" // Invalid characters
err = ValidateContactFormV2(req)
if err == nil {
t.Errorf("Expected validation error for invalid optional field")
}
}
func TestValidatorV2_MultipleErrors(t *testing.T) {
req := &ContactFormRequest{
Name: strings.Repeat("a", 101), // Too long
Email: "invalid-email", // Invalid format
Subject: "", // Required
Message: "<script>test</script>", // Valid (will be sanitized)
}
err := ValidateContactFormV2(req)
if err == nil {
t.Errorf("Expected validation errors")
return
}
verrs, ok := err.(ValidationErrors)
if !ok {
t.Errorf("Expected ValidationErrors, got %T", err)
return
}
// Should have at least 3 errors (name max, email format, subject required)
if len(verrs) < 3 {
t.Errorf("Expected at least 3 errors, got %d: %v", len(verrs), verrs)
}
}
// ==============================================================================
// VALIDATION ERRORS TESTS
// ==============================================================================
func TestValidationErrors_Error(t *testing.T) {
errors := ValidationErrors{
{Field: "name", Tag: "required", Message: "Name is required"},
{Field: "email", Tag: "email", Message: "Invalid email"},
}
errMsg := errors.Error()
if !strings.Contains(errMsg, "name") || !strings.Contains(errMsg, "email") {
t.Errorf("Error message should contain all field names: %s", errMsg)
}
}
func TestValidationErrors_GetFieldError(t *testing.T) {
errors := ValidationErrors{
{Field: "name", Tag: "required", Message: "Name is required"},
{Field: "email", Tag: "email", Message: "Invalid email"},
}
err := errors.GetFieldError("name")
if err == nil {
t.Errorf("Expected to find name error")
}
if err != nil && err.Tag != "required" {
t.Errorf("Expected required tag, got %s", err.Tag)
}
noErr := errors.GetFieldError("nonexistent")
if noErr != nil {
t.Errorf("Expected nil for nonexistent field")
}
}
// ==============================================================================
// BENCHMARK TESTS
// ==============================================================================
func BenchmarkValidatorV2_FirstValidation(b *testing.B) {
req := &ContactFormRequest{
Name: "John Smith",
Email: "john@example.com",
Company: "Acme Corp",
Subject: "Inquiry",
Message: "Hello, I have a question.",
Timestamp: time.Now().Unix() - 10,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Create new validator each time (no cache benefit)
v := NewValidator()
_ = v.Validate(req)
}
}
func BenchmarkValidatorV2_CachedValidation(b *testing.B) {
req := &ContactFormRequest{
Name: "John Smith",
Email: "john@example.com",
Company: "Acme Corp",
Subject: "Inquiry",
Message: "Hello, I have a question.",
Timestamp: time.Now().Unix() - 10,
}
// First validation to populate cache
v := NewValidator()
_ = v.Validate(req)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = v.Validate(req)
}
}
func BenchmarkValidatorV2_GlobalValidator(b *testing.B) {
req := &ContactFormRequest{
Name: "John Smith",
Email: "john@example.com",
Company: "Acme Corp",
Subject: "Inquiry",
Message: "Hello, I have a question.",
Timestamp: time.Now().Unix() - 10,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = ValidateContactFormV2(req)
}
}
// ==============================================================================
// HELPER FUNCTIONS
// ==============================================================================
// hasFieldError checks if ValidationErrors contains a specific field and tag error
func hasFieldError(errors ValidationErrors, field string, tag string) bool {
for _, err := range errors {
if err.Field == field && err.Tag == tag {
return true
}
}
return false
}