diff --git a/src/main/java/com/eactive/apim/portal/apps/user/service/PasswordService.java b/src/main/java/com/eactive/apim/portal/apps/user/service/PasswordService.java index 7d17a74..68cc50a 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/service/PasswordService.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/service/PasswordService.java @@ -34,7 +34,8 @@ public class PasswordService { .orElseThrow(() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다.")); validatePasswordUpdate(user, newPassword, confirmPassword); - checkPasswordHistory(user.getLoginId(), newPassword); + // PTL_USER_PASSWORD_HISTORY.USER_ID 에는 loginId가 아닌 PortalUser.id가 저장된다. + checkPasswordHistory(user.getId(), newPassword); List histories = passwordHistoryRepository.findRecentPasswordsByUserId(user.getId()); if(histories.isEmpty()) { diff --git a/src/main/java/com/eactive/apim/portal/djb/testcleanup/controller/TestCleanupInternalController.java b/src/main/java/com/eactive/apim/portal/djb/testcleanup/controller/TestCleanupInternalController.java index 69bef25..9ab7e31 100644 --- a/src/main/java/com/eactive/apim/portal/djb/testcleanup/controller/TestCleanupInternalController.java +++ b/src/main/java/com/eactive/apim/portal/djb/testcleanup/controller/TestCleanupInternalController.java @@ -17,6 +17,7 @@ import org.apache.commons.lang3.StringUtils; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -105,6 +106,81 @@ public class TestCleanupInternalController { return ResponseEntity.ok(body); } + /** + * Playwright 실행 전 동일 이메일 계정의 존재 여부를 확인한다. 조회 전용이며 삭제하지 않는다. + */ + @GetMapping("/user/exists") + public ResponseEntity> checkUserExists( + @RequestParam String email, HttpServletRequest request) { + ResponseEntity> guardFailure = checkGuards(request); + if (guardFailure != null) { + return guardFailure; + } + if (StringUtils.isBlank(email)) { + return badRequest("email 은 필수입니다."); + } + + TestCleanupResult result = testCleanupService.checkUserExists(email); + log.info("테스트 정리(user exists) 조회 - email: {}, found: {}, from: {}", + email, result.isFound(), request.getRemoteAddr()); + + Map body = baseBody(); + body.put("email", email); + body.put("userId", result.getTargetId()); + body.put("userFound", result.isFound()); + return ResponseEntity.ok(body); + } + + /** + * 휴대폰 번호로 남아 있는 초대 레코드를 삭제한다. 1020 재실행 전 초대중 중복을 정리하는 용도다. + */ + @PostMapping("/invitation") + public ResponseEntity> deleteInvitations( + @RequestParam String mobile, HttpServletRequest request) { + ResponseEntity> guardFailure = checkGuards(request); + if (guardFailure != null) { + return guardFailure; + } + if (StringUtils.isBlank(mobile)) { + return badRequest("mobile 은 필수입니다."); + } + + TestCleanupResult result = testCleanupService.deleteInvitationsByMobile(mobile); + log.info("테스트 정리(invitation) 실행 - mobile: {}, found: {}, from: {}", + mobile, result.isFound(), request.getRemoteAddr()); + + Map body = baseBody(); + body.put("mobile", mobile); + body.put("invitationFound", result.isFound()); + body.put("deletedCounts", result.getDeletedCounts()); + return ResponseEntity.ok(body); + } + + /** + * 휴대폰 번호로 찾은 계정을 법인 소속에서 제외한다. 계정 자체는 삭제하지 않는다. + */ + @PostMapping("/membership") + public ResponseEntity> detachMembership( + @RequestParam String mobile, HttpServletRequest request) { + ResponseEntity> guardFailure = checkGuards(request); + if (guardFailure != null) { + return guardFailure; + } + if (StringUtils.isBlank(mobile)) { + return badRequest("mobile 은 필수입니다."); + } + + TestCleanupResult result = testCleanupService.detachUsersFromOrgByMobile(mobile); + log.info("테스트 정리(membership) 실행 - mobile: {}, found: {}, from: {}", + mobile, result.isFound(), request.getRemoteAddr()); + + Map body = baseBody(); + body.put("mobile", mobile); + body.put("membershipFound", result.isFound()); + body.put("deletedCounts", result.getDeletedCounts()); + return ResponseEntity.ok(body); + } + @PostMapping("/orphans") public ResponseEntity> cleanOrphans(HttpServletRequest request) { ResponseEntity> guardFailure = checkGuards(request); diff --git a/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupService.java b/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupService.java index 0c13e36..0089d25 100644 --- a/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupService.java +++ b/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupService.java @@ -10,8 +10,11 @@ import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventReposit import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestRepository; import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest; import com.eactive.apim.portal.djb.webhook.service.WebhookService; +import com.eactive.apim.portal.common.util.PhoneNumberUtil; +import com.eactive.apim.portal.invitation.repository.UserInvitationRepository; import com.eactive.apim.portal.portalorg.entity.PortalOrg; import com.eactive.apim.portal.portaluser.entity.PortalUser; +import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode; import com.eactive.apim.portal.portaluser.repository.PortalUserPrivacyAgreementRepository; import com.eactive.apim.portal.portaluser.repository.PortalUserRepository; import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository; @@ -57,6 +60,7 @@ public class TestCleanupService { private final WebhookRequestApiRepository webhookRequestApiRepository; private final WebhookRequestEventRepository webhookRequestEventRepository; private final WebhookService webhookService; + private final UserInvitationRepository userInvitationRepository; private final TestCleanupNativeQueries nativeQueries; /** @@ -66,6 +70,12 @@ public class TestCleanupService { assertNonProdProfile(); Optional orgOpt = portalOrgRepository.findByCompRegNo(compRegNo); + // 가입 화면은 000-00-00001처럼 입력받지만 DB에는 숫자만 저장되는 환경도 있다. + // cleanup API는 두 형식을 모두 받아 이전 E2E 실행 법인을 빠짐없이 정리해야 한다. + String digitsOnlyCompRegNo = compRegNo.replaceAll("\\D", ""); + if (!orgOpt.isPresent() && !digitsOnlyCompRegNo.isEmpty() && !digitsOnlyCompRegNo.equals(compRegNo)) { + orgOpt = portalOrgRepository.findByCompRegNo(digitsOnlyCompRegNo); + } if (!orgOpt.isPresent()) { return TestCleanupResult.notFound(); } @@ -73,6 +83,8 @@ public class TestCleanupService { String orgId = org.getId(); TestCleanupResult result = TestCleanupResult.found(orgId); + result.put("PTL_USER_INVITATION", userInvitationRepository.deleteByOrgId(orgId)); + List users = portalUserRepository.findAllByPortalOrg_Id(orgId); for (PortalUser user : users) { result.merge(deleteUserCascadeInternal(user)); @@ -118,6 +130,60 @@ public class TestCleanupService { return result; } + /** + * 이메일 기준으로 테스트 계정 존재 여부만 조회한다. 삭제나 데이터 변경은 수행하지 않는다. + */ + @Transactional(readOnly = true) + public TestCleanupResult checkUserExists(String email) { + assertNonProdProfile(); + + return portalUserRepository.findPortalUserByEmailAddr(email) + .map(user -> TestCleanupResult.found(user.getId())) + .orElseGet(TestCleanupResult::notFound); + } + + /** + * 휴대폰 번호로 남은 초대 레코드를 전부 삭제한다. 1020 재실행 전 PENDING 초대 중복을 방지한다. + */ + public TestCleanupResult deleteInvitationsByMobile(String mobile) { + assertNonProdProfile(); + + String normalizedMobile = PhoneNumberUtil.normalize(mobile); + if (normalizedMobile == null) { + return TestCleanupResult.notFound(); + } + long deleted = userInvitationRepository.deleteByInvitationMobile(normalizedMobile); + TestCleanupResult result = deleted > 0 ? TestCleanupResult.found(normalizedMobile) : TestCleanupResult.notFound(); + result.put("PTL_USER_INVITATION", deleted); + return result; + } + + /** + * 휴대폰 번호로 찾은 계정을 법인 소속에서만 제외해 개인회원으로 되돌린다. + * 1020이 수락/소속 제외 전에 중단된 경우, 1000 선행 개인회원은 보존하면서 재초대 가능 상태로 복구한다. + */ + public TestCleanupResult detachUsersFromOrgByMobile(String mobile) { + assertNonProdProfile(); + + String normalizedMobile = PhoneNumberUtil.normalize(mobile); + if (normalizedMobile == null) { + return TestCleanupResult.notFound(); + } + + TestCleanupResult result = TestCleanupResult.notFound(); + for (PortalUser user : portalUserRepository.findAllByMobileNumber(normalizedMobile)) { + if (user.getPortalOrg() == null) { + continue; + } + user.setPortalOrg(null); + user.setRoleCode(RoleCode.ROLE_USER); + portalUserRepository.save(user); + result = TestCleanupResult.found(user.getId()); + result.put("PTL_USER_ORG_MEMBERSHIP", 1L); + } + return result; + } + private TestCleanupResult deleteUserCascadeInternal(PortalUser user) { TestCleanupResult result = TestCleanupResult.found(user.getId()); result.put("PTL_INQUIRY_COMMENT", inquiryCommentRepository.deleteByInquiry_Inquirer_Id(user.getId())); diff --git a/src/test/java/com/eactive/apim/portal/apps/user/PasswordServiceTest.java b/src/test/java/com/eactive/apim/portal/apps/user/PasswordServiceTest.java new file mode 100644 index 0000000..154fc99 --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/apps/user/PasswordServiceTest.java @@ -0,0 +1,71 @@ +package com.eactive.apim.portal.apps.user; + +import com.eactive.apim.portal.apps.user.service.PasswordService; +import com.eactive.apim.portal.common.dto.PasswordValidationDTO; +import com.eactive.apim.portal.portaluser.entity.PortalUser; +import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory; +import com.eactive.apim.portal.portaluser.repository.PortalUserRepository; +import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository; +import java.util.Collections; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +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; + +@ExtendWith(MockitoExtension.class) +class PasswordServiceTest { + + @Mock + private PortalUserRepository portalUserRepository; + @Mock + private UserPasswordHistoryRepository passwordHistoryRepository; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private LocalValidatorFactoryBean validator; + + @InjectMocks + private PasswordService passwordService; + + @Test + void rejectsPasswordStoredAgainstPortalUserId() { + PortalUser user = new PortalUser(); + user.setId("user-uuid"); + user.setLoginId("user@example.com"); + user.setMobileNumber("010-1234-5678"); + user.setPasswordHash("temporary-password-hash"); + + UserPasswordHistory previousPassword = new UserPasswordHistory(); + previousPassword.setUserId("user-uuid"); + previousPassword.setPasswordHash("original-password-hash"); + + when(portalUserRepository.findByLoginId("user@example.com")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("Original!123", "temporary-password-hash")).thenReturn(false); + when(validator.validate(any(PasswordValidationDTO.class))).thenReturn(Collections.emptySet()); + when(passwordHistoryRepository.findRecentPasswordsByUserId("user-uuid")) + .thenReturn(Collections.singletonList(previousPassword)); + when(passwordEncoder.matches("Original!123", "original-password-hash")).thenReturn(true); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> passwordService.updatePassword( + "user@example.com", "Original!123", "Original!123") + ); + + assertEquals("최근 5회 이내에 사용한 비밀번호는 사용할 수 없습니다.", exception.getMessage()); + verify(passwordHistoryRepository).findRecentPasswordsByUserId("user-uuid"); + verify(passwordHistoryRepository, never()).findRecentPasswordsByUserId("user@example.com"); + verify(portalUserRepository, never()).save(user); + } +} diff --git a/src/test/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupServiceTest.java b/src/test/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupServiceTest.java index 2893ad7..8447303 100644 --- a/src/test/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupServiceTest.java +++ b/src/test/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupServiceTest.java @@ -2,6 +2,7 @@ package com.eactive.apim.portal.djb.testcleanup.service; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyLong; @@ -22,6 +23,7 @@ import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventReposit import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestRepository; import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest; import com.eactive.apim.portal.djb.webhook.service.WebhookService; +import com.eactive.apim.portal.invitation.repository.UserInvitationRepository; import com.eactive.apim.portal.portalorg.entity.PortalOrg; import com.eactive.apim.portal.portaluser.entity.PortalUser; import com.eactive.apim.portal.portaluser.repository.PortalUserPrivacyAgreementRepository; @@ -60,6 +62,7 @@ class TestCleanupServiceTest { @Mock private WebhookRequestApiRepository webhookRequestApiRepository; @Mock private WebhookRequestEventRepository webhookRequestEventRepository; @Mock private WebhookService webhookService; + @Mock private UserInvitationRepository userInvitationRepository; @Mock private TestCleanupNativeQueries nativeQueries; private TestCleanupService service; @@ -76,7 +79,7 @@ class TestCleanupServiceTest { inquiryRepository, inquiryCommentRepository, partnershipApplicationRepository, userRoleHistoryRepository, portalUserPrivacyAgreementRepository, userPasswordHistoryRepository, userLogRepository, credentialRepository, webhookRequestRepository, webhookRequestApiRepository, - webhookRequestEventRepository, webhookService, nativeQueries); + webhookRequestEventRepository, webhookService, userInvitationRepository, nativeQueries); when(environment.acceptsProfiles(Profiles.of("stage", "prod"))).thenReturn(false); } @@ -98,6 +101,24 @@ class TestCleanupServiceTest { verifyNoInteractions(webhookService, portalUserRepository); } + @Test + void deleteOrgCascade_fallsBackToDigitsOnlyBusinessNumber() { + String hyphenatedCompRegNo = "123-45-67890"; + String digitsOnlyCompRegNo = "1234567890"; + PortalOrg org = org(); + when(portalOrgRepository.findByCompRegNo(hyphenatedCompRegNo)).thenReturn(Optional.empty()); + when(portalOrgRepository.findByCompRegNo(digitsOnlyCompRegNo)).thenReturn(Optional.of(org)); + when(portalUserRepository.findAllByPortalOrg_Id(ORG_ID)).thenReturn(Collections.emptyList()); + when(webhookRequestRepository.findByOrgId(ORG_ID)).thenReturn(Optional.empty()); + + TestCleanupResult result = service.deleteOrgCascade(hyphenatedCompRegNo); + + assertTrue(result.isFound()); + assertEquals(ORG_ID, result.getTargetId()); + verify(portalOrgRepository).findByCompRegNo(digitsOnlyCompRegNo); + verify(portalOrgRepository).delete(org); + } + @Test void deleteOrgCascade_found_cascadesUsersAndOrg() { PortalOrg org = org(); @@ -153,6 +174,58 @@ class TestCleanupServiceTest { assertTrue(result.getDeletedCounts().isEmpty()); } + @Test + void checkUserExists_found_doesNotDeleteData() { + when(portalUserRepository.findPortalUserByEmailAddr(EMAIL)).thenReturn(Optional.of(user())); + + TestCleanupResult result = service.checkUserExists(EMAIL); + + assertTrue(result.isFound()); + assertEquals(USER_ID, result.getTargetId()); + assertTrue(result.getDeletedCounts().isEmpty()); + verify(portalUserRepository, never()).delete(org.mockito.ArgumentMatchers.any(PortalUser.class)); + } + + @Test + void checkUserExists_notFound() { + when(portalUserRepository.findPortalUserByEmailAddr(EMAIL)).thenReturn(Optional.empty()); + + TestCleanupResult result = service.checkUserExists(EMAIL); + + assertFalse(result.isFound()); + assertTrue(result.getDeletedCounts().isEmpty()); + } + + @Test + void deleteInvitationsByMobile_normalizesAndDeletesAllMatches() { + when(userInvitationRepository.deleteByInvitationMobile("010-1234-5678")).thenReturn(2L); + + TestCleanupResult result = service.deleteInvitationsByMobile("01012345678"); + + assertTrue(result.isFound()); + assertEquals("010-1234-5678", result.getTargetId()); + assertEquals(2L, result.getDeletedCounts().get("PTL_USER_INVITATION")); + verify(userInvitationRepository).deleteByInvitationMobile("010-1234-5678"); + } + + @Test + void detachUsersFromOrgByMobile_keepsUserAndRemovesOnlyMembership() { + PortalUser target = user(); + target.setPortalOrg(org()); + when(portalUserRepository.findAllByMobileNumber("010-1234-5678")) + .thenReturn(Collections.singletonList(target)); + + TestCleanupResult result = service.detachUsersFromOrgByMobile("01012345678"); + + assertTrue(result.isFound()); + assertEquals(USER_ID, result.getTargetId()); + assertEquals(1L, result.getDeletedCounts().get("PTL_USER_ORG_MEMBERSHIP")); + assertNull(target.getPortalOrg()); + assertEquals(com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode.ROLE_USER, target.getRoleCode()); + verify(portalUserRepository).save(target); + verify(portalUserRepository, never()).delete(target); + } + @Test void deleteUserCascade_found_deletesOrphanDataAndUser() { PortalUser target = user();