- Playwright 테스트 정리용 internal API 추가
- /internal/test-cleanup/{org,user,orphans} 신설
- dev 프로필 전용, 토큰+IP allowlist 이중 가드
- 13개 고아 테이블 cascade/스윕 삭제 서비스
- 리포지토리 deleteBy 메서드 추가, 단위테스트 포함
This commit is contained in:
+5
@@ -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);
|
||||
}
|
||||
|
||||
+4
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
+190
@@ -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: <PTL_PROPERTY Portal/internal.api.token>' \
|
||||
* '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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -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를 수행할 수 없습니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
}
|
||||
+141
@@ -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를 수행할 수 없습니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -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());
|
||||
}
|
||||
}
|
||||
+194
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user