build: /implement-feature auth-system --requirements 'User model with email/...
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
rdev-worker 2026-02-05 07:59:55 +00:00
parent e2638b7cd2
commit fd9bf961bb
12 changed files with 1480 additions and 2 deletions

View File

@ -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()

View 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
}

View 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
}
}

View 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"])
}
}
})
}
}

View File

@ -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))

View File

@ -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",

View File

@ -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")
)

View 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
}

View 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")
}
})
}

View 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)
}

View 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)
}

View 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)
}
})
}
}