Files
juanatsap f91a24ea9b feat: Add plain text CV endpoint and contact form with security
Plain text endpoint:
- Add /text route for plain text CV (for curl/AI crawlers)
- Use k3a/html2text library for HTML-to-text conversion
- Add Plain Text button to hamburger menu with UI translations

Contact form feature:
- Add ContactHandler with proper email service integration
- Add CSRF protection middleware
- Add rate limiting (5 submissions/hour per IP)
- Add honeypot and timing-based bot protection
- Add input validation with detailed error messages
- Add security logging middleware
- Add browser-only middleware for API protection

Code quality:
- Fix all golangci-lint errcheck warnings for w.Write calls
- Remove duplicate getClientIP functions
- Wire up ContactHandler in routes.Setup
2025-11-30 13:47:49 +00:00

525 lines
14 KiB
Go

package validation
import (
"strings"
"testing"
"time"
)
// ==============================================================================
// EMAIL VALIDATION TESTS
// ==============================================================================
func TestIsValidEmail(t *testing.T) {
tests := []struct {
name string
email string
want bool
}{
// Valid emails
{"Valid standard", "test@example.com", true},
{"Valid with subdomain", "user@mail.example.com", true},
{"Valid with plus", "user+tag@example.com", true},
{"Valid with dot", "first.last@example.com", true},
{"Valid with hyphen", "user-name@example.com", true},
{"Valid with numbers", "user123@example.com", true},
// Invalid emails
{"Empty", "", false},
{"No @", "userexample.com", false},
{"Multiple @", "user@@example.com", false},
{"No domain", "user@", false},
{"No local part", "@example.com", false},
{"Spaces", "user @example.com", false},
{"Missing TLD", "user@example", false},
{"Too long", strings.Repeat("a", 250) + "@example.com", false},
{"Special chars", "user<>@example.com", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsValidEmail(tt.email); got != tt.want {
t.Errorf("IsValidEmail(%q) = %v, want %v", tt.email, got, tt.want)
}
})
}
}
// ==============================================================================
// EMAIL INJECTION TESTS
// ==============================================================================
func TestContainsEmailInjection(t *testing.T) {
tests := []struct {
name string
input string
want bool // true if injection detected
}{
// Safe inputs
{"Normal text", "Hello World", false},
{"Name with apostrophe", "O'Connor", false},
{"Hyphenated name", "Anne-Marie", false},
// Injection attempts
{"CRLF injection", "Name\r\nBcc: attacker@evil.com", true},
{"LF injection", "Name\nBcc: attacker@evil.com", true},
{"Content-Type header", "Name\r\nContent-Type: text/html", true},
{"BCC header", "bcc: attacker@evil.com", true},
{"CC header", "cc: attacker@evil.com", true},
{"To header", "to: victim@example.com", true},
{"From header", "from: fake@example.com", true},
{"Reply-To header", "reply-to: attacker@evil.com", true},
{"MIME-Version", "MIME-Version: 1.0", true},
{"X-Mailer", "X-Mailer: Evil", true},
{"Case insensitive", "BCC: attacker@evil.com", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ContainsEmailInjection(tt.input); got != tt.want {
t.Errorf("ContainsEmailInjection(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
// ==============================================================================
// NAME VALIDATION TESTS
// ==============================================================================
func TestIsValidName(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
// Valid names
{"Simple name", "John", true},
{"Full name", "John Smith", true},
{"Hyphenated", "Anne-Marie", true},
{"Apostrophe", "O'Connor", true},
{"Multiple words", "Juan José García", true},
{"Spanish characters", "José María", true},
{"French characters", "François Dubois", true},
{"German characters", "Müller", true},
// Invalid names
{"Empty", "", false},
{"Numbers", "John123", false},
{"Special chars", "John@Smith", false},
{"HTML tags", "<script>alert(1)</script>", false},
{"Email", "john@example.com", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsValidName(tt.input); got != tt.want {
t.Errorf("IsValidName(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
// ==============================================================================
// SUBJECT VALIDATION TESTS
// ==============================================================================
func TestIsValidSubject(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
// Valid subjects
{"Simple", "Hello", true},
{"With spaces", "Hello World", true},
{"With punctuation", "Question about your services!", true},
{"With numbers", "Order #12345", true},
{"Complex", "Re: Your inquiry (urgent)", true},
// Invalid subjects
{"Empty", "", false},
{"HTML tags", "<script>alert(1)</script>", false},
{"Email injection", "Subject\nBcc: evil@example.com", false},
{"Special chars", "Subject $ % ^ &", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsValidSubject(tt.input); got != tt.want {
t.Errorf("IsValidSubject(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
// ==============================================================================
// COMPANY VALIDATION TESTS
// ==============================================================================
func TestIsValidCompany(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
// Valid company names
{"Empty (optional)", "", true},
{"Simple", "Acme Corp", true},
{"With punctuation", "Smith & Sons, Inc.", true},
{"With hyphen", "Coca-Cola", true},
{"With parentheses", "Example (Spain)", true},
// Invalid company names
{"HTML tags", "<script>", false},
{"Email injection", "Company\nBcc: evil@example.com", false},
{"Special chars", "Company$$$", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsValidCompany(tt.input); got != tt.want {
t.Errorf("IsValidCompany(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
// ==============================================================================
// SANITIZATION TESTS
// ==============================================================================
func TestSanitizeContactForm(t *testing.T) {
req := &ContactFormRequest{
Name: " John \n Smith ",
Email: " john@example.com \r\n",
Company: " Acme Corp ",
Subject: " Test Subject ",
Message: "<script>alert('XSS')</script>\n\n\nHello World\n\n\n\n",
}
SanitizeContactForm(req)
// Check whitespace trimmed
if req.Name != "John Smith" {
t.Errorf("Name not properly sanitized: %q", req.Name)
}
// Check newlines removed from headers
if strings.Contains(req.Email, "\n") || strings.Contains(req.Email, "\r") {
t.Errorf("Email still contains newlines: %q", req.Email)
}
// Check HTML escaped
if !strings.Contains(req.Message, "&lt;script&gt;") {
t.Errorf("Message not properly HTML escaped: %q", req.Message)
}
// Check whitespace normalized
if strings.Contains(req.Message, " ") {
t.Errorf("Message whitespace not normalized: %q", req.Message)
}
}
// ==============================================================================
// FULL VALIDATION TESTS
// ==============================================================================
func TestValidateContactForm(t *testing.T) {
tests := []struct {
name string
req *ContactFormRequest
wantError bool
errorField string
}{
{
name: "Valid form",
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
},
wantError: false,
},
{
name: "Bot detected - honeypot filled",
req: &ContactFormRequest{
Name: "Bot",
Email: "bot@example.com",
Subject: "Spam",
Message: "Spam message",
Honeypot: "http://evil.com", // Bot filled this
},
wantError: true,
errorField: "website",
},
{
name: "Bot detected - submitted too fast",
req: &ContactFormRequest{
Name: "John",
Email: "john@example.com",
Subject: "Test",
Message: "Test",
Timestamp: time.Now().Unix(), // Just now (< 2 seconds)
},
wantError: true,
errorField: "timestamp",
},
{
name: "Missing required field - name",
req: &ContactFormRequest{
Name: "",
Email: "john@example.com",
Subject: "Test",
Message: "Test",
},
wantError: true,
errorField: "name",
},
{
name: "Invalid email format",
req: &ContactFormRequest{
Name: "John",
Email: "invalid-email",
Subject: "Test",
Message: "Test",
},
wantError: true,
errorField: "email",
},
{
name: "Email injection attempt",
req: &ContactFormRequest{
Name: "Evil\nBcc: attacker@evil.com",
Email: "evil@example.com",
Subject: "Test",
Message: "Test",
},
wantError: true,
errorField: "name",
},
{
name: "Name too long",
req: &ContactFormRequest{
Name: strings.Repeat("a", 101),
Email: "john@example.com",
Subject: "Test",
Message: "Test",
},
wantError: true,
errorField: "name",
},
{
name: "Subject too long",
req: &ContactFormRequest{
Name: "John",
Email: "john@example.com",
Subject: strings.Repeat("a", 201),
Message: "Test",
},
wantError: true,
errorField: "subject",
},
{
name: "Message too long",
req: &ContactFormRequest{
Name: "John",
Email: "john@example.com",
Subject: "Test",
Message: strings.Repeat("a", 5001),
},
wantError: true,
errorField: "message",
},
{
name: "Invalid name characters",
req: &ContactFormRequest{
Name: "John123",
Email: "john@example.com",
Subject: "Test",
Message: "Test",
},
wantError: true,
errorField: "name",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateContactForm(tt.req)
if tt.wantError {
if err == nil {
t.Errorf("Expected error but got none")
return
}
// Check error is ValidationError
valErr, ok := err.(*ValidationError)
if !ok {
t.Errorf("Expected ValidationError, got %T", err)
return
}
// Check correct field
if valErr.Field != tt.errorField {
t.Errorf("Expected error field %q, got %q", tt.errorField, valErr.Field)
}
} else {
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
}
})
}
}
// ==============================================================================
// SECURITY TESTS (Attack Simulations)
// ==============================================================================
func TestSecurityAttacks(t *testing.T) {
attacks := []struct {
name string
field string
value string
reason string
}{
{
name: "SQL Injection in name",
field: "name",
value: "Robert'; DROP TABLE users; --",
reason: "Should reject SQL injection attempts",
},
// NOTE: XSS in message is allowed during validation - it's escaped during sanitization
// This is intentional - we don't reject valid messages that happen to contain < or >
// The sanitization step handles HTML escaping before sending email
{
name: "Email header injection",
field: "email",
value: "test@example.com\nBcc: attacker@evil.com",
reason: "Should block email header injection",
},
{
name: "Command injection",
field: "name",
value: "Test; rm -rf /",
reason: "Should block command injection attempts",
},
{
name: "Path traversal",
field: "subject",
value: "../../../etc/passwd",
reason: "Should reject path traversal attempts",
},
}
for _, attack := range attacks {
t.Run(attack.name, func(t *testing.T) {
req := &ContactFormRequest{
Name: "John Smith",
Email: "john@example.com",
Subject: "Test",
Message: "Test",
Timestamp: time.Now().Unix() - 10,
}
// Inject attack into specified field
switch attack.field {
case "name":
req.Name = attack.value
case "email":
req.Email = attack.value
case "subject":
req.Subject = attack.value
case "message":
req.Message = attack.value
}
err := ValidateContactForm(req)
if err == nil {
t.Errorf("%s: Expected validation to fail, but it passed", attack.reason)
}
})
}
}
// ==============================================================================
// NORMALIZATION TESTS
// ==============================================================================
func TestRemoveNewlines(t *testing.T) {
tests := []struct {
input string
want string
}{
{"No newlines", "No newlines"},
{"With\nnewline", "Withnewline"},
{"With\r\nCRLF", "WithCRLF"},
{"Multiple\n\n\nnewlines", "Multiplenewlines"},
}
for _, tt := range tests {
if got := removeNewlines(tt.input); got != tt.want {
t.Errorf("removeNewlines(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestNormalizeWhitespace(t *testing.T) {
tests := []struct {
input string
want string
}{
{"Normal text", "Normal text"},
{"Multiple spaces", "Multiple spaces"},
{"Multiple\n\n\n\nnewlines", "Multiple\n\nnewlines"},
{" Leading and trailing ", "Leading and trailing"},
}
for _, tt := range tests {
if got := normalizeWhitespace(tt.input); got != tt.want {
t.Errorf("normalizeWhitespace(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
// ==============================================================================
// BENCHMARK TESTS
// ==============================================================================
func BenchmarkIsValidEmail(b *testing.B) {
email := "test@example.com"
for i := 0; i < b.N; i++ {
IsValidEmail(email)
}
}
func BenchmarkContainsEmailInjection(b *testing.B) {
text := "Normal text without injection"
for i := 0; i < b.N; i++ {
ContainsEmailInjection(text)
}
}
func BenchmarkValidateContactForm(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++ {
_ = ValidateContactForm(req)
}
}