diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..312bdf5 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(find:*)", + "Bash(mvn clean:*)", + "Bash(mvn test:*)", + "Bash(npm run build:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/backend/common/src/main/java/dev/rheinsw/common/controller/exception/handler/GlobalExceptionHandler.java b/backend/common/src/main/java/dev/rheinsw/common/controller/exception/handler/GlobalExceptionHandler.java index 19f47ee..b414b05 100644 --- a/backend/common/src/main/java/dev/rheinsw/common/controller/exception/handler/GlobalExceptionHandler.java +++ b/backend/common/src/main/java/dev/rheinsw/common/controller/exception/handler/GlobalExceptionHandler.java @@ -2,47 +2,86 @@ package dev.rheinsw.common.controller.exception.handler; import dev.rheinsw.common.controller.exception.ApiException; import dev.rheinsw.common.usecase.exception.UseCaseException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.context.request.WebRequest; import java.time.Instant; import java.util.List; +import java.util.UUID; /** * @author Thatsaphorn Atchariyaphap * @since 06.07.25 */ +@Slf4j @ControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(ApiException.class) - public ResponseEntity handleBusinessException(ApiException ex) { + public ResponseEntity handleBusinessException(ApiException ex, WebRequest request) { + String correlationId = UUID.randomUUID().toString(); + log.warn("Business exception [{}] at {}: {}", correlationId, request.getDescription(false), ex.getMessage(), ex); + return ResponseEntity.badRequest().body( - new ApiErrorResponse(Instant.now(), ex.getMessage(), List.of(ex.getMessage())) + new ApiErrorResponse(Instant.now(), ex.getMessage(), List.of(ex.getMessage()), correlationId) ); } @ExceptionHandler(UseCaseException.class) - public ResponseEntity handleUseCaseException(UseCaseException ex) { + public ResponseEntity handleUseCaseException(UseCaseException ex, WebRequest request) { + String correlationId = UUID.randomUUID().toString(); + log.warn("Use case exception [{}] at {}: {}", correlationId, request.getDescription(false), ex.getMessage(), ex); + return ResponseEntity.badRequest().body( - new ApiErrorResponse(Instant.now(), ex.getMessage(), List.of(ex.getMessage())) + new ApiErrorResponse(Instant.now(), ex.getMessage(), List.of(ex.getMessage()), correlationId) ); } + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException ex, WebRequest request) { + String correlationId = UUID.randomUUID().toString(); + log.warn("Invalid argument [{}] at {}: {}", correlationId, request.getDescription(false), ex.getMessage()); + + return ResponseEntity.badRequest().body( + new ApiErrorResponse(Instant.now(), "Ungültige Eingabedaten", List.of("Die übermittelten Daten sind ungültig"), correlationId) + ); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(MethodArgumentNotValidException ex, WebRequest request) { + String correlationId = UUID.randomUUID().toString(); + log.warn("Validation failure [{}] at {}: {} validation errors", correlationId, request.getDescription(false), ex.getBindingResult().getErrorCount()); + + List errors = ex.getBindingResult().getFieldErrors().stream() + .map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage()) + .toList(); + + return ResponseEntity.badRequest().body( + new ApiErrorResponse(Instant.now(), "Validierungsfehler in den übermittelten Daten", errors, correlationId) + ); + } @ExceptionHandler(Exception.class) - public ResponseEntity handleGeneric(Exception ex) { - ex.printStackTrace(); // log the stack trace - return ResponseEntity.internalServerError().body( - new ApiErrorResponse(Instant.now(), "Ein unerwarteter Fehler ist aufgetreten", List.of(ex.getMessage())) + public ResponseEntity handleGeneric(Exception ex, WebRequest request) { + String correlationId = UUID.randomUUID().toString(); + log.error("Unexpected error [{}] at {}", correlationId, request.getDescription(false), ex); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( + new ApiErrorResponse(Instant.now(), "Ein unerwarteter Fehler ist aufgetreten", + List.of("Bitte versuchen Sie es später erneut oder kontaktieren Sie den Support"), correlationId) ); } public record ApiErrorResponse( Instant timestamp, String message, - List errors + List errors, + String correlationId ) { } } diff --git a/backend/server/pom.xml b/backend/server/pom.xml index 171eefb..5e92993 100644 --- a/backend/server/pom.xml +++ b/backend/server/pom.xml @@ -122,6 +122,23 @@ 1.0.0 compile + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + org.mockito + mockito-core + test + \ No newline at end of file diff --git a/backend/server/src/main/java/dev/rheinsw/server/internal/customer/controller/CustomerController.java b/backend/server/src/main/java/dev/rheinsw/server/internal/customer/controller/CustomerController.java index 9ce230e..cdfd0c2 100644 --- a/backend/server/src/main/java/dev/rheinsw/server/internal/customer/controller/CustomerController.java +++ b/backend/server/src/main/java/dev/rheinsw/server/internal/customer/controller/CustomerController.java @@ -7,7 +7,9 @@ import dev.rheinsw.server.internal.customer.model.Customer; import dev.rheinsw.server.internal.customer.repository.CustomerRepository; import dev.rheinsw.server.internal.customer.usecase.LoadCustomerQuery; import dev.rheinsw.server.internal.customer.usecase.RegisterCustomerUseCase; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -23,6 +25,7 @@ import java.util.UUID; * @author Thatsaphorn Atchariyaphap * @since 02.07.25 */ +@Slf4j @RestController @RequestMapping("/api/customers") @RequiredArgsConstructor @@ -33,8 +36,11 @@ public class CustomerController extends AbstractController { private final LoadCustomerQuery loadCustomerQuery; @PostMapping - public ResponseEntity register(@RequestBody CreateCustomerDto request) { + public ResponseEntity register(@Valid @RequestBody CreateCustomerDto request) { var currentUser = getUserFromCurrentSession(); + + log.info("User {} registering new customer: {} ({})", + currentUser.getUsername(), request.name(), request.email()); var result = registerCustomerUseCase.register( currentUser, @@ -48,22 +54,47 @@ public class CustomerController extends AbstractController { request.notes() ); + log.info("Successfully registered customer with ID: {} by user: {}", + result, currentUser.getUsername()); + return ResponseEntity.ok(result); } @GetMapping("/{id}") public ResponseEntity loadById(@PathVariable("id") UUID id) { - return ResponseEntity.ok(loadCustomerQuery.loadById(id)); + if (id == null) { + log.warn("Attempted to load customer with null ID"); + throw new IllegalArgumentException("Customer ID cannot be null"); + } + + var currentUser = getUserFromCurrentSession(); + log.debug("User {} loading customer: {}", currentUser.getUsername(), id); + + Customer customer = loadCustomerQuery.loadById(id); + log.debug("Successfully loaded customer: {} for user: {}", + customer.getId(), currentUser.getUsername()); + + return ResponseEntity.ok(customer); } @GetMapping public ResponseEntity> findAll() { + var currentUser = getUserFromCurrentSession(); + log.debug("User {} loading all customers", currentUser.getUsername()); + var result = loadCustomerQuery.findAll(); + log.info("User {} loaded {} customers", currentUser.getUsername(), result.size()); + return ResponseEntity.ok(result); } @PostMapping("/validate") - public ResponseEntity> validateCustomer(@RequestBody CustomerValidationRequest request) { + public ResponseEntity> validateCustomer(@Valid @RequestBody CustomerValidationRequest request) { + var currentUser = getUserFromCurrentSession(); + + log.debug("User {} validating potential customer duplicates for email: {}", + currentUser.getUsername(), request.email()); + List matches = repository.findPotentialDuplicates( request.email(), request.companyName(), @@ -72,6 +103,9 @@ public class CustomerController extends AbstractController { request.city() ); + log.info("Found {} potential duplicate customers for validation by user: {}", + matches.size(), currentUser.getUsername()); + return ResponseEntity.ok(matches); } diff --git a/backend/server/src/main/java/dev/rheinsw/server/internal/customer/dtos/CreateCustomerDto.java b/backend/server/src/main/java/dev/rheinsw/server/internal/customer/dtos/CreateCustomerDto.java index 0238b9e..47c724a 100644 --- a/backend/server/src/main/java/dev/rheinsw/server/internal/customer/dtos/CreateCustomerDto.java +++ b/backend/server/src/main/java/dev/rheinsw/server/internal/customer/dtos/CreateCustomerDto.java @@ -2,6 +2,10 @@ package dev.rheinsw.server.internal.customer.dtos; import dev.rheinsw.server.internal.customer.model.records.CustomerNote; import dev.rheinsw.server.internal.customer.model.records.CustomerPhoneNumber; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import java.util.List; @@ -10,13 +14,33 @@ import java.util.List; * @since 06.07.25 */ public record CreateCustomerDto( + @NotBlank(message = "Email is required") + @Email(message = "Email must be valid") String email, + + @NotBlank(message = "Name is required") + @Size(max = 255, message = "Name cannot exceed 255 characters") String name, + + @Size(max = 255, message = "Company name cannot exceed 255 characters") String companyName, + + @Valid List phoneNumbers, + + @NotBlank(message = "Street is required") + @Size(max = 255, message = "Street cannot exceed 255 characters") String street, + + @NotBlank(message = "ZIP code is required") + @Size(max = 10, message = "ZIP code cannot exceed 10 characters") String zip, + + @NotBlank(message = "City is required") + @Size(max = 100, message = "City cannot exceed 100 characters") String city, + + @Valid List notes ) { } diff --git a/backend/server/src/main/java/dev/rheinsw/server/internal/customer/dtos/CustomerValidationRequest.java b/backend/server/src/main/java/dev/rheinsw/server/internal/customer/dtos/CustomerValidationRequest.java index 0430fa1..57e5f7c 100644 --- a/backend/server/src/main/java/dev/rheinsw/server/internal/customer/dtos/CustomerValidationRequest.java +++ b/backend/server/src/main/java/dev/rheinsw/server/internal/customer/dtos/CustomerValidationRequest.java @@ -1,14 +1,26 @@ package dev.rheinsw.server.internal.customer.dtos; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; + /** * @author Thatsaphorn Atchariyaphap * @since 06.07.25 */ public record CustomerValidationRequest( + @Email(message = "Email must be valid if provided") String email, + + @Size(max = 255, message = "Company name cannot exceed 255 characters") String companyName, + + @Size(max = 255, message = "Street cannot exceed 255 characters") String street, + + @Size(max = 10, message = "ZIP code cannot exceed 10 characters") String zip, + + @Size(max = 100, message = "City cannot exceed 100 characters") String city ) { } \ No newline at end of file diff --git a/backend/server/src/main/java/dev/rheinsw/server/internal/project/controller/ProjectController.java b/backend/server/src/main/java/dev/rheinsw/server/internal/project/controller/ProjectController.java index c222c7e..81f379d 100644 --- a/backend/server/src/main/java/dev/rheinsw/server/internal/project/controller/ProjectController.java +++ b/backend/server/src/main/java/dev/rheinsw/server/internal/project/controller/ProjectController.java @@ -5,7 +5,9 @@ import dev.rheinsw.server.internal.project.model.CreateCustomerProjectDto; import dev.rheinsw.server.internal.project.model.Project; import dev.rheinsw.server.internal.project.model.records.ProjectNote; import dev.rheinsw.server.internal.project.usecase.ProjectUseCaseImpl; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -22,6 +24,7 @@ import java.util.UUID; * @author Thatsaphorn Atchariyaphap * @since 12.07.25 */ +@Slf4j @RestController @RequestMapping("/api/projects") @RequiredArgsConstructor @@ -30,11 +33,16 @@ public class ProjectController extends AbstractController { private final ProjectUseCaseImpl useCase; @PostMapping - public ResponseEntity create(@RequestBody CreateCustomerProjectDto request) { + public ResponseEntity create(@Valid @RequestBody CreateCustomerProjectDto request) { var currentUser = getUserFromCurrentSession(); + log.info("User {} creating new project: {} for customer: {}", + currentUser.getUsername(), request.name(), request.customerId()); + var now = Instant.now(); - var notes = request.notes().stream().map(n -> new ProjectNote(n.text(), currentUser.getId(), currentUser.getId(), now, now)).toList(); + var notes = request.notes().stream() + .map(n -> new ProjectNote(n.text(), currentUser.getId(), currentUser.getId(), now, now)) + .toList(); var result = useCase.createProject( currentUser, @@ -45,18 +53,48 @@ public class ProjectController extends AbstractController { notes ); + log.info("Successfully created project with ID: {} by user: {}", + result, currentUser.getUsername()); + return ResponseEntity.ok(result); } - @GetMapping + @GetMapping("/{id}") public ResponseEntity findProjectById(@PathVariable("id") UUID id) { + if (id == null) { + log.warn("Attempted to load project with null ID"); + throw new IllegalArgumentException("Project ID cannot be null"); + } + + var currentUser = getUserFromCurrentSession(); + log.debug("User {} loading project: {}", currentUser.getUsername(), id); + var result = useCase.getProjectById(id); + if (result == null) { + log.warn("Project not found: {} requested by user: {}", id, currentUser.getUsername()); + throw new IllegalArgumentException("Project not found: " + id); + } + + log.debug("Successfully loaded project: {} for user: {}", + result.getId(), currentUser.getUsername()); + return ResponseEntity.ok(result); } @GetMapping("/customer/{customerId}") public ResponseEntity> findAllCustomerProjects(@PathVariable("customerId") UUID customerId) { + if (customerId == null) { + log.warn("Attempted to load projects with null customer ID"); + throw new IllegalArgumentException("Customer ID cannot be null"); + } + + var currentUser = getUserFromCurrentSession(); + log.debug("User {} loading projects for customer: {}", currentUser.getUsername(), customerId); + var result = useCase.getProjectsByCustomerId(customerId); + log.info("User {} loaded {} projects for customer: {}", + currentUser.getUsername(), result.size(), customerId); + return ResponseEntity.ok(result); } diff --git a/backend/server/src/main/java/dev/rheinsw/server/internal/project/model/CreateCustomerProjectDto.java b/backend/server/src/main/java/dev/rheinsw/server/internal/project/model/CreateCustomerProjectDto.java index 3704f1c..4dfdf77 100644 --- a/backend/server/src/main/java/dev/rheinsw/server/internal/project/model/CreateCustomerProjectDto.java +++ b/backend/server/src/main/java/dev/rheinsw/server/internal/project/model/CreateCustomerProjectDto.java @@ -1,6 +1,10 @@ package dev.rheinsw.server.internal.project.model; import dev.rheinsw.server.internal.project.model.enums.ProjectStatus; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import java.time.LocalDate; import java.util.List; @@ -11,15 +15,28 @@ import java.util.UUID; * @since 13.07.25 */ public record CreateCustomerProjectDto( + @NotNull(message = "Customer ID is required") UUID customerId, // Reference to the related customer + + @NotBlank(message = "Project name is required") + @Size(max = 255, message = "Project name cannot exceed 255 characters") String name, // Project name + + @Size(max = 2000, message = "Project description cannot exceed 2000 characters") String description, // Optional project description + + @NotNull(message = "Project status is required") ProjectStatus status, // Enum for project status + + @Valid List notes, // Optional list of project notes + LocalDate startDate // Project start date ) { public record ProjectNoteDto( + @NotBlank(message = "Note text is required") + @Size(max = 1000, message = "Note text cannot exceed 1000 characters") String text // Note text ) { } diff --git a/backend/server/src/main/java/dev/rheinsw/server/internal/project/usecase/ProjectUseCaseImpl.java b/backend/server/src/main/java/dev/rheinsw/server/internal/project/usecase/ProjectUseCaseImpl.java index 6e911b1..d2a2b43 100644 --- a/backend/server/src/main/java/dev/rheinsw/server/internal/project/usecase/ProjectUseCaseImpl.java +++ b/backend/server/src/main/java/dev/rheinsw/server/internal/project/usecase/ProjectUseCaseImpl.java @@ -1,11 +1,13 @@ package dev.rheinsw.server.internal.project.usecase; +import dev.rheinsw.common.usecase.exception.UseCaseException; import dev.rheinsw.server.security.user.entity.User; import dev.rheinsw.server.internal.project.model.Project; import dev.rheinsw.server.internal.project.model.enums.ProjectStatus; import dev.rheinsw.server.internal.project.model.records.ProjectNote; import dev.rheinsw.server.internal.project.repository.ProjectRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.time.Instant; @@ -16,6 +18,7 @@ import java.util.UUID; * @author Thatsaphorn Atchariyaphap * @since 12.07.25 */ +@Slf4j @Service @RequiredArgsConstructor public class ProjectUseCaseImpl implements LoadProjectUseCase, CreateProjectUseCase { @@ -31,31 +34,87 @@ public class ProjectUseCaseImpl implements LoadProjectUseCase, CreateProjectUseC ProjectStatus status, List notes ) { - final var now = Instant.now(); - var enrichedNotes = notes.stream() - .map(n -> new ProjectNote(n.text(), creator.getId(), creator.getId(), now, now)) - .toList(); + if (creator == null) { + log.error("Cannot create project with null creator"); + throw new IllegalArgumentException("Creator cannot be null"); + } + + if (customerId == null) { + log.error("Cannot create project with null customer ID"); + throw new IllegalArgumentException("Customer ID cannot be null"); + } + + if (name == null || name.trim().isEmpty()) { + log.error("Cannot create project with null or empty name"); + throw new IllegalArgumentException("Project name cannot be null or empty"); + } + + log.debug("Creating project '{}' for customer: {} by user: {}", + name, customerId, creator.getUsername()); - Project project = Project.builder() - .id(UUID.randomUUID()) - .customerId(customerId) - .name(name) - .description(description) - .status(status) - .notes(enrichedNotes) - .build(); + try { + final var now = Instant.now(); + var enrichedNotes = (notes != null) ? notes.stream() + .map(n -> new ProjectNote(n.text(), creator.getId(), creator.getId(), now, now)) + .toList() : List.of(); - var savedProject = repository.save(project); - return savedProject.getId(); + Project project = Project.builder() + .id(UUID.randomUUID()) + .customerId(customerId) + .name(name.trim()) + .description(description != null ? description.trim() : null) + .status(status) + .notes(enrichedNotes) + .build(); + + var savedProject = repository.save(project); + log.info("Successfully created project: {} for customer: {} by user: {}", + savedProject.getId(), customerId, creator.getUsername()); + + return savedProject.getId(); + } catch (Exception e) { + log.error("Failed to create project '{}' for customer: {} by user: {}", + name, customerId, creator.getUsername(), e); + throw new UseCaseException("Failed to create project: " + e.getMessage()); + } } @Override public Project getProjectById(UUID id) { - return repository.findById(id).orElse(null); + if (id == null) { + log.error("Cannot get project with null ID"); + throw new IllegalArgumentException("Project ID cannot be null"); + } + + log.debug("Loading project: {}", id); + + return repository.findById(id) + .map(project -> { + log.debug("Found project: {} ({})", project.getName(), project.getId()); + return project; + }) + .orElseThrow(() -> { + log.warn("Project not found: {}", id); + return new UseCaseException("Project not found: " + id); + }); } @Override public List getProjectsByCustomerId(UUID customerId) { - return repository.findByCustomerId(customerId); + if (customerId == null) { + log.error("Cannot get projects with null customer ID"); + throw new IllegalArgumentException("Customer ID cannot be null"); + } + + log.debug("Loading projects for customer: {}", customerId); + + try { + List projects = repository.findByCustomerId(customerId); + log.debug("Found {} projects for customer: {}", projects.size(), customerId); + return projects; + } catch (Exception e) { + log.error("Failed to load projects for customer: {}", customerId, e); + throw new UseCaseException("Failed to load projects for customer: " + e.getMessage()); + } } } diff --git a/backend/server/src/main/java/dev/rheinsw/server/security/session/CurrentSessionProvider.java b/backend/server/src/main/java/dev/rheinsw/server/security/session/CurrentSessionProvider.java index 59bc011..d071d64 100644 --- a/backend/server/src/main/java/dev/rheinsw/server/security/session/CurrentSessionProvider.java +++ b/backend/server/src/main/java/dev/rheinsw/server/security/session/CurrentSessionProvider.java @@ -1,6 +1,7 @@ package dev.rheinsw.server.security.session; import dev.rheinsw.server.security.session.model.CurrentSession; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.jwt.Jwt; @@ -10,19 +11,52 @@ import org.springframework.stereotype.Component; * @author Thatsaphorn Atchariyaphap * @since 04.07.25 */ +@Slf4j @Component public class CurrentSessionProvider { public CurrentSession getCurrentSession() { + log.debug("Retrieving current user session"); + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null) { + log.warn("No authentication context found"); + throw new IllegalStateException("Authentication is missing"); + } + + if (auth.getPrincipal() == null) { + log.warn("Authentication principal is null"); + throw new IllegalStateException("Authentication principal is missing"); + } + if (!(auth.getPrincipal() instanceof Jwt jwt)) { + log.warn("Authentication principal is not a JWT token: {}", + auth.getPrincipal().getClass().getSimpleName()); throw new IllegalStateException("JWT is missing or invalid"); } - return new CurrentSession( - jwt.getClaimAsString("sub"), - jwt.getClaimAsString("preferred_username"), - jwt.getClaimAsString("email") - ); + String sub = jwt.getClaimAsString("sub"); + String username = jwt.getClaimAsString("preferred_username"); + String email = jwt.getClaimAsString("email"); + + if (sub == null || sub.isEmpty()) { + log.error("JWT 'sub' claim is missing or empty"); + throw new IllegalStateException("Required JWT claim 'sub' is missing"); + } + + if (username == null || username.isEmpty()) { + log.error("JWT 'preferred_username' claim is missing or empty for user: {}", sub); + throw new IllegalStateException("Required JWT claim 'preferred_username' is missing"); + } + + if (email == null || email.isEmpty()) { + log.error("JWT 'email' claim is missing or empty for user: {}", sub); + throw new IllegalStateException("Required JWT claim 'email' is missing"); + } + + CurrentSession session = new CurrentSession(sub, username, email); + log.info("Successfully retrieved session for user: {} ({})", username, sub); + + return session; } } \ No newline at end of file diff --git a/backend/server/src/main/java/dev/rheinsw/server/security/session/UserSessionFilter.java b/backend/server/src/main/java/dev/rheinsw/server/security/session/UserSessionFilter.java index f6f7167..78e0cb3 100644 --- a/backend/server/src/main/java/dev/rheinsw/server/security/session/UserSessionFilter.java +++ b/backend/server/src/main/java/dev/rheinsw/server/security/session/UserSessionFilter.java @@ -8,6 +8,8 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.NonNull; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -17,6 +19,7 @@ import java.io.IOException; * @author Thatsaphorn Atchariyaphap * @since 04.07.25 */ +@Slf4j @Component @RequiredArgsConstructor public class UserSessionFilter extends OncePerRequestFilter { @@ -28,11 +31,25 @@ public class UserSessionFilter extends OncePerRequestFilter { protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { + + String requestUri = request.getRequestURI(); + String method = request.getMethod(); + + log.debug("Processing user session for {} {}", method, requestUri); + try { CurrentSession session = currentSessionProvider.getCurrentSession(); + log.debug("Retrieved session for user: {} from {}", session.username(), requestUri); + userService.getUserBySession(session); + log.debug("User validation successful for: {}", session.username()); + + } catch (AuthenticationException e) { + log.warn("Authentication failed for {} {}: {}", method, requestUri, e.getMessage()); + } catch (IllegalStateException e) { + log.warn("Session state error for {} {}: {}", method, requestUri, e.getMessage()); } catch (Exception e) { - // You might want to log this but not block the request + log.error("Unexpected error during user session processing for {} {}", method, requestUri, e); } filterChain.doFilter(request, response); diff --git a/backend/server/src/main/java/dev/rheinsw/server/security/user/UserService.java b/backend/server/src/main/java/dev/rheinsw/server/security/user/UserService.java index cf66518..9247898 100644 --- a/backend/server/src/main/java/dev/rheinsw/server/security/user/UserService.java +++ b/backend/server/src/main/java/dev/rheinsw/server/security/user/UserService.java @@ -5,6 +5,7 @@ import dev.rheinsw.server.security.session.model.CurrentSession; import dev.rheinsw.server.security.user.repository.UserRepository; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.time.Instant; @@ -13,6 +14,7 @@ import java.time.Instant; * @author Thatsaphorn Atchariyaphap * @since 04.07.25 */ +@Slf4j @Service @RequiredArgsConstructor public class UserService { @@ -21,18 +23,48 @@ public class UserService { @Transactional public User getUserBySession(CurrentSession session) { + if (session == null) { + log.error("Attempted to get user with null session"); + throw new IllegalArgumentException("Session cannot be null"); + } + + if (session.keycloakId() == null || session.keycloakId().isEmpty()) { + log.error("Attempted to get user with null or empty keycloakId"); + throw new IllegalArgumentException("Session keycloakId cannot be null or empty"); + } + + log.debug("Looking up user for keycloakId: {}", session.keycloakId()); + return userRepository.findByKeycloakId(session.keycloakId()) + .map(existingUser -> { + log.debug("Found existing user: {} ({})", existingUser.getUsername(), existingUser.getId()); + return existingUser; + }) .orElseGet(() -> createUser(session)); } private User createUser(CurrentSession session) { - User newUser = User.builder() - .keycloakId(session.keycloakId()) - .username(session.username()) - .email(session.email()) - .createdAt(Instant.now()) - .build(); - return userRepository.save(newUser); + log.info("Creating new user for keycloakId: {}, username: {}, email: {}", + session.keycloakId(), session.username(), session.email()); + + try { + User newUser = User.builder() + .keycloakId(session.keycloakId()) + .username(session.username()) + .email(session.email()) + .createdAt(Instant.now()) + .build(); + + User savedUser = userRepository.save(newUser); + log.info("Successfully created new user with ID: {} for username: {}", + savedUser.getId(), savedUser.getUsername()); + + return savedUser; + } catch (Exception e) { + log.error("Failed to create new user for keycloakId: {}, username: {}", + session.keycloakId(), session.username(), e); + throw new RuntimeException("Failed to create user", e); + } } } diff --git a/backend/server/src/test/java/dev/rheinsw/server/internal/project/usecase/ProjectUseCaseImplTest.java b/backend/server/src/test/java/dev/rheinsw/server/internal/project/usecase/ProjectUseCaseImplTest.java new file mode 100644 index 0000000..f86b53a --- /dev/null +++ b/backend/server/src/test/java/dev/rheinsw/server/internal/project/usecase/ProjectUseCaseImplTest.java @@ -0,0 +1,224 @@ +package dev.rheinsw.server.internal.project.usecase; + +import dev.rheinsw.common.usecase.exception.UseCaseException; +import dev.rheinsw.server.internal.project.model.Project; +import dev.rheinsw.server.internal.project.model.enums.ProjectStatus; +import dev.rheinsw.server.internal.project.model.records.ProjectNote; +import dev.rheinsw.server.internal.project.repository.ProjectRepository; +import dev.rheinsw.server.security.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProjectUseCaseImplTest { + + @Mock + private ProjectRepository projectRepository; + + private ProjectUseCaseImpl projectUseCase; + + private User testUser; + private UUID customerId; + + @BeforeEach + void setUp() { + projectUseCase = new ProjectUseCaseImpl(projectRepository); + testUser = User.builder() + .id(1L) + .keycloakId("keycloak123") + .username("testuser") + .email("test@example.com") + .createdAt(Instant.now()) + .build(); + customerId = UUID.randomUUID(); + } + + @Test + void createProject_WithValidData_ShouldCreateProject() { + // Given + String projectName = "Test Project"; + String description = "Test Description"; + ProjectStatus status = ProjectStatus.IN_PROGRESS; + List notes = List.of(); + + Project savedProject = Project.builder() + .id(UUID.randomUUID()) + .customerId(customerId) + .name(projectName) + .description(description) + .status(status) + .notes(List.of()) + .build(); + + when(projectRepository.save(any(Project.class))).thenReturn(savedProject); + + // When + UUID result = projectUseCase.createProject(testUser, customerId, projectName, description, status, notes); + + // Then + assertThat(result).isEqualTo(savedProject.getId()); + verify(projectRepository).save(argThat(project -> + project.getCustomerId().equals(customerId) && + project.getName().equals(projectName) && + project.getDescription().equals(description) && + project.getStatus().equals(status) + )); + } + + @Test + void createProject_WithNotes_ShouldEnrichNotesWithCreatorInfo() { + // Given + String projectName = "Test Project"; + List notes = List.of( + new ProjectNote("Note 1", null, null, null, null) + ); + + Project savedProject = Project.builder() + .id(UUID.randomUUID()) + .customerId(customerId) + .name(projectName) + .status(ProjectStatus.IN_PROGRESS) + .notes(List.of()) + .build(); + + when(projectRepository.save(any(Project.class))).thenReturn(savedProject); + + // When + UUID result = projectUseCase.createProject(testUser, customerId, projectName, null, ProjectStatus.IN_PROGRESS, notes); + + // Then + assertThat(result).isEqualTo(savedProject.getId()); + verify(projectRepository).save(argThat(project -> { + ProjectNote enrichedNote = project.getNotes().get(0); + return enrichedNote.text().equals("Note 1") && + enrichedNote.createdBy().equals(testUser.getId()) && + enrichedNote.updatedBy().equals(testUser.getId()) && + enrichedNote.createdAt() != null && + enrichedNote.updatedAt() != null; + })); + } + + @Test + void createProject_WithNullCreator_ShouldThrowException() { + // When & Then + assertThatThrownBy(() -> projectUseCase.createProject(null, customerId, "Project", null, ProjectStatus.IN_PROGRESS, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Creator cannot be null"); + } + + @Test + void createProject_WithNullCustomerId_ShouldThrowException() { + // When & Then + assertThatThrownBy(() -> projectUseCase.createProject(testUser, null, "Project", null, ProjectStatus.IN_PROGRESS, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Customer ID cannot be null"); + } + + @Test + void createProject_WithNullName_ShouldThrowException() { + // When & Then + assertThatThrownBy(() -> projectUseCase.createProject(testUser, customerId, null, null, ProjectStatus.IN_PROGRESS, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Project name cannot be null or empty"); + } + + @Test + void createProject_WithEmptyName_ShouldThrowException() { + // When & Then + assertThatThrownBy(() -> projectUseCase.createProject(testUser, customerId, " ", null, ProjectStatus.IN_PROGRESS, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Project name cannot be null or empty"); + } + + @Test + void getProjectById_WithExistingProject_ShouldReturnProject() { + // Given + UUID projectId = UUID.randomUUID(); + Project project = Project.builder() + .id(projectId) + .customerId(customerId) + .name("Test Project") + .status(ProjectStatus.IN_PROGRESS) + .build(); + + when(projectRepository.findById(projectId)).thenReturn(Optional.of(project)); + + // When + Project result = projectUseCase.getProjectById(projectId); + + // Then + assertThat(result).isEqualTo(project); + } + + @Test + void getProjectById_WithNonExistentProject_ShouldThrowException() { + // Given + UUID projectId = UUID.randomUUID(); + when(projectRepository.findById(projectId)).thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> projectUseCase.getProjectById(projectId)) + .isInstanceOf(UseCaseException.class) + .hasMessage("Project not found: " + projectId); + } + + @Test + void getProjectById_WithNullId_ShouldThrowException() { + // When & Then + assertThatThrownBy(() -> projectUseCase.getProjectById(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Project ID cannot be null"); + } + + @Test + void getProjectsByCustomerId_WithExistingProjects_ShouldReturnProjects() { + // Given + List projects = List.of( + Project.builder().id(UUID.randomUUID()).customerId(customerId).name("Project 1").build(), + Project.builder().id(UUID.randomUUID()).customerId(customerId).name("Project 2").build() + ); + + when(projectRepository.findByCustomerId(customerId)).thenReturn(projects); + + // When + List result = projectUseCase.getProjectsByCustomerId(customerId); + + // Then + assertThat(result).hasSize(2); + assertThat(result).isEqualTo(projects); + } + + @Test + void getProjectsByCustomerId_WithNullCustomerId_ShouldThrowException() { + // When & Then + assertThatThrownBy(() -> projectUseCase.getProjectsByCustomerId(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Customer ID cannot be null"); + } + + @Test + void getProjectsByCustomerId_WithRepositoryException_ShouldThrowUseCaseException() { + // Given + when(projectRepository.findByCustomerId(customerId)).thenThrow(new RuntimeException("Database error")); + + // When & Then + assertThatThrownBy(() -> projectUseCase.getProjectsByCustomerId(customerId)) + .isInstanceOf(UseCaseException.class) + .hasMessage("Failed to load projects for customer: Database error"); + } +} \ No newline at end of file diff --git a/backend/server/src/test/java/dev/rheinsw/server/security/session/CurrentSessionProviderTest.java b/backend/server/src/test/java/dev/rheinsw/server/security/session/CurrentSessionProviderTest.java new file mode 100644 index 0000000..583dd6d --- /dev/null +++ b/backend/server/src/test/java/dev/rheinsw/server/security/session/CurrentSessionProviderTest.java @@ -0,0 +1,151 @@ +package dev.rheinsw.server.security.session; + +import dev.rheinsw.server.security.session.model.CurrentSession; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CurrentSessionProviderTest { + + @Mock + private SecurityContext securityContext; + + @Mock + private Authentication authentication; + + @Mock + private Jwt jwt; + + private CurrentSessionProvider currentSessionProvider; + + @BeforeEach + void setUp() { + currentSessionProvider = new CurrentSessionProvider(); + SecurityContextHolder.setContext(securityContext); + } + + @Test + void getCurrentSession_WithValidJwt_ShouldReturnCurrentSession() { + // Given + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn(jwt); + when(jwt.getClaimAsString("sub")).thenReturn("user123"); + when(jwt.getClaimAsString("preferred_username")).thenReturn("testuser"); + when(jwt.getClaimAsString("email")).thenReturn("test@example.com"); + + // When + CurrentSession result = currentSessionProvider.getCurrentSession(); + + // Then + assertThat(result).isNotNull(); + assertThat(result.keycloakId()).isEqualTo("user123"); + assertThat(result.username()).isEqualTo("testuser"); + assertThat(result.email()).isEqualTo("test@example.com"); + } + + @Test + void getCurrentSession_WithNullAuthentication_ShouldThrowException() { + // Given + when(securityContext.getAuthentication()).thenReturn(null); + + // When & Then + assertThatThrownBy(() -> currentSessionProvider.getCurrentSession()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Authentication is missing"); + } + + @Test + void getCurrentSession_WithNullPrincipal_ShouldThrowException() { + // Given + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn(null); + + // When & Then + assertThatThrownBy(() -> currentSessionProvider.getCurrentSession()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Authentication principal is missing"); + } + + @Test + void getCurrentSession_WithNonJwtPrincipal_ShouldThrowException() { + // Given + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn("not-a-jwt"); + + // When & Then + assertThatThrownBy(() -> currentSessionProvider.getCurrentSession()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("JWT is missing or invalid"); + } + + @Test + void getCurrentSession_WithMissingSubClaim_ShouldThrowException() { + // Given + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn(jwt); + when(jwt.getClaimAsString("sub")).thenReturn(null); + when(jwt.getClaimAsString("preferred_username")).thenReturn("testuser"); + when(jwt.getClaimAsString("email")).thenReturn("test@example.com"); + + // When & Then + assertThatThrownBy(() -> currentSessionProvider.getCurrentSession()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Required JWT claim 'sub' is missing"); + } + + @Test + void getCurrentSession_WithEmptySubClaim_ShouldThrowException() { + // Given + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn(jwt); + when(jwt.getClaimAsString("sub")).thenReturn(""); + when(jwt.getClaimAsString("preferred_username")).thenReturn("testuser"); + when(jwt.getClaimAsString("email")).thenReturn("test@example.com"); + + // When & Then + assertThatThrownBy(() -> currentSessionProvider.getCurrentSession()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Required JWT claim 'sub' is missing"); + } + + @Test + void getCurrentSession_WithMissingUsernameClaim_ShouldThrowException() { + // Given + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn(jwt); + when(jwt.getClaimAsString("sub")).thenReturn("user123"); + when(jwt.getClaimAsString("preferred_username")).thenReturn(null); + when(jwt.getClaimAsString("email")).thenReturn("test@example.com"); + + // When & Then + assertThatThrownBy(() -> currentSessionProvider.getCurrentSession()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Required JWT claim 'preferred_username' is missing"); + } + + @Test + void getCurrentSession_WithMissingEmailClaim_ShouldThrowException() { + // Given + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn(jwt); + when(jwt.getClaimAsString("sub")).thenReturn("user123"); + when(jwt.getClaimAsString("preferred_username")).thenReturn("testuser"); + when(jwt.getClaimAsString("email")).thenReturn(null); + + // When & Then + assertThatThrownBy(() -> currentSessionProvider.getCurrentSession()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Required JWT claim 'email' is missing"); + } +} \ No newline at end of file diff --git a/backend/server/src/test/java/dev/rheinsw/server/security/user/UserServiceTest.java b/backend/server/src/test/java/dev/rheinsw/server/security/user/UserServiceTest.java new file mode 100644 index 0000000..cb26f0b --- /dev/null +++ b/backend/server/src/test/java/dev/rheinsw/server/security/user/UserServiceTest.java @@ -0,0 +1,139 @@ +package dev.rheinsw.server.security.user; + +import dev.rheinsw.server.security.session.model.CurrentSession; +import dev.rheinsw.server.security.user.entity.User; +import dev.rheinsw.server.security.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + + private UserService userService; + + @BeforeEach + void setUp() { + userService = new UserService(userRepository); + } + + @Test + void getUserBySession_WithExistingUser_ShouldReturnUser() { + // Given + CurrentSession session = new CurrentSession("keycloak123", "testuser", "test@example.com"); + User existingUser = User.builder() + .id(1L) + .keycloakId("keycloak123") + .username("testuser") + .email("test@example.com") + .createdAt(Instant.now()) + .build(); + + when(userRepository.findByKeycloakId("keycloak123")).thenReturn(Optional.of(existingUser)); + + // When + User result = userService.getUserBySession(session); + + // Then + assertThat(result).isEqualTo(existingUser); + verify(userRepository, never()).save(any()); + } + + @Test + void getUserBySession_WithNewUser_ShouldCreateAndReturnUser() { + // Given + CurrentSession session = new CurrentSession("keycloak456", "newuser", "new@example.com"); + User newUser = User.builder() + .id(2L) + .keycloakId("keycloak456") + .username("newuser") + .email("new@example.com") + .createdAt(Instant.now()) + .build(); + + when(userRepository.findByKeycloakId("keycloak456")).thenReturn(Optional.empty()); + when(userRepository.save(any(User.class))).thenReturn(newUser); + + // When + User result = userService.getUserBySession(session); + + // Then + assertThat(result).isEqualTo(newUser); + verify(userRepository).save(argThat(user -> + user.getKeycloakId().equals("keycloak456") && + user.getUsername().equals("newuser") && + user.getEmail().equals("new@example.com") && + user.getCreatedAt() != null + )); + } + + @Test + void getUserBySession_WithNullSession_ShouldThrowException() { + // When & Then + assertThatThrownBy(() -> userService.getUserBySession(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Session cannot be null"); + + verify(userRepository, never()).findByKeycloakId(any()); + verify(userRepository, never()).save(any()); + } + + @Test + void getUserBySession_WithNullKeycloakId_ShouldThrowException() { + // Given + CurrentSession session = new CurrentSession(null, "testuser", "test@example.com"); + + // When & Then + assertThatThrownBy(() -> userService.getUserBySession(session)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Session keycloakId cannot be null or empty"); + + verify(userRepository, never()).findByKeycloakId(any()); + verify(userRepository, never()).save(any()); + } + + @Test + void getUserBySession_WithEmptyKeycloakId_ShouldThrowException() { + // Given + CurrentSession session = new CurrentSession("", "testuser", "test@example.com"); + + // When & Then + assertThatThrownBy(() -> userService.getUserBySession(session)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Session keycloakId cannot be null or empty"); + + verify(userRepository, never()).findByKeycloakId(any()); + verify(userRepository, never()).save(any()); + } + + @Test + void getUserBySession_WithRepositoryException_ShouldThrowRuntimeException() { + // Given + CurrentSession session = new CurrentSession("keycloak789", "testuser", "test@example.com"); + + when(userRepository.findByKeycloakId("keycloak789")).thenReturn(Optional.empty()); + when(userRepository.save(any(User.class))).thenThrow(new RuntimeException("Database error")); + + // When & Then + assertThatThrownBy(() -> userService.getUserBySession(session)) + .isInstanceOf(RuntimeException.class) + .hasMessage("Failed to create user"); + } +} \ No newline at end of file diff --git a/internal_frontend/services/customers/usecases/addCustomer.ts b/internal_frontend/services/customers/usecases/addCustomer.ts index 3d80309..bf564e9 100644 --- a/internal_frontend/services/customers/usecases/addCustomer.ts +++ b/internal_frontend/services/customers/usecases/addCustomer.ts @@ -34,6 +34,12 @@ export async function addCustomer(params: CreateCustomerDto): Promise { const errorMessage = isJson ? (rawBody?.message ?? rawBody?.errors?.join(", ")) ?? "Unbekannter Fehler" : String(rawBody); + + // Log correlation ID if available for debugging + if (isJson && rawBody?.correlationId) { + console.error(`[api /api/customers] Error occurred [${rawBody.correlationId}]:`, errorMessage); + } + throw new Error(errorMessage); } diff --git a/internal_frontend/services/customers/usecases/validateCustomer.ts b/internal_frontend/services/customers/usecases/validateCustomer.ts index d440c69..30aae1a 100644 --- a/internal_frontend/services/customers/usecases/validateCustomer.ts +++ b/internal_frontend/services/customers/usecases/validateCustomer.ts @@ -30,6 +30,12 @@ export async function validateCustomer(input: { const errorMessage = isJson ? (rawBody?.message ?? rawBody?.errors?.join(", ")) ?? "Unbekannter Fehler" : String(rawBody); + + // Log correlation ID if available for debugging + if (isJson && rawBody?.correlationId) { + console.error(`[api /api/customers/validate] Error occurred [${rawBody.correlationId}]:`, errorMessage); + } + throw new Error(errorMessage); }