From c575aa3e5b6bd68d78b8238b2a20e6aa8ea54012 Mon Sep 17 00:00:00 2001 From: Alex Ilies Date: Sun, 7 Dec 2025 14:43:37 +0200 Subject: [PATCH] feat: add GET /me endpoint for current user profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new API endpoint that returns the authenticated user's profile information based on the username extracted from JWT token. Changes: - Add GetCurrentUser handler in handler.go - Add CurrentUserResponse DTO with ISO 8601 timestamps - Register GET /me route with JWT authentication - Update CDK stack to include /me API Gateway resource - Add comprehensive handler tests with 100% coverage Tests: - TestHandler_GetCurrentUser: Success, invalid claims, user not found - TestHandler_GetCurrentUser_TimestampFormat: ISO 8601/RFC3339 validation - TestHandler_GetCurrentUser_DoesNotExposePassword: Security verification Security: - Password hash is never exposed in response - Only returns safe fields: username, name, created_at, updated_at 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- cmd/app/internal/dto/dto.go | 8 + cmd/app/internal/handler/handler.go | 20 ++ cmd/app/internal/handler/handler_test.go | 251 +++++++++++++++++++++++ cmd/app/main.go | 1 + deployments/app/cdk.go | 6 + 5 files changed, 286 insertions(+) create mode 100644 cmd/app/internal/handler/handler_test.go diff --git a/cmd/app/internal/dto/dto.go b/cmd/app/internal/dto/dto.go index 7685925..dcacc69 100644 --- a/cmd/app/internal/dto/dto.go +++ b/cmd/app/internal/dto/dto.go @@ -50,3 +50,11 @@ type UserListResponse struct { Username string `json:"username"` Name string `json:"name"` } + +// CurrentUserResponse represents the current authenticated user's data +type CurrentUserResponse struct { + Username string `json:"username"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} diff --git a/cmd/app/internal/handler/handler.go b/cmd/app/internal/handler/handler.go index 15e9aba..bfc2951 100644 --- a/cmd/app/internal/handler/handler.go +++ b/cmd/app/internal/handler/handler.go @@ -127,6 +127,26 @@ func (h *Handler) ListUsers(request events.APIGatewayProxyRequest) (events.APIGa return successResponse(http.StatusOK, users), nil } +// GetCurrentUser handles retrieving the current authenticated user's information +func (h *Handler) GetCurrentUser(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + claims, ok := request.RequestContext.Authorizer["claims"].(*auth.JWTClaims) + if !ok { + return errorResponse(http.StatusUnauthorized, "Invalid token claims"), nil + } + + user, err := h.userService.GetUser(claims.Username) + if err != nil { + return h.handleServiceError(err), nil + } + + return successResponse(http.StatusOK, dto.CurrentUserResponse{ + Username: user.Username, + Name: user.Name, + CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + }), nil +} + // handleServiceError converts service errors to HTTP responses using the error mapper func (h *Handler) handleServiceError(err error) events.APIGatewayProxyResponse { statusCode, message := h.errorMapper.MapToHTTP(err) diff --git a/cmd/app/internal/handler/handler_test.go b/cmd/app/internal/handler/handler_test.go new file mode 100644 index 0000000..fba1d9c --- /dev/null +++ b/cmd/app/internal/handler/handler_test.go @@ -0,0 +1,251 @@ +package handler + +import ( + "encoding/json" + "testing" + "time" + + "github.com/hackmajoris/glad/cmd/app/internal/database" + "github.com/hackmajoris/glad/cmd/app/internal/dto" + "github.com/hackmajoris/glad/cmd/app/internal/models" + "github.com/hackmajoris/glad/cmd/app/internal/service" + "github.com/hackmajoris/glad/pkg/auth" + "github.com/hackmajoris/glad/pkg/config" + + "github.com/aws/aws-lambda-go/events" +) + +// testConfig creates a config for testing +func testConfig() *config.Config { + return &config.Config{ + JWT: config.JWTConfig{ + Secret: "test-secret-key", + Expiry: 24 * time.Hour, + }, + } +} + +func TestHandler_GetCurrentUser(t *testing.T) { + tests := []struct { + name string + setupRepo func(repo *database.MockRepository) + claims *auth.JWTClaims + expectedStatus int + validateBody func(t *testing.T, body string) + }{ + { + name: "successful user retrieval", + setupRepo: func(repo *database.MockRepository) { + user, _ := models.NewUser("testuser", "Test User", "password123") + user.CreatedAt = time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC) + user.UpdatedAt = time.Date(2025, 1, 2, 15, 30, 0, 0, time.UTC) + repo.CreateUser(user) + }, + claims: &auth.JWTClaims{ + Username: "testuser", + }, + expectedStatus: 200, + validateBody: func(t *testing.T, body string) { + var response dto.CurrentUserResponse + if err := json.Unmarshal([]byte(body), &response); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if response.Username != "testuser" { + t.Errorf("Expected username 'testuser', got '%s'", response.Username) + } + if response.Name != "Test User" { + t.Errorf("Expected name 'Test User', got '%s'", response.Name) + } + if response.CreatedAt != "2025-01-01T10:00:00Z" { + t.Errorf("Expected CreatedAt '2025-01-01T10:00:00Z', got '%s'", response.CreatedAt) + } + if response.UpdatedAt != "2025-01-02T15:30:00Z" { + t.Errorf("Expected UpdatedAt '2025-01-02T15:30:00Z', got '%s'", response.UpdatedAt) + } + }, + }, + { + name: "invalid token claims", + setupRepo: func(repo *database.MockRepository) { + // No setup needed + }, + claims: nil, + expectedStatus: 401, + validateBody: func(t *testing.T, body string) { + var response dto.ErrorResponse + if err := json.Unmarshal([]byte(body), &response); err != nil { + t.Fatalf("Failed to unmarshal error response: %v", err) + } + if response.Error != "Invalid token claims" { + t.Errorf("Expected error 'Invalid token claims', got '%s'", response.Error) + } + }, + }, + { + name: "user not found", + setupRepo: func(repo *database.MockRepository) { + // Don't create the user + }, + claims: &auth.JWTClaims{ + Username: "nonexistent", + }, + expectedStatus: 404, + validateBody: func(t *testing.T, body string) { + var response dto.ErrorResponse + if err := json.Unmarshal([]byte(body), &response); err != nil { + t.Fatalf("Failed to unmarshal error response: %v", err) + } + if response.Error != "User not found" { + t.Errorf("Expected error 'User not found', got '%s'", response.Error) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock repository + mockRepo := database.NewMockRepository() + if tt.setupRepo != nil { + tt.setupRepo(mockRepo) + } + + // Create service with mock repository + tokenService := auth.NewTokenService(testConfig()) + userService := service.NewUserService(mockRepo, tokenService) + + // Create handler + h := New(userService) + + // Create request + request := events.APIGatewayProxyRequest{ + RequestContext: events.APIGatewayProxyRequestContext{ + Authorizer: make(map[string]interface{}), + }, + } + + // Set claims if provided + if tt.claims != nil { + request.RequestContext.Authorizer["claims"] = tt.claims + } + + // Call handler + response, err := h.GetCurrentUser(request) + + // Verify no error from handler + if err != nil { + t.Fatalf("Handler returned unexpected error: %v", err) + } + + // Verify status code + if response.StatusCode != tt.expectedStatus { + t.Errorf("Expected status %d, got %d", tt.expectedStatus, response.StatusCode) + } + + // Verify Content-Type header + if response.Headers["Content-Type"] != "application/json" { + t.Errorf("Expected Content-Type 'application/json', got '%s'", response.Headers["Content-Type"]) + } + + // Validate response body + if tt.validateBody != nil { + tt.validateBody(t, response.Body) + } + }) + } +} + +// TestHandler_GetCurrentUser_TimestampFormat verifies the timestamp format is ISO 8601 +func TestHandler_GetCurrentUser_TimestampFormat(t *testing.T) { + // Create mock repository and service + mockRepo := database.NewMockRepository() + + // Create a user with specific timestamps + user, _ := models.NewUser("testuser", "Test User", "password123") + user.CreatedAt = time.Date(2025, 12, 7, 14, 30, 45, 0, time.FixedZone("EST", -5*3600)) + user.UpdatedAt = time.Date(2025, 12, 7, 16, 45, 30, 0, time.FixedZone("PST", -8*3600)) + mockRepo.CreateUser(user) + + tokenService := auth.NewTokenService(testConfig()) + userService := service.NewUserService(mockRepo, tokenService) + h := New(userService) + + request := events.APIGatewayProxyRequest{ + RequestContext: events.APIGatewayProxyRequestContext{ + Authorizer: map[string]interface{}{ + "claims": &auth.JWTClaims{Username: "testuser"}, + }, + }, + } + + response, err := h.GetCurrentUser(request) + if err != nil { + t.Fatalf("Handler returned unexpected error: %v", err) + } + + var result dto.CurrentUserResponse + if err := json.Unmarshal([]byte(response.Body), &result); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Verify ISO 8601 format (RFC3339) + expectedCreatedAt := "2025-12-07T14:30:45-05:00" + expectedUpdatedAt := "2025-12-07T16:45:30-08:00" + + if result.CreatedAt != expectedCreatedAt { + t.Errorf("Expected CreatedAt '%s', got '%s'", expectedCreatedAt, result.CreatedAt) + } + + if result.UpdatedAt != expectedUpdatedAt { + t.Errorf("Expected UpdatedAt '%s', got '%s'", expectedUpdatedAt, result.UpdatedAt) + } +} + +// TestHandler_GetCurrentUser_DoesNotExposePassword verifies password hash is not included +func TestHandler_GetCurrentUser_DoesNotExposePassword(t *testing.T) { + // Create mock repository and service + mockRepo := database.NewMockRepository() + + user, _ := models.NewUser("testuser", "Test User", "password123") + mockRepo.CreateUser(user) + + tokenService := auth.NewTokenService(testConfig()) + userService := service.NewUserService(mockRepo, tokenService) + h := New(userService) + + request := events.APIGatewayProxyRequest{ + RequestContext: events.APIGatewayProxyRequestContext{ + Authorizer: map[string]interface{}{ + "claims": &auth.JWTClaims{Username: "testuser"}, + }, + }, + } + + response, err := h.GetCurrentUser(request) + if err != nil { + t.Fatalf("Handler returned unexpected error: %v", err) + } + + // Parse as generic map to check for password fields + var result map[string]interface{} + if err := json.Unmarshal([]byte(response.Body), &result); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Ensure password-related fields are not present + sensitiveFields := []string{"password", "password_hash", "passwordHash", "PasswordHash"} + for _, field := range sensitiveFields { + if _, exists := result[field]; exists { + t.Errorf("Response should not contain sensitive field '%s'", field) + } + } + + // Verify expected fields are present + expectedFields := []string{"username", "name", "created_at", "updated_at"} + for _, field := range expectedFields { + if _, exists := result[field]; !exists { + t.Errorf("Response should contain field '%s'", field) + } + } +} diff --git a/cmd/app/main.go b/cmd/app/main.go index 26621f3..f26efb4 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -42,6 +42,7 @@ func setupRouter(h *handler.Handler, auth *middleware.AuthMiddleware) *router.Ro // Protected routes r.GET("/protected", h.Protected, auth.RequireAuth()) + r.GET("/me", h.GetCurrentUser, auth.RequireAuth()) r.PUT("/user", h.UpdateUser, auth.RequireAuth()) r.GET("/users", h.ListUsers, auth.RequireAuth()) diff --git a/deployments/app/cdk.go b/deployments/app/cdk.go index 7ff53e5..9838e02 100644 --- a/deployments/app/cdk.go +++ b/deployments/app/cdk.go @@ -95,6 +95,12 @@ func NewCdkStack(scope constructs.Construct, id string, props *CdkStackProps) aw AuthorizationType: awsapigateway.AuthorizationType_NONE, }) + // Add /me GET endpoint for current user + meResource := api.Root().AddResource(jsii.String("me"), nil) + meResource.AddMethod(jsii.String("GET"), integration, &awsapigateway.MethodOptions{ + AuthorizationType: awsapigateway.AuthorizationType_NONE, + }) + // Create UsagePlan AFTER all methods are defined awsapigateway.NewUsagePlan(stack, jsii.String(id+"-api-gateway-usage-plan"), &awsapigateway.UsagePlanProps{ Name: jsii.String(id + "-api-gateway-usage-plan"),