diff --git a/README.md b/README.md index f0c81f2..4aafb6e 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,32 @@ -# Apocapoc +# Apocapoc API - Self-Hosted Habit Tracker -Self-hosted habit tracking service with a clean, hexagonal architecture. +**Lightweight REST API for habit tracking built with Go.** Designed for developers who want full control over their data without relying on third-party services. Deploy in minutes with Docker and start building your own productivity tools. + +[![Docker](https://img.shields.io/docker/v/ghcr.io/davidfolch/apocapoc-api?label=docker&logo=docker)](https://github.com/davidfolch/apocapoc-api/pkgs/container/apocapoc-api) +[![Go Version](https://img.shields.io/github/go-mod/go-version/davidfolch/apocapoc-api)](https://golang.org/) +[![License](https://img.shields.io/github/license/davidfolch/apocapoc-api)](LICENSE) + +## Table of Contents + +- [Features](#features) +- [Quick Start](#quick-start) + - [Docker Compose (Recommended)](#using-docker-compose-recommended) + - [Binary Installation](#using-the-binary) +- [Use Cases](#use-cases) +- [API Documentation](#api-documentation) +- [Development](#development) +- [Architecture](#architecture) ## Features -- **Multiple habit types**: Boolean (check), Counter, Value -- **Flexible scheduling**: Daily, Weekly, Monthly with specific days -- **Carry-over support**: Choose if incomplete habits persist or expire -- **Full history tracking**: Complete audit trail of all interactions -- **Statistics**: Track streaks, completion rates, and progress -- **Self-hosted first**: Easy deployment with SQLite -- **Security**: JWT authentication, rate limiting on auth endpoints, optional email verification -- **Email notifications**: Optional welcome emails and verification emails -- **Registration control**: Open or closed registration modes -- **API Documentation**: Interactive Swagger UI +- **Multiple habit types**: Boolean (daily check-ins), Counter (track numbers), Value (measurements) +- **Flexible scheduling**: Daily, Weekly, Monthly with custom day selection +- **Statistics endpoints**: Streaks, completion rates, and progress tracking +- **Complete history**: Full audit trail of all interactions +- **Easy deployment**: Single Docker container or binary with embedded SQLite +- **Security**: JWT authentication, rate limiting, optional email verification +- **Registration modes**: Open or closed for controlled access +- **Interactive docs**: Built-in Swagger UI for testing endpoints ## Quick Start @@ -60,87 +73,81 @@ volumes: docker-compose up -d ``` -The API will be available at `http://localhost:8080` +API available at `http://localhost:8080` -**Available image tags:** -- `latest`: Latest stable release (recommended for production) -- `1`, `1.0`, `1.0.0`: Specific version tags -- `edge`: Latest development build from main branch (unstable) -- `sha-abc123`: Specific commit (for debugging) +**Image tags:** +- `latest`: Stable release (recommended) +- `1`, `1.0`, `1.0.0`: Specific versions +- `edge`: Development build (unstable) -**Configuration options:** +**Configuration:** -**Required:** -- `JWT_SECRET`: **Required**. Use a long random string -- `DB_PATH`: Database file path (default: `./data/apocapoc.db`) +*Required:* +- `JWT_SECRET`: Long random string (required) +- `DB_PATH`: Database path (default: `./data/apocapoc.db`) -**Application:** +*Application:* - `PORT`: HTTP port (default: `8080`) -- `APP_URL`: Public URL for email links (e.g., `https://habits.yourdomain.com`) -- `DEFAULT_TIMEZONE`: Timezone for date calculations (e.g., `UTC`, `Europe/Madrid`) +- `APP_URL`: Public URL for email links +- `DEFAULT_TIMEZONE`: e.g., `UTC`, `Europe/Madrid` -**Authentication:** -- `JWT_EXPIRY`: Token expiration (e.g., `1h`, `24h`) -- `REFRESH_TOKEN_EXPIRY`: Refresh token expiration (e.g., `7d`, `168h`) +*Authentication:* +- `JWT_EXPIRY`: e.g., `1h`, `24h` +- `REFRESH_TOKEN_EXPIRY`: e.g., `7d`, `168h` +- `REGISTRATION_MODE`: `open` or `closed` -**Registration:** -- `REGISTRATION_MODE`: `open` (anyone can register) or `closed` (registration disabled) +*Email (optional):* +- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM` +- `SUPPORT_EMAIL`: Default `contact@apocapoc.app` +- `SEND_WELCOME_EMAIL`: `true`/`false` -**Email (optional - all or none):** -- `SMTP_HOST`: SMTP server hostname -- `SMTP_PORT`: SMTP port (`587` for STARTTLS, `465` for SSL) -- `SMTP_USER`: SMTP username -- `SMTP_PASSWORD`: SMTP password (use `$$` to escape `$` in passwords) -- `SMTP_FROM`: From address for emails -- `SUPPORT_EMAIL`: Support email shown in emails (default: `contact@apocapoc.app`) -- `SEND_WELCOME_EMAIL`: Send welcome email after verification (`true`/`false`) - -**Note:** If SMTP is not configured, email verification is skipped and users are auto-verified. +Without SMTP config, users are auto-verified. ### Using the binary -1. Download the latest release from [GitHub Releases](https://github.com/davidfolch/apocapoc-api/releases) -2. Extract the archive: - ```bash - tar -xzf apocapoc-api_*_linux_amd64.tar.gz - ``` -3. Copy `.env.example` to `.env` and configure -4. Run the binary: - ```bash - ./apocapoc-api - ``` +1. Download from [GitHub Releases](https://github.com/davidfolch/apocapoc-api/releases) +2. Extract: `tar -xzf apocapoc-api_*_linux_amd64.tar.gz` +3. Configure: `cp .env.example .env` (edit as needed) +4. Run: `./apocapoc-api` -The API will be available at `http://localhost:8080` +API available at `http://localhost:8080` -**Note:** Linux binaries only (amd64 and arm64). For other platforms, use Docker. +*Note: Linux only (amd64/arm64). Use Docker for other platforms.* ## Development -### Prerequisites - -- Go 1.23+ -- SQLite -- Docker (optional) - -### Running locally with Docker (Recommended) +**Prerequisites:** Go 1.23+, SQLite, Docker (optional) +**With Docker:** ```bash cp docker-compose.example.yml docker-compose.yml docker-compose up --build ``` -The API will be available at `http://localhost:8080` - -### Running locally with Go - +**With Go:** ```bash cp .env.example .env go run cmd/api/main.go ``` +API runs on `http://localhost:8080` + +## Use Cases + +Perfect for: + +- **Custom mobile/web apps**: Build your own interface without backend complexity +- **Personal dashboards**: Integrate with Grafana, Nextcloud, or Home Assistant +- **Automation workflows**: Connect to n8n, Zapier, or custom scripts +- **Privacy-focused teams**: Keep sensitive productivity data on your infrastructure +- **API learning projects**: Clean architecture with real-world examples +- **Offline-first tools**: SQLite backend works without cloud dependencies + ## API Documentation -Once running, visit `http://localhost:8080/api/v1/docs` for interactive Swagger documentation. +Access the interactive Swagger UI at `http://localhost:8080/api/v1/docs` + +Includes endpoint reference, schemas, authentication examples, and live testing. ## Architecture @@ -156,6 +163,10 @@ This project follows hexagonal (ports & adapters) architecture: - 📧 Email: contact@apocapoc.app - 🐛 Issues: [GitHub Issues](https://github.com/davidfolch/apocapoc-api/issues) +## Keywords + +`habit-tracker` `habit-tracking` `rest-api` `self-hosted` `golang` `api` `habits` `productivity` `docker` `sqlite` `hexagonal-architecture` `clean-architecture` `habit-tracker-api` `self-hosted-api` `personal-analytics` `privacy` `open-source` + ## License MIT diff --git a/internal/application/commands/delete_user_test.go b/internal/application/commands/delete_user_test.go new file mode 100644 index 0000000..047046e --- /dev/null +++ b/internal/application/commands/delete_user_test.go @@ -0,0 +1,134 @@ +package commands + +import ( + "context" + "testing" + + "apocapoc-api/internal/domain/entities" + "apocapoc-api/internal/shared/errors" +) + +type mockDeleteUserRepo struct { + findByIDFunc func(ctx context.Context, id string) (*entities.User, error) + deleteFunc func(ctx context.Context, id string) error +} + +func (m *mockDeleteUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) { + if m.findByIDFunc != nil { + return m.findByIDFunc(ctx, id) + } + return nil, errors.ErrNotFound +} + +func (m *mockDeleteUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) { + return nil, errors.ErrNotFound +} + +func (m *mockDeleteUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) { + return nil, errors.ErrNotFound +} + +func (m *mockDeleteUserRepo) Create(ctx context.Context, user *entities.User) error { + return nil +} + +func (m *mockDeleteUserRepo) Update(ctx context.Context, user *entities.User) error { + return nil +} + +func (m *mockDeleteUserRepo) Delete(ctx context.Context, id string) error { + if m.deleteFunc != nil { + return m.deleteFunc(ctx, id) + } + return nil +} + +func TestDeleteUserHandler_Success(t *testing.T) { + var deletedID string + + repo := &mockDeleteUserRepo{ + findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) { + user := entities.NewUser("test@example.com", "hashedPassword", "UTC") + user.ID = id + return user, nil + }, + deleteFunc: func(ctx context.Context, id string) error { + deletedID = id + return nil + }, + } + + handler := NewDeleteUserHandler(repo) + + cmd := DeleteUserCommand{ + UserID: "user-123", + } + + err := handler.Handle(context.Background(), cmd) + if err != nil { + t.Fatalf("Handle() unexpected error = %v", err) + } + + if deletedID != "user-123" { + t.Errorf("deletedID = %v, want %v", deletedID, "user-123") + } +} + +func TestDeleteUserHandler_EmptyUserID(t *testing.T) { + repo := &mockDeleteUserRepo{} + handler := NewDeleteUserHandler(repo) + + cmd := DeleteUserCommand{ + UserID: "", + } + + err := handler.Handle(context.Background(), cmd) + if err != errors.ErrInvalidInput { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput) + } +} + +func TestDeleteUserHandler_UserNotFound(t *testing.T) { + repo := &mockDeleteUserRepo{ + findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) { + return nil, errors.ErrNotFound + }, + } + + handler := NewDeleteUserHandler(repo) + + cmd := DeleteUserCommand{ + UserID: "non-existent-user", + } + + err := handler.Handle(context.Background(), cmd) + if err != errors.ErrNotFound { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrNotFound) + } +} + +func TestDeleteUserHandler_DeleteError(t *testing.T) { + customError := errors.ErrNotFound + + repo := &mockDeleteUserRepo{ + findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) { + user := entities.NewUser("test@example.com", "hashedPassword", "UTC") + user.ID = id + return user, nil + }, + deleteFunc: func(ctx context.Context, id string) error { + return customError + }, + } + + handler := NewDeleteUserHandler(repo) + + cmd := DeleteUserCommand{ + UserID: "user-123", + } + + err := handler.Handle(context.Background(), cmd) + if err != customError { + t.Errorf("Handle() error = %v, want %v", err, customError) + } +} diff --git a/internal/application/commands/request_password_reset_handler.go b/internal/application/commands/request_password_reset_handler.go index 5444b70..ca09db4 100644 --- a/internal/application/commands/request_password_reset_handler.go +++ b/internal/application/commands/request_password_reset_handler.go @@ -18,10 +18,10 @@ type RequestPasswordResetCommand struct { } type RequestPasswordResetHandler struct { - userRepo repositories.UserRepository - passwordResetTokenRepo repositories.PasswordResetTokenRepository - emailService services.EmailService - appURL string + userRepo repositories.UserRepository + passwordResetTokenRepo repositories.PasswordResetTokenRepository + emailService services.EmailService + appURL string } func NewRequestPasswordResetHandler( diff --git a/internal/application/commands/reset_password_test.go b/internal/application/commands/reset_password_test.go new file mode 100644 index 0000000..46a8a29 --- /dev/null +++ b/internal/application/commands/reset_password_test.go @@ -0,0 +1,381 @@ +package commands + +import ( + "context" + "testing" + "time" + + "apocapoc-api/internal/domain/entities" + "apocapoc-api/internal/shared/errors" +) + +type mockResetPasswordUserRepo struct { + findByIDFunc func(ctx context.Context, id string) (*entities.User, error) + updateFunc func(ctx context.Context, user *entities.User) error + users map[string]*entities.User +} + +func (m *mockResetPasswordUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) { + if m.findByIDFunc != nil { + return m.findByIDFunc(ctx, id) + } + if user, ok := m.users[id]; ok { + return user, nil + } + return nil, errors.ErrNotFound +} + +func (m *mockResetPasswordUserRepo) Update(ctx context.Context, user *entities.User) error { + if m.updateFunc != nil { + return m.updateFunc(ctx, user) + } + m.users[user.ID] = user + return nil +} + +func (m *mockResetPasswordUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) { + return nil, errors.ErrNotFound +} + +func (m *mockResetPasswordUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) { + return nil, errors.ErrNotFound +} + +func (m *mockResetPasswordUserRepo) Create(ctx context.Context, user *entities.User) error { + return nil +} + +func (m *mockResetPasswordUserRepo) Delete(ctx context.Context, id string) error { + return nil +} + +type mockPasswordResetTokenRepo struct { + findByTokenFunc func(ctx context.Context, token string) (*entities.PasswordResetToken, error) + updateFunc func(ctx context.Context, token *entities.PasswordResetToken) error + tokens map[string]*entities.PasswordResetToken +} + +func (m *mockPasswordResetTokenRepo) Create(ctx context.Context, token *entities.PasswordResetToken) error { + m.tokens[token.Token] = token + return nil +} + +func (m *mockPasswordResetTokenRepo) FindByToken(ctx context.Context, token string) (*entities.PasswordResetToken, error) { + if m.findByTokenFunc != nil { + return m.findByTokenFunc(ctx, token) + } + if t, ok := m.tokens[token]; ok { + return t, nil + } + return nil, errors.ErrNotFound +} + +func (m *mockPasswordResetTokenRepo) Update(ctx context.Context, token *entities.PasswordResetToken) error { + if m.updateFunc != nil { + return m.updateFunc(ctx, token) + } + m.tokens[token.Token] = token + return nil +} + +func (m *mockPasswordResetTokenRepo) DeleteExpired(ctx context.Context) error { + return nil +} + +type mockResetPasswordHasher struct { + hashFunc func(password string) (string, error) +} + +func (m *mockResetPasswordHasher) Hash(password string) (string, error) { + if m.hashFunc != nil { + return m.hashFunc(password) + } + return "hashed_" + password, nil +} + +func (m *mockResetPasswordHasher) Compare(hashedPassword, password string) error { + return nil +} + +func TestResetPasswordHandler_Success(t *testing.T) { + user := entities.NewUser("test@example.com", "old_hash", "UTC") + user.ID = "user-123" + + resetToken := entities.NewPasswordResetToken( + user.ID, + "reset-token", + time.Now().Add(1*time.Hour), + ) + + var updatedUser *entities.User + var updatedToken *entities.PasswordResetToken + + userRepo := &mockResetPasswordUserRepo{ + users: map[string]*entities.User{ + user.ID: user, + }, + updateFunc: func(ctx context.Context, u *entities.User) error { + updatedUser = u + return nil + }, + } + + tokenRepo := &mockPasswordResetTokenRepo{ + tokens: map[string]*entities.PasswordResetToken{ + resetToken.Token: resetToken, + }, + updateFunc: func(ctx context.Context, t *entities.PasswordResetToken) error { + updatedToken = t + return nil + }, + } + + hasher := &mockResetPasswordHasher{} + + handler := NewResetPasswordHandler(userRepo, tokenRepo, hasher) + + cmd := ResetPasswordCommand{ + Token: "reset-token", + NewPassword: "NewP@ssw0rd123", + } + + err := handler.Handle(context.Background(), cmd) + if err != nil { + t.Fatalf("Handle() unexpected error = %v", err) + } + + if updatedUser == nil { + t.Fatal("User was not updated") + } + + if updatedUser.PasswordHash != "hashed_NewP@ssw0rd123" { + t.Errorf("PasswordHash = %v, want %v", updatedUser.PasswordHash, "hashed_NewP@ssw0rd123") + } + + if updatedToken == nil { + t.Fatal("Token was not updated") + } + + if !updatedToken.IsUsed() { + t.Error("Token should be marked as used") + } +} + +func TestResetPasswordHandler_EmptyToken(t *testing.T) { + handler := NewResetPasswordHandler( + &mockResetPasswordUserRepo{users: make(map[string]*entities.User)}, + &mockPasswordResetTokenRepo{tokens: make(map[string]*entities.PasswordResetToken)}, + &mockResetPasswordHasher{}, + ) + + cmd := ResetPasswordCommand{ + Token: "", + NewPassword: "NewP@ssw0rd123", + } + + err := handler.Handle(context.Background(), cmd) + if err != errors.ErrInvalidInput { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput) + } +} + +func TestResetPasswordHandler_EmptyPassword(t *testing.T) { + handler := NewResetPasswordHandler( + &mockResetPasswordUserRepo{users: make(map[string]*entities.User)}, + &mockPasswordResetTokenRepo{tokens: make(map[string]*entities.PasswordResetToken)}, + &mockResetPasswordHasher{}, + ) + + cmd := ResetPasswordCommand{ + Token: "reset-token", + NewPassword: "", + } + + err := handler.Handle(context.Background(), cmd) + if err != errors.ErrInvalidInput { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput) + } +} + +func TestResetPasswordHandler_InvalidPassword(t *testing.T) { + handler := NewResetPasswordHandler( + &mockResetPasswordUserRepo{users: make(map[string]*entities.User)}, + &mockPasswordResetTokenRepo{tokens: make(map[string]*entities.PasswordResetToken)}, + &mockResetPasswordHasher{}, + ) + + tests := []struct { + name string + password string + }{ + {"too short", "Short1!"}, + {"no uppercase", "password123!"}, + {"no lowercase", "PASSWORD123!"}, + {"no digit", "Password!"}, + {"no special char", "Password123"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := ResetPasswordCommand{ + Token: "reset-token", + NewPassword: tt.password, + } + + err := handler.Handle(context.Background(), cmd) + if err != errors.ErrInvalidInput { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput) + } + }) + } +} + +func TestResetPasswordHandler_TokenNotFound(t *testing.T) { + handler := NewResetPasswordHandler( + &mockResetPasswordUserRepo{users: make(map[string]*entities.User)}, + &mockPasswordResetTokenRepo{tokens: make(map[string]*entities.PasswordResetToken)}, + &mockResetPasswordHasher{}, + ) + + cmd := ResetPasswordCommand{ + Token: "non-existent-token", + NewPassword: "NewP@ssw0rd123", + } + + err := handler.Handle(context.Background(), cmd) + if err != errors.ErrInvalidInput { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput) + } +} + +func TestResetPasswordHandler_ExpiredToken(t *testing.T) { + resetToken := entities.NewPasswordResetToken( + "user-123", + "expired-token", + time.Now().Add(-1*time.Hour), + ) + + tokenRepo := &mockPasswordResetTokenRepo{ + tokens: map[string]*entities.PasswordResetToken{ + resetToken.Token: resetToken, + }, + } + + handler := NewResetPasswordHandler( + &mockResetPasswordUserRepo{users: make(map[string]*entities.User)}, + tokenRepo, + &mockResetPasswordHasher{}, + ) + + cmd := ResetPasswordCommand{ + Token: "expired-token", + NewPassword: "NewP@ssw0rd123", + } + + err := handler.Handle(context.Background(), cmd) + if err != errors.ErrInvalidInput { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput) + } +} + +func TestResetPasswordHandler_UsedToken(t *testing.T) { + resetToken := entities.NewPasswordResetToken( + "user-123", + "used-token", + time.Now().Add(1*time.Hour), + ) + resetToken.MarkAsUsed() + + tokenRepo := &mockPasswordResetTokenRepo{ + tokens: map[string]*entities.PasswordResetToken{ + resetToken.Token: resetToken, + }, + } + + handler := NewResetPasswordHandler( + &mockResetPasswordUserRepo{users: make(map[string]*entities.User)}, + tokenRepo, + &mockResetPasswordHasher{}, + ) + + cmd := ResetPasswordCommand{ + Token: "used-token", + NewPassword: "NewP@ssw0rd123", + } + + err := handler.Handle(context.Background(), cmd) + if err != errors.ErrInvalidInput { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput) + } +} + +func TestResetPasswordHandler_UserNotFound(t *testing.T) { + resetToken := entities.NewPasswordResetToken( + "non-existent-user", + "reset-token", + time.Now().Add(1*time.Hour), + ) + + tokenRepo := &mockPasswordResetTokenRepo{ + tokens: map[string]*entities.PasswordResetToken{ + resetToken.Token: resetToken, + }, + } + + userRepo := &mockResetPasswordUserRepo{ + users: make(map[string]*entities.User), + } + + handler := NewResetPasswordHandler(userRepo, tokenRepo, &mockResetPasswordHasher{}) + + cmd := ResetPasswordCommand{ + Token: "reset-token", + NewPassword: "NewP@ssw0rd123", + } + + err := handler.Handle(context.Background(), cmd) + if err != errors.ErrNotFound { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrNotFound) + } +} + +func TestResetPasswordHandler_HashingError(t *testing.T) { + user := entities.NewUser("test@example.com", "old_hash", "UTC") + user.ID = "user-123" + + resetToken := entities.NewPasswordResetToken( + user.ID, + "reset-token", + time.Now().Add(1*time.Hour), + ) + + userRepo := &mockResetPasswordUserRepo{ + users: map[string]*entities.User{ + user.ID: user, + }, + } + + tokenRepo := &mockPasswordResetTokenRepo{ + tokens: map[string]*entities.PasswordResetToken{ + resetToken.Token: resetToken, + }, + } + + hasher := &mockResetPasswordHasher{ + hashFunc: func(password string) (string, error) { + return "", errors.ErrInvalidInput + }, + } + + handler := NewResetPasswordHandler(userRepo, tokenRepo, hasher) + + cmd := ResetPasswordCommand{ + Token: "reset-token", + NewPassword: "NewP@ssw0rd123", + } + + err := handler.Handle(context.Background(), cmd) + if err == nil { + t.Fatal("Handle() expected error but got nil") + } +} diff --git a/internal/application/commands/revoke_token_test.go b/internal/application/commands/revoke_token_test.go new file mode 100644 index 0000000..8e131f4 --- /dev/null +++ b/internal/application/commands/revoke_token_test.go @@ -0,0 +1,188 @@ +package commands + +import ( + "context" + "testing" + "time" + + "apocapoc-api/internal/domain/entities" + "apocapoc-api/internal/shared/errors" +) + +type mockRefreshTokenRepo struct { + tokens map[string]*entities.RefreshToken +} + +func (m *mockRefreshTokenRepo) Create(ctx context.Context, token *entities.RefreshToken) error { + m.tokens[token.Token] = token + return nil +} + +func (m *mockRefreshTokenRepo) FindByToken(ctx context.Context, token string) (*entities.RefreshToken, error) { + if t, ok := m.tokens[token]; ok { + return t, nil + } + return nil, errors.ErrNotFound +} + +func (m *mockRefreshTokenRepo) FindByUserID(ctx context.Context, userID string) ([]*entities.RefreshToken, error) { + var tokens []*entities.RefreshToken + for _, t := range m.tokens { + if t.UserID == userID { + tokens = append(tokens, t) + } + } + return tokens, nil +} + +func (m *mockRefreshTokenRepo) RevokeByToken(ctx context.Context, token string) error { + if t, ok := m.tokens[token]; ok { + t.Revoke() + return nil + } + return errors.ErrNotFound +} + +func (m *mockRefreshTokenRepo) RevokeAllByUserID(ctx context.Context, userID string) error { + for _, t := range m.tokens { + if t.UserID == userID { + t.Revoke() + } + } + return nil +} + +func (m *mockRefreshTokenRepo) DeleteExpired(ctx context.Context) error { + return nil +} + +func TestRevokeTokenHandler_Handle(t *testing.T) { + repo := &mockRefreshTokenRepo{ + tokens: make(map[string]*entities.RefreshToken), + } + + handler := NewRevokeTokenHandler(repo) + + token := entities.NewRefreshToken("user-123", "valid-token", time.Now().Add(24*time.Hour)) + repo.Create(context.Background(), token) + + tests := []struct { + name string + cmd RevokeTokenCommand + expectError bool + expectedErr error + }{ + { + name: "revoke valid token", + cmd: RevokeTokenCommand{ + RefreshToken: "valid-token", + }, + expectError: false, + }, + { + name: "revoke empty token", + cmd: RevokeTokenCommand{ + RefreshToken: "", + }, + expectError: true, + expectedErr: errors.ErrInvalidInput, + }, + { + name: "revoke non-existent token", + cmd: RevokeTokenCommand{ + RefreshToken: "non-existent-token", + }, + expectError: true, + expectedErr: errors.ErrNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := handler.Handle(context.Background(), tt.cmd) + + if tt.expectError { + if err == nil { + t.Fatal("Handle() expected error but got nil") + } + if tt.expectedErr != nil && err != tt.expectedErr { + t.Errorf("Handle() error = %v, want %v", err, tt.expectedErr) + } + } else { + if err != nil { + t.Fatalf("Handle() unexpected error = %v", err) + } + } + }) + } +} + +func TestRevokeAllTokensHandler_Handle(t *testing.T) { + repo := &mockRefreshTokenRepo{ + tokens: make(map[string]*entities.RefreshToken), + } + + handler := NewRevokeAllTokensHandler(repo) + + token1 := entities.NewRefreshToken("user-123", "token-1", time.Now().Add(24*time.Hour)) + token2 := entities.NewRefreshToken("user-123", "token-2", time.Now().Add(24*time.Hour)) + token3 := entities.NewRefreshToken("user-456", "token-3", time.Now().Add(24*time.Hour)) + + repo.Create(context.Background(), token1) + repo.Create(context.Background(), token2) + repo.Create(context.Background(), token3) + + tests := []struct { + name string + cmd RevokeAllTokensCommand + expectError bool + expectedErr error + }{ + { + name: "revoke all tokens for user", + cmd: RevokeAllTokensCommand{ + UserID: "user-123", + }, + expectError: false, + }, + { + name: "revoke with empty user ID", + cmd: RevokeAllTokensCommand{ + UserID: "", + }, + expectError: true, + expectedErr: errors.ErrInvalidInput, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := handler.Handle(context.Background(), tt.cmd) + + if tt.expectError { + if err == nil { + t.Fatal("Handle() expected error but got nil") + } + if tt.expectedErr != nil && err != tt.expectedErr { + t.Errorf("Handle() error = %v, want %v", err, tt.expectedErr) + } + } else { + if err != nil { + t.Fatalf("Handle() unexpected error = %v", err) + } + + if !tt.expectError && tt.cmd.UserID == "user-123" { + if repo.tokens["token-1"].RevokedAt == nil { + t.Error("token-1 should be revoked") + } + if repo.tokens["token-2"].RevokedAt == nil { + t.Error("token-2 should be revoked") + } + if repo.tokens["token-3"].RevokedAt != nil { + t.Error("token-3 should not be revoked") + } + } + } + }) + } +} diff --git a/internal/application/commands/verify_email_test.go b/internal/application/commands/verify_email_test.go new file mode 100644 index 0000000..7fffe4f --- /dev/null +++ b/internal/application/commands/verify_email_test.go @@ -0,0 +1,338 @@ +package commands + +import ( + "context" + "testing" + "time" + + "apocapoc-api/internal/domain/entities" + "apocapoc-api/internal/domain/services" + "apocapoc-api/internal/shared/errors" +) + +type mockVerifyEmailUserRepo struct { + findByVerificationTokenFunc func(ctx context.Context, token string) (*entities.User, error) + updateFunc func(ctx context.Context, user *entities.User) error + users map[string]*entities.User +} + +func (m *mockVerifyEmailUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) { + if m.findByVerificationTokenFunc != nil { + return m.findByVerificationTokenFunc(ctx, token) + } + if user, ok := m.users[token]; ok { + return user, nil + } + return nil, errors.ErrNotFound +} + +func (m *mockVerifyEmailUserRepo) Update(ctx context.Context, user *entities.User) error { + if m.updateFunc != nil { + return m.updateFunc(ctx, user) + } + m.users[user.Email] = user + return nil +} + +func (m *mockVerifyEmailUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) { + return nil, errors.ErrNotFound +} + +func (m *mockVerifyEmailUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) { + return nil, errors.ErrNotFound +} + +func (m *mockVerifyEmailUserRepo) Create(ctx context.Context, user *entities.User) error { + return nil +} + +func (m *mockVerifyEmailUserRepo) Delete(ctx context.Context, id string) error { + return nil +} + +type mockEmailService struct { + sendFunc func(message services.EmailMessage) error + sentMessages []services.EmailMessage +} + +func (m *mockEmailService) Send(message services.EmailMessage) error { + m.sentMessages = append(m.sentMessages, message) + if m.sendFunc != nil { + return m.sendFunc(message) + } + return nil +} + +func TestVerifyEmailHandler_Success(t *testing.T) { + token := "valid-token" + expiry := time.Now().Add(24 * time.Hour) + + user := entities.NewUser("test@example.com", "hashedPassword", "UTC") + user.ID = "user-123" + user.EmailVerified = false + user.EmailVerificationToken = &token + user.EmailVerificationExpiry = &expiry + + var updatedUser *entities.User + repo := &mockVerifyEmailUserRepo{ + users: map[string]*entities.User{ + token: user, + }, + updateFunc: func(ctx context.Context, u *entities.User) error { + updatedUser = u + return nil + }, + } + + handler := NewVerifyEmailHandler(repo, nil, false) + + cmd := VerifyEmailCommand{ + Token: token, + } + + err := handler.Handle(context.Background(), cmd) + if err != nil { + t.Fatalf("Handle() unexpected error = %v", err) + } + + if updatedUser == nil { + t.Fatal("User was not updated") + } + + if !updatedUser.EmailVerified { + t.Error("EmailVerified should be true") + } + + if updatedUser.EmailVerificationToken != nil { + t.Error("EmailVerificationToken should be nil after verification") + } + + if updatedUser.EmailVerificationExpiry != nil { + t.Error("EmailVerificationExpiry should be nil after verification") + } +} + +func TestVerifyEmailHandler_EmptyToken(t *testing.T) { + repo := &mockVerifyEmailUserRepo{ + users: make(map[string]*entities.User), + } + handler := NewVerifyEmailHandler(repo, nil, false) + + cmd := VerifyEmailCommand{ + Token: "", + } + + err := handler.Handle(context.Background(), cmd) + if err != errors.ErrInvalidInput { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput) + } +} + +func TestVerifyEmailHandler_TokenNotFound(t *testing.T) { + repo := &mockVerifyEmailUserRepo{ + users: make(map[string]*entities.User), + } + handler := NewVerifyEmailHandler(repo, nil, false) + + cmd := VerifyEmailCommand{ + Token: "non-existent-token", + } + + err := handler.Handle(context.Background(), cmd) + if err != errors.ErrInvalidInput { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput) + } +} + +func TestVerifyEmailHandler_AlreadyVerified(t *testing.T) { + token := "valid-token" + expiry := time.Now().Add(24 * time.Hour) + + user := entities.NewUser("test@example.com", "hashedPassword", "UTC") + user.ID = "user-123" + user.EmailVerified = true + user.EmailVerificationToken = &token + user.EmailVerificationExpiry = &expiry + + repo := &mockVerifyEmailUserRepo{ + users: map[string]*entities.User{ + token: user, + }, + } + + handler := NewVerifyEmailHandler(repo, nil, false) + + cmd := VerifyEmailCommand{ + Token: token, + } + + err := handler.Handle(context.Background(), cmd) + if err != errors.ErrAlreadyExists { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrAlreadyExists) + } +} + +func TestVerifyEmailHandler_ExpiredToken(t *testing.T) { + token := "expired-token" + expiry := time.Now().Add(-1 * time.Hour) + + user := entities.NewUser("test@example.com", "hashedPassword", "UTC") + user.ID = "user-123" + user.EmailVerified = false + user.EmailVerificationToken = &token + user.EmailVerificationExpiry = &expiry + + repo := &mockVerifyEmailUserRepo{ + users: map[string]*entities.User{ + token: user, + }, + } + + handler := NewVerifyEmailHandler(repo, nil, false) + + cmd := VerifyEmailCommand{ + Token: token, + } + + err := handler.Handle(context.Background(), cmd) + if err != errors.ErrInvalidInput { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput) + } +} + +func TestVerifyEmailHandler_NilExpiry(t *testing.T) { + token := "valid-token" + + user := entities.NewUser("test@example.com", "hashedPassword", "UTC") + user.ID = "user-123" + user.EmailVerified = false + user.EmailVerificationToken = &token + user.EmailVerificationExpiry = nil + + repo := &mockVerifyEmailUserRepo{ + users: map[string]*entities.User{ + token: user, + }, + } + + handler := NewVerifyEmailHandler(repo, nil, false) + + cmd := VerifyEmailCommand{ + Token: token, + } + + err := handler.Handle(context.Background(), cmd) + if err != errors.ErrInvalidInput { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput) + } +} + +func TestVerifyEmailHandler_WithWelcomeEmail(t *testing.T) { + token := "valid-token" + expiry := time.Now().Add(24 * time.Hour) + + user := entities.NewUser("test@example.com", "hashedPassword", "UTC") + user.ID = "user-123" + user.EmailVerified = false + user.EmailVerificationToken = &token + user.EmailVerificationExpiry = &expiry + + repo := &mockVerifyEmailUserRepo{ + users: map[string]*entities.User{ + token: user, + }, + } + + emailService := &mockEmailService{} + handler := NewVerifyEmailHandler(repo, emailService, true) + + cmd := VerifyEmailCommand{ + Token: token, + } + + err := handler.Handle(context.Background(), cmd) + if err != nil { + t.Fatalf("Handle() unexpected error = %v", err) + } + + if len(emailService.sentMessages) != 1 { + t.Fatalf("Expected 1 email sent, got %d", len(emailService.sentMessages)) + } + + sentEmail := emailService.sentMessages[0] + if sentEmail.To != "test@example.com" { + t.Errorf("Email To = %v, want %v", sentEmail.To, "test@example.com") + } + + if sentEmail.Subject != "Welcome to Apocapoc!" { + t.Errorf("Email Subject = %v, want %v", sentEmail.Subject, "Welcome to Apocapoc!") + } + + if !sentEmail.IsHTML { + t.Error("Email should be HTML") + } +} + +func TestVerifyEmailHandler_WithoutWelcomeEmail(t *testing.T) { + token := "valid-token" + expiry := time.Now().Add(24 * time.Hour) + + user := entities.NewUser("test@example.com", "hashedPassword", "UTC") + user.ID = "user-123" + user.EmailVerified = false + user.EmailVerificationToken = &token + user.EmailVerificationExpiry = &expiry + + repo := &mockVerifyEmailUserRepo{ + users: map[string]*entities.User{ + token: user, + }, + } + + emailService := &mockEmailService{} + handler := NewVerifyEmailHandler(repo, emailService, false) + + cmd := VerifyEmailCommand{ + Token: token, + } + + err := handler.Handle(context.Background(), cmd) + if err != nil { + t.Fatalf("Handle() unexpected error = %v", err) + } + + if len(emailService.sentMessages) != 0 { + t.Errorf("Expected 0 emails sent, got %d", len(emailService.sentMessages)) + } +} + +func TestVerifyEmailHandler_UpdateError(t *testing.T) { + token := "valid-token" + expiry := time.Now().Add(24 * time.Hour) + + user := entities.NewUser("test@example.com", "hashedPassword", "UTC") + user.ID = "user-123" + user.EmailVerified = false + user.EmailVerificationToken = &token + user.EmailVerificationExpiry = &expiry + + repo := &mockVerifyEmailUserRepo{ + users: map[string]*entities.User{ + token: user, + }, + updateFunc: func(ctx context.Context, u *entities.User) error { + return errors.ErrNotFound + }, + } + + handler := NewVerifyEmailHandler(repo, nil, false) + + cmd := VerifyEmailCommand{ + Token: token, + } + + err := handler.Handle(context.Background(), cmd) + if err == nil { + t.Fatal("Handle() expected error but got nil") + } +} diff --git a/internal/application/queries/login_user_test.go b/internal/application/queries/login_user_test.go new file mode 100644 index 0000000..672b62d --- /dev/null +++ b/internal/application/queries/login_user_test.go @@ -0,0 +1,210 @@ +package queries + +import ( + "context" + "testing" + + "apocapoc-api/internal/domain/entities" + "apocapoc-api/internal/shared/errors" +) + +type mockLoginUserRepo struct { + findByEmailFunc func(ctx context.Context, email string) (*entities.User, error) +} + +func (m *mockLoginUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) { + if m.findByEmailFunc != nil { + return m.findByEmailFunc(ctx, email) + } + return nil, errors.ErrNotFound +} + +func (m *mockLoginUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) { + return nil, errors.ErrNotFound +} + +func (m *mockLoginUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) { + return nil, errors.ErrNotFound +} + +func (m *mockLoginUserRepo) Create(ctx context.Context, user *entities.User) error { + return nil +} + +func (m *mockLoginUserRepo) Update(ctx context.Context, user *entities.User) error { + return nil +} + +func (m *mockLoginUserRepo) Delete(ctx context.Context, id string) error { + return nil +} + +type mockLoginPasswordHasher struct { + compareFunc func(hashedPassword, password string) error +} + +func (m *mockLoginPasswordHasher) Hash(password string) (string, error) { + return "hashed_" + password, nil +} + +func (m *mockLoginPasswordHasher) Compare(hashedPassword, password string) error { + if m.compareFunc != nil { + return m.compareFunc(hashedPassword, password) + } + return nil +} + +func TestLoginUserHandler_Success(t *testing.T) { + user := entities.NewUser("test@example.com", "hashed_password", "UTC") + user.ID = "user-123" + user.EmailVerified = true + + repo := &mockLoginUserRepo{ + findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) { + return user, nil + }, + } + + hasher := &mockLoginPasswordHasher{ + compareFunc: func(hashedPassword, password string) error { + return nil + }, + } + + handler := NewLoginUserHandler(repo, hasher) + + query := LoginUserQuery{ + Email: "test@example.com", + Password: "password123", + } + + result, err := handler.Handle(context.Background(), query) + if err != nil { + t.Fatalf("Handle() unexpected error = %v", err) + } + + if result.UserID != "user-123" { + t.Errorf("UserID = %v, want %v", result.UserID, "user-123") + } + + if result.Email != "test@example.com" { + t.Errorf("Email = %v, want %v", result.Email, "test@example.com") + } + + if result.Timezone != "UTC" { + t.Errorf("Timezone = %v, want %v", result.Timezone, "UTC") + } +} + +func TestLoginUserHandler_EmptyEmail(t *testing.T) { + repo := &mockLoginUserRepo{} + hasher := &mockLoginPasswordHasher{} + handler := NewLoginUserHandler(repo, hasher) + + query := LoginUserQuery{ + Email: "", + Password: "password123", + } + + _, err := handler.Handle(context.Background(), query) + if err != errors.ErrInvalidInput { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput) + } +} + +func TestLoginUserHandler_EmptyPassword(t *testing.T) { + repo := &mockLoginUserRepo{} + hasher := &mockLoginPasswordHasher{} + handler := NewLoginUserHandler(repo, hasher) + + query := LoginUserQuery{ + Email: "test@example.com", + Password: "", + } + + _, err := handler.Handle(context.Background(), query) + if err != errors.ErrInvalidInput { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput) + } +} + +func TestLoginUserHandler_UserNotFound(t *testing.T) { + repo := &mockLoginUserRepo{ + findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) { + return nil, errors.ErrNotFound + }, + } + + hasher := &mockLoginPasswordHasher{} + handler := NewLoginUserHandler(repo, hasher) + + query := LoginUserQuery{ + Email: "nonexistent@example.com", + Password: "password123", + } + + _, err := handler.Handle(context.Background(), query) + if err != errors.ErrNotFound { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrNotFound) + } +} + +func TestLoginUserHandler_InvalidPassword(t *testing.T) { + user := entities.NewUser("test@example.com", "hashed_password", "UTC") + user.ID = "user-123" + user.EmailVerified = true + + repo := &mockLoginUserRepo{ + findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) { + return user, nil + }, + } + + hasher := &mockLoginPasswordHasher{ + compareFunc: func(hashedPassword, password string) error { + return errors.ErrInvalidInput + }, + } + + handler := NewLoginUserHandler(repo, hasher) + + query := LoginUserQuery{ + Email: "test@example.com", + Password: "wrongpassword", + } + + _, err := handler.Handle(context.Background(), query) + if err != errors.ErrNotFound { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrNotFound) + } +} + +func TestLoginUserHandler_EmailNotVerified(t *testing.T) { + user := entities.NewUser("test@example.com", "hashed_password", "UTC") + user.ID = "user-123" + user.EmailVerified = false + + repo := &mockLoginUserRepo{ + findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) { + return user, nil + }, + } + + hasher := &mockLoginPasswordHasher{ + compareFunc: func(hashedPassword, password string) error { + return nil + }, + } + + handler := NewLoginUserHandler(repo, hasher) + + query := LoginUserQuery{ + Email: "test@example.com", + Password: "password123", + } + + _, err := handler.Handle(context.Background(), query) + if err != errors.ErrEmailNotVerified { + t.Errorf("Handle() error = %v, want %v", err, errors.ErrEmailNotVerified) + } +} diff --git a/internal/application/queries/refresh_token_test.go b/internal/application/queries/refresh_token_test.go index 5e111fe..a5e0401 100644 --- a/internal/application/queries/refresh_token_test.go +++ b/internal/application/queries/refresh_token_test.go @@ -182,3 +182,87 @@ func TestRefreshTokenHandler_EmptyToken(t *testing.T) { t.Errorf("Expected ErrInvalidInput, got %v", err) } } + +func TestGenerateRefreshToken(t *testing.T) { + token1, err := GenerateRefreshToken() + if err != nil { + t.Fatalf("GenerateRefreshToken() unexpected error = %v", err) + } + + if token1 == "" { + t.Fatal("GenerateRefreshToken() returned empty token") + } + + token2, err := GenerateRefreshToken() + if err != nil { + t.Fatalf("GenerateRefreshToken() unexpected error = %v", err) + } + + if token1 == token2 { + t.Error("GenerateRefreshToken() generated identical tokens") + } + + if len(token1) < 20 { + t.Errorf("GenerateRefreshToken() token too short: %d characters", len(token1)) + } +} + +func TestCreateRefreshToken(t *testing.T) { + userID := "user-123" + expiryDuration := 7 * 24 * time.Hour + + token, err := CreateRefreshToken(userID, expiryDuration) + if err != nil { + t.Fatalf("CreateRefreshToken() unexpected error = %v", err) + } + + if token == nil { + t.Fatal("CreateRefreshToken() returned nil") + } + + if token.UserID != userID { + t.Errorf("UserID = %v, want %v", token.UserID, userID) + } + + if token.Token == "" { + t.Error("Token is empty") + } + + if token.ExpiresAt.IsZero() { + t.Error("ExpiresAt is zero") + } + + if token.CreatedAt.IsZero() { + t.Error("CreatedAt is zero") + } + + expectedExpiry := time.Now().Add(expiryDuration) + diff := token.ExpiresAt.Sub(expectedExpiry) + if diff > time.Second || diff < -time.Second { + t.Errorf("ExpiresAt difference too large: %v", diff) + } + + if !token.IsValid() { + t.Error("Token should be valid") + } +} + +func TestCreateRefreshToken_MultipleCalls(t *testing.T) { + token1, err := CreateRefreshToken("user-1", 24*time.Hour) + if err != nil { + t.Fatalf("CreateRefreshToken(1) unexpected error = %v", err) + } + + token2, err := CreateRefreshToken("user-2", 24*time.Hour) + if err != nil { + t.Fatalf("CreateRefreshToken(2) unexpected error = %v", err) + } + + if token1.Token == token2.Token { + t.Error("CreateRefreshToken() generated identical tokens for different users") + } + + if token1.UserID == token2.UserID { + t.Error("UserIDs should be different") + } +} diff --git a/internal/domain/entities/password_reset_token_test.go b/internal/domain/entities/password_reset_token_test.go new file mode 100644 index 0000000..310bdb8 --- /dev/null +++ b/internal/domain/entities/password_reset_token_test.go @@ -0,0 +1,135 @@ +package entities + +import ( + "testing" + "time" +) + +func TestNewPasswordResetToken(t *testing.T) { + userID := "user-123" + token := "reset-token-abc" + expiresAt := time.Now().Add(1 * time.Hour) + + prt := NewPasswordResetToken(userID, token, expiresAt) + + if prt == nil { + t.Fatal("NewPasswordResetToken() returned nil") + } + + if prt.ID == "" { + t.Error("ID is empty") + } + + if prt.UserID != userID { + t.Errorf("UserID = %v, want %v", prt.UserID, userID) + } + + if prt.Token != token { + t.Errorf("Token = %v, want %v", prt.Token, token) + } + + if !prt.ExpiresAt.Equal(expiresAt) { + t.Errorf("ExpiresAt = %v, want %v", prt.ExpiresAt, expiresAt) + } + + if prt.UsedAt != nil { + t.Error("UsedAt should be nil for new token") + } + + if prt.CreatedAt.IsZero() { + t.Error("CreatedAt is zero") + } +} + +func TestPasswordResetToken_IsExpired(t *testing.T) { + tests := []struct { + name string + expiresAt time.Time + want bool + }{ + { + name: "future expiry", + expiresAt: time.Now().Add(1 * time.Hour), + want: false, + }, + { + name: "past expiry", + expiresAt: time.Now().Add(-1 * time.Hour), + want: true, + }, + { + name: "expires in 1 second", + expiresAt: time.Now().Add(1 * time.Second), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prt := NewPasswordResetToken("user-123", "token", tt.expiresAt) + got := prt.IsExpired() + if got != tt.want { + t.Errorf("IsExpired() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestPasswordResetToken_IsUsed(t *testing.T) { + prt := NewPasswordResetToken("user-123", "token", time.Now().Add(1*time.Hour)) + + if prt.IsUsed() { + t.Error("IsUsed() should return false for new token") + } + + prt.MarkAsUsed() + + if !prt.IsUsed() { + t.Error("IsUsed() should return true after MarkAsUsed()") + } +} + +func TestPasswordResetToken_MarkAsUsed(t *testing.T) { + prt := NewPasswordResetToken("user-123", "token", time.Now().Add(1*time.Hour)) + + if prt.UsedAt != nil { + t.Error("UsedAt should be nil before MarkAsUsed()") + } + + prt.MarkAsUsed() + + if prt.UsedAt == nil { + t.Fatal("UsedAt should not be nil after MarkAsUsed()") + } + + now := time.Now() + diff := now.Sub(*prt.UsedAt) + if diff > time.Second || diff < 0 { + t.Errorf("UsedAt difference too large: %v", diff) + } + + firstUsedAt := prt.UsedAt + time.Sleep(10 * time.Millisecond) + prt.MarkAsUsed() + + if prt.UsedAt == firstUsedAt { + t.Error("MarkAsUsed() should update UsedAt on subsequent calls") + } +} + +func TestPasswordResetToken_MultipleTokens(t *testing.T) { + token1 := NewPasswordResetToken("user-1", "token-1", time.Now().Add(1*time.Hour)) + token2 := NewPasswordResetToken("user-2", "token-2", time.Now().Add(1*time.Hour)) + + if token1.ID == token2.ID { + t.Error("NewPasswordResetToken() generated identical IDs") + } + + if token1.UserID == token2.UserID { + t.Error("UserIDs should be different") + } + + if token1.Token == token2.Token { + t.Error("Tokens should be different") + } +} diff --git a/internal/domain/entities/refresh_token_test.go b/internal/domain/entities/refresh_token_test.go new file mode 100644 index 0000000..335ba71 --- /dev/null +++ b/internal/domain/entities/refresh_token_test.go @@ -0,0 +1,160 @@ +package entities + +import ( + "testing" + "time" +) + +func TestNewRefreshToken(t *testing.T) { + userID := "user-123" + token := "refresh-token-abc" + expiresAt := time.Now().Add(7 * 24 * time.Hour) + + rt := NewRefreshToken(userID, token, expiresAt) + + if rt == nil { + t.Fatal("NewRefreshToken() returned nil") + } + + if rt.UserID != userID { + t.Errorf("UserID = %v, want %v", rt.UserID, userID) + } + + if rt.Token != token { + t.Errorf("Token = %v, want %v", rt.Token, token) + } + + if !rt.ExpiresAt.Equal(expiresAt) { + t.Errorf("ExpiresAt = %v, want %v", rt.ExpiresAt, expiresAt) + } + + if rt.RevokedAt != nil { + t.Error("RevokedAt should be nil for new token") + } + + if rt.CreatedAt.IsZero() { + t.Error("CreatedAt is zero") + } +} + +func TestRefreshToken_IsValid(t *testing.T) { + tests := []struct { + name string + expiresAt time.Time + revoked bool + want bool + }{ + { + name: "valid token", + expiresAt: time.Now().Add(24 * time.Hour), + revoked: false, + want: true, + }, + { + name: "expired token", + expiresAt: time.Now().Add(-1 * time.Hour), + revoked: false, + want: false, + }, + { + name: "revoked token", + expiresAt: time.Now().Add(24 * time.Hour), + revoked: true, + want: false, + }, + { + name: "expired and revoked", + expiresAt: time.Now().Add(-1 * time.Hour), + revoked: true, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rt := NewRefreshToken("user-123", "token", tt.expiresAt) + if tt.revoked { + rt.Revoke() + } + + got := rt.IsValid() + if got != tt.want { + t.Errorf("IsValid() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRefreshToken_Revoke(t *testing.T) { + rt := NewRefreshToken("user-123", "token", time.Now().Add(24*time.Hour)) + + if rt.RevokedAt != nil { + t.Error("RevokedAt should be nil before Revoke()") + } + + if !rt.IsValid() { + t.Error("Token should be valid before Revoke()") + } + + rt.Revoke() + + if rt.RevokedAt == nil { + t.Fatal("RevokedAt should not be nil after Revoke()") + } + + if rt.IsValid() { + t.Error("Token should not be valid after Revoke()") + } + + now := time.Now() + diff := now.Sub(*rt.RevokedAt) + if diff > time.Second || diff < 0 { + t.Errorf("RevokedAt difference too large: %v", diff) + } +} + +func TestRefreshToken_RevokeMultipleTimes(t *testing.T) { + rt := NewRefreshToken("user-123", "token", time.Now().Add(24*time.Hour)) + + rt.Revoke() + firstRevokedAt := rt.RevokedAt + + time.Sleep(10 * time.Millisecond) + rt.Revoke() + + if rt.RevokedAt == firstRevokedAt { + t.Error("Revoke() should update RevokedAt on subsequent calls") + } +} + +func TestRefreshToken_ExpirationCheck(t *testing.T) { + expiresIn := 100 * time.Millisecond + rt := NewRefreshToken("user-123", "token", time.Now().Add(expiresIn)) + + if !rt.IsValid() { + t.Error("Token should be valid initially") + } + + time.Sleep(expiresIn + 10*time.Millisecond) + + if rt.IsValid() { + t.Error("Token should be invalid after expiration") + } +} + +func TestRefreshToken_MultipleTokens(t *testing.T) { + token1 := NewRefreshToken("user-1", "token-1", time.Now().Add(24*time.Hour)) + token2 := NewRefreshToken("user-2", "token-2", time.Now().Add(24*time.Hour)) + + if token1.UserID == token2.UserID { + t.Error("UserIDs should be different") + } + + if token1.Token == token2.Token { + t.Error("Tokens should be different") + } + + if token1.CreatedAt.Equal(token2.CreatedAt) { + t.Log("Warning: CreatedAt timestamps are identical (possible race condition in test)") + } +} diff --git a/internal/infrastructure/auth/jwt_test.go b/internal/infrastructure/auth/jwt_test.go new file mode 100644 index 0000000..32f363a --- /dev/null +++ b/internal/infrastructure/auth/jwt_test.go @@ -0,0 +1,357 @@ +package auth + +import ( + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func TestNewJWTService(t *testing.T) { + tests := []struct { + name string + secret string + expiryHours int + }{ + { + name: "standard configuration", + secret: "my-secret-key", + expiryHours: 24, + }, + { + name: "short expiry", + secret: "test-secret", + expiryHours: 1, + }, + { + name: "long expiry", + secret: "test-secret", + expiryHours: 168, + }, + { + name: "empty secret", + secret: "", + expiryHours: 24, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := NewJWTService(tt.secret, tt.expiryHours) + if service == nil { + t.Fatal("NewJWTService() returned nil") + } + + if string(service.secret) != tt.secret { + t.Errorf("secret = %v, want %v", string(service.secret), tt.secret) + } + + expectedExpiry := time.Duration(tt.expiryHours) * time.Hour + if service.expiry != expectedExpiry { + t.Errorf("expiry = %v, want %v", service.expiry, expectedExpiry) + } + }) + } +} + +func TestJWTService_GenerateToken(t *testing.T) { + service := NewJWTService("test-secret-key", 24) + + tests := []struct { + name string + userID string + email string + }{ + { + name: "standard user", + userID: "user-123", + email: "user@example.com", + }, + { + name: "empty user ID", + userID: "", + email: "user@example.com", + }, + { + name: "empty email", + userID: "user-123", + email: "", + }, + { + name: "both empty", + userID: "", + email: "", + }, + { + name: "special characters in email", + userID: "user-456", + email: "user+test@example.com", + }, + { + name: "uuid as user ID", + userID: "550e8400-e29b-41d4-a716-446655440000", + email: "uuid@example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token, err := service.GenerateToken(tt.userID, tt.email) + if err != nil { + t.Fatalf("GenerateToken() error = %v", err) + } + + if token == "" { + t.Fatal("GenerateToken() returned empty token") + } + + claims, err := service.ValidateToken(token) + if err != nil { + t.Fatalf("ValidateToken() error = %v", err) + } + + if claims.UserID != tt.userID { + t.Errorf("UserID = %v, want %v", claims.UserID, tt.userID) + } + + if claims.Email != tt.email { + t.Errorf("Email = %v, want %v", claims.Email, tt.email) + } + }) + } +} + +func TestJWTService_ValidateToken(t *testing.T) { + secret := "test-secret-key" + service := NewJWTService(secret, 24) + + tests := []struct { + name string + setupToken func() string + expectError bool + errorMsg string + }{ + { + name: "valid token", + setupToken: func() string { + token, _ := service.GenerateToken("user-123", "user@example.com") + return token + }, + expectError: false, + }, + { + name: "invalid token format", + setupToken: func() string { + return "not.a.valid.token" + }, + expectError: true, + }, + { + name: "empty token", + setupToken: func() string { + return "" + }, + expectError: true, + }, + { + name: "token with wrong secret", + setupToken: func() string { + wrongService := NewJWTService("wrong-secret", 24) + token, _ := wrongService.GenerateToken("user-123", "user@example.com") + return token + }, + expectError: true, + }, + { + name: "expired token", + setupToken: func() string { + expiredService := NewJWTService(secret, -1) + token, _ := expiredService.GenerateToken("user-123", "user@example.com") + return token + }, + expectError: true, + }, + { + name: "malformed token", + setupToken: func() string { + return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.malformed" + }, + expectError: true, + }, + { + name: "token with invalid signature", + setupToken: func() string { + token, _ := service.GenerateToken("user-123", "user@example.com") + return token[:len(token)-5] + "xxxxx" + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token := tt.setupToken() + claims, err := service.ValidateToken(token) + + if tt.expectError { + if err == nil { + t.Fatal("ValidateToken() expected error but got nil") + } + } else { + if err != nil { + t.Fatalf("ValidateToken() unexpected error = %v", err) + } + if claims == nil { + t.Fatal("ValidateToken() returned nil claims") + } + } + }) + } +} + +func TestJWTService_ValidateTokenClaims(t *testing.T) { + service := NewJWTService("test-secret-key", 24) + + userID := "user-123" + email := "user@example.com" + + token, err := service.GenerateToken(userID, email) + if err != nil { + t.Fatalf("GenerateToken() error = %v", err) + } + + claims, err := service.ValidateToken(token) + if err != nil { + t.Fatalf("ValidateToken() error = %v", err) + } + + if claims.UserID != userID { + t.Errorf("UserID = %v, want %v", claims.UserID, userID) + } + + if claims.Email != email { + t.Errorf("Email = %v, want %v", claims.Email, email) + } + + if claims.ExpiresAt == nil { + t.Fatal("ExpiresAt is nil") + } + + if claims.IssuedAt == nil { + t.Fatal("IssuedAt is nil") + } + + if claims.ExpiresAt.Before(claims.IssuedAt.Time) { + t.Error("ExpiresAt is before IssuedAt") + } + + expectedExpiry := claims.IssuedAt.Add(24 * time.Hour) + if !claims.ExpiresAt.Time.Equal(expectedExpiry) { + diff := claims.ExpiresAt.Time.Sub(expectedExpiry) + if diff > time.Second || diff < -time.Second { + t.Errorf("ExpiresAt = %v, want approximately %v (diff: %v)", claims.ExpiresAt.Time, expectedExpiry, diff) + } + } +} + +func TestJWTService_TokenExpiry(t *testing.T) { + tests := []struct { + name string + expiryHours int + }{ + { + name: "1 hour expiry", + expiryHours: 1, + }, + { + name: "24 hours expiry", + expiryHours: 24, + }, + { + name: "168 hours (1 week) expiry", + expiryHours: 168, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := NewJWTService("test-secret", tt.expiryHours) + token, err := service.GenerateToken("user-123", "user@example.com") + if err != nil { + t.Fatalf("GenerateToken() error = %v", err) + } + + claims, err := service.ValidateToken(token) + if err != nil { + t.Fatalf("ValidateToken() error = %v", err) + } + + expectedExpiry := time.Now().Add(time.Duration(tt.expiryHours) * time.Hour) + diff := claims.ExpiresAt.Time.Sub(expectedExpiry) + + if diff > time.Second || diff < -time.Second { + t.Errorf("ExpiresAt difference too large: %v", diff) + } + }) + } +} + +func TestJWTService_ValidateTokenWithWrongSigningMethod(t *testing.T) { + service := NewJWTService("test-secret", 24) + + claims := &Claims{ + UserID: "user-123", + Email: "user@example.com", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodNone, claims) + tokenString, err := token.SignedString(jwt.UnsafeAllowNoneSignatureType) + if err != nil { + t.Fatalf("SignedString() error = %v", err) + } + + _, err = service.ValidateToken(tokenString) + if err == nil { + t.Fatal("ValidateToken() expected error for none signing method but got nil") + } +} + +func TestJWTService_MultipleTokens(t *testing.T) { + service := NewJWTService("test-secret", 24) + + token1, err := service.GenerateToken("user-1", "user1@example.com") + if err != nil { + t.Fatalf("GenerateToken(1) error = %v", err) + } + + token2, err := service.GenerateToken("user-2", "user2@example.com") + if err != nil { + t.Fatalf("GenerateToken(2) error = %v", err) + } + + if token1 == token2 { + t.Error("Generated identical tokens for different users") + } + + claims1, err := service.ValidateToken(token1) + if err != nil { + t.Fatalf("ValidateToken(1) error = %v", err) + } + + claims2, err := service.ValidateToken(token2) + if err != nil { + t.Fatalf("ValidateToken(2) error = %v", err) + } + + if claims1.UserID == claims2.UserID { + t.Error("Claims have same UserID") + } + + if claims1.Email == claims2.Email { + t.Error("Claims have same Email") + } +} diff --git a/internal/infrastructure/config/config_test.go b/internal/infrastructure/config/config_test.go new file mode 100644 index 0000000..7ad3514 --- /dev/null +++ b/internal/infrastructure/config/config_test.go @@ -0,0 +1,315 @@ +package config + +import ( + "os" + "testing" +) + +func TestLoad_Success(t *testing.T) { + os.Setenv("DB_PATH", "/test/db.sqlite") + os.Setenv("APP_URL", "http://test.com") + os.Setenv("JWT_SECRET", "test-secret") + os.Setenv("JWT_EXPIRY", "1h") + os.Setenv("REFRESH_TOKEN_EXPIRY", "7d") + os.Setenv("DEFAULT_TIMEZONE", "UTC") + defer clearEnv() + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.DBPath != "/test/db.sqlite" { + t.Errorf("DBPath = %v, want %v", cfg.DBPath, "/test/db.sqlite") + } + if cfg.AppURL != "http://test.com" { + t.Errorf("AppURL = %v, want %v", cfg.AppURL, "http://test.com") + } + if cfg.JWTSecret != "test-secret" { + t.Errorf("JWTSecret = %v, want %v", cfg.JWTSecret, "test-secret") + } + if cfg.JWTExpiry != "1h" { + t.Errorf("JWTExpiry = %v, want %v", cfg.JWTExpiry, "1h") + } + if cfg.RefreshTokenExpiry != "7d" { + t.Errorf("RefreshTokenExpiry = %v, want %v", cfg.RefreshTokenExpiry, "7d") + } + if cfg.DefaultTimezone != "UTC" { + t.Errorf("DefaultTimezone = %v, want %v", cfg.DefaultTimezone, "UTC") + } +} + +func TestLoad_WithDefaults(t *testing.T) { + os.Setenv("DB_PATH", "/test/db.sqlite") + os.Setenv("APP_URL", "http://test.com") + os.Setenv("JWT_SECRET", "test-secret") + os.Setenv("JWT_EXPIRY", "1h") + os.Setenv("REFRESH_TOKEN_EXPIRY", "7d") + os.Setenv("DEFAULT_TIMEZONE", "UTC") + defer clearEnv() + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.Port != "8080" { + t.Errorf("Port = %v, want default %v", cfg.Port, "8080") + } + + if cfg.SMTPPort != "587" { + t.Errorf("SMTPPort = %v, want default %v", cfg.SMTPPort, "587") + } + + if cfg.SupportEmail != "contact@apocapoc.app" { + t.Errorf("SupportEmail = %v, want default %v", cfg.SupportEmail, "contact@apocapoc.app") + } + + if cfg.SendWelcomeEmail != "false" { + t.Errorf("SendWelcomeEmail = %v, want default %v", cfg.SendWelcomeEmail, "false") + } + + if cfg.RegistrationMode != "open" { + t.Errorf("RegistrationMode = %v, want default %v", cfg.RegistrationMode, "open") + } +} + +func TestLoad_WithCustomDefaults(t *testing.T) { + os.Setenv("DB_PATH", "/test/db.sqlite") + os.Setenv("APP_URL", "http://test.com") + os.Setenv("JWT_SECRET", "test-secret") + os.Setenv("JWT_EXPIRY", "1h") + os.Setenv("REFRESH_TOKEN_EXPIRY", "7d") + os.Setenv("DEFAULT_TIMEZONE", "UTC") + os.Setenv("PORT", "3000") + os.Setenv("SMTP_PORT", "465") + os.Setenv("SUPPORT_EMAIL", "support@test.com") + os.Setenv("SEND_WELCOME_EMAIL", "true") + os.Setenv("REGISTRATION_MODE", "closed") + defer clearEnv() + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.Port != "3000" { + t.Errorf("Port = %v, want %v", cfg.Port, "3000") + } + + if cfg.SMTPPort != "465" { + t.Errorf("SMTPPort = %v, want %v", cfg.SMTPPort, "465") + } + + if cfg.SupportEmail != "support@test.com" { + t.Errorf("SupportEmail = %v, want %v", cfg.SupportEmail, "support@test.com") + } + + if cfg.SendWelcomeEmail != "true" { + t.Errorf("SendWelcomeEmail = %v, want %v", cfg.SendWelcomeEmail, "true") + } + + if cfg.RegistrationMode != "closed" { + t.Errorf("RegistrationMode = %v, want %v", cfg.RegistrationMode, "closed") + } +} + +func TestLoad_MissingDBPath(t *testing.T) { + clearEnv() + os.Setenv("APP_URL", "http://test.com") + os.Setenv("JWT_SECRET", "test-secret") + os.Setenv("JWT_EXPIRY", "1h") + os.Setenv("REFRESH_TOKEN_EXPIRY", "7d") + os.Setenv("DEFAULT_TIMEZONE", "UTC") + defer clearEnv() + + _, err := Load() + if err == nil { + t.Fatal("Load() expected error for missing DB_PATH but got nil") + } + + expectedMsg := "DB_PATH is required" + if err.Error() != expectedMsg { + t.Errorf("Load() error = %v, want %v", err.Error(), expectedMsg) + } +} + +func TestLoad_MissingJWTSecret(t *testing.T) { + clearEnv() + os.Setenv("DB_PATH", "/test/db.sqlite") + os.Setenv("APP_URL", "http://test.com") + os.Setenv("JWT_EXPIRY", "1h") + os.Setenv("REFRESH_TOKEN_EXPIRY", "7d") + os.Setenv("DEFAULT_TIMEZONE", "UTC") + defer clearEnv() + + _, err := Load() + if err == nil { + t.Fatal("Load() expected error for missing JWT_SECRET but got nil") + } + + expectedMsg := "JWT_SECRET is required" + if err.Error() != expectedMsg { + t.Errorf("Load() error = %v, want %v", err.Error(), expectedMsg) + } +} + +func TestLoad_MissingJWTExpiry(t *testing.T) { + clearEnv() + os.Setenv("DB_PATH", "/test/db.sqlite") + os.Setenv("APP_URL", "http://test.com") + os.Setenv("JWT_SECRET", "test-secret") + os.Setenv("REFRESH_TOKEN_EXPIRY", "7d") + os.Setenv("DEFAULT_TIMEZONE", "UTC") + defer clearEnv() + + _, err := Load() + if err == nil { + t.Fatal("Load() expected error for missing JWT_EXPIRY but got nil") + } + + expectedMsg := "JWT_EXPIRY is required" + if err.Error() != expectedMsg { + t.Errorf("Load() error = %v, want %v", err.Error(), expectedMsg) + } +} + +func TestLoad_MissingRefreshTokenExpiry(t *testing.T) { + clearEnv() + os.Setenv("DB_PATH", "/test/db.sqlite") + os.Setenv("APP_URL", "http://test.com") + os.Setenv("JWT_SECRET", "test-secret") + os.Setenv("JWT_EXPIRY", "1h") + os.Setenv("DEFAULT_TIMEZONE", "UTC") + defer clearEnv() + + _, err := Load() + if err == nil { + t.Fatal("Load() expected error for missing REFRESH_TOKEN_EXPIRY but got nil") + } + + expectedMsg := "REFRESH_TOKEN_EXPIRY is required" + if err.Error() != expectedMsg { + t.Errorf("Load() error = %v, want %v", err.Error(), expectedMsg) + } +} + +func TestLoad_MissingDefaultTimezone(t *testing.T) { + clearEnv() + os.Setenv("DB_PATH", "/test/db.sqlite") + os.Setenv("APP_URL", "http://test.com") + os.Setenv("JWT_SECRET", "test-secret") + os.Setenv("JWT_EXPIRY", "1h") + os.Setenv("REFRESH_TOKEN_EXPIRY", "7d") + defer clearEnv() + + _, err := Load() + if err == nil { + t.Fatal("Load() expected error for missing DEFAULT_TIMEZONE but got nil") + } + + expectedMsg := "DEFAULT_TIMEZONE is required" + if err.Error() != expectedMsg { + t.Errorf("Load() error = %v, want %v", err.Error(), expectedMsg) + } +} + +func TestLoad_WithSMTPConfig(t *testing.T) { + os.Setenv("DB_PATH", "/test/db.sqlite") + os.Setenv("APP_URL", "http://test.com") + os.Setenv("JWT_SECRET", "test-secret") + os.Setenv("JWT_EXPIRY", "1h") + os.Setenv("REFRESH_TOKEN_EXPIRY", "7d") + os.Setenv("DEFAULT_TIMEZONE", "UTC") + os.Setenv("SMTP_HOST", "smtp.test.com") + os.Setenv("SMTP_PORT", "587") + os.Setenv("SMTP_USER", "user@test.com") + os.Setenv("SMTP_PASSWORD", "test-password") + os.Setenv("SMTP_FROM", "noreply@test.com") + defer clearEnv() + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.SMTPHost != "smtp.test.com" { + t.Errorf("SMTPHost = %v, want %v", cfg.SMTPHost, "smtp.test.com") + } + if cfg.SMTPPort != "587" { + t.Errorf("SMTPPort = %v, want %v", cfg.SMTPPort, "587") + } + if cfg.SMTPUser != "user@test.com" { + t.Errorf("SMTPUser = %v, want %v", cfg.SMTPUser, "user@test.com") + } + if cfg.SMTPPassword != "test-password" { + t.Errorf("SMTPPassword = %v, want %v", cfg.SMTPPassword, "test-password") + } + if cfg.SMTPFrom != "noreply@test.com" { + t.Errorf("SMTPFrom = %v, want %v", cfg.SMTPFrom, "noreply@test.com") + } +} + +func TestGetEnvOrDefault(t *testing.T) { + tests := []struct { + name string + key string + defaultValue string + envValue string + want string + }{ + { + name: "env value exists", + key: "TEST_KEY", + defaultValue: "default", + envValue: "custom", + want: "custom", + }, + { + name: "env value empty uses default", + key: "TEST_KEY_EMPTY", + defaultValue: "default", + envValue: "", + want: "default", + }, + { + name: "env value not set uses default", + key: "TEST_KEY_NOT_SET", + defaultValue: "default", + envValue: "", + want: "default", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envValue != "" { + os.Setenv(tt.key, tt.envValue) + defer os.Unsetenv(tt.key) + } + + got := getEnvOrDefault(tt.key, tt.defaultValue) + if got != tt.want { + t.Errorf("getEnvOrDefault() = %v, want %v", got, tt.want) + } + }) + } +} + +func clearEnv() { + os.Unsetenv("DB_PATH") + os.Unsetenv("PORT") + os.Unsetenv("APP_URL") + os.Unsetenv("JWT_SECRET") + os.Unsetenv("JWT_EXPIRY") + os.Unsetenv("REFRESH_TOKEN_EXPIRY") + os.Unsetenv("DEFAULT_TIMEZONE") + os.Unsetenv("SMTP_HOST") + os.Unsetenv("SMTP_PORT") + os.Unsetenv("SMTP_USER") + os.Unsetenv("SMTP_PASSWORD") + os.Unsetenv("SMTP_FROM") + os.Unsetenv("SUPPORT_EMAIL") + os.Unsetenv("SEND_WELCOME_EMAIL") + os.Unsetenv("REGISTRATION_MODE") +} diff --git a/internal/infrastructure/crypto/bcrypt_hasher_test.go b/internal/infrastructure/crypto/bcrypt_hasher_test.go new file mode 100644 index 0000000..d28e3a2 --- /dev/null +++ b/internal/infrastructure/crypto/bcrypt_hasher_test.go @@ -0,0 +1,217 @@ +package crypto + +import ( + "testing" + + "golang.org/x/crypto/bcrypt" +) + +func TestNewBcryptHasher(t *testing.T) { + hasher := NewBcryptHasher() + if hasher == nil { + t.Fatal("NewBcryptHasher() returned nil") + } + + _, ok := hasher.(*BcryptHasher) + if !ok { + t.Fatal("NewBcryptHasher() did not return *BcryptHasher") + } +} + +func TestBcryptHasher_Hash(t *testing.T) { + hasher := NewBcryptHasher() + + tests := []struct { + name string + password string + }{ + { + name: "simple password", + password: "password123", + }, + { + name: "complex password", + password: "P@ssw0rd!123$%^&*()", + }, + { + name: "empty password", + password: "", + }, + { + name: "unicode password", + password: "pässwörd123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hashed, err := hasher.Hash(tt.password) + if err != nil { + t.Fatalf("Hash() error = %v", err) + } + + if hashed == "" { + t.Fatal("Hash() returned empty string") + } + + if hashed == tt.password { + t.Fatal("Hash() returned the same as input password") + } + + err = bcrypt.CompareHashAndPassword([]byte(hashed), []byte(tt.password)) + if err != nil { + t.Fatalf("Generated hash does not match original password: %v", err) + } + }) + } +} + +func TestBcryptHasher_Compare(t *testing.T) { + hasher := NewBcryptHasher() + + tests := []struct { + name string + password string + compareWith string + expectError bool + errorAssertion func(error) bool + }{ + { + name: "matching passwords", + password: "password123", + compareWith: "password123", + expectError: false, + }, + { + name: "non-matching passwords", + password: "password123", + compareWith: "wrongpassword", + expectError: true, + errorAssertion: func(err error) bool { + return err == bcrypt.ErrMismatchedHashAndPassword + }, + }, + { + name: "empty password comparison", + password: "", + compareWith: "", + expectError: false, + }, + { + name: "unicode password match", + password: "pässwörd123", + compareWith: "pässwörd123", + expectError: false, + }, + { + name: "unicode password mismatch", + password: "pässwörd123", + compareWith: "password123", + expectError: true, + errorAssertion: func(err error) bool { + return err == bcrypt.ErrMismatchedHashAndPassword + }, + }, + { + name: "case sensitive", + password: "Password123", + compareWith: "password123", + expectError: true, + errorAssertion: func(err error) bool { + return err == bcrypt.ErrMismatchedHashAndPassword + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hashed, err := hasher.Hash(tt.password) + if err != nil { + t.Fatalf("Hash() error = %v", err) + } + + err = hasher.Compare(hashed, tt.compareWith) + if tt.expectError { + if err == nil { + t.Fatal("Compare() expected error but got nil") + } + if tt.errorAssertion != nil && !tt.errorAssertion(err) { + t.Fatalf("Compare() error = %v, but assertion failed", err) + } + } else { + if err != nil { + t.Fatalf("Compare() unexpected error = %v", err) + } + } + }) + } +} + +func TestBcryptHasher_CompareWithInvalidHash(t *testing.T) { + hasher := NewBcryptHasher() + + tests := []struct { + name string + invalidHash string + password string + expectError bool + }{ + { + name: "invalid hash format", + invalidHash: "not-a-valid-hash", + password: "password123", + expectError: true, + }, + { + name: "empty hash", + invalidHash: "", + password: "password123", + expectError: true, + }, + { + name: "corrupted hash", + invalidHash: "$2a$10$invalidhashdata", + password: "password123", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := hasher.Compare(tt.invalidHash, tt.password) + if !tt.expectError && err != nil { + t.Fatalf("Compare() unexpected error = %v", err) + } + if tt.expectError && err == nil { + t.Fatal("Compare() expected error but got nil") + } + }) + } +} + +func TestBcryptHasher_HashGeneratesDifferentHashes(t *testing.T) { + hasher := NewBcryptHasher() + password := "samePassword123" + + hash1, err := hasher.Hash(password) + if err != nil { + t.Fatalf("Hash() error = %v", err) + } + + hash2, err := hasher.Hash(password) + if err != nil { + t.Fatalf("Hash() error = %v", err) + } + + if hash1 == hash2 { + t.Fatal("Hash() generated identical hashes for same password (should use salt)") + } + + if err := hasher.Compare(hash1, password); err != nil { + t.Fatalf("First hash doesn't match password: %v", err) + } + + if err := hasher.Compare(hash2, password); err != nil { + t.Fatalf("Second hash doesn't match password: %v", err) + } +} diff --git a/internal/infrastructure/http/integration_test.go b/internal/infrastructure/http/integration_test.go index 8dbdef3..08a182b 100644 --- a/internal/infrastructure/http/integration_test.go +++ b/internal/infrastructure/http/integration_test.go @@ -42,12 +42,12 @@ func setupTestServer(t *testing.T) *TestServer { refreshTokenRepo := sqlite.NewRefreshTokenRepository(db) passwordResetTokenRepo := sqlite.NewPasswordResetTokenRepository(db) - registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, nil, "", "open") + registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, nil, "", "open", false) loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher) refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo) revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo) revokeAllTokensHandler := commands.NewRevokeAllTokensHandler(refreshTokenRepo) - verifyEmailHandler := commands.NewVerifyEmailHandler(userRepo) + verifyEmailHandler := commands.NewVerifyEmailHandler(userRepo, nil, false) resendVerificationEmailHandler := commands.NewResendVerificationEmailHandler(userRepo, nil, "") requestPasswordResetHandler := commands.NewRequestPasswordResetHandler(userRepo, passwordResetTokenRepo, nil, "") resetPasswordHandler := commands.NewResetPasswordHandler(userRepo, passwordResetTokenRepo, passwordHasher) diff --git a/internal/shared/errors/errors.go b/internal/shared/errors/errors.go index 31c849b..12ce73c 100644 --- a/internal/shared/errors/errors.go +++ b/internal/shared/errors/errors.go @@ -3,11 +3,11 @@ package errors import "errors" var ( - ErrNotFound = errors.New("resource not found") - ErrAlreadyExists = errors.New("resource already exists") - ErrInvalidInput = errors.New("invalid input") - ErrUnauthorized = errors.New("unauthorized") - ErrInvalidCredentials = errors.New("invalid credentials") - ErrEmailNotVerified = errors.New("email not verified") - ErrRegistrationClosed = errors.New("registration is closed") + ErrNotFound = errors.New("resource not found") + ErrAlreadyExists = errors.New("resource already exists") + ErrInvalidInput = errors.New("invalid input") + ErrUnauthorized = errors.New("unauthorized") + ErrInvalidCredentials = errors.New("invalid credentials") + ErrEmailNotVerified = errors.New("email not verified") + ErrRegistrationClosed = errors.New("registration is closed") )