Add rate limiting for password reset and improve SMTP tests
- Add RateLimitByEmail middleware (3 attempts/hour per email) - Apply rate limiting to /api/v1/auth/forgot-password endpoint - Expand SMTP tests with config validation, message types, and error detection - Improve test coverage from 44.6% to 53.3%
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package email
|
package email
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"apocapoc-api/internal/domain/services"
|
"apocapoc-api/internal/domain/services"
|
||||||
@@ -25,9 +26,68 @@ func TestNewSMTPService(t *testing.T) {
|
|||||||
if service.GetConfig().Host != config.Host {
|
if service.GetConfig().Host != config.Host {
|
||||||
t.Errorf("Expected host %s, got %s", config.Host, service.GetConfig().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{
|
config := SMTPConfig{
|
||||||
Host: "smtp.example.com",
|
Host: "smtp.example.com",
|
||||||
Port: 587,
|
Port: 587,
|
||||||
@@ -39,26 +99,201 @@ func TestSMTPService_MessageConstruction(t *testing.T) {
|
|||||||
|
|
||||||
service := NewSMTPService(config)
|
service := NewSMTPService(config)
|
||||||
|
|
||||||
message := services.EmailMessage{
|
tests := []struct {
|
||||||
To: "recipient@example.com",
|
name string
|
||||||
Subject: "Test Email",
|
message services.EmailMessage
|
||||||
Body: "<h1>Test</h1>",
|
}{
|
||||||
IsHTML: true,
|
{
|
||||||
|
name: "HTML message",
|
||||||
|
message: services.EmailMessage{
|
||||||
|
To: "recipient@example.com",
|
||||||
|
Subject: "Test Email",
|
||||||
|
Body: "<h1>Test</h1>",
|
||||||
|
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: "<p>Special chars: ñ, á, ü, €</p>",
|
||||||
|
IsHTML: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if message.To == "" {
|
for _, tt := range tests {
|
||||||
t.Error("Expected recipient to be set")
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
}
|
if tt.message.To == "" {
|
||||||
|
t.Error("Expected recipient to be set")
|
||||||
|
}
|
||||||
|
|
||||||
if message.Subject == "" {
|
if tt.message.Subject == "" {
|
||||||
t.Error("Expected subject to be set")
|
t.Error("Expected subject to be set")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !message.IsHTML {
|
if tt.message.Body == "" {
|
||||||
t.Error("Expected message to be HTML")
|
t.Error("Expected body to be set")
|
||||||
}
|
}
|
||||||
|
|
||||||
if service == nil {
|
if service == nil {
|
||||||
t.Fatal("Service should not be 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"apocapoc-api/internal/infrastructure/auth"
|
"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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHa
|
|||||||
r.Post("/logout", authHandlers.Logout)
|
r.Post("/logout", authHandlers.Logout)
|
||||||
r.Post("/verify-email", authHandlers.VerifyEmail)
|
r.Post("/verify-email", authHandlers.VerifyEmail)
|
||||||
r.Post("/resend-verification", authHandlers.ResendVerification)
|
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)
|
r.Post("/reset-password", authHandlers.ResetPassword)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user