diff --git a/src/main/java/com/eactive/apim/portal/apps/community/partnership/repository/PartnershipApplicationRepository.java b/src/main/java/com/eactive/apim/portal/apps/community/partnership/repository/PartnershipApplicationRepository.java index ada77dd..1e4ad75 100644 --- a/src/main/java/com/eactive/apim/portal/apps/community/partnership/repository/PartnershipApplicationRepository.java +++ b/src/main/java/com/eactive/apim/portal/apps/community/partnership/repository/PartnershipApplicationRepository.java @@ -6,6 +6,7 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; @Repository @EMSDataSource @@ -16,4 +17,8 @@ public interface PartnershipApplicationRepository extends JpaRepository findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy); + + /** createdBy = PortalUser.id (평문 등가 조회 가능한 이유는 위와 동일). */ + @Transactional + long deleteByCreatedBy(String createdBy); } diff --git a/src/main/java/com/eactive/apim/portal/apps/community/qna/repository/InquiryRepository.java b/src/main/java/com/eactive/apim/portal/apps/community/qna/repository/InquiryRepository.java index bb23f9d..78ed916 100644 --- a/src/main/java/com/eactive/apim/portal/apps/community/qna/repository/InquiryRepository.java +++ b/src/main/java/com/eactive/apim/portal/apps/community/qna/repository/InquiryRepository.java @@ -7,10 +7,14 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; @Repository @EMSDataSource public interface InquiryRepository extends JpaRepository, JpaSpecificationExecutor { Optional findByInquirerAndId(PortalUser inquirer, String id); + + @Transactional + long deleteByInquirer_Id(String inquirerId); } diff --git a/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java b/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java index 533419b..cb5f2ce 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java @@ -113,7 +113,10 @@ public class PortalConfigSecurity { // /internal/menu 는 브라우저 세션이 없는 서버간 호출(admin → portal)이라 CSRF 토큰을 실을 수 없다. // 대신 InternalApiTokenService 의 공유 토큰 헤더 + 허용 IP 목록으로 통제한다. // (커스텀 헤더는 cross-site form POST 로 위조할 수 없어 CSRF 경로가 차단된다) - .ignoringRequestMatchers(new AntPathRequestMatcher("/internal/menu/**")) + // /internal/test-cleanup 도 동일 이유(Playwright 등 세션 없는 호출) + 동일 통제 방식. + .ignoringRequestMatchers( + new AntPathRequestMatcher("/internal/menu/**"), + new AntPathRequestMatcher("/internal/test-cleanup/**")) ) // 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면 // 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져 diff --git a/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/repository/InquiryCommentRepository.java b/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/repository/InquiryCommentRepository.java index 6b40e5a..3f5319d 100644 --- a/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/repository/InquiryCommentRepository.java +++ b/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/repository/InquiryCommentRepository.java @@ -4,6 +4,7 @@ import com.eactive.apim.portal.qna.entity.InquiryComment; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; import java.util.Collection; import java.util.List; @@ -17,4 +18,7 @@ public interface InquiryCommentRepository extends JpaRepository countActiveGroupByInquiry(@Param("inquiryIds") Collection inquiryIds, @Param("delYn") String delYn); + + @Transactional + long deleteByInquiry_Inquirer_Id(String inquirerId); } 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 new file mode 100644 index 0000000..36ed078 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/testcleanup/controller/TestCleanupInternalController.java @@ -0,0 +1,190 @@ +package com.eactive.apim.portal.djb.testcleanup.controller; + +import com.eactive.apim.portal.common.internal.InternalApiTokenService; +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.portalproperty.service.PortalPropertyService; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.Map; +import javax.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Playwright E2E 테스트 정리용 내부 API — local(개인 dev 프로필, 항상 {@code dev} 동반 활성화)과 + * 배포된 dev 서버 양쪽에서만 존재한다({@code @Profile("dev")}, stage/prod 는 빈 자체가 없어 404). + * + *

가드는 {@link com.eactive.apim.portal.djb.menu.MenuInternalController} 와 동일하게 두 겹이다.

+ *
    + *
  1. 공유 토큰 헤더 — {@link InternalApiTokenService}(menu.reload 와 동일 토큰 재사용).
  2. + *
  3. 허용 IP 목록 — PTL_PROPERTY {@code Portal / testcleanup.internal.allow-ips}(기본 loopback). + * menu 와 별도 프로퍼티라 Playwright 실행 호스트만 좁게 허용할 수 있다.
  4. + *
+ * + *

파괴적 작업 안전장치: {@code org}/{@code user} 삭제는 구체적 식별자(compRegNo/email)가 필수이며 + * 와일드카드/전체삭제 파라미터는 없다. {@code orphans} 스윕은 이미 사라진 org/user 를 참조하는 행만 + * 대상이라 살아있는 테스트 데이터를 지울 위험이 없다.

+ * + *
curl -X POST -H 'X-Internal-Token: <PTL_PROPERTY Portal/internal.api.token>' \
+ *   'http://127.0.0.1:39130/internal/test-cleanup/org?compRegNo=1234567890'
+ */ +@Slf4j +@Profile("dev") +@RestController +@RequestMapping("/internal/test-cleanup") +@RequiredArgsConstructor +public class TestCleanupInternalController { + + static final String PROP_GROUP = "Portal"; + static final String PROP_ALLOW_IPS = "testcleanup.internal.allow-ips"; + static final String DEFAULT_ALLOW_IPS = "127.0.0.1,::1"; + static final String PROP_ALLOW_IPS_DESCRIPTION = + "테스트 정리 내부 API(법인/계정/고아데이터 삭제) 허용 IP 목록. 콤마(,)/세미콜론(;)/줄바꿈 구분, " + + "정확일치·IPv4 CIDR(172.30.1.0/24)·와일드카드(172.30.*.*) 지원"; + + private final TestCleanupService testCleanupService; + private final OrphanCleanupService orphanCleanupService; + private final PortalPropertyService portalPropertyService; + private final InternalApiTokenService internalApiTokenService; + + @PostMapping("/org") + public ResponseEntity> deleteOrg( + @RequestParam String compRegNo, HttpServletRequest request) { + ResponseEntity> guardFailure = checkGuards(request); + if (guardFailure != null) { + return guardFailure; + } + if (StringUtils.isBlank(compRegNo)) { + return badRequest("compRegNo 는 필수입니다."); + } + + TestCleanupResult result = testCleanupService.deleteOrgCascade(compRegNo); + log.info("테스트 정리(org) 실행 - compRegNo: {}, from: {}", compRegNo, request.getRemoteAddr()); + + Map body = baseBody(); + body.put("compRegNo", compRegNo); + body.put("orgId", result.getTargetId()); + body.put("orgFound", result.isFound()); + body.put("deletedCounts", result.getDeletedCounts()); + return ResponseEntity.ok(body); + } + + @PostMapping("/user") + public ResponseEntity> deleteUser( + @RequestParam String email, HttpServletRequest request) { + ResponseEntity> guardFailure = checkGuards(request); + if (guardFailure != null) { + return guardFailure; + } + if (StringUtils.isBlank(email)) { + return badRequest("email 은 필수입니다."); + } + + TestCleanupResult result = testCleanupService.deleteUserCascade(email); + log.info("테스트 정리(user) 실행 - email: {}, from: {}", email, request.getRemoteAddr()); + + Map body = baseBody(); + body.put("email", email); + body.put("userId", result.getTargetId()); + body.put("userFound", result.isFound()); + body.put("deletedCounts", result.getDeletedCounts()); + return ResponseEntity.ok(body); + } + + @PostMapping("/orphans") + public ResponseEntity> cleanOrphans(HttpServletRequest request) { + ResponseEntity> guardFailure = checkGuards(request); + if (guardFailure != null) { + return guardFailure; + } + + TestCleanupResult result = orphanCleanupService.sweep(); + log.info("테스트 정리(orphans) 실행 - from: {}", request.getRemoteAddr()); + + Map body = baseBody(); + body.put("deletedCounts", result.getDeletedCounts()); + return ResponseEntity.ok(body); + } + + private ResponseEntity> checkGuards(HttpServletRequest request) { + if (!isAllowedIp(request)) { + return denied(HttpStatus.FORBIDDEN, + "허용되지 않은 접근입니다. (" + PROP_GROUP + "/" + PROP_ALLOW_IPS + " 확인)"); + } + if (!hasValidToken(request)) { + return denied(HttpStatus.UNAUTHORIZED, + "내부 API 토큰이 유효하지 않습니다. (" + InternalApiTokenService.PROP_GROUP + "/" + + InternalApiTokenService.PROP_TOKEN + " 확인)"); + } + return null; + } + + private Map baseBody() { + Map body = new LinkedHashMap<>(); + body.put("result", "OK"); + body.put("processedAt", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + return body; + } + + private ResponseEntity> badRequest(String message) { + Map body = new LinkedHashMap<>(); + body.put("result", "BAD_REQUEST"); + body.put("message", message); + return ResponseEntity.badRequest().body(body); + } + + private ResponseEntity> denied(HttpStatus status, String message) { + Map denied = new LinkedHashMap<>(); + denied.put("result", "DENIED"); + denied.put("message", message); + return ResponseEntity.status(status).body(denied); + } + + /** + * 공유 토큰 헤더 검사. 헤더명은 PTL_PROPERTY 로 바뀔 수 있으므로 매 요청 조회한다(호출 빈도가 낮다). + */ + private boolean hasValidToken(HttpServletRequest request) { + String headerName = internalApiTokenService.ensureHeaderName(); + if (!internalApiTokenService.matches(request.getHeader(headerName))) { + log.warn("테스트 정리 내부 API 차단 - 토큰 불일치, remote: {}, header: {}", request.getRemoteAddr(), headerName); + return false; + } + return true; + } + + /** + * 허용 IP 검사. 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다. + */ + private boolean isAllowedIp(HttpServletRequest request) { + String remote = IpAddressMatcher.canonicalize(request.getRemoteAddr()); + boolean viaProxy = request.getHeader("X-Forwarded-For") != null; + + if (viaProxy || !IpAddressMatcher.matches(resolveAllowIps(), remote)) { + log.warn("테스트 정리 내부 API 차단 - remote: {}, viaProxy: {}", remote, viaProxy); + return false; + } + return true; + } + + private String resolveAllowIps() { + try { + return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_ALLOW_IPS, + DEFAULT_ALLOW_IPS, PROP_ALLOW_IPS_DESCRIPTION); + } catch (Exception e) { + log.warn("허용 IP 목록 조회 실패 - 기본값({}) 사용", DEFAULT_ALLOW_IPS, e); + return DEFAULT_ALLOW_IPS; + } + } +} diff --git a/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/OrphanCleanupService.java b/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/OrphanCleanupService.java new file mode 100644 index 0000000..ae584c1 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/OrphanCleanupService.java @@ -0,0 +1,81 @@ +package com.eactive.apim.portal.djb.testcleanup.service; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Playwright 반복 실행으로 쌓인, 더 이상 유효한 PTL_ORG/PTL_USER 를 참조하지 않는 잔존(고아) 행을 + * 13개 테이블에서 스윕 삭제한다. local(dev 동반 활성화)·dev 서버에서만 동작한다({@link TestCleanupService} + * 참고). 대상이 "이미 사라진 org/user"뿐이라 살아있는 테스트 데이터를 건드릴 위험이 없어 파라미터가 없다. + */ +@Slf4j +@Profile("dev") +@Service +@Transactional +public class OrphanCleanupService { + + private final Environment environment; + + @PersistenceContext + private EntityManager entityManager; + + public OrphanCleanupService(Environment environment) { + this.environment = environment; + } + + public TestCleanupResult sweep() { + assertNonProdProfile(); + TestCleanupResult result = TestCleanupResult.found(null); + + // 자식 -> 부모 순서 (FK 제약은 없으나 논리적 정합성 유지 목적) + result.put("PTL_INQUIRY_COMMENT", exec( + "DELETE FROM PTL_INQUIRY_COMMENT WHERE INQUIRY_ID IN " + + "(SELECT ID FROM PTL_INQUIRY WHERE INQUIRER_ID IS NOT NULL AND INQUIRER_ID NOT IN (SELECT ID FROM PTL_USER))")); + result.put("PTL_INQUIRY", exec( + "DELETE FROM PTL_INQUIRY WHERE INQUIRER_ID IS NOT NULL AND INQUIRER_ID NOT IN (SELECT ID FROM PTL_USER)")); + result.put("PTL_PARTNERSHIP_APPLICATION", exec( + "DELETE FROM PTL_PARTNERSHIP_APPLICATION WHERE CREATED_BY IS NOT NULL AND CREATED_BY NOT IN (SELECT ID FROM PTL_USER)")); + result.put("PTL_USER_PRIVACY_POLICY_AGREEMENT", exec( + "DELETE FROM PTL_USER_PRIVACY_POLICY_AGREEMENT WHERE CREATED_BY IS NOT NULL AND CREATED_BY NOT IN (SELECT ID FROM PTL_USER)")); + result.put("PTL_USER_PASSWORD_HISTORY", exec( + "DELETE FROM PTL_USER_PASSWORD_HISTORY WHERE USER_ID IS NOT NULL AND USER_ID NOT IN (SELECT ID FROM PTL_USER)")); + result.put("PTL_USER_ROLE_HISTORY", exec( + "DELETE FROM PTL_USER_ROLE_HISTORY WHERE USER_ID IS NOT NULL AND USER_ID NOT IN (SELECT LOGIN_ID FROM PTL_USER)")); + result.put("PTL_USER_LOG", exec( + "DELETE FROM PTL_USER_LOG WHERE LOGIN_ID IS NOT NULL AND LOGIN_ID NOT IN (SELECT LOGIN_ID FROM PTL_USER)")); + result.put("PTL_CREDENTIAL_API", exec( + "DELETE FROM PTL_CREDENTIAL_API WHERE CLIENT_ID IN " + + "(SELECT CLIENTID FROM PTL_CREDENTIAL WHERE ORGID IS NOT NULL AND ORGID NOT IN (SELECT ID FROM PTL_ORG))")); + result.put("PTL_CREDENTIAL", exec( + "DELETE FROM PTL_CREDENTIAL WHERE ORGID IS NOT NULL AND ORGID NOT IN (SELECT ID FROM PTL_ORG)")); + result.put("PTL_WEBHOOK_REQ_API", exec( + "DELETE FROM PTL_WEBHOOK_REQ_API WHERE WEBHOOK_REQ_ID IN " + + "(SELECT ID FROM PTL_WEBHOOK_REQ WHERE ORG_ID IS NOT NULL AND ORG_ID NOT IN (SELECT ID FROM PTL_ORG))")); + result.put("PTL_WEBHOOK_REQ_EVENT", exec( + "DELETE FROM PTL_WEBHOOK_REQ_EVENT WHERE WEBHOOK_REQ_ID IN " + + "(SELECT ID FROM PTL_WEBHOOK_REQ WHERE ORG_ID IS NOT NULL AND ORG_ID NOT IN (SELECT ID FROM PTL_ORG))")); + result.put("PTL_WEBHOOK_REQ", exec( + "DELETE FROM PTL_WEBHOOK_REQ WHERE ORG_ID IS NOT NULL AND ORG_ID NOT IN (SELECT ID FROM PTL_ORG)")); + result.put("PTL_WEBHOOK_SEND_LOG", exec( + "DELETE FROM PTL_WEBHOOK_SEND_LOG WHERE ORG_ID IS NOT NULL AND ORG_ID NOT IN (SELECT ID FROM PTL_ORG)")); + + log.info("테스트 정리 - 고아 데이터 스윕 완료: {}", result.getDeletedCounts()); + return result; + } + + private long exec(String sql) { + return entityManager.createNativeQuery(sql).executeUpdate(); + } + + private void assertNonProdProfile() { + if (environment.acceptsProfiles(Profiles.of("stage", "prod"))) { + throw new IllegalStateException("stage/prod 환경에서는 테스트 정리 API를 수행할 수 없습니다."); + } + } +} diff --git a/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupNativeQueries.java b/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupNativeQueries.java new file mode 100644 index 0000000..8ed9ecf --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupNativeQueries.java @@ -0,0 +1,33 @@ +package com.eactive.apim.portal.djb.testcleanup.service; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Repository; + +/** + * 독립 엔티티가 없어 JPA 파생 삭제 메서드를 쓸 수 없는 테이블(PTL_CREDENTIAL_API, PTL_WEBHOOK_SEND_LOG) 전용, + * 특정 org 소유분만 지우는 삭제. EMS 가 {@code @Primary} 이므로 기본 EntityManager 를 그대로 쓴다. + */ +@Profile("dev") +@Repository +class TestCleanupNativeQueries { + + @PersistenceContext + private EntityManager entityManager; + + int deleteCredentialApiByOrgId(String orgId) { + return entityManager.createNativeQuery( + "DELETE FROM PTL_CREDENTIAL_API WHERE CLIENT_ID IN " + + "(SELECT CLIENTID FROM PTL_CREDENTIAL WHERE ORGID = :orgId)") + .setParameter("orgId", orgId) + .executeUpdate(); + } + + int deleteWebhookSendLogByOrgId(String orgId) { + return entityManager.createNativeQuery( + "DELETE FROM PTL_WEBHOOK_SEND_LOG WHERE ORG_ID = :orgId") + .setParameter("orgId", orgId) + .executeUpdate(); + } +} diff --git a/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupResult.java b/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupResult.java new file mode 100644 index 0000000..e633ca0 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupResult.java @@ -0,0 +1,45 @@ +package com.eactive.apim.portal.djb.testcleanup.service; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 테스트 정리 API 응답용 결과 누적기. 대상 존재 여부 + 테이블별 삭제 건수를 담는다. + */ +public class TestCleanupResult { + + private boolean found; + private String targetId; + private final Map deletedCounts = new LinkedHashMap<>(); + + public static TestCleanupResult notFound() { + return new TestCleanupResult(); + } + + public static TestCleanupResult found(String targetId) { + TestCleanupResult result = new TestCleanupResult(); + result.found = true; + result.targetId = targetId; + return result; + } + + public void put(String table, long count) { + deletedCounts.merge(table, count, Long::sum); + } + + public void merge(TestCleanupResult other) { + other.deletedCounts.forEach(this::put); + } + + public boolean isFound() { + return found; + } + + public String getTargetId() { + return targetId; + } + + public Map getDeletedCounts() { + return deletedCounts; + } +} 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 new file mode 100644 index 0000000..af66250 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupService.java @@ -0,0 +1,141 @@ +package com.eactive.apim.portal.djb.testcleanup.service; + +import com.eactive.apim.portal.app.repository.CredentialRepository; +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; +import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository; +import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestApiRepository; +import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventRepository; +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.portalorg.entity.PortalOrg; +import com.eactive.apim.portal.portaluser.entity.PortalUser; +import com.eactive.apim.portal.portaluser.repository.PortalUserPrivacyAgreementRepository; +import com.eactive.apim.portal.portaluser.repository.PortalUserRepository; +import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository; +import com.eactive.apim.portal.portaluser.repository.UserRoleHistoryRepository; +import com.eactive.apim.portal.user.repository.UserLogRepository; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Playwright E2E 테스트가 반복 실행되며 쌓이는 테스트 법인(PTL_ORG)·계정(PTL_USER)과 + * 그에 딸린 데이터를 하드 삭제한다. local(dev 동반 활성화)·dev 서버에서만 존재하는 빈이며, + * stage/prod 는 {@code @Profile("dev")} 로 빈 자체가 등록되지 않는다. + * + *

모든 대상 테이블이 EMS 스키마(PTL_*)라 무지정 {@code @Transactional}(EMS, {@code @Primary})만 쓴다.

+ */ +@Slf4j +@Profile("dev") +@Service +@RequiredArgsConstructor +@Transactional +public class TestCleanupService { + + private final Environment environment; + private final PortalOrgRepository portalOrgRepository; + private final PortalUserRepository portalUserRepository; + private final InquiryRepository inquiryRepository; + private final InquiryCommentRepository inquiryCommentRepository; + private final PartnershipApplicationRepository partnershipApplicationRepository; + private final UserRoleHistoryRepository userRoleHistoryRepository; + private final PortalUserPrivacyAgreementRepository portalUserPrivacyAgreementRepository; + private final UserPasswordHistoryRepository userPasswordHistoryRepository; + private final UserLogRepository userLogRepository; + private final CredentialRepository credentialRepository; + private final WebhookRequestRepository webhookRequestRepository; + private final WebhookRequestApiRepository webhookRequestApiRepository; + private final WebhookRequestEventRepository webhookRequestEventRepository; + private final WebhookService webhookService; + private final TestCleanupNativeQueries nativeQueries; + + /** + * 사업자등록번호로 법인을 찾아, 소속 계정 전원 + org 소유 CREDENTIAL/WEBHOOK + 법인 자체를 하드 삭제한다. + */ + public TestCleanupResult deleteOrgCascade(String compRegNo) { + assertNonProdProfile(); + + Optional orgOpt = portalOrgRepository.findByCompRegNo(compRegNo); + if (!orgOpt.isPresent()) { + return TestCleanupResult.notFound(); + } + PortalOrg org = orgOpt.get(); + String orgId = org.getId(); + TestCleanupResult result = TestCleanupResult.found(orgId); + + List users = portalUserRepository.findAllByPortalOrg_Id(orgId); + for (PortalUser user : users) { + result.merge(deleteUserCascadeInternal(user)); + } + + result.put("PTL_CREDENTIAL_API", nativeQueries.deleteCredentialApiByOrgId(orgId)); + result.put("PTL_CREDENTIAL", credentialRepository.deleteByOrgid(orgId)); + + Optional webhook = webhookRequestRepository.findByOrgId(orgId); + if (webhook.isPresent()) { + Long webhookReqId = webhook.get().getId(); + result.put("PTL_WEBHOOK_REQ_API", webhookRequestApiRepository.findByWebhookReqId(webhookReqId).size()); + result.put("PTL_WEBHOOK_REQ_EVENT", webhookRequestEventRepository.findByWebhookReqId(webhookReqId).size()); + webhookService.delete(webhookReqId, orgId); + result.put("PTL_WEBHOOK_REQ", 1L); + } else { + result.put("PTL_WEBHOOK_REQ_API", 0L); + result.put("PTL_WEBHOOK_REQ_EVENT", 0L); + result.put("PTL_WEBHOOK_REQ", 0L); + } + result.put("PTL_WEBHOOK_SEND_LOG", nativeQueries.deleteWebhookSendLogByOrgId(orgId)); + + portalOrgRepository.delete(org); + result.put("PTL_ORG", 1L); + + log.info("테스트 정리 - 법인 완전삭제 완료: compRegNo={}, orgId={}, userCount={}", compRegNo, orgId, users.size()); + return result; + } + + /** + * 이메일로 계정을 찾아 단독 하드 삭제한다(org 소속 여부와 무관 — org 소유 CREDENTIAL/WEBHOOK 은 건드리지 않는다). + */ + public TestCleanupResult deleteUserCascade(String email) { + assertNonProdProfile(); + + Optional userOpt = portalUserRepository.findPortalUserByEmailAddr(email); + if (!userOpt.isPresent()) { + return TestCleanupResult.notFound(); + } + PortalUser user = userOpt.get(); + TestCleanupResult result = deleteUserCascadeInternal(user); + log.info("테스트 정리 - 계정 완전삭제 완료: userId={}", user.getId()); + return result; + } + + private TestCleanupResult deleteUserCascadeInternal(PortalUser user) { + TestCleanupResult result = TestCleanupResult.found(user.getId()); + result.put("PTL_INQUIRY_COMMENT", inquiryCommentRepository.deleteByInquiry_Inquirer_Id(user.getId())); + result.put("PTL_INQUIRY", inquiryRepository.deleteByInquirer_Id(user.getId())); + result.put("PTL_PARTNERSHIP_APPLICATION", partnershipApplicationRepository.deleteByCreatedBy(user.getId())); + // UserRoleHistory.userId 는 loginId 를 저장한다(다른 이력 테이블과 시맨틱이 반대이므로 혼동 주의). + result.put("PTL_USER_ROLE_HISTORY", userRoleHistoryRepository.deleteByUserId(user.getLoginId())); + result.put("PTL_USER_PRIVACY_POLICY_AGREEMENT", portalUserPrivacyAgreementRepository.deleteByCreatedBy(user.getId())); + // UserPasswordHistory.userId 는 PortalUser.id 를 저장한다. + result.put("PTL_USER_PASSWORD_HISTORY", userPasswordHistoryRepository.deleteByUserId(user.getId())); + result.put("PTL_USER_LOG", userLogRepository.deleteByLoginId(user.getLoginId())); + portalUserRepository.delete(user); + result.put("PTL_USER", 1L); + return result; + } + + private void assertNonProdProfile() { + if (environment.acceptsProfiles(Profiles.of("stage", "prod"))) { + throw new IllegalStateException("stage/prod 환경에서는 테스트 정리 API를 수행할 수 없습니다."); + } + } +} diff --git a/src/test/java/com/eactive/apim/portal/djb/testcleanup/service/OrphanCleanupServiceTest.java b/src/test/java/com/eactive/apim/portal/djb/testcleanup/service/OrphanCleanupServiceTest.java new file mode 100644 index 0000000..49601cd --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/djb/testcleanup/service/OrphanCleanupServiceTest.java @@ -0,0 +1,55 @@ +package com.eactive.apim.portal.djb.testcleanup.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import javax.persistence.EntityManager; +import javax.persistence.Query; +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.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class OrphanCleanupServiceTest { + + @Mock private Environment environment; + @Mock private EntityManager entityManager; + @Mock private Query query; + + private OrphanCleanupService service; + + @BeforeEach + void setUp() { + service = new OrphanCleanupService(environment); + ReflectionTestUtils.setField(service, "entityManager", entityManager); + when(environment.acceptsProfiles(Profiles.of("stage", "prod"))).thenReturn(false); + when(entityManager.createNativeQuery(org.mockito.ArgumentMatchers.anyString())).thenReturn(query); + when(query.executeUpdate()).thenReturn(0); + } + + @Test + void sweep_runsAllThirteenTablesAndReportsCounts() { + when(query.executeUpdate()).thenReturn(3); + + TestCleanupResult result = service.sweep(); + + assertEquals(13, result.getDeletedCounts().size()); + result.getDeletedCounts().values().forEach(count -> assertEquals(3L, count)); + } + + @Test + void sweep_prodProfile_throws() { + when(environment.acceptsProfiles(Profiles.of("stage", "prod"))).thenReturn(true); + + assertThrows(IllegalStateException.class, () -> service.sweep()); + } +} 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 new file mode 100644 index 0000000..2893ad7 --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupServiceTest.java @@ -0,0 +1,194 @@ +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.times; +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.apps.community.partnership.repository.PartnershipApplicationRepository; +import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository; +import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository; +import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository; +import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestApiRepository; +import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventRepository; +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.portalorg.entity.PortalOrg; +import com.eactive.apim.portal.portaluser.entity.PortalUser; +import com.eactive.apim.portal.portaluser.repository.PortalUserPrivacyAgreementRepository; +import com.eactive.apim.portal.portaluser.repository.PortalUserRepository; +import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository; +import com.eactive.apim.portal.portaluser.repository.UserRoleHistoryRepository; +import com.eactive.apim.portal.user.repository.UserLogRepository; +import java.util.Collections; +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.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class TestCleanupServiceTest { + + @Mock private Environment environment; + @Mock private PortalOrgRepository portalOrgRepository; + @Mock private PortalUserRepository portalUserRepository; + @Mock private InquiryRepository inquiryRepository; + @Mock private InquiryCommentRepository inquiryCommentRepository; + @Mock private PartnershipApplicationRepository partnershipApplicationRepository; + @Mock private UserRoleHistoryRepository userRoleHistoryRepository; + @Mock private PortalUserPrivacyAgreementRepository portalUserPrivacyAgreementRepository; + @Mock private UserPasswordHistoryRepository userPasswordHistoryRepository; + @Mock private UserLogRepository userLogRepository; + @Mock private CredentialRepository credentialRepository; + @Mock private WebhookRequestRepository webhookRequestRepository; + @Mock private WebhookRequestApiRepository webhookRequestApiRepository; + @Mock private WebhookRequestEventRepository webhookRequestEventRepository; + @Mock private WebhookService webhookService; + @Mock private TestCleanupNativeQueries nativeQueries; + + private TestCleanupService service; + + private static final String ORG_ID = "ORG1"; + private static final String USER_ID = "USER1"; + private static final String LOGIN_ID = "user1@test.com"; + private static final String COMP_REG_NO = "1234567890"; + private static final String EMAIL = "user1@test.com"; + + @BeforeEach + void setUp() { + service = new TestCleanupService(environment, portalOrgRepository, portalUserRepository, + inquiryRepository, inquiryCommentRepository, partnershipApplicationRepository, + userRoleHistoryRepository, portalUserPrivacyAgreementRepository, userPasswordHistoryRepository, + userLogRepository, credentialRepository, webhookRequestRepository, webhookRequestApiRepository, + webhookRequestEventRepository, webhookService, nativeQueries); + when(environment.acceptsProfiles(Profiles.of("stage", "prod"))).thenReturn(false); + } + + private PortalUser user() { + PortalUser u = new PortalUser(); + u.setId(USER_ID); + u.setLoginId(LOGIN_ID); + return u; + } + + @Test + void deleteOrgCascade_notFound() { + when(portalOrgRepository.findByCompRegNo(COMP_REG_NO)).thenReturn(Optional.empty()); + + TestCleanupResult result = service.deleteOrgCascade(COMP_REG_NO); + + assertFalse(result.isFound()); + assertTrue(result.getDeletedCounts().isEmpty()); + verifyNoInteractions(webhookService, portalUserRepository); + } + + @Test + void deleteOrgCascade_found_cascadesUsersAndOrg() { + PortalOrg org = org(); + PortalUser member = user(); + when(portalOrgRepository.findByCompRegNo(COMP_REG_NO)).thenReturn(Optional.of(org)); + when(portalUserRepository.findAllByPortalOrg_Id(ORG_ID)).thenReturn(Collections.singletonList(member)); + when(webhookRequestRepository.findByOrgId(ORG_ID)).thenReturn(Optional.empty()); + when(nativeQueries.deleteCredentialApiByOrgId(ORG_ID)).thenReturn(2); + when(credentialRepository.deleteByOrgid(ORG_ID)).thenReturn(1L); + + TestCleanupResult result = service.deleteOrgCascade(COMP_REG_NO); + + assertTrue(result.isFound()); + assertEquals(ORG_ID, result.getTargetId()); + assertEquals(1L, result.getDeletedCounts().get("PTL_USER")); + assertEquals(2L, result.getDeletedCounts().get("PTL_CREDENTIAL_API")); + assertEquals(1L, result.getDeletedCounts().get("PTL_CREDENTIAL")); + assertEquals(1L, result.getDeletedCounts().get("PTL_ORG")); + assertEquals(0L, result.getDeletedCounts().get("PTL_WEBHOOK_REQ")); + verify(inquiryCommentRepository).deleteByInquiry_Inquirer_Id(USER_ID); + verify(inquiryRepository).deleteByInquirer_Id(USER_ID); + verify(userRoleHistoryRepository).deleteByUserId(LOGIN_ID); + verify(userPasswordHistoryRepository).deleteByUserId(USER_ID); + verify(portalUserRepository).delete(member); + verify(webhookService, never()).delete(anyLong(), anyString()); + verify(portalOrgRepository).delete(org); + } + + @Test + void deleteOrgCascade_withWebhook_delegatesToWebhookService() { + PortalOrg org = org(); + WebhookRequest webhook = new WebhookRequest(); + webhook.setId(99L); + when(portalOrgRepository.findByCompRegNo(COMP_REG_NO)).thenReturn(Optional.of(org)); + when(portalUserRepository.findAllByPortalOrg_Id(ORG_ID)).thenReturn(Collections.emptyList()); + when(webhookRequestRepository.findByOrgId(ORG_ID)).thenReturn(Optional.of(webhook)); + when(webhookRequestApiRepository.findByWebhookReqId(99L)).thenReturn(Collections.emptyList()); + when(webhookRequestEventRepository.findByWebhookReqId(99L)).thenReturn(Collections.emptyList()); + + TestCleanupResult result = service.deleteOrgCascade(COMP_REG_NO); + + verify(webhookService, times(1)).delete(99L, ORG_ID); + assertEquals(1L, result.getDeletedCounts().get("PTL_WEBHOOK_REQ")); + } + + @Test + void deleteUserCascade_notFound() { + when(portalUserRepository.findPortalUserByEmailAddr(EMAIL)).thenReturn(Optional.empty()); + + TestCleanupResult result = service.deleteUserCascade(EMAIL); + + assertFalse(result.isFound()); + assertTrue(result.getDeletedCounts().isEmpty()); + } + + @Test + void deleteUserCascade_found_deletesOrphanDataAndUser() { + PortalUser target = user(); + when(portalUserRepository.findPortalUserByEmailAddr(EMAIL)).thenReturn(Optional.of(target)); + + TestCleanupResult result = service.deleteUserCascade(EMAIL); + + assertTrue(result.isFound()); + assertEquals(USER_ID, result.getTargetId()); + verify(inquiryCommentRepository).deleteByInquiry_Inquirer_Id(USER_ID); + verify(inquiryRepository).deleteByInquirer_Id(USER_ID); + verify(partnershipApplicationRepository).deleteByCreatedBy(USER_ID); + verify(userRoleHistoryRepository).deleteByUserId(LOGIN_ID); + verify(portalUserPrivacyAgreementRepository).deleteByCreatedBy(USER_ID); + verify(userPasswordHistoryRepository).deleteByUserId(USER_ID); + verify(userLogRepository).deleteByLoginId(LOGIN_ID); + verify(portalUserRepository).delete(target); + } + + @Test + void deleteOrgCascade_prodProfile_throws() { + when(environment.acceptsProfiles(Profiles.of("stage", "prod"))).thenReturn(true); + + assertThrows(IllegalStateException.class, () -> service.deleteOrgCascade(COMP_REG_NO)); + } + + @Test + void deleteUserCascade_prodProfile_throws() { + when(environment.acceptsProfiles(Profiles.of("stage", "prod"))).thenReturn(true); + + assertThrows(IllegalStateException.class, () -> service.deleteUserCascade(EMAIL)); + } + + private PortalOrg org() { + PortalOrg o = new PortalOrg(); + o.setId(ORG_ID); + return o; + } +}