build: /implement-feature auth-system --requirements 'User model with email/...
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
e2638b7cd2
commit
fd9bf961bb
@ -6,6 +6,7 @@ import (
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/pkg/logging"
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/services/auth-api/internal/adapter/memory"
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/services/auth-api/internal/api"
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/services/auth-api/internal/config"
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/services/auth-api/internal/service"
|
||||
)
|
||||
|
||||
@ -13,17 +14,22 @@ func main() {
|
||||
// Create logger
|
||||
logger := logging.Default()
|
||||
|
||||
// Load configuration
|
||||
cfg := config.Load()
|
||||
|
||||
// Create adapters (repositories)
|
||||
exampleRepo := memory.NewExampleRepository()
|
||||
userRepo := memory.NewUserRepository()
|
||||
|
||||
// Create services (business logic)
|
||||
exampleService := service.NewExampleService(exampleRepo, logger)
|
||||
userService := service.NewUserService(userRepo, []byte(cfg.JWTSecret), "slack-auth-1770277926", logger)
|
||||
|
||||
// Create application
|
||||
application := app.New("auth-api", app.WithDefaultPort(8001))
|
||||
|
||||
// Register routes with dependency injection
|
||||
api.RegisterRoutes(application, exampleService)
|
||||
api.RegisterRoutes(application, exampleService, userService)
|
||||
|
||||
// Start server
|
||||
application.Run()
|
||||
|
||||
80
services/auth-api/internal/adapter/memory/user.go
Normal file
80
services/auth-api/internal/adapter/memory/user.go
Normal file
@ -0,0 +1,80 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/services/auth-api/internal/domain"
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/services/auth-api/internal/port"
|
||||
)
|
||||
|
||||
// Compile-time verification that UserRepository implements port.UserRepository.
|
||||
var _ port.UserRepository = (*UserRepository)(nil)
|
||||
|
||||
// UserRepository is a thread-safe in-memory implementation of port.UserRepository.
|
||||
type UserRepository struct {
|
||||
mu sync.RWMutex
|
||||
users map[domain.UserID]*domain.User
|
||||
}
|
||||
|
||||
// NewUserRepository creates a new in-memory user repository.
|
||||
func NewUserRepository() *UserRepository {
|
||||
return &UserRepository{
|
||||
users: make(map[domain.UserID]*domain.User),
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns a user by ID.
|
||||
// Returns domain.ErrUserNotFound if not found.
|
||||
func (r *UserRepository) Get(ctx context.Context, id domain.UserID) (*domain.User, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
u, ok := r.users[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrUserNotFound
|
||||
}
|
||||
// Return a copy to prevent external mutation
|
||||
cpy := *u
|
||||
return &cpy, nil
|
||||
}
|
||||
|
||||
// GetByEmail returns a user by email address.
|
||||
// Returns domain.ErrUserNotFound if not found.
|
||||
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, u := range r.users {
|
||||
if u.Email == email {
|
||||
// Return a copy to prevent external mutation
|
||||
cpy := *u
|
||||
return &cpy, nil
|
||||
}
|
||||
}
|
||||
return nil, domain.ErrUserNotFound
|
||||
}
|
||||
|
||||
// Create stores a new user.
|
||||
func (r *UserRepository) Create(ctx context.Context, user *domain.User) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Store a copy to prevent external mutation
|
||||
cpy := *user
|
||||
r.users[user.ID] = &cpy
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExistsByEmail checks if a user with the given email exists.
|
||||
func (r *UserRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, u := range r.users {
|
||||
if u.Email == email {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
144
services/auth-api/internal/api/handlers/user.go
Normal file
144
services/auth-api/internal/api/handlers/user.go
Normal file
@ -0,0 +1,144 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/pkg/app"
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/pkg/auth"
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/pkg/httperror"
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/pkg/logging"
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/services/auth-api/internal/domain"
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/services/auth-api/internal/service"
|
||||
)
|
||||
|
||||
// User handles HTTP requests for user/auth resources.
|
||||
type User struct {
|
||||
svc *service.UserService
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewUser creates a new User handler with injected dependencies.
|
||||
func NewUser(svc *service.UserService, logger *logging.Logger) *User {
|
||||
return &User{
|
||||
svc: svc,
|
||||
logger: logger.WithComponent("UserHandler"),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRequest is the request body for user registration.
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" validate:"required,email,max=254"`
|
||||
Password string `json:"password" validate:"required,min=8,max=72"`
|
||||
}
|
||||
|
||||
// LoginRequest is the request body for user login.
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
// UserResponse is the response for a user resource (excludes password).
|
||||
type UserResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AuthResponse is the response for authentication endpoints.
|
||||
type AuthResponse struct {
|
||||
User UserResponse `json:"user"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// toUserResponse converts a domain user to an API response.
|
||||
func toUserResponse(u *domain.User) UserResponse {
|
||||
return UserResponse{
|
||||
ID: u.ID.String(),
|
||||
Email: u.Email,
|
||||
CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: u.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// Register creates a new user account.
|
||||
func (h *User) Register(w http.ResponseWriter, r *http.Request) error {
|
||||
var req RegisterRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
output, err := h.svc.Register(r.Context(), service.RegisterInput{
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
})
|
||||
if err != nil {
|
||||
return mapUserDomainError(err)
|
||||
}
|
||||
|
||||
httpresponse.Created(w, r, AuthResponse{
|
||||
User: toUserResponse(output.User),
|
||||
Token: output.Token,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Login authenticates a user and returns a JWT token.
|
||||
func (h *User) Login(w http.ResponseWriter, r *http.Request) error {
|
||||
var req LoginRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
output, err := h.svc.Login(r.Context(), service.LoginInput{
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
})
|
||||
if err != nil {
|
||||
return mapUserDomainError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, AuthResponse{
|
||||
User: toUserResponse(output.User),
|
||||
Token: output.Token,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Me returns the current authenticated user's profile.
|
||||
func (h *User) Me(w http.ResponseWriter, r *http.Request) error {
|
||||
// Get authenticated user from context
|
||||
authUser := auth.GetUser(r.Context())
|
||||
if authUser == nil {
|
||||
return httperror.Unauthorized("authentication required")
|
||||
}
|
||||
|
||||
// Fetch full user data from service
|
||||
user, err := h.svc.GetByID(r.Context(), domain.UserID(authUser.ID))
|
||||
if err != nil {
|
||||
return mapUserDomainError(err)
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, toUserResponse(user))
|
||||
return nil
|
||||
}
|
||||
|
||||
// mapUserDomainError converts domain errors to HTTP errors.
|
||||
func mapUserDomainError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrUserNotFound):
|
||||
return httperror.NotFound("user not found")
|
||||
case errors.Is(err, domain.ErrDuplicateUser):
|
||||
return httperror.Conflict("user with this email already exists")
|
||||
case errors.Is(err, domain.ErrInvalidEmail):
|
||||
return httperror.BadRequest("invalid email address")
|
||||
case errors.Is(err, domain.ErrInvalidPassword):
|
||||
return httperror.BadRequest("password must be between 8 and 72 characters")
|
||||
case errors.Is(err, domain.ErrInvalidCredentials):
|
||||
return httperror.Unauthorized("invalid email or password")
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
402
services/auth-api/internal/api/handlers/user_test.go
Normal file
402
services/auth-api/internal/api/handlers/user_test.go
Normal file
@ -0,0 +1,402 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/pkg/auth"
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/pkg/logging"
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/services/auth-api/internal/domain"
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/services/auth-api/internal/port"
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/services/auth-api/internal/service"
|
||||
)
|
||||
|
||||
// mockUserRepository implements port.UserRepository for testing.
|
||||
type mockUserRepository struct {
|
||||
mu sync.RWMutex
|
||||
users map[domain.UserID]*domain.User
|
||||
}
|
||||
|
||||
var _ port.UserRepository = (*mockUserRepository)(nil)
|
||||
|
||||
func newMockUserRepository() *mockUserRepository {
|
||||
return &mockUserRepository{
|
||||
users: make(map[domain.UserID]*domain.User),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) Get(ctx context.Context, id domain.UserID) (*domain.User, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
u, ok := m.users[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrUserNotFound
|
||||
}
|
||||
cpy := *u
|
||||
return &cpy, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, u := range m.users {
|
||||
if u.Email == email {
|
||||
cpy := *u
|
||||
return &cpy, nil
|
||||
}
|
||||
}
|
||||
return nil, domain.ErrUserNotFound
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) Create(ctx context.Context, user *domain.User) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
cpy := *user
|
||||
m.users[user.ID] = &cpy
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, u := range m.users {
|
||||
if u.Email == email {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var testJWTSecret = []byte("test-secret-key-for-testing-only")
|
||||
|
||||
func newTestUserHandler() (*User, *mockUserRepository) {
|
||||
repo := newMockUserRepository()
|
||||
svc := service.NewUserService(repo, testJWTSecret, "slack-auth-1770277926", logging.Nop())
|
||||
handler := NewUser(svc, logging.Nop())
|
||||
return handler, repo
|
||||
}
|
||||
|
||||
func TestUser_Register(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body any
|
||||
setup func(*mockUserRepository)
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "valid registration",
|
||||
body: RegisterRequest{
|
||||
Email: "newuser@example.com",
|
||||
Password: "password123",
|
||||
},
|
||||
wantStatus: http.StatusCreated,
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: nil,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid email",
|
||||
body: RegisterRequest{
|
||||
Email: "not-an-email",
|
||||
Password: "password123",
|
||||
},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "short password",
|
||||
body: RegisterRequest{
|
||||
Email: "user@example.com",
|
||||
Password: "short",
|
||||
},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "duplicate email",
|
||||
body: RegisterRequest{
|
||||
Email: "existing@example.com",
|
||||
Password: "password123",
|
||||
},
|
||||
setup: func(repo *mockUserRepository) {
|
||||
user, _ := domain.NewUser("existing-id", "existing@example.com", "password123")
|
||||
_ = repo.Create(context.Background(), user)
|
||||
},
|
||||
wantStatus: http.StatusConflict,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handler, repo := newTestUserHandler()
|
||||
|
||||
if tt.setup != nil {
|
||||
tt.setup(repo)
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/auth-api/register", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.Register(w, r); err != nil {
|
||||
switch tt.wantStatus {
|
||||
case http.StatusBadRequest:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
case http.StatusConflict:
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
default:
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
var body []byte
|
||||
if tt.body != nil {
|
||||
var err error
|
||||
body, err = json.Marshal(tt.body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth-api/register", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
|
||||
}
|
||||
|
||||
if tt.wantStatus == http.StatusCreated {
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
data, ok := resp["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected 'data' field in response")
|
||||
}
|
||||
|
||||
if _, ok := data["token"].(string); !ok {
|
||||
t.Error("expected 'token' in response")
|
||||
}
|
||||
|
||||
if _, ok := data["user"].(map[string]any); !ok {
|
||||
t.Error("expected 'user' in response")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_Login(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body any
|
||||
setup func(*mockUserRepository)
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "valid login",
|
||||
body: LoginRequest{
|
||||
Email: "user@example.com",
|
||||
Password: "correctpassword",
|
||||
},
|
||||
setup: func(repo *mockUserRepository) {
|
||||
user, _ := domain.NewUser("user-id", "user@example.com", "correctpassword")
|
||||
_ = repo.Create(context.Background(), user)
|
||||
},
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "wrong password",
|
||||
body: LoginRequest{
|
||||
Email: "user@example.com",
|
||||
Password: "wrongpassword",
|
||||
},
|
||||
setup: func(repo *mockUserRepository) {
|
||||
user, _ := domain.NewUser("user-id", "user@example.com", "correctpassword")
|
||||
_ = repo.Create(context.Background(), user)
|
||||
},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "user not found",
|
||||
body: LoginRequest{
|
||||
Email: "nonexistent@example.com",
|
||||
Password: "password123",
|
||||
},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: nil,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handler, repo := newTestUserHandler()
|
||||
|
||||
if tt.setup != nil {
|
||||
tt.setup(repo)
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/auth-api/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.Login(w, r); err != nil {
|
||||
switch tt.wantStatus {
|
||||
case http.StatusBadRequest:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
case http.StatusUnauthorized:
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
default:
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
var body []byte
|
||||
if tt.body != nil {
|
||||
var err error
|
||||
body, err = json.Marshal(tt.body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth-api/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
|
||||
}
|
||||
|
||||
if tt.wantStatus == http.StatusOK {
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
data, ok := resp["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected 'data' field in response")
|
||||
}
|
||||
|
||||
if _, ok := data["token"].(string); !ok {
|
||||
t.Error("expected 'token' in response")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_Me(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*mockUserRepository)
|
||||
withAuth bool
|
||||
authUserID string
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "authenticated user",
|
||||
setup: func(repo *mockUserRepository) {
|
||||
user, _ := domain.NewUser("user-123", "user@example.com", "password123")
|
||||
_ = repo.Create(context.Background(), user)
|
||||
},
|
||||
withAuth: true,
|
||||
authUserID: "user-123",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "no auth",
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "user not found in db",
|
||||
setup: func(repo *mockUserRepository) {
|
||||
// Don't seed the user
|
||||
},
|
||||
withAuth: true,
|
||||
authUserID: "nonexistent-user",
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handler, repo := newTestUserHandler()
|
||||
|
||||
if tt.setup != nil {
|
||||
tt.setup(repo)
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/auth-api/me", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.Me(w, r); err != nil {
|
||||
switch tt.wantStatus {
|
||||
case http.StatusUnauthorized:
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
case http.StatusNotFound:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
default:
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/auth-api/me", nil)
|
||||
|
||||
// Add authenticated user to context if needed
|
||||
if tt.withAuth {
|
||||
authUser := &auth.User{
|
||||
ID: tt.authUserID,
|
||||
Email: "user@example.com",
|
||||
}
|
||||
ctx := auth.SetUser(req.Context(), authUser)
|
||||
req = req.WithContext(ctx)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
|
||||
}
|
||||
|
||||
if tt.wantStatus == http.StatusOK {
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
data, ok := resp["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected 'data' field in response")
|
||||
}
|
||||
|
||||
if id, ok := data["id"].(string); !ok || id != tt.authUserID {
|
||||
t.Errorf("expected id %s, got %v", tt.authUserID, data["id"])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -14,13 +14,14 @@ import (
|
||||
// This allows the monorepo to expose multiple services under a single domain:
|
||||
// - https://domain/api/auth-api/health
|
||||
// - https://domain/api/auth-api/examples
|
||||
func RegisterRoutes(application *app.App, exampleService *service.ExampleService) {
|
||||
func RegisterRoutes(application *app.App, exampleService *service.ExampleService, userService *service.UserService) {
|
||||
logger := application.Logger()
|
||||
cfg := config.Load()
|
||||
|
||||
// Initialize handlers with injected services
|
||||
healthHandler := handlers.NewHealth(logger)
|
||||
exampleHandler := handlers.NewExample(exampleService, logger)
|
||||
userHandler := handlers.NewUser(userService, logger)
|
||||
|
||||
// Build and mount OpenAPI spec
|
||||
spec := NewServiceSpec()
|
||||
@ -31,6 +32,10 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
|
||||
application.Route("/api/auth-api", func(r app.Router) {
|
||||
r.Get("/health", healthHandler.Check)
|
||||
|
||||
// Public auth routes (no auth required)
|
||||
r.Post("/register", app.Wrap(userHandler.Register))
|
||||
r.Post("/login", app.Wrap(userHandler.Login))
|
||||
|
||||
// Public routes (no auth required)
|
||||
r.Get("/examples", app.Wrap(exampleHandler.List))
|
||||
r.Get("/examples/{id}", app.Wrap(exampleHandler.Get))
|
||||
@ -46,6 +51,9 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
|
||||
}))
|
||||
}
|
||||
|
||||
// User profile endpoint
|
||||
r.Get("/me", app.Wrap(userHandler.Me))
|
||||
|
||||
r.Post("/examples", app.Wrap(exampleHandler.Create))
|
||||
r.Put("/examples/{id}", app.Wrap(exampleHandler.Update))
|
||||
r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete))
|
||||
|
||||
@ -8,6 +8,7 @@ func NewServiceSpec() *openapi.OpenAPISpec {
|
||||
WithDescription("REST API for the auth-api service").
|
||||
WithBearerSecurity("bearer", "JWT authentication token").
|
||||
WithTag("Health", "Service health endpoints").
|
||||
WithTag("Auth", "Authentication endpoints").
|
||||
WithTag("Examples", "Example CRUD endpoints")
|
||||
|
||||
// Define reusable schemas
|
||||
@ -29,6 +30,29 @@ func NewServiceSpec() *openapi.OpenAPISpec {
|
||||
"description": openapi.StringWithMinMax(0, 500).WithDescription("Updated description"),
|
||||
}))
|
||||
|
||||
// Auth schemas
|
||||
spec.WithSchema("User", openapi.Object(map[string]openapi.Schema{
|
||||
"id": openapi.UUID().WithDescription("Unique user identifier"),
|
||||
"email": openapi.String().WithDescription("User email address").WithExample("user@example.com"),
|
||||
"created_at": openapi.DateTime().WithDescription("Account creation timestamp"),
|
||||
"updated_at": openapi.DateTime().WithDescription("Last update timestamp"),
|
||||
}, "id", "email"))
|
||||
|
||||
spec.WithSchema("RegisterRequest", openapi.Object(map[string]openapi.Schema{
|
||||
"email": openapi.StringWithMinMax(3, 254).WithDescription("User email address").WithExample("user@example.com"),
|
||||
"password": openapi.StringWithMinMax(8, 72).WithDescription("User password (8-72 characters)"),
|
||||
}, "email", "password"))
|
||||
|
||||
spec.WithSchema("LoginRequest", openapi.Object(map[string]openapi.Schema{
|
||||
"email": openapi.String().WithDescription("User email address").WithExample("user@example.com"),
|
||||
"password": openapi.String().WithDescription("User password"),
|
||||
}, "email", "password"))
|
||||
|
||||
spec.WithSchema("AuthResponse", openapi.Object(map[string]openapi.Schema{
|
||||
"user": openapi.Ref("User"),
|
||||
"token": openapi.String().WithDescription("JWT authentication token"),
|
||||
}, "user", "token"))
|
||||
|
||||
// Health
|
||||
spec.AddPath("/api/auth-api/health", "get", map[string]any{
|
||||
"summary": "Health check",
|
||||
@ -41,6 +65,45 @@ func NewServiceSpec() *openapi.OpenAPISpec {
|
||||
},
|
||||
})
|
||||
|
||||
// Register
|
||||
spec.AddPath("/api/auth-api/register", "post", map[string]any{
|
||||
"summary": "Register new user",
|
||||
"description": "Creates a new user account and returns a JWT token.",
|
||||
"tags": []string{"Auth"},
|
||||
"requestBody": openapi.RequestBody(openapi.Ref("RegisterRequest"), true),
|
||||
"responses": map[string]any{
|
||||
"201": openapi.OpResponse("User created", openapi.ResponseSchema(openapi.Ref("AuthResponse"))),
|
||||
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
|
||||
"409": openapi.OpResponse("Email already exists", openapi.ErrorResponseSchema()),
|
||||
"422": openapi.OpResponse("Validation error", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
// Login
|
||||
spec.AddPath("/api/auth-api/login", "post", map[string]any{
|
||||
"summary": "User login",
|
||||
"description": "Authenticates a user and returns a JWT token.",
|
||||
"tags": []string{"Auth"},
|
||||
"requestBody": openapi.RequestBody(openapi.Ref("LoginRequest"), true),
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Login successful", openapi.ResponseSchema(openapi.Ref("AuthResponse"))),
|
||||
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
|
||||
"401": openapi.OpResponse("Invalid credentials", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
// Get current user
|
||||
spec.AddPath("/api/auth-api/me", "get", map[string]any{
|
||||
"summary": "Get current user",
|
||||
"description": "Returns the profile of the currently authenticated user.",
|
||||
"tags": []string{"Auth"},
|
||||
"security": []map[string][]string{{"bearer": {}}},
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("User"))),
|
||||
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
// List examples
|
||||
spec.AddPath("/api/auth-api/examples", "get", map[string]any{
|
||||
"summary": "List examples",
|
||||
|
||||
@ -18,4 +18,19 @@ var (
|
||||
|
||||
// ErrInvalidExampleName indicates the example name is invalid.
|
||||
ErrInvalidExampleName = errors.New("invalid example name")
|
||||
|
||||
// ErrUserNotFound indicates the requested user does not exist.
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
|
||||
// ErrDuplicateUser indicates a user with the same email already exists.
|
||||
ErrDuplicateUser = errors.New("user with this email already exists")
|
||||
|
||||
// ErrInvalidEmail indicates the email is invalid.
|
||||
ErrInvalidEmail = errors.New("invalid email")
|
||||
|
||||
// ErrInvalidPassword indicates the password is invalid.
|
||||
ErrInvalidPassword = errors.New("invalid password")
|
||||
|
||||
// ErrInvalidCredentials indicates invalid login credentials.
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
)
|
||||
|
||||
113
services/auth-api/internal/domain/user.go
Normal file
113
services/auth-api/internal/domain/user.go
Normal file
@ -0,0 +1,113 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// UserID is a strongly-typed identifier for users.
|
||||
type UserID string
|
||||
|
||||
// String returns the string representation of the ID.
|
||||
func (id UserID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero returns true if the ID is empty.
|
||||
func (id UserID) IsZero() bool {
|
||||
return id == ""
|
||||
}
|
||||
|
||||
// User email constraints.
|
||||
const (
|
||||
MinEmailLen = 3
|
||||
MaxEmailLen = 254
|
||||
MinPasswordLen = 8
|
||||
MaxPasswordLen = 72 // bcrypt limit
|
||||
)
|
||||
|
||||
// User represents a user domain entity.
|
||||
// This is a pure domain model with no external dependencies.
|
||||
type User struct {
|
||||
ID UserID
|
||||
Email string
|
||||
PasswordHash string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NewUser creates a new User with validation and password hashing.
|
||||
// Returns ErrInvalidEmail if the email is invalid.
|
||||
// Returns ErrInvalidPassword if the password is invalid.
|
||||
func NewUser(id UserID, email, password string) (*User, error) {
|
||||
if err := validateEmail(email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validatePassword(password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
passwordHash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
return &User{
|
||||
ID: id,
|
||||
Email: email,
|
||||
PasswordHash: passwordHash,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CheckPassword verifies if the provided password matches the stored hash.
|
||||
func (u *User) CheckPassword(password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// validateEmail validates a user email address.
|
||||
func validateEmail(email string) error {
|
||||
length := utf8.RuneCountInString(email)
|
||||
if length < MinEmailLen || length > MaxEmailLen {
|
||||
return ErrInvalidEmail
|
||||
}
|
||||
// Basic email validation - contains @ with text before and after
|
||||
hasAt := false
|
||||
atPos := -1
|
||||
for i, r := range email {
|
||||
if r == '@' {
|
||||
if hasAt {
|
||||
return ErrInvalidEmail // Multiple @
|
||||
}
|
||||
hasAt = true
|
||||
atPos = i
|
||||
}
|
||||
}
|
||||
if !hasAt || atPos == 0 || atPos == len(email)-1 {
|
||||
return ErrInvalidEmail
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePassword validates a password.
|
||||
func validatePassword(password string) error {
|
||||
length := utf8.RuneCountInString(password)
|
||||
if length < MinPasswordLen || length > MaxPasswordLen {
|
||||
return ErrInvalidPassword
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashPassword creates a bcrypt hash of the password.
|
||||
func hashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
184
services/auth-api/internal/domain/user_test.go
Normal file
184
services/auth-api/internal/domain/user_test.go
Normal file
@ -0,0 +1,184 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewUser(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
id UserID
|
||||
email string
|
||||
password string
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "valid user",
|
||||
id: "user-123",
|
||||
email: "user@example.com",
|
||||
password: "password123",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "empty email",
|
||||
id: "user-123",
|
||||
email: "",
|
||||
password: "password123",
|
||||
wantErr: ErrInvalidEmail,
|
||||
},
|
||||
{
|
||||
name: "email without @",
|
||||
id: "user-123",
|
||||
email: "not-an-email",
|
||||
password: "password123",
|
||||
wantErr: ErrInvalidEmail,
|
||||
},
|
||||
{
|
||||
name: "email with @ at start",
|
||||
id: "user-123",
|
||||
email: "@example.com",
|
||||
password: "password123",
|
||||
wantErr: ErrInvalidEmail,
|
||||
},
|
||||
{
|
||||
name: "email with @ at end",
|
||||
id: "user-123",
|
||||
email: "user@",
|
||||
password: "password123",
|
||||
wantErr: ErrInvalidEmail,
|
||||
},
|
||||
{
|
||||
name: "email with multiple @",
|
||||
id: "user-123",
|
||||
email: "user@@example.com",
|
||||
password: "password123",
|
||||
wantErr: ErrInvalidEmail,
|
||||
},
|
||||
{
|
||||
name: "short password",
|
||||
id: "user-123",
|
||||
email: "user@example.com",
|
||||
password: "short",
|
||||
wantErr: ErrInvalidPassword,
|
||||
},
|
||||
{
|
||||
name: "empty password",
|
||||
id: "user-123",
|
||||
email: "user@example.com",
|
||||
password: "",
|
||||
wantErr: ErrInvalidPassword,
|
||||
},
|
||||
{
|
||||
name: "minimum valid password",
|
||||
id: "user-123",
|
||||
email: "user@example.com",
|
||||
password: "12345678",
|
||||
wantErr: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
user, err := NewUser(tt.id, tt.email, tt.password)
|
||||
|
||||
if tt.wantErr != nil {
|
||||
if err != tt.wantErr {
|
||||
t.Errorf("expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if user.ID != tt.id {
|
||||
t.Errorf("expected ID %s, got %s", tt.id, user.ID)
|
||||
}
|
||||
|
||||
if user.Email != tt.email {
|
||||
t.Errorf("expected email %s, got %s", tt.email, user.Email)
|
||||
}
|
||||
|
||||
if user.PasswordHash == "" {
|
||||
t.Error("expected password hash to be set")
|
||||
}
|
||||
|
||||
if user.PasswordHash == tt.password {
|
||||
t.Error("password hash should not equal plaintext password")
|
||||
}
|
||||
|
||||
if user.CreatedAt.IsZero() {
|
||||
t.Error("expected CreatedAt to be set")
|
||||
}
|
||||
|
||||
if user.UpdatedAt.IsZero() {
|
||||
t.Error("expected UpdatedAt to be set")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_CheckPassword(t *testing.T) {
|
||||
user, err := NewUser("user-123", "user@example.com", "correctpassword")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "correct password",
|
||||
password: "correctpassword",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wrong password",
|
||||
password: "wrongpassword",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty password",
|
||||
password: "",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "similar password",
|
||||
password: "correctPassword",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := user.CheckPassword(tt.password)
|
||||
if got != tt.want {
|
||||
t.Errorf("CheckPassword() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserID(t *testing.T) {
|
||||
t.Run("String", func(t *testing.T) {
|
||||
id := UserID("user-123")
|
||||
if id.String() != "user-123" {
|
||||
t.Errorf("String() = %s, want user-123", id.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IsZero", func(t *testing.T) {
|
||||
var zeroID UserID
|
||||
if !zeroID.IsZero() {
|
||||
t.Error("expected zero ID to be zero")
|
||||
}
|
||||
|
||||
nonZeroID := UserID("user-123")
|
||||
if nonZeroID.IsZero() {
|
||||
t.Error("expected non-zero ID to not be zero")
|
||||
}
|
||||
})
|
||||
}
|
||||
27
services/auth-api/internal/port/user.go
Normal file
27
services/auth-api/internal/port/user.go
Normal file
@ -0,0 +1,27 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/services/auth-api/internal/domain"
|
||||
)
|
||||
|
||||
// UserRepository defines the interface for user persistence operations.
|
||||
// Implementations may use databases, in-memory storage, or external services.
|
||||
type UserRepository interface {
|
||||
// Get returns a user by ID.
|
||||
// Returns domain.ErrUserNotFound if not found.
|
||||
Get(ctx context.Context, id domain.UserID) (*domain.User, error)
|
||||
|
||||
// GetByEmail returns a user by email address.
|
||||
// Returns domain.ErrUserNotFound if not found.
|
||||
GetByEmail(ctx context.Context, email string) (*domain.User, error)
|
||||
|
||||
// Create stores a new user.
|
||||
// The user must have a valid ID set.
|
||||
Create(ctx context.Context, user *domain.User) error
|
||||
|
||||
// ExistsByEmail checks if a user with the given email exists.
|
||||
// Used for duplicate detection.
|
||||
ExistsByEmail(ctx context.Context, email string) (bool, error)
|
||||
}
|
||||
143
services/auth-api/internal/service/user.go
Normal file
143
services/auth-api/internal/service/user.go
Normal file
@ -0,0 +1,143 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/pkg/auth"
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/pkg/logging"
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/services/auth-api/internal/domain"
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/services/auth-api/internal/port"
|
||||
)
|
||||
|
||||
// UserService handles user-related business logic.
|
||||
type UserService struct {
|
||||
repo port.UserRepository
|
||||
jwtSecret []byte
|
||||
jwtIssuer string
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewUserService creates a new user service.
|
||||
func NewUserService(repo port.UserRepository, jwtSecret []byte, jwtIssuer string, logger *logging.Logger) *UserService {
|
||||
return &UserService{
|
||||
repo: repo,
|
||||
jwtSecret: jwtSecret,
|
||||
jwtIssuer: jwtIssuer,
|
||||
logger: logger.WithService("UserService"),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterInput contains the data needed to register a user.
|
||||
type RegisterInput struct {
|
||||
Email string
|
||||
Password string
|
||||
}
|
||||
|
||||
// RegisterOutput contains the result of user registration.
|
||||
type RegisterOutput struct {
|
||||
User *domain.User
|
||||
Token string
|
||||
}
|
||||
|
||||
// Register creates a new user with duplicate detection.
|
||||
// Returns domain.ErrDuplicateUser if email already exists.
|
||||
// Returns domain.ErrInvalidEmail if email is invalid.
|
||||
// Returns domain.ErrInvalidPassword if password is invalid.
|
||||
func (s *UserService) Register(ctx context.Context, input RegisterInput) (*RegisterOutput, error) {
|
||||
// Check for duplicates
|
||||
exists, err := s.repo.ExistsByEmail(ctx, input.Email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists {
|
||||
return nil, domain.ErrDuplicateUser
|
||||
}
|
||||
|
||||
// Generate new ID
|
||||
id := domain.UserID(uuid.New().String())
|
||||
|
||||
// Create domain entity (validates email/password and hashes password)
|
||||
user, err := domain.NewUser(id, input.Email, input.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Persist
|
||||
if err := s.repo.Create(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
token, err := s.generateToken(user)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to generate token after registration", "error", err, "user_id", id)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("user registered", "id", id, "email", input.Email)
|
||||
return &RegisterOutput{
|
||||
User: user,
|
||||
Token: token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LoginInput contains the data needed to authenticate a user.
|
||||
type LoginInput struct {
|
||||
Email string
|
||||
Password string
|
||||
}
|
||||
|
||||
// LoginOutput contains the result of user authentication.
|
||||
type LoginOutput struct {
|
||||
User *domain.User
|
||||
Token string
|
||||
}
|
||||
|
||||
// Login authenticates a user and returns a JWT token.
|
||||
// Returns domain.ErrInvalidCredentials if credentials are incorrect.
|
||||
func (s *UserService) Login(ctx context.Context, input LoginInput) (*LoginOutput, error) {
|
||||
// Find user by email
|
||||
user, err := s.repo.GetByEmail(ctx, input.Email)
|
||||
if err != nil {
|
||||
if err == domain.ErrUserNotFound {
|
||||
return nil, domain.ErrInvalidCredentials
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if !user.CheckPassword(input.Password) {
|
||||
return nil, domain.ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
token, err := s.generateToken(user)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to generate token", "error", err, "user_id", user.ID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("user logged in", "id", user.ID, "email", input.Email)
|
||||
return &LoginOutput{
|
||||
User: user,
|
||||
Token: token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetByID returns a user by ID.
|
||||
// Returns domain.ErrUserNotFound if not found.
|
||||
func (s *UserService) GetByID(ctx context.Context, id domain.UserID) (*domain.User, error) {
|
||||
return s.repo.Get(ctx, id)
|
||||
}
|
||||
|
||||
// generateToken creates a JWT token for the user.
|
||||
func (s *UserService) generateToken(user *domain.User) (string, error) {
|
||||
authUser := &auth.User{
|
||||
ID: user.ID.String(),
|
||||
Email: user.Email,
|
||||
}
|
||||
return auth.GenerateTokenWithIssuer(s.jwtSecret, authUser, 24*time.Hour, s.jwtIssuer, s.jwtIssuer)
|
||||
}
|
||||
293
services/auth-api/internal/service/user_test.go
Normal file
293
services/auth-api/internal/service/user_test.go
Normal file
@ -0,0 +1,293 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/pkg/logging"
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/services/auth-api/internal/domain"
|
||||
"git.threesix.ai/jordan/slack-auth-1770277926/services/auth-api/internal/port"
|
||||
)
|
||||
|
||||
// mockUserRepository implements port.UserRepository for testing.
|
||||
type mockUserRepository struct {
|
||||
mu sync.RWMutex
|
||||
users map[domain.UserID]*domain.User
|
||||
}
|
||||
|
||||
var _ port.UserRepository = (*mockUserRepository)(nil)
|
||||
|
||||
func newMockUserRepository() *mockUserRepository {
|
||||
return &mockUserRepository{
|
||||
users: make(map[domain.UserID]*domain.User),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) Get(ctx context.Context, id domain.UserID) (*domain.User, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
u, ok := m.users[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrUserNotFound
|
||||
}
|
||||
cpy := *u
|
||||
return &cpy, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, u := range m.users {
|
||||
if u.Email == email {
|
||||
cpy := *u
|
||||
return &cpy, nil
|
||||
}
|
||||
}
|
||||
return nil, domain.ErrUserNotFound
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) Create(ctx context.Context, user *domain.User) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
cpy := *user
|
||||
m.users[user.ID] = &cpy
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, u := range m.users {
|
||||
if u.Email == email {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var testJWTSecret = []byte("test-secret-key-for-testing-only")
|
||||
|
||||
func newTestUserService() (*UserService, *mockUserRepository) {
|
||||
repo := newMockUserRepository()
|
||||
svc := NewUserService(repo, testJWTSecret, "slack-auth-1770277926", logging.Nop())
|
||||
return svc, repo
|
||||
}
|
||||
|
||||
func TestUserService_Register(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input RegisterInput
|
||||
setup func(*mockUserRepository)
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "valid registration",
|
||||
input: RegisterInput{
|
||||
Email: "newuser@example.com",
|
||||
Password: "password123",
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "duplicate email",
|
||||
input: RegisterInput{
|
||||
Email: "existing@example.com",
|
||||
Password: "password123",
|
||||
},
|
||||
setup: func(repo *mockUserRepository) {
|
||||
user, _ := domain.NewUser("existing-id", "existing@example.com", "password123")
|
||||
_ = repo.Create(context.Background(), user)
|
||||
},
|
||||
wantErr: domain.ErrDuplicateUser,
|
||||
},
|
||||
{
|
||||
name: "invalid email",
|
||||
input: RegisterInput{
|
||||
Email: "not-valid",
|
||||
Password: "password123",
|
||||
},
|
||||
wantErr: domain.ErrInvalidEmail,
|
||||
},
|
||||
{
|
||||
name: "short password",
|
||||
input: RegisterInput{
|
||||
Email: "user@example.com",
|
||||
Password: "short",
|
||||
},
|
||||
wantErr: domain.ErrInvalidPassword,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc, repo := newTestUserService()
|
||||
|
||||
if tt.setup != nil {
|
||||
tt.setup(repo)
|
||||
}
|
||||
|
||||
output, err := svc.Register(context.Background(), tt.input)
|
||||
|
||||
if tt.wantErr != nil {
|
||||
if err != tt.wantErr {
|
||||
t.Errorf("expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if output.User == nil {
|
||||
t.Fatal("expected user in output")
|
||||
}
|
||||
|
||||
if output.User.Email != tt.input.Email {
|
||||
t.Errorf("expected email %s, got %s", tt.input.Email, output.User.Email)
|
||||
}
|
||||
|
||||
if output.Token == "" {
|
||||
t.Error("expected token in output")
|
||||
}
|
||||
|
||||
// Verify user was persisted
|
||||
persisted, err := repo.GetByEmail(context.Background(), tt.input.Email)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get persisted user: %v", err)
|
||||
}
|
||||
if persisted.Email != tt.input.Email {
|
||||
t.Errorf("persisted user email mismatch")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserService_Login(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input LoginInput
|
||||
setup func(*mockUserRepository)
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "valid login",
|
||||
input: LoginInput{
|
||||
Email: "user@example.com",
|
||||
Password: "correctpassword",
|
||||
},
|
||||
setup: func(repo *mockUserRepository) {
|
||||
user, _ := domain.NewUser("user-id", "user@example.com", "correctpassword")
|
||||
_ = repo.Create(context.Background(), user)
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "wrong password",
|
||||
input: LoginInput{
|
||||
Email: "user@example.com",
|
||||
Password: "wrongpassword",
|
||||
},
|
||||
setup: func(repo *mockUserRepository) {
|
||||
user, _ := domain.NewUser("user-id", "user@example.com", "correctpassword")
|
||||
_ = repo.Create(context.Background(), user)
|
||||
},
|
||||
wantErr: domain.ErrInvalidCredentials,
|
||||
},
|
||||
{
|
||||
name: "user not found",
|
||||
input: LoginInput{
|
||||
Email: "nonexistent@example.com",
|
||||
Password: "password123",
|
||||
},
|
||||
wantErr: domain.ErrInvalidCredentials,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc, repo := newTestUserService()
|
||||
|
||||
if tt.setup != nil {
|
||||
tt.setup(repo)
|
||||
}
|
||||
|
||||
output, err := svc.Login(context.Background(), tt.input)
|
||||
|
||||
if tt.wantErr != nil {
|
||||
if err != tt.wantErr {
|
||||
t.Errorf("expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if output.User == nil {
|
||||
t.Fatal("expected user in output")
|
||||
}
|
||||
|
||||
if output.Token == "" {
|
||||
t.Error("expected token in output")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserService_GetByID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
userID domain.UserID
|
||||
setup func(*mockUserRepository)
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "existing user",
|
||||
userID: "user-123",
|
||||
setup: func(repo *mockUserRepository) {
|
||||
user, _ := domain.NewUser("user-123", "user@example.com", "password123")
|
||||
_ = repo.Create(context.Background(), user)
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "user not found",
|
||||
userID: "nonexistent",
|
||||
wantErr: domain.ErrUserNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc, repo := newTestUserService()
|
||||
|
||||
if tt.setup != nil {
|
||||
tt.setup(repo)
|
||||
}
|
||||
|
||||
user, err := svc.GetByID(context.Background(), tt.userID)
|
||||
|
||||
if tt.wantErr != nil {
|
||||
if err != tt.wantErr {
|
||||
t.Errorf("expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if user.ID != tt.userID {
|
||||
t.Errorf("expected user ID %s, got %s", tt.userID, user.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user