Added comprehensive user management system: - User registration with validation (3-50 char username, 6+ char password) - JWT-based authentication with bcrypt password hashing - Admin authentication with master password - Password reset workflow with admin flagging - PostgreSQL repository implementation - SQLite repository for testing - Unified authentication service interface API Endpoints: - POST /api/v1/auth/register - User registration - POST /api/v1/auth/login - User/admin authentication - POST /api/v1/auth/password-reset/request - Request password reset - POST /api/v1/auth/password-reset/complete - Complete password reset - POST /api/v1/auth/validate - JWT token validation Security Features: - Password hashing with bcrypt - JWT token generation and validation - Admin claims in JWT tokens - Configurable token expiration - Input validation for all endpoints Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
238 lines
6.6 KiB
Go
238 lines
6.6 KiB
Go
package user
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"dance-lessons-coach/pkg/config"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// createTestConfig creates a test configuration with telemetry disabled
|
|
func createTestConfig() *config.Config {
|
|
return &config.Config{
|
|
Telemetry: config.TelemetryConfig{
|
|
Enabled: false,
|
|
Persistence: config.PersistenceTelemetryConfig{
|
|
Enabled: false,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestSQLiteRepository(t *testing.T) {
|
|
t.Run("CRUD operations", func(t *testing.T) {
|
|
// Create a temporary database
|
|
dbPath := "test_db.sqlite"
|
|
defer os.Remove(dbPath)
|
|
|
|
cfg := createTestConfig()
|
|
repo, err := NewSQLiteRepository(dbPath, cfg)
|
|
require.NoError(t, err)
|
|
defer repo.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Test CreateUser
|
|
user := &User{
|
|
Username: "testuser",
|
|
PasswordHash: "hashedpassword",
|
|
Description: ptrString("Test user"),
|
|
CurrentGoal: ptrString("Learn to dance"),
|
|
IsAdmin: false,
|
|
}
|
|
|
|
err = repo.CreateUser(ctx, user)
|
|
require.NoError(t, err)
|
|
assert.NotZero(t, user.ID)
|
|
|
|
// Test GetUserByUsername
|
|
retrievedUser, err := repo.GetUserByUsername(ctx, "testuser")
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, retrievedUser)
|
|
assert.Equal(t, "testuser", retrievedUser.Username)
|
|
|
|
// Test UserExists
|
|
exists, err := repo.UserExists(ctx, "testuser")
|
|
require.NoError(t, err)
|
|
assert.True(t, exists)
|
|
|
|
// Test UpdateUser
|
|
retrievedUser.Description = ptrString("Updated description")
|
|
err = repo.UpdateUser(ctx, retrievedUser)
|
|
require.NoError(t, err)
|
|
|
|
// Verify update
|
|
updatedUser, err := repo.GetUserByUsername(ctx, "testuser")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Updated description", *updatedUser.Description)
|
|
|
|
// Test AllowPasswordReset
|
|
err = repo.AllowPasswordReset(ctx, "testuser")
|
|
require.NoError(t, err)
|
|
|
|
// Verify password reset flag
|
|
userWithReset, err := repo.GetUserByUsername(ctx, "testuser")
|
|
require.NoError(t, err)
|
|
assert.True(t, userWithReset.AllowPasswordReset)
|
|
|
|
// Test CompletePasswordReset
|
|
err = repo.CompletePasswordReset(ctx, "testuser", "newhashedpassword")
|
|
require.NoError(t, err)
|
|
|
|
// Verify password reset completion
|
|
userAfterReset, err := repo.GetUserByUsername(ctx, "testuser")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "newhashedpassword", userAfterReset.PasswordHash)
|
|
assert.False(t, userAfterReset.AllowPasswordReset)
|
|
|
|
// Test DeleteUser
|
|
err = repo.DeleteUser(ctx, userAfterReset.ID)
|
|
require.NoError(t, err)
|
|
|
|
// Verify deletion
|
|
deletedUser, err := repo.GetUserByUsername(ctx, "testuser")
|
|
require.NoError(t, err)
|
|
assert.Nil(t, deletedUser)
|
|
})
|
|
}
|
|
|
|
func TestAuthService(t *testing.T) {
|
|
t.Run("Password hashing and authentication", func(t *testing.T) {
|
|
// Create a temporary database
|
|
dbPath := "test_auth_db.sqlite"
|
|
defer os.Remove(dbPath)
|
|
|
|
cfg := createTestConfig()
|
|
repo, err := NewSQLiteRepository(dbPath, cfg)
|
|
require.NoError(t, err)
|
|
defer repo.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create user service
|
|
jwtConfig := JWTConfig{
|
|
Secret: "test-secret",
|
|
ExpirationTime: time.Hour,
|
|
Issuer: "test-issuer",
|
|
}
|
|
userService := NewUserService(repo, jwtConfig, "admin123")
|
|
|
|
// Test password hashing
|
|
password := "testpassword123"
|
|
hashedPassword, err := userService.HashPassword(ctx, password)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, hashedPassword)
|
|
|
|
// Create a test user
|
|
user := &User{
|
|
Username: "testuser",
|
|
PasswordHash: hashedPassword,
|
|
}
|
|
err = repo.CreateUser(ctx, user)
|
|
require.NoError(t, err)
|
|
|
|
// Test successful authentication
|
|
authenticatedUser, err := userService.Authenticate(ctx, "testuser", password)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, authenticatedUser)
|
|
assert.Equal(t, "testuser", authenticatedUser.Username)
|
|
|
|
// Test failed authentication with wrong password
|
|
_, err = userService.Authenticate(ctx, "testuser", "wrongpassword")
|
|
assert.Error(t, err)
|
|
assert.Equal(t, "invalid credentials", err.Error())
|
|
|
|
// Test JWT generation
|
|
token, err := userService.GenerateJWT(ctx, authenticatedUser)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, token)
|
|
|
|
// Test JWT validation
|
|
validatedUser, err := userService.ValidateJWT(ctx, token)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, validatedUser)
|
|
assert.Equal(t, authenticatedUser.ID, validatedUser.ID)
|
|
|
|
// Test admin authentication
|
|
adminUser, err := userService.AdminAuthenticate(ctx, "admin123")
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, adminUser)
|
|
assert.True(t, adminUser.IsAdmin)
|
|
assert.Equal(t, "admin", adminUser.Username)
|
|
|
|
// Test failed admin authentication
|
|
_, err = userService.AdminAuthenticate(ctx, "wrongadminpassword")
|
|
assert.Error(t, err)
|
|
assert.Equal(t, "invalid admin credentials", err.Error())
|
|
})
|
|
}
|
|
|
|
func TestPasswordResetService(t *testing.T) {
|
|
t.Run("Password reset workflow", func(t *testing.T) {
|
|
// Create a temporary database
|
|
dbPath := "test_reset_db.sqlite"
|
|
defer os.Remove(dbPath)
|
|
|
|
cfg := createTestConfig()
|
|
repo, err := NewSQLiteRepository(dbPath, cfg)
|
|
require.NoError(t, err)
|
|
defer repo.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create user service
|
|
jwtConfig := JWTConfig{
|
|
Secret: "test-secret",
|
|
ExpirationTime: time.Hour,
|
|
Issuer: "test-issuer",
|
|
}
|
|
userService := NewUserService(repo, jwtConfig, "admin123")
|
|
|
|
// Create a test user
|
|
password := "oldpassword123"
|
|
hashedPassword, err := userService.HashPassword(ctx, password)
|
|
require.NoError(t, err)
|
|
|
|
user := &User{
|
|
Username: "resetuser",
|
|
PasswordHash: hashedPassword,
|
|
}
|
|
err = repo.CreateUser(ctx, user)
|
|
require.NoError(t, err)
|
|
|
|
// Test password reset request
|
|
err = userService.RequestPasswordReset(ctx, "resetuser")
|
|
require.NoError(t, err)
|
|
|
|
// Verify user is flagged for reset
|
|
userAfterRequest, err := repo.GetUserByUsername(ctx, "resetuser")
|
|
require.NoError(t, err)
|
|
assert.True(t, userAfterRequest.AllowPasswordReset)
|
|
|
|
// Test password reset completion
|
|
newPassword := "newpassword123"
|
|
err = userService.CompletePasswordReset(ctx, "resetuser", newPassword)
|
|
require.NoError(t, err)
|
|
|
|
// Verify password was updated and reset flag was cleared
|
|
userAfterReset, err := repo.GetUserByUsername(ctx, "resetuser")
|
|
require.NoError(t, err)
|
|
assert.False(t, userAfterReset.AllowPasswordReset)
|
|
|
|
// Verify new password works by authenticating with the new password
|
|
authenticatedUser, err := userService.Authenticate(ctx, "resetuser", newPassword)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, authenticatedUser)
|
|
assert.Equal(t, "resetuser", authenticatedUser.Username)
|
|
})
|
|
}
|
|
|
|
// Helper function to create string pointers
|
|
func ptrString(s string) *string {
|
|
return &s
|
|
}
|