테스트 코드 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- API 상태 관련 서비스 테스트 클래스 신규 작성 (총 7개)
- 테스트 코드에서 `visibleApiIds` 필터 적용 확인 및 조회 로직 검증 포함
- `TestCleanupService` 확장: 그룹 제한 데이터 관련 신규 Fixture 및 Mock 추가
This commit is contained in:
Rinjae
2026-08-26 17:44:48 +09:00
parent e55b7a6bf2
commit 28017fd83c
18 changed files with 962 additions and 35 deletions
@@ -64,33 +64,36 @@ public class ApiPermissionFilter {
.collect(Collectors.toList());
}
private static boolean checkServicePermissions(ApiServiceDTO spec, boolean isAuthenticated,
/**
* org 축과 role 축은 각자 독립적으로 선택적 제한이다(비어있으면 그 축은 통과) — 둘을 AND로 합친다.
* (과거 OR 합성은 한쪽 축이 비어있으면 그 축이 무조건 true가 되어 다른 쪽 제한을 무력화시키는
* 결함이 있었다 — 예: displayOrg 만 설정하고 displayRoleCode 를 비워두면 roleMatch 가 로그인
* 여부만으로 true 가 되어 조직 제한이 사실상 적용되지 않았다.)
*/
static boolean checkServicePermissions(ApiServiceDTO spec, boolean isAuthenticated,
String org, String roleCode) {
if (!isAuthenticated) {
return (spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()) && (spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty());
}
boolean orgMatch = org != null && (spec.getDisplayOrg() == null ||
spec.getDisplayOrg().isEmpty() ||
spec.getDisplayOrg().contains(org));
boolean roleMatch = roleCode != null && (spec.getDisplayRoleCode() == null ||
spec.getDisplayRoleCode().isEmpty() ||
spec.getDisplayRoleCode().contains(roleCode));
return orgMatch || roleMatch;
boolean orgOk = spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()
|| (org != null && spec.getDisplayOrg().contains(org));
boolean roleOk = spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty()
|| (roleCode != null && spec.getDisplayRoleCode().contains(roleCode));
return orgOk && roleOk;
}
private static boolean checkApiPermissions(ApiSpecInfoDto spec, boolean isAuthenticated,
/** {@link #checkServicePermissions} 와 동일 규칙(중복 유지 — 대상 DTO 타입만 다름). */
static boolean checkApiPermissions(ApiSpecInfoDto spec, boolean isAuthenticated,
String org, String roleCode) {
if (!isAuthenticated) {
return (spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()) && (spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty());
}
boolean orgMatch = org != null && (spec.getDisplayOrg() == null ||
spec.getDisplayOrg().isEmpty() ||
spec.getDisplayOrg().contains(org));
boolean roleMatch = roleCode != null && (spec.getDisplayRoleCode() == null ||
spec.getDisplayRoleCode().isEmpty() ||
spec.getDisplayRoleCode().contains(roleCode));
return orgMatch || roleMatch;
boolean orgOk = spec.getDisplayOrg() == null || spec.getDisplayOrg().isEmpty()
|| (org != null && spec.getDisplayOrg().contains(org));
boolean roleOk = spec.getDisplayRoleCode() == null || spec.getDisplayRoleCode().isEmpty()
|| (roleCode != null && spec.getDisplayRoleCode().contains(roleCode));
return orgOk && roleOk;
}
}
@@ -27,13 +27,23 @@ import java.util.Optional;
*
* <p>공지 조건을 EXISTS 로 쓰는 이유: 예전처럼 {@code FROM ... , PortalNotice n} 으로 조인하면
* NOTICE_ID 가 없는 지연 이슈가 행 자체에서 사라진다.</p>
*
* <p>API 그룹으로 특정 법인에게만 공개된 API 의 이슈는, 그 API 를 조회할 수 없는 사용자에게는
* 이슈 자체(제목·요약·타임라인 포함)를 노출하지 않는다. 영향 API 가 하나도 없는 이슈(전사 공지성)는
* 그대로 노출한다. {@code visibleApiIds} 는 {@code ApiStatusCatalogService#getVisibleApiIdsForQuery()}
* 로 구한, 현재 사용자에게 공개된 API ID 집합이다.</p>
*/
public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatusIncident, Long> {
String VISIBLE = " i.draftYn = 'N'"
+ " AND (i.noticeId IS NULL"
+ " OR EXISTS (SELECT 1 FROM PortalNotice n"
+ " WHERE n.id = i.noticeId AND n.useYn = 'Y')) ";
+ " WHERE n.id = i.noticeId AND n.useYn = 'Y'))"
+ " AND (NOT EXISTS (SELECT 1 FROM DjbApistatusIncidentApi ia"
+ " WHERE ia.incidentId = i.incidentId)"
+ " OR EXISTS (SELECT 1 FROM DjbApistatusIncidentApi ia2"
+ " WHERE ia2.incidentId = i.incidentId"
+ " AND ia2.apiId IN :visibleApiIds)) ";
/** 종결 판정이 STATE 로 이뤄지는 종류 (장애·지연). JPQL 리터럴로 써야 해서 FQCN 을 쓴다 */
String KIND_INCIDENT = "com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind.INCIDENT";
@@ -55,7 +65,8 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
+ " AND i.state NOT IN :closedStates"
+ " ORDER BY i.startedAt DESC")
List<DjbApistatusIncident> findVisibleOpenIncidents(@Param("kinds") Collection<IncidentKind> kinds,
@Param("closedStates") Collection<IncidentState> closedStates);
@Param("closedStates") Collection<IncidentState> closedStates,
@Param("visibleApiIds") Collection<String> visibleApiIds);
/**
* 예정/진행 중 점검 (P5). 종료 시각이 없거나 아직 지나지 않은 점검.
@@ -66,7 +77,8 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
+ " AND (i.endAt IS NULL OR i.endAt >= :now)"
+ " ORDER BY i.startedAt ASC")
List<DjbApistatusIncident> findVisibleOngoingMaintenance(@Param("kind") IncidentKind kind,
@Param("now") LocalDateTime now);
@Param("now") LocalDateTime now,
@Param("visibleApiIds") Collection<String> visibleApiIds);
/**
* 종결된 이슈 (P6). 장애·지연은 종결 상태, 점검은 종료 시각 경과.
@@ -80,6 +92,7 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
+ " AND " + CLOSED_CONDITION)
Page<DjbApistatusIncident> findVisibleClosedIssues(@Param("closedStates") Collection<IncidentState> closedStates,
@Param("now") LocalDateTime now,
@Param("visibleApiIds") Collection<String> visibleApiIds,
Pageable pageable);
/**
@@ -92,7 +105,8 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
+ " AND (i.endAt IS NULL OR i.endAt >= :from)"
+ " ORDER BY i.startedAt DESC")
List<DjbApistatusIncident> findVisibleOverlapping(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
@Param("to") LocalDateTime to,
@Param("visibleApiIds") Collection<String> visibleApiIds);
/**
* 기간과 겹치고 특정 API 에 영향을 준 이슈 (P9/P10 의 apiId 필터).
@@ -106,7 +120,8 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
+ " ORDER BY i.startedAt DESC")
List<DjbApistatusIncident> findVisibleOverlappingByApi(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("apiId") String apiId);
@Param("apiId") String apiId,
@Param("visibleApiIds") Collection<String> visibleApiIds);
/**
* 공개 상세 (P7)
@@ -114,5 +129,6 @@ public interface ApiStatusIncidentQueryRepository extends Repository<DjbApistatu
@Query("SELECT i FROM DjbApistatusIncident i"
+ " WHERE " + VISIBLE
+ " AND i.incidentId = :incidentId")
Optional<DjbApistatusIncident> findVisibleById(@Param("incidentId") Long incidentId);
Optional<DjbApistatusIncident> findVisibleById(@Param("incidentId") Long incidentId,
@Param("visibleApiIds") Collection<String> visibleApiIds);
}
@@ -152,7 +152,8 @@ public class ApiCurrentStatusService {
/** 진행 중(미종결) 장애·지연 중 API 별로 가장 심각한 한 건 */
private Map<String, DjbApistatusIncident> mapOpenIncidents(Set<String> apiIds) {
List<DjbApistatusIncident> openIncidents = incidentQueryRepository
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES);
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES,
catalogService.getVisibleApiIdsForQuery());
if (openIncidents.isEmpty()) {
return Collections.emptyMap();
}
@@ -178,7 +179,8 @@ public class ApiCurrentStatusService {
/** 이미 시작된 점검 중 API 별로 가장 먼저 시작된 한 건 (예정 점검은 현재 상태가 아니므로 제외) */
private Map<String, DjbApistatusIncident> mapStartedMaintenance(Set<String> apiIds, LocalDateTime now) {
List<DjbApistatusIncident> maintenances = incidentQueryRepository
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, now).stream()
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, now,
catalogService.getVisibleApiIdsForQuery()).stream()
.filter(incident -> incident.getStartedAt() != null && !incident.getStartedAt().isAfter(now))
.collect(Collectors.toList());
if (maintenances.isEmpty()) {
@@ -208,7 +210,8 @@ public class ApiCurrentStatusService {
private Map<String, LocalDateTime> collectLastIncidentAt(Set<String> apiIds, LocalDateTime now, int windowDays) {
LocalDateTime windowStart = now.toLocalDate().minusDays(windowDays - 1L).atStartOfDay();
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay(),
catalogService.getVisibleApiIdsForQuery()).stream()
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.collect(Collectors.toList());
if (incidents.isEmpty()) {
@@ -16,6 +16,7 @@ import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
@@ -99,6 +100,19 @@ public class ApiStatusCatalogService {
return names;
}
/** IN 절 바인딩용 API ID 없을 리 없는 더미값. 실제 API ID 는 절대 이 값이 될 수 없다 */
private static final List<String> NO_VISIBLE_API = Collections.singletonList("__NO_VISIBLE_API__");
/**
* {@link ApiStatusIncidentQueryRepository} 의 {@code :visibleApiIds} 바인딩용.
* Oracle 은 빈 컬렉션으로 {@code IN ()} 을 만들면 구문 오류가 나므로, 조회 가능한 API 가
* 하나도 없을 때는 절대 매치되지 않는 더미값으로 대체한다.
*/
public Collection<String> getVisibleApiIdsForQuery() {
Map<String, String> names = getVisibleApiNames();
return names.isEmpty() ? NO_VISIBLE_API : names.keySet();
}
/**
* API 상태 모니터링 Job(eapim-admin Quartz)의 마지막 실행 시각.
*
@@ -130,9 +130,10 @@ public class ApiStatusIssueHistoryService {
private List<DjbApistatusIncident> findOverlapping(LocalDateTime from, LocalDateTime to,
String apiId, String kind) {
java.util.Collection<String> visibleApiIds = catalogService.getVisibleApiIdsForQuery();
List<DjbApistatusIncident> incidents = StringUtils.isBlank(apiId)
? incidentQueryRepository.findVisibleOverlapping(from, to)
: incidentQueryRepository.findVisibleOverlappingByApi(from, to, apiId);
? incidentQueryRepository.findVisibleOverlapping(from, to, visibleApiIds)
: incidentQueryRepository.findVisibleOverlappingByApi(from, to, apiId, visibleApiIds);
java.util.function.Predicate<DjbApistatusIncident> filter = kindFilter(kind);
if (filter == null) {
@@ -25,19 +25,22 @@ public class ApiStatusQueryService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final ApiStatusAssembler assembler;
private final ApiStatusCatalogService catalogService;
/** P3 - 진행 중 장애·지연 */
public List<ActiveIncidentDTO> getActiveIncidents() {
LocalDateTime now = ApiStatusSupport.now();
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES);
.findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES,
catalogService.getVisibleApiIdsForQuery());
return assembler.toActiveIncidents(incidents, now);
}
/** P5 - 예정/진행 중 점검 */
public List<MaintenanceCardDTO> getOngoingMaintenance() {
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, ApiStatusSupport.now());
.findVisibleOngoingMaintenance(IncidentKind.MAINTENANCE, ApiStatusSupport.now(),
catalogService.getVisibleApiIdsForQuery());
return assembler.toMaintenanceCards(incidents);
}
@@ -46,14 +49,14 @@ public class ApiStatusQueryService {
int limit = size <= 0 ? 5 : Math.min(size, 50);
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleClosedIssues(ApiStatusSupport.CLOSED_STATES, ApiStatusSupport.now(),
PageRequest.of(0, limit))
catalogService.getVisibleApiIdsForQuery(), PageRequest.of(0, limit))
.getContent();
return assembler.toPastIssueCards(incidents);
}
/** P7 - 이슈 공개 상세 */
public Optional<PastIssueCardDTO> getIssueDetail(Long incidentId) {
return incidentQueryRepository.findVisibleById(incidentId)
return incidentQueryRepository.findVisibleById(incidentId, catalogService.getVisibleApiIdsForQuery())
.map(incident -> assembler.toPastIssueCards(java.util.Collections.singletonList(incident)).get(0));
}
}
@@ -34,6 +34,7 @@ public class ApiStatusUptimeService {
private final ApiStatusIncidentQueryRepository incidentQueryRepository;
private final DjbApistatusIncidentApiRepository incidentApiRepository;
private final ApiStatusCatalogService catalogService;
/** P2 - 90일 가동률 */
public List<DailyStatDTO> getDailyStats(int days) {
@@ -43,7 +44,8 @@ public class ApiStatusUptimeService {
LocalDate from = today.minusDays(windowDays - 1L);
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOverlapping(from.atStartOfDay(), today.plusDays(1).atStartOfDay());
.findVisibleOverlapping(from.atStartOfDay(), today.plusDays(1).atStartOfDay(),
catalogService.getVisibleApiIdsForQuery());
List<DailyStatDTO> result = new ArrayList<>();
for (int offset = 0; offset < windowDays; offset++) {
@@ -66,7 +68,8 @@ public class ApiStatusUptimeService {
// 장애와 지연 모두 서비스 저하이므로 가동률에서 차감한다. 점검은 계획된 작업이라 제외.
List<DjbApistatusIncident> incidents = incidentQueryRepository
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay()).stream()
.findVisibleOverlapping(windowStart, now.toLocalDate().plusDays(1).atStartOfDay(),
catalogService.getVisibleApiIdsForQuery()).stream()
.filter(incident -> incident.getKind() != null && incident.getKind().isDegrading())
.collect(Collectors.toList());
@@ -44,7 +44,7 @@ import java.util.stream.Collectors;
*/
@Slf4j
@Controller
@RequestMapping("/djb/notitest")
@RequestMapping("//notitest")
@RequiredArgsConstructor
public class NotiTestController {
@@ -2,6 +2,7 @@ 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.GroupRestrictedIncidentFixture;
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;
@@ -310,6 +311,105 @@ public class TestCleanupInternalController {
return ResponseEntity.ok(body);
}
/**
* API Status 그룹공개(법인전용) 필터 검증용 "다른 법인" 계정을 준비한다. 지정 사업자번호의
* 법인이 없으면 새로 만들고(승인 절차 없이 즉시 로그인 가능), 있으면 매니저 계정을 재소속·
* 비밀번호 재설정으로 되돌린다(heal). 정리는 기존 {@code /internal/test-cleanup/org} 를
* 그대로 재사용한다(같은 compRegNo).
*/
@PostMapping("/secondary-org")
public ResponseEntity<Map<String, Object>> ensureSecondaryOrg(
@RequestParam String compRegNo, @RequestParam String orgName, @RequestParam String managerEmail,
@RequestParam String password, @RequestParam String mobile, @RequestParam String userName,
HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(compRegNo) || StringUtils.isBlank(orgName) || StringUtils.isBlank(managerEmail)
|| StringUtils.isBlank(password) || StringUtils.isBlank(mobile) || StringUtils.isBlank(userName)) {
return badRequest("compRegNo, orgName, managerEmail, password, mobile, userName 은 모두 필수입니다.");
}
TestCleanupResult result =
testCleanupService.ensureSecondaryTestOrg(compRegNo, orgName, managerEmail, password, mobile, userName);
log.info("테스트 정리(secondary-org) 실행 - compRegNo: {}, managerEmail: {}, from: {}",
compRegNo, managerEmail, request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("compRegNo", compRegNo);
body.put("managerEmail", managerEmail);
body.put("orgId", result.getTargetId());
body.put("orgCreated", result.getDeletedCounts().getOrDefault("PTL_ORG_CREATED", 0L) > 0);
body.put("userCreated", result.getDeletedCounts().getOrDefault("PTL_USER_CREATED", 0L) > 0);
return ResponseEntity.ok(body);
}
/**
* API Status 그룹공개 필터 검증용 픽스처를 만든다 — {@code managerEmail} 소속 법인으로 현재
* 게시된 API 중 하나(동적 선택)를 제한하고, 그 API 에 영향을 준 종결 인시던트 1건을 만든다.
*/
@PostMapping("/group-restricted-incident")
public ResponseEntity<Map<String, Object>> ensureGroupRestrictedIncident(
@RequestParam String managerEmail, @RequestParam String subject,
@RequestParam(required = false) String summary, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(managerEmail) || StringUtils.isBlank(subject)) {
return badRequest("managerEmail 과 subject 는 필수입니다.");
}
GroupRestrictedIncidentFixture fixture;
try {
fixture = testCleanupService.ensureGroupRestrictedIncident(managerEmail, subject, summary);
} catch (IllegalArgumentException | IllegalStateException e) {
return badRequest(e.getMessage());
}
log.info("테스트 정리(group-restricted-incident) 실행 - managerEmail: {}, apiId: {}, incidentId: {}, from: {}",
managerEmail, fixture.getApiId(), fixture.getIncidentId(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("apiId", fixture.getApiId());
body.put("apiName", fixture.getApiName());
body.put("orgId", fixture.getOrgId());
body.put("incidentId", fixture.getIncidentId());
body.put("previousDisplayOrg", fixture.getPreviousDisplayOrg());
body.put("previousDisplayRoleCode", fixture.getPreviousDisplayRoleCode());
return ResponseEntity.ok(body);
}
/**
* {@code /group-restricted-incident} 로 만든 픽스처를 원복한다 — 정확한 apiId/incidentId 를
* 요구해 와일드카드 삭제가 없다.
*/
@PostMapping("/group-restricted-incident/revert")
public ResponseEntity<Map<String, Object>> revertGroupRestrictedIncident(
@RequestParam String apiId, @RequestParam Long incidentId,
@RequestParam(required = false) String previousDisplayOrg,
@RequestParam(required = false) String previousDisplayRoleCode, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(apiId) || incidentId == null) {
return badRequest("apiId 와 incidentId 는 필수입니다.");
}
TestCleanupResult result = testCleanupService.revertGroupRestrictedIncident(
apiId, incidentId, previousDisplayOrg, previousDisplayRoleCode);
log.info("테스트 정리(group-restricted-incident revert) 실행 - apiId: {}, incidentId: {}, found: {}, from: {}",
apiId, incidentId, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("apiId", apiId);
body.put("incidentId", incidentId);
body.put("incidentFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 휴대폰 번호로 남아 있는 초대 레코드를 삭제한다. 1020 재실행 전 초대중 중복을 정리하는 용도다.
*/
@@ -0,0 +1,19 @@
package com.eactive.apim.portal.djb.testcleanup.service;
import lombok.Value;
/**
* {@link TestCleanupService#ensureGroupRestrictedIncident} 결과. {@link TestCleanupResult}는
* "대상 존재 여부 + 테이블별 삭제건수"(Long) 형태라 이 작업의 응답(선택된 API/법인/원래 공개범위)엔
* 맞지 않아 전용 타입을 둔다. {@code previousDisplayOrg}/{@code previousDisplayRoleCode}는
* 원복({@link TestCleanupService#revertGroupRestrictedIncident})에 그대로 되돌려줘야 한다.
*/
@Value
public class GroupRestrictedIncidentFixture {
String apiId;
String apiName;
String orgId;
Long incidentId;
String previousDisplayOrg;
String previousDisplayRoleCode;
}
@@ -8,9 +8,20 @@ import com.eactive.apim.portal.approval.statemachine.RequestedState;
import com.eactive.apim.portal.apps.approval.service.ApprovalService;
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.apispec.repository.ApiSpecInfoRepository;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
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;
@@ -22,6 +33,7 @@ import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
@@ -32,11 +44,14 @@ import com.eactive.apim.portal.portaluser.repository.UserRoleHistoryRepository;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.user.repository.UserLogRepository;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
@@ -81,6 +96,22 @@ public class TestCleanupService {
private final TestCleanupNativeQueries nativeQueries;
private final PortalUserService portalUserService;
private final PasswordEncoder passwordEncoder;
private final ApiSpecInfoRepository apiSpecInfoRepository;
private final DjbApistatusIncidentRepository djbApistatusIncidentRepository;
private final DjbApistatusIncidentApiRepository djbApistatusIncidentApiRepository;
private final ApiSearchFacade apiSearchFacade;
/** notice-incident.spec.ts 기본 영향 API — 동시 실행 충돌을 피하려 자동선택에서 제외한다 */
private static final String RESERVED_DEFAULT_API_ID = "ANSTOSOBP00001S2";
/**
* 이 내부 API 는 Spring Security 인증 없이(IP+토큰 가드만으로) 호출되므로
* {@code AuditorAwareImpl}(SecurityUtil.getPortalAuthenticatedUser() 기반)이 항상
* empty 를 반환해 {@code @CreatedBy} 자동 채움이 동작하지 않는다. PTL_ORG/PTL_USER 는
* created_by 가 nullable 이라 문제없이 넘어가지만, DJB_APISTATUS_INCIDENT(_API) 는
* NOT NULL 이라 명시적으로 채워야 한다(ORA-01400).
*/
private static final String TEST_FIXTURE_CREATED_BY = "PLAYWRIGHT_TEST";
/**
* 사업자등록번호로 법인을 찾아, 소속 계정 전원 + org 소유 CREDENTIAL/WEBHOOK + 법인 자체를 하드 삭제한다.
@@ -416,6 +447,182 @@ public class TestCleanupService {
return result;
}
/**
* API Status 그룹공개(법인전용) 필터를 Playwright 로 검증하려면 "다른 법인" 로그인 계정이
* 하나 더 필요하다. 지정 사업자번호(compRegNo)의 법인이 없으면 새로 만들고(관리자 승인 절차
* 없이 바로 ACTIVE/COMPLETED — {@link PortalUserService#createUserWithOrg} 가 신규 법인가입
* 경로에서 쓰는 것과 같은 즉시활성 경로를 재사용), 있으면 매니저 계정을 재소속·비밀번호
* 재설정으로 로그인 가능한 상태로 되돌린다(heal). 정리는 새 API 를 만들 필요 없이 기존
* {@link #deleteOrgCascade(String)}(compRegNo)를 그대로 재사용한다.
*/
public TestCleanupResult ensureSecondaryTestOrg(
String compRegNo, String orgName, String managerEmail, String password, String mobile, String userName) {
assertNonProdProfile();
Optional<PortalOrg> existingOrg = portalOrgRepository.findByCompRegNo(compRegNo);
boolean orgCreated = !existingOrg.isPresent();
PortalOrg org;
if (existingOrg.isPresent()) {
org = existingOrg.get();
} else {
PortalOrg created = new PortalOrg();
created.setId(UUID.randomUUID().toString());
created.setCompRegNo(compRegNo);
created.setOrgName(orgName);
created.setOrgStatus(PortalOrgEnums.OrgStatus.ACTIVE);
created.setApprovalStatus(PortalOrgEnums.ApprovalStatus.COMPLETED);
org = portalOrgRepository.save(created);
}
Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(managerEmail);
boolean userCreated;
PortalUser user;
if (existingUser.isPresent()) {
user = existingUser.get();
user.setPortalOrg(org);
user.setRoleCode(RoleCode.ROLE_CORP_MANAGER);
user.setUserStatus(PortalUserEnums.UserStatus.ACTIVE);
user.setApprovalStatus(PortalUserEnums.ApprovalStatus.COMPLETED);
user.setAccountLockYn("N");
user.setLoginFailureCount(0);
user.setPasswordHash(passwordEncoder.encode(password));
user.setPasswordChangeDate(LocalDateTime.now());
String normalizedMobile = PhoneNumberUtil.normalize(mobile);
if (normalizedMobile != null) {
user.setMobileNumber(normalizedMobile);
}
portalUserRepository.save(user);
userCreated = false;
} else {
PortalUserRegistrationDTO dto = new PortalUserRegistrationDTO();
dto.setLoginId(managerEmail);
dto.setUserName(userName);
dto.setPassword(password);
dto.setMobileNumber(mobile);
// emailVerified=true — 가입 즉시 ACTIVE/COMPLETED (승인 대기 없음), registrationType="corporate" → ROLE_CORP_MANAGER
user = portalUserService.createUserWithOrg(dto, org, "corporate", true);
userCreated = true;
}
TestCleanupResult result = TestCleanupResult.found(org.getId());
result.put("PTL_ORG_CREATED", orgCreated ? 1L : 0L);
result.put("PTL_USER_CREATED", userCreated ? 1L : 0L);
log.info("테스트 정리 - 보조 법인 준비 완료: compRegNo={}, orgId={}, orgCreated={}, managerEmail={}, userCreated={}",
compRegNo, org.getId(), orgCreated, managerEmail, userCreated);
return result;
}
/**
* API Status 그룹공개 필터 Playwright 검증용 픽스처를 만든다. {@code managerEmail} 로 제한 대상
* 법인을 역산하므로(환경마다 org id 를 몰라도 됨), 이 환경에 실제로 게시된(displayYn=Y) API 중
* 하나를 동적으로 골라 그 API 의 {@code displayOrg} 를 해당 법인으로 제한한 뒤, 그 API 에 영향을
* 준 것으로 기록된 종결(RESOLVED) 인시던트 1건을 만든다 — API Status 메인(P6)과 전체 이력
* (P9/P10) 양쪽에서 동시에 검증 가능하도록 최근 시각으로 둔다.
*
* <p>특정 api_id 를 하드코딩하지 않는다 — 환경(dev/local/stage)마다 카탈로그가 다르기 때문이다.
* {@code notice-incident.spec.ts} 가 기본으로 쓰는 API 는 동시실행 충돌을 피하려 후보에서
* 제외한다(있으면).</p>
*
* <p>후보는 {@code PTL_API_SPEC_INFO.DISPLAY_YN='Y'} 만으로 고르지 않는다 — 포털 카탈로그
* 노출은 그것만으로 결정되지 않고 AGWAPP.API_GROUP_API 매핑까지 있어야 한다
* ({@link ApiStatusCatalogService#getSelectableApis()} 주석 참고). 그 조건까지 반영된 실제
* 카탈로그 조회 경로({@link ApiSearchFacade#searchApis})를 그대로 재사용해야, 뽑은 API 가
* {@code ApiStatusCatalogService#getVisibleApiIdsForQuery()}(= 이 검증이 실제로 통제하는
* visibleApiIds)에도 반드시 잡힌다. 이 메서드는 비인증 컨텍스트에서 호출되므로
* {@code searchApis} 결과는 이미 "공개범위 제한 없음"만 걸러져 있다(비로그인 판정 —
* {@code ApiPermissionFilter.checkApiPermissions} 의 !isAuthenticated 분기).</p>
*/
@SuppressWarnings("unchecked")
public GroupRestrictedIncidentFixture ensureGroupRestrictedIncident(
String managerEmail, String subject, String summary) {
assertNonProdProfile();
PortalUser manager = portalUserRepository.findPortalUserByEmailAddr(managerEmail)
.orElseThrow(() -> new IllegalArgumentException("관리자 계정을 찾을 수 없습니다: " + managerEmail));
PortalOrg org = manager.getPortalOrg();
if (org == null) {
throw new IllegalArgumentException("관리자 계정이 법인에 소속되어 있지 않습니다: " + managerEmail);
}
Object apisObj = apiSearchFacade.searchApis(new ApiGroupSearch()).get("apis");
List<ApiSpecInfoDto> catalog = apisObj instanceof List ? (List<ApiSpecInfoDto>) apisObj : Collections.emptyList();
if (catalog.isEmpty()) {
throw new IllegalStateException(
"이 환경에 공개범위 제한 없이 카탈로그에 노출된 API가 하나도 없습니다. "
+ "API 그룹에 편성된 API Spec을 하나 이상 게시해야 이 검증을 진행할 수 있습니다.");
}
String candidateApiId = catalog.stream()
.map(ApiSpecInfoDto::getApiId)
.filter(StringUtils::isNotBlank)
.filter(apiId -> !RESERVED_DEFAULT_API_ID.equals(apiId))
.findFirst()
.orElseGet(() -> catalog.get(0).getApiId());
ApiSpecInfo target = apiSpecInfoRepository.findById(candidateApiId)
.orElseThrow(() -> new IllegalStateException("카탈로그 API 스펙을 찾지 못했습니다: " + candidateApiId));
String previousDisplayOrg = target.getDisplayOrg();
String previousDisplayRoleCode = target.getDisplayRoleCode();
target.setDisplayOrg(org.getId());
apiSpecInfoRepository.save(target);
LocalDateTime now = LocalDateTime.now();
DjbApistatusIncident incident = new DjbApistatusIncident();
incident.setKind(IncidentKind.INCIDENT);
incident.setState(IncidentState.RESOLVED);
incident.setTitle(subject);
incident.setSummary(StringUtils.defaultString(summary));
incident.setStartedAt(now.minusHours(1));
incident.setEndAt(now);
incident.setDraftYn("N");
incident.setCreatedBy(TEST_FIXTURE_CREATED_BY);
incident = djbApistatusIncidentRepository.save(incident);
DjbApistatusIncidentApi incidentApi = new DjbApistatusIncidentApi();
incidentApi.setIncidentId(incident.getIncidentId());
incidentApi.setApiId(target.getApiId());
incidentApi.setApiName(target.getApiName());
incidentApi.setCreatedBy(TEST_FIXTURE_CREATED_BY);
djbApistatusIncidentApiRepository.save(incidentApi);
log.info("테스트 정리 - 그룹전용 인시던트 준비 완료: apiId={}, orgId={}, incidentId={}, previousDisplayOrg={}",
target.getApiId(), org.getId(), incident.getIncidentId(), previousDisplayOrg);
return new GroupRestrictedIncidentFixture(target.getApiId(), target.getApiName(), org.getId(),
incident.getIncidentId(), previousDisplayOrg, previousDisplayRoleCode);
}
/**
* {@link #ensureGroupRestrictedIncident} 로 만든 픽스처를 원복한다 — 인시던트/영향API 행 삭제 +
* 대상 API 의 공개범위를 호출자가 넘긴 원래값으로 되돌린다. 정확한 {@code apiId}/{@code incidentId}
* 를 요구해 와일드카드 삭제가 없다.
*/
public TestCleanupResult revertGroupRestrictedIncident(
String apiId, Long incidentId, String previousDisplayOrg, String previousDisplayRoleCode) {
assertNonProdProfile();
Optional<DjbApistatusIncident> incidentOpt = djbApistatusIncidentRepository.findById(incidentId);
if (!incidentOpt.isPresent()) {
return TestCleanupResult.notFound();
}
djbApistatusIncidentApiRepository.deleteByIncidentId(incidentId);
djbApistatusIncidentRepository.deleteById(incidentId);
Optional<ApiSpecInfo> specOpt = apiSpecInfoRepository.findById(apiId);
if (specOpt.isPresent()) {
ApiSpecInfo spec = specOpt.get();
spec.setDisplayOrg(previousDisplayOrg);
spec.setDisplayRoleCode(previousDisplayRoleCode);
apiSpecInfoRepository.save(spec);
}
TestCleanupResult result = TestCleanupResult.found(String.valueOf(incidentId));
result.put("DJB_APISTATUS_INCIDENT", 1L);
result.put("DJB_APISTATUS_INCIDENT_API", 1L);
log.info("테스트 정리 - 그룹전용 인시던트 원복 완료: apiId={}, incidentId={}", apiId, incidentId);
return result;
}
private void assertNonProdProfile() {
if (environment.acceptsProfiles(Profiles.of("stage", "prod"))) {
throw new IllegalStateException("stage/prod 환경에서는 테스트 정리 API를 수행할 수 없습니다.");