diff --git a/internal/infrastructure/email/smtp_service_test.go b/internal/infrastructure/email/smtp_service_test.go index 5ef47cc..5deca4c 100644 --- a/internal/infrastructure/email/smtp_service_test.go +++ b/internal/infrastructure/email/smtp_service_test.go @@ -1,6 +1,7 @@ package email import ( + "strings" "testing" "apocapoc-api/internal/domain/services" @@ -25,9 +26,68 @@ func TestNewSMTPService(t *testing.T) { if service.GetConfig().Host != config.Host { t.Errorf("Expected host %s, got %s", config.Host, service.GetConfig().Host) } + + if service.GetConfig().Port != config.Port { + t.Errorf("Expected port %d, got %d", config.Port, service.GetConfig().Port) + } + + if service.GetConfig().From != config.From { + t.Errorf("Expected from %s, got %s", config.From, service.GetConfig().From) + } } -func TestSMTPService_MessageConstruction(t *testing.T) { +func TestSMTPService_ConfigValidation(t *testing.T) { + tests := []struct { + name string + config SMTPConfig + }{ + { + name: "Port 587 (STARTTLS)", + config: SMTPConfig{ + Host: "smtp.example.com", + Port: 587, + Username: "user@example.com", + Password: "password", + From: "noreply@example.com", + }, + }, + { + name: "Port 465 (SSL)", + config: SMTPConfig{ + Host: "smtp.example.com", + Port: 465, + Username: "user@example.com", + Password: "password", + From: "noreply@example.com", + }, + }, + { + name: "Port 25 (Plain)", + config: SMTPConfig{ + Host: "smtp.example.com", + Port: 25, + Username: "user@example.com", + Password: "password", + From: "noreply@example.com", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := NewSMTPService(tt.config) + if service == nil { + t.Fatal("Expected service to be created") + } + + if service.GetConfig().Port != tt.config.Port { + t.Errorf("Expected port %d, got %d", tt.config.Port, service.GetConfig().Port) + } + }) + } +} + +func TestSMTPService_MessageTypes(t *testing.T) { config := SMTPConfig{ Host: "smtp.example.com", Port: 587, @@ -39,26 +99,201 @@ func TestSMTPService_MessageConstruction(t *testing.T) { service := NewSMTPService(config) - message := services.EmailMessage{ - To: "recipient@example.com", - Subject: "Test Email", - Body: "

Test

", - IsHTML: true, + tests := []struct { + name string + message services.EmailMessage + }{ + { + name: "HTML message", + message: services.EmailMessage{ + To: "recipient@example.com", + Subject: "Test Email", + Body: "

Test

", + IsHTML: true, + }, + }, + { + name: "Plain text message", + message: services.EmailMessage{ + To: "recipient@example.com", + Subject: "Test Email", + Body: "Plain text body", + IsHTML: false, + }, + }, + { + name: "Message with special characters", + message: services.EmailMessage{ + To: "recipient@example.com", + Subject: "Test Email with émojis 🎉", + Body: "

Special chars: ñ, á, ü, €

", + IsHTML: true, + }, + }, } - if message.To == "" { - t.Error("Expected recipient to be set") - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.message.To == "" { + t.Error("Expected recipient to be set") + } - if message.Subject == "" { - t.Error("Expected subject to be set") - } + if tt.message.Subject == "" { + t.Error("Expected subject to be set") + } - if !message.IsHTML { - t.Error("Expected message to be HTML") - } + if tt.message.Body == "" { + t.Error("Expected body to be set") + } - if service == nil { - t.Fatal("Service should not be nil") + if service == nil { + t.Fatal("Service should not be nil") + } + }) } } + +func TestIsAuthError(t *testing.T) { + tests := []struct { + name string + errStr string + expected bool + }{ + { + name: "Authentication failed error", + errStr: "535 Authentication failed", + expected: true, + }, + { + name: "Invalid credentials error", + errStr: "Invalid credentials provided", + expected: true, + }, + { + name: "535 error code", + errStr: "535 5.7.8 Error", + expected: true, + }, + { + name: "Connection refused (not auth)", + errStr: "connection refused", + expected: false, + }, + { + name: "Generic error (not auth)", + errStr: "some other error", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := &mockError{msg: tt.errStr} + result := isAuthError(err) + if result != tt.expected { + t.Errorf("Expected %v, got %v for error: %s", tt.expected, result, tt.errStr) + } + }) + } +} + +func TestIsConfigError(t *testing.T) { + tests := []struct { + name string + errStr string + expected bool + }{ + { + name: "Connection refused", + errStr: "connection refused", + expected: true, + }, + { + name: "No such host", + errStr: "no such host smtp.invalid.com", + expected: true, + }, + { + name: "Network unreachable", + errStr: "network is unreachable", + expected: true, + }, + { + name: "Authentication error (not config)", + errStr: "authentication failed", + expected: false, + }, + { + name: "Generic error (not config)", + errStr: "some other error", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := &mockError{msg: tt.errStr} + result := isConfigError(err) + if result != tt.expected { + t.Errorf("Expected %v, got %v for error: %s", tt.expected, result, tt.errStr) + } + }) + } +} + +func TestSMTPService_Send_InvalidConfig(t *testing.T) { + config := SMTPConfig{ + Host: "invalid.smtp.server.that.does.not.exist", + Port: 587, + Username: "user@example.com", + Password: "password", + From: "noreply@example.com", + } + + service := NewSMTPService(config) + + message := services.EmailMessage{ + To: "test@example.com", + Subject: "Test", + Body: "Test body", + IsHTML: false, + } + + err := service.Send(message) + if err == nil { + t.Error("Expected error when sending to invalid SMTP server") + } + + if !strings.Contains(err.Error(), "failed to send email") { + t.Errorf("Expected error message to contain 'failed to send email', got: %s", err.Error()) + } +} + +func TestSMTPService_HealthCheck_InvalidConfig(t *testing.T) { + config := SMTPConfig{ + Host: "invalid.smtp.server.that.does.not.exist", + Port: 587, + Username: "user@example.com", + Password: "password", + From: "noreply@example.com", + } + + service := NewSMTPService(config) + + err := service.HealthCheck() + if err == nil { + t.Error("Expected error when health checking invalid SMTP server") + } + + if !strings.Contains(err.Error(), "SMTP") { + t.Errorf("Expected error message to contain 'SMTP', got: %s", err.Error()) + } +} + +type mockError struct { + msg string +} + +func (e *mockError) Error() string { + return e.msg +} diff --git a/internal/infrastructure/http/rate_limit_middleware.go b/internal/infrastructure/http/rate_limit_middleware.go index 2b4ebd1..3359823 100644 --- a/internal/infrastructure/http/rate_limit_middleware.go +++ b/internal/infrastructure/http/rate_limit_middleware.go @@ -1,8 +1,12 @@ package http import ( + "bytes" + "encoding/json" + "io" "net/http" "strconv" + "strings" "time" "apocapoc-api/internal/infrastructure/auth" @@ -36,3 +40,39 @@ func RateLimitByUser(jwtService *auth.JWTService, requestsPerMinute int, duratio }) } } + +func RateLimitByEmail(requests int, duration time.Duration) func(http.Handler) http.Handler { + limiter := httprate.NewRateLimiter( + requests, + duration, + httprate.WithKeyFuncs(func(r *http.Request) (string, error) { + body, err := io.ReadAll(r.Body) + if err != nil { + return r.RemoteAddr, nil + } + r.Body = io.NopCloser(bytes.NewBuffer(body)) + + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + return r.RemoteAddr, nil + } + + if email, ok := data["email"].(string); ok && email != "" { + return "email:" + strings.ToLower(email), nil + } + + return r.RemoteAddr, nil + }), + httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + w.Write([]byte(`{"error":"Too many password reset attempts. Please try again later."}`)) + }), + ) + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + limiter.Handler(next).ServeHTTP(w, r) + }) + } +} diff --git a/internal/infrastructure/http/router.go b/internal/infrastructure/http/router.go index 2c1e68b..19fbf1b 100644 --- a/internal/infrastructure/http/router.go +++ b/internal/infrastructure/http/router.go @@ -46,7 +46,9 @@ func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHa r.Post("/logout", authHandlers.Logout) r.Post("/verify-email", authHandlers.VerifyEmail) r.Post("/resend-verification", authHandlers.ResendVerification) - r.Post("/forgot-password", authHandlers.ForgotPassword) + + r.With(RateLimitByEmail(3, 1*time.Hour)).Post("/forgot-password", authHandlers.ForgotPassword) + r.Post("/reset-password", authHandlers.ResetPassword) })