테스트 코드 추가:
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를 수행할 수 없습니다.");
@@ -0,0 +1,143 @@
package com.eactive.apim.portal.apps.auth.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.user.dto.PortalOrgDTO;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
/**
* org 축·role 축은 각자 독립적으로 선택적 제한이며 AND 로 합쳐야 한다.
*
* <p>과거 OR 합성 결함: displayRoleCode 를 비워두면(=역할 제한 없음) roleMatch 가
* "로그인만 했으면" 무조건 true 가 되어, displayOrg 만 설정한 법인전용 API 가 다른 법인
* 로그인 사용자에게도 그대로 보였다 — 오직 비로그인 사용자만 걸러졌다.</p>
*/
class ApiPermissionFilterTest {
private static final String ORG_A = "org-a";
private static final String ORG_B = "org-b";
private static final String ROLE_MANAGER = "ROLE_CORP_MANAGER";
private static final String ROLE_USER = "ROLE_CORP_USER";
// ── checkApiPermissions (ApiSpecInfoDto) ──────────────────────────
@Test
void checkApiPermissions_noRestriction_visibleToAnonymous() {
ApiSpecInfoDto spec = apiSpec(null, null);
assertTrue(ApiPermissionFilter.checkApiPermissions(spec, false, null, null));
}
@Test
void checkApiPermissions_orgOnlyRestriction_hiddenFromAnonymous() {
ApiSpecInfoDto spec = apiSpec(ORG_A, null);
assertFalse(ApiPermissionFilter.checkApiPermissions(spec, false, null, null));
}
@Test
void checkApiPermissions_orgOnlyRestriction_visibleToMatchingOrg() {
ApiSpecInfoDto spec = apiSpec(ORG_A, null);
assertTrue(ApiPermissionFilter.checkApiPermissions(spec, true, ORG_A, ROLE_MANAGER));
}
@Test
void checkApiPermissions_orgOnlyRestriction_hiddenFromDifferentOrg() {
// 회귀 케이스 — 과거 OR 버그에서는 displayRoleCode 가 비어 roleMatch=true 가 되어 여기서 true 가 나왔다.
ApiSpecInfoDto spec = apiSpec(ORG_A, null);
assertFalse(ApiPermissionFilter.checkApiPermissions(spec, true, ORG_B, ROLE_MANAGER));
}
@Test
void checkApiPermissions_roleOnlyRestriction_hiddenFromNonMatchingRole() {
ApiSpecInfoDto spec = apiSpec(null, ROLE_MANAGER);
assertFalse(ApiPermissionFilter.checkApiPermissions(spec, true, ORG_A, ROLE_USER));
}
@Test
void checkApiPermissions_roleOnlyRestriction_visibleRegardlessOfOrg() {
ApiSpecInfoDto spec = apiSpec(null, ROLE_MANAGER);
assertTrue(ApiPermissionFilter.checkApiPermissions(spec, true, ORG_B, ROLE_MANAGER));
}
@Test
void checkApiPermissions_bothRestrictions_requireIntersection() {
ApiSpecInfoDto spec = apiSpec(ORG_A, ROLE_MANAGER);
assertTrue(ApiPermissionFilter.checkApiPermissions(spec, true, ORG_A, ROLE_MANAGER));
// org 는 맞지만 role 이 안 맞음 → 여전히 숨겨져야 한다(AND)
assertFalse(ApiPermissionFilter.checkApiPermissions(spec, true, ORG_A, ROLE_USER));
// role 은 맞지만 org 가 안 맞음
assertFalse(ApiPermissionFilter.checkApiPermissions(spec, true, ORG_B, ROLE_MANAGER));
}
// ── checkServicePermissions (ApiServiceDTO, 현재 호출부 없음 — 동일 규칙 유지 확인) ──
@Test
void checkServicePermissions_orgOnlyRestriction_hiddenFromDifferentOrg() {
ApiServiceDTO spec = new ApiServiceDTO();
spec.setDisplayOrg(ORG_A);
assertFalse(ApiPermissionFilter.checkServicePermissions(spec, true, ORG_B, ROLE_MANAGER));
assertTrue(ApiPermissionFilter.checkServicePermissions(spec, true, ORG_A, ROLE_MANAGER));
}
// ── filterApisByPermissions (SecurityUtil 정적 메서드 경유 통합 경로) ──
@Test
void filterApisByPermissions_orgOnlyRestriction_excludesAuthenticatedDifferentOrg() {
ApiSpecInfoDto visibleToOrgA = apiSpec(ORG_A, null);
visibleToOrgA.setApiId("API-ORG-A");
ApiSpecInfoDto publicApi = apiSpec(null, null);
publicApi.setApiId("API-PUBLIC");
List<ApiSpecInfoDto> apis = Arrays.asList(visibleToOrgA, publicApi);
PortalAuthenticatedUser user = org.mockito.Mockito.mock(PortalAuthenticatedUser.class);
PortalOrgDTO userOrg = new PortalOrgDTO();
userOrg.setId(ORG_B);
try (MockedStatic<SecurityUtil> securityUtil = mockStatic(SecurityUtil.class)) {
securityUtil.when(SecurityUtil::isAuthenticated).thenReturn(true);
securityUtil.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(user);
securityUtil.when(SecurityUtil::getUserOrg).thenReturn(userOrg);
when(user.getRoleCode()).thenReturn(com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode.ROLE_CORP_MANAGER);
List<ApiSpecInfoDto> filtered = ApiPermissionFilter.filterApisByPermissions(apis);
assertEquals(1, filtered.size());
assertEquals("API-PUBLIC", filtered.get(0).getApiId());
}
}
@Test
void filterApisByPermissions_anonymous_onlyPublicApisVisible() {
ApiSpecInfoDto restricted = apiSpec(ORG_A, null);
restricted.setApiId("API-ORG-A");
ApiSpecInfoDto publicApi = apiSpec(null, null);
publicApi.setApiId("API-PUBLIC");
try (MockedStatic<SecurityUtil> securityUtil = mockStatic(SecurityUtil.class)) {
securityUtil.when(SecurityUtil::isAuthenticated).thenReturn(false);
List<ApiSpecInfoDto> filtered =
ApiPermissionFilter.filterApisByPermissions(Arrays.asList(restricted, publicApi));
assertEquals(1, filtered.size());
assertEquals("API-PUBLIC", filtered.get(0).getApiId());
}
}
private ApiSpecInfoDto apiSpec(String displayOrg, String displayRoleCode) {
ApiSpecInfoDto dto = new ApiSpecInfoDto();
dto.setDisplayOrg(displayOrg);
dto.setDisplayRoleCode(displayRoleCode);
return dto;
}
}
@@ -0,0 +1,69 @@
package com.eactive.apim.portal.djb.apistatus.service;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.eactive.apim.portal.apispec.repository.ApiSpecInfoRepository;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Set;
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;
/**
* API 개별 현재상태(P11, 정상/점검/지연/장애) 판정이 진행 중 장애·점검·최근 이슈를
* 모두 현재 사용자의 조회가능 API 집합으로 필터링해서 조회하는지 검증한다.
* {@code apiId} 를 직접 지정해 호출하는 {@code /apistatus/current.json} 도 이 경로를 탄다 -
* 조회 권한 없는 apiId 를 직접 넣어도 실제 상태(장애 여부)가 새어나가지 않아야 한다.
*/
@ExtendWith(MockitoExtension.class)
class ApiCurrentStatusServiceTest {
@Mock private ApiStatusIncidentQueryRepository incidentQueryRepository;
@Mock private DjbApistatusIncidentApiRepository incidentApiRepository;
@Mock private ApiSpecInfoRepository apiSpecInfoRepository;
@Mock private ApiStatusUptimeService uptimeService;
@Mock private ApiStatusCatalogService catalogService;
private ApiCurrentStatusService service;
private Set<String> visibleApiIds;
@BeforeEach
void setUp() {
service = new ApiCurrentStatusService(
incidentQueryRepository, incidentApiRepository, apiSpecInfoRepository, uptimeService, catalogService);
visibleApiIds = Collections.singleton("API-PUBLIC");
when(catalogService.getVisibleApiIdsForQuery()).thenReturn(visibleApiIds);
when(catalogService.getWindowDays()).thenReturn(90);
}
@Test
void resolveStatuses_passesVisibleApiIdsToOpenMaintenanceAndOverlapQueries() {
when(incidentQueryRepository.findVisibleOpenIncidents(
eq(IncidentKind.DEGRADING), any(), eq(visibleApiIds)))
.thenReturn(Collections.emptyList());
when(incidentQueryRepository.findVisibleOngoingMaintenance(
eq(IncidentKind.MAINTENANCE), any(LocalDateTime.class), eq(visibleApiIds)))
.thenReturn(Collections.emptyList());
when(incidentQueryRepository.findVisibleOverlapping(
any(LocalDateTime.class), any(LocalDateTime.class), eq(visibleApiIds)))
.thenReturn(Collections.emptyList());
service.resolveStatuses(Collections.singleton("API1"));
verify(incidentQueryRepository).findVisibleOpenIncidents(
eq(IncidentKind.DEGRADING), any(), eq(visibleApiIds));
verify(incidentQueryRepository).findVisibleOngoingMaintenance(
eq(IncidentKind.MAINTENANCE), any(LocalDateTime.class), eq(visibleApiIds));
verify(incidentQueryRepository).findVisibleOverlapping(
any(LocalDateTime.class), any(LocalDateTime.class), eq(visibleApiIds));
}
}
@@ -0,0 +1,78 @@
package com.eactive.apim.portal.djb.apistatus.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import com.eactive.apim.gateway.data.statistics.repository.GwApiStatusRepository;
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.portalproperty.service.PortalPropertyService;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
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;
/**
* getVisibleApiIdsForQuery() 는 ApiStatusIncidentQueryRepository 의 {@code :visibleApiIds}
* 바인딩 소스다. API 그룹 공개범위(ApiSearchFacade → ApiPermissionFilter 경로)를 그대로
* 재사용하는지, 조회 가능한 API 가 하나도 없을 때 Oracle {@code IN ()} 구문 오류를 피하는
* 더미값으로 대체하는지를 검증한다.
*/
@ExtendWith(MockitoExtension.class)
class ApiStatusCatalogServiceTest {
@Mock private ApiSearchFacade apiSearchFacade;
@Mock private PortalPropertyService portalPropertyService;
@Mock private GwApiStatusRepository gwApiStatusRepository;
private ApiStatusCatalogService service;
@BeforeEach
void setUp() {
service = new ApiStatusCatalogService(apiSearchFacade, portalPropertyService, gwApiStatusRepository);
}
@Test
void getVisibleApiIdsForQuery_returnsApiIdsVisibleToCurrentUser() {
stubSearchApis(apiSpec("API1", "결제 API"), apiSpec("API2", "조회 API"));
Collection<String> visibleApiIds = service.getVisibleApiIdsForQuery();
assertEquals(2, visibleApiIds.size());
assertTrue(visibleApiIds.contains("API1"));
assertTrue(visibleApiIds.contains("API2"));
}
@Test
void getVisibleApiIdsForQuery_whenNothingVisible_returnsDummyThatMatchesNoRealApiId() {
stubSearchApis();
Collection<String> visibleApiIds = service.getVisibleApiIdsForQuery();
assertEquals(1, visibleApiIds.size());
assertFalse(visibleApiIds.contains("API1"));
assertFalse(visibleApiIds.isEmpty());
}
private void stubSearchApis(ApiSpecInfoDto... apis) {
Map<String, Object> result = new HashMap<>();
result.put("apis", Arrays.asList(apis));
when(apiSearchFacade.searchApis(any(ApiGroupSearch.class))).thenReturn(result);
}
private ApiSpecInfoDto apiSpec(String apiId, String apiName) {
ApiSpecInfoDto dto = new ApiSpecInfoDto();
dto.setApiId(apiId);
dto.setApiName(apiName);
return dto;
}
}
@@ -0,0 +1,93 @@
package com.eactive.apim.portal.djb.apistatus.service;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Set;
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.springframework.data.domain.PageRequest;
/**
* 전체 장애 이력 페이지(P8) 의 일자 인덱스(P9)·목록(P10) 조회가 현재 사용자의
* 조회가능 API 집합을 {@link ApiStatusIncidentQueryRepository} 에 그대로 전달하는지 검증한다.
* 이 테스트가 다루는 대상이 원 분석에서 지적된 "그룹 전용 API 장애가 전체 사용자에게
* 노출되던" 경로다.
*
* <p>mock 이라 JPQL 자체의 필터링 정확성은 검증 범위 밖이다 - 실제 쿼리 실행 검증은
* 별도 통합 테스트(@DataJpaTest 등)가 필요하다.</p>
*/
@ExtendWith(MockitoExtension.class)
class ApiStatusIssueHistoryServiceTest {
@Mock private ApiStatusIncidentQueryRepository incidentQueryRepository;
@Mock private ApiStatusAssembler assembler;
@Mock private ApiStatusCatalogService catalogService;
private ApiStatusIssueHistoryService service;
private Set<String> visibleApiIds;
@BeforeEach
void setUp() {
service = new ApiStatusIssueHistoryService(incidentQueryRepository, assembler, catalogService);
visibleApiIds = Collections.singleton("API-PUBLIC");
when(catalogService.getVisibleApiIdsForQuery()).thenReturn(visibleApiIds);
}
@Test
void getIssueDates_withoutApiFilter_passesVisibleApiIdsToOverlapQuery() {
when(incidentQueryRepository.findVisibleOverlapping(
any(LocalDateTime.class), any(LocalDateTime.class), eq(visibleApiIds)))
.thenReturn(Collections.emptyList());
service.getIssueDates(30, null, null);
verify(incidentQueryRepository).findVisibleOverlapping(
any(LocalDateTime.class), any(LocalDateTime.class), eq(visibleApiIds));
}
@Test
void getIssueDates_withApiFilter_apiIdItselfMustBeVisibleTooOrRowsAreExcluded() {
when(incidentQueryRepository.findVisibleOverlappingByApi(
any(LocalDateTime.class), any(LocalDateTime.class), eq("API-RESTRICTED"), eq(visibleApiIds)))
.thenReturn(Collections.emptyList());
service.getIssueDates(30, "API-RESTRICTED", null);
verify(incidentQueryRepository).findVisibleOverlappingByApi(
any(LocalDateTime.class), any(LocalDateTime.class), eq("API-RESTRICTED"), eq(visibleApiIds));
}
@Test
void getIssues_withoutApiFilter_passesVisibleApiIdsToOverlapQuery() {
when(incidentQueryRepository.findVisibleOverlapping(
any(LocalDateTime.class), any(LocalDateTime.class), eq(visibleApiIds)))
.thenReturn(Collections.emptyList());
service.getIssues(null, null, null, PageRequest.of(0, 20));
verify(incidentQueryRepository).findVisibleOverlapping(
any(LocalDateTime.class), any(LocalDateTime.class), eq(visibleApiIds));
}
@Test
void getIssues_withApiFilter_passesVisibleApiIdsToPerApiQuery() {
when(incidentQueryRepository.findVisibleOverlappingByApi(
any(LocalDateTime.class), any(LocalDateTime.class), eq("API-RESTRICTED"), eq(visibleApiIds)))
.thenReturn(Collections.emptyList());
service.getIssues(LocalDate.now(), "API-RESTRICTED", null, PageRequest.of(0, 20));
verify(incidentQueryRepository).findVisibleOverlappingByApi(
any(LocalDateTime.class), any(LocalDateTime.class), eq("API-RESTRICTED"), eq(visibleApiIds));
}
}
@@ -0,0 +1,102 @@
package com.eactive.apim.portal.djb.apistatus.service;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.Set;
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.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
/**
* API Status 메인 화면(P3 진행중 장애 / P5 점검 / P6 최근 이슈 / P7 상세) 조회가
* 현재 사용자의 조회가능 API 집합({@link ApiStatusCatalogService#getVisibleApiIdsForQuery()})을
* {@link ApiStatusIncidentQueryRepository} 에 그대로 전달하는지 검증한다.
*
* <p>mock 이라 JPQL 자체의 필터링 정확성(그룹 전용 API 의 인시던트가 실제로 걸러지는지)은
* 검증 범위 밖이다 - 실제 쿼리 실행 검증은 별도 통합 테스트가 필요하다.</p>
*/
@ExtendWith(MockitoExtension.class)
class ApiStatusQueryServiceTest {
@Mock private ApiStatusIncidentQueryRepository incidentQueryRepository;
@Mock private ApiStatusAssembler assembler;
@Mock private ApiStatusCatalogService catalogService;
private ApiStatusQueryService service;
private Set<String> visibleApiIds;
@BeforeEach
void setUp() {
service = new ApiStatusQueryService(incidentQueryRepository, assembler, catalogService);
visibleApiIds = Collections.singleton("API-PUBLIC");
when(catalogService.getVisibleApiIdsForQuery()).thenReturn(visibleApiIds);
}
@Test
void getActiveIncidents_passesVisibleApiIds() {
when(incidentQueryRepository.findVisibleOpenIncidents(
eq(IncidentKind.DEGRADING), eq(ApiStatusSupport.CLOSED_STATES), eq(visibleApiIds)))
.thenReturn(Collections.emptyList());
when(assembler.toActiveIncidents(anyList(), any(LocalDateTime.class)))
.thenReturn(Collections.emptyList());
service.getActiveIncidents();
verify(incidentQueryRepository).findVisibleOpenIncidents(
eq(IncidentKind.DEGRADING), eq(ApiStatusSupport.CLOSED_STATES), eq(visibleApiIds));
}
@Test
void getOngoingMaintenance_passesVisibleApiIds() {
when(incidentQueryRepository.findVisibleOngoingMaintenance(
eq(IncidentKind.MAINTENANCE), any(LocalDateTime.class), eq(visibleApiIds)))
.thenReturn(Collections.emptyList());
when(assembler.toMaintenanceCards(anyList())).thenReturn(Collections.emptyList());
service.getOngoingMaintenance();
verify(incidentQueryRepository).findVisibleOngoingMaintenance(
eq(IncidentKind.MAINTENANCE), any(LocalDateTime.class), eq(visibleApiIds));
}
@Test
void getRecentClosedIssues_passesVisibleApiIds() {
List<DjbApistatusIncident> empty = Collections.emptyList();
when(incidentQueryRepository.findVisibleClosedIssues(
eq(ApiStatusSupport.CLOSED_STATES), any(LocalDateTime.class), eq(visibleApiIds),
eq(PageRequest.of(0, 5))))
.thenReturn(new PageImpl<>(empty));
when(assembler.toPastIssueCards(anyList())).thenReturn(Collections.emptyList());
service.getRecentClosedIssues(0);
verify(incidentQueryRepository).findVisibleClosedIssues(
eq(ApiStatusSupport.CLOSED_STATES), any(LocalDateTime.class), eq(visibleApiIds),
eq(PageRequest.of(0, 5)));
}
@Test
void getIssueDetail_passesVisibleApiIds() {
when(incidentQueryRepository.findVisibleById(eq(1L), eq(visibleApiIds)))
.thenReturn(Optional.empty());
service.getIssueDetail(1L);
verify(incidentQueryRepository).findVisibleById(eq(1L), eq(visibleApiIds));
}
}
@@ -0,0 +1,64 @@
package com.eactive.apim.portal.djb.apistatus.service;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
import com.eactive.apim.portal.djb.apistatus.repository.ApiStatusIncidentQueryRepository;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Set;
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;
/**
* 90일 가동률 집계(P2) 가 인시던트 조회를 현재 사용자의 조회가능 API 집합으로
* 필터링하는지 검증한다. 필터링 전에는 그룹 전용 API 의 장애가 전체 가동률과
* 일자별 이슈 제목({@code DailyStatDTO.issues})에 그대로 섞여 들어갔다.
*/
@ExtendWith(MockitoExtension.class)
class ApiStatusUptimeServiceTest {
@Mock private ApiStatusIncidentQueryRepository incidentQueryRepository;
@Mock private DjbApistatusIncidentApiRepository incidentApiRepository;
@Mock private ApiStatusCatalogService catalogService;
private ApiStatusUptimeService service;
private Set<String> visibleApiIds;
@BeforeEach
void setUp() {
service = new ApiStatusUptimeService(incidentQueryRepository, incidentApiRepository, catalogService);
visibleApiIds = Collections.singleton("API-PUBLIC");
when(catalogService.getVisibleApiIdsForQuery()).thenReturn(visibleApiIds);
}
@Test
void getDailyStats_passesVisibleApiIds() {
when(incidentQueryRepository.findVisibleOverlapping(
any(LocalDateTime.class), any(LocalDateTime.class), eq(visibleApiIds)))
.thenReturn(Collections.emptyList());
service.getDailyStats(7);
verify(incidentQueryRepository).findVisibleOverlapping(
any(LocalDateTime.class), any(LocalDateTime.class), eq(visibleApiIds));
}
@Test
void getApiUptimeRatios_passesVisibleApiIds() {
when(incidentQueryRepository.findVisibleOverlapping(
any(LocalDateTime.class), any(LocalDateTime.class), eq(visibleApiIds)))
.thenReturn(Collections.emptyList());
service.getApiUptimeRatios(Collections.singleton("API1"), 7);
verify(incidentQueryRepository).findVisibleOverlapping(
any(LocalDateTime.class), any(LocalDateTime.class), eq(visibleApiIds));
}
}
@@ -23,6 +23,9 @@ import com.eactive.apim.portal.apps.community.partnership.repository.Partnership
import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.apispec.repository.ApiSpecInfoRepository;
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;
@@ -77,6 +80,10 @@ class TestCleanupServiceTest {
@Mock private TestCleanupNativeQueries nativeQueries;
@Mock private PortalUserService portalUserService;
@Mock private PasswordEncoder passwordEncoder;
@Mock private ApiSpecInfoRepository apiSpecInfoRepository;
@Mock private DjbApistatusIncidentRepository djbApistatusIncidentRepository;
@Mock private DjbApistatusIncidentApiRepository djbApistatusIncidentApiRepository;
@Mock private com.eactive.apim.portal.apps.apis.service.ApiSearchFacade apiSearchFacade;
private TestCleanupService service;
@@ -94,7 +101,9 @@ class TestCleanupServiceTest {
userLogRepository, credentialRepository, appRequestRepository, approvalService,
webhookRequestRepository, webhookRequestApiRepository,
webhookRequestEventRepository, webhookService, userInvitationRepository, fileService,
nativeQueries, portalUserService, passwordEncoder);
nativeQueries, portalUserService, passwordEncoder,
apiSpecInfoRepository, djbApistatusIncidentRepository, djbApistatusIncidentApiRepository,
apiSearchFacade);
when(environment.acceptsProfiles(Profiles.of("stage", "prod"))).thenReturn(false);
}