diff --git a/src/main/java/com/pooli/permission/controller/RoleController.java b/src/main/java/com/pooli/permission/controller/RoleController.java index 0ad3907c..5e54624a 100644 --- a/src/main/java/com/pooli/permission/controller/RoleController.java +++ b/src/main/java/com/pooli/permission/controller/RoleController.java @@ -88,31 +88,37 @@ public class RoleController { @PatchMapping("/representative") public ResponseEntity transferRepresentativeRole( @AuthenticationPrincipal AuthUserDetails userDetails, + @Parameter(description = "현재 대표 회선 ID (ADMIN 필수, OWNER 무시)", example = "1001") @RequestParam(required = false) Long currentLineId, @Parameter(description = "변경 대상 회선 ID", example = "1002") @RequestParam Long changeLineId, HttpServletRequest request, HttpServletResponse response) { RepresentativeRoleTransferResDto result = - roleService.transferRepresentativeRole(userDetails.getLineId(), changeLineId); + roleService.transferRepresentativeRole(currentLineId, changeLineId, userDetails); - // 역할 양도 후 현재 사용자 세션의 ROLE_FAMILY_OWNER → ROLE_FAMILY_MEMBER 교체 - List updatedAuthorities = userDetails.getAuthorities().stream() - .map(auth -> "ROLE_FAMILY_OWNER".equals(auth.getAuthority()) - ? new SimpleGrantedAuthority("ROLE_FAMILY_MEMBER") - : auth) - .collect(Collectors.toList()); + // ADMIN이 대리 실행한 경우 세션 권한 변경 불필요 + boolean isAdmin = userDetails.getAuthorities().stream() + .anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority())); + if (!isAdmin) { + // 역할 양도 후 현재 사용자 세션의 ROLE_FAMILY_OWNER → ROLE_FAMILY_MEMBER 교체 + List updatedAuthorities = userDetails.getAuthorities().stream() + .map(auth -> "ROLE_FAMILY_OWNER".equals(auth.getAuthority()) + ? new SimpleGrantedAuthority("ROLE_FAMILY_MEMBER") + : auth) + .collect(Collectors.toList()); - AuthUserDetails updatedUserDetails = AuthUserDetails.builder() - .userId(userDetails.getUserId()) - .email(userDetails.getEmail()) - .lineId(userDetails.getLineId()) - .authorities(updatedAuthorities) - .build(); + AuthUserDetails updatedUserDetails = AuthUserDetails.builder() + .userId(userDetails.getUserId()) + .email(userDetails.getEmail()) + .lineId(userDetails.getLineId()) + .authorities(updatedAuthorities) + .build(); - SecurityContext newContext = SecurityContextHolder.createEmptyContext(); - newContext.setAuthentication( - new UsernamePasswordAuthenticationToken(updatedUserDetails, null, updatedAuthorities)); - SecurityContextHolder.setContext(newContext); - securityContextRepository.saveContext(newContext, request, response); + SecurityContext newContext = SecurityContextHolder.createEmptyContext(); + newContext.setAuthentication( + new UsernamePasswordAuthenticationToken(updatedUserDetails, null, updatedAuthorities)); + SecurityContextHolder.setContext(newContext); + securityContextRepository.saveContext(newContext, request, response); + } return ResponseEntity.ok(result); } diff --git a/src/main/java/com/pooli/permission/service/RoleService.java b/src/main/java/com/pooli/permission/service/RoleService.java index a08e53b7..e7de0e38 100644 --- a/src/main/java/com/pooli/permission/service/RoleService.java +++ b/src/main/java/com/pooli/permission/service/RoleService.java @@ -1,9 +1,10 @@ package com.pooli.permission.service; +import com.pooli.auth.service.AuthUserDetails; import com.pooli.permission.domain.dto.response.RepresentativeRoleTransferResDto; public interface RoleService { // RY1-292: 대표자 권한 양도 - RepresentativeRoleTransferResDto transferRepresentativeRole(Long currentLineId, Long changeLineId); + RepresentativeRoleTransferResDto transferRepresentativeRole(Long currentLineId, Long changeLineId, AuthUserDetails userDetails); } diff --git a/src/main/java/com/pooli/permission/service/RoleServiceImpl.java b/src/main/java/com/pooli/permission/service/RoleServiceImpl.java index da29e534..c75ff113 100644 --- a/src/main/java/com/pooli/permission/service/RoleServiceImpl.java +++ b/src/main/java/com/pooli/permission/service/RoleServiceImpl.java @@ -3,7 +3,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.pooli.auth.service.AuthUserDetails; import com.pooli.common.exception.ApplicationException; +import com.pooli.common.exception.CommonErrorCode; import com.pooli.family.domain.entity.FamilyLine; import com.pooli.family.domain.enums.FamilyRole; import com.pooli.notification.domain.enums.AlarmCode; @@ -14,7 +16,9 @@ import com.pooli.permission.mapper.FamilyLineMapper; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor public class RoleServiceImpl implements RoleService { @@ -24,13 +28,14 @@ public class RoleServiceImpl implements RoleService { @Override @Transactional - public RepresentativeRoleTransferResDto transferRepresentativeRole(Long currentLineId, Long changeLineId) { + public RepresentativeRoleTransferResDto transferRepresentativeRole(Long currentLineId, Long changeLineId, AuthUserDetails userDetails) { + Long resolvedLineId = resolveCurrentLineId(currentLineId, userDetails); - if (currentLineId.equals(changeLineId)) { + if (resolvedLineId.equals(changeLineId)) { throw new ApplicationException(PermissionErrorCode.ROLE_TRANSFER_SELF); } - FamilyLine currentFamilyLine = familyLineMapper.findByLineId(currentLineId) + FamilyLine currentFamilyLine = familyLineMapper.findByLineId(resolvedLineId) .orElseThrow(() -> new ApplicationException(PermissionErrorCode.ROLE_TRANSFER_SOURCE_NOT_FOUND)); FamilyLine targetFamilyLine = familyLineMapper.findByLineId(changeLineId) @@ -56,13 +61,29 @@ public RepresentativeRoleTransferResDto transferRepresentativeRole(Long currentL .role(FamilyRole.OWNER) .build()); - familyLineMapper.findAllFamilyIdByLineId(currentLineId) + familyLineMapper.findAllFamilyIdByLineId(resolvedLineId) .forEach(lineId -> alarmHistoryService.createAlarm(lineId, AlarmCode.PERMISSION, AlarmType.ROLE_TRANSFERRED)); return RepresentativeRoleTransferResDto.builder() - .currentOwnerLineId(currentLineId) + .currentOwnerLineId(resolvedLineId) .changeOwnerLineId(changeLineId) .build(); } + + // ADMIN이면 currentLineId 필수, OWNER이면 세션 lineId 강제 사용 + private Long resolveCurrentLineId(Long currentLineId, AuthUserDetails userDetails) { + boolean isAdmin = userDetails.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")); + if (isAdmin) { + if (currentLineId == null) { + throw new ApplicationException(CommonErrorCode.MISSING_REQUEST_PARAM); + } + return currentLineId; + } + if (currentLineId != null) { + log.warn("OWNER supplied currentLineId={} ignored, using session lineId={}", currentLineId, userDetails.getLineId()); + } + return userDetails.getLineId(); + } } diff --git a/src/test/java/com/pooli/permission/service/RoleServiceImplTest.java b/src/test/java/com/pooli/permission/service/RoleServiceImplTest.java index 26d5b4bb..981027d6 100644 --- a/src/test/java/com/pooli/permission/service/RoleServiceImplTest.java +++ b/src/test/java/com/pooli/permission/service/RoleServiceImplTest.java @@ -11,7 +11,9 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import com.pooli.auth.service.AuthUserDetails; import com.pooli.common.exception.ApplicationException; +import com.pooli.common.exception.CommonErrorCode; import com.pooli.family.domain.entity.FamilyLine; import com.pooli.family.domain.enums.FamilyRole; import com.pooli.notification.domain.enums.AlarmCode; @@ -42,12 +44,28 @@ class RoleServiceImplTest { @InjectMocks private RoleServiceImpl roleService; + // OWNER 사용자 (lineId = 1001L) + private final AuthUserDetails ownerUser = AuthUserDetails.builder() + .userId(1L) + .email("user@example.com") + .lineId(1001L) + .authorities(AuthUserDetails.toAuthorities(List.of("USER", "FAMILY_OWNER"))) + .build(); + + // ADMIN 사용자 (lineId = null) + private final AuthUserDetails adminUser = AuthUserDetails.builder() + .userId(100L) + .email("admin@example.com") + .lineId(null) + .authorities(AuthUserDetails.toAuthorities(List.of("ADMIN"))) + .build(); + @Test - @DisplayName("자기 자신에게 대표자 양도를 요청하면 PERMISSION-4004를 반환한다") + @DisplayName("OWNER: 자기 자신에게 대표자 양도를 요청하면 PERMISSION-4004를 반환한다") void transferRepresentativeRole_selfTransfer_throwsRoleTransferSelf() { ApplicationException ex = assertThrows( ApplicationException.class, - () -> roleService.transferRepresentativeRole(1001L, 1001L) + () -> roleService.transferRepresentativeRole(null, 1001L, ownerUser) ); assertThat(ex.getErrorCode()).isEqualTo(PermissionErrorCode.ROLE_TRANSFER_SELF); @@ -55,13 +73,13 @@ void transferRepresentativeRole_selfTransfer_throwsRoleTransferSelf() { } @Test - @DisplayName("현재 대표 회선이 없으면 PERMISSION-4403을 반환한다") + @DisplayName("OWNER: 현재 대표 회선이 없으면 PERMISSION-4403을 반환한다") void transferRepresentativeRole_sourceMissing_throwsSourceNotFound() { when(familyLineMapper.findByLineId(1001L)).thenReturn(Optional.empty()); ApplicationException ex = assertThrows( ApplicationException.class, - () -> roleService.transferRepresentativeRole(1001L, 1002L) + () -> roleService.transferRepresentativeRole(null, 1002L, ownerUser) ); assertThat(ex.getErrorCode()).isEqualTo(PermissionErrorCode.ROLE_TRANSFER_SOURCE_NOT_FOUND); @@ -71,7 +89,7 @@ void transferRepresentativeRole_sourceMissing_throwsSourceNotFound() { } @Test - @DisplayName("양도 대상 회선이 없으면 PERMISSION-4402를 반환한다") + @DisplayName("OWNER: 양도 대상 회선이 없으면 PERMISSION-4402를 반환한다") void transferRepresentativeRole_targetMissing_throwsTargetNotFound() { when(familyLineMapper.findByLineId(1001L)) .thenReturn(Optional.of(familyLine(1L, 1001L, FamilyRole.OWNER))); @@ -79,7 +97,7 @@ void transferRepresentativeRole_targetMissing_throwsTargetNotFound() { ApplicationException ex = assertThrows( ApplicationException.class, - () -> roleService.transferRepresentativeRole(1001L, 1002L) + () -> roleService.transferRepresentativeRole(null, 1002L, ownerUser) ); assertThat(ex.getErrorCode()).isEqualTo(PermissionErrorCode.ROLE_TRANSFER_TARGET_NOT_FOUND); @@ -88,7 +106,7 @@ void transferRepresentativeRole_targetMissing_throwsTargetNotFound() { } @Test - @DisplayName("다른 가족 간 양도 요청이면 PERMISSION-4901을 반환한다") + @DisplayName("OWNER: 다른 가족 간 양도 요청이면 PERMISSION-4901을 반환한다") void transferRepresentativeRole_differentFamily_throwsDifferentFamily() { when(familyLineMapper.findByLineId(1001L)) .thenReturn(Optional.of(familyLine(1L, 1001L, FamilyRole.OWNER))); @@ -97,7 +115,7 @@ void transferRepresentativeRole_differentFamily_throwsDifferentFamily() { ApplicationException ex = assertThrows( ApplicationException.class, - () -> roleService.transferRepresentativeRole(1001L, 1002L) + () -> roleService.transferRepresentativeRole(null, 1002L, ownerUser) ); assertThat(ex.getErrorCode()).isEqualTo(PermissionErrorCode.ROLE_TRANSFER_DIFFERENT_FAMILY); @@ -106,7 +124,7 @@ void transferRepresentativeRole_differentFamily_throwsDifferentFamily() { } @Test - @DisplayName("대상이 이미 OWNER면 PERMISSION-4902를 반환한다") + @DisplayName("OWNER: 대상이 이미 OWNER면 PERMISSION-4902를 반환한다") void transferRepresentativeRole_targetAlreadyOwner_throwsAlreadyRepresentative() { when(familyLineMapper.findByLineId(1001L)) .thenReturn(Optional.of(familyLine(1L, 1001L, FamilyRole.OWNER))); @@ -115,7 +133,7 @@ void transferRepresentativeRole_targetAlreadyOwner_throwsAlreadyRepresentative() ApplicationException ex = assertThrows( ApplicationException.class, - () -> roleService.transferRepresentativeRole(1001L, 1002L) + () -> roleService.transferRepresentativeRole(null, 1002L, ownerUser) ); assertThat(ex.getErrorCode()).isEqualTo(PermissionErrorCode.ROLE_TRANSFER_TARGET_ALREADY_REPRESENTATIVE); @@ -124,7 +142,7 @@ void transferRepresentativeRole_targetAlreadyOwner_throwsAlreadyRepresentative() } @Test - @DisplayName("대표자 양도 성공 시 OWNER/MEMBER 역할을 교체하고 가족 전체에 ROLE_TRANSFERRED 알림을 보낸다") + @DisplayName("OWNER: 대표자 양도 성공 시 OWNER/MEMBER 역할을 교체하고 가족 전체에 ROLE_TRANSFERRED 알림을 보낸다") void transferRepresentativeRole_success_updatesRoleAndSendsAlarmToFamilyMembers() { when(familyLineMapper.findByLineId(1001L)) .thenReturn(Optional.of(familyLine(1L, 1001L, FamilyRole.OWNER))); @@ -133,7 +151,7 @@ void transferRepresentativeRole_success_updatesRoleAndSendsAlarmToFamilyMembers( when(familyLineMapper.findAllFamilyIdByLineId(1001L)) .thenReturn(List.of(1001L, 1002L, 1003L)); - RepresentativeRoleTransferResDto result = roleService.transferRepresentativeRole(1001L, 1002L); + RepresentativeRoleTransferResDto result = roleService.transferRepresentativeRole(null, 1002L, ownerUser); assertThat(result.getCurrentOwnerLineId()).isEqualTo(1001L); assertThat(result.getChangeOwnerLineId()).isEqualTo(1002L); @@ -156,6 +174,57 @@ void transferRepresentativeRole_success_updatesRoleAndSendsAlarmToFamilyMembers( assertThat(alarmTargetCaptor.getAllValues()).containsExactly(1001L, 1002L, 1003L); } + // --- ADMIN 케이스 --- + + @Test + @DisplayName("ADMIN: currentLineId 없이 호출하면 COMMON:4004 필수 파라미터 누락을 반환한다") + void transferRepresentativeRole_admin_missingCurrentLineId_throwsMissingParam() { + ApplicationException ex = assertThrows( + ApplicationException.class, + () -> roleService.transferRepresentativeRole(null, 1002L, adminUser) + ); + + assertThat(ex.getErrorCode()).isEqualTo(CommonErrorCode.MISSING_REQUEST_PARAM); + verifyNoInteractions(familyLineMapper, alarmHistoryService); + } + + @Test + @DisplayName("ADMIN: currentLineId를 지정하면 해당 회선 기준으로 양도가 정상 처리된다") + void transferRepresentativeRole_admin_withCurrentLineId_success() { + when(familyLineMapper.findByLineId(1001L)) + .thenReturn(Optional.of(familyLine(1L, 1001L, FamilyRole.OWNER))); + when(familyLineMapper.findByLineId(1002L)) + .thenReturn(Optional.of(familyLine(1L, 1002L, FamilyRole.MEMBER))); + when(familyLineMapper.findAllFamilyIdByLineId(1001L)) + .thenReturn(List.of(1001L, 1002L)); + + RepresentativeRoleTransferResDto result = roleService.transferRepresentativeRole(1001L, 1002L, adminUser); + + assertThat(result.getCurrentOwnerLineId()).isEqualTo(1001L); + assertThat(result.getChangeOwnerLineId()).isEqualTo(1002L); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(FamilyLine.class); + verify(familyLineMapper, times(2)).updateRole(updateCaptor.capture()); + assertThat(updateCaptor.getAllValues()) + .extracting(FamilyLine::getLineId, FamilyLine::getRole) + .containsExactlyInAnyOrder( + tuple(1001L, FamilyRole.MEMBER), + tuple(1002L, FamilyRole.OWNER) + ); + } + + @Test + @DisplayName("ADMIN: 자기 자신에게 양도 시도 시 PERMISSION-4004를 반환한다") + void transferRepresentativeRole_admin_selfTransfer_throwsRoleTransferSelf() { + ApplicationException ex = assertThrows( + ApplicationException.class, + () -> roleService.transferRepresentativeRole(1001L, 1001L, adminUser) + ); + + assertThat(ex.getErrorCode()).isEqualTo(PermissionErrorCode.ROLE_TRANSFER_SELF); + verifyNoInteractions(familyLineMapper, alarmHistoryService); + } + private FamilyLine familyLine(Long familyId, Long lineId, FamilyRole role) { return FamilyLine.builder() .familyId(familyId)