- Playwright 테스트 정리용 internal API 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- /internal/test-cleanup/{org,user,orphans} 신설
- dev 프로필 전용, 토큰+IP allowlist 이중 가드
- 13개 고아 테이블 cascade/스윕 삭제 서비스
- 리포지토리 deleteBy 메서드 추가, 단위테스트 포함
This commit is contained in:
Rinjae
2026-08-21 17:57:51 +09:00
parent bac9a54dee
commit f1d6181210
11 changed files with 756 additions and 1 deletions
@@ -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<Partners
* createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다.
*/
List<PartnershipApplication> findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy);
/** createdBy = PortalUser.id (평문 등가 조회 가능한 이유는 위와 동일). */
@Transactional
long deleteByCreatedBy(String createdBy);
}
@@ -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<Inquiry, String>, JpaSpecificationExecutor<Inquiry> {
Optional<Inquiry> findByInquirerAndId(PortalUser inquirer, String id);
@Transactional
long deleteByInquirer_Id(String inquirerId);
}
@@ -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보다 먼저 예외를 던져
@@ -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<InquiryComment,
+ " group by c.inquiry.id")
List<Object[]> countActiveGroupByInquiry(@Param("inquiryIds") Collection<String> inquiryIds,
@Param("delYn") String delYn);
@Transactional
long deleteByInquiry_Inquirer_Id(String inquirerId);
}
@@ -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).
*
* <p>가드는 {@link com.eactive.apim.portal.djb.menu.MenuInternalController} 와 동일하게 두 겹이다.</p>
* <ol>
* <li><b>공유 토큰 헤더</b> — {@link InternalApiTokenService}(menu.reload 와 동일 토큰 재사용).</li>
* <li><b>허용 IP 목록</b> — PTL_PROPERTY {@code Portal / testcleanup.internal.allow-ips}(기본 loopback).
* menu 와 별도 프로퍼티라 Playwright 실행 호스트만 좁게 허용할 수 있다.</li>
* </ol>
*
* <p>파괴적 작업 안전장치: {@code org}/{@code user} 삭제는 구체적 식별자(compRegNo/email)가 필수이며
* 와일드카드/전체삭제 파라미터는 없다. {@code orphans} 스윕은 이미 사라진 org/user 를 참조하는 행만
* 대상이라 살아있는 테스트 데이터를 지울 위험이 없다.</p>
*
* <pre>curl -X POST -H 'X-Internal-Token: &lt;PTL_PROPERTY Portal/internal.api.token&gt;' \
* 'http://127.0.0.1:39130/internal/test-cleanup/org?compRegNo=1234567890'</pre>
*/
@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<Map<String, Object>> deleteOrg(
@RequestParam String compRegNo, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> deleteUser(
@RequestParam String email, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> cleanOrphans(HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
TestCleanupResult result = orphanCleanupService.sweep();
log.info("테스트 정리(orphans) 실행 - from: {}", request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
private ResponseEntity<Map<String, Object>> 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<String, Object> baseBody() {
Map<String, Object> 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<Map<String, Object>> badRequest(String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("result", "BAD_REQUEST");
body.put("message", message);
return ResponseEntity.badRequest().body(body);
}
private ResponseEntity<Map<String, Object>> denied(HttpStatus status, String message) {
Map<String, Object> 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;
}
}
}
@@ -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를 수행할 수 없습니다.");
}
}
}
@@ -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();
}
}
@@ -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<String, Long> 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<String, Long> getDeletedCounts() {
return deletedCounts;
}
}
@@ -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")} 로 빈 자체가 등록되지 않는다.
*
* <p>모든 대상 테이블이 EMS 스키마(PTL_*)라 무지정 {@code @Transactional}(EMS, {@code @Primary})만 쓴다.</p>
*/
@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<PortalOrg> orgOpt = portalOrgRepository.findByCompRegNo(compRegNo);
if (!orgOpt.isPresent()) {
return TestCleanupResult.notFound();
}
PortalOrg org = orgOpt.get();
String orgId = org.getId();
TestCleanupResult result = TestCleanupResult.found(orgId);
List<PortalUser> 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<WebhookRequest> 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<PortalUser> 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를 수행할 수 없습니다.");
}
}
}
@@ -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());
}
}
@@ -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;
}
}