diff --git a/src/main/java/com/eactive/apim/portal/apps/approval/service/ApprovalService.java b/src/main/java/com/eactive/apim/portal/apps/approval/service/ApprovalService.java index 56a8f75..cb062e0 100644 --- a/src/main/java/com/eactive/apim/portal/apps/approval/service/ApprovalService.java +++ b/src/main/java/com/eactive/apim/portal/apps/approval/service/ApprovalService.java @@ -41,7 +41,13 @@ public class ApprovalService { approval.setApprovalType(ApprovalType.APP); approval.setTargetId(request.getId()); approval.setRequester(SecurityUtil.getPortalAuthenticatedUser()); - approval.setApprovalSubject("[" + request.getOrg().getOrgName() + "] " + request.getType().getDescription() + " 승인"); + // 동일 법인의 신규/변경/해지 요청이 관리자 목록에서 같은 제목으로 보이면 대상 식별이 불가능하다. + // 클라이언트 이름을 제목에 포함해 운영자와 E2E 모두 정확한 승인 건을 검색할 수 있게 한다. + String clientName = request.getClientName(); + String clientNamePart = clientName == null || clientName.trim().isEmpty() + ? "" : " [" + clientName.trim() + "]"; + approval.setApprovalSubject("[" + request.getOrg().getOrgName() + "]" + clientNamePart + + " " + request.getType().getDescription() + " 승인"); for (PortalApprovalLineUser user : optLine.get().getPortalApprovalLineUsers()) { this.addApprover(approval, user.getUser(), user.getApprovalOrder()); 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 9ab7e31..f6d15b1 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 @@ -5,11 +5,14 @@ import com.eactive.apim.portal.common.util.IpAddressMatcher; import com.eactive.apim.portal.djb.testcleanup.service.OrphanCleanupService; import com.eactive.apim.portal.djb.testcleanup.service.TestCleanupResult; import com.eactive.apim.portal.djb.testcleanup.service.TestCleanupService; +import com.eactive.apim.portal.portalorg.entity.PortalOrg; +import com.eactive.apim.portal.portaluser.entity.PortalUser; import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Optional; import javax.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -131,6 +134,71 @@ public class TestCleanupInternalController { return ResponseEntity.ok(body); } + /** + * 2000 실행 전 법인 계정의 실제 선행 상태를 조회한다. 조회 전용이며 cleanup을 수행하지 않는다. + * API 신청 권한의 최종 렌더링 여부는 보안 권한 매핑에 따르므로, E2E는 이 응답으로 데이터 선행조건을 + * 확인한 다음 /clients 화면의 생성 버튼 노출까지 함께 검증한다. + */ + @GetMapping("/user/status") + public ResponseEntity> getUserStatus( + @RequestParam String email, HttpServletRequest request) { + ResponseEntity> guardFailure = checkGuards(request); + if (guardFailure != null) { + return guardFailure; + } + if (StringUtils.isBlank(email)) { + return badRequest("email 은 필수입니다."); + } + + Optional userOpt = testCleanupService.findUserForStatus(email); + Map body = baseBody(); + body.put("email", email); + body.put("userFound", userOpt.isPresent()); + if (userOpt.isPresent()) { + PortalUser user = userOpt.get(); + PortalOrg org = user.getPortalOrg(); + body.put("userId", user.getId()); + body.put("roleCode", user.getRoleCode() == null ? null : user.getRoleCode().name()); + body.put("userStatus", user.getUserStatus() == null ? null : user.getUserStatus().name()); + body.put("userApprovalStatus", user.getApprovalStatus() == null ? null : user.getApprovalStatus().name()); + body.put("authCompletedYn", user.getAuthCompletedYn()); + body.put("orgId", org == null ? null : org.getId()); + body.put("orgStatus", org == null || org.getOrgStatus() == null ? null : org.getOrgStatus().name()); + body.put("orgApprovalStatus", org == null || org.getApprovalStatus() == null + ? null : org.getApprovalStatus().name()); + } + log.info("테스트 정리(user status) 조회 - email: {}, found: {}, from: {}", + email, userOpt.isPresent(), request.getRemoteAddr()); + return ResponseEntity.ok(body); + } + + /** + * 2000 재실행 전 동일 이름으로 남은 테스트 클라이언트 신청만 취소한다. + * 대상은 {@code 단위테스트앱-} 접두사와 이메일 소속 법인으로 이중 한정한다. + */ + @PostMapping("/app-request") + public ResponseEntity> cancelPendingTestAppRequests( + @RequestParam String email, @RequestParam String clientName, HttpServletRequest request) { + ResponseEntity> guardFailure = checkGuards(request); + if (guardFailure != null) { + return guardFailure; + } + if (StringUtils.isBlank(email) || StringUtils.isBlank(clientName)) { + return badRequest("email 과 clientName 은 필수입니다."); + } + + TestCleanupResult result = testCleanupService.cancelPendingTestAppRequests(email, clientName); + log.info("테스트 정리(app request) 실행 - email: {}, clientName: {}, found: {}, from: {}", + email, clientName, result.isFound(), request.getRemoteAddr()); + + Map body = baseBody(); + body.put("email", email); + body.put("clientName", clientName); + body.put("appRequestFound", result.isFound()); + body.put("deletedCounts", result.getDeletedCounts()); + return ResponseEntity.ok(body); + } + /** * 휴대폰 번호로 남아 있는 초대 레코드를 삭제한다. 1020 재실행 전 초대중 중복을 정리하는 용도다. */ 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 0089d25..b09bdaa 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 @@ -1,6 +1,11 @@ package com.eactive.apim.portal.djb.testcleanup.service; import com.eactive.apim.portal.app.repository.CredentialRepository; +import com.eactive.apim.portal.apprequest.entity.AppRequest; +import com.eactive.apim.portal.apprequest.repository.AppRequestRepository; +import com.eactive.apim.portal.approval.statemachine.ProcessingState; +import com.eactive.apim.portal.approval.statemachine.RequestedState; +import com.eactive.apim.portal.apps.approval.service.ApprovalService; import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository; import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository; import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository; @@ -56,6 +61,8 @@ public class TestCleanupService { private final UserPasswordHistoryRepository userPasswordHistoryRepository; private final UserLogRepository userLogRepository; private final CredentialRepository credentialRepository; + private final AppRequestRepository appRequestRepository; + private final ApprovalService approvalService; private final WebhookRequestRepository webhookRequestRepository; private final WebhookRequestApiRepository webhookRequestApiRepository; private final WebhookRequestEventRepository webhookRequestEventRepository; @@ -142,6 +149,47 @@ public class TestCleanupService { .orElseGet(TestCleanupResult::notFound); } + /** + * E2E 실행 가능 여부를 판단하기 위한 계정 조회다. 삭제나 상태 변경은 수행하지 않는다. + * 호출자는 법인 소속/승인/역할을 확인해 2000 선행 시나리오(1010/1011)가 갖춰졌는지 판단한다. + */ + @Transactional(readOnly = true) + public Optional findUserForStatus(String email) { + assertNonProdProfile(); + + return portalUserRepository.findPortalUserByEmailAddr(email); + } + + /** + * 동일 이름으로 재실행할 때 남은 테스트 APP 신청만 취소한다. + * 운영 데이터 오삭제를 막기 위해 {@code 단위테스트앱-} 접두사, 이메일의 법인 소속, 정확한 클라이언트명 세 조건을 모두 요구한다. + */ + public TestCleanupResult cancelPendingTestAppRequests(String email, String clientName) { + assertNonProdProfile(); + if (clientName == null || !clientName.startsWith("단위테스트앱-")) { + throw new IllegalArgumentException("테스트 앱 이름(단위테스트앱-*)만 정리할 수 있습니다."); + } + + Optional userOpt = portalUserRepository.findPortalUserByEmailAddr(email); + if (!userOpt.isPresent() || userOpt.get().getPortalOrg() == null) { + return TestCleanupResult.notFound(); + } + + TestCleanupResult result = TestCleanupResult.notFound(); + long cancelled = 0L; + for (AppRequest request : appRequestRepository.findAllByOrgAndClientName(userOpt.get().getPortalOrg(), clientName)) { + if (request.getApproval() == null || !(request.getApproval().getApprovalStatus() instanceof RequestedState + || request.getApproval().getApprovalStatus() instanceof ProcessingState)) { + continue; + } + approvalService.cancelAppApproval(request); + result = TestCleanupResult.found(request.getId()); + cancelled++; + } + result.put("PTL_APP_REQUEST_CANCELLED", cancelled); + return result; + } + /** * 휴대폰 번호로 남은 초대 레코드를 전부 삭제한다. 1020 재실행 전 PENDING 초대 중복을 방지한다. */ 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 8447303..1d5547a 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 @@ -14,6 +14,11 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.eactive.apim.portal.app.repository.CredentialRepository; +import com.eactive.apim.portal.apprequest.entity.AppRequest; +import com.eactive.apim.portal.apprequest.repository.AppRequestRepository; +import com.eactive.apim.portal.approval.entity.Approval; +import com.eactive.apim.portal.approval.statemachine.RequestedState; +import com.eactive.apim.portal.apps.approval.service.ApprovalService; import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository; import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository; import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository; @@ -58,6 +63,8 @@ class TestCleanupServiceTest { @Mock private UserPasswordHistoryRepository userPasswordHistoryRepository; @Mock private UserLogRepository userLogRepository; @Mock private CredentialRepository credentialRepository; + @Mock private AppRequestRepository appRequestRepository; + @Mock private ApprovalService approvalService; @Mock private WebhookRequestRepository webhookRequestRepository; @Mock private WebhookRequestApiRepository webhookRequestApiRepository; @Mock private WebhookRequestEventRepository webhookRequestEventRepository; @@ -78,7 +85,8 @@ class TestCleanupServiceTest { service = new TestCleanupService(environment, portalOrgRepository, portalUserRepository, inquiryRepository, inquiryCommentRepository, partnershipApplicationRepository, userRoleHistoryRepository, portalUserPrivacyAgreementRepository, userPasswordHistoryRepository, - userLogRepository, credentialRepository, webhookRequestRepository, webhookRequestApiRepository, + userLogRepository, credentialRepository, appRequestRepository, approvalService, + webhookRequestRepository, webhookRequestApiRepository, webhookRequestEventRepository, webhookService, userInvitationRepository, nativeQueries); when(environment.acceptsProfiles(Profiles.of("stage", "prod"))).thenReturn(false); } @@ -226,6 +234,36 @@ class TestCleanupServiceTest { verify(portalUserRepository, never()).delete(target); } + @Test + void cancelPendingTestAppRequests_cancelsOnlyExactTestAppRequest() { + PortalUser target = user(); + PortalOrg org = org(); + target.setPortalOrg(org); + AppRequest request = new AppRequest(); + request.setId("APP_REQ_1"); + request.setClientName("단위테스트앱-20260824-144907"); + Approval approval = new Approval(); + approval.setApprovalStatus(new RequestedState()); + request.setApproval(approval); + when(portalUserRepository.findPortalUserByEmailAddr(EMAIL)).thenReturn(Optional.of(target)); + when(appRequestRepository.findAllByOrgAndClientName(org, request.getClientName())) + .thenReturn(Collections.singletonList(request)); + + TestCleanupResult result = service.cancelPendingTestAppRequests(EMAIL, request.getClientName()); + + assertTrue(result.isFound()); + assertEquals("APP_REQ_1", result.getTargetId()); + assertEquals(1L, result.getDeletedCounts().get("PTL_APP_REQUEST_CANCELLED")); + verify(approvalService).cancelAppApproval(request); + } + + @Test + void cancelPendingTestAppRequests_rejectsNonTestName() { + assertThrows(IllegalArgumentException.class, + () -> service.cancelPendingTestAppRequests(EMAIL, "운영앱")); + verifyNoInteractions(appRequestRepository, approvalService); + } + @Test void deleteUserCascade_found_deletesOrphanDataAndUser() { PortalUser target = user();