diff --git a/src/main/java/com/eactive/apim/portal/apps/auth/service/ApiPermissionFilter.java b/src/main/java/com/eactive/apim/portal/apps/auth/service/ApiPermissionFilter.java index 3f3066d..9ae30e9 100644 --- a/src/main/java/com/eactive/apim/portal/apps/auth/service/ApiPermissionFilter.java +++ b/src/main/java/com/eactive/apim/portal/apps/auth/service/ApiPermissionFilter.java @@ -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; } } diff --git a/src/main/java/com/eactive/apim/portal/djb/apistatus/repository/ApiStatusIncidentQueryRepository.java b/src/main/java/com/eactive/apim/portal/djb/apistatus/repository/ApiStatusIncidentQueryRepository.java index bc47090..79189a5 100644 --- a/src/main/java/com/eactive/apim/portal/djb/apistatus/repository/ApiStatusIncidentQueryRepository.java +++ b/src/main/java/com/eactive/apim/portal/djb/apistatus/repository/ApiStatusIncidentQueryRepository.java @@ -27,13 +27,23 @@ import java.util.Optional; * *

공지 조건을 EXISTS 로 쓰는 이유: 예전처럼 {@code FROM ... , PortalNotice n} 으로 조인하면 * NOTICE_ID 가 없는 지연 이슈가 행 자체에서 사라진다.

+ * + *

API 그룹으로 특정 법인에게만 공개된 API 의 이슈는, 그 API 를 조회할 수 없는 사용자에게는 + * 이슈 자체(제목·요약·타임라인 포함)를 노출하지 않는다. 영향 API 가 하나도 없는 이슈(전사 공지성)는 + * 그대로 노출한다. {@code visibleApiIds} 는 {@code ApiStatusCatalogService#getVisibleApiIdsForQuery()} + * 로 구한, 현재 사용자에게 공개된 API ID 집합이다.

*/ public interface ApiStatusIncidentQueryRepository extends Repository { 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 findVisibleOpenIncidents(@Param("kinds") Collection kinds, - @Param("closedStates") Collection closedStates); + @Param("closedStates") Collection closedStates, + @Param("visibleApiIds") Collection visibleApiIds); /** * 예정/진행 중 점검 (P5). 종료 시각이 없거나 아직 지나지 않은 점검. @@ -66,7 +77,8 @@ public interface ApiStatusIncidentQueryRepository extends Repository= :now)" + " ORDER BY i.startedAt ASC") List findVisibleOngoingMaintenance(@Param("kind") IncidentKind kind, - @Param("now") LocalDateTime now); + @Param("now") LocalDateTime now, + @Param("visibleApiIds") Collection visibleApiIds); /** * 종결된 이슈 (P6). 장애·지연은 종결 상태, 점검은 종료 시각 경과. @@ -80,6 +92,7 @@ public interface ApiStatusIncidentQueryRepository extends Repository findVisibleClosedIssues(@Param("closedStates") Collection closedStates, @Param("now") LocalDateTime now, + @Param("visibleApiIds") Collection visibleApiIds, Pageable pageable); /** @@ -92,7 +105,8 @@ public interface ApiStatusIncidentQueryRepository extends Repository= :from)" + " ORDER BY i.startedAt DESC") List findVisibleOverlapping(@Param("from") LocalDateTime from, - @Param("to") LocalDateTime to); + @Param("to") LocalDateTime to, + @Param("visibleApiIds") Collection visibleApiIds); /** * 기간과 겹치고 특정 API 에 영향을 준 이슈 (P9/P10 의 apiId 필터). @@ -106,7 +120,8 @@ public interface ApiStatusIncidentQueryRepository extends Repository findVisibleOverlappingByApi(@Param("from") LocalDateTime from, @Param("to") LocalDateTime to, - @Param("apiId") String apiId); + @Param("apiId") String apiId, + @Param("visibleApiIds") Collection visibleApiIds); /** * 공개 상세 (P7) @@ -114,5 +129,6 @@ public interface ApiStatusIncidentQueryRepository extends Repository findVisibleById(@Param("incidentId") Long incidentId); + Optional findVisibleById(@Param("incidentId") Long incidentId, + @Param("visibleApiIds") Collection visibleApiIds); } diff --git a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiCurrentStatusService.java b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiCurrentStatusService.java index 6293cf0..19ba3ba 100644 --- a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiCurrentStatusService.java +++ b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiCurrentStatusService.java @@ -152,7 +152,8 @@ public class ApiCurrentStatusService { /** 진행 중(미종결) 장애·지연 중 API 별로 가장 심각한 한 건 */ private Map mapOpenIncidents(Set apiIds) { List 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 mapStartedMaintenance(Set apiIds, LocalDateTime now) { List 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 collectLastIncidentAt(Set apiIds, LocalDateTime now, int windowDays) { LocalDateTime windowStart = now.toLocalDate().minusDays(windowDays - 1L).atStartOfDay(); List 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()) { diff --git a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusCatalogService.java b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusCatalogService.java index cb9bc8b..d3f109f 100644 --- a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusCatalogService.java +++ b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusCatalogService.java @@ -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 NO_VISIBLE_API = Collections.singletonList("__NO_VISIBLE_API__"); + + /** + * {@link ApiStatusIncidentQueryRepository} 의 {@code :visibleApiIds} 바인딩용. + * Oracle 은 빈 컬렉션으로 {@code IN ()} 을 만들면 구문 오류가 나므로, 조회 가능한 API 가 + * 하나도 없을 때는 절대 매치되지 않는 더미값으로 대체한다. + */ + public Collection getVisibleApiIdsForQuery() { + Map names = getVisibleApiNames(); + return names.isEmpty() ? NO_VISIBLE_API : names.keySet(); + } + /** * API 상태 모니터링 Job(eapim-admin Quartz)의 마지막 실행 시각. * diff --git a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusIssueHistoryService.java b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusIssueHistoryService.java index 3c5eb8e..e168075 100644 --- a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusIssueHistoryService.java +++ b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusIssueHistoryService.java @@ -130,9 +130,10 @@ public class ApiStatusIssueHistoryService { private List findOverlapping(LocalDateTime from, LocalDateTime to, String apiId, String kind) { + java.util.Collection visibleApiIds = catalogService.getVisibleApiIdsForQuery(); List 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 filter = kindFilter(kind); if (filter == null) { diff --git a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusQueryService.java b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusQueryService.java index 7ac0314..46a6183 100644 --- a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusQueryService.java +++ b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusQueryService.java @@ -25,19 +25,22 @@ public class ApiStatusQueryService { private final ApiStatusIncidentQueryRepository incidentQueryRepository; private final ApiStatusAssembler assembler; + private final ApiStatusCatalogService catalogService; /** P3 - 진행 중 장애·지연 */ public List getActiveIncidents() { LocalDateTime now = ApiStatusSupport.now(); List incidents = incidentQueryRepository - .findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES); + .findVisibleOpenIncidents(IncidentKind.DEGRADING, ApiStatusSupport.CLOSED_STATES, + catalogService.getVisibleApiIdsForQuery()); return assembler.toActiveIncidents(incidents, now); } /** P5 - 예정/진행 중 점검 */ public List getOngoingMaintenance() { List 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 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 getIssueDetail(Long incidentId) { - return incidentQueryRepository.findVisibleById(incidentId) + return incidentQueryRepository.findVisibleById(incidentId, catalogService.getVisibleApiIdsForQuery()) .map(incident -> assembler.toPastIssueCards(java.util.Collections.singletonList(incident)).get(0)); } } diff --git a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusUptimeService.java b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusUptimeService.java index c51cf68..85c5e35 100644 --- a/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusUptimeService.java +++ b/src/main/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusUptimeService.java @@ -34,6 +34,7 @@ public class ApiStatusUptimeService { private final ApiStatusIncidentQueryRepository incidentQueryRepository; private final DjbApistatusIncidentApiRepository incidentApiRepository; + private final ApiStatusCatalogService catalogService; /** P2 - 90일 가동률 */ public List getDailyStats(int days) { @@ -43,7 +44,8 @@ public class ApiStatusUptimeService { LocalDate from = today.minusDays(windowDays - 1L); List incidents = incidentQueryRepository - .findVisibleOverlapping(from.atStartOfDay(), today.plusDays(1).atStartOfDay()); + .findVisibleOverlapping(from.atStartOfDay(), today.plusDays(1).atStartOfDay(), + catalogService.getVisibleApiIdsForQuery()); List result = new ArrayList<>(); for (int offset = 0; offset < windowDays; offset++) { @@ -66,7 +68,8 @@ public class ApiStatusUptimeService { // 장애와 지연 모두 서비스 저하이므로 가동률에서 차감한다. 점검은 계획된 작업이라 제외. List 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()); diff --git a/src/main/java/com/eactive/apim/portal/djb/notitest/controller/NotiTestController.java b/src/main/java/com/eactive/apim/portal/djb/notitest/controller/NotiTestController.java index 7db5f21..b9a8378 100644 --- a/src/main/java/com/eactive/apim/portal/djb/notitest/controller/NotiTestController.java +++ b/src/main/java/com/eactive/apim/portal/djb/notitest/controller/NotiTestController.java @@ -44,7 +44,7 @@ import java.util.stream.Collectors; */ @Slf4j @Controller -@RequestMapping("/djb/notitest") +@RequestMapping("//notitest") @RequiredArgsConstructor public class NotiTestController { diff --git a/src/main/java/com/eactive/apim/portal/djb/testcleanup/controller/TestCleanupInternalController.java b/src/main/java/com/eactive/apim/portal/djb/testcleanup/controller/TestCleanupInternalController.java index c392cff..322a730 100644 --- a/src/main/java/com/eactive/apim/portal/djb/testcleanup/controller/TestCleanupInternalController.java +++ b/src/main/java/com/eactive/apim/portal/djb/testcleanup/controller/TestCleanupInternalController.java @@ -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> ensureSecondaryOrg( + @RequestParam String compRegNo, @RequestParam String orgName, @RequestParam String managerEmail, + @RequestParam String password, @RequestParam String mobile, @RequestParam String userName, + HttpServletRequest request) { + ResponseEntity> 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 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> ensureGroupRestrictedIncident( + @RequestParam String managerEmail, @RequestParam String subject, + @RequestParam(required = false) String summary, HttpServletRequest request) { + ResponseEntity> 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 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> revertGroupRestrictedIncident( + @RequestParam String apiId, @RequestParam Long incidentId, + @RequestParam(required = false) String previousDisplayOrg, + @RequestParam(required = false) String previousDisplayRoleCode, HttpServletRequest request) { + ResponseEntity> 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 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 재실행 전 초대중 중복을 정리하는 용도다. */ diff --git a/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/GroupRestrictedIncidentFixture.java b/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/GroupRestrictedIncidentFixture.java new file mode 100644 index 0000000..0c09a6d --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/GroupRestrictedIncidentFixture.java @@ -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; +} diff --git a/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupService.java b/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupService.java index a1b4a9e..571bbb6 100644 --- a/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupService.java +++ b/src/main/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupService.java @@ -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 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 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) 양쪽에서 동시에 검증 가능하도록 최근 시각으로 둔다. + * + *

특정 api_id 를 하드코딩하지 않는다 — 환경(dev/local/stage)마다 카탈로그가 다르기 때문이다. + * {@code notice-incident.spec.ts} 가 기본으로 쓰는 API 는 동시실행 충돌을 피하려 후보에서 + * 제외한다(있으면).

+ * + *

후보는 {@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 분기).

+ */ + @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 catalog = apisObj instanceof List ? (List) 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 incidentOpt = djbApistatusIncidentRepository.findById(incidentId); + if (!incidentOpt.isPresent()) { + return TestCleanupResult.notFound(); + } + + djbApistatusIncidentApiRepository.deleteByIncidentId(incidentId); + djbApistatusIncidentRepository.deleteById(incidentId); + + Optional 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를 수행할 수 없습니다."); diff --git a/src/test/java/com/eactive/apim/portal/apps/auth/service/ApiPermissionFilterTest.java b/src/test/java/com/eactive/apim/portal/apps/auth/service/ApiPermissionFilterTest.java new file mode 100644 index 0000000..6ab60ca --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/apps/auth/service/ApiPermissionFilterTest.java @@ -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 로 합쳐야 한다. + * + *

과거 OR 합성 결함: displayRoleCode 를 비워두면(=역할 제한 없음) roleMatch 가 + * "로그인만 했으면" 무조건 true 가 되어, displayOrg 만 설정한 법인전용 API 가 다른 법인 + * 로그인 사용자에게도 그대로 보였다 — 오직 비로그인 사용자만 걸러졌다.

+ */ +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 apis = Arrays.asList(visibleToOrgA, publicApi); + + PortalAuthenticatedUser user = org.mockito.Mockito.mock(PortalAuthenticatedUser.class); + PortalOrgDTO userOrg = new PortalOrgDTO(); + userOrg.setId(ORG_B); + + try (MockedStatic 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 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 = mockStatic(SecurityUtil.class)) { + securityUtil.when(SecurityUtil::isAuthenticated).thenReturn(false); + + List 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; + } +} diff --git a/src/test/java/com/eactive/apim/portal/djb/apistatus/service/ApiCurrentStatusServiceTest.java b/src/test/java/com/eactive/apim/portal/djb/apistatus/service/ApiCurrentStatusServiceTest.java new file mode 100644 index 0000000..34c5bee --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/djb/apistatus/service/ApiCurrentStatusServiceTest.java @@ -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 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)); + } +} diff --git a/src/test/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusCatalogServiceTest.java b/src/test/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusCatalogServiceTest.java new file mode 100644 index 0000000..33ec855 --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusCatalogServiceTest.java @@ -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 visibleApiIds = service.getVisibleApiIdsForQuery(); + + assertEquals(2, visibleApiIds.size()); + assertTrue(visibleApiIds.contains("API1")); + assertTrue(visibleApiIds.contains("API2")); + } + + @Test + void getVisibleApiIdsForQuery_whenNothingVisible_returnsDummyThatMatchesNoRealApiId() { + stubSearchApis(); + + Collection visibleApiIds = service.getVisibleApiIdsForQuery(); + + assertEquals(1, visibleApiIds.size()); + assertFalse(visibleApiIds.contains("API1")); + assertFalse(visibleApiIds.isEmpty()); + } + + private void stubSearchApis(ApiSpecInfoDto... apis) { + Map 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; + } +} diff --git a/src/test/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusIssueHistoryServiceTest.java b/src/test/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusIssueHistoryServiceTest.java new file mode 100644 index 0000000..bb888a1 --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusIssueHistoryServiceTest.java @@ -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 장애가 전체 사용자에게 + * 노출되던" 경로다. + * + *

mock 이라 JPQL 자체의 필터링 정확성은 검증 범위 밖이다 - 실제 쿼리 실행 검증은 + * 별도 통합 테스트(@DataJpaTest 등)가 필요하다.

+ */ +@ExtendWith(MockitoExtension.class) +class ApiStatusIssueHistoryServiceTest { + + @Mock private ApiStatusIncidentQueryRepository incidentQueryRepository; + @Mock private ApiStatusAssembler assembler; + @Mock private ApiStatusCatalogService catalogService; + + private ApiStatusIssueHistoryService service; + private Set 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)); + } +} diff --git a/src/test/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusQueryServiceTest.java b/src/test/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusQueryServiceTest.java new file mode 100644 index 0000000..9165acf --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusQueryServiceTest.java @@ -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} 에 그대로 전달하는지 검증한다. + * + *

mock 이라 JPQL 자체의 필터링 정확성(그룹 전용 API 의 인시던트가 실제로 걸러지는지)은 + * 검증 범위 밖이다 - 실제 쿼리 실행 검증은 별도 통합 테스트가 필요하다.

+ */ +@ExtendWith(MockitoExtension.class) +class ApiStatusQueryServiceTest { + + @Mock private ApiStatusIncidentQueryRepository incidentQueryRepository; + @Mock private ApiStatusAssembler assembler; + @Mock private ApiStatusCatalogService catalogService; + + private ApiStatusQueryService service; + private Set 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 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)); + } +} diff --git a/src/test/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusUptimeServiceTest.java b/src/test/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusUptimeServiceTest.java new file mode 100644 index 0000000..46ae2d6 --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/djb/apistatus/service/ApiStatusUptimeServiceTest.java @@ -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 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)); + } +} diff --git a/src/test/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupServiceTest.java b/src/test/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupServiceTest.java index 8add348..d947e6e 100644 --- a/src/test/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupServiceTest.java +++ b/src/test/java/com/eactive/apim/portal/djb/testcleanup/service/TestCleanupServiceTest.java @@ -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); }