Files
goyco/internal/handlers/auth_handler_test.go
2025-11-23 14:20:09 +01:00

1551 lines
47 KiB
Go

package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"goyco/internal/config"
"goyco/internal/database"
"goyco/internal/middleware"
"goyco/internal/repositories"
"goyco/internal/services"
"goyco/internal/testutils"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
func newAuthHandler(repo repositories.UserRepository) *AuthHandler {
return newAuthHandlerWithSender(repo, &testutils.EmailSenderStub{})
}
func newAuthHandlerWithSender(repo repositories.UserRepository, sender services.EmailSender) *AuthHandler {
cfg := &config.Config{
JWT: config.JWTConfig{Secret: "secret", Expiration: 1},
App: config.AppConfig{BaseURL: "https://test.example.com"},
}
mockRefreshRepo := &mockRefreshTokenRepository{}
authService, err := services.NewAuthFacadeForTest(cfg, repo, nil, nil, mockRefreshRepo, sender)
if err != nil {
panic(fmt.Sprintf("Failed to create auth service: %v", err))
}
return NewAuthHandler(authService, repo)
}
type mockAuthService struct {
loginFunc func(string, string) (*services.AuthResult, error)
registerFunc func(string, string, string) (*services.RegistrationResult, error)
confirmEmailFunc func(string) (*database.User, error)
resendVerificationFunc func(string) error
requestPasswordResetFunc func(string) error
resetPasswordFunc func(string, string) error
updateEmailFunc func(uint, string) (*database.User, error)
updateUsernameFunc func(uint, string) (*database.User, error)
updatePasswordFunc func(uint, string, string) (*database.User, error)
deleteAccountFunc func(uint) error
confirmAccountDeletionWithPostsFunc func(string, bool) error
refreshAccessTokenFunc func(string) (*services.AuthResult, error)
revokeRefreshTokenFunc func(string) error
revokeAllUserTokensFunc func(uint) error
invalidateAllSessionsFunc func(uint) error
getAdminEmailFunc func() string
verifyTokenFunc func(string) (uint, error)
getUserIDFromDeletionTokenFunc func(string) (uint, error)
userHasPostsFunc func(uint) (bool, int64, error)
}
func (m *mockAuthService) Login(username, password string) (*services.AuthResult, error) {
if m.loginFunc != nil {
return m.loginFunc(username, password)
}
return &services.AuthResult{
User: &database.User{ID: 1, Username: username},
AccessToken: "access_token",
RefreshToken: "refresh_token",
}, nil
}
func (m *mockAuthService) Register(username, email, password string) (*services.RegistrationResult, error) {
if m.registerFunc != nil {
return m.registerFunc(username, email, password)
}
return &services.RegistrationResult{
User: &database.User{ID: 1, Username: username, Email: email},
VerificationSent: true,
}, nil
}
func (m *mockAuthService) ConfirmEmail(token string) (*database.User, error) {
if m.confirmEmailFunc != nil {
return m.confirmEmailFunc(token)
}
return &database.User{ID: 1}, nil
}
func (m *mockAuthService) ResendVerificationEmail(email string) error {
if m.resendVerificationFunc != nil {
return m.resendVerificationFunc(email)
}
return nil
}
func (m *mockAuthService) RequestPasswordReset(usernameOrEmail string) error {
if m.requestPasswordResetFunc != nil {
return m.requestPasswordResetFunc(usernameOrEmail)
}
return nil
}
func (m *mockAuthService) ResetPassword(token, newPassword string) error {
if m.resetPasswordFunc != nil {
return m.resetPasswordFunc(token, newPassword)
}
return nil
}
func (m *mockAuthService) UpdateEmail(userID uint, email string) (*database.User, error) {
if m.updateEmailFunc != nil {
return m.updateEmailFunc(userID, email)
}
return &database.User{ID: userID, Email: email}, nil
}
func (m *mockAuthService) UpdateUsername(userID uint, username string) (*database.User, error) {
if m.updateUsernameFunc != nil {
return m.updateUsernameFunc(userID, username)
}
return &database.User{ID: userID, Username: username}, nil
}
func (m *mockAuthService) UpdatePassword(userID uint, currentPassword, newPassword string) (*database.User, error) {
if m.updatePasswordFunc != nil {
return m.updatePasswordFunc(userID, currentPassword, newPassword)
}
return &database.User{ID: userID}, nil
}
func (m *mockAuthService) RequestAccountDeletion(userID uint) error {
if m.deleteAccountFunc != nil {
return m.deleteAccountFunc(userID)
}
return nil
}
func (m *mockAuthService) ConfirmAccountDeletionWithPosts(token string, deletePosts bool) error {
if m.confirmAccountDeletionWithPostsFunc != nil {
return m.confirmAccountDeletionWithPostsFunc(token, deletePosts)
}
return nil
}
func (m *mockAuthService) RefreshAccessToken(refreshToken string) (*services.AuthResult, error) {
if m.refreshAccessTokenFunc != nil {
return m.refreshAccessTokenFunc(refreshToken)
}
return &services.AuthResult{
User: &database.User{ID: 1, Username: "testuser"},
AccessToken: "new_access_token",
RefreshToken: refreshToken,
}, nil
}
func (m *mockAuthService) RevokeRefreshToken(refreshToken string) error {
if m.revokeRefreshTokenFunc != nil {
return m.revokeRefreshTokenFunc(refreshToken)
}
return nil
}
func (m *mockAuthService) RevokeAllUserTokens(userID uint) error {
if m.revokeAllUserTokensFunc != nil {
return m.revokeAllUserTokensFunc(userID)
}
return nil
}
func (m *mockAuthService) InvalidateAllSessions(userID uint) error {
if m.invalidateAllSessionsFunc != nil {
return m.invalidateAllSessionsFunc(userID)
}
return nil
}
func (m *mockAuthService) GetAdminEmail() string {
if m.getAdminEmailFunc != nil {
return m.getAdminEmailFunc()
}
return "admin@example.com"
}
func (m *mockAuthService) VerifyToken(tokenString string) (uint, error) {
if m.verifyTokenFunc != nil {
return m.verifyTokenFunc(tokenString)
}
return 1, nil
}
func (m *mockAuthService) GetUserIDFromDeletionToken(token string) (uint, error) {
if m.getUserIDFromDeletionTokenFunc != nil {
return m.getUserIDFromDeletionTokenFunc(token)
}
return 1, nil
}
func (m *mockAuthService) UserHasPosts(userID uint) (bool, int64, error) {
if m.userHasPostsFunc != nil {
return m.userHasPostsFunc(userID)
}
return false, 0, nil
}
type mockRefreshTokenRepository struct{}
func (m *mockRefreshTokenRepository) Create(token *database.RefreshToken) error {
return nil
}
func (m *mockRefreshTokenRepository) GetByTokenHash(tokenHash string) (*database.RefreshToken, error) {
return nil, gorm.ErrRecordNotFound
}
func (m *mockRefreshTokenRepository) DeleteByUserID(userID uint) error {
return nil
}
func (m *mockRefreshTokenRepository) DeleteExpired() error {
return nil
}
func (m *mockRefreshTokenRepository) DeleteByID(id uint) error {
return nil
}
func (m *mockRefreshTokenRepository) GetByUserID(userID uint) ([]database.RefreshToken, error) {
return []database.RefreshToken{}, nil
}
func (m *mockRefreshTokenRepository) CountByUserID(userID uint) (int64, error) {
return 0, nil
}
func newMockAuthHandler(repo repositories.UserRepository, mockService *mockAuthService) *AuthHandler {
return &AuthHandler{
authService: mockService,
userRepo: repo,
}
}
func TestAuthHandlerLoginSuccess(t *testing.T) {
hashed, _ := bcrypt.GenerateFromPassword([]byte("Password123!"), bcrypt.DefaultCost)
repo := &testutils.UserRepositoryStub{
GetByUsernameFn: func(username string) (*database.User, error) {
return &database.User{ID: 1, Username: username, Password: string(hashed), EmailVerified: true}, nil
},
}
handler := newAuthHandler(repo)
bodyStr := `{"username":"user","password":"Password123!"}`
request := createLoginRequest(bodyStr)
recorder := httptest.NewRecorder()
handler.Login(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
var resp AuthResponse
if err := json.NewDecoder(recorder.Body).Decode(&resp); err != nil {
t.Fatalf("decode error: %v", err)
}
if !resp.Success || resp.Data == nil {
t.Fatalf("expected success response, got %+v", resp)
}
}
func TestAuthHandlerLoginErrors(t *testing.T) {
handler := newAuthHandler(&testutils.UserRepositoryStub{})
recorder := httptest.NewRecorder()
request := createLoginRequest("invalid")
handler.Login(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
recorder = httptest.NewRecorder()
request = createLoginRequest(`{"username":" ","password":""}`)
handler.Login(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
recorder = httptest.NewRecorder()
request = createLoginRequest(`{"username":"user","password":"WrongPass123!"}`)
handler.Login(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusUnauthorized)
hashed, _ := bcrypt.GenerateFromPassword([]byte("Password123!"), bcrypt.DefaultCost)
repo := &testutils.UserRepositoryStub{GetByUsernameFn: func(string) (*database.User, error) {
return &database.User{ID: 1, Username: "user", Password: string(hashed), EmailVerified: false}, nil
}}
handler = newAuthHandler(repo)
recorder = httptest.NewRecorder()
request = createLoginRequest(`{"username":"user","password":"Password123!"}`)
handler.Login(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusForbidden)
repo = &testutils.UserRepositoryStub{GetByUsernameFn: func(string) (*database.User, error) {
return nil, errors.New("database offline")
}}
handler = newAuthHandler(repo)
recorder = httptest.NewRecorder()
request = createLoginRequest(`{"username":"user","password":"Password123!"}`)
handler.Login(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusInternalServerError)
if !strings.Contains(recorder.Body.String(), "Authentication failed") {
t.Fatalf("expected response to include generic error message, got %q", recorder.Body.String())
}
}
func TestAuthHandlerRegisterSuccess(t *testing.T) {
repo := &testutils.UserRepositoryStub{
GetByUsernameFn: func(string) (*database.User, error) {
return nil, gorm.ErrRecordNotFound
},
CreateFn: func(user *database.User) error {
user.ID = 1
return nil
},
}
sent := false
handler := newAuthHandlerWithSender(repo, &testutils.EmailSenderStub{SendFn: func(to, subject, body string) error {
sent = true
return nil
}})
request := createRegisterRequest(`{"username":"newuser","email":"new@example.com","password":"Password123!"}`)
recorder := httptest.NewRecorder()
handler.Register(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusCreated)
var resp AuthResponse
_ = json.NewDecoder(recorder.Body).Decode(&resp)
if !resp.Success {
t.Fatalf("expected success response, got %v", resp)
}
if !sent {
t.Fatalf("expected verification email to be sent")
}
}
func TestAuthHandlerRegisterErrors(t *testing.T) {
repo := &testutils.UserRepositoryStub{}
handler := newAuthHandler(repo)
recorder := httptest.NewRecorder()
request := createRegisterRequest("invalid")
handler.Register(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
recorder = httptest.NewRecorder()
request = createRegisterRequest(`{"username":"","email":"","password":""}`)
handler.Register(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
repo = &testutils.UserRepositoryStub{GetByUsernameFn: func(string) (*database.User, error) {
return &database.User{ID: 1}, nil
}}
handler = newAuthHandler(repo)
recorder = httptest.NewRecorder()
request = createRegisterRequest(`{"username":"new","email":"taken@example.com","password":"Password123!"}`)
handler.Register(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusConflict)
repo = &testutils.UserRepositoryStub{
GetByUsernameFn: func(string) (*database.User, error) {
return nil, gorm.ErrRecordNotFound
},
GetByEmailFn: func(string) (*database.User, error) {
return &database.User{ID: 2}, nil
},
}
handler = newAuthHandler(repo)
recorder = httptest.NewRecorder()
request = createRegisterRequest(`{"username":"another","email":"taken@example.com","password":"Password123!"}`)
handler.Register(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusConflict)
}
func TestAuthHandlerMe(t *testing.T) {
repo := &testutils.UserRepositoryStub{
GetByIDFn: func(id uint) (*database.User, error) {
return &database.User{
ID: id,
Username: "user",
Email: "user@example.com",
Password: "secret",
EmailVerified: true,
}, nil
},
}
handler := newAuthHandler(repo)
request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
recorder := httptest.NewRecorder()
handler.Me(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusUnauthorized)
request = httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
ctx := context.WithValue(request.Context(), middleware.UserIDKey, uint(7))
request = request.WithContext(ctx)
recorder = httptest.NewRecorder()
handler.Me(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
var resp AuthResponse
if err := json.NewDecoder(recorder.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
user, ok := resp.Data.(map[string]any)
if !ok {
t.Fatalf("Expected user to be map[string]any, got %T", resp.Data)
}
if _, ok := user["password"]; ok {
t.Fatalf("expected password field to be omitted, got %+v", user)
}
}
func TestAuthHandlerConfirmEmail(t *testing.T) {
repo := &testutils.UserRepositoryStub{}
handler := newAuthHandler(repo)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/auth/confirm", nil)
handler.ConfirmEmail(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
verified := false
repo = &testutils.UserRepositoryStub{
GetByVerificationFn: func(token string) (*database.User, error) {
if token == "" {
t.Fatalf("expected hashed token to be provided")
}
return &database.User{ID: 3, Username: "user", EmailVerified: false}, nil
},
UpdateFn: func(u *database.User) error {
verified = u.EmailVerified
return nil
},
}
handler = newAuthHandler(repo)
recorder = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodGet, "/api/auth/confirm?token=abc123", nil)
handler.ConfirmEmail(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
if !verified {
t.Fatalf("expected update to mark user as verified")
}
}
func TestAuthHandlerRequestPasswordReset(t *testing.T) {
repo := &testutils.UserRepositoryStub{
GetByEmailFn: func(email string) (*database.User, error) {
if email == "user@example.com" {
return &database.User{ID: 1, Username: "user", Email: "user@example.com"}, nil
}
return nil, gorm.ErrRecordNotFound
},
}
handler := newAuthHandlerWithSender(repo, &testutils.EmailSenderStub{SendFn: func(to, subject, body string) error {
return nil
}})
recorder := httptest.NewRecorder()
request := createForgotPasswordRequest(`{"username_or_email":"user@example.com"}`)
handler.RequestPasswordReset(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
repo = &testutils.UserRepositoryStub{
GetByUsernameFn: func(username string) (*database.User, error) {
if username == "user" {
return &database.User{ID: 1, Username: "user", Email: "user@example.com"}, nil
}
return nil, gorm.ErrRecordNotFound
},
}
handler = newAuthHandlerWithSender(repo, &testutils.EmailSenderStub{SendFn: func(to, subject, body string) error {
return nil
}})
recorder = httptest.NewRecorder()
request = createForgotPasswordRequest(`{"username_or_email":"user"}`)
handler.RequestPasswordReset(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
recorder = httptest.NewRecorder()
request = createForgotPasswordRequest(`{"username_or_email":""}`)
handler.RequestPasswordReset(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
recorder = httptest.NewRecorder()
request = createForgotPasswordRequest(`invalid json`)
handler.RequestPasswordReset(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
}
func TestAuthHandlerResetPassword(t *testing.T) {
repo := &testutils.UserRepositoryStub{}
handler := newAuthHandler(repo)
recorder := httptest.NewRecorder()
request := createResetPasswordRequest(`{"new_password":"NewPassword123!"}`)
handler.ResetPassword(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
recorder = httptest.NewRecorder()
request = createResetPasswordRequest(`{"token":"valid_token"}`)
handler.ResetPassword(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
recorder = httptest.NewRecorder()
request = createResetPasswordRequest(`{"token":"valid_token","new_password":"short"}`)
handler.ResetPassword(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
recorder = httptest.NewRecorder()
request = createResetPasswordRequest(`invalid json`)
handler.ResetPassword(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
}
func TestAuthHandlerResetPasswordServiceOutcomes(t *testing.T) {
repo := &testutils.UserRepositoryStub{}
tests := []struct {
name string
setup func(*mockAuthService)
expectedStatus int
expectedError string
expectedMsg string
}{
{
name: "expired token",
setup: func(ms *mockAuthService) {
ms.resetPasswordFunc = func(token, newPassword string) error {
return fmt.Errorf("token expired")
}
},
expectedStatus: http.StatusBadRequest,
expectedError: "The reset link has expired",
},
{
name: "invalid token",
setup: func(ms *mockAuthService) {
ms.resetPasswordFunc = func(token, newPassword string) error {
return fmt.Errorf("token invalid")
}
},
expectedStatus: http.StatusBadRequest,
expectedError: "The reset link is invalid",
},
{
name: "unexpected error",
setup: func(ms *mockAuthService) {
ms.resetPasswordFunc = func(token, newPassword string) error {
return fmt.Errorf("smtp outage")
}
},
expectedStatus: http.StatusInternalServerError,
expectedError: "Unable to reset password",
},
{
name: "success",
setup: func(ms *mockAuthService) {
ms.resetPasswordFunc = func(token, newPassword string) error {
return nil
}
},
expectedStatus: http.StatusOK,
expectedMsg: "Password reset successfully",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockService := &mockAuthService{}
if tt.setup != nil {
tt.setup(mockService)
}
handler := newMockAuthHandler(repo, mockService)
request := createResetPasswordRequest(`{"token":"abc","new_password":"Password123!"}`)
recorder := httptest.NewRecorder()
handler.ResetPassword(recorder, request)
testutils.AssertHTTPStatus(t, recorder, tt.expectedStatus)
var resp AuthResponse
_ = json.NewDecoder(recorder.Body).Decode(&resp)
if tt.expectedError != "" {
if !strings.Contains(resp.Error, tt.expectedError) {
t.Fatalf("expected error to contain %q, got %q", tt.expectedError, resp.Error)
}
return
}
if resp.Message == "" || !strings.Contains(resp.Message, tt.expectedMsg) {
t.Fatalf("expected success message containing %q, got %q", tt.expectedMsg, resp.Message)
}
if !resp.Success {
t.Fatalf("expected success response")
}
})
}
}
func TestAuthHandlerUpdateEmail(t *testing.T) {
tests := []struct {
name string
requestBody string
userID uint
mockSetup func(*testutils.UserRepositoryStub)
expectedStatus int
expectedError string
}{
{
name: "valid email update",
requestBody: `{"email": "new@example.com"}`,
userID: 1,
mockSetup: func(repo *testutils.UserRepositoryStub) {
repo.GetByIDFn = func(id uint) (*database.User, error) {
return &database.User{ID: id, Email: "old@example.com"}, nil
}
repo.UpdateFn = func(user *database.User) error { return nil }
},
expectedStatus: http.StatusOK,
},
{
name: "missing user context",
requestBody: `{"email": "new@example.com"}`,
userID: 0,
mockSetup: func(repo *testutils.UserRepositoryStub) {},
expectedStatus: http.StatusUnauthorized,
expectedError: "Authentication required",
},
{
name: "invalid JSON",
requestBody: `invalid json`,
userID: 1,
mockSetup: func(repo *testutils.UserRepositoryStub) {},
expectedStatus: http.StatusBadRequest,
expectedError: "Invalid request",
},
{
name: "empty email",
requestBody: `{"email": ""}`,
userID: 1,
mockSetup: func(repo *testutils.UserRepositoryStub) {},
expectedStatus: http.StatusBadRequest,
expectedError: "Email is required",
},
{
name: "email already taken",
requestBody: `{"email": "taken@example.com"}`,
userID: 1,
mockSetup: func(repo *testutils.UserRepositoryStub) {
repo.GetByIDFn = func(id uint) (*database.User, error) {
return &database.User{ID: id, Email: "old@example.com"}, nil
}
},
expectedStatus: http.StatusConflict,
expectedError: "That email is already in use",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := &testutils.UserRepositoryStub{}
tt.mockSetup(repo)
mockService := &mockAuthService{}
if tt.name == "email already taken" {
mockService.updateEmailFunc = func(userID uint, email string) (*database.User, error) {
return nil, services.ErrEmailTaken
}
}
handler := newMockAuthHandler(repo, mockService)
request := createUpdateEmailRequest(tt.requestBody)
if tt.userID > 0 {
ctx := context.WithValue(request.Context(), middleware.UserIDKey, tt.userID)
request = request.WithContext(ctx)
}
recorder := httptest.NewRecorder()
handler.UpdateEmail(recorder, request)
testutils.AssertHTTPStatus(t, recorder, tt.expectedStatus)
if tt.expectedError != "" {
var resp AuthResponse
json.NewDecoder(recorder.Body).Decode(&resp)
if !strings.Contains(resp.Error, tt.expectedError) {
t.Fatalf("expected error to contain %q, got %q", tt.expectedError, resp.Error)
}
}
})
}
}
func TestAuthHandlerUpdateUsername(t *testing.T) {
tests := []struct {
name string
requestBody string
userID uint
mockSetup func(*testutils.UserRepositoryStub)
expectedStatus int
expectedError string
}{
{
name: "valid username update",
requestBody: `{"username": "newusername"}`,
userID: 1,
mockSetup: func(repo *testutils.UserRepositoryStub) {
repo.GetByIDFn = func(id uint) (*database.User, error) {
return &database.User{ID: id, Username: "oldusername"}, nil
}
repo.UpdateFn = func(user *database.User) error { return nil }
},
expectedStatus: http.StatusOK,
},
{
name: "missing user context",
requestBody: `{"username": "newusername"}`,
userID: 0,
mockSetup: func(repo *testutils.UserRepositoryStub) {},
expectedStatus: http.StatusUnauthorized,
expectedError: "Authentication required",
},
{
name: "empty username",
requestBody: `{"username": ""}`,
userID: 1,
mockSetup: func(repo *testutils.UserRepositoryStub) {},
expectedStatus: http.StatusBadRequest,
expectedError: "Username is required",
},
{
name: "username already taken",
requestBody: `{"username": "takenuser"}`,
userID: 1,
mockSetup: func(repo *testutils.UserRepositoryStub) {
repo.GetByIDFn = func(id uint) (*database.User, error) {
return &database.User{ID: id, Username: "oldusername"}, nil
}
},
expectedStatus: http.StatusConflict,
expectedError: "That username is already taken",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := &testutils.UserRepositoryStub{}
tt.mockSetup(repo)
mockService := &mockAuthService{}
if tt.name == "username already taken" {
mockService.updateUsernameFunc = func(userID uint, username string) (*database.User, error) {
return nil, services.ErrUsernameTaken
}
}
handler := newMockAuthHandler(repo, mockService)
request := createUpdateUsernameRequest(tt.requestBody)
if tt.userID > 0 {
ctx := context.WithValue(request.Context(), middleware.UserIDKey, tt.userID)
request = request.WithContext(ctx)
}
recorder := httptest.NewRecorder()
handler.UpdateUsername(recorder, request)
testutils.AssertHTTPStatus(t, recorder, tt.expectedStatus)
if tt.expectedError != "" {
var resp AuthResponse
json.NewDecoder(recorder.Body).Decode(&resp)
if !strings.Contains(resp.Error, tt.expectedError) {
t.Fatalf("expected error to contain %q, got %q", tt.expectedError, resp.Error)
}
}
})
}
}
func TestAuthHandlerUpdatePassword(t *testing.T) {
tests := []struct {
name string
requestBody string
userID uint
mockSetup func(*testutils.UserRepositoryStub)
expectedStatus int
expectedError string
}{
{
name: "valid password update",
requestBody: `{"current_password": "OldPass123!", "new_password": "NewPassword123!"}`,
userID: 1,
mockSetup: func(repo *testutils.UserRepositoryStub) {
repo.GetByIDFn = func(id uint) (*database.User, error) {
hashed, _ := bcrypt.GenerateFromPassword([]byte("OldPass123!"), bcrypt.DefaultCost)
return &database.User{ID: id, Password: string(hashed)}, nil
}
repo.UpdateFn = func(user *database.User) error { return nil }
},
expectedStatus: http.StatusOK,
},
{
name: "missing user context",
requestBody: `{"current_password": "OldPass123!", "new_password": "NewPassword123!"}`,
userID: 0,
mockSetup: func(repo *testutils.UserRepositoryStub) {},
expectedStatus: http.StatusUnauthorized,
expectedError: "Authentication required",
},
{
name: "empty current password",
requestBody: `{"current_password": "", "new_password": "NewPassword123!"}`,
userID: 1,
mockSetup: func(repo *testutils.UserRepositoryStub) {},
expectedStatus: http.StatusBadRequest,
expectedError: "Current password is required",
},
{
name: "empty new password",
requestBody: `{"current_password": "OldPass123!", "new_password": ""}`,
userID: 1,
mockSetup: func(repo *testutils.UserRepositoryStub) {},
expectedStatus: http.StatusBadRequest,
expectedError: "Password is required",
},
{
name: "short new password",
requestBody: `{"current_password": "OldPass123!", "new_password": "short"}`,
userID: 1,
mockSetup: func(repo *testutils.UserRepositoryStub) {},
expectedStatus: http.StatusBadRequest,
expectedError: "Password must be at least 8 characters long",
},
{
name: "incorrect current password",
requestBody: `{"current_password": "WrongPass123!", "new_password": "NewPassword123!"}`,
userID: 1,
mockSetup: func(repo *testutils.UserRepositoryStub) {
repo.GetByIDFn = func(id uint) (*database.User, error) {
hashed, _ := bcrypt.GenerateFromPassword([]byte("CorrectPass123!"), bcrypt.DefaultCost)
return &database.User{ID: id, Password: string(hashed)}, nil
}
},
expectedStatus: http.StatusBadRequest,
expectedError: "Current password is incorrect",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := &testutils.UserRepositoryStub{}
tt.mockSetup(repo)
handler := newAuthHandler(repo)
request := createUpdatePasswordRequest(tt.requestBody)
if tt.userID > 0 {
ctx := context.WithValue(request.Context(), middleware.UserIDKey, tt.userID)
request = request.WithContext(ctx)
}
recorder := httptest.NewRecorder()
handler.UpdatePassword(recorder, request)
testutils.AssertHTTPStatus(t, recorder, tt.expectedStatus)
if tt.expectedError != "" {
var resp AuthResponse
json.NewDecoder(recorder.Body).Decode(&resp)
if !strings.Contains(resp.Error, tt.expectedError) {
t.Fatalf("expected error to contain %q, got %q", tt.expectedError, resp.Error)
}
}
})
}
}
func TestAuthHandlerDeleteAccount(t *testing.T) {
tests := []struct {
name string
userID uint
mockSetup func(*testutils.UserRepositoryStub)
expectedStatus int
expectedError string
}{
{
name: "valid account deletion request",
userID: 1,
mockSetup: func(repo *testutils.UserRepositoryStub) {
repo.GetByIDFn = func(id uint) (*database.User, error) {
return &database.User{ID: id, Username: "user", Email: "user@example.com"}, nil
}
},
expectedStatus: http.StatusOK,
},
{
name: "missing user context",
userID: 0,
mockSetup: func(repo *testutils.UserRepositoryStub) {},
expectedStatus: http.StatusUnauthorized,
expectedError: "Authentication required",
},
{
name: "email service unavailable",
userID: 1,
mockSetup: func(repo *testutils.UserRepositoryStub) {
repo.GetByIDFn = func(id uint) (*database.User, error) {
return &database.User{ID: id, Username: "user", Email: "user@example.com"}, nil
}
},
expectedStatus: http.StatusServiceUnavailable,
expectedError: "Account deletion isn't available right now",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := &testutils.UserRepositoryStub{}
tt.mockSetup(repo)
mockService := &mockAuthService{}
if tt.name == "email service unavailable" {
mockService.deleteAccountFunc = func(userID uint) error {
return services.ErrEmailSenderUnavailable
}
}
handler := newMockAuthHandler(repo, mockService)
request := httptest.NewRequest(http.MethodDelete, "/api/auth/account", nil)
if tt.userID > 0 {
ctx := context.WithValue(request.Context(), middleware.UserIDKey, tt.userID)
request = request.WithContext(ctx)
}
recorder := httptest.NewRecorder()
handler.DeleteAccount(recorder, request)
testutils.AssertHTTPStatus(t, recorder, tt.expectedStatus)
if tt.expectedError != "" {
var resp AuthResponse
json.NewDecoder(recorder.Body).Decode(&resp)
if !strings.Contains(resp.Error, tt.expectedError) {
t.Fatalf("expected error to contain %q, got %q", tt.expectedError, resp.Error)
}
}
})
}
}
func TestAuthHandlerResendVerificationEmail(t *testing.T) {
makeRequest := func(body string, setup func(*mockAuthService)) (*httptest.ResponseRecorder, AuthResponse) {
request := createResendVerificationRequest(body)
repo := &testutils.UserRepositoryStub{}
mockService := &mockAuthService{}
if setup != nil {
setup(mockService)
}
handler := newMockAuthHandler(repo, mockService)
recorder := httptest.NewRecorder()
handler.ResendVerificationEmail(recorder, request)
var resp AuthResponse
_ = json.NewDecoder(recorder.Body).Decode(&resp)
return recorder, resp
}
tests := []struct {
name string
body string
setup func(*mockAuthService)
expectedStatus int
expectedError string
expectedMsg string
}{
{
name: "invalid json",
body: "not-json",
expectedStatus: http.StatusBadRequest,
expectedError: "Invalid request",
},
{
name: "missing email",
body: `{}`,
expectedStatus: http.StatusBadRequest,
expectedError: "Email address is required",
},
{
name: "account not found",
body: `{"email":"user@example.com"}`,
setup: func(ms *mockAuthService) {
ms.resendVerificationFunc = func(email string) error {
return services.ErrInvalidCredentials
}
},
expectedStatus: http.StatusNotFound,
expectedError: "No account found with this email address",
},
{
name: "invalid email format",
body: `{"email":"user@example.com"}`,
setup: func(ms *mockAuthService) {
ms.resendVerificationFunc = func(email string) error {
return services.ErrInvalidEmail
}
},
expectedStatus: http.StatusBadRequest,
expectedError: "Invalid email address format",
},
{
name: "email already verified",
body: `{"email":"user@example.com"}`,
setup: func(ms *mockAuthService) {
ms.resendVerificationFunc = func(email string) error {
return fmt.Errorf("email already verified")
}
},
expectedStatus: http.StatusConflict,
expectedError: "This email address is already verified",
},
{
name: "rate limited",
body: `{"email":"user@example.com"}`,
setup: func(ms *mockAuthService) {
ms.resendVerificationFunc = func(email string) error {
return fmt.Errorf("verification email sent recently, please wait before requesting another")
}
},
expectedStatus: http.StatusTooManyRequests,
expectedError: "Please wait 5 minutes before requesting another verification email",
},
{
name: "email service unavailable",
body: `{"email":"user@example.com"}`,
setup: func(ms *mockAuthService) {
ms.resendVerificationFunc = func(email string) error {
return services.ErrEmailSenderUnavailable
}
},
expectedStatus: http.StatusServiceUnavailable,
expectedError: "We couldn't send the verification email. Try again later.",
},
{
name: "unexpected error",
body: `{"email":"user@example.com"}`,
setup: func(ms *mockAuthService) {
ms.resendVerificationFunc = func(email string) error {
return fmt.Errorf("smtp failed")
}
},
expectedStatus: http.StatusInternalServerError,
expectedError: "Unable to resend verification email",
},
{
name: "success",
body: `{"email":"user@example.com"}`,
expectedStatus: http.StatusOK,
expectedMsg: "Verification email sent successfully",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rr, resp := makeRequest(tt.body, tt.setup)
if rr.Code != tt.expectedStatus {
t.Fatalf("expected status %d, got %d", tt.expectedStatus, rr.Code)
}
if tt.expectedError != "" {
if !strings.Contains(resp.Error, tt.expectedError) {
t.Fatalf("expected error to contain %q, got %q", tt.expectedError, resp.Error)
}
return
}
if resp.Message != tt.expectedMsg {
t.Fatalf("expected message %q, got %q", tt.expectedMsg, resp.Message)
}
if !resp.Success {
t.Fatalf("expected success response")
}
})
}
}
func TestAuthHandlerConfirmAccountDeletion(t *testing.T) {
repo := &testutils.UserRepositoryStub{}
tests := []struct {
name string
body string
setup func(*mockAuthService)
expectedStatus int
expectedError string
expectedMessage string
expectedSuccess bool
expectedPostsFlag *bool
}{
{
name: "invalid json",
body: "not-json",
expectedStatus: http.StatusBadRequest,
expectedError: "Invalid request",
},
{
name: "missing token",
body: `{}`,
expectedStatus: http.StatusBadRequest,
expectedError: "Deletion token is required",
},
{
name: "invalid token from service",
body: `{"token":"abc"}`,
setup: func(ms *mockAuthService) {
ms.confirmAccountDeletionWithPostsFunc = func(token string, deletePosts bool) error {
return services.ErrInvalidDeletionToken
}
},
expectedStatus: http.StatusBadRequest,
expectedError: "This deletion link is invalid or has expired.",
},
{
name: "email sender unavailable",
body: `{"token":"abc"}`,
setup: func(ms *mockAuthService) {
ms.confirmAccountDeletionWithPostsFunc = func(token string, deletePosts bool) error {
return services.ErrEmailSenderUnavailable
}
},
expectedStatus: http.StatusServiceUnavailable,
expectedError: "Account deletion isn't available right now because email delivery is disabled.",
},
{
name: "deletion succeeds but notification fails",
body: `{"token":"abc","delete_posts":true}`,
setup: func(ms *mockAuthService) {
ms.confirmAccountDeletionWithPostsFunc = func(token string, deletePosts bool) error {
return services.ErrDeletionEmailFailed
}
},
expectedStatus: http.StatusOK,
expectedMessage: "Your account has been deleted, but we couldn't send the confirmation email.",
expectedSuccess: true,
expectedPostsFlag: func() *bool { b := true; return &b }(),
},
{
name: "successful confirmation",
body: `{"token":"abc","delete_posts":false}`,
setup: func(ms *mockAuthService) {
ms.confirmAccountDeletionWithPostsFunc = func(token string, deletePosts bool) error {
if token != "abc" || deletePosts {
return fmt.Errorf("unexpected arguments")
}
return nil
}
},
expectedStatus: http.StatusOK,
expectedMessage: "Your account has been deleted.",
expectedSuccess: true,
expectedPostsFlag: func() *bool { b := false; return &b }(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockService := &mockAuthService{}
if tt.setup != nil {
tt.setup(mockService)
}
handler := newMockAuthHandler(repo, mockService)
request := createConfirmAccountDeletionRequest(tt.body)
recorder := httptest.NewRecorder()
handler.ConfirmAccountDeletion(recorder, request)
testutils.AssertHTTPStatus(t, recorder, tt.expectedStatus)
var resp AuthResponse
_ = json.NewDecoder(recorder.Body).Decode(&resp)
if tt.expectedError != "" {
if !strings.Contains(resp.Error, tt.expectedError) {
t.Fatalf("expected error to contain %q, got %q", tt.expectedError, resp.Error)
}
return
}
if tt.expectedSuccess != resp.Success {
t.Fatalf("expected success=%t, got %t", tt.expectedSuccess, resp.Success)
}
if tt.expectedMessage != "" && tt.expectedMessage != resp.Message {
t.Fatalf("expected message %q, got %q", tt.expectedMessage, resp.Message)
}
if tt.expectedPostsFlag != nil {
data, ok := resp.Data.(map[string]any)
if !ok {
t.Fatalf("expected data map, got %#v", resp.Data)
}
postsDeleted, ok := data["posts_deleted"].(bool)
if !ok || postsDeleted != *tt.expectedPostsFlag {
t.Fatalf("expected posts_deleted=%t, got %#v", *tt.expectedPostsFlag, data["posts_deleted"])
}
}
})
}
}
func TestAuthHandlerLogout(t *testing.T) {
handler := newAuthHandler(&testutils.UserRepositoryStub{})
request := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
recorder := httptest.NewRecorder()
handler.Logout(recorder, request)
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
var resp AuthResponse
json.NewDecoder(recorder.Body).Decode(&resp)
if !resp.Success || resp.Message != "Logged out successfully" {
t.Fatalf("expected success logout response, got %+v", resp)
}
}
func TestAuthHandler_EdgeCases(t *testing.T) {
authService := &mockAuthService{}
userRepo := testutils.NewUserRepositoryStub()
handler := NewAuthHandler(authService, userRepo)
t.Run("Login with empty username", func(t *testing.T) {
body := bytes.NewBufferString(`{"username":"","password":"Password123!"}`)
req := httptest.NewRequest("POST", "/api/auth/login", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.Login(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400 for empty username, got %d", w.Code)
}
})
t.Run("Login with empty password", func(t *testing.T) {
body := bytes.NewBufferString(`{"username":"testuser","password":""}`)
req := httptest.NewRequest("POST", "/api/auth/login", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.Login(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400 for empty password, got %d", w.Code)
}
})
t.Run("Register with very long username", func(t *testing.T) {
longUsername := strings.Repeat("a", 100)
body := bytes.NewBufferString(fmt.Sprintf(`{"username":"%s","email":"test@example.com","password":"Password123!"}`, longUsername))
req := httptest.NewRequest("POST", "/api/auth/register", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.Register(w, req)
testutils.AssertHTTPStatus(t, w, http.StatusBadRequest)
})
t.Run("Register with invalid email format", func(t *testing.T) {
body := bytes.NewBufferString(`{"username":"testuser","email":"invalid-email","password":"Password123!"}`)
req := httptest.NewRequest("POST", "/api/auth/register", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.Register(w, req)
testutils.AssertHTTPStatus(t, w, http.StatusBadRequest)
})
}
func TestAuthHandler_ConcurrentAccess(t *testing.T) {
authService := &mockAuthService{
loginFunc: func(username, password string) (*services.AuthResult, error) {
return &services.AuthResult{
User: &database.User{ID: 1, Username: username},
AccessToken: "access_token",
}, nil
},
}
userRepo := testutils.NewUserRepositoryStub()
handler := NewAuthHandler(authService, userRepo)
t.Run("Concurrent login attempts", func(t *testing.T) {
concurrency := 10
done := make(chan bool, concurrency)
for i := 0; i < concurrency; i++ {
go func() {
req := createLoginRequest(`{"username":"testuser","password":"Password123!"}`)
w := httptest.NewRecorder()
handler.Login(w, req)
testutils.AssertHTTPStatus(t, w, http.StatusOK)
done <- true
}()
}
for i := 0; i < concurrency; i++ {
<-done
}
})
}
func TestAuthHandler_RefreshToken(t *testing.T) {
authService := &mockAuthService{}
userRepo := testutils.NewUserRepositoryStub()
handler := NewAuthHandler(authService, userRepo)
t.Run("Successful_Refresh", func(t *testing.T) {
authService.refreshAccessTokenFunc = func(refreshToken string) (*services.AuthResult, error) {
return &services.AuthResult{
User: &database.User{ID: 1, Username: "testuser"},
AccessToken: "new_access_token",
RefreshToken: refreshToken,
}, nil
}
req := createRefreshTokenRequest(`{"refresh_token":"valid_refresh_token"}`)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.RefreshToken(w, req)
testutils.AssertSuccessResponse(t, w)
})
t.Run("Invalid_Request_Body", func(t *testing.T) {
req := createRefreshTokenRequest(`invalid json`)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.RefreshToken(w, req)
testutils.AssertHTTPStatus(t, w, http.StatusBadRequest)
})
t.Run("Missing_Refresh_Token", func(t *testing.T) {
req := createRefreshTokenRequest(`{"refresh_token":""}`)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.RefreshToken(w, req)
testutils.AssertHTTPStatus(t, w, http.StatusBadRequest)
})
t.Run("Expired_Refresh_Token", func(t *testing.T) {
authService.refreshAccessTokenFunc = func(refreshToken string) (*services.AuthResult, error) {
return nil, services.ErrRefreshTokenExpired
}
req := createRefreshTokenRequest(`{"refresh_token":"expired_token"}`)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.RefreshToken(w, req)
testutils.AssertHTTPStatus(t, w, http.StatusUnauthorized)
})
t.Run("Invalid_Refresh_Token", func(t *testing.T) {
authService.refreshAccessTokenFunc = func(refreshToken string) (*services.AuthResult, error) {
return nil, services.ErrRefreshTokenInvalid
}
req := createRefreshTokenRequest(`{"refresh_token":"invalid_token"}`)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.RefreshToken(w, req)
testutils.AssertHTTPStatus(t, w, http.StatusUnauthorized)
})
t.Run("Account_Locked", func(t *testing.T) {
authService.refreshAccessTokenFunc = func(refreshToken string) (*services.AuthResult, error) {
return nil, services.ErrAccountLocked
}
req := createRefreshTokenRequest(`{"refresh_token":"locked_token"}`)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.RefreshToken(w, req)
testutils.AssertHTTPStatus(t, w, http.StatusForbidden)
})
t.Run("Internal_Error", func(t *testing.T) {
authService.refreshAccessTokenFunc = func(refreshToken string) (*services.AuthResult, error) {
return nil, fmt.Errorf("internal error")
}
req := createRefreshTokenRequest(`{"refresh_token":"error_token"}`)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.RefreshToken(w, req)
testutils.AssertHTTPStatus(t, w, http.StatusInternalServerError)
})
}
func TestAuthHandler_RevokeToken(t *testing.T) {
authService := &mockAuthService{}
userRepo := testutils.NewUserRepositoryStub()
handler := NewAuthHandler(authService, userRepo)
t.Run("Successful_Revoke", func(t *testing.T) {
authService.revokeRefreshTokenFunc = func(refreshToken string) error {
return nil
}
req := createRevokeTokenRequest(`{"refresh_token":"token_to_revoke"}`)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.RevokeToken(w, req)
testutils.AssertSuccessResponse(t, w)
})
t.Run("Invalid_Request_Body", func(t *testing.T) {
req := createRevokeTokenRequest(`invalid json`)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.RevokeToken(w, req)
testutils.AssertHTTPStatus(t, w, http.StatusBadRequest)
})
t.Run("Missing_Refresh_Token", func(t *testing.T) {
req := createRevokeTokenRequest(`{"refresh_token":""}`)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.RevokeToken(w, req)
testutils.AssertHTTPStatus(t, w, http.StatusBadRequest)
})
t.Run("Revoke_Error", func(t *testing.T) {
authService.revokeRefreshTokenFunc = func(refreshToken string) error {
return fmt.Errorf("revoke failed")
}
req := createRevokeTokenRequest(`{"refresh_token":"token"}`)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.RevokeToken(w, req)
testutils.AssertHTTPStatus(t, w, http.StatusInternalServerError)
})
}
func TestAuthHandler_RevokeAllTokens(t *testing.T) {
authService := &mockAuthService{}
userRepo := testutils.NewUserRepositoryStub()
handler := NewAuthHandler(authService, userRepo)
t.Run("Successful_Revoke_All", func(t *testing.T) {
authService.revokeAllUserTokensFunc = func(userID uint) error {
return nil
}
req := httptest.NewRequest("POST", "/api/auth/revoke-all", nil)
req = req.WithContext(context.WithValue(req.Context(), middleware.UserIDKey, uint(1)))
w := httptest.NewRecorder()
handler.RevokeAllTokens(w, req)
testutils.AssertSuccessResponse(t, w)
})
t.Run("Unauthenticated_Request", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/auth/revoke-all", nil)
w := httptest.NewRecorder()
handler.RevokeAllTokens(w, req)
testutils.AssertHTTPStatus(t, w, http.StatusUnauthorized)
})
t.Run("Revoke_Error", func(t *testing.T) {
authService.revokeAllUserTokensFunc = func(userID uint) error {
return fmt.Errorf("revoke failed")
}
req := httptest.NewRequest("POST", "/api/auth/revoke-all", nil)
req = req.WithContext(context.WithValue(req.Context(), middleware.UserIDKey, uint(1)))
w := httptest.NewRecorder()
handler.RevokeAllTokens(w, req)
testutils.AssertHTTPStatus(t, w, http.StatusInternalServerError)
})
}