8 Commits

Author SHA1 Message Date
Rinjae f1d6181210 - 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 메서드 추가, 단위테스트 포함
2026-08-21 17:57:51 +09:00
Rinjae bac9a54dee 활성 프로파일 뱃지 추가 - HTTP 세션 로그 설정 제거 - 개인정보 암호화 처리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-21 17:25:41 +09:00
Rinjae 603cba65a7 API 목록 페이징 기능 추가 - 검색 결과 페이지 처리 로직 구현
API 아이콘 스트리밍 엔드포인트 추가 - base64 데이터 URL 캐싱 처리
response 압축 설정 추가 - application.yml에 설정 값 정의
2026-08-21 10:48:47 +09:00
Rinjae 889d77b990 로그 설정 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- JVM 파라미터로 파일, 세션, API 테스터 로그 레벨 제어 추가
- 루트, 세션, API 테스터 로그 레벨 동적 설정 지원
2026-08-20 14:34:01 +09:00
Rinjae bf2dad6ba7 고객센터 연락처 미노출 조건 변경 - 문자열 공백 검사 로직 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-19 18:47:54 +09:00
Rinjae 3a1b0cb9c1 고객센터 연락처 미노출 조건 변경 - 문자열 공백 검사 함수 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-19 18:25:19 +09:00
Rinjae a13279a777 고객센터 연락처 미노출 조건 추가 - 값 공백 시 th:if로 처리
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-19 16:46:58 +09:00
Rinjae 5299352235 CSS hover 색상값 표현 변경 (RGB → 퍼센트 형식)
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-19 15:17:41 +09:00
29 changed files with 1040 additions and 66 deletions
@@ -15,6 +15,10 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.ui.ModelMap;
@@ -79,12 +83,17 @@ public class ApiController {
}
@GetMapping
public String apiList(@ModelAttribute ApiGroupSearch search, Model model) {
public String apiList(@ModelAttribute ApiGroupSearch search, @PageableDefault Pageable pageable, Model model) {
Map<String, Object> searchResult = apiSearchFacade.searchApis(search);
@SuppressWarnings("unchecked")
List<ApiSpecInfoDto> allApis = (List<ApiSpecInfoDto>) searchResult.get("apis");
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, pageable);
model.addAttribute("search", search);
model.addAttribute("services", searchResult.get("services"));
model.addAttribute("apis", searchResult.get("apis"));
model.addAttribute("apis", apiPage.getContent());
model.addAttribute("page", apiPage);
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
@@ -94,6 +103,19 @@ public class ApiController {
return "apps/apis/mainApiList";
}
/**
* apiSearchFacade.searchApis() 는 다른 소비처(API Status 필터 목록 등)와 계약을 공유하므로
* 항상 전체 List 를 돌려준다. 목록 화면 렌더링에서만 결과를 잘라 Page 로 감싼다.
*/
private Page<ApiSpecInfoDto> slicePage(List<ApiSpecInfoDto> apis, Pageable pageable) {
int total = apis == null ? 0 : apis.size();
int fromIndex = Math.min(pageable.getPageNumber() * pageable.getPageSize(), total);
int toIndex = Math.min(fromIndex + pageable.getPageSize(), total);
List<ApiSpecInfoDto> content = total == 0 ? new ArrayList<>() : apis.subList(fromIndex, toIndex);
return new PageImpl<>(content, pageable, total);
}
@GetMapping("/testbed/api")
public String testbedByApi(@RequestParam(value = "id", required = false) String id, Model model) {
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
@@ -46,4 +46,6 @@ public class ApiSpecInfoDto {
private String displayRoleCode;
private String apiGroupName;
private String apiGroupId;
}
@@ -115,7 +115,9 @@ public class ApiSearchFacadeImpl implements ApiSearchFacade {
filteredApis.forEach(api -> {
ApiServiceDTO service = apiData.getServicesByApiId().get(api.getApiId());
if (service != null) {
api.setMainIcon(service.getMainIcon());
// mainIcon(CLOB, base64)은 카드 수만큼 복제하지 않는다. 렌더링은
// apiGroupId 기준 /api-services/{id}/icon 스트리밍 엔드포인트를 사용한다.
api.setApiGroupId(service.getId());
api.setApiGroupName(service.getGroupName());
}
});
@@ -5,8 +5,17 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceTabInfo;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -20,6 +29,10 @@ import org.springframework.web.servlet.ModelAndView;
@RequiredArgsConstructor
public class ApiServiceController {
// ApiGroup.mainIcon 은 "data:image/png;base64,...." 형태의 Data URL 문자열 그대로 저장돼 있다.
private static final Pattern DATA_URL_PATTERN = Pattern.compile("^data:(image/[a-zA-Z0-9+.-]+);base64,(.+)$", Pattern.DOTALL);
private static final String DEFAULT_ICON_PATH = "/img/api_icon_default.png";
private final ApiServiceService apiServiceService;
@@ -52,4 +65,28 @@ public class ApiServiceController {
return "apps/apiservice/apiServiceDetail";
}
/**
* API 그룹 아이콘 스트리밍. mainIcon(CLOB, Data URL 문자열)을 그대로 HTML에 인라인하면
* 카드 개수만큼 반복 전송돼 응답 용량이 폭증하므로, 그룹 id 당 1회만 내려주고
* 브라우저 캐시로 재사용시킨다.
*/
@GetMapping("/{id}/icon")
public ResponseEntity<byte[]> icon(@PathVariable String id) {
String mainIcon = apiServiceService.getMainIcon(id);
Matcher matcher = mainIcon == null ? null : DATA_URL_PATTERN.matcher(mainIcon);
if (matcher == null || !matcher.matches()) {
return ResponseEntity.status(HttpStatus.FOUND)
.header(HttpHeaders.LOCATION, DEFAULT_ICON_PATH)
.build();
}
byte[] imageBytes = Base64.getDecoder().decode(matcher.group(2));
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(matcher.group(1)))
.cacheControl(CacheControl.maxAge(1, TimeUnit.DAYS).cachePublic())
.body(imageBytes);
}
}
@@ -120,5 +120,14 @@ public class ApiServiceService {
.orElse(null);
}
/**
* 그룹 아이콘(mainIcon)만 가볍게 조회. {@link #getApiGroupById} 는 apiGroupApiList 까지
* 함께 로드해 무거우므로, 아이콘 스트리밍 엔드포인트 전용으로 CLOB 값만 꺼낸다.
*/
public String getMainIcon(String id) {
return apiServiceRepository.findById(id)
.map(ApiGroup::getMainIcon)
.orElse(null);
}
}
@@ -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);
}
@@ -111,6 +111,18 @@ public class GlobalControllerAdvice {
return clientGuardService.isDevtoolsGuardEnabled();
}
/**
* 상단 헤더 좌측 노출용 활성 프로파일 배지. prod 프로파일이면 노출하지 않는다(null).
*/
@ModelAttribute("activeProfileBadge")
public String activeProfileBadge() {
if (environment.acceptsProfiles(Profiles.of("prod"))) {
return null;
}
String[] activeProfiles = environment.getActiveProfiles();
return activeProfiles.length == 0 ? "default" : String.join(", ", activeProfiles);
}
/**
* 푸터 고객센터 연락처. PortalProperty(Portal/customer.center.contact)에서 조회.
* 전화번호가 아닐 수도 있으므로 값 그대로 출력하되 템플릿에서 th:text(HTML escape)로 렌더한다.
@@ -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를 수행할 수 없습니다.");
}
}
}
+5
View File
@@ -30,6 +30,11 @@ server:
# RequestHeader set X-Forwarded-Proto "https" / X-Forwarded-Port "1443" (mod_wl_ohs 는 미전송)
forward-headers-strategy: native
compression:
enabled: true
mime-types: text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json
min-response-size: 1024
spring:
config:
+19 -4
View File
@@ -19,6 +19,21 @@
<property name="__consoleLevel.FALSE" value="OFF"/>
<property name="CONSOLE_EFFECTIVE_LEVEL" value="${__consoleLevel.${CONSOLE_LOG_ENABLED}}"/>
<!-- 파일 로그(ROLLING/ERROR_FILE 등 root 하위 appender) 레벨 JVM 파라미터 제어 -->
<!-- 사용: -DFILE_LOG_LEVEL=ERROR (전체 미남김: -DFILE_LOG_LEVEL=OFF) -->
<!-- 기본값: dev 프로파일 DEBUG, 그 외 INFO (기존 동작 유지) -->
<property name="FILE_LOG_LEVEL" value="${FILE_LOG_LEVEL:-INFO}"/>
<property name="FILE_LOG_LEVEL_DEV" value="${FILE_LOG_LEVEL:-DEBUG}"/>
<!-- API 테스터 감사 로그(eapim.portal.apitester.audit) on/off JVM 파라미터 제어 -->
<!-- 사용: -DAPI_TESTER_AUDIT_LOG_ENABLED=false / 기본값: true(켜짐, INFO) -->
<property name="API_TESTER_AUDIT_LOG_ENABLED" value="${API_TESTER_AUDIT_LOG_ENABLED:-true}"/>
<property name="__apiTesterAuditLevel.true" value="INFO"/>
<property name="__apiTesterAuditLevel.TRUE" value="INFO"/>
<property name="__apiTesterAuditLevel.false" value="OFF"/>
<property name="__apiTesterAuditLevel.FALSE" value="OFF"/>
<property name="API_TESTER_AUDIT_EFFECTIVE_LEVEL" value="${__apiTesterAuditLevel.${API_TESTER_AUDIT_LOG_ENABLED}}"/>
<appender name="HIBERNATE_APPENDER" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/hibernate.log</file>
@@ -95,23 +110,23 @@
</appender>
<logger name="eapim.portal.session" level="INFO" additivity="false">
<logger name="eapim.portal.session" level="${FILE_LOG_LEVEL}" additivity="false">
<appender-ref ref="HTTP_SESSION" />
</logger>
<logger name="eapim.portal.apitester.audit" level="INFO" additivity="false">
<logger name="eapim.portal.apitester.audit" level="${API_TESTER_AUDIT_EFFECTIVE_LEVEL}" additivity="false">
<appender-ref ref="API_TESTER_AUDIT" />
</logger>
<root level="INFO">
<root level="${FILE_LOG_LEVEL}">
<appender-ref ref="ROLLING"/>
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
<springProfile name="dev">
<root level="DEBUG">
<root level="${FILE_LOG_LEVEL_DEV}">
<appender-ref ref="ROLLING"/>
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ERROR_FILE"/>
+93 -45
View File
@@ -821,6 +821,50 @@ hr {
flex-shrink: 0;
}
.env-badge {
display: inline-flex;
align-items: center;
margin-left: 10px;
padding: 2px 8px;
border-radius: 4px;
background: var(--accent-orange);
color: var(--white);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
line-height: 1.6;
}
@media (max-width: 1024px) {
.env-badge {
display: none;
}
}
.env-strip {
display: none;
}
@media (max-width: 1024px) {
.env-strip {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
top: 0;
left: 0;
right: 0;
height: 16px;
background: var(--accent-orange);
color: var(--white);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
z-index: 1100;
pointer-events: none;
}
}
.logo-wrapper {
display: flex;
align-items: center;
@@ -1192,7 +1236,7 @@ hr {
transition: all 0.3s ease;
}
.mobile-drawer .drawer-welcome .btn-drawer-login:hover {
background: rgb(0, 65.7, 162);
background: rgb(0%, 25.7647058824%, 63.5294117647%);
}
.mobile-drawer .drawer-welcome.authenticated {
flex-direction: row;
@@ -2503,7 +2547,7 @@ hr {
color: #FFFFFF;
}
.btn-success:hover {
background: rgb(83.2897959184, 199.3102040816, 106.493877551);
background: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
transform: translateY(-3px);
}
.btn-danger {
@@ -2511,7 +2555,7 @@ hr {
color: #FFFFFF;
}
.btn-danger:hover {
background: rgb(255, 70.8, 70.8);
background: rgb(100%, 27.7647058824%, 27.7647058824%);
transform: translateY(-3px);
}
.btn-ghost {
@@ -2777,7 +2821,7 @@ hr {
.action-btn-delete:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(255, 88.9, 88.9);
background: rgb(100%, 34.862745098%, 34.862745098%);
}
.action-btn-delete:active {
transform: translateY(0);
@@ -2895,7 +2939,7 @@ hr {
background: #a4d6ea;
}
.btn-input-action.btn-change:hover {
background: rgb(131.6625, 199.4303571429, 226.5375);
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
}
@@ -2970,7 +3014,7 @@ hr {
border: none;
}
.btn-action-primary:hover {
background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
background: rgb(12.4992826399%, 36.3615494978%, 80.6771879484%);
transform: translateY(-2px);
color: #fff;
}
@@ -3020,7 +3064,7 @@ hr {
}
.status-badge.status-processing {
background: rgba(255, 217, 61, 0.1);
color: rgb(221.2, 177.8721649485, 0);
color: rgb(86.7450980392%, 69.7537901759%, 0%);
}
.status-badge.status-failed {
background: rgba(255, 107, 107, 0.1);
@@ -3060,7 +3104,7 @@ hr {
}
.status-badge-header.status-processing {
background: rgba(255, 217, 61, 0.1);
color: rgb(221.2, 177.8721649485, 0);
color: rgb(86.7450980392%, 69.7537901759%, 0%);
}
.badge-sm {
@@ -4248,7 +4292,7 @@ select.form-control {
.file-upload-wrapper .file-remove-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(255, 88.9, 88.9);
background: rgb(100%, 34.862745098%, 34.862745098%);
}
.file-upload-wrapper .file-remove-btn:active {
transform: translateY(0);
@@ -4569,7 +4613,7 @@ select.form-control {
transition: all 0.3s ease;
}
.form-actions--with-withdrawal .withdrawal-link:hover {
background: rgb(210.2090909091, 232.6045454545, 242.9409090909);
background: rgb(82.4349376114%, 91.2174688057%, 95.2709447415%);
}
.form-actions--with-withdrawal .withdrawal-link img {
width: 22px;
@@ -4724,7 +4768,7 @@ select.form-control {
text-decoration: underline;
}
.notice-content-box a:hover {
color: rgb(0, 65.7, 162);
color: rgb(0%, 25.7647058824%, 63.5294117647%);
}
.form-row--content .form-label-wrapper {
@@ -5660,7 +5704,7 @@ select.form-control {
font-size: 16px;
}
.drawer-logout-btn:hover {
background: rgb(255, 70.8, 70.8);
background: rgb(100%, 27.7647058824%, 27.7647058824%);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
}
@@ -6373,7 +6417,7 @@ select.form-control {
color: #64748b;
}
.list-table-btn--default:hover {
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
}
.list-table-btn--primary {
background-color: #ecf0fa;
@@ -6381,7 +6425,7 @@ select.form-control {
color: #2a69de;
}
.list-table-btn--primary:hover {
background-color: rgb(216.7625, 224.8125, 244.9375);
background-color: rgb(85.0049019608%, 88.1617647059%, 96.0539215686%);
}
.list-table-btn--secondary {
background-color: #f5f5f4;
@@ -6389,7 +6433,7 @@ select.form-control {
color: #64748b;
}
.list-table-btn--secondary:hover {
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
}
.list-table-btn--danger {
background-color: #fbe7e9;
@@ -6397,7 +6441,7 @@ select.form-control {
color: #bb1026;
}
.list-table-btn--danger:hover {
background-color: rgb(247.5571428571, 210.3428571429, 214.0642857143);
background-color: rgb(97.081232493%, 82.487394958%, 83.9467787115%);
}
.table-pagination {
@@ -7047,7 +7091,7 @@ select.form-control {
.alert.alert-error {
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
color: rgb(255, 70.8, 70.8);
color: rgb(100%, 27.7647058824%, 27.7647058824%);
align-items: center;
}
.alert.alert-error svg {
@@ -7061,7 +7105,7 @@ select.form-control {
.alert.alert-success {
background: rgba(107, 207, 127, 0.1);
border: 1px solid rgba(107, 207, 127, 0.3);
color: rgb(61.5183673469, 189.6816326531, 87.1510204082);
color: rgb(24.12484994%, 74.3849539816%, 34.1768707483%);
}
.alert.alert-info {
background: rgba(0, 73, 180, 0.1);
@@ -11432,10 +11476,10 @@ body.index-page-body {
line-height: 20px;
}
.login-button:hover {
background: rgb(25.65, 70.3, 173.85);
background: rgb(10.0588235294%, 27.568627451%, 68.1764705882%);
}
.login-button:active {
background: rgb(24.3, 66.6, 164.7);
background: rgb(9.5294117647%, 26.1176470588%, 64.5882352941%);
}
.login-button:disabled {
opacity: 0.6;
@@ -11478,10 +11522,10 @@ body.index-page-body {
border-bottom-right-radius: 8px;
}
.login-links-container .link-btn:hover {
background: rgb(220.61, 227.85, 245.95);
background: rgb(86.5137254902%, 89.3529411765%, 96.4509803922%);
}
.login-links-container .link-btn:active {
background: rgb(205.22, 215.7, 241.9);
background: rgb(80.4784313725%, 84.5882352941%, 94.862745098%);
}
.login-alert {
@@ -11990,12 +12034,12 @@ body.index-page-body {
}
.auth-request-button:hover,
.auth-verify-button:hover {
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
transform: none !important;
}
.auth-request-button:active,
.auth-verify-button:active {
background: rgb(21.411588785, 146.3125233645, 233.148411215);
background: rgb(8.3967014843%, 57.3774601429%, 91.4307494961%);
}
.auth-request-button:disabled,
.auth-verify-button:disabled {
@@ -12044,10 +12088,10 @@ body.index-page-body {
background: #f0f2f5;
}
.account-recovery-card .form-actions .cancel-button:hover {
background: rgb(225.45, 229.39, 235.3);
background: rgb(88.4117647059%, 89.9568627451%, 92.2745098039%);
}
.account-recovery-card .form-actions .cancel-button:active {
background: rgb(210.9, 216.78, 225.6);
background: rgb(82.7058823529%, 85.0117647059%, 88.4705882353%);
}
.account-recovery-card .form-actions .submit-button {
color: #FFFFFF;
@@ -12057,7 +12101,7 @@ body.index-page-body {
background: rgb(6, 54, 125);
}
.account-recovery-card .form-actions .submit-button:active {
background: rgb(0, 65.7, 162);
background: rgb(0%, 25.7647058824%, 63.5294117647%);
}
.account-recovery-card .form-actions .submit-button:disabled {
opacity: 0.6;
@@ -12293,7 +12337,7 @@ body.index-page-body {
transition: color 0.3s ease;
}
.result-info-box .info-text .info-link:hover {
color: rgb(0, 65.7, 162);
color: rgb(0%, 25.7647058824%, 63.5294117647%);
}
@media (max-width: 576px) {
.result-info-box .info-text {
@@ -17493,7 +17537,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.btn-copy-action:hover {
background: rgb(131.6625, 199.4303571429, 226.5375);
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
}
@media (max-width: 768px) {
.btn-copy-action {
@@ -17520,7 +17564,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.btn-view-secret:hover {
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
}
.btn-view-secret svg {
width: 20px;
@@ -17705,7 +17749,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border-radius: 8px;
}
.btn-copy-action:hover {
background: rgb(131.6625, 199.4303571429, 226.5375);
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
}
.btn-view-secret {
width: 100% !important;
@@ -17721,7 +17765,7 @@ input[type=checkbox]:checked + .custom-checkbox {
height: 16px;
}
.btn-view-secret:hover {
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
}
#revealedSecretBox {
width: 100%;
@@ -17994,7 +18038,7 @@ input[type=checkbox]:checked + .custom-checkbox {
flex-shrink: 0;
}
.detail-wrap .dt-btn-copy:hover {
background: rgb(189.6, 230.0857142857, 255);
background: rgb(74.3529411765%, 90.2296918768%, 100%);
}
.detail-wrap .dt-btn-copy svg {
color: #2a69de;
@@ -18073,6 +18117,10 @@ input[type=checkbox]:checked + .custom-checkbox {
padding-right: 20px;
flex: 1;
}
.detail-wrap .dt-api-name:hover {
color: #2a69de;
text-decoration: underline;
}
@media (max-width: 768px) {
.detail-wrap .dt-api-name {
font-size: 14px;
@@ -18171,7 +18219,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.detail-wrap .dt-btn-gray:hover {
background: rgb(170.6589473684, 183.4378947368, 193.6610526316);
background: rgb(66.9250773994%, 71.9364293086%, 75.9455108359%);
}
.detail-wrap .dt-btn-red {
width: 156px;
@@ -18189,7 +18237,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.detail-wrap .dt-btn-red:hover {
background: rgb(255, 70.0915337423, 64.24);
background: rgb(100%, 27.4868759774%, 25.1921568627%);
}
.detail-wrap .dt-btn-blue {
width: 156px;
@@ -19763,7 +19811,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-list:hover {
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
}
.btn-inquiry-list:active {
transform: scale(0.98);
@@ -19796,7 +19844,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-edit:hover {
background: rgb(0, 69.35, 171);
background: rgb(0%, 27.1960784314%, 67.0588235294%);
}
.btn-inquiry-edit:active {
transform: scale(0.98);
@@ -19829,7 +19877,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-delete:hover {
background: rgb(217.9841772152, 41.3658227848, 58.2873417722);
background: rgb(85.4839910648%, 16.2218912882%, 22.8577810871%);
}
.btn-inquiry-delete:active {
transform: scale(0.98);
@@ -19901,7 +19949,7 @@ input[type=checkbox]:checked + .custom-checkbox {
margin-left: 8px;
}
.file-upload-inline .btn-remove-file-inline:hover {
background: rgb(209.4151898734, 36.2848101266, 52.8721518987);
background: rgb(82.1236038719%, 14.2293373045%, 20.7341772152%);
}
.file-upload-inline .btn-remove-file-inline svg {
width: 12px;
@@ -19933,7 +19981,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.file-upload-inline .btn-file-attach:hover {
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
}
.file-upload-inline .btn-file-attach svg {
width: 22px;
@@ -19993,7 +20041,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none;
}
.inquiry-form-container .form-actions .btn-secondary:hover {
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
}
.inquiry-form-container .form-actions .btn-primary {
background: #0049b4;
@@ -20001,7 +20049,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none;
}
.inquiry-form-container .form-actions .btn-primary:hover {
background: rgb(0, 69.35, 171);
background: rgb(0%, 27.1960784314%, 67.0588235294%);
}
.inquiry-form-container .file-upload-inline .file-input-display {
min-height: 50px;
@@ -20790,7 +20838,7 @@ input[type=checkbox]:checked + .custom-checkbox {
cursor: pointer;
}
.djb-board-write-container .form-actions .btn-submit:hover {
background-color: rgb(33.643902439, 97.8731707317, 217.156097561);
background-color: rgb(13.193687231%, 38.3816355811%, 85.1592539455%);
}
@media (max-width: 768px) {
.djb-board-write-container .form-actions .btn-submit {
@@ -21326,7 +21374,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: all 0.3s ease;
}
.org-file-remove:hover {
background: rgb(255, 70.8, 70.8);
background: rgb(100%, 27.7647058824%, 27.7647058824%);
}
.org-file-notice {
@@ -22338,7 +22386,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.status-indicator.status-active {
background-color: rgba(107, 207, 127, 0.1);
color: rgb(83.2897959184, 199.3102040816, 106.493877551);
color: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
}
.status-indicator.status-active .status-dot {
background-color: #6BCF7F;
File diff suppressed because one or more lines are too long
@@ -156,6 +156,50 @@
}
}
// 활성 Spring 프로파일 표시 (prod 제외)
// 데스크톱: 로고 옆 인라인 뱃지 / 모바일·태블릿: 최상단 floating 바(fixed, 레이아웃 미영향)
.env-badge {
display: inline-flex;
align-items: center;
margin-left: 10px;
padding: 2px 8px;
border-radius: 4px;
background: var(--accent-orange);
color: var(--white);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
line-height: 1.6;
@media (max-width: 1024px) {
display: none;
}
}
.env-strip {
display: none;
@media (max-width: 1024px) {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
top: 0;
left: 0;
right: 0;
height: 16px;
background: var(--accent-orange);
color: var(--white);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
z-index: 1100;
pointer-events: none;
}
}
// Logo Wrapper
.logo-wrapper {
display: flex;
@@ -1198,6 +1198,11 @@
padding-right: 20px;
flex: 1;
&:hover {
color: #2a69de;
text-decoration: underline;
}
@media (max-width: 768px) {
font-size: 14px;
}
@@ -61,6 +61,7 @@
<form id="searchForm" th:action="@{/apis}" method="get">
<input type="hidden" name="groupIds"
th:value="${search.groupIds != null and !search.groupIds.isEmpty() ? search.groupIds[0] : ''}" />
<input type="hidden" name="page" value="1" />
<input type="text" class="search-input" name="keyword" th:value="${search.keyword}"
placeholder="검색어를 입력하세요.">
<button type="submit" class="search-submit-btn" aria-label="검색">
@@ -99,13 +100,17 @@
<!-- API Image (Bottom Right) -->
<div class="api-card-image">
<img th:if="${api.mainIcon}" th:src="${api.mainIcon}" th:alt="${api.apiGroupName}"
onerror="this.style.display='none'">
<img th:if="${api.apiGroupId}" th:src="@{/api-services/{id}/icon(id=${api.apiGroupId})}"
th:alt="${api.apiGroupName}" onerror="this.style.display='none'">
</div>
</div>
</div>
<!-- Pagination -->
<div class="pagination" th:if="${apis != null and !apis.isEmpty()}"
th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}"></div>
<!-- Empty State -->
<div class="api-empty-state" th:if="${apis == null or apis.isEmpty()}">
<div class="empty-icon">🔍</div>
@@ -121,6 +126,14 @@
<th:block layout:fragment="contentScript">
<script th:inline="javascript">
// fragment/pagination.html 의 인라인 onclick 에서 호출 (전역 스코프 필요)
window.fn_select_page = function (pageNo) {
const form = document.getElementById('searchForm');
const pageInput = form.querySelector('input[name="page"]');
if (pageInput) pageInput.value = pageNo;
form.submit();
};
document.addEventListener('DOMContentLoaded', function () {
// DOM Elements
const sidebar = document.getElementById('apiSidebar');
@@ -128,6 +141,7 @@
const mobileOverlay = document.getElementById('mobileOverlay');
const searchForm = document.getElementById('searchForm');
const groupIdsInput = searchForm.querySelector('input[name="groupIds"]');
const pageInput = searchForm.querySelector('input[name="page"]');
const searchInput = searchForm.querySelector('input[name="keyword"]');
// API Card Click Handlers
@@ -230,6 +244,9 @@
groupIdsInput.value = '';
}
// 카테고리 변경 시 1페이지로 리셋
if (pageInput) pageInput.value = '1';
// Submit form
searchForm.submit();
});
@@ -239,6 +256,7 @@
searchInput.addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
if (pageInput) pageInput.value = '1';
searchForm.submit();
}
});
@@ -238,10 +238,8 @@
</span>
</p>
<div class="card-illustration">
<!-- 서비스별 아이콘 매핑 - base64 인코딩된 이미지 사용 -->
<img
th:src="${service.mainIcon != null and !#strings.isEmpty(service.mainIcon)} ? ${service.mainIcon} : @{/img/api_icon_default.png}"
th:alt="${service.groupName}">
<!-- 서비스별 아이콘: base64 인라인 대신 그룹 id 기준 스트리밍 엔드포인트(캐시 가능) 사용 -->
<img th:src="@{/api-services/{id}/icon(id=${service.id})}" th:alt="${service.groupName}">
</div>
</div>
</div>
@@ -560,9 +558,6 @@
</th:block>
</body>
<th:block layout:fragment="contentScript">
<!-- 메인 페이지 전용 스크립트 모듈 추가 -->
<script th:src="@{/js/main.js}"></script>
<!-- 로그인 후 처리 스크립트 -->
<script th:src="@{/js/login-success-handler.js}"></script>
<script th:inline="javascript">
@@ -164,7 +164,7 @@
<label class="dt-label">API 목록</label>
<div class="dt-api-list-box">
<div class="dt-api-item" th:each="api : ${apiKey.apiList}">
<span class="dt-api-name" th:text="${api.apiDesc}">추가된 API 1</span>
<a class="dt-api-name" th:href="@{/apis/detail(id=${api.apiId})}" th:text="${api.apiDesc}">추가된 API 1</a>
<span class="dt-api-status-badge">승인</span>
</div>
<div class="dt-empty-message" th:if="${apiKey.apiList == null or apiKey.apiList.isEmpty()}">
@@ -19,7 +19,7 @@
th:classappend="${isInvited ? 'input-readonly' : ''}" maxlength="50" th:value="${domain}"
th:readonly="${isInvited}" th:placeholder="#{portalUser.Register.domain}">
</div>
<button type="button" class="btn-action-primary md" th:classappend="${isInvited ? 'hidden' : ''}">
<button type="button" class="btn-action-primary md btn_check_email" th:classappend="${isInvited ? 'hidden' : ''}">
중복체크
</button>
</div>
@@ -31,7 +31,9 @@
<option th:each="site : ${relatedSites}" th:value="${site.url}" th:text="${site.name}"></option>
</select>
</div>
<p class="footer-contact" th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
<!-- 고객센터 연락처: PortalProperty(Portal/customer.center.contact). 값이 공백이면 미노출 -->
<p class="footer-contact" th:if="${!#strings.isEmpty(#strings.trim(customerCenterContact))}"
th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
</div>
</div>
</div>
@@ -3,6 +3,8 @@
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<body>
<th:block th:fragment="headerFragment(headerClass)">
<!-- 활성 프로파일 표시(prod 제외). 데스크톱은 로고 옆 뱃지, 모바일/태블릿은 최상단 floating 바(env-badge 는 그때 숨김) -->
<div th:if="${activeProfileBadge != null}" class="env-strip" th:text="${activeProfileBadge}">dev</div>
<!-- Global Header Container -->
<header class="global-header" th:classappend="${headerClass}">
<div class="container">
@@ -23,6 +25,7 @@
<img src="/img/logo/logo-djb.png" alt="DJBank" class="mobile-logo">
</a>
<a th:href="@{/}" class="mobile-logo-text" style="font-size: 22px; font-weight: 700; color: #212529; text-decoration: none; margin-left: 8px; vertical-align: middle;">API Portal</a>
<span th:if="${activeProfileBadge != null}" class="env-badge" th:text="${activeProfileBadge}">dev</span>
</div>
</div>
</div>
@@ -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;
}
}