6c7595b041
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
668 lines
16 KiB
Go
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
|
|
}
|