diff --git a/internal/handlers/contact.go b/internal/handlers/contact.go index 89d2317..23dc4ed 100644 --- a/internal/handlers/contact.go +++ b/internal/handlers/contact.go @@ -11,14 +11,20 @@ import ( "github.com/juanatsap/cv-site/internal/templates" ) +// EmailSender is an interface for sending contact form emails +// This allows for easy mocking in tests +type EmailSender interface { + SendContactForm(data *services.ContactFormData) error +} + // ContactHandler handles contact form submissions type ContactHandler struct { templates *templates.Manager - emailService *services.EmailService + emailService EmailSender } // NewContactHandler creates a new contact handler -func NewContactHandler(tmpl *templates.Manager, emailService *services.EmailService) *ContactHandler { +func NewContactHandler(tmpl *templates.Manager, emailService EmailSender) *ContactHandler { return &ContactHandler{ templates: tmpl, emailService: emailService, @@ -139,23 +145,28 @@ func (h *ContactHandler) renderSuccess(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) + // Fallback HTML for when templates aren't available (e.g., in tests) + fallbackHTML := `
+

Message Sent!

+

Thank you for your message. I'll get back to you soon.

+
` + + // Check if templates are properly initialized + if !h.templates.IsInitialized() { + _, _ = w.Write([]byte(fallbackHTML)) + return + } + tmpl, err := h.templates.Render("contact-success") if err != nil { log.Printf("ERROR loading success template: %v", err) - // Fallback to simple HTML - _, _ = w.Write([]byte(`
-

Message Sent!

-

Thank you for your message. I'll get back to you soon.

-
`)) + _, _ = w.Write([]byte(fallbackHTML)) return } if err := tmpl.Execute(w, nil); err != nil { - log.Printf("ERROR rendering success template: %v", err) - _, _ = w.Write([]byte(`
-

Message Sent!

-

Thank you for your message. I'll get back to you soon.

-
`)) + log.Printf("ERROR rendering error template: %v", err) + _, _ = w.Write([]byte(fallbackHTML)) } } @@ -164,6 +175,15 @@ func (h *ContactHandler) renderError(w http.ResponseWriter, r *http.Request, mes w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusBadRequest) + // Fallback HTML for when templates aren't available (e.g., in tests) + fallbackHTML := `

Error

` + message + `

` + + // Check if templates are properly initialized + if !h.templates.IsInitialized() { + _, _ = w.Write([]byte(fallbackHTML)) + return + } + data := map[string]interface{}{ "Message": message, } @@ -171,20 +191,13 @@ func (h *ContactHandler) renderError(w http.ResponseWriter, r *http.Request, mes tmpl, err := h.templates.Render("contact-error") if err != nil { log.Printf("ERROR loading error template: %v", err) - // Fallback to simple HTML - _, _ = w.Write([]byte(`
-

Error

-

` + message + `

-
`)) + _, _ = w.Write([]byte(fallbackHTML)) return } if err := tmpl.Execute(w, data); err != nil { log.Printf("ERROR rendering error template: %v", err) - _, _ = w.Write([]byte(`
-

Error

-

` + message + `

-
`)) + _, _ = w.Write([]byte(fallbackHTML)) } } diff --git a/internal/templates/template.go b/internal/templates/template.go index e75b6f7..780d6cc 100644 --- a/internal/templates/template.go +++ b/internal/templates/template.go @@ -17,6 +17,12 @@ type Manager struct { mu sync.RWMutex } +// IsInitialized returns true if the template manager has been properly initialized +// with a config. Empty Manager structs (e.g., in tests) will return false. +func (m *Manager) IsInitialized() bool { + return m != nil && m.config != nil +} + // NewManager creates a new template manager func NewManager(cfg *config.TemplateConfig) (*Manager, error) { m := &Manager{ diff --git a/tests/security/contact_security_test.go b/tests/security/contact_security_test.go index ff0f40d..1f32305 100644 --- a/tests/security/contact_security_test.go +++ b/tests/security/contact_security_test.go @@ -15,6 +15,18 @@ import ( "github.com/juanatsap/cv-site/internal/templates" ) +// mockEmailSender is a mock implementation of handlers.EmailSender for testing +type mockEmailSender struct{} + +func (m *mockEmailSender) SendContactForm(data *services.ContactFormData) error { + // Validate like the real service would + if err := data.Validate(); err != nil { + return fmt.Errorf("validation failed: %w", err) + } + // Always succeed - don't actually send + return nil +} + // setupTestServer creates a test server with the contact handler and security middleware func setupTestServer(t *testing.T) http.Handler { t.Helper() @@ -22,18 +34,8 @@ func setupTestServer(t *testing.T) http.Handler { // Create a minimal template manager for testing tmpl := &templates.Manager{} - // Create a mock email service configuration - emailConfig := &services.EmailConfig{ - SMTPHost: "localhost", - SMTPPort: "1025", - SMTPUser: "test", - SMTPPassword: "test", - FromEmail: "test@example.com", - ToEmail: "recipient@example.com", - } - - // Create email service with mock config (won't actually send emails in tests) - emailService := services.NewEmailService(emailConfig) + // Create mock email service that doesn't actually send + emailService := &mockEmailSender{} // Create the contact handler contactHandler := handlers.NewContactHandler(tmpl, emailService) @@ -306,36 +308,43 @@ func TestInputValidation_EmailFormat(t *testing.T) { tests := []struct { name string email string + ip string // Use unique IPs to avoid rate limiting wantCode int }{ { name: "valid email", email: "test@example.com", + ip: "10.1.1.1", wantCode: http.StatusOK, }, { name: "valid email with subdomain", email: "user@mail.example.com", + ip: "10.1.1.2", wantCode: http.StatusOK, }, { name: "invalid - no @", email: "notanemail", + ip: "10.1.1.3", wantCode: http.StatusBadRequest, }, { name: "invalid - no domain", email: "test@", + ip: "10.1.1.4", wantCode: http.StatusBadRequest, }, { name: "invalid - no TLD", email: "test@example", + ip: "10.1.1.5", wantCode: http.StatusBadRequest, }, { name: "empty email", email: "", + ip: "10.1.1.6", wantCode: http.StatusBadRequest, }, } @@ -356,6 +365,7 @@ func TestInputValidation_EmailFormat(t *testing.T) { req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)") req.Header.Set("Referer", "http://localhost:8080/") req.Header.Set("HX-Request", "true") + req.Header.Set("X-Forwarded-For", tt.ip) // Use unique IP to avoid rate limiting rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) @@ -374,31 +384,37 @@ func TestInputValidation_MessageLength(t *testing.T) { tests := []struct { name string message string + ip string // Use unique IPs to avoid rate limiting wantCode int }{ { name: "valid message - minimum length", message: "Short msg!", + ip: "10.2.1.1", wantCode: http.StatusOK, }, { name: "valid message - normal length", message: "This is a normal length message that should pass validation.", + ip: "10.2.1.2", wantCode: http.StatusOK, }, { name: "valid message - maximum length", message: strings.Repeat("a", 5000), + ip: "10.2.1.3", wantCode: http.StatusOK, }, { name: "invalid - too long", message: strings.Repeat("a", 5001), + ip: "10.2.1.4", wantCode: http.StatusBadRequest, }, { name: "invalid - empty", message: "", + ip: "10.2.1.5", wantCode: http.StatusBadRequest, }, } @@ -419,6 +435,7 @@ func TestInputValidation_MessageLength(t *testing.T) { req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)") req.Header.Set("Referer", "http://localhost:8080/") req.Header.Set("HX-Request", "true") + req.Header.Set("X-Forwarded-For", tt.ip) // Use unique IP to avoid rate limiting rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) @@ -438,30 +455,35 @@ func TestInputValidation_RequiredFields(t *testing.T) { name string email string message string + ip string // Use unique IPs to avoid rate limiting wantCode int }{ { name: "all required fields present", email: "test@example.com", message: "Valid message", + ip: "10.3.1.1", wantCode: http.StatusOK, }, { name: "missing email", email: "", message: "Valid message", + ip: "10.3.1.2", wantCode: http.StatusBadRequest, }, { name: "missing message", email: "test@example.com", message: "", + ip: "10.3.1.3", wantCode: http.StatusBadRequest, }, { name: "both missing", email: "", message: "", + ip: "10.3.1.4", wantCode: http.StatusBadRequest, }, } @@ -481,6 +503,7 @@ func TestInputValidation_RequiredFields(t *testing.T) { req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)") req.Header.Set("Referer", "http://localhost:8080/") req.Header.Set("HX-Request", "true") + req.Header.Set("X-Forwarded-For", tt.ip) // Use unique IP to avoid rate limiting rr := httptest.NewRecorder() handler.ServeHTTP(rr, req)