diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/main/java/backend/fullstack/auth/AuthService.java b/backend/src/main/java/backend/fullstack/auth/AuthService.java index 3fbf896..604db0f 100644 --- a/backend/src/main/java/backend/fullstack/auth/AuthService.java +++ b/backend/src/main/java/backend/fullstack/auth/AuthService.java @@ -55,7 +55,7 @@ public AuthService( */ public User registerBootstrapAdmin(RegisterRequest request) { if (userRepository.count() > 0) { - throw new AccessDeniedException("Public registration is disabled after bootstrap"); + throw new AccessDeniedException("Bootstrap registration is only available before the first user is created"); } if (request.getRole() != Role.ADMIN) { diff --git a/backend/src/main/java/backend/fullstack/config/SecurityConfig.java b/backend/src/main/java/backend/fullstack/config/SecurityConfig.java index 238e04a..1c316ce 100644 --- a/backend/src/main/java/backend/fullstack/config/SecurityConfig.java +++ b/backend/src/main/java/backend/fullstack/config/SecurityConfig.java @@ -28,15 +28,18 @@ public class SecurityConfig { private final JwtAuthFilter jwtAuthFilter; + private final SecurityErrorHandler securityErrorHandler; private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; public SecurityConfig( JwtAuthFilter jwtAuthFilter, + SecurityErrorHandler securityErrorHandler, UserRepository userRepository, PasswordEncoder passwordEncoder ) { this.jwtAuthFilter = jwtAuthFilter; + this.securityErrorHandler = securityErrorHandler; this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; } @@ -46,12 +49,17 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint(securityErrorHandler) + .accessDeniedHandler(securityErrorHandler) + ) .authenticationProvider(authenticationProvider()) .authorizeHttpRequests(auth -> auth .requestMatchers("/error").permitAll() .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() .requestMatchers(HttpMethod.POST, "/api/auth/login", "/api/auth/logout").permitAll() - .requestMatchers(HttpMethod.POST, "/api/auth/register").access(this::canAccessRegister) + .requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/organization") + .access(this::canAccessBootstrapSetup) .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); @@ -78,20 +86,22 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration c return configuration.getAuthenticationManager(); } - private AuthorizationDecision canAccessRegister( + private AuthorizationDecision canAccessBootstrapSetup( Supplier authentication, RequestAuthorizationContext context ) { - return new AuthorizationDecision(isRegisterAccessAllowed(authentication.get())); + return new AuthorizationDecision(isBootstrapSetupAllowed(authentication.get())); } boolean isRegisterAccessAllowed(Authentication auth) { - if (userRepository.count() == 0) { - return true; - } + return isBootstrapSetupAllowed(auth); + } + + boolean isOrganizationCreateAccessAllowed(Authentication auth) { + return isBootstrapSetupAllowed(auth); + } - return auth != null - && auth.isAuthenticated() - && auth.getAuthorities().stream().anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority())); + private boolean isBootstrapSetupAllowed(Authentication auth) { + return userRepository.count() == 0; } } diff --git a/backend/src/main/java/backend/fullstack/config/SecurityErrorHandler.java b/backend/src/main/java/backend/fullstack/config/SecurityErrorHandler.java new file mode 100644 index 0000000..c8a601e --- /dev/null +++ b/backend/src/main/java/backend/fullstack/config/SecurityErrorHandler.java @@ -0,0 +1,87 @@ +package backend.fullstack.config; + +import java.io.IOException; +import java.time.LocalDateTime; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import backend.fullstack.dto.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Writes security-layer authentication and authorization failures using the + * same JSON error envelope as controller exceptions. + * + * @version 1.0 + * @since 04.04.26 + */ +@Component +public class SecurityErrorHandler implements AuthenticationEntryPoint, AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + public SecurityErrorHandler(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + writeErrorResponse( + response, + HttpStatus.UNAUTHORIZED, + "UNAUTHORIZED", + authException.getMessage() == null ? "Authentication is required" : authException.getMessage(), + request.getRequestURI() + ); + } + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + writeErrorResponse( + response, + HttpStatus.FORBIDDEN, + "ACCESS_DENIED", + accessDeniedException.getMessage() == null ? "Access denied" : accessDeniedException.getMessage(), + request.getRequestURI() + ); + } + + private void writeErrorResponse( + HttpServletResponse response, + HttpStatus status, + String errorCode, + String message, + String path + ) throws IOException { + ErrorResponse errorResponse = new ErrorResponse( + LocalDateTime.now(), + status.value(), + status.getReasonPhrase(), + errorCode, + message, + null, + path + ); + + response.setStatus(status.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(response.getOutputStream(), errorResponse); + } +} diff --git a/backend/src/main/java/backend/fullstack/location/LocationController.java b/backend/src/main/java/backend/fullstack/location/LocationController.java index 12c4851..4d1c737 100644 --- a/backend/src/main/java/backend/fullstack/location/LocationController.java +++ b/backend/src/main/java/backend/fullstack/location/LocationController.java @@ -19,6 +19,18 @@ import java.util.List; +/** + * Controller for managing locations within an organization. + * Endpoints: + * - POST /api/locations: Create a new location. + * - GET /api/locations: Get all accessible locations. + * - GET /api/locations/{id}: Get location by ID. + * - PUT /api/locations/{id}: Update location. + * - DELETE /api/locations/{id}: Delete location. + * + * @version 1.0 + * @since 03.04.26 + */ @RestController @RequestMapping("/api/locations") @RequiredArgsConstructor diff --git a/backend/src/main/java/backend/fullstack/location/LocationService.java b/backend/src/main/java/backend/fullstack/location/LocationService.java index 83b8a3d..ea8fa20 100644 --- a/backend/src/main/java/backend/fullstack/location/LocationService.java +++ b/backend/src/main/java/backend/fullstack/location/LocationService.java @@ -16,7 +16,7 @@ /** * Service class for managing locations within an organization. * - * @version 1.0 + * @version 1.1 * @since 28.03.26 */ @Service diff --git a/backend/src/main/java/backend/fullstack/organization/OrganizationService.java b/backend/src/main/java/backend/fullstack/organization/OrganizationService.java index 05787f7..53818b9 100644 --- a/backend/src/main/java/backend/fullstack/organization/OrganizationService.java +++ b/backend/src/main/java/backend/fullstack/organization/OrganizationService.java @@ -18,8 +18,8 @@ /** * Service class for managing organizations. * - * @version 1.0 - * @since 28.03.26 + * @version 1.1 + * @since 03.04.26 */ @Service @RequiredArgsConstructor diff --git a/backend/src/main/java/backend/fullstack/permission/PermissionBootstrapSeeder.java b/backend/src/main/java/backend/fullstack/permission/PermissionBootstrapSeeder.java new file mode 100644 index 0000000..6cbac2c --- /dev/null +++ b/backend/src/main/java/backend/fullstack/permission/PermissionBootstrapSeeder.java @@ -0,0 +1,96 @@ +package backend.fullstack.permission; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import backend.fullstack.user.role.Role; + +/** + * Component responsible for seeding the permission system with initial data on application startup. + * + * @version 1.0 + * @since 31.03.26 + */ +@Component +public class PermissionBootstrapSeeder implements ApplicationRunner { + + private static final Logger logger = LoggerFactory.getLogger(PermissionBootstrapSeeder.class); + + private final PermissionDefinitionRepository permissionDefinitionRepository; + private final RolePermissionBindingRepository rolePermissionBindingRepository; + + public PermissionBootstrapSeeder( + PermissionDefinitionRepository permissionDefinitionRepository, + RolePermissionBindingRepository rolePermissionBindingRepository + ) { + this.permissionDefinitionRepository = permissionDefinitionRepository; + this.rolePermissionBindingRepository = rolePermissionBindingRepository; + } + + /** + * Seeds the permission system with initial data on application startup. + * + * @param args the application arguments + */ + @Override + @Transactional + public void run(ApplicationArguments args) { + try { + seedPermissionDefinitions(); + seedRolePermissions(); + } catch (RuntimeException ex) { + // Startup should not fail solely because permission seed cannot run. + logger.warn("Permission bootstrap seed skipped due to repository/database issue", ex); + } + } + + /** + * Seeds the permission definitions with initial data. + */ + private void seedPermissionDefinitions() { + for (Permission permission : Permission.values()) { + permissionDefinitionRepository.findByPermissionKey(permission.key()) + .orElseGet(() -> permissionDefinitionRepository.save( + PermissionDefinition.builder() + .permissionKey(permission.key()) + .description(permission.key()) + .build() + )); + } + } + + /** + * Seeds the role permissions with initial data. + */ + private void seedRolePermissions() { + Map> defaults = DefaultRolePermissionMatrix.create(); + + for (Map.Entry> entry : defaults.entrySet()) { + Role role = entry.getKey(); + if (rolePermissionBindingRepository.existsByRole(role)) { + continue; + } + + for (Permission permission : entry.getValue()) { + PermissionDefinition definition = permissionDefinitionRepository + .findByPermissionKey(permission.key()) + .orElseThrow(() -> new IllegalStateException("Permission definition missing: " + permission.key())); + + rolePermissionBindingRepository.save( + RolePermissionBinding.builder() + .role(role) + .permission(definition) + .build() + ); + } + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/backend/fullstack/permission/PermissionScope.java b/backend/src/main/java/backend/fullstack/permission/PermissionScope.java new file mode 100644 index 0000000..f8a47f1 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/permission/PermissionScope.java @@ -0,0 +1,9 @@ +package backend.fullstack.permission; + +/** + * Enum representing the scope of a permission. + */ +public enum PermissionScope { + ORGANIZATION, + LOCATION +} diff --git a/backend/src/main/java/backend/fullstack/permission/catalog/DefaultRolePermissionMatrix.java b/backend/src/main/java/backend/fullstack/permission/catalog/DefaultRolePermissionMatrix.java index 1d62332..3bf2b1c 100644 --- a/backend/src/main/java/backend/fullstack/permission/catalog/DefaultRolePermissionMatrix.java +++ b/backend/src/main/java/backend/fullstack/permission/catalog/DefaultRolePermissionMatrix.java @@ -36,6 +36,7 @@ public static Map> create() { Permission.LOGS_TEMPERATURE_READ, Permission.CHECKLISTS_READ, Permission.DEVIATIONS_READ, + Permission.DEVIATIONS_RESOLVE, Permission.REPORTS_READ, Permission.REPORTS_EXPORT )); @@ -49,9 +50,13 @@ public static Map> create() { Permission.LOGS_TEMPERATURE_CREATE, Permission.CHECKLISTS_READ, Permission.CHECKLISTS_COMPLETE, + Permission.CHECKLISTS_APPROVE, + Permission.CHECKLISTS_TEMPLATE_MANAGE, Permission.DEVIATIONS_READ, Permission.DEVIATIONS_CREATE, - Permission.REPORTS_READ + Permission.DEVIATIONS_RESOLVE, + Permission.REPORTS_READ, + Permission.REPORTS_EXPORT )); mapping.put(Role.STAFF, EnumSet.of( diff --git a/backend/src/main/java/backend/fullstack/permission/core/AuthorizationService.java b/backend/src/main/java/backend/fullstack/permission/core/AuthorizationService.java index bcb3ed4..dd7e945 100644 --- a/backend/src/main/java/backend/fullstack/permission/core/AuthorizationService.java +++ b/backend/src/main/java/backend/fullstack/permission/core/AuthorizationService.java @@ -194,7 +194,6 @@ public boolean canViewUser(User targetUser) { */ public void assertCanCreateUser(Role targetRole, Long primaryLocationId) { assertPermission(Permission.USERS_CREATE); - User actor = accessContext.getCurrentUser(); switch (actor.getRole()) { @@ -235,6 +234,10 @@ public void assertCanManageUser(User targetUser) { return; } + if (actor.getId().equals(targetUser.getId())) { + return; + } + if (targetUser.getRole() == Role.ADMIN) { throw new AccessDeniedException("Only ADMIN can manage ADMIN users"); } @@ -274,32 +277,23 @@ public void assertCanManageUser(User targetUser) { */ public void assertCanChangeRole(User targetUser, Role newRole, Long primaryLocationId) { assertCanManageUser(targetUser); - User actor = accessContext.getCurrentUser(); - if (actor.getRole() == Role.ADMIN) { - return; - } - if (actor.getRole() == Role.SUPERVISOR) { - if (newRole != Role.MANAGER && newRole != Role.STAFF) { - throw new AccessDeniedException("Supervisors can only assign MANAGER and STAFF roles"); + switch (actor.getRole()) { + case ADMIN -> { + if (newRole == Role.MANAGER || newRole == Role.STAFF) { + accessContext.assertCanAccess(primaryLocationId); + } } - - if (newRole == Role.MANAGER || newRole == Role.STAFF) { + case SUPERVISOR -> { + if (newRole != Role.MANAGER && newRole != Role.STAFF) { + throw new AccessDeniedException("Supervisors can only assign MANAGER and STAFF roles"); + } accessContext.assertCanAccess(primaryLocationId); } - return; - } - - if (actor.getRole() == Role.MANAGER) { - if (newRole != Role.STAFF) { - throw new AccessDeniedException("Managers can only assign STAFF role"); - } - accessContext.assertCanAccess(primaryLocationId); - return; + case MANAGER -> throw new AccessDeniedException("Managers cannot change user roles"); + case STAFF -> throw new AccessDeniedException("Staff cannot change user roles"); } - - throw new AccessDeniedException("Insufficient role for role updates"); } /** @@ -318,14 +312,16 @@ public void assertCanAssignLocations(User targetUser, List requestedLocati } User actor = accessContext.getCurrentUser(); - if (actor.getRole() == Role.ADMIN) { - return; + if (actor.getRole() == Role.MANAGER || actor.getRole() == Role.STAFF) { + throw new AccessDeniedException("Only ADMIN or SUPERVISOR can assign locations"); } - Set allowed = new HashSet<>(accessContext.getAllowedLocationIds()); - boolean hasForbiddenLocation = requestedLocationIds.stream().anyMatch(locationId -> !allowed.contains(locationId)); - if (hasForbiddenLocation) { - throw new AccessDeniedException("Cannot assign locations outside your scope"); + if (actor.getRole() == Role.SUPERVISOR) { + Set allowed = new HashSet<>(accessContext.getAllowedLocationIds()); + boolean hasForbiddenLocation = requestedLocationIds.stream().anyMatch(locationId -> !allowed.contains(locationId)); + if (hasForbiddenLocation) { + throw new AccessDeniedException("Cannot assign locations outside your scope"); + } } } @@ -338,6 +334,11 @@ public void assertCanAssignLocations(User targetUser, List requestedLocati public void assertCanDeactivateUser(User targetUser) { assertPermission(Permission.USERS_DEACTIVATE); assertCanManageUser(targetUser); + + User actor = accessContext.getCurrentUser(); + if (actor.getRole() == Role.MANAGER || actor.getRole() == Role.STAFF) { + throw new AccessDeniedException("Only ADMIN or SUPERVISOR can deactivate users"); + } } /** diff --git a/backend/src/main/java/backend/fullstack/training/TrainingController.java b/backend/src/main/java/backend/fullstack/training/TrainingController.java new file mode 100644 index 0000000..55ca566 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/training/TrainingController.java @@ -0,0 +1,75 @@ +package backend.fullstack.training; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import backend.fullstack.config.ApiResponse; +import backend.fullstack.training.dto.TrainingRecordRequest; +import backend.fullstack.training.dto.TrainingRecordResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; + +/** + * REST endpoints for training records. + */ +@RestController +@RequestMapping("/api/training/records") +public class TrainingController { + + private final TrainingService trainingService; + + public TrainingController(TrainingService trainingService) { + this.trainingService = trainingService; + } + + @PostMapping + @Operation(summary = "Create training record") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "Training record created"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Forbidden") + }) + public ResponseEntity> create( + @Valid @RequestBody TrainingRecordRequest request + ) { + return ResponseEntity.status(201) + .body(ApiResponse.success("Training record created", trainingService.create(request))); + } + + @GetMapping + @Operation(summary = "List visible training records") + public ApiResponse> getVisibleRecords( + @RequestParam(required = false) Long userId, + @RequestParam(required = false) TrainingType trainingType, + @RequestParam(required = false) TrainingStatus status + ) { + return ApiResponse.success( + "Training records fetched", + trainingService.getVisibleRecords(userId, trainingType, status) + ); + } + + @GetMapping("/{id}") + @Operation(summary = "Get training record by id") + public ApiResponse getById(@PathVariable Long id) { + return ApiResponse.success("Training record fetched", trainingService.getById(id)); + } + + @PutMapping("/{id}") + @Operation(summary = "Update training record") + public ApiResponse update( + @PathVariable Long id, + @Valid @RequestBody TrainingRecordRequest request + ) { + return ApiResponse.success("Training record updated", trainingService.update(id, request)); + } +} diff --git a/backend/src/main/java/backend/fullstack/training/TrainingRecordRepository.java b/backend/src/main/java/backend/fullstack/training/TrainingRecordRepository.java index 7814fd9..22eb771 100644 --- a/backend/src/main/java/backend/fullstack/training/TrainingRecordRepository.java +++ b/backend/src/main/java/backend/fullstack/training/TrainingRecordRepository.java @@ -1,6 +1,8 @@ package backend.fullstack.training; import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -15,6 +17,24 @@ */ public interface TrainingRecordRepository extends JpaRepository { + Optional findByIdAndUser_Organization_Id(Long id, Long organizationId); + + @Query(""" + SELECT t + FROM TrainingRecord t + WHERE t.user.organization.id = :organizationId + AND (:userId IS NULL OR t.user.id = :userId) + AND (:trainingType IS NULL OR t.trainingType = :trainingType) + AND (:status IS NULL OR t.status = :status) + ORDER BY t.completedAt DESC NULLS LAST, t.id DESC + """) + List findVisibleRecords( + @Param("organizationId") Long organizationId, + @Param("userId") Long userId, + @Param("trainingType") TrainingType trainingType, + @Param("status") TrainingStatus status + ); + @Query(""" SELECT COUNT(t) > 0 FROM TrainingRecord t diff --git a/backend/src/main/java/backend/fullstack/training/TrainingService.java b/backend/src/main/java/backend/fullstack/training/TrainingService.java new file mode 100644 index 0000000..86c35a7 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/training/TrainingService.java @@ -0,0 +1,150 @@ +package backend.fullstack.training; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import backend.fullstack.exceptions.AccessDeniedException; +import backend.fullstack.exceptions.ResourceNotFoundException; +import backend.fullstack.exceptions.RoleException; +import backend.fullstack.permission.core.AuthorizationService; +import backend.fullstack.training.dto.TrainingRecordRequest; +import backend.fullstack.training.dto.TrainingRecordResponse; +import backend.fullstack.user.AccessContextService; +import backend.fullstack.user.User; +import backend.fullstack.user.UserRepository; + +/** + * Service for managing staff training records within an organization. + */ +@Service +public class TrainingService { + + private final TrainingRecordRepository trainingRecordRepository; + private final UserRepository userRepository; + private final AccessContextService accessContext; + private final AuthorizationService authorizationService; + + public TrainingService( + TrainingRecordRepository trainingRecordRepository, + UserRepository userRepository, + AccessContextService accessContext, + AuthorizationService authorizationService + ) { + this.trainingRecordRepository = trainingRecordRepository; + this.userRepository = userRepository; + this.accessContext = accessContext; + this.authorizationService = authorizationService; + } + + @Transactional + public TrainingRecordResponse create(TrainingRecordRequest request) { + User targetUser = findUserInCurrentOrganization(request.getUserId()); + authorizationService.assertCanManageUser(targetUser); + validateRequestWindow(request); + + TrainingRecord trainingRecord = TrainingRecord.builder() + .user(targetUser) + .trainingType(request.getTrainingType()) + .status(request.getStatus()) + .completedAt(resolveCompletedAt(request)) + .expiresAt(request.getExpiresAt()) + .build(); + + return toResponse(trainingRecordRepository.save(trainingRecord)); + } + + @Transactional(readOnly = true) + public List getVisibleRecords(Long userId, TrainingType trainingType, TrainingStatus status) { + Long organizationId = accessContext.getCurrentOrganizationId(); + + if (userId != null) { + User targetUser = findUserInCurrentOrganization(userId); + assertCanViewTrainingForUser(targetUser); + } + + return trainingRecordRepository.findVisibleRecords(organizationId, userId, trainingType, status) + .stream() + .filter(record -> canViewTrainingForUser(record.getUser())) + .map(this::toResponse) + .toList(); + } + + @Transactional(readOnly = true) + public TrainingRecordResponse getById(Long id) { + TrainingRecord trainingRecord = findRecordInCurrentOrganization(id); + assertCanViewTrainingForUser(trainingRecord.getUser()); + return toResponse(trainingRecord); + } + + @Transactional + public TrainingRecordResponse update(Long id, TrainingRecordRequest request) { + TrainingRecord trainingRecord = findRecordInCurrentOrganization(id); + User targetUser = findUserInCurrentOrganization(request.getUserId()); + authorizationService.assertCanManageUser(targetUser); + validateRequestWindow(request); + + trainingRecord.setUser(targetUser); + trainingRecord.setTrainingType(request.getTrainingType()); + trainingRecord.setStatus(request.getStatus()); + trainingRecord.setCompletedAt(resolveCompletedAt(request)); + trainingRecord.setExpiresAt(request.getExpiresAt()); + + return toResponse(trainingRecordRepository.save(trainingRecord)); + } + + private TrainingRecord findRecordInCurrentOrganization(Long id) { + Long organizationId = accessContext.getCurrentOrganizationId(); + return trainingRecordRepository.findByIdAndUser_Organization_Id(id, organizationId) + .orElseThrow(() -> new ResourceNotFoundException("Training record not found")); + } + + private User findUserInCurrentOrganization(Long userId) { + Long organizationId = accessContext.getCurrentOrganizationId(); + return userRepository.findById(userId) + .filter(user -> organizationId.equals(user.getOrganizationId())) + .orElseThrow(() -> new ResourceNotFoundException("User not found")); + } + + private void validateRequestWindow(TrainingRecordRequest request) { + if (request.getExpiresAt() != null && request.getCompletedAt() != null + && request.getExpiresAt().isBefore(request.getCompletedAt())) { + throw new RoleException("expiresAt must be after completedAt"); + } + } + + private LocalDateTime resolveCompletedAt(TrainingRecordRequest request) { + if (request.getStatus() == TrainingStatus.COMPLETED) { + return request.getCompletedAt() != null ? request.getCompletedAt() : LocalDateTime.now(); + } + return request.getCompletedAt(); + } + + private void assertCanViewTrainingForUser(User targetUser) { + if (!canViewTrainingForUser(targetUser)) { + throw new AccessDeniedException("No access to this training record"); + } + } + + private boolean canViewTrainingForUser(User targetUser) { + User actor = accessContext.getCurrentUser(); + return actor.getId().equals(targetUser.getId()) || authorizationService.canViewUser(targetUser); + } + + private TrainingRecordResponse toResponse(TrainingRecord trainingRecord) { + User user = trainingRecord.getUser(); + return TrainingRecordResponse.builder() + .id(trainingRecord.getId()) + .userId(user.getId()) + .userEmail(user.getEmail()) + .userName(user.getFirstName() + " " + user.getLastName()) + .organizationId(user.getOrganizationId()) + .trainingType(trainingRecord.getTrainingType()) + .status(trainingRecord.getStatus()) + .completedAt(trainingRecord.getCompletedAt()) + .expiresAt(trainingRecord.getExpiresAt()) + .build(); + } +} diff --git a/backend/src/main/java/backend/fullstack/training/dto/TrainingRecordRequest.java b/backend/src/main/java/backend/fullstack/training/dto/TrainingRecordRequest.java new file mode 100644 index 0000000..86c2b77 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/training/dto/TrainingRecordRequest.java @@ -0,0 +1,37 @@ +package backend.fullstack.training.dto; + +import java.time.LocalDateTime; + +import backend.fullstack.training.TrainingStatus; +import backend.fullstack.training.TrainingType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +/** + * Request payload for creating or updating a training record. + */ +@Getter +@Setter +@Schema(description = "Training record request") +public class TrainingRecordRequest { + + @NotNull(message = "User id is required") + @Schema(description = "User id the training belongs to", example = "12") + private Long userId; + + @NotNull(message = "Training type is required") + @Schema(description = "Training type", example = "GENERAL") + private TrainingType trainingType; + + @NotNull(message = "Training status is required") + @Schema(description = "Training status", example = "COMPLETED") + private TrainingStatus status; + + @Schema(description = "When the training was completed") + private LocalDateTime completedAt; + + @Schema(description = "When the training expires") + private LocalDateTime expiresAt; +} diff --git a/backend/src/main/java/backend/fullstack/training/dto/TrainingRecordResponse.java b/backend/src/main/java/backend/fullstack/training/dto/TrainingRecordResponse.java new file mode 100644 index 0000000..846dba1 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/training/dto/TrainingRecordResponse.java @@ -0,0 +1,51 @@ +package backend.fullstack.training.dto; + +import java.time.LocalDateTime; + +import backend.fullstack.training.TrainingStatus; +import backend.fullstack.training.TrainingType; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Response payload for training records. + */ +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "Training record response") +public class TrainingRecordResponse { + + @Schema(example = "1") + private Long id; + + @Schema(example = "12") + private Long userId; + + @Schema(example = "staff@everest.no") + private String userEmail; + + @Schema(example = "Ola Nordmann") + private String userName; + + @Schema(example = "100") + private Long organizationId; + + @Schema(example = "GENERAL") + private TrainingType trainingType; + + @Schema(example = "COMPLETED") + private TrainingStatus status; + + @Schema(description = "When the training was completed") + private LocalDateTime completedAt; + + @Schema(description = "When the training expires") + private LocalDateTime expiresAt; +} diff --git a/backend/src/main/java/backend/fullstack/user/AccessContextService.java b/backend/src/main/java/backend/fullstack/user/AccessContextService.java index ace30dc..44b0adc 100644 --- a/backend/src/main/java/backend/fullstack/user/AccessContextService.java +++ b/backend/src/main/java/backend/fullstack/user/AccessContextService.java @@ -10,6 +10,8 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; import backend.fullstack.config.JwtPrincipal; import backend.fullstack.location.LocationRepository; @@ -26,6 +28,8 @@ @Service public class AccessContextService { + private static final String CURRENT_USER_REQUEST_ATTRIBUTE = AccessContextService.class.getName() + ".currentUser"; + private final UserRepository userRepository; private final LocationRepository locationRepository; private final UserLocationScopeAssignmentRepository userLocationScopeAssignmentRepository; @@ -47,18 +51,28 @@ public AccessContextService( * @return List of accessible location IDs for the current user. */ public List getAllowedLocationIds() { - User user = getCurrentUser(); + JwtPrincipal principal = getJwtPrincipal(); + User user = null; Set uniqueLocationIds = new LinkedHashSet<>(); - switch (user.getRole()) { - case ADMIN -> uniqueLocationIds.addAll(locationRepository.findIdsByOrganizationId(user.getOrganizationId())); - case SUPERVISOR, MANAGER, STAFF -> uniqueLocationIds.addAll(userRepository.findEffectiveLocationScopeByUserId(user.getId())); + if (principal != null && !principal.locationIds().isEmpty()) { + uniqueLocationIds.addAll(principal.locationIds()); + } else { + user = getCurrentUser(); + switch (user.getRole()) { + case ADMIN -> uniqueLocationIds.addAll(locationRepository.findIdsByOrganizationId(user.getOrganizationId())); + case SUPERVISOR, MANAGER, STAFF -> uniqueLocationIds.addAll(userRepository.findEffectiveLocationScopeByUserId(user.getId())); + } } + Long userId = principal != null && principal.userId() != null + ? principal.userId() + : requireCurrentUser(user).getId(); + // Temporary scope assignments are resolved from the database at request-time // so access changes take effect immediately without requiring re-login. uniqueLocationIds.addAll( - userLocationScopeAssignmentRepository.findActiveLocationIdsByUserId(user.getId(), LocalDateTime.now()) + userLocationScopeAssignmentRepository.findActiveLocationIdsByUserId(userId, LocalDateTime.now()) ); return new ArrayList<>(uniqueLocationIds); @@ -132,11 +146,15 @@ public void assertHasRole(Role... allowedRoles) { * @throws AccessDeniedException if the request is unauthenticated or the authenticated user cannot be found in the database. */ public User getCurrentUser() { - Authentication authentication = requireAuthentication(); - String email = resolveAuthenticatedEmail(authentication); + User cachedUser = getCachedCurrentUser(); + if (cachedUser != null) { + return cachedUser; + } - return userRepository.findByEmail(email) - .orElseThrow(() -> new AccessDeniedException("Authenticated user not found")); + Authentication authentication = requireAuthentication(); + User currentUser = resolveCurrentUser(authentication); + cacheCurrentUser(currentUser); + return currentUser; } /** @@ -193,4 +211,42 @@ private String resolveAuthenticatedEmail(Authentication authentication) { return authentication.getName(); } + private User resolveCurrentUser(Authentication authentication) { + Object principal = authentication.getPrincipal(); + + if (principal instanceof User user) { + return user; + } + + if (principal instanceof JwtPrincipal jwtPrincipal && jwtPrincipal.userId() != null) { + return userRepository.findById(jwtPrincipal.userId()) + .orElseThrow(() -> new AccessDeniedException("Authenticated user not found")); + } + + String email = resolveAuthenticatedEmail(authentication); + return userRepository.findByEmail(email) + .orElseThrow(() -> new AccessDeniedException("Authenticated user not found")); + } + + private User requireCurrentUser(User user) { + return user != null ? user : getCurrentUser(); + } + + private User getCachedCurrentUser() { + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (requestAttributes == null) { + return null; + } + + Object cached = requestAttributes.getAttribute(CURRENT_USER_REQUEST_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST); + return cached instanceof User user ? user : null; + } + + private void cacheCurrentUser(User user) { + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (requestAttributes != null) { + requestAttributes.setAttribute(CURRENT_USER_REQUEST_ATTRIBUTE, user, RequestAttributes.SCOPE_REQUEST); + } + } + } diff --git a/backend/src/main/java/backend/fullstack/user/UserService.java b/backend/src/main/java/backend/fullstack/user/UserService.java index 1e26451..28f2023 100644 --- a/backend/src/main/java/backend/fullstack/user/UserService.java +++ b/backend/src/main/java/backend/fullstack/user/UserService.java @@ -299,6 +299,7 @@ public void assignProfiles( LocalDateTime endsAt, boolean replaceScopeAssignments ) { + accessContext.assertHasRole(Role.ADMIN); User user = findInCurrentOrg(id); authorizationService.assertCanManageUser(user); validateWindow(startsAt, endsAt); @@ -365,6 +366,7 @@ public void assignTemporaryLocationScope( TemporaryAssignmentMode mode, String reason ) { + accessContext.assertHasRole(Role.ADMIN); User user = findInCurrentOrg(id); authorizationService.assertCanManageUser(user); validateWindow(startsAt, endsAt); @@ -397,6 +399,7 @@ public void assignTemporaryLocationScope( @Transactional public void completeTemporaryLocationScope(Long userId, Long assignmentId) { + accessContext.assertHasRole(Role.ADMIN); User user = findInCurrentOrg(userId); authorizationService.assertCanManageUser(user); @@ -416,6 +419,7 @@ public void completeTemporaryLocationScope(Long userId, Long assignmentId) { @Transactional public void confirmTemporaryLocationScope(Long userId, Long assignmentId) { + accessContext.assertHasRole(Role.ADMIN); User user = findInCurrentOrg(userId); authorizationService.assertCanManageUser(user); @@ -443,6 +447,7 @@ public void assignTemporaryPermission( LocalDateTime endsAt, String reason ) { + accessContext.assertHasRole(Role.ADMIN); addUserPermissionOverride(id, permission, effect, scope, locationId, startsAt, endsAt, reason); } diff --git a/backend/src/main/resources/db/migration/V7__create_training_records.sql b/backend/src/main/resources/db/migration/V7__create_training_records.sql new file mode 100644 index 0000000..98b932c --- /dev/null +++ b/backend/src/main/resources/db/migration/V7__create_training_records.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS training_records ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + training_type VARCHAR(40) NOT NULL, + status VARCHAR(20) NOT NULL, + completed_at TIMESTAMP NULL, + expires_at TIMESTAMP NULL, + CONSTRAINT fk_training_records_user + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE INDEX idx_training_records_user_id ON training_records(user_id); +CREATE INDEX idx_training_records_training_type ON training_records(training_type); +CREATE INDEX idx_training_records_status ON training_records(status); diff --git a/backend/src/main/resources/db/migration/V8__training_test_data.sql b/backend/src/main/resources/db/migration/V8__training_test_data.sql new file mode 100644 index 0000000..37b3659 --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__training_test_data.sql @@ -0,0 +1,32 @@ +INSERT INTO training_records (user_id, training_type, status, completed_at, expires_at) +SELECT u.id, 'GENERAL', 'COMPLETED', CURRENT_TIMESTAMP, NULL +FROM users u +WHERE u.email = 'admin@everest.no' + AND NOT EXISTS ( + SELECT 1 + FROM training_records tr + WHERE tr.user_id = u.id + AND tr.training_type = 'GENERAL' + ); + +INSERT INTO training_records (user_id, training_type, status, completed_at, expires_at) +SELECT u.id, 'CHECKLIST_APPROVAL', 'COMPLETED', CURRENT_TIMESTAMP, TIMESTAMPADD(DAY, 365, CURRENT_TIMESTAMP) +FROM users u +WHERE u.email = 'manager@everest.no' + AND NOT EXISTS ( + SELECT 1 + FROM training_records tr + WHERE tr.user_id = u.id + AND tr.training_type = 'CHECKLIST_APPROVAL' + ); + +INSERT INTO training_records (user_id, training_type, status, completed_at, expires_at) +SELECT u.id, 'FREEZER_LOGGING', 'COMPLETED', CURRENT_TIMESTAMP, TIMESTAMPADD(DAY, 180, CURRENT_TIMESTAMP) +FROM users u +WHERE u.email = 'staff@everest.no' + AND NOT EXISTS ( + SELECT 1 + FROM training_records tr + WHERE tr.user_id = u.id + AND tr.training_type = 'FREEZER_LOGGING' + ); diff --git a/backend/src/test/java/backend/fullstack/auth/AuthCapabilitiesControllerTest.java b/backend/src/test/java/backend/fullstack/auth/AuthCapabilitiesControllerTest.java new file mode 100644 index 0000000..443a9a3 --- /dev/null +++ b/backend/src/test/java/backend/fullstack/auth/AuthCapabilitiesControllerTest.java @@ -0,0 +1,56 @@ +package backend.fullstack.auth; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import backend.fullstack.permission.core.AuthorizationService; +import backend.fullstack.permission.dto.CapabilitiesResponse; +import backend.fullstack.user.role.Role; + +class AuthCapabilitiesControllerTest { + + private MockMvc mockMvc; + private AuthorizationService authorizationService; + + @BeforeEach + void setUp() { + authorizationService = Mockito.mock(AuthorizationService.class); + AuthCapabilitiesController controller = new AuthCapabilitiesController(authorizationService); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + void getCapabilitiesReturnsWrappedResponse() throws Exception { + CapabilitiesResponse response = new CapabilitiesResponse(); + response.setRole(Role.SUPERVISOR); + response.setOrganizationId(10L); + response.setAllowedLocationIds(List.of(1L, 2L)); + response.setPermissions(List.of("users.read.location", "training.records.read")); + response.setActiveProfileNames(List.of("Shift Leader")); + + when(authorizationService.getCurrentCapabilities()).thenReturn(response); + + mockMvc.perform(get("/api/auth/capabilities")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Capabilities fetched")) + .andExpect(jsonPath("$.data.role").value("SUPERVISOR")) + .andExpect(jsonPath("$.data.organizationId").value(10)) + .andExpect(jsonPath("$.data.allowedLocationIds[0]").value(1)) + .andExpect(jsonPath("$.data.permissions[0]").value("users.read.location")) + .andExpect(jsonPath("$.data.activeProfileNames[0]").value("Shift Leader")); + + verify(authorizationService).getCurrentCapabilities(); + } +} diff --git a/backend/src/test/java/backend/fullstack/config/SecurityConfigRegisterAccessTest.java b/backend/src/test/java/backend/fullstack/config/SecurityConfigRegisterAccessTest.java index 5cd77d7..d96a0a3 100644 --- a/backend/src/test/java/backend/fullstack/config/SecurityConfigRegisterAccessTest.java +++ b/backend/src/test/java/backend/fullstack/config/SecurityConfigRegisterAccessTest.java @@ -1,13 +1,10 @@ package backend.fullstack.config; import java.lang.reflect.Proxy; -import java.util.List; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import backend.fullstack.user.UserRepository; @@ -16,11 +13,7 @@ class SecurityConfigRegisterAccessTest { @Test void allowsAnonymousRegisterWhenNoUsersExist() { - SecurityConfig config = new SecurityConfig( - new JwtAuthFilter(new JwtUtil(new JwtProperties())), - repositoryWithCount(0L), - new BCryptPasswordEncoder() - ); + SecurityConfig config = configWithUserCount(0L); boolean allowed = config.isRegisterAccessAllowed(null); @@ -29,11 +22,7 @@ void allowsAnonymousRegisterWhenNoUsersExist() { @Test void deniesAnonymousRegisterAfterBootstrap() { - SecurityConfig config = new SecurityConfig( - new JwtAuthFilter(new JwtUtil(new JwtProperties())), - repositoryWithCount(3L), - new BCryptPasswordEncoder() - ); + SecurityConfig config = configWithUserCount(3L); boolean allowed = config.isRegisterAccessAllowed(null); @@ -41,25 +30,41 @@ void deniesAnonymousRegisterAfterBootstrap() { } @Test - void allowsAdminRegisterAfterBootstrap() { - SecurityConfig config = new SecurityConfig( - new JwtAuthFilter(new JwtUtil(new JwtProperties())), - repositoryWithCount(3L), - new BCryptPasswordEncoder() - ); + void deniesAdminRegisterAfterBootstrap() { + SecurityConfig config = configWithUserCount(3L); + + boolean allowed = config.isRegisterAccessAllowed(null); + + assertFalse(allowed); + } - UsernamePasswordAuthenticationToken adminAuth = - new UsernamePasswordAuthenticationToken( - "admin@everest.no", - null, - List.of(new SimpleGrantedAuthority("ROLE_ADMIN")) - ); + @Test + void allowsOrganizationCreateWhenNoUsersExist() { + SecurityConfig config = configWithUserCount(0L); - boolean allowed = config.isRegisterAccessAllowed(adminAuth); + boolean allowed = config.isOrganizationCreateAccessAllowed(null); assertTrue(allowed); } + @Test + void deniesOrganizationCreateAfterBootstrap() { + SecurityConfig config = configWithUserCount(3L); + + boolean allowed = config.isOrganizationCreateAccessAllowed(null); + + assertFalse(allowed); + } + + private SecurityConfig configWithUserCount(long count) { + return new SecurityConfig( + new JwtAuthFilter(new JwtUtil(new JwtProperties())), + new SecurityErrorHandler(new com.fasterxml.jackson.databind.ObjectMapper()), + repositoryWithCount(count), + new BCryptPasswordEncoder() + ); + } + private UserRepository repositoryWithCount(long count) { return (UserRepository) Proxy.newProxyInstance( UserRepository.class.getClassLoader(), diff --git a/backend/src/test/java/backend/fullstack/location/LocationControllerTest.java b/backend/src/test/java/backend/fullstack/location/LocationControllerTest.java new file mode 100644 index 0000000..09f27bf --- /dev/null +++ b/backend/src/test/java/backend/fullstack/location/LocationControllerTest.java @@ -0,0 +1,173 @@ +package backend.fullstack.location; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import backend.fullstack.config.GlobalExceptionHandler; +import backend.fullstack.location.dto.LocationRequest; +import backend.fullstack.location.dto.LocationResponse; + +class LocationControllerTest { + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + private TestLocationService locationService; + + @BeforeEach + void setUp() { + locationService = new TestLocationService(); + LocationController controller = new LocationController(locationService); + + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + objectMapper = new ObjectMapper().findAndRegisterModules(); + } + + @Test + void createReturnsWrappedResponse() throws Exception { + LocationRequest request = request(); + locationService.createResponse = response(1L); + + mockMvc.perform(post("/api/locations") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Location created")) + .andExpect(jsonPath("$.data.id").value(1)) + .andExpect(jsonPath("$.data.name").value("Trondheim Office")); + } + + @Test + void getAllAccessibleReturnsWrappedList() throws Exception { + locationService.allAccessibleResponse = List.of(response(1L), response(2L)); + + mockMvc.perform(get("/api/locations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Accessible locations retrieved")) + .andExpect(jsonPath("$.data", hasSize(2))); + + assert locationService.getAllAccessibleCalled; + } + + @Test + void getByIdReturnsWrappedResponse() throws Exception { + locationService.getByIdResponse = response(1L); + + mockMvc.perform(get("/api/locations/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Location retrieved")) + .andExpect(jsonPath("$.data.id").value(1)); + + assert locationService.lastId.equals(1L); + } + + @Test + void updateReturnsWrappedResponse() throws Exception { + LocationRequest request = request(); + locationService.updateResponse = response(1L); + + mockMvc.perform(put("/api/locations/1") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Location updated")) + .andExpect(jsonPath("$.data.id").value(1)); + } + + @Test + void deleteDelegatesToService() throws Exception { + mockMvc.perform(delete("/api/locations/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Location deleted")); + + assert locationService.deletedId.equals(1L); + } + + @Test + void createReturnsValidationErrorForMissingFields() throws Exception { + mockMvc.perform(post("/api/locations") + .contentType(APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.fieldErrors.name").value("Location name is required")) + .andExpect(jsonPath("$.fieldErrors.address").value("Location address is required")); + } + + private static LocationRequest request() { + LocationRequest request = new LocationRequest(); + request.setName("Trondheim Office"); + request.setAddress("Kongens gate 1, 7011 Trondheim"); + return request; + } + + private static LocationResponse response(Long id) { + return LocationResponse.builder() + .id(id) + .name("Trondheim Office") + .address("Kongens gate 1, 7011 Trondheim") + .build(); + } + + private static final class TestLocationService extends LocationService { + + private LocationResponse createResponse; + private List allAccessibleResponse = List.of(); + private LocationResponse getByIdResponse; + private LocationResponse updateResponse; + private boolean getAllAccessibleCalled; + private Long lastId; + private Long deletedId; + + private TestLocationService() { + super(null, null, null, null); + } + + @Override + public LocationResponse create(LocationRequest request) { + return createResponse; + } + + @Override + public List getAllAccessible() { + getAllAccessibleCalled = true; + return allAccessibleResponse; + } + + @Override + public LocationResponse getById(Long id) { + lastId = id; + return getByIdResponse; + } + + @Override + public LocationResponse update(Long id, LocationRequest request) { + lastId = id; + return updateResponse; + } + + @Override + public void delete(Long id) { + deletedId = id; + } + } +} diff --git a/backend/src/test/java/backend/fullstack/location/LocationServiceTest.java b/backend/src/test/java/backend/fullstack/location/LocationServiceTest.java new file mode 100644 index 0000000..8b4d437 --- /dev/null +++ b/backend/src/test/java/backend/fullstack/location/LocationServiceTest.java @@ -0,0 +1,103 @@ +package backend.fullstack.location; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.access.AccessDeniedException; + +import backend.fullstack.exceptions.ResourceNotFoundException; +import backend.fullstack.location.dto.LocationMapper; +import backend.fullstack.location.dto.LocationRequest; +import backend.fullstack.location.dto.LocationResponse; +import backend.fullstack.organization.Organization; +import backend.fullstack.organization.OrganizationRepository; +import backend.fullstack.user.AccessContextService; +import backend.fullstack.user.role.Role; + +@ExtendWith(MockitoExtension.class) +class LocationServiceTest { + + @Mock + private LocationRepository locationRepository; + @Mock + private OrganizationRepository organizationRepository; + @Mock + private AccessContextService accessContext; + @Mock + private LocationMapper locationMapper; + + private LocationService locationService; + + @BeforeEach + void setUp() { + locationService = new LocationService(locationRepository, organizationRepository, accessContext, locationMapper); + } + + @Test + void createStoresLocationUnderCurrentOrganization() { + LocationRequest request = new LocationRequest(); + request.setName("Oslo"); + request.setAddress("Street 1"); + + Organization organization = Organization.builder() + .id(100L) + .name("Everest") + .organizationNumber("937219997") + .build(); + Location mapped = Location.builder().name("Oslo").address("Street 1").build(); + Location saved = Location.builder().id(7L).organization(organization).name("Oslo").address("Street 1").build(); + LocationResponse response = LocationResponse.builder().id(7L).name("Oslo").address("Street 1").build(); + + when(accessContext.getCurrentOrganizationId()).thenReturn(100L); + when(organizationRepository.findById(100L)).thenReturn(Optional.of(organization)); + when(locationMapper.toEntity(request)).thenReturn(mapped); + when(locationRepository.save(any(Location.class))).thenReturn(saved); + when(locationMapper.toResponse(saved)).thenReturn(response); + + LocationResponse result = locationService.create(request); + + verify(accessContext).assertHasRole(Role.ADMIN); + ArgumentCaptor captor = ArgumentCaptor.forClass(Location.class); + verify(locationRepository).save(captor.capture()); + assertEquals(organization.getId(), captor.getValue().getOrganizationId()); + assertEquals(7L, result.getId()); + } + + @Test + void getAllAccessibleReturnsMappedLocationsForAllowedIds() { + Location oslo = Location.builder().id(1L).name("Oslo").address("A").build(); + Location bergen = Location.builder().id(2L).name("Bergen").address("B").build(); + + when(accessContext.getAllowedLocationIds()).thenReturn(List.of(1L, 2L)); + when(locationRepository.findAllById(List.of(1L, 2L))).thenReturn(List.of(oslo, bergen)); + when(locationMapper.toResponse(oslo)).thenReturn(LocationResponse.builder().id(1L).name("Oslo").address("A").build()); + when(locationMapper.toResponse(bergen)).thenReturn(LocationResponse.builder().id(2L).name("Bergen").address("B").build()); + + List result = locationService.getAllAccessible(); + + assertEquals(2, result.size()); + assertEquals("Oslo", result.get(0).getName()); + assertEquals("Bergen", result.get(1).getName()); + } + + @Test + void getByIdThrowsWhenLocationDoesNotExistInCurrentOrganization() { + when(accessContext.getCurrentOrganizationId()).thenReturn(100L); + when(locationRepository.findByIdAndOrganization_Id(9L, 100L)).thenReturn(Optional.empty()); + + assertThrows(ResourceNotFoundException.class, () -> locationService.getById(9L)); + verify(accessContext).getCurrentOrganizationId(); + } +} diff --git a/backend/src/test/java/backend/fullstack/organization/OrganizationControllerTest.java b/backend/src/test/java/backend/fullstack/organization/OrganizationControllerTest.java new file mode 100644 index 0000000..ff07476 --- /dev/null +++ b/backend/src/test/java/backend/fullstack/organization/OrganizationControllerTest.java @@ -0,0 +1,153 @@ +package backend.fullstack.organization; + +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import backend.fullstack.config.GlobalExceptionHandler; +import backend.fullstack.organization.dto.OrganizationRequest; +import backend.fullstack.organization.dto.OrganizationResponse; + +class OrganizationControllerTest { + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + private TestOrganizationService organizationService; + + @BeforeEach + void setUp() { + organizationService = new TestOrganizationService(); + OrganizationController controller = new OrganizationController(organizationService); + + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + objectMapper = new ObjectMapper().findAndRegisterModules(); + } + + @Test + void createReturnsWrappedResponse() throws Exception { + OrganizationRequest request = request(); + organizationService.createResponse = response(1L); + + mockMvc.perform(post("/api/organization") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Organization created")) + .andExpect(jsonPath("$.data.id").value(1)) + .andExpect(jsonPath("$.data.name").value("Everest AS")); + } + + @Test + void getCurrentOrganizationReturnsWrappedResponse() throws Exception { + organizationService.currentResponse = response(1L); + + mockMvc.perform(get("/api/organization/me")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Organization retrieved")) + .andExpect(jsonPath("$.data.id").value(1)); + + assert organizationService.getCurrentCalled; + } + + @Test + void getByIdReturnsWrappedResponse() throws Exception { + organizationService.getByIdResponse = response(5L); + + mockMvc.perform(get("/api/organization/5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Organization retrieved")) + .andExpect(jsonPath("$.data.id").value(5)); + + assert organizationService.lastId.equals(5L); + } + + @Test + void updateCurrentOrganizationReturnsWrappedResponse() throws Exception { + OrganizationRequest request = request(); + organizationService.updateResponse = response(1L); + + mockMvc.perform(put("/api/organization/me") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Organization updated")) + .andExpect(jsonPath("$.data.id").value(1)); + } + + @Test + void createReturnsValidationErrorForMissingFields() throws Exception { + mockMvc.perform(post("/api/organization") + .contentType(APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.fieldErrors.name").value("Organization name is required")) + .andExpect(jsonPath("$.fieldErrors.organizationNumber").value("Organization number is required")); + } + + private static OrganizationRequest request() { + OrganizationRequest request = new OrganizationRequest(); + request.setName("Everest AS"); + request.setOrganizationNumber("123456789"); + return request; + } + + private static OrganizationResponse response(Long id) { + return OrganizationResponse.builder() + .id(id) + .name("Everest AS") + .organizationNumber("123456789") + .locationCount(2) + .locationsList(java.util.List.of()) + .build(); + } + + private static final class TestOrganizationService extends OrganizationService { + + private OrganizationResponse createResponse; + private OrganizationResponse currentResponse; + private OrganizationResponse getByIdResponse; + private OrganizationResponse updateResponse; + private boolean getCurrentCalled; + private Long lastId; + + private TestOrganizationService() { + super(null, null, null, null, null); + } + + @Override + public OrganizationResponse create(OrganizationRequest request) { + return createResponse; + } + + @Override + public OrganizationResponse getCurrentOrganization() { + getCurrentCalled = true; + return currentResponse; + } + + @Override + public OrganizationResponse getById(Long id) { + lastId = id; + return getByIdResponse; + } + + @Override + public OrganizationResponse updateCurrentOrganization(OrganizationRequest request) { + return updateResponse; + } + } +} diff --git a/backend/src/test/java/backend/fullstack/organization/OrganizationServiceTest.java b/backend/src/test/java/backend/fullstack/organization/OrganizationServiceTest.java new file mode 100644 index 0000000..20aa98b --- /dev/null +++ b/backend/src/test/java/backend/fullstack/organization/OrganizationServiceTest.java @@ -0,0 +1,163 @@ +package backend.fullstack.organization; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; + +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.access.AccessDeniedException; + +import backend.fullstack.exceptions.OrganizationConflictException; +import backend.fullstack.organization.dto.OrganizationMapper; +import backend.fullstack.organization.dto.OrganizationRequest; +import backend.fullstack.organization.dto.OrganizationResponse; +import backend.fullstack.permission.core.AuthorizationService; +import backend.fullstack.permission.model.Permission; +import backend.fullstack.user.AccessContextService; +import backend.fullstack.user.UserRepository; + +@ExtendWith(MockitoExtension.class) +class OrganizationServiceTest { + + @Mock + private OrganizationRepository organizationRepository; + @Mock + private UserRepository userRepository; + @Mock + private AccessContextService accessContext; + @Mock + private AuthorizationService authorizationService; + @Mock + private OrganizationMapper organizationMapper; + + private OrganizationService organizationService; + + @BeforeEach + void setUp() { + organizationService = new OrganizationService( + organizationRepository, + userRepository, + accessContext, + authorizationService, + organizationMapper + ); + } + + @Test + void createRejectsDuplicateOrganizationNumber() { + OrganizationRequest request = new OrganizationRequest(); + request.setName("Everest"); + request.setOrganizationNumber("937219997"); + + when(userRepository.count()).thenReturn(0L); + when(organizationRepository.existsByOrganizationNumber("937219997")).thenReturn(true); + + assertThrows(OrganizationConflictException.class, () -> organizationService.create(request)); + } + + @Test + void createRejectsOrganizationCreationAfterBootstrap() { + OrganizationRequest request = new OrganizationRequest(); + request.setName("Everest"); + request.setOrganizationNumber("937219997"); + + when(userRepository.count()).thenReturn(1L); + + assertThrows(AccessDeniedException.class, () -> organizationService.create(request)); + verify(organizationRepository, never()).save(any(Organization.class)); + } + + @Test + void createSavesOrganizationAndMapsResponse() { + OrganizationRequest request = new OrganizationRequest(); + request.setName("Everest"); + request.setOrganizationNumber("937219997"); + + Organization mapped = Organization.builder() + .name("Everest") + .organizationNumber("937219997") + .build(); + Organization saved = Organization.builder() + .id(1L) + .name("Everest") + .organizationNumber("937219997") + .build(); + OrganizationResponse response = OrganizationResponse.builder() + .id(1L) + .name("Everest") + .organizationNumber("937219997") + .locationCount(0) + .build(); + + when(userRepository.count()).thenReturn(0L); + when(organizationRepository.existsByOrganizationNumber("937219997")).thenReturn(false); + when(organizationMapper.toEntity(request)).thenReturn(mapped); + when(organizationRepository.save(any(Organization.class))).thenReturn(saved); + when(organizationMapper.toResponse(saved)).thenReturn(response); + + OrganizationResponse result = organizationService.create(request); + + assertEquals(1L, result.getId()); + verify(organizationRepository).save(any(Organization.class)); + } + + @Test + void updateCurrentOrganizationSavesChangesBeforeMappingResponse() { + OrganizationRequest request = new OrganizationRequest(); + request.setName("Updated Everest"); + request.setOrganizationNumber("987654321"); + + Organization existing = Organization.builder() + .id(100L) + .name("Everest") + .organizationNumber("123456789") + .build(); + Organization saved = Organization.builder() + .id(100L) + .name("Updated Everest") + .organizationNumber("987654321") + .build(); + OrganizationResponse response = OrganizationResponse.builder() + .id(100L) + .name("Updated Everest") + .organizationNumber("987654321") + .build(); + + when(accessContext.getCurrentOrganizationId()).thenReturn(100L); + when(organizationRepository.findById(100L)).thenReturn(Optional.of(existing)); + when(organizationRepository.existsByOrganizationNumberAndIdNot("987654321", 100L)).thenReturn(false); + when(organizationRepository.save(existing)).thenReturn(saved); + when(organizationMapper.toResponse(saved)).thenReturn(response); + + OrganizationResponse result = organizationService.updateCurrentOrganization(request); + + assertEquals(100L, result.getId()); + assertEquals("Updated Everest", existing.getName()); + assertEquals("987654321", existing.getOrganizationNumber()); + verify(authorizationService).assertPermission(Permission.ORGANIZATION_SETTINGS_UPDATE); + verify(organizationRepository).save(existing); + } + + @Test + void getByIdDeniesAccessToOtherOrganizations() { + Organization organization = Organization.builder() + .id(200L) + .name("Other") + .organizationNumber("123456789") + .build(); + + when(organizationRepository.findById(200L)).thenReturn(Optional.of(organization)); + when(accessContext.getCurrentOrganizationId()).thenReturn(100L); + + assertThrows(AccessDeniedException.class, () -> organizationService.getById(200L)); + } +} diff --git a/backend/src/test/java/backend/fullstack/permission/AuthorizationServiceTest.java b/backend/src/test/java/backend/fullstack/permission/AuthorizationServiceTest.java index 766ac48..c1bd7ba 100644 --- a/backend/src/test/java/backend/fullstack/permission/AuthorizationServiceTest.java +++ b/backend/src/test/java/backend/fullstack/permission/AuthorizationServiceTest.java @@ -81,7 +81,7 @@ void hasPermissionForLocationReturnsFalseWhenLocationNotAllowed() { User actor = user(1L, 100L, Role.MANAGER, "actor@everest.no"); authenticate(actor); - when(userRepository.findByEmail(actor.getEmail())).thenReturn(java.util.Optional.of(actor)); + when(userRepository.findById(actor.getId())).thenReturn(java.util.Optional.of(actor)); when(userRepository.findEffectiveLocationScopeByUserId(actor.getId())).thenReturn(List.of(10L, 20L)); when(userLocationScopeAssignmentRepository.findActiveLocationIdsByUserId(eq(actor.getId()), any())) .thenReturn(List.of()); @@ -102,7 +102,7 @@ void assertCanViewUserAllowsOwnUserAndDeniesCrossOrganization() { User sameUser = user(1L, 100L, Role.STAFF, "actor@everest.no"); User otherOrgUser = user(2L, 200L, Role.STAFF, "other@everest.no"); - when(userRepository.findByEmail(actor.getEmail())).thenReturn(java.util.Optional.of(actor)); + when(userRepository.findById(actor.getId())).thenReturn(java.util.Optional.of(actor)); rolePermissionCatalog.setEffectivePermissions(null, Set.of(Permission.USERS_READ_LOCATION)); assertDoesNotThrow(() -> authorizationService.assertCanViewUser(sameUser)); @@ -117,40 +117,89 @@ void assertCanViewUserAllowsOwnUserAndDeniesCrossOrganization() { @Test void assertCanManageUserRoleMatrixEnforced() { User supervisor = user(10L, 100L, Role.SUPERVISOR, "supervisor@everest.no"); + User targetManagerInScope = user(12L, 100L, Role.MANAGER, "target-manager-in-scope@everest.no"); User targetSupervisor = user(11L, 100L, Role.SUPERVISOR, "target-supervisor@everest.no"); authenticate(supervisor); - when(userRepository.findByEmail(supervisor.getEmail())).thenReturn(java.util.Optional.of(supervisor)); + when(userRepository.findById(supervisor.getId())).thenReturn(java.util.Optional.of(supervisor)); rolePermissionCatalog.setEffectivePermissions(null, Set.of(Permission.USERS_UPDATE)); + when(userRepository.findAdditionalLocationIdsByUserId(supervisor.getId())).thenReturn(List.of(7L)); + when(userRepository.findAdditionalLocationIdsByUserId(targetManagerInScope.getId())).thenReturn(List.of(7L)); + + assertDoesNotThrow(() -> authorizationService.assertCanManageUser(targetManagerInScope)); assertThrows(backend.fullstack.exceptions.AccessDeniedException.class, () -> authorizationService.assertCanManageUser(targetSupervisor)); User manager = user(20L, 100L, Role.MANAGER, "manager@everest.no"); + User targetStaff = user(31L, 100L, Role.STAFF, "target-staff@everest.no"); User targetManager = user(21L, 100L, Role.MANAGER, "target-manager@everest.no"); authenticate(manager); - when(userRepository.findByEmail(manager.getEmail())).thenReturn(java.util.Optional.of(manager)); + when(userRepository.findById(manager.getId())).thenReturn(java.util.Optional.of(manager)); + when(userRepository.findAdditionalLocationIdsByUserId(manager.getId())).thenReturn(List.of(9L)); + when(userRepository.findAdditionalLocationIdsByUserId(targetStaff.getId())).thenReturn(List.of(9L)); + assertDoesNotThrow(() -> authorizationService.assertCanManageUser(targetStaff)); assertThrows(backend.fullstack.exceptions.AccessDeniedException.class, () -> authorizationService.assertCanManageUser(targetManager)); User staff = user(30L, 100L, Role.STAFF, "staff@everest.no"); - User targetStaff = user(31L, 100L, Role.STAFF, "target-staff@everest.no"); authenticate(staff); - when(userRepository.findByEmail(staff.getEmail())).thenReturn(java.util.Optional.of(staff)); - + when(userRepository.findById(staff.getId())).thenReturn(java.util.Optional.of(staff)); assertThrows(backend.fullstack.exceptions.AccessDeniedException.class, () -> authorizationService.assertCanManageUser(targetStaff)); } + @Test + void assertCanCreateUserFollowsHierarchy() { + User supervisor = user(10L, 100L, Role.SUPERVISOR, "supervisor@everest.no"); + authenticate(supervisor); + when(userRepository.findById(supervisor.getId())).thenReturn(java.util.Optional.of(supervisor)); + when(userRepository.findEffectiveLocationScopeByUserId(supervisor.getId())).thenReturn(List.of(7L)); + when(userLocationScopeAssignmentRepository.findActiveLocationIdsByUserId(eq(supervisor.getId()), any())) + .thenReturn(List.of()); + rolePermissionCatalog.setEffectivePermissions(null, Set.of(Permission.USERS_CREATE)); + + assertDoesNotThrow(() -> authorizationService.assertCanCreateUser(Role.STAFF, 7L)); + + backend.fullstack.exceptions.AccessDeniedException supervisorEx = + assertThrows(backend.fullstack.exceptions.AccessDeniedException.class, + () -> authorizationService.assertCanCreateUser(Role.SUPERVISOR, 7L)); + assertTrue(supervisorEx.getMessage().contains("Supervisors can only create")); + + User manager = user(20L, 100L, Role.MANAGER, "manager@everest.no"); + authenticate(manager); + when(userRepository.findById(manager.getId())).thenReturn(java.util.Optional.of(manager)); + when(userRepository.findEffectiveLocationScopeByUserId(manager.getId())).thenReturn(List.of(7L)); + when(userLocationScopeAssignmentRepository.findActiveLocationIdsByUserId(eq(manager.getId()), any())) + .thenReturn(List.of()); + rolePermissionCatalog.setEffectivePermissions(null, Set.of(Permission.USERS_CREATE)); + + assertDoesNotThrow(() -> authorizationService.assertCanCreateUser(Role.STAFF, 7L)); + + backend.fullstack.exceptions.AccessDeniedException managerEx = + assertThrows(backend.fullstack.exceptions.AccessDeniedException.class, + () -> authorizationService.assertCanCreateUser(Role.MANAGER, 7L)); + assertTrue(managerEx.getMessage().contains("Managers can only create")); + + User admin = user(1L, 100L, Role.ADMIN, "admin@everest.no"); + authenticate(admin); + when(userRepository.findById(admin.getId())).thenReturn(java.util.Optional.of(admin)); + when(locationRepository.findIdsByOrganizationId(100L)).thenReturn(List.of(7L)); + when(userLocationScopeAssignmentRepository.findActiveLocationIdsByUserId(eq(admin.getId()), any())) + .thenReturn(List.of()); + + assertDoesNotThrow(() -> authorizationService.assertCanCreateUser(Role.STAFF, 7L)); + } + @Test void getCurrentCapabilitiesIncludesPerLocationCapabilities() { User actor = user(1L, 100L, Role.MANAGER, "actor@everest.no"); authenticate(actor); - when(userRepository.findByEmail(actor.getEmail())).thenReturn(java.util.Optional.of(actor)); + when(userRepository.findById(actor.getId())).thenReturn(java.util.Optional.of(actor)); when(userRepository.findEffectiveLocationScopeByUserId(actor.getId())).thenReturn(List.of(7L, 8L)); when(userLocationScopeAssignmentRepository.findActiveLocationIdsByUserId(eq(actor.getId()), any())) .thenReturn(List.of()); diff --git a/backend/src/test/java/backend/fullstack/training/TrainingControllerTest.java b/backend/src/test/java/backend/fullstack/training/TrainingControllerTest.java new file mode 100644 index 0000000..0ce18ce --- /dev/null +++ b/backend/src/test/java/backend/fullstack/training/TrainingControllerTest.java @@ -0,0 +1,146 @@ +package backend.fullstack.training; + +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import backend.fullstack.config.GlobalExceptionHandler; +import backend.fullstack.training.dto.TrainingRecordRequest; +import backend.fullstack.training.dto.TrainingRecordResponse; + +class TrainingControllerTest { + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + private TrainingService trainingService; + + @BeforeEach + void setUp() { + trainingService = Mockito.mock(TrainingService.class); + TrainingController controller = new TrainingController(trainingService); + + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + objectMapper = new ObjectMapper().findAndRegisterModules(); + } + + @Test + void createReturnsCreatedApiResponse() throws Exception { + TrainingRecordRequest request = request(); + TrainingRecordResponse response = response(9L, 42L); + + when(trainingService.create(org.mockito.ArgumentMatchers.any(TrainingRecordRequest.class))) + .thenReturn(response); + + mockMvc.perform(post("/api/training/records") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Training record created")) + .andExpect(jsonPath("$.data.id").value(9)) + .andExpect(jsonPath("$.data.userId").value(42)) + .andExpect(jsonPath("$.data.trainingType").value("GENERAL")); + } + + @Test + void getVisibleRecordsReturnsWrappedList() throws Exception { + when(trainingService.getVisibleRecords(42L, TrainingType.GENERAL, TrainingStatus.COMPLETED)) + .thenReturn(List.of(response(9L, 42L), response(10L, 42L))); + + mockMvc.perform(get("/api/training/records") + .param("userId", "42") + .param("trainingType", "GENERAL") + .param("status", "COMPLETED")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Training records fetched")) + .andExpect(jsonPath("$.data", hasSize(2))); + + verify(trainingService).getVisibleRecords(42L, TrainingType.GENERAL, TrainingStatus.COMPLETED); + } + + @Test + void getByIdReturnsWrappedResponse() throws Exception { + when(trainingService.getById(9L)).thenReturn(response(9L, 42L)); + + mockMvc.perform(get("/api/training/records/9")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Training record fetched")) + .andExpect(jsonPath("$.data.id").value(9)) + .andExpect(jsonPath("$.data.userId").value(42)); + + verify(trainingService).getById(9L); + } + + @Test + void updateReturnsWrappedResponse() throws Exception { + TrainingRecordRequest request = request(); + TrainingRecordResponse response = response(9L, 42L); + + when(trainingService.update(eq(9L), org.mockito.ArgumentMatchers.any(TrainingRecordRequest.class))) + .thenReturn(response); + + mockMvc.perform(put("/api/training/records/9") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Training record updated")) + .andExpect(jsonPath("$.data.id").value(9)); + } + + @Test + void createReturnsValidationErrorForMissingRequiredFields() throws Exception { + mockMvc.perform(post("/api/training/records") + .contentType(APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.fieldErrors.userId").value("User id is required")) + .andExpect(jsonPath("$.fieldErrors.trainingType").value("Training type is required")) + .andExpect(jsonPath("$.fieldErrors.status").value("Training status is required")); + } + + private static TrainingRecordRequest request() { + TrainingRecordRequest request = new TrainingRecordRequest(); + request.setUserId(42L); + request.setTrainingType(TrainingType.GENERAL); + request.setStatus(TrainingStatus.COMPLETED); + request.setCompletedAt(LocalDateTime.of(2026, 4, 4, 10, 0)); + return request; + } + + private static TrainingRecordResponse response(Long id, Long userId) { + return TrainingRecordResponse.builder() + .id(id) + .userId(userId) + .userEmail("staff@everest.no") + .userName("Staff User") + .organizationId(100L) + .trainingType(TrainingType.GENERAL) + .status(TrainingStatus.COMPLETED) + .completedAt(LocalDateTime.of(2026, 4, 4, 10, 0)) + .build(); + } +} diff --git a/backend/src/test/java/backend/fullstack/training/TrainingRecordRepositoryTest.java b/backend/src/test/java/backend/fullstack/training/TrainingRecordRepositoryTest.java new file mode 100644 index 0000000..6378afe --- /dev/null +++ b/backend/src/test/java/backend/fullstack/training/TrainingRecordRepositoryTest.java @@ -0,0 +1,134 @@ +package backend.fullstack.training; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; + +import backend.fullstack.organization.Organization; +import backend.fullstack.user.User; +import backend.fullstack.user.role.Role; + +@DataJpaTest +class TrainingRecordRepositoryTest { + + @Autowired + private TrainingRecordRepository trainingRecordRepository; + + @Autowired + private TestEntityManager entityManager; + + @Test + void hasValidTrainingReturnsTrueForCompletedNonExpiredRecord() { + User user = persistUser("manager@everest.no"); + + entityManager.persist(TrainingRecord.builder() + .user(user) + .trainingType(TrainingType.CHECKLIST_APPROVAL) + .status(TrainingStatus.COMPLETED) + .completedAt(LocalDateTime.now().minusDays(2)) + .expiresAt(LocalDateTime.now().plusDays(30)) + .build()); + entityManager.flush(); + + boolean hasValidTraining = trainingRecordRepository.hasValidTraining( + user.getId(), + TrainingType.CHECKLIST_APPROVAL, + LocalDateTime.now() + ); + + assertTrue(hasValidTraining); + } + + @Test + void hasValidTrainingReturnsFalseForExpiredRecord() { + User user = persistUser("staff@everest.no"); + + entityManager.persist(TrainingRecord.builder() + .user(user) + .trainingType(TrainingType.FREEZER_LOGGING) + .status(TrainingStatus.COMPLETED) + .completedAt(LocalDateTime.now().minusDays(30)) + .expiresAt(LocalDateTime.now().minusDays(1)) + .build()); + entityManager.flush(); + + boolean hasValidTraining = trainingRecordRepository.hasValidTraining( + user.getId(), + TrainingType.FREEZER_LOGGING, + LocalDateTime.now() + ); + + assertFalse(hasValidTraining); + } + + @Test + void findVisibleRecordsFiltersByOrganizationAndOptionalFields() { + Organization organization = persistOrganization("Everest", "937219997"); + Organization otherOrganization = persistOrganization("Other", "123456789"); + + User user = persistUser(organization, "staff@everest.no"); + User otherUser = persistUser(otherOrganization, "staff@other.no"); + + entityManager.persist(TrainingRecord.builder() + .user(user) + .trainingType(TrainingType.GENERAL) + .status(TrainingStatus.COMPLETED) + .completedAt(LocalDateTime.now().minusDays(1)) + .build()); + + entityManager.persist(TrainingRecord.builder() + .user(otherUser) + .trainingType(TrainingType.GENERAL) + .status(TrainingStatus.COMPLETED) + .completedAt(LocalDateTime.now().minusDays(1)) + .build()); + + entityManager.flush(); + + List records = trainingRecordRepository.findVisibleRecords( + organization.getId(), + user.getId(), + TrainingType.GENERAL, + TrainingStatus.COMPLETED + ); + + assertTrue(records.stream().allMatch(record -> record.getUser().getOrganizationId().equals(organization.getId()))); + assertTrue(records.stream().allMatch(record -> record.getUser().getId().equals(user.getId()))); + assertTrue(records.stream().allMatch(record -> record.getTrainingType() == TrainingType.GENERAL)); + } + + private User persistUser(String email) { + Organization organization = persistOrganization("Everest", email.hashCode() % 2 == 0 ? "937219998" : "937219999"); + return persistUser(organization, email); + } + + private User persistUser(Organization organization, String email) { + User user = User.builder() + .organization(organization) + .email(email) + .firstName("Test") + .lastName("User") + .passwordHash("hash") + .role(Role.STAFF) + .isActive(true) + .build(); + entityManager.persist(user); + return user; + } + + private Organization persistOrganization(String name, String organizationNumber) { + Organization organization = Organization.builder() + .name(name) + .organizationNumber(organizationNumber) + .build(); + entityManager.persist(organization); + return organization; + } +} diff --git a/backend/src/test/java/backend/fullstack/training/TrainingServiceTest.java b/backend/src/test/java/backend/fullstack/training/TrainingServiceTest.java new file mode 100644 index 0000000..ce029e3 --- /dev/null +++ b/backend/src/test/java/backend/fullstack/training/TrainingServiceTest.java @@ -0,0 +1,176 @@ +package backend.fullstack.training; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import backend.fullstack.exceptions.AccessDeniedException; +import backend.fullstack.exceptions.RoleException; +import backend.fullstack.organization.Organization; +import backend.fullstack.permission.core.AuthorizationService; +import backend.fullstack.training.dto.TrainingRecordRequest; +import backend.fullstack.training.dto.TrainingRecordResponse; +import backend.fullstack.user.AccessContextService; +import backend.fullstack.user.User; +import backend.fullstack.user.UserRepository; +import backend.fullstack.user.role.Role; + +@ExtendWith(MockitoExtension.class) +class TrainingServiceTest { + + @Mock + private TrainingRecordRepository trainingRecordRepository; + @Mock + private UserRepository userRepository; + @Mock + private AccessContextService accessContext; + @Mock + private AuthorizationService authorizationService; + + private TrainingService trainingService; + + @BeforeEach + void setUp() { + trainingService = new TrainingService( + trainingRecordRepository, + userRepository, + accessContext, + authorizationService + ); + } + + @Test + void createCompletedTrainingAutoSetsCompletedAtWhenMissing() { + User targetUser = user(42L, 100L, Role.STAFF, "staff@everest.no"); + TrainingRecordRequest request = request(42L, TrainingStatus.COMPLETED, null, null); + + when(accessContext.getCurrentOrganizationId()).thenReturn(100L); + when(userRepository.findById(42L)).thenReturn(Optional.of(targetUser)); + when(trainingRecordRepository.save(any(TrainingRecord.class))).thenAnswer(invocation -> { + TrainingRecord record = invocation.getArgument(0); + record.setId(9L); + return record; + }); + + TrainingRecordResponse response = trainingService.create(request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TrainingRecord.class); + verify(trainingRecordRepository).save(captor.capture()); + assertNotNull(captor.getValue().getCompletedAt()); + assertEquals(9L, response.getId()); + verify(authorizationService).assertCanManageUser(targetUser); + } + + @Test + void getVisibleRecordsFiltersOutUnauthorizedUsers() { + User actor = user(10L, 100L, Role.MANAGER, "manager@everest.no"); + User self = user(10L, 100L, Role.MANAGER, "manager@everest.no"); + User teammate = user(42L, 100L, Role.STAFF, "staff@everest.no"); + + when(accessContext.getCurrentOrganizationId()).thenReturn(100L); + when(accessContext.getCurrentUser()).thenReturn(actor); + when(trainingRecordRepository.findVisibleRecords(100L, null, null, null)) + .thenReturn(List.of( + record(1L, self, TrainingType.GENERAL), + record(2L, teammate, TrainingType.CHECKLIST_APPROVAL) + )); + when(authorizationService.canViewUser(teammate)).thenReturn(false); + + List visible = trainingService.getVisibleRecords(null, null, null); + + assertEquals(1, visible.size()); + assertEquals(self.getId(), visible.get(0).getUserId()); + } + + @Test + void getByIdRejectsRecordWhenActorCannotViewTargetUser() { + User actor = user(10L, 100L, Role.MANAGER, "manager@everest.no"); + User teammate = user(42L, 100L, Role.STAFF, "staff@everest.no"); + + when(accessContext.getCurrentOrganizationId()).thenReturn(100L); + when(accessContext.getCurrentUser()).thenReturn(actor); + when(trainingRecordRepository.findByIdAndUser_Organization_Id(9L, 100L)) + .thenReturn(Optional.of(record(9L, teammate, TrainingType.GENERAL))); + when(authorizationService.canViewUser(teammate)).thenReturn(false); + + assertThrows(AccessDeniedException.class, () -> trainingService.getById(9L)); + } + + @Test + void updateRejectsInvalidDateWindow() { + User targetUser = user(42L, 100L, Role.STAFF, "staff@everest.no"); + TrainingRecord existing = record(9L, targetUser, TrainingType.GENERAL); + LocalDateTime completedAt = LocalDateTime.of(2026, 4, 4, 12, 0); + LocalDateTime expiresAt = completedAt.minusDays(1); + + when(accessContext.getCurrentOrganizationId()).thenReturn(100L); + when(trainingRecordRepository.findByIdAndUser_Organization_Id(9L, 100L)) + .thenReturn(Optional.of(existing)); + when(userRepository.findById(42L)).thenReturn(Optional.of(targetUser)); + + assertThrows(RoleException.class, () -> trainingService.update( + 9L, + request(42L, TrainingStatus.COMPLETED, completedAt, expiresAt) + )); + + verify(trainingRecordRepository, never()).save(any()); + } + + private static TrainingRecordRequest request( + Long userId, + TrainingStatus status, + LocalDateTime completedAt, + LocalDateTime expiresAt + ) { + TrainingRecordRequest request = new TrainingRecordRequest(); + request.setUserId(userId); + request.setTrainingType(TrainingType.GENERAL); + request.setStatus(status); + request.setCompletedAt(completedAt); + request.setExpiresAt(expiresAt); + return request; + } + + private static TrainingRecord record(Long id, User user, TrainingType type) { + return TrainingRecord.builder() + .id(id) + .user(user) + .trainingType(type) + .status(TrainingStatus.COMPLETED) + .completedAt(LocalDateTime.of(2026, 4, 4, 10, 0)) + .build(); + } + + private static User user(Long id, Long orgId, Role role, String email) { + Organization organization = Organization.builder() + .id(orgId) + .name("Everest") + .organizationNumber("937219997") + .build(); + + return User.builder() + .id(id) + .organization(organization) + .email(email) + .firstName("Test") + .lastName("User") + .passwordHash("hash") + .role(role) + .build(); + } +} diff --git a/backend/src/test/java/backend/fullstack/user/AccessContextServiceTest.java b/backend/src/test/java/backend/fullstack/user/AccessContextServiceTest.java index 0329469..d36db1a 100644 --- a/backend/src/test/java/backend/fullstack/user/AccessContextServiceTest.java +++ b/backend/src/test/java/backend/fullstack/user/AccessContextServiceTest.java @@ -18,6 +18,7 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; +import backend.fullstack.config.JwtPrincipal; import backend.fullstack.location.LocationRepository; import backend.fullstack.organization.Organization; import backend.fullstack.user.role.Role; @@ -76,6 +77,27 @@ void managerGetsEffectiveScopePlusTemporaryLocations() { assertEquals(List.of(4L, 5L, 6L), allowed); } + @Test + void usesJwtLocationScopeBeforeRepositoryLookup() { + JwtPrincipal principal = new JwtPrincipal( + 2L, + "manager@everest.no", + Role.MANAGER, + 100L, + List.of(4L, 5L) + ); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, List.of()) + ); + + when(userLocationScopeAssignmentRepository.findActiveLocationIdsByUserId(eq(2L), any())) + .thenReturn(List.of(6L)); + + List allowed = accessContextService.getAllowedLocationIds(); + + assertEquals(List.of(4L, 5L, 6L), allowed); + } + @Test void assertCanAccessThrowsWhenLocationIsMissing() { User staff = user(3L, 100L, Role.STAFF, "staff@everest.no"); diff --git a/backend/src/test/java/backend/fullstack/user/UserAdminPermissionControllerTest.java b/backend/src/test/java/backend/fullstack/user/UserAdminPermissionControllerTest.java new file mode 100644 index 0000000..dbbb7ed --- /dev/null +++ b/backend/src/test/java/backend/fullstack/user/UserAdminPermissionControllerTest.java @@ -0,0 +1,148 @@ +package backend.fullstack.user; + +import static org.mockito.Mockito.verify; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import backend.fullstack.config.GlobalExceptionHandler; +import backend.fullstack.permission.model.Permission; +import backend.fullstack.permission.model.PermissionEffect; +import backend.fullstack.permission.model.PermissionScope; +import backend.fullstack.user.dto.AssignProfilesRequest; +import backend.fullstack.user.dto.TemporaryLocationScopeRequest; +import backend.fullstack.user.dto.UserPermissionOverrideRequest; + +class UserAdminPermissionControllerTest { + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + private UserService userService; + + @BeforeEach + void setUp() { + userService = Mockito.mock(UserService.class); + UserAdminPermissionController controller = new UserAdminPermissionController(userService); + + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + objectMapper = new ObjectMapper().findAndRegisterModules(); + } + + @Test + void assignProfilesDelegatesToService() throws Exception { + AssignProfilesRequest request = new AssignProfilesRequest(); + request.setProfileIds(List.of(1L, 2L)); + request.setLocationId(5L); + request.setStartsAt(LocalDateTime.of(2026, 4, 4, 8, 0)); + request.setEndsAt(LocalDateTime.of(2026, 4, 4, 16, 0)); + request.setReplaceScopeAssignments(true); + + mockMvc.perform(put("/api/admin/users/50/profiles") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Profiles assigned")); + + verify(userService).assignProfiles(50L, List.of(1L, 2L), 5L, request.getStartsAt(), request.getEndsAt(), true); + } + + @Test + void assignPermissionOverrideDelegatesToService() throws Exception { + UserPermissionOverrideRequest request = new UserPermissionOverrideRequest(); + request.setPermission(Permission.USERS_UPDATE); + request.setEffect(PermissionEffect.ALLOW); + request.setScope(PermissionScope.LOCATION); + request.setLocationId(5L); + request.setStartsAt(LocalDateTime.of(2026, 4, 4, 8, 0)); + request.setEndsAt(LocalDateTime.of(2026, 4, 4, 16, 0)); + request.setReason("Shift coverage"); + + mockMvc.perform(post("/api/admin/users/50/permissions") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Permission override assigned")); + + verify(userService).assignTemporaryPermission( + 50L, + Permission.USERS_UPDATE, + PermissionEffect.ALLOW, + PermissionScope.LOCATION, + 5L, + request.getStartsAt(), + request.getEndsAt(), + "Shift coverage" + ); + } + + @Test + void assignTemporaryLocationDelegatesToService() throws Exception { + TemporaryLocationScopeRequest request = new TemporaryLocationScopeRequest(); + request.setLocationId(7L); + request.setStartsAt(LocalDateTime.of(2026, 4, 4, 8, 0)); + request.setEndsAt(LocalDateTime.of(2026, 4, 4, 16, 0)); + request.setMode(TemporaryAssignmentMode.ACTING); + request.setReason("Cover absence"); + + mockMvc.perform(post("/api/admin/users/50/locations/temporary") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Temporary location scope assigned")); + + verify(userService).assignTemporaryLocationScope( + 50L, + 7L, + request.getStartsAt(), + request.getEndsAt(), + TemporaryAssignmentMode.ACTING, + "Cover absence" + ); + } + + @Test + void assignProfilesValidatesRequiredProfileIds() throws Exception { + mockMvc.perform(put("/api/admin/users/50/profiles") + .contentType(APPLICATION_JSON) + .content("{\"profileIds\":[]}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.fieldErrors.profileIds").value("At least one profile id is required")); + } + + @Test + void completeTemporaryLocationDelegatesToService() throws Exception { + mockMvc.perform(post("/api/admin/users/50/locations/temporary/11/complete")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Temporary location assignment completed")); + + verify(userService).completeTemporaryLocationScope(50L, 11L); + } + + @Test + void confirmTemporaryLocationDelegatesToService() throws Exception { + mockMvc.perform(post("/api/admin/users/50/locations/temporary/11/confirm")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Temporary location assignment confirmed")); + + verify(userService).confirmTemporaryLocationScope(50L, 11L); + } +} diff --git a/backend/src/test/java/backend/fullstack/user/UserControllerTest.java b/backend/src/test/java/backend/fullstack/user/UserControllerTest.java new file mode 100644 index 0000000..f965897 --- /dev/null +++ b/backend/src/test/java/backend/fullstack/user/UserControllerTest.java @@ -0,0 +1,299 @@ +package backend.fullstack.user; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import backend.fullstack.config.GlobalExceptionHandler; +import backend.fullstack.location.dto.AssignLocationsRequest; +import backend.fullstack.user.dto.ChangePasswordRequest; +import backend.fullstack.user.dto.CreateUserRequest; +import backend.fullstack.user.dto.UpdateUserProfileRequest; +import backend.fullstack.user.dto.UserResponse; +import backend.fullstack.user.role.Role; +import backend.fullstack.user.role.UpdateRoleRequest; + +class UserControllerTest { + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + private TestUserService userService; + + @BeforeEach + void setUp() { + userService = new TestUserService(); + UserController controller = new UserController(userService); + + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + objectMapper = new ObjectMapper().findAndRegisterModules(); + } + + @Test + void getAllInOrganizationReturnsWrappedList() throws Exception { + userService.allInOrganizationResponse = List.of(response(1L), response(2L)); + + mockMvc.perform(get("/api/users")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Users retrieved")) + .andExpect(jsonPath("$.data", hasSize(2))); + + assert userService.getAllCalled; + } + + @Test + void getMyProfileReturnsWrappedResponse() throws Exception { + userService.myProfileResponse = response(1L); + + mockMvc.perform(get("/api/users/me")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Profile retrieved")) + .andExpect(jsonPath("$.data.id").value(1)); + + assert userService.getMyProfileCalled; + } + + @Test + void getByIdReturnsWrappedResponse() throws Exception { + userService.getByIdResponse = response(2L); + + mockMvc.perform(get("/api/users/2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("User retrieved")) + .andExpect(jsonPath("$.data.id").value(2)); + + assert userService.lastId.equals(2L); + } + + @Test + void createReturnsWrappedResponse() throws Exception { + CreateUserRequest request = createRequest(); + userService.createResponse = response(9L); + + mockMvc.perform(post("/api/users") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("User created")) + .andExpect(jsonPath("$.data.id").value(9)) + .andExpect(jsonPath("$.data.email").value("staff@everest.no")); + } + + @Test + void updateProfileReturnsWrappedResponse() throws Exception { + UpdateUserProfileRequest request = updateProfileRequest(); + userService.updateProfileResponse = response(3L); + + mockMvc.perform(put("/api/users/3") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("User updated")) + .andExpect(jsonPath("$.data.id").value(3)); + } + + @Test + void updateRoleReturnsWrappedResponse() throws Exception { + UpdateRoleRequest request = new UpdateRoleRequest(); + request.setRole(Role.MANAGER); + request.setLocationId(5L); + + userService.updateRoleResponse = response(3L); + + mockMvc.perform(put("/api/users/3/role") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("User role updated")) + .andExpect(jsonPath("$.data.id").value(3)); + } + + @Test + void assignAdditionalLocationsReturnsWrappedResponse() throws Exception { + AssignLocationsRequest request = new AssignLocationsRequest(); + request.setLocationIds(List.of(1L, 2L)); + + userService.assignLocationsResponse = response(3L); + + mockMvc.perform(put("/api/users/3/locations") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("User locations updated")) + .andExpect(jsonPath("$.data.id").value(3)); + } + + @Test + void changePasswordDelegatesToService() throws Exception { + ChangePasswordRequest request = new ChangePasswordRequest(); + request.setCurrentPassword("OldPassword1"); + request.setNewPassword("NewPassword1"); + + mockMvc.perform(post("/api/users/me/change-password") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Password changed")); + + assert userService.changePasswordCalled; + } + + @Test + void deactivateDelegatesToService() throws Exception { + mockMvc.perform(post("/api/users/3/deactivate")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("User deactivated")); + + assert userService.deactivatedId.equals(3L); + } + + @Test + void reactivateDelegatesToService() throws Exception { + mockMvc.perform(post("/api/users/3/reactivate")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("User reactivated")); + + assert userService.reactivatedId.equals(3L); + } + + @Test + void createReturnsValidationErrorForMissingFields() throws Exception { + mockMvc.perform(post("/api/users") + .contentType(APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.fieldErrors.firstName").value("First name is required")) + .andExpect(jsonPath("$.fieldErrors.lastName").value("Last name is required")) + .andExpect(jsonPath("$.fieldErrors.email").value("Email is required")) + .andExpect(jsonPath("$.fieldErrors.role").value("Role is required")) + .andExpect(jsonPath("$.fieldErrors.password").value("Password is required")); + } + + private static CreateUserRequest createRequest() { + CreateUserRequest request = new CreateUserRequest(); + request.setFirstName("Ola"); + request.setLastName("Nordmann"); + request.setEmail("staff@everest.no"); + request.setRole(Role.STAFF); + request.setLocationId(1L); + request.setPassword("Password1"); + return request; + } + + private static UpdateUserProfileRequest updateProfileRequest() { + UpdateUserProfileRequest request = new UpdateUserProfileRequest(); + request.setFirstName("Ola"); + request.setLastName("Nordmann"); + return request; + } + + private static UserResponse response(Long id) { + return UserResponse.builder() + .id(id) + .firstName("Ola") + .lastName("Nordmann") + .email("staff@everest.no") + .role(Role.STAFF) + .homeLocationId(1L) + .homeLocationName("Trondheim Office") + .additionalLocationIds(List.of(2L, 3L)) + .organizationName("Everest AS") + .organizationId(10L) + .isActive(true) + .createdAt(LocalDateTime.of(2026, 4, 4, 12, 0)) + .build(); + } + + private static final class TestUserService extends UserService { + + private List allInOrganizationResponse = List.of(); + private UserResponse myProfileResponse; + private UserResponse getByIdResponse; + private UserResponse createResponse; + private UserResponse updateProfileResponse; + private UserResponse updateRoleResponse; + private UserResponse assignLocationsResponse; + private boolean getAllCalled; + private boolean getMyProfileCalled; + private boolean changePasswordCalled; + private Long lastId; + private Long deactivatedId; + private Long reactivatedId; + + private TestUserService() { + super(null, null, null, null, null, null, null, null, null, null); + } + + @Override + public List getAllInOrganization() { + getAllCalled = true; + return allInOrganizationResponse; + } + + @Override + public UserResponse getMyProfile() { + getMyProfileCalled = true; + return myProfileResponse; + } + + @Override + public UserResponse getById(Long id) { + lastId = id; + return getByIdResponse; + } + + @Override + public UserResponse create(CreateUserRequest request) { + return createResponse; + } + + @Override + public UserResponse updateProfile(Long id, UpdateUserProfileRequest request) { + lastId = id; + return updateProfileResponse; + } + + @Override + public UserResponse updateRole(Long id, UpdateRoleRequest request) { + lastId = id; + return updateRoleResponse; + } + + @Override + public UserResponse assignAdditionalLocations(Long id, AssignLocationsRequest request) { + lastId = id; + return assignLocationsResponse; + } + + @Override + public void changePassword(ChangePasswordRequest request) { + changePasswordCalled = true; + } + + @Override + public void deactivate(Long id) { + deactivatedId = id; + } + + @Override + public void reactivate(Long id) { + reactivatedId = id; + } + } +} diff --git a/backend/src/test/java/backend/fullstack/user/UserServicePermissionFlowTest.java b/backend/src/test/java/backend/fullstack/user/UserServicePermissionFlowTest.java index 4bfba78..b6d34f5 100644 --- a/backend/src/test/java/backend/fullstack/user/UserServicePermissionFlowTest.java +++ b/backend/src/test/java/backend/fullstack/user/UserServicePermissionFlowTest.java @@ -22,6 +22,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.crypto.password.PasswordEncoder; @@ -208,6 +209,15 @@ void validateWindowRejectsInvalidRange() { )); } + @Test + void assignProfilesRequiresAdminRole() { + User target = targetUser(); + setPrincipal(Role.MANAGER); + + assertThrows(AccessDeniedException.class, + () -> userService.assignProfiles(50L, List.of(1L), null, null, null, true)); + } + private static User targetUser() { Organization organization = Organization.builder() .id(100L) @@ -258,10 +268,14 @@ private static Location location(Long id, Long orgId) { } private static void setAdminPrincipal() { + setPrincipal(Role.ADMIN); + } + + private static void setPrincipal(Role role) { JwtPrincipal principal = new JwtPrincipal( 999L, - "admin@everest.no", - Role.ADMIN, + role.name().toLowerCase() + "@everest.no", + role, 100L, List.of() ); diff --git a/backend/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/backend/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..fdbd0b1 --- /dev/null +++ b/backend/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass