Implement Sprint 2 security enhancements
CI/CD Pipeline / Test (push) Has been cancelled
CI/CD Pipeline / Lint (push) Has been cancelled
CI/CD Pipeline / Build and Push Docker Image (push) Has been cancelled

Add user-based rate limiting middleware (100 req/min) for authenticated endpoints using httprate library. Implement common password validation blocking 50+ weak passwords. Improve test coverage from 38.3% to 44.6% with comprehensive refresh token tests.

Security improvements:
- Rate limiting by user ID for /habits and /stats endpoints
- X-RateLimit-Limit header in responses
- Common password blacklist in password validation
- Refresh token test suite with 5 scenarios (valid, invalid, expired, revoked, empty)
This commit is contained in:
2025-11-27 10:33:01 +01:00
parent bbe0757ab6
commit 7cb2756b67
17 changed files with 350 additions and 71 deletions
@@ -278,4 +278,3 @@ func TestGetHabitEntriesHandler_RequiresPaginationWithLongDateRange(t *testing.T
t.Errorf("Expected ErrInvalidInput for date range > 1 year without pagination, got %v", err)
}
}
@@ -10,14 +10,14 @@ import (
)
type HabitStatsDTO struct {
HabitID string `json:"habit_id"`
HabitName string `json:"habit_name"`
TotalCompletions int `json:"total_completions"`
CurrentStreak int `json:"current_streak"`
LongestStreak int `json:"longest_streak"`
CompletionRate float64 `json:"completion_rate"`
CompletionsThisWeek int `json:"completions_this_week"`
CompletionsThisMonth int `json:"completions_this_month"`
HabitID string `json:"habit_id"`
HabitName string `json:"habit_name"`
TotalCompletions int `json:"total_completions"`
CurrentStreak int `json:"current_streak"`
LongestStreak int `json:"longest_streak"`
CompletionRate float64 `json:"completion_rate"`
CompletionsThisWeek int `json:"completions_this_week"`
CompletionsThisMonth int `json:"completions_this_month"`
}
type GetHabitStatsQuery struct {
@@ -0,0 +1,176 @@
package queries
import (
"context"
"testing"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/shared/errors"
)
type mockRefreshTokenRepository struct {
findByTokenFunc func(ctx context.Context, token string) (*entities.RefreshToken, error)
}
func (m *mockRefreshTokenRepository) Create(ctx context.Context, token *entities.RefreshToken) error {
return nil
}
func (m *mockRefreshTokenRepository) FindByToken(ctx context.Context, token string) (*entities.RefreshToken, error) {
if m.findByTokenFunc != nil {
return m.findByTokenFunc(ctx, token)
}
return nil, errors.ErrNotFound
}
func (m *mockRefreshTokenRepository) FindByUserID(ctx context.Context, userID string) ([]*entities.RefreshToken, error) {
return nil, nil
}
func (m *mockRefreshTokenRepository) RevokeByToken(ctx context.Context, token string) error {
return nil
}
func (m *mockRefreshTokenRepository) RevokeAllByUserID(ctx context.Context, userID string) error {
return nil
}
func (m *mockRefreshTokenRepository) DeleteExpired(ctx context.Context) error {
return nil
}
type mockUserRepositoryForRefresh struct {
findByIDFunc func(ctx context.Context, id string) (*entities.User, error)
}
func (m *mockUserRepositoryForRefresh) Create(ctx context.Context, user *entities.User) error {
return nil
}
func (m *mockUserRepositoryForRefresh) FindByID(ctx context.Context, id string) (*entities.User, error) {
if m.findByIDFunc != nil {
return m.findByIDFunc(ctx, id)
}
return nil, errors.ErrNotFound
}
func (m *mockUserRepositoryForRefresh) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
return nil, nil
}
func (m *mockUserRepositoryForRefresh) Update(ctx context.Context, user *entities.User) error {
return nil
}
func TestRefreshTokenHandler_Success(t *testing.T) {
refreshTokenRepo := &mockRefreshTokenRepository{
findByTokenFunc: func(ctx context.Context, token string) (*entities.RefreshToken, error) {
return entities.NewRefreshToken("user-123", token, time.Now().Add(24*time.Hour)), nil
},
}
userRepo := &mockUserRepositoryForRefresh{
findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) {
user := entities.NewUser("test@example.com", "hash", "UTC")
user.ID = id
return user, nil
},
}
handler := NewRefreshTokenHandler(refreshTokenRepo, userRepo)
query := RefreshTokenQuery{
RefreshToken: "valid-token",
}
result, err := handler.Handle(context.Background(), query)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if result.UserID != "user-123" {
t.Errorf("Expected userID 'user-123', got %s", result.UserID)
}
if result.Email != "test@example.com" {
t.Errorf("Expected email 'test@example.com', got %s", result.Email)
}
}
func TestRefreshTokenHandler_InvalidToken(t *testing.T) {
refreshTokenRepo := &mockRefreshTokenRepository{}
userRepo := &mockUserRepositoryForRefresh{}
handler := NewRefreshTokenHandler(refreshTokenRepo, userRepo)
query := RefreshTokenQuery{
RefreshToken: "invalid-token",
}
_, err := handler.Handle(context.Background(), query)
if err != errors.ErrNotFound {
t.Errorf("Expected ErrNotFound, got %v", err)
}
}
func TestRefreshTokenHandler_ExpiredToken(t *testing.T) {
refreshTokenRepo := &mockRefreshTokenRepository{
findByTokenFunc: func(ctx context.Context, token string) (*entities.RefreshToken, error) {
return entities.NewRefreshToken("user-123", token, time.Now().Add(-1*time.Hour)), nil
},
}
userRepo := &mockUserRepositoryForRefresh{}
handler := NewRefreshTokenHandler(refreshTokenRepo, userRepo)
query := RefreshTokenQuery{
RefreshToken: "expired-token",
}
_, err := handler.Handle(context.Background(), query)
if err != errors.ErrNotFound {
t.Errorf("Expected ErrNotFound for expired token, got %v", err)
}
}
func TestRefreshTokenHandler_RevokedToken(t *testing.T) {
refreshToken := entities.NewRefreshToken("user-123", "revoked-token", time.Now().Add(24*time.Hour))
refreshToken.Revoke()
refreshTokenRepo := &mockRefreshTokenRepository{
findByTokenFunc: func(ctx context.Context, token string) (*entities.RefreshToken, error) {
return refreshToken, nil
},
}
userRepo := &mockUserRepositoryForRefresh{}
handler := NewRefreshTokenHandler(refreshTokenRepo, userRepo)
query := RefreshTokenQuery{
RefreshToken: "revoked-token",
}
_, err := handler.Handle(context.Background(), query)
if err != errors.ErrNotFound {
t.Errorf("Expected ErrNotFound for revoked token, got %v", err)
}
}
func TestRefreshTokenHandler_EmptyToken(t *testing.T) {
refreshTokenRepo := &mockRefreshTokenRepository{}
userRepo := &mockUserRepositoryForRefresh{}
handler := NewRefreshTokenHandler(refreshTokenRepo, userRepo)
query := RefreshTokenQuery{
RefreshToken: "",
}
_, err := handler.Handle(context.Background(), query)
if err != errors.ErrInvalidInput {
t.Errorf("Expected ErrInvalidInput, got %v", err)
}
}