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
This commit is contained in:
@@ -0,0 +1,524 @@
|
||||
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, "<script>") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user