From fd9bf961bbb74b26c4f5aa5ae8712ab7469c22d5 Mon Sep 17 00:00:00 2001 From: rdev-worker Date: Thu, 5 Feb 2026 07:59:55 +0000 Subject: [PATCH] build: /implement-feature auth-system --requirements 'User model with email/... --- services/auth-api/cmd/server/main.go | 8 +- .../auth-api/internal/adapter/memory/user.go | 80 ++++ .../auth-api/internal/api/handlers/user.go | 144 +++++++ .../internal/api/handlers/user_test.go | 402 ++++++++++++++++++ services/auth-api/internal/api/routes.go | 10 +- services/auth-api/internal/api/spec.go | 63 +++ services/auth-api/internal/domain/errors.go | 15 + services/auth-api/internal/domain/user.go | 113 +++++ .../auth-api/internal/domain/user_test.go | 184 ++++++++ services/auth-api/internal/port/user.go | 27 ++ services/auth-api/internal/service/user.go | 143 +++++++ .../auth-api/internal/service/user_test.go | 293 +++++++++++++ 12 files changed, 1480 insertions(+), 2 deletions(-) create mode 100644 services/auth-api/internal/adapter/memory/user.go create mode 100644 services/auth-api/internal/api/handlers/user.go create mode 100644 services/auth-api/internal/api/handlers/user_test.go create mode 100644 services/auth-api/internal/domain/user.go create mode 100644 services/auth-api/internal/domain/user_test.go create mode 100644 services/auth-api/internal/port/user.go create mode 100644 services/auth-api/internal/service/user.go create mode 100644 services/auth-api/internal/service/user_test.go diff --git a/services/auth-api/cmd/server/main.go b/services/auth-api/cmd/server/main.go index 2fac7d0..b4c21c5 100644 --- a/services/auth-api/cmd/server/main.go +++ b/services/auth-api/cmd/server/main.go @@ -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() diff --git a/services/auth-api/internal/adapter/memory/user.go b/services/auth-api/internal/adapter/memory/user.go new file mode 100644 index 0000000..4cd1645 --- /dev/null +++ b/services/auth-api/internal/adapter/memory/user.go @@ -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 +} diff --git a/services/auth-api/internal/api/handlers/user.go b/services/auth-api/internal/api/handlers/user.go new file mode 100644 index 0000000..7c1588c --- /dev/null +++ b/services/auth-api/internal/api/handlers/user.go @@ -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 + } +} diff --git a/services/auth-api/internal/api/handlers/user_test.go b/services/auth-api/internal/api/handlers/user_test.go new file mode 100644 index 0000000..9556c69 --- /dev/null +++ b/services/auth-api/internal/api/handlers/user_test.go @@ -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"]) + } + } + }) + } +} diff --git a/services/auth-api/internal/api/routes.go b/services/auth-api/internal/api/routes.go index 488d60b..2941992 100644 --- a/services/auth-api/internal/api/routes.go +++ b/services/auth-api/internal/api/routes.go @@ -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)) diff --git a/services/auth-api/internal/api/spec.go b/services/auth-api/internal/api/spec.go index 8381e8f..8afe765 100644 --- a/services/auth-api/internal/api/spec.go +++ b/services/auth-api/internal/api/spec.go @@ -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", diff --git a/services/auth-api/internal/domain/errors.go b/services/auth-api/internal/domain/errors.go index d4ffe10..55d7598 100644 --- a/services/auth-api/internal/domain/errors.go +++ b/services/auth-api/internal/domain/errors.go @@ -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") ) diff --git a/services/auth-api/internal/domain/user.go b/services/auth-api/internal/domain/user.go new file mode 100644 index 0000000..fbe3926 --- /dev/null +++ b/services/auth-api/internal/domain/user.go @@ -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 +} diff --git a/services/auth-api/internal/domain/user_test.go b/services/auth-api/internal/domain/user_test.go new file mode 100644 index 0000000..03596e6 --- /dev/null +++ b/services/auth-api/internal/domain/user_test.go @@ -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") + } + }) +} diff --git a/services/auth-api/internal/port/user.go b/services/auth-api/internal/port/user.go new file mode 100644 index 0000000..3ddf100 --- /dev/null +++ b/services/auth-api/internal/port/user.go @@ -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) +} diff --git a/services/auth-api/internal/service/user.go b/services/auth-api/internal/service/user.go new file mode 100644 index 0000000..08c8bdb --- /dev/null +++ b/services/auth-api/internal/service/user.go @@ -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) +} diff --git a/services/auth-api/internal/service/user_test.go b/services/auth-api/internal/service/user_test.go new file mode 100644 index 0000000..f1e43f9 --- /dev/null +++ b/services/auth-api/internal/service/user_test.go @@ -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) + } + }) + } +}