diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92c3618..b53bbd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,5 +25,5 @@ jobs: distribution: temurin cache: maven - - name: Run tests - run: mvn clean test --batch-mode --no-transfer-progress + - name: Build and Test + run: mvn clean verify --batch-mode --no-transfer-progress diff --git a/pom.xml b/pom.xml index 9e13819..c3e303e 100644 --- a/pom.xml +++ b/pom.xml @@ -116,6 +116,12 @@ zipkin-reporter-brave + + + org.springframework.boot + spring-boot-starter-aop + + org.springframework.boot diff --git a/src/main/java/com/cn/hotelDemo/annotation/AuditLogged.java b/src/main/java/com/cn/hotelDemo/annotation/AuditLogged.java new file mode 100644 index 0000000..46d9a67 --- /dev/null +++ b/src/main/java/com/cn/hotelDemo/annotation/AuditLogged.java @@ -0,0 +1,14 @@ +package com.cn.hotelDemo.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuditLogged { + String action(); + String resourceType(); + String resourceIdSpel() default "''"; +} diff --git a/src/main/java/com/cn/hotelDemo/aspect/AuditAspect.java b/src/main/java/com/cn/hotelDemo/aspect/AuditAspect.java new file mode 100644 index 0000000..9661dec --- /dev/null +++ b/src/main/java/com/cn/hotelDemo/aspect/AuditAspect.java @@ -0,0 +1,76 @@ +package com.cn.hotelDemo.aspect; + +import java.lang.reflect.Method; + +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import com.cn.hotelDemo.annotation.AuditLogged; +import com.cn.hotelDemo.service.AuditService; + +@Aspect +@Component +public class AuditAspect { + + private final AuditService auditService; + private final ExpressionParser parser = new SpelExpressionParser(); + + public AuditAspect(AuditService auditService) { + this.auditService = auditService; + } + + @AfterReturning(pointcut = "@annotation(auditLogged)", returning = "result") + public void logAudit(JoinPoint joinPoint, AuditLogged auditLogged, Object result) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String username = (authentication != null && authentication.getName() != null) ? authentication.getName() : "anonymous"; + + String resourceId = resolveSpelExpression(joinPoint, result, auditLogged.resourceIdSpel(), "N/A"); + String action = auditLogged.action().startsWith("#") ? resolveSpelExpression(joinPoint, result, auditLogged.action(), "UNKNOWN") : auditLogged.action(); + + auditService.record( + action, + username, + auditLogged.resourceType(), + resourceId, + "SUCCESS", + "Action performed successfully" + ); + } + + private String resolveSpelExpression(JoinPoint joinPoint, Object result, String spelExpression, String defaultValue) { + if (spelExpression == null || spelExpression.isEmpty() || spelExpression.equals("''")) { + return defaultValue; + } + + EvaluationContext context = new StandardEvaluationContext(); + + // Add method arguments to context + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + String[] parameterNames = signature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + if (parameterNames != null) { + for (int i = 0; i < parameterNames.length; i++) { + context.setVariable(parameterNames[i], args[i]); + } + } + + // Add method return value to context + context.setVariable("result", result); + + try { + Object evaluatedId = parser.parseExpression(spelExpression).getValue(context); + return evaluatedId != null ? String.valueOf(evaluatedId) : "N/A"; + } catch (Exception e) { + return "UNKNOWN"; + } + } +} diff --git a/src/main/java/com/cn/hotelDemo/controller/BookingController.java b/src/main/java/com/cn/hotelDemo/controller/BookingController.java index b3b2e55..2d54556 100644 --- a/src/main/java/com/cn/hotelDemo/controller/BookingController.java +++ b/src/main/java/com/cn/hotelDemo/controller/BookingController.java @@ -18,7 +18,7 @@ import com.cn.hotelDemo.dto.BookingResponse; import com.cn.hotelDemo.model.Booking; import com.cn.hotelDemo.model.BookingStatus; -import com.cn.hotelDemo.service.AuditService; +import com.cn.hotelDemo.annotation.AuditLogged; import com.cn.hotelDemo.service.BookingService; import io.swagger.v3.oas.annotations.Operation; @@ -33,21 +33,17 @@ public class BookingController { private final BookingService bookingService; - private final AuditService auditService; - - public BookingController(BookingService bookingService, AuditService auditService) { + public BookingController(BookingService bookingService) { this.bookingService = bookingService; - this.auditService = auditService; } @PostMapping("/create") @PreAuthorize("hasRole('ADMIN') or hasAuthority('admin') or hasRole('NORMAL') or hasAuthority('normal')") @Operation(summary = "Create a new booking") + @AuditLogged(action = "#result != null ? 'BOOKING_' + #result.body.status.name() : 'UNKNOWN'", resourceType = "BOOKING", resourceIdSpel = "#result.body.bookingId") public ResponseEntity createBooking(@Valid @RequestBody BookingRequest bookingRequest, Authentication authentication) { BookingResponse response = bookingService.createBooking(bookingRequest); - auditService.record("BOOKING_" + response.getStatus().name(), authentication.getName(), "BOOKING", - String.valueOf(response.getBookingId()), response.getStatus().name(), response.getMessage()); return new ResponseEntity<>(response, response.getStatus() == BookingStatus.CONFIRMED ? HttpStatus.CREATED : HttpStatus.CONFLICT); } @@ -57,9 +53,6 @@ public ResponseEntity createBooking(@Valid @RequestBody Booking @Operation(summary = "Get a booking by its ID") public ResponseEntity getBookingById(@PathVariable Long id) { Booking booking = bookingService.getBookingById(id); - if (booking == null) { - return ResponseEntity.notFound().build(); - } return ResponseEntity.ok(booking); } @@ -87,11 +80,9 @@ public ResponseEntity> getBookingsByHotel(@PathVariable Long hotel @PostMapping("/cancel/{id}") @PreAuthorize("hasRole('ADMIN') or hasAuthority('admin') or hasRole('NORMAL') or hasAuthority('normal')") @Operation(summary = "Cancel a booking by its ID") + @AuditLogged(action = "BOOKING_CANCELLED", resourceType = "BOOKING", resourceIdSpel = "#result.body.bookingId") public ResponseEntity cancelBooking(@PathVariable Long id, Authentication authentication) { Booking booking = bookingService.getBookingById(id); - if (booking == null) { - return ResponseEntity.notFound().build(); - } boolean isAdmin = authentication.getAuthorities().stream() .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("admin")); @@ -102,8 +93,6 @@ public ResponseEntity cancelBooking(@PathVariable Long id, Auth } BookingResponse response = bookingService.cancelBooking(id); - auditService.record("BOOKING_CANCELLED", authentication.getName(), "BOOKING", - String.valueOf(response.getBookingId()), response.getStatus().name(), response.getMessage()); return ResponseEntity.ok(response); } } diff --git a/src/main/java/com/cn/hotelDemo/controller/HotelController.java b/src/main/java/com/cn/hotelDemo/controller/HotelController.java index 6b14730..fe90b6c 100644 --- a/src/main/java/com/cn/hotelDemo/controller/HotelController.java +++ b/src/main/java/com/cn/hotelDemo/controller/HotelController.java @@ -20,7 +20,7 @@ import com.cn.hotelDemo.dto.HotelRequest; import com.cn.hotelDemo.model.Hotel; import com.cn.hotelDemo.service.HotelService; -import com.cn.hotelDemo.service.AuditService; +import com.cn.hotelDemo.annotation.AuditLogged; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -34,29 +34,22 @@ public class HotelController { @Autowired HotelService hotelService; - - @Autowired - AuditService auditService; @GetMapping("/userDetail") @Operation(summary = "Get current authenticated user details from OIDC") + @AuditLogged(action = "OIDC_PROFILE_VIEWED", resourceType = "AUTH", resourceIdSpel = "#oidcUser.email") public String getDetails(@AuthenticationPrincipal OidcUser oidcUser) { - String details = "User name: %s, email: %s".formatted(oidcUser.getFullName(), oidcUser.getEmail()); - auditService.record("OIDC_PROFILE_VIEWED", oidcUser.getEmail(), "AUTH", oidcUser.getEmail(), "SUCCESS", - "Authenticated profile details were requested"); - return details; + return "User name: %s, email: %s".formatted(oidcUser.getFullName(), oidcUser.getEmail()); } @PostMapping("/create") @PreAuthorize("hasRole('ADMIN') or hasAuthority('admin')") @Operation(summary = "Create a new hotel (Admin only)") - public void createHotel(@Valid @RequestBody HotelRequest hotelRequest, Authentication authentication) + @AuditLogged(action = "HOTEL_CREATED", resourceType = "HOTEL", resourceIdSpel = "#result.id") + public Hotel createHotel(@Valid @RequestBody HotelRequest hotelRequest, Authentication authentication) { - Hotel createdHotel = hotelService.createHotel(hotelRequest); - auditService.record("HOTEL_CREATED", authentication.getName(), "HOTEL", String.valueOf(createdHotel.getId()), - "SUCCESS", "Hotel created with name=%s, city=%s".formatted(createdHotel.getName(), - createdHotel.getCity())); - } + return hotelService.createHotel(hotelRequest); + } @GetMapping("/id/{id}") @PreAuthorize("hasRole('NORMAL') or hasAuthority('normal')") @@ -77,11 +70,9 @@ public List getAllHotels() @DeleteMapping("/remove/id/{id}") @PreAuthorize("hasRole('admin') or hasAuthority('admin')") @Operation(summary = "Delete a hotel by ID (Admin only)") + @AuditLogged(action = "HOTEL_DELETED", resourceType = "HOTEL", resourceIdSpel = "#id") public void deleteHotelById(@PathVariable Long id, Authentication authentication) { hotelService.deleteHotelById(id); - auditService.record("HOTEL_DELETED", authentication.getName(), "HOTEL", String.valueOf(id), "SUCCESS", - "Hotel delete requested for id=%s".formatted(id)); - } } diff --git a/src/main/java/com/cn/hotelDemo/controller/LoginController.java b/src/main/java/com/cn/hotelDemo/controller/LoginController.java index a5cbc26..ce4e1e1 100644 --- a/src/main/java/com/cn/hotelDemo/controller/LoginController.java +++ b/src/main/java/com/cn/hotelDemo/controller/LoginController.java @@ -5,7 +5,7 @@ import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; -import com.cn.hotelDemo.service.AuditService; +import com.cn.hotelDemo.annotation.AuditLogged; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -14,17 +14,13 @@ @Tag(name = "Login Controller", description = "Endpoints for authentication UI") public class LoginController { - private final AuditService auditService; - - public LoginController(AuditService auditService) { - this.auditService = auditService; + public LoginController() { } @GetMapping("/login") @Operation(summary = "Serve the login page") + @AuditLogged(action = "LOGIN_PAGE_VIEWED", resourceType = "AUTH", resourceIdSpel = "#request.getRemoteAddr()") public String login(HttpServletRequest request) { - auditService.record("LOGIN_PAGE_VIEWED", "anonymous", "AUTH", "/login", "SUCCESS", - "Login page rendered from %s".formatted(request.getRemoteAddr())); return "login"; } } diff --git a/src/main/java/com/cn/hotelDemo/controller/RoomController.java b/src/main/java/com/cn/hotelDemo/controller/RoomController.java index d4dad5f..c43a2cd 100644 --- a/src/main/java/com/cn/hotelDemo/controller/RoomController.java +++ b/src/main/java/com/cn/hotelDemo/controller/RoomController.java @@ -19,7 +19,7 @@ import com.cn.hotelDemo.dto.RoomRequest; import com.cn.hotelDemo.model.Room; -import com.cn.hotelDemo.service.AuditService; +import com.cn.hotelDemo.annotation.AuditLogged; import com.cn.hotelDemo.service.RoomService; import jakarta.validation.Valid; @@ -35,21 +35,16 @@ public class RoomController { private final RoomService roomService; - private final AuditService auditService; - - public RoomController(RoomService roomService, AuditService auditService) { + public RoomController(RoomService roomService) { this.roomService = roomService; - this.auditService = auditService; } @PostMapping("/create") @PreAuthorize("hasRole('ADMIN') or hasAuthority('admin')") @Operation(summary = "Create a new room in a hotel") + @AuditLogged(action = "ROOM_CREATED", resourceType = "ROOM", resourceIdSpel = "#result.body.id") public ResponseEntity createRoom(@Valid @RequestBody RoomRequest roomRequest, Authentication authentication) { Room room = roomService.createRoom(roomRequest); - auditService.record("ROOM_CREATED", authentication.getName(), "ROOM", String.valueOf(room.getId()), "SUCCESS", - "Room created for hotelId=%s, roomNumber=%s".formatted(roomRequest.getHotelId(), - roomRequest.getRoomNumber())); return new ResponseEntity<>(room, HttpStatus.CREATED); } @@ -63,15 +58,13 @@ public ResponseEntity> getRoomsByHotel(@PathVariable Long hotelId) { @GetMapping("/hotel/{hotelId}/available") @PreAuthorize("hasRole('ADMIN') or hasAuthority('admin') or hasRole('NORMAL') or hasAuthority('normal')") @Operation(summary = "Check room availability between dates for a hotel") + @AuditLogged(action = "ROOM_AVAILABILITY_SEARCHED", resourceType = "ROOM", resourceIdSpel = "#hotelId") public ResponseEntity> getAvailableRoomsByHotel( @PathVariable Long hotelId, Authentication authentication, @RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate checkInDate, @RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate checkOutDate) { List rooms = roomService.getAvailableRoomsByHotelAndDates(hotelId, checkInDate, checkOutDate); - auditService.record("ROOM_AVAILABILITY_SEARCHED", authentication.getName(), "ROOM", String.valueOf(hotelId), - "SUCCESS", "Availability checked for hotelId=%s between %s and %s".formatted(hotelId, checkInDate, - checkOutDate)); return ResponseEntity.ok(rooms); } @@ -80,9 +73,6 @@ public ResponseEntity> getAvailableRoomsByHotel( @Operation(summary = "Get a room by its ID") public ResponseEntity getRoomById(@PathVariable Long id) { Room room = roomService.getRoomById(id); - if (room == null) { - return ResponseEntity.notFound().build(); - } return ResponseEntity.ok(room); } diff --git a/src/main/java/com/cn/hotelDemo/controller/UserController.java b/src/main/java/com/cn/hotelDemo/controller/UserController.java index 2feb024..bfdacdd 100644 --- a/src/main/java/com/cn/hotelDemo/controller/UserController.java +++ b/src/main/java/com/cn/hotelDemo/controller/UserController.java @@ -15,7 +15,7 @@ import com.cn.hotelDemo.dto.UserRequest; import com.cn.hotelDemo.model.User; -import com.cn.hotelDemo.service.AuditService; +import com.cn.hotelDemo.annotation.AuditLogged; import com.cn.hotelDemo.service.UserService; import io.swagger.v3.oas.annotations.Operation; @@ -26,13 +26,9 @@ @RequestMapping("/user") @Tag(name = "User Controller", description = "Endpoints for managing users") public class UserController { - @Autowired UserService userService; - @Autowired - AuditService auditService; - @GetMapping("/getUsers") @Operation(summary = "Get all users") @SecurityRequirement(name = "Bearer Authentication") @@ -49,21 +45,18 @@ public User getUserById(@PathVariable Long id) { @PostMapping("/createUser") @Operation(summary = "Register a new user (Public)") - public void createUser(@Valid @RequestBody UserRequest userRequest) + @AuditLogged(action = "USER_CREATED", resourceType = "USER", resourceIdSpel = "#result.id") + public User createUser(@Valid @RequestBody UserRequest userRequest) { - User createdUser = userService.createUser(userRequest); - auditService.record("USER_CREATED", userRequest.getUsername(), "USER", String.valueOf(createdUser.getId()), - "SUCCESS", "Public user registration completed for email=%s".formatted(userRequest.getEmail())); + return userService.createUser(userRequest); } @DeleteMapping("/remove/id/{id}") @Operation(summary = "Delete a user by ID") @SecurityRequirement(name = "Bearer Authentication") + @AuditLogged(action = "USER_DELETED", resourceType = "USER", resourceIdSpel = "#id") public void deleteUserById(@PathVariable Long id) { userService.deleteUserById(id); - auditService.record("USER_DELETED", "system", "USER", String.valueOf(id), "SUCCESS", - "User delete requested for id=%s".formatted(id)); - } } diff --git a/src/main/java/com/cn/hotelDemo/exception/BookingNotFoundException.java b/src/main/java/com/cn/hotelDemo/exception/BookingNotFoundException.java new file mode 100644 index 0000000..e35f51e --- /dev/null +++ b/src/main/java/com/cn/hotelDemo/exception/BookingNotFoundException.java @@ -0,0 +1,7 @@ +package com.cn.hotelDemo.exception; + +public class BookingNotFoundException extends RuntimeException { + public BookingNotFoundException(Long id) { + super("Booking not found with ID: " + id); + } +} diff --git a/src/main/java/com/cn/hotelDemo/exception/GlobalExceptionHandler.java b/src/main/java/com/cn/hotelDemo/exception/GlobalExceptionHandler.java index 244fab8..fdac143 100644 --- a/src/main/java/com/cn/hotelDemo/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/cn/hotelDemo/exception/GlobalExceptionHandler.java @@ -45,6 +45,34 @@ public ResponseEntity handleUserNotFound(UserNotFoundException ex, Htt return new ResponseEntity<>(error, status); } + @ExceptionHandler(RoomNotFoundException.class) + @ResponseBody + public ResponseEntity handleRoomNotFound(RoomNotFoundException ex, HttpServletRequest request) { + HttpStatus status = HttpStatus.NOT_FOUND; + ApiError error = new ApiError( + LocalDateTime.now(), + status.value(), + "Not Found", + ex.getMessage(), + request.getRequestURI() + ); + return new ResponseEntity<>(error, status); + } + + @ExceptionHandler(BookingNotFoundException.class) + @ResponseBody + public ResponseEntity handleBookingNotFound(BookingNotFoundException ex, HttpServletRequest request) { + HttpStatus status = HttpStatus.NOT_FOUND; + ApiError error = new ApiError( + LocalDateTime.now(), + status.value(), + "Not Found", + ex.getMessage(), + request.getRequestURI() + ); + return new ResponseEntity<>(error, status); + } + @ExceptionHandler(IllegalArgumentException.class) @ResponseBody public ResponseEntity handleIllegalArgument(IllegalArgumentException ex, HttpServletRequest request) { diff --git a/src/main/java/com/cn/hotelDemo/exception/RoomNotFoundException.java b/src/main/java/com/cn/hotelDemo/exception/RoomNotFoundException.java new file mode 100644 index 0000000..4e46ae9 --- /dev/null +++ b/src/main/java/com/cn/hotelDemo/exception/RoomNotFoundException.java @@ -0,0 +1,7 @@ +package com.cn.hotelDemo.exception; + +public class RoomNotFoundException extends RuntimeException { + public RoomNotFoundException(Long id) { + super("Room not found with ID: " + id); + } +} diff --git a/src/main/java/com/cn/hotelDemo/service/BookingService.java b/src/main/java/com/cn/hotelDemo/service/BookingService.java index 127fd12..e59b820 100644 --- a/src/main/java/com/cn/hotelDemo/service/BookingService.java +++ b/src/main/java/com/cn/hotelDemo/service/BookingService.java @@ -89,7 +89,7 @@ public List getAllBookings() { } public Booking getBookingById(Long id) { - return bookingRepository.findById(id).orElse(null); + return bookingRepository.findById(id).orElseThrow(() -> new com.cn.hotelDemo.exception.BookingNotFoundException(id)); } public List getBookingsByUserId(Long userId) { diff --git a/src/main/java/com/cn/hotelDemo/service/RoomService.java b/src/main/java/com/cn/hotelDemo/service/RoomService.java index f60adef..1b08ea7 100644 --- a/src/main/java/com/cn/hotelDemo/service/RoomService.java +++ b/src/main/java/com/cn/hotelDemo/service/RoomService.java @@ -79,7 +79,7 @@ public List getRoomsByHotelId(Long hotelId) { } public Room getRoomById(Long id) { - return roomRepository.findById(id).orElse(null); + return roomRepository.findById(id).orElseThrow(() -> new com.cn.hotelDemo.exception.RoomNotFoundException(id)); } public List getAllRooms() { diff --git a/src/test/java/com/cn/hotelDemo/config/HotelSecurityConfigTest.java b/src/test/java/com/cn/hotelDemo/config/HotelSecurityConfigTest.java index 5480d70..6b1632c 100644 --- a/src/test/java/com/cn/hotelDemo/config/HotelSecurityConfigTest.java +++ b/src/test/java/com/cn/hotelDemo/config/HotelSecurityConfigTest.java @@ -10,6 +10,29 @@ import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority; +import org.springframework.security.oauth2.core.user.OAuth2UserAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.core.convert.converter.Converter; + import com.cn.hotelDemo.controller.HotelController; import com.cn.hotelDemo.controller.AuditController; import com.cn.hotelDemo.controller.LoginController; @@ -19,9 +42,8 @@ import com.cn.hotelDemo.service.UserService; import org.springframework.context.annotation.Import; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.nimbusds.jose.shaded.gson.internal.LinkedTreeMap; +import com.cn.hotelDemo.model.User; @WebMvcTest({HotelController.class, AuditController.class, LoginController.class}) @Import(HotelSecurityConfig.class) @@ -79,4 +101,73 @@ public void loginEndpoint_shouldBePermittedWithoutAuth() throws Exception { mockMvc.perform(get("/login")) .andExpect(status().isOk()); } + + @Test + public void testJwtAuthenticationConverter_WithRealmAccessRoles() { + HotelSecurityConfig config = new HotelSecurityConfig(userRepository, null); + JwtAuthenticationConverter converter = config.jwtAuthenticationConverter(); + + Map claims = new HashMap<>(); + LinkedTreeMap> realmAccess = new LinkedTreeMap<>(); + realmAccess.put("roles", List.of("admin", "user")); + claims.put("realm_access", realmAccess); + + Jwt jwt = mock(Jwt.class); + when(jwt.getClaims()).thenReturn(claims); + + @SuppressWarnings("unchecked") + Converter> authoritiesConverter = + (Converter>) (Object) + org.springframework.test.util.ReflectionTestUtils.getField(converter, "jwtGrantedAuthoritiesConverter"); + + Collection authorities = authoritiesConverter.convert(jwt); + + assertEquals(2, authorities.size()); + assertTrue(authorities.contains(new SimpleGrantedAuthority("admin"))); + assertTrue(authorities.contains(new SimpleGrantedAuthority("user"))); + } + + @Test + public void testUserAuthoritiesMapper_WithOAuth2UserAuthority() { + HotelSecurityConfig config = new HotelSecurityConfig(userRepository, null); + GrantedAuthoritiesMapper mapper = config.userAuthoritiesMapper(); + + Map attributes = new HashMap<>(); + Map realmAccess = new HashMap<>(); + realmAccess.put("roles", List.of("admin", "manager")); + attributes.put("realm_access", realmAccess); + + OAuth2UserAuthority authority = new OAuth2UserAuthority(attributes); + + Collection mappedAuthorities = mapper.mapAuthorities(Set.of(authority)); + + assertEquals(2, mappedAuthorities.size()); + assertTrue(mappedAuthorities.contains(new SimpleGrantedAuthority("ROLE_ADMIN"))); + assertTrue(mappedAuthorities.contains(new SimpleGrantedAuthority("ROLE_MANAGER"))); + } + + @Test + public void testUserAuthoritiesMapper_WithOidcUserAuthority_KnownUser() throws Exception { + HotelSecurityConfig config = new HotelSecurityConfig(userRepository, null); + GrantedAuthoritiesMapper mapper = config.userAuthoritiesMapper(); + + Map attributes = new HashMap<>(); + attributes.put("email", "test@example.com"); + + OidcIdToken idToken = mock(OidcIdToken.class); + when(idToken.getIssuer()).thenReturn(java.net.URI.create("https://accounts.google.com").toURL()); + + OidcUserAuthority mockAuthority = mock(OidcUserAuthority.class); + when(mockAuthority.getAttributes()).thenReturn(attributes); + when(mockAuthority.getIdToken()).thenReturn(idToken); + + User dbUser = new User(); + dbUser.setRole("ADMIN"); + when(userRepository.findByEmail("test@example.com")).thenReturn(dbUser); + + Collection mappedAuthorities = mapper.mapAuthorities(Set.of(mockAuthority)); + + assertEquals(1, mappedAuthorities.size()); + assertTrue(mappedAuthorities.contains(new SimpleGrantedAuthority("ROLE_ADMIN"))); + } }