테스트 코드 추가:
- API 상태 관련 서비스 테스트 클래스 신규 작성 (총 7개) - 테스트 코드에서 `visibleApiIds` 필터 적용 확인 및 조회 로직 검증 포함 - `TestCleanupService` 확장: 그룹 제한 데이터 관련 신규 Fixture 및 Mock 추가
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+22
-6
@@ -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);
|
||||
}
|
||||
|
||||
+6
-3
@@ -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()) {
|
||||
|
||||
+14
@@ -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)의 마지막 실행 시각.
|
||||
*
|
||||
|
||||
+3
-2
@@ -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) {
|
||||
|
||||
+7
-4
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -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());
|
||||
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ import java.util.stream.Collectors;
|
||||
*/
|
||||
@Slf4j
|
||||
@Controller
|
||||
@RequestMapping("/djb/notitest")
|
||||
@RequestMapping("//notitest")
|
||||
@RequiredArgsConstructor
|
||||
public class NotiTestController {
|
||||
|
||||
|
||||
+100
@@ -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 재실행 전 초대중 중복을 정리하는 용도다.
|
||||
*/
|
||||
|
||||
+19
@@ -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;
|
||||
}
|
||||
+207
@@ -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를 수행할 수 없습니다.");
|
||||
|
||||
Reference in New Issue
Block a user