- 2FA 수신자 결정적 해시 컬럼 추가 및 조회/중복 처리 개선

- Swagger UI i18n 한글 패치 파일 추가
- Swagger UI 응답/스니펫 패널 커스텀화 및 코드 생성 기능 추가
This commit is contained in:
Rinjae
2026-07-02 18:16:57 +09:00
parent e8d5c8ca03
commit 5d70962521
20 changed files with 1998 additions and 0 deletions
@@ -0,0 +1,23 @@
-- =============================================================================
-- 2FA 인증코드 조회용 결정적 해시 키 컬럼 추가
-- 대상: EMS 스키마 소유자(EMSAPP 등)로 접속하여 실행
-- 엔티티: com.eactive.apim.portal.portaluser.entity.TwoFactorAuth
--
-- 배경: RECIPIENT(이메일/휴대폰) 컬럼에 PersonalDataEncryptConverter 암호화가
-- 적용되면서, 값 동등 조회(WHERE RECIPIENT = ?)와 중복정리가 깨져
-- 인증코드 검증이 "일치하지 않습니다"로 실패함.
-- 해결: RECIPIENT 는 암호화 보관(감사/표시용)을 유지하고, 평문의 결정적 해시
-- (HMAC-SHA256, 소문자 hex 64자)를 RECIPIENT_KEY 에 저장하여 조회/정리에 사용.
--
-- ※ hibernate.hbm2ddl.auto=validate 이므로, 신규 엔티티 배포(재기동) "전"에
-- 반드시 이 스크립트를 먼저 실행해야 기동 검증을 통과함.
-- =============================================================================
ALTER TABLE PTL_TWO_FACTOR_AUTH ADD (RECIPIENT_KEY VARCHAR2(64));
-- 인증코드 조회/중복정리는 RECIPIENT_KEY 기준
CREATE INDEX IX_PTL_TWO_FACTOR_AUTH_RKEY ON PTL_TWO_FACTOR_AUTH (RECIPIENT_KEY);
COMMENT ON COLUMN PTL_TWO_FACTOR_AUTH.RECIPIENT_KEY IS '수신자 결정적 해시 조회키 (HMAC-SHA256 hex). RECIPIENT 암호화로 깨지는 동등조회/중복정리용';
COMMIT;
@@ -0,0 +1,37 @@
package com.eactive.apim.portal.djb.testbed.advice;
import com.eactive.apim.portal.djb.testbed.controller.DjbTestbedAuthController;
import com.eactive.apim.portal.djb.testbed.controller.DjbTestbedSpecController;
import com.eactive.apim.portal.djb.testbed.exception.DjbUnsupportedAuthTypeException;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 테스트베드 두 컨트롤러 한정 예외 → JSON 변환.
* (qna 로컬 핸들러와 동일한 {@code {code, message}} 포맷.)
*/
@RestControllerAdvice(assignableTypes = {DjbTestbedAuthController.class, DjbTestbedSpecController.class})
public class DjbTestbedExceptionHandler {
@ExceptionHandler(DjbUnsupportedAuthTypeException.class)
public ResponseEntity<Map<String, Object>> handleUnsupportedAuthType(DjbUnsupportedAuthTypeException ex) {
Map<String, Object> body = new HashMap<>();
body.put("code", "UNSUPPORTED_AUTHTYPE");
body.put("message", ex.getMessage());
body.put("authtype", ex.getAuthtype());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<Map<String, Object>> handleAccessDenied(AccessDeniedException ex) {
Map<String, Object> body = new HashMap<>();
body.put("code", "FORBIDDEN");
body.put("message", ex.getMessage());
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
}
}
@@ -0,0 +1,90 @@
package com.eactive.apim.portal.djb.testbed.config;
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* 테스트베드 게이트웨이 설정을 {@code PTL_PROPERTY}(그룹 {@code Portal}, key prefix {@code djb.gateway.})
* 에서 읽는 얇은 접근자. 별도 property-injection 인프라 대신 기존
* {@link PortalPropertyService#getOrCreateProperty} 를 직접 사용한다(사용자 지시).
*
* <p>{@code getOrCreateProperty} 는 DB row 미존재 시 default 로 INSERT(그룹 존재 시)하지만
* <em>공백 값 보정은 하지 않으므로</em>, 여기 {@link #resolve} 에서 {@code isBlank → default} 폴백을 둔다.
*
* <p>비-Spring 컴포넌트인 {@code ApiTesterFilter}(@WebFilter)는
* {@code ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class)} 로 접근한다.
*/
@Component
@RequiredArgsConstructor
public class DjbTestbedGatewayProperty {
private final PortalPropertyService portalPropertyService;
private static final String GROUP = "Portal";
public static final String KEY_BASE_URL = "djb.gateway.base-url";
public static final String KEY_TOKEN_PATH = "djb.gateway.token-path";
public static final String KEY_API_KEY_HEADER = "djb.gateway.api-key-header";
public static final String KEY_OAUTH_HEADER = "djb.gateway.oauth-token-header";
public static final String DEFAULT_BASE_URL = "PortalMock";
/** GW 모드 실제 토큰 엔드포인트 경로 (DJERP_4000 OAuth 발급가이드 v0.2). */
public static final String DEFAULT_TOKEN_PATH = "/dj/oauth/token";
public static final String DEFAULT_API_KEY_HEADER = "X-DJB-API-Key";
/** 게이트웨이 API 호출 시 액세스 토큰 헤더명 (가이드 v0.2: Authorization → X-AUTH-TOKEN). */
public static final String DEFAULT_OAUTH_HEADER = "X-AUTH-TOKEN";
/** PortalMock 모드에서 사용하는 포털 기존 mock 토큰 경로. */
public static final String PORTAL_MOCK_TOKEN_PATH = "/api/v1/oauth/token";
public String baseUrl() {
return resolve(KEY_BASE_URL, DEFAULT_BASE_URL,
"GW Base URL. 문자열 \"PortalMock\" 이면 ApiTesterFilter 가 mock 토큰 반환");
}
public String tokenPath() {
return resolve(KEY_TOKEN_PATH, DEFAULT_TOKEN_PATH,
"OAuth 토큰 발급 경로 (GW 모드에서만 사용)");
}
public String apiKeyHeader() {
return resolve(KEY_API_KEY_HEADER, DEFAULT_API_KEY_HEADER,
"API Key 전달 헤더명");
}
public String oauthHeader() {
return resolve(KEY_OAUTH_HEADER, DEFAULT_OAUTH_HEADER,
"OAuth 액세스 토큰 전달 헤더명 (Bearer 토큰)");
}
public DjbGatewayMode resolveGatewayMode() {
return DEFAULT_BASE_URL.equalsIgnoreCase(baseUrl()) ? DjbGatewayMode.PORTAL_MOCK : DjbGatewayMode.GATEWAY;
}
/**
* OAuth 토큰 발급 URL.
* PortalMock → {@code portalOrigin + /api/v1/oauth/token} (기존 mock 필터가 가로챔),
* GATEWAY → {@code baseUrl + tokenPath}.
*/
public String resolveTokenUrl(String portalOrigin) {
if (resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) {
return stripTrailingSlash(portalOrigin) + PORTAL_MOCK_TOKEN_PATH;
}
return stripTrailingSlash(baseUrl()) + tokenPath();
}
private String resolve(String key, String defaultValue, String description) {
String value = portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
return StringUtils.hasText(value) ? value.trim() : defaultValue;
}
private static String stripTrailingSlash(String s) {
if (s == null) {
return "";
}
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
}
}
@@ -0,0 +1,51 @@
package com.eactive.apim.portal.djb.testbed.controller;
import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialSecretDto;
import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto;
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedAuthService;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 테스트베드 인증 컨텍스트/시크릿 REST.
* 컨트롤러 진입은 인증 없이 허용하되(비대상자도 context 를 받아야 함),
* 실제 인증/역할/소유권 검증은 {@link DjbTestbedAuthService} 가 수행한다.
*/
@RestController
@RequestMapping("/djb/testbed/auth")
@RequiredArgsConstructor
public class DjbTestbedAuthController {
private final DjbTestbedAuthService authService;
@GetMapping("/context")
public ResponseEntity<DjbTestbedContextDto> context(HttpServletRequest request) {
return ResponseEntity.ok(authService.buildContext(resolveOrigin(request)));
}
@GetMapping("/credentials/{clientId}/secret")
public ResponseEntity<DjbCredentialSecretDto> credentialSecret(@PathVariable String clientId,
@RequestParam String apiId) {
DjbCredentialSecretDto secret = authService.getCredentialSecret(clientId, apiId);
return ResponseEntity.ok()
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.header(HttpHeaders.PRAGMA, "no-cache")
.body(secret);
}
/** scheme://host[:port] (기본 포트는 생략). PortalMock 토큰 URL 구성에 사용. */
private String resolveOrigin(HttpServletRequest request) {
String scheme = request.getScheme();
String host = request.getServerName();
int port = request.getServerPort();
boolean defaultPort = ("http".equals(scheme) && port == 80) || ("https".equals(scheme) && port == 443);
return scheme + "://" + host + (defaultPort ? "" : ":" + port);
}
}
@@ -0,0 +1,58 @@
package com.eactive.apim.portal.djb.testbed.controller;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
import com.eactive.apim.portal.djb.testbed.service.DjbSwaggerSpecEnricher;
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedAuthService;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.FileCopyUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* testbed spec(swagger.json)에 AUTHTYPE 기반 securityScheme 를 주입해 반환.
* {@code default-token-api-spec} 은 클래스패스 기본 spec 을 그대로 반환(enrich 대상 외).
*/
@RestController
@RequestMapping("/djb/testbed/apis")
@RequiredArgsConstructor
@Slf4j
public class DjbTestbedSpecController {
private final ApiSpecInfoService apiSpecInfoService;
private final DjbTestbedAuthService authService;
private final DjbSwaggerSpecEnricher enricher;
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
private static final String DEFAULT_SPEC_PATH = "swagger/default-token-api-spec.json";
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> swaggerWithAuth(@PathVariable String id) throws IOException {
if (DEFAULT_TOKEN_API_ID.equals(id)) {
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
return ResponseEntity.ok(content);
}
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
return ResponseEntity.notFound().build();
}
DjbAuthType authType = authService.resolveAuthType(id);
String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType);
return ResponseEntity.ok(enriched);
}
}
@@ -0,0 +1,21 @@
package com.eactive.apim.portal.djb.testbed.dto;
import lombok.Builder;
import lombok.Data;
/**
* 테스트베드 앱 셀렉트에 노출할 앱(PTL_CREDENTIAL) 1건. 시크릿은 포함하지 않는다.
*/
@Data
@Builder
public class DjbCredentialOptionDto {
/** PTL_CREDENTIAL.clientid */
private String clientId;
/** 사용자에게 보여줄 앱명(PTL_CREDENTIAL.clientname) */
private String clientName;
/** "0": 차단, "1": 정상 */
private String appStatus;
}
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.djb.testbed.dto;
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
import lombok.Builder;
import lombok.Data;
/**
* 선택한 앱의 시크릿 단건 응답(캐시 금지 · 로그 마스킹 대상).
* OAUTH 시 {@code clientSecret} 은 client_secret, API_KEY 시 API Key 값이다.
*/
@Data
@Builder
public class DjbCredentialSecretDto {
private String clientId;
private String clientSecret;
private DjbAuthType authType;
}
@@ -0,0 +1,36 @@
package com.eactive.apim.portal.djb.testbed.dto;
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
import java.util.List;
import lombok.Builder;
import lombok.Data;
/**
* {@code GET /djb/testbed/auth/context} 응답. 시크릿은 포함하지 않는다.
*/
@Data
@Builder
public class DjbTestbedContextDto {
/** 인증 정보 제공 대상자 여부 */
private boolean eligible;
/** 비대상 사유 코드: ANONYMOUS / INDIVIDUAL_USER / NO_CREDENTIAL (대상자면 null) */
private String reason;
/** 사용자가 선택할 수 있는 앱 목록 */
private List<DjbCredentialOptionDto> credentials;
/** 게이트웨이 정보(시크릿 제외) */
private GatewayInfo gateway;
@Data
@Builder
public static class GatewayInfo {
private DjbGatewayMode mode;
private String baseUrl;
private String tokenUrl;
private String apiKeyHeader;
private String oauthTokenHeader;
}
}
@@ -0,0 +1,35 @@
package com.eactive.apim.portal.djb.testbed.enums;
import com.eactive.apim.portal.djb.testbed.exception.DjbUnsupportedAuthTypeException;
/**
* 테스트베드가 지원하는 인증 체계. {@code TSEAIHE01.AUTHTYPE}(소문자 저장) 값을 매핑한다.
* <ul>
* <li>{@code oauth}/{@code oauth2}/{@code ca} → {@link #OAUTH}</li>
* <li>{@code api_key}/{@code apikey} → {@link #API_KEY}</li>
* <li>그 외 → {@link DjbUnsupportedAuthTypeException}</li>
* </ul>
* ({@code ca} 는 게이트웨이 레거시 매퍼 {@code ObpApiService.mapAuthType} 이 OAUTH2 로 취급하는 값.)
*/
public enum DjbAuthType {
OAUTH,
API_KEY;
public static DjbAuthType fromAuthtype(String authtype) {
if (authtype == null) {
throw new DjbUnsupportedAuthTypeException("null");
}
String normalized = authtype.trim().toLowerCase();
switch (normalized) {
case "oauth":
case "oauth2":
case "ca":
return OAUTH;
case "api_key":
case "apikey":
return API_KEY;
default:
throw new DjbUnsupportedAuthTypeException(authtype);
}
}
}
@@ -0,0 +1,14 @@
package com.eactive.apim.portal.djb.testbed.enums;
/**
* 테스트베드 호출이 향하는 대상 모드.
* <ul>
* <li>{@code PORTAL_MOCK} — {@code djb.gateway.base-url} 이 문자열 "PortalMock" 인 경우.
* {@code ApiTesterFilter} 가 mock 토큰/샘플 응답을 반환한다.</li>
* <li>{@code GATEWAY} — 그 외(실제 게이트웨이 URL). 토큰 발급 요청을 실 게이트웨이로 forward.</li>
* </ul>
*/
public enum DjbGatewayMode {
PORTAL_MOCK,
GATEWAY
}
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.djb.testbed.exception;
/**
* TSEAIHE01.AUTHTYPE 값이 테스트베드가 지원하는 인증 체계(oauth/api_key)로
* 매핑되지 않을 때 발생. {@link com.eactive.apim.portal.djb.testbed.advice.DjbTestbedExceptionHandler}
* 에서 400 응답으로 변환된다.
*/
public class DjbUnsupportedAuthTypeException extends RuntimeException {
private final String authtype;
public DjbUnsupportedAuthTypeException(String authtype) {
super("지원하지 않는 인증 체계: " + authtype);
this.authtype = authtype;
}
public String getAuthtype() {
return authtype;
}
}
@@ -0,0 +1,88 @@
package com.eactive.apim.portal.djb.testbed.service;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* testbed spec(JSON)에 인증 체계를 주입한다.
*
* <p>현재 데이터는 <b>OpenAPI 3.0</b>(auto-gen {@code "openapi":"3.0.0"})이므로
* {@code components.securitySchemes} + 글로벌 {@code security} 에 주입한다.
* (원본이 Swagger 2.0 이면 {@code securityDefinitions} 로 폴백.)
*
* <p>게이트웨이가 OAuth 액세스 토큰을 표준 {@code Authorization} 이 아니라 커스텀 헤더
* {@code X-AUTH-TOKEN: Bearer ...} 로 받으므로(DJERP_4000 v0.2), OAuth 도 API_KEY 와 동일하게
* <b>apiKey-in-header</b> 스킴으로 모델링한다(헤더명만 상이). 토큰 발급은 템플릿 JS가
* {@code /api/call-api} 경유로 수행해 값을 {@code "Bearer <token>"} 으로 authorize 한다.
*
* <p>원본의 {@code paths}, {@code x-*} 확장, 기타 {@code components} 는 트리 조작으로 보존한다
* (문자열 치환 금지).
*/
@Service
@RequiredArgsConstructor
public class DjbSwaggerSpecEnricher {
private final ObjectMapper objectMapper;
private final DjbTestbedGatewayProperty gatewayProperty;
public static final String OAUTH_SCHEME = "djbOAuth";
public static final String API_KEY_SCHEME = "djbApiKey";
public String enrich(String specJson, DjbAuthType authType) throws JsonProcessingException {
JsonNode parsed = objectMapper.readTree(specJson);
if (!parsed.isObject()) {
// 예상 밖 형태면 원본 유지(멱등)
return specJson;
}
ObjectNode root = (ObjectNode) parsed;
String schemeName = (authType == DjbAuthType.OAUTH) ? OAUTH_SCHEME : API_KEY_SCHEME;
ObjectNode scheme = buildApiKeyScheme(authType);
boolean swagger2 = root.has("swagger") && !root.has("openapi");
if (swagger2) {
getOrCreateObject(root, "securityDefinitions").set(schemeName, scheme);
} else {
ObjectNode components = getOrCreateObject(root, "components");
getOrCreateObject(components, "securitySchemes").set(schemeName, scheme);
}
// 글로벌 security 요구사항 (apiKey 스킴은 빈 스코프 배열)
ArrayNode security = objectMapper.createArrayNode();
ObjectNode requirement = objectMapper.createObjectNode();
requirement.set(schemeName, objectMapper.createArrayNode());
security.add(requirement);
root.set("security", security);
return objectMapper.writeValueAsString(root);
}
/** {@code { "type":"apiKey", "in":"header", "name":"<header>" }} — 2.0/3.0 동일 형태. */
private ObjectNode buildApiKeyScheme(DjbAuthType authType) {
String headerName = (authType == DjbAuthType.OAUTH)
? gatewayProperty.oauthHeader()
: gatewayProperty.apiKeyHeader();
ObjectNode scheme = objectMapper.createObjectNode();
scheme.put("type", "apiKey");
scheme.put("in", "header");
scheme.put("name", headerName);
return scheme;
}
private ObjectNode getOrCreateObject(ObjectNode parent, String field) {
JsonNode node = parent.get(field);
if (node != null && node.isObject()) {
return (ObjectNode) node;
}
ObjectNode created = objectMapper.createObjectNode();
parent.set(field, created);
return created;
}
}
@@ -0,0 +1,133 @@
package com.eactive.apim.portal.djb.testbed.service;
import com.eactive.apim.gateway.data.eaimessage.EAIMessageRepository;
import com.eactive.apim.portal.app.entity.Credential;
import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialOptionDto;
import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialSecretDto;
import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto;
import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto.GatewayInfo;
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.eai.data.entity.onl.message.EAIMessageEntity;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
/**
* 테스트베드 인증 컨텍스트 구성 · 앱 시크릿 조회 · authtype 매핑.
* 인증/역할/소유권 검증은 이 서비스에서 수행한다({@code SecurityFilterChain} 은 URL 인가 블록이 없음).
*/
@Service
@RequiredArgsConstructor
public class DjbTestbedAuthService {
private final CredentialRepository credentialRepository;
private final EAIMessageRepository eaiMessageRepository;
private final DjbTestbedGatewayProperty gatewayProperty;
private static final String APP_STATUS_ACTIVE = "1";
/**
* 비대상 사유: {@code ANONYMOUS}(비로그인) / {@code INDIVIDUAL_USER}(ROLE_USER) /
* {@code NO_CREDENTIAL}(appstatus='1' 앱 0건). 하나라도 해당하면 {@code eligible=false}.
*/
public DjbTestbedContextDto buildContext(String portalOrigin) {
GatewayInfo gateway = buildGatewayInfo(portalOrigin);
if (!SecurityUtil.isAuthenticated()) {
return notEligible("ANONYMOUS", gateway);
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user.getRoleCode() == RoleCode.ROLE_USER) {
return notEligible("INDIVIDUAL_USER", gateway);
}
String orgId = SecurityUtil.getUserOrg().getId();
List<DjbCredentialOptionDto> options = credentialRepository.findAllByOrgid(orgId).stream()
.filter(c -> APP_STATUS_ACTIVE.equals(c.getAppstatus()))
.map(this::toOption)
.collect(Collectors.toList());
if (options.isEmpty()) {
return notEligible("NO_CREDENTIAL", gateway);
}
return DjbTestbedContextDto.builder()
.eligible(true)
.reason(null)
.credentials(options)
.gateway(gateway)
.build();
}
/**
* 선택 앱의 시크릿 단건. 로그인 + 비-ROLE_USER + 본인 소유(orgId 일치) 검증 후 반환.
* 소유 불일치/비대상은 {@link AccessDeniedException}(→ 403).
*/
public DjbCredentialSecretDto getCredentialSecret(String clientId, String apiId) {
if (!SecurityUtil.isAuthenticated()) {
throw new AccessDeniedException("로그인이 필요합니다.");
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user.getRoleCode() == RoleCode.ROLE_USER) {
throw new AccessDeniedException("개인사용자는 이용할 수 없습니다.");
}
String orgId = SecurityUtil.getUserOrg().getId();
Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId)
.orElseThrow(() -> new AccessDeniedException("본인 소유의 앱이 아닙니다."));
DjbAuthType authType = resolveAuthType(apiId);
return DjbCredentialSecretDto.builder()
.clientId(credential.getClientid())
.clientSecret(credential.getClientsecret())
.authType(authType)
.build();
}
/**
* apiId(=TSEAIHE01.eaisvcname, admin 입력 컨벤션)로 authtype 조회 후 매핑.
* 미매핑/미존재는 {@code DjbUnsupportedAuthTypeException}(→ 400).
*/
public DjbAuthType resolveAuthType(String apiId) {
String authtype = eaiMessageRepository.findById(apiId)
.map(EAIMessageEntity::getAuthtype)
.orElse(null);
return DjbAuthType.fromAuthtype(authtype);
}
private DjbCredentialOptionDto toOption(Credential c) {
return DjbCredentialOptionDto.builder()
.clientId(c.getClientid())
.clientName(c.getClientname())
.appStatus(c.getAppstatus())
.build();
}
private GatewayInfo buildGatewayInfo(String portalOrigin) {
return GatewayInfo.builder()
.mode(gatewayProperty.resolveGatewayMode())
.baseUrl(gatewayProperty.baseUrl())
.tokenUrl(gatewayProperty.resolveTokenUrl(portalOrigin))
.apiKeyHeader(gatewayProperty.apiKeyHeader())
.oauthTokenHeader(gatewayProperty.oauthHeader())
.build();
}
private DjbTestbedContextDto notEligible(String reason, GatewayInfo gateway) {
return DjbTestbedContextDto.builder()
.eligible(false)
.reason(reason)
.credentials(Collections.emptyList())
.gateway(gateway)
.build();
}
}
@@ -0,0 +1,161 @@
/*!
* djb-swagger-i18n.js
* Swagger UI 영문 라벨을 한글로 치환하는 i18n 패치 (방법 B - DOM patch)
* 배포 경로 권장: static/plugins/swaggerUI/djb-swagger-i18n.js
* index.html 의 swagger-initializer.js 직전 또는 직후에 <script> 로 로드
*
* ?lang=en 쿼리스트링이 있으면 패치 비활성화 (원문 보기)
* 매핑 카탈로그는 01-i18n-mapping/i18n-strings.csv 와 동기 유지
*/
(function (window) {
'use strict';
// ── 비활성화 옵션 ──
if (/[?&]lang=en\b/.test(window.location.search)) return;
// ── 매핑 (key 는 디버깅용, 실제 비교는 EN 문자열 일치) ──
var I18N_MAP = {
// P0 — OAuth2 모달 본문
'auth.modal.title': { en: 'Available authorizations', ko: '사용 가능한 인증' },
'auth.scopes.description': {
en: 'Scopes are used to grant an application different levels of access to data on behalf of the end user. Each API may declare one or more scopes.',
ko: '스코프는 최종 사용자를 대신하여 애플리케이션이 데이터에 접근할 수 있는 권한 수준을 부여합니다. 각 API는 하나 이상의 스코프를 선언할 수 있습니다.'
},
'auth.scopes.swagger_grant': {
en: 'API requires the following scopes. Select which ones you want to grant to Swagger UI.',
ko: '이 API 는 아래 스코프가 필요합니다. Swagger UI 에 부여할 스코프를 선택하세요.'
},
'auth.flow.clientCredentials': { en: 'OAuth2 client credentials flow', ko: 'OAuth2 클라이언트 자격증명 흐름' },
'auth.flow.implicit': { en: 'OAuth2 implicit flow', ko: 'OAuth2 암묵적 흐름' },
'auth.flow.password': { en: 'OAuth2 password flow', ko: 'OAuth2 비밀번호 흐름' },
'auth.flow.authorizationCode': { en: 'OAuth2 authorization code flow', ko: 'OAuth2 인가 코드 흐름' },
'auth.field.tokenUrl': { en: 'Token URL:', ko: '토큰 URL:' },
'auth.field.flow': { en: 'Flow:', ko: '흐름:' },
'auth.field.clientId': { en: 'client_id:', ko: '클라이언트 ID (client_id):' },
'auth.field.clientSecret': { en: 'client_secret:',ko: '클라이언트 시크릿 (client_secret):' },
'auth.field.scopes': { en: 'Scopes:', ko: '스코프:' },
'auth.action.selectAll': { en: 'select all', ko: '모두 선택' },
'auth.action.selectNone': { en: 'select none', ko: '선택 해제' },
'auth.action.authorize': { en: 'Authorize', ko: '인증' },
'auth.action.close': { en: 'Close', ko: '닫기' },
'auth.action.logout': { en: 'Logout', ko: '로그아웃' },
'auth.modal.authorized': { en: 'authorized', ko: '인증됨' },
// P1 — 기타 인증 흐름
'auth.scheme.apiKey': { en: 'API key', ko: 'API 키' },
'auth.field.name': { en: 'name:', ko: '이름:' },
'auth.field.in': { en: 'in:', ko: '위치:' },
'auth.field.value': { en: 'Value:', ko: '값:' },
'basic.field.username': { en: 'Username:',ko: '사용자 ID:' },
'basic.field.password': { en: 'Password:',ko: '비밀번호:' },
// P2 — 오퍼레이션 / 파라미터 / 응답 UI 라벨 (PoC ko-i18n.js 카탈로그와 동기)
'op.tryItOut': { en: 'Try it out', ko: '실행해보기' },
'op.cancel': { en: 'Cancel', ko: '취소' },
'op.execute': { en: 'Execute', ko: '실행' },
'op.clear': { en: 'Clear', ko: '지우기' },
'op.reset': { en: 'Reset', ko: '초기화' },
'op.parameters': { en: 'Parameters', ko: '파라미터' },
'op.parameter': { en: 'Parameter', ko: '파라미터' },
'op.name': { en: 'Name', ko: '이름' },
'op.description': { en: 'Description', ko: '설명' },
'op.value': { en: 'Value', ko: '값' },
'op.type': { en: 'Type', ko: '타입' },
'op.requestBody': { en: 'Request body', ko: '요청 본문' },
'op.requestUrl': { en: 'Request URL', ko: '요청 URL' },
'op.responses': { en: 'Responses', ko: '응답' },
'op.response': { en: 'Response', ko: '응답' },
'op.responseBody': { en: 'Response body', ko: '응답 본문' },
'op.responseHeaders': { en: 'Response headers', ko: '응답 헤더' },
'op.responseCType': { en: 'Response content type', ko: '응답 컨텐츠 타입' },
'op.code': { en: 'Code', ko: '코드' },
'op.details': { en: 'Details', ko: '상세' },
'op.schema': { en: 'Schema', ko: '스키마' },
'op.schemas': { en: 'Schemas', ko: '스키마' },
'op.exampleValue': { en: 'Example Value', ko: '예시 값' },
'op.noParameters': { en: 'No parameters', ko: '파라미터 없음' },
'op.required': { en: 'required', ko: '필수' },
'op.servers': { en: 'Servers', ko: '서버' },
'op.serverResponse': { en: 'Server response', ko: '서버 응답' },
'op.loading': { en: 'Loading...', ko: '불러오는 중...' },
'op.curlUpper': { en: 'Curl', ko: 'cURL' },
'op.curlLower': { en: 'curl', ko: 'cURL' },
'op.editValue': { en: 'Edit Value', ko: '값 편집' },
'op.model': { en: 'Model', ko: '모델' },
'op.snippets': { en: 'Snippets', ko: '스니펫' },
'op.mediaType': { en: 'Media type', ko: '미디어 타입' },
'op.controlsAccept': { en: 'Controls Accept header.', ko: 'Accept 헤더를 제어합니다.' },
'op.sendEmptyValue': { en: 'Send empty value', ko: '빈 값 전송' },
'op.hide': { en: 'Hide', ko: '숨기기' },
'op.show': { en: 'Show', ko: '표시' }
};
// 빠른 lookup 을 위해 EN → KO 인덱스 사전 생성
var EN_TO_KO = Object.create(null);
Object.keys(I18N_MAP).forEach(function (k) {
var e = I18N_MAP[k];
EN_TO_KO[e.en] = e.ko;
});
// ── 치환 대상 노드 식별 + 치환 ──
function translateNode(node) {
if (!node) return;
if (node.nodeType === Node.TEXT_NODE) {
var raw = node.nodeValue;
if (!raw) return;
var trimmed = raw.trim();
if (!trimmed) return;
// 1) 완전 일치 (가장 안전)
if (EN_TO_KO[trimmed]) {
node.nodeValue = raw.replace(trimmed, EN_TO_KO[trimmed]);
return;
}
// 2) 라벨 colon 패턴: "Token URL:" 같이 끝이 ":" 인 짧은 라벨
// (불필요 — 1번 완전 일치로 대부분 커버)
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') return;
// input placeholder / button title 도 일부 케이스에 대비
if (node.tagName === 'INPUT' && node.placeholder && EN_TO_KO[node.placeholder.trim()]) {
node.placeholder = EN_TO_KO[node.placeholder.trim()];
}
var i, child = node.childNodes, len = child.length;
for (i = 0; i < len; i++) translateNode(child[i]);
}
function translateAll() {
var root = document.getElementById('swagger-ui') || document.body;
translateNode(root);
}
// ── MutationObserver — SwaggerUI 가 동적으로 DOM 을 다시 그릴 때마다 재적용 ──
var observer = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var m = mutations[i];
if (m.addedNodes && m.addedNodes.length) {
for (var j = 0; j < m.addedNodes.length; j++) translateNode(m.addedNodes[j]);
}
if (m.type === 'characterData') {
translateNode(m.target);
}
}
});
function start() {
translateAll();
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})(window);
@@ -0,0 +1,187 @@
/*!
* DjbSwaggerResponse — Swagger UI 기본 라이브 응답 표(.live-responses-table)를 CSS 로
* 숨기고, 실행(Execute)한 operation 블록 안에 커스텀 응답 패널을 세로 적층으로 렌더한다.
* (요청 스니펫 .request-snippets 은 같은 .responses-wrapper 안에 있으므로 유지)
*
* ┌─────────────────────────────────────────────┐
* │ 응답 헤더 (Table) │
* ├─────────────────────────────────────────────┤
* │ 응답 본문 (JSON Pretty / Raw) │
* ├─────────────────────────────────────────────┤
* │ 응답코드 칩 + Response Time(ms) │
* └─────────────────────────────────────────────┘
*
* 포탈 testbed 는 API 하나에 operation 이 여러 개일 수 있으므로, 전역 단일 그리드가
* 아니라 Execute 를 누른 opblock 안에 그리드를 만든다(클릭 캡처로 대상 특정).
*
* 연결:
* const ui = SwaggerUIBundle({
* responseInterceptor: function (res) { DjbSwaggerResponse.renderResponse(res); return res; }
* });
* window.ui = ui;
* DjbSwaggerResponse.attach('#swagger-ui'); // Execute 클릭 캡처 + 타이머 등록
*/
(function (global) {
"use strict";
var startTs = 0;
var targetOpblock = null;
function now() {
return (global.performance && performance.now) ? performance.now() : Date.now();
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function statusKind(status) {
if (status >= 200 && status < 300) return "ok";
if (status >= 300 && status < 400) return "warn";
if (status >= 400) return "err";
return "neutral";
}
function prettyJson(text) {
try { return JSON.stringify(JSON.parse(text), null, 2); }
catch (e) { return null; }
}
function renderEmpty() {
return ''
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
+ '<div class="empty">요청 중…</div></div>'
+ '<div class="cell-body"><div class="cell-title">응답 본문</div>'
+ '<div class="empty">요청 중…</div></div>'
+ '<div class="cell-meta">'
+ '<span class="status-chip neutral">대기</span>'
+ '<span class="time">Response Time: -</span>'
+ '</div>';
}
function renderHeadersTable(headers) {
var keys = Object.keys(headers || {});
if (!keys.length) return '<div class="empty">헤더가 없습니다.</div>';
var rows = keys.map(function (k) {
return '<tr><td class="hdr-name">' + escapeHtml(k) + '</td>'
+ '<td class="hdr-val">' + escapeHtml(headers[k]) + '</td></tr>';
}).join("");
return '<table class="hdr-table"><thead><tr><th>이름</th><th>값</th></tr></thead>'
+ '<tbody>' + rows + '</tbody></table>';
}
function bodyTabs(rawText) {
var pretty = prettyJson(rawText);
var hasJson = pretty != null;
var jsonTab = hasJson ? '<button type="button" class="body-tab active" data-tab="json">JSON (Pretty)</button>' : "";
var rawTab = '<button type="button" class="body-tab' + (hasJson ? "" : " active") + '" data-tab="raw">Raw</button>';
var jsonPanel = hasJson ? '<pre class="body-panel mono active" data-tab="json">' + escapeHtml(pretty) + '</pre>' : "";
var rawPanel = '<pre class="body-panel mono' + (hasJson ? "" : " active") + '" data-tab="raw">' + escapeHtml(rawText || "") + '</pre>';
return '<div class="body-tabs" role="tablist">' + jsonTab + rawTab + '</div>'
+ '<div class="body-panels">' + jsonPanel + rawPanel + '</div>';
}
function attachTabHandlers(root) {
if (root.__djbTabBound) return;
root.__djbTabBound = true;
root.addEventListener("click", function (e) {
var btn = e.target.closest && e.target.closest(".body-tab");
if (!btn) return;
var panels = root.querySelectorAll(".body-panel");
var tabs = root.querySelectorAll(".body-tab");
tabs.forEach(function (t) { t.classList.toggle("active", t === btn); });
var which = btn.getAttribute("data-tab");
panels.forEach(function (p) { p.classList.toggle("active", p.getAttribute("data-tab") === which); });
});
}
// 대상 opblock(없으면 열려있는/첫 opblock) 안 .opblock-body 에 그리드 1개 확보.
// 항상 opblock-body 최하단으로 이동시킨다: Execute 시점엔 responses-wrapper(요청
// 스니펫)가 아직 없어 그리드가 그 위에 붙지만, 응답 후 재호출 시 최하단으로 옮겨
// "Execute → 요청 스니펫 → 응답 그리드" 순서를 보장한다.
function gridFor(opblock) {
var host = opblock
|| document.querySelector("#swagger-ui .opblock.is-open")
|| document.querySelector("#swagger-ui .opblock");
if (!host) return null;
var body = host.querySelector(".opblock-body");
if (!body) return null; // 접힘 상태에는 마운트하지 않음 (bare opblock 오배치 방지)
var grid = body.querySelector(".djb-response-grid");
if (!grid) {
grid = document.createElement("div");
grid.className = "djb-response-grid djb-empty";
grid.innerHTML = renderEmpty();
attachTabHandlers(grid);
}
body.appendChild(grid); // 기존 노드면 최하단으로 이동
return grid;
}
// Execute 클릭 시점에 대상 opblock 확정 + 타이머 시작 + 빈 그리드 표시.
function onExecuteClick(e) {
var t = e.target;
if (!t || !t.closest) return;
var btn = t.closest(".btn.execute");
if (!btn) return;
targetOpblock = btn.closest(".opblock");
startTs = now();
var grid = gridFor(targetOpblock);
if (grid) {
grid.classList.add("djb-empty");
grid.classList.remove("djb-error");
grid.innerHTML = renderEmpty();
}
}
/**
* @param {Object} res Swagger UI responseInterceptor 결과
* { status, statusText, headers, text, data, ... }
*/
function renderResponse(res) {
if (!res) return;
// spec 로딩 응답(/…/swagger.json)은 responseInterceptor 로도 들어온다. 이때 렌더하면
// 실행 전인데 응답 본문에 OpenAPI spec 이 그려지므로 무시한다. 사용자 API 호출은
// 프록시(/api/call-api)로 나가므로 url 에 swagger.json 이 없다.
if (res.url && res.url.indexOf("swagger.json") !== -1) return;
var ms = Math.max(0, Math.round(now() - startTs));
var status = res.status || 0;
var statusText = res.statusText || "";
var headers = res.headers || {};
var rawText = (typeof res.text === "string") ? res.text
: (typeof res.data === "string") ? res.data
: (res.data != null ? JSON.stringify(res.data) : "");
var op = targetOpblock;
// Swagger(React) 가 응답 렌더로 opblock-body 를 재조정한 뒤 append 되도록 지연.
setTimeout(function () {
var grid = gridFor(op);
if (!grid) return;
var kind = statusKind(status);
grid.classList.remove("djb-empty", "djb-error");
grid.innerHTML = ''
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
+ renderHeadersTable(headers) + '</div>'
+ '<div class="cell-body"><div class="cell-title">응답 본문</div>'
+ bodyTabs(rawText) + '</div>'
+ '<div class="cell-meta">'
+ '<span class="status-chip ' + kind + '">응답코드: ' + escapeHtml(status)
+ (statusText ? " " + escapeHtml(statusText) : "") + '</span>'
+ '<span class="time">Response Time: ' + ms + ' ms</span>'
+ '</div>';
attachTabHandlers(grid);
}, 0);
}
// #swagger-ui 는 API 전환 시에도 element 자체는 유지되므로 캡처 리스너 1회 등록으로 충분.
function attach(rootSel) {
var root = document.querySelector(rootSel || "#swagger-ui");
if (!root || root.__djbRespBound) return;
root.__djbRespBound = true;
// capture 단계: native 응답 흐름보다 먼저 타겟/타이머 확보
root.addEventListener("click", onExecuteClick, true);
}
global.DjbSwaggerResponse = { attach: attach, renderResponse: renderResponse };
})(window);
@@ -0,0 +1,251 @@
/*!
* DjbSwaggerSnippetPanel — Execute 없이 페이지 진입 즉시 요청 스니펫을 보여주는 상시 패널.
* (PoC rinjae-works/swagger-poc/js/snippet-panel.js 를 포탈용으로 이식)
*
* Swagger UI 5 의 내장 .request-snippets 는 Execute 이후에만, 그리고 응답영역 안에
* 렌더되므로 CSS 로 숨기고, 동일한 window.SnippetGenerators 로 직접 패널을 만들어
* operation 의 Execute 버튼(.btn-group) 바로 아래에 마운트한다. Body/헤더 변경 시 자동 갱신.
*
* 전제: 이 페이지는 항상 1개 API(단일 operation) 노출 — spec 의 첫 path/method 사용.
* spec 은 window.ui 에서 지연 조회한다.
*
* 연결: SwaggerUIBundle 의 onComplete 에서 DjbSwaggerSnippetPanel.mount() 호출.
*/
(function (global) {
"use strict";
var TABS = [
{ key: "curl_bash", title: "cURL (Bash)", gen: "curlBash" },
{ key: "curl_powershell", title: "cURL (PowerShell)", gen: "curlPwsh" },
{ key: "curl_cmd", title: "cURL (CMD)", gen: "curlCmd" },
{ key: "csharp", title: "C#", gen: "csharp" },
{ key: "ecma5", title: "ECMA5 (JavaScript)", gen: "ecma5" },
{ key: "java", title: "Java", gen: "javaOkhttp" },
{ key: "python", title: "Python", gen: "python" }
];
var activeKey = "curl_bash";
var rootSel = "#swagger-ui";
var observer = null;
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// window.ui 에서 해석된 spec(json) 조회.
function getSpecJson() {
try {
var ui = global.ui;
if (ui && typeof ui.spec === "function") {
var s = ui.spec();
if (s && typeof s.toJS === "function") {
var o = s.toJS();
return o.json || o;
}
}
} catch (e) { /* noop */ }
return null;
}
// Path 1 + Method 1 전제 하에 단일 operation 반환.
function getSingleOperation(spec) {
var paths = (spec && spec.paths) ? spec.paths : {};
var pk = Object.keys(paths)[0];
if (!pk) return null;
var ops = paths[pk];
var mk = ["get", "post", "put", "patch", "delete", "options", "head"].filter(function (m) { return ops[m]; })[0];
if (!mk) return null;
return { path: pk, method: mk, operation: ops[mk] };
}
function readHeaderInput(name) {
var row = document.querySelector('tr[data-param-name="' + name + '"][data-param-in="header"]');
if (row) {
var input = row.querySelector('input[type="text"], input:not([type])');
if (input && input.value) return input.value;
}
return null;
}
// Authorize 모달 사용 후에만 표시되는 인증 헤더 추출.
function readAuthHeaders() {
var headers = {};
try {
var ui = global.ui;
if (!ui || !ui.authSelectors) return headers;
var auths = ui.authSelectors.authorized();
if (!auths) return headers;
var js = auths.toJS();
Object.keys(js).forEach(function (key) {
var a = js[key];
if (!a || !a.schema) return;
var scheme = a.schema;
if (scheme.type === "oauth2") {
var tok = a.token && a.token.access_token;
if (tok) headers["Authorization"] = "Bearer " + tok;
} else if (scheme.type === "apiKey" && scheme.in === "header") {
if (a.value) headers[scheme.name] = a.value;
} else if (scheme.type === "http" && scheme.scheme === "bearer") {
if (a.value) headers["Authorization"] = "Bearer " + a.value;
}
});
} catch (e) { /* noop */ }
return headers;
}
function buildRequest() {
var spec = getSpecJson();
var opInfo = spec && getSingleOperation(spec);
if (!opInfo) return null;
var op = opInfo.operation;
var server = (spec.servers && spec.servers[0] && spec.servers[0].url) || "";
var url = server + opInfo.path;
var headers = {};
(op.parameters || []).forEach(function (p) {
if (p.in !== "header") return;
var val = readHeaderInput(p.name)
|| (p.example !== undefined ? String(p.example) : null)
|| (p.schema && p.schema.example !== undefined ? String(p.schema.example) : null);
if (val) headers[p.name] = val;
});
var body = "";
if (op.requestBody && op.requestBody.content) {
var cts = Object.keys(op.requestBody.content);
var ct = cts[0] || "application/json";
headers["Content-Type"] = ct;
var ta = document.querySelector(rootSel + " textarea.body-param__text");
if (ta && ta.value) {
body = ta.value;
} else if (op.requestBody.content[ct] && op.requestBody.content[ct].example !== undefined) {
try { body = JSON.stringify(op.requestBody.content[ct].example, null, 2); } catch (e) { body = ""; }
}
}
headers["accept"] = headers["accept"] || "application/json";
var auth = readAuthHeaders();
Object.keys(auth).forEach(function (k) { headers[k] = auth[k]; });
return { url: url, method: opInfo.method.toUpperCase(), headers: headers, body: body };
}
function panelEl() { return document.querySelector("#djb-snippet-panel"); }
function renderInto(el) {
if (!el) return;
var gens = global.SnippetGenerators;
var code;
if (!gens) {
code = "// 스니펫 생성기 로드 대기…";
} else {
try {
var req = buildRequest();
if (!req) { code = "// 스펙 로딩 중…"; }
else {
var active = TABS.filter(function (t) { return t.key === activeKey; })[0] || TABS[0];
code = gens[active.gen](req);
}
} catch (e) { code = "// 스니펫 생성 오류: " + e.message; }
}
var tabsHtml = TABS.map(function (t) {
return '<button type="button" class="djb-sn-tab ' + (t.key === activeKey ? "active" : "") + '" data-key="' + t.key + '">' + t.title + '</button>';
}).join("");
el.innerHTML =
'<div class="djb-sn-header">'
+ '<h4>요청 스니펫</h4>'
+ '<span class="djb-sn-hint">(실행 없이 즉시 미리보기 — Body / 헤더 변경 시 자동 갱신)</span>'
+ '<button type="button" class="djb-sn-copy" data-action="copy">복사</button>'
+ '</div>'
+ '<div class="djb-sn-tabs" role="tablist">' + tabsHtml + '</div>'
+ '<pre class="djb-sn-code mono">' + escapeHtml(code) + '</pre>';
}
// 옵저버 재진입(무한 루프) 방지: 우리 DOM 변경 중에는 관찰 일시 중지.
function withObserverPaused(fn) {
if (observer) observer.disconnect();
try { fn(); }
finally {
if (observer) {
var root = document.querySelector(rootSel);
if (root) observer.observe(root, { childList: true, subtree: true });
}
}
}
// Execute 버튼(.btn-group) 바로 아래에 패널 확보.
// - operation 이 접혀 opblock-body/실행영역이 없으면: 잘못 남은 패널을 제거하고 중단
// (bare .opblock 에 붙어 opblock-summary 뒤로 튀는 문제 방지). 다음 펼침 때 재생성.
// - 이미 있으나 위치가 어긋나면: anchor 바로 뒤로 재배치.
function ensureMounted() {
var op = document.querySelector(rootSel + " .opblock.is-open")
|| document.querySelector(rootSel + " .opblock");
var body = op && op.querySelector(".opblock-body");
var anchor = body && (body.querySelector(".btn-group") || body.querySelector(".execute-wrapper"));
var existing = panelEl();
if (!body || !anchor) {
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
return;
}
if (existing) {
if (existing.parentNode !== anchor.parentNode || existing.previousElementSibling !== anchor) {
anchor.parentNode.insertBefore(existing, anchor.nextSibling);
}
return;
}
var panel = document.createElement("div");
panel.id = "djb-snippet-panel";
panel.className = "djb-snippet-panel";
anchor.parentNode.insertBefore(panel, anchor.nextSibling);
renderInto(panel);
}
function onClick(e) {
var tab = e.target.closest && e.target.closest("#djb-snippet-panel .djb-sn-tab");
if (tab) {
activeKey = tab.getAttribute("data-key");
withObserverPaused(function () { renderInto(panelEl()); });
return;
}
var copy = e.target.closest && e.target.closest('#djb-snippet-panel [data-action="copy"]');
if (copy) {
var code = document.querySelector("#djb-snippet-panel .djb-sn-code");
if (code && global.navigator && navigator.clipboard) {
navigator.clipboard.writeText(code.innerText).then(function () {
copy.textContent = "복사됨";
setTimeout(function () { copy.textContent = "복사"; }, 1200);
});
}
}
}
function onInput(e) {
var t = e.target;
if (!t || !t.matches) return;
if (t.matches("textarea.body-param__text") || t.matches("tr[data-param-name] input")) {
withObserverPaused(function () { renderInto(panelEl()); });
}
}
function mount(opts) {
rootSel = (opts && opts.rootSel) || "#swagger-ui";
var root = document.querySelector(rootSel);
if (!root) return;
if (!root.__djbSnBound) {
root.__djbSnBound = true;
root.addEventListener("click", onClick);
root.addEventListener("input", onInput);
observer = new MutationObserver(function () {
withObserverPaused(function () { ensureMounted(); });
});
observer.observe(root, { childList: true, subtree: true });
}
withObserverPaused(function () { ensureMounted(); });
}
global.DjbSwaggerSnippetPanel = { mount: mount, render: function () { renderInto(panelEl()); } };
})(window);
@@ -0,0 +1,223 @@
/*!
* DjbSwaggerSnippets — Swagger UI 의 SnippetGeneratorPlugin 에 등록되는 7개 언어
* 클라이언트 코드 생성기. 서버측 /api/sample_code/{language} 호출을 대체한다.
*
* cURL (Bash) / cURL (PowerShell) / cURL (CMD) / C# / ECMA5 (JS) / Java / Python
*
* 입력: Swagger UI requestSnippetGenerator 가 넘기는 Immutable request.
* .toJS() 로 plain object 변환 후 url/method/headers/body 사용.
* (showMutatedRequest:false 이므로 프록시 URL 이 아닌 실제 대상 요청이 넘어온다)
* 출력: string (코드 본문)
*
* 전역: window.SnippetGeneratorPlugin (Swagger plugins 배열에 등록),
* window.SnippetGenerators (개별 생성 함수 접근용)
*/
(function (global) {
"use strict";
function unwrap(req) {
if (!req) return {};
if (typeof req.toJS === "function") return req.toJS();
return req;
}
function getCt(headers) {
for (const k of Object.keys(headers || {})) {
if (k.toLowerCase() === "content-type") return headers[k];
}
return null;
}
function bodyAsString(body) {
if (body === undefined || body === null) return "";
if (typeof body === "string") return body;
try { return JSON.stringify(body); } catch (e) { return String(body); }
}
function escapeSingleQuote(s) { return String(s).replace(/'/g, "'\\''"); }
function escapeDoubleQuote(s) { return String(s).replace(/"/g, '\\"'); }
// ─── cURL (Bash) ────────────────────────────────────────────────────────────
function curlBash(reqRaw) {
const r = unwrap(reqRaw);
const lines = [`curl --location --request ${r.method || "GET"} '${escapeSingleQuote(r.url)}'`];
const headers = r.headers || {};
for (const k of Object.keys(headers)) {
lines.push(` --header '${escapeSingleQuote(k)}: ${escapeSingleQuote(headers[k])}'`);
}
const body = bodyAsString(r.body);
if (body) {
lines.push(` --data-raw '${escapeSingleQuote(body)}'`);
}
return lines.join(" \\\n");
}
// ─── cURL (PowerShell) ─────────────────────────────────────────────────────
function curlPwsh(reqRaw) {
const r = unwrap(reqRaw);
// 백틱(`) 줄 연속자
const segments = [`curl.exe --location --request ${r.method || "GET"} "${escapeDoubleQuote(r.url)}"`];
const headers = r.headers || {};
for (const k of Object.keys(headers)) {
segments.push(` --header "${escapeDoubleQuote(k)}: ${escapeDoubleQuote(headers[k])}"`);
}
const body = bodyAsString(r.body);
if (body) {
segments.push(` --data-raw "${escapeDoubleQuote(body)}"`);
}
return segments.join(" `\n");
}
// ─── cURL (Windows CMD) ────────────────────────────────────────────────────
function curlCmd(reqRaw) {
const r = unwrap(reqRaw);
const segments = [`curl.exe --location --request ${r.method || "GET"} "${escapeDoubleQuote(r.url)}"`];
const headers = r.headers || {};
for (const k of Object.keys(headers)) {
segments.push(` --header "${escapeDoubleQuote(k)}: ${escapeDoubleQuote(headers[k])}"`);
}
const body = bodyAsString(r.body);
if (body) {
segments.push(` --data-raw "${escapeDoubleQuote(body)}"`);
}
return segments.join(" ^\n");
}
// ─── C# (RestSharp) ─────────────────────────────────────────────────────────
function csharp(reqRaw) {
const r = unwrap(reqRaw);
const method = (r.method || "GET").toUpperCase();
const headers = r.headers || {};
const body = bodyAsString(r.body);
const lines = [
`using RestSharp;`,
``,
`var client = new RestClient("${escapeDoubleQuote(r.url)}");`,
`var request = new RestRequest(Method.${method});`,
];
for (const k of Object.keys(headers)) {
lines.push(`request.AddHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}");`);
}
if (body) {
const ct = getCt(headers) || "application/json";
lines.push(`request.AddParameter("${escapeDoubleQuote(ct)}", @"${body.replace(/"/g, '""')}", ParameterType.RequestBody);`);
}
lines.push(`IRestResponse response = client.Execute(request);`);
lines.push(`Console.WriteLine(response.Content);`);
return lines.join("\n");
}
// ─── ECMA5 / JavaScript (XHR) ──────────────────────────────────────────────
function ecma5(reqRaw) {
const r = unwrap(reqRaw);
const method = (r.method || "GET").toUpperCase();
const headers = r.headers || {};
const body = bodyAsString(r.body);
const lines = [
`var xhr = new XMLHttpRequest();`,
`xhr.withCredentials = false;`,
``,
`xhr.addEventListener("readystatechange", function () {`,
` if (this.readyState === 4) {`,
` console.log(this.responseText);`,
` }`,
`});`,
``,
`xhr.open("${method}", "${escapeDoubleQuote(r.url)}");`,
];
for (const k of Object.keys(headers)) {
lines.push(`xhr.setRequestHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}");`);
}
if (body) {
// ES5 라 template literal 사용 X — JSON.stringify 로 안전한 문자열 리터럴 생성
lines.push(`xhr.send(${JSON.stringify(body)});`);
} else {
lines.push(`xhr.send(null);`);
}
return lines.join("\n");
}
// ─── Java (OkHttp) ─────────────────────────────────────────────────────────
function javaOkhttp(reqRaw) {
const r = unwrap(reqRaw);
const method = (r.method || "GET").toUpperCase();
const headers = r.headers || {};
const body = bodyAsString(r.body);
const ct = getCt(headers) || "application/json";
const lines = [
`OkHttpClient client = new OkHttpClient().newBuilder().build();`,
`MediaType mediaType = MediaType.parse("${escapeDoubleQuote(ct)}");`,
];
if (body) {
lines.push(`RequestBody body = RequestBody.create(mediaType, "${escapeDoubleQuote(body)}");`);
}
lines.push(`Request request = new Request.Builder()`);
lines.push(` .url("${escapeDoubleQuote(r.url)}")`);
if (body) {
lines.push(` .method("${method}", body)`);
} else {
lines.push(` .method("${method}", null)`);
}
for (const k of Object.keys(headers)) {
lines.push(` .addHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}")`);
}
lines.push(` .build();`);
lines.push(`Response response = client.newCall(request).execute();`);
return lines.join("\n");
}
// ─── Python (requests) ─────────────────────────────────────────────────────
function python(reqRaw) {
const r = unwrap(reqRaw);
const method = (r.method || "GET").toLowerCase();
const headers = r.headers || {};
const body = bodyAsString(r.body);
const headerEntries = Object.keys(headers).map(
k => ` "${escapeDoubleQuote(k)}": "${escapeDoubleQuote(headers[k])}"`
);
const lines = [
`import requests`,
``,
`url = "${escapeDoubleQuote(r.url)}"`,
``,
];
if (body) {
lines.push(`payload = ${JSON.stringify(body)}`);
}
lines.push(`headers = {`);
if (headerEntries.length) lines.push(headerEntries.join(",\n"));
lines.push(`}`);
lines.push(``);
if (body) {
lines.push(`response = requests.request("${method.toUpperCase()}", url, headers=headers, data=payload)`);
} else {
lines.push(`response = requests.request("${method.toUpperCase()}", url, headers=headers)`);
}
lines.push(`print(response.text)`);
return lines.join("\n");
}
// ─── Swagger UI 플러그인 등록 페이로드 ──────────────────────────────────────
const SnippetGeneratorPlugin = function () {
return {
fn: {
// curl_bash / curl_powershell / curl_cmd 는 Swagger UI 5 내장 기본 키와 동일하게
// 맞춰 내장 생성기를 덮어쓴다. (curl_pwsh 등 다른 키를 쓰면 내장 PowerShell 탭이
// 중복 표시됨)
requestSnippetGenerator_curl_bash: curlBash,
requestSnippetGenerator_curl_powershell: curlPwsh,
requestSnippetGenerator_curl_cmd: curlCmd,
requestSnippetGenerator_csharp: csharp,
requestSnippetGenerator_ecma5: ecma5,
requestSnippetGenerator_java: javaOkhttp,
requestSnippetGenerator_python: python
}
};
};
global.SnippetGeneratorPlugin = SnippetGeneratorPlugin;
global.SnippetGenerators = {
curlBash, curlPwsh, curlCmd, csharp, ecma5, javaOkhttp, python
};
})(window);
@@ -0,0 +1,277 @@
/* ─────────────────────────────────────────────────────────────────────────── */
/* DJB Swagger Testbed overlay */
/* - 기본 라이브 응답 표(.live-responses-table) 숨김 (스니펫은 유지) */
/* - 실행한 operation 안에 세로 적층 커스텀 응답 패널(.djb-response-grid) */
/* - D2Coding 우선 monospace fallback */
/* 포탈 통합용. PoC(rinjae-works/swagger-poc/css/overlay.css) 에서 응답 파트만 */
/* 발췌·클래스화(단일 ID → 다중 op 대응). */
/* ─────────────────────────────────────────────────────────────────────────── */
/* D2Coding 은 시스템에 설치된 경우에만 사용 (local()). 미설치 시 fallback 체인 사용. */
@font-face {
font-family: "D2Coding";
src: local("D2Coding"),
local("D2Coding-Regular"),
local("D2Coding ligature");
font-display: swap;
}
:root {
--djb-mono: "D2Coding", "Menlo", "Consolas", "Courier New", monospace;
--djb-border: #d0d7de;
--djb-bg: #ffffff;
--djb-bg-alt: #f6f8fa;
--djb-text: #1f2328;
--djb-text-muted: #6e7781;
--djb-ok: #1f883d;
--djb-warn: #bf8700;
--djb-err: #cf222e;
--djb-accent: #0969da;
}
/* ─── Swagger UI 기본 응답영역 통째 숨김 ──────────────────────────────────────
요청 스니펫은 내장(.request-snippets, 실행 후·응답영역 내부·밝은 테마) 대신
상시 다크 패널(#djb-snippet-panel, djb-swagger-snippet-panel.js)로 대체하고,
응답 결과는 커스텀 그리드(.djb-response-grid)로 대체한다.
최종 순서: 파라미터 → 요청 본문 → Execute → 요청 스니펫(패널) → 응답 그리드. */
.swagger-ui .responses-wrapper { display: none !important; }
.swagger-ui .request-snippets { display: none !important; }
/* ─── 상시 요청 스니펫 패널 (PoC overlay.css #djb-snippet-panel) ─────────────── */
.swagger-ui #djb-snippet-panel {
margin: 16px;
padding: 12px 16px 16px;
background: var(--djb-bg);
border: 1px solid var(--djb-border);
border-radius: 8px;
}
.swagger-ui #djb-snippet-panel .djb-sn-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.swagger-ui #djb-snippet-panel .djb-sn-header h4 {
margin: 0;
font-size: 14px;
font-weight: 700;
color: var(--djb-text);
}
.swagger-ui #djb-snippet-panel .djb-sn-hint {
color: var(--djb-text-muted);
font-size: 11px;
}
.swagger-ui #djb-snippet-panel .djb-sn-copy {
margin-left: auto;
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
background: var(--djb-bg-alt);
border: 1px solid var(--djb-border);
border-radius: 4px;
cursor: pointer;
color: var(--djb-text);
}
.swagger-ui #djb-snippet-panel .djb-sn-copy:hover { background: #eaeef2; }
.swagger-ui #djb-snippet-panel .djb-sn-tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
border-bottom: 1px solid var(--djb-border);
}
.swagger-ui #djb-snippet-panel .djb-sn-tab {
background: transparent;
border: 1px solid transparent;
border-bottom: none;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: var(--djb-text-muted);
cursor: pointer;
border-radius: 4px 4px 0 0;
white-space: nowrap;
}
.swagger-ui #djb-snippet-panel .djb-sn-tab:hover { color: var(--djb-text); }
.swagger-ui #djb-snippet-panel .djb-sn-tab.active {
color: var(--djb-accent);
border-color: var(--djb-border);
background: var(--djb-bg);
margin-bottom: -1px;
}
.swagger-ui #djb-snippet-panel .djb-sn-code {
margin: 0;
padding: 12px;
background: #0d1117;
color: #e6edf3;
border-radius: 6px;
max-height: 360px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
font-family: var(--djb-mono);
font-size: 12.5px;
line-height: 1.55;
}
/* 한글 라벨이 길어도 깨지지 않도록 */
.swagger-ui .opblock .opblock-summary-path,
.swagger-ui .opblock-tag,
.swagger-ui table thead tr th,
.swagger-ui .parameter__name,
.swagger-ui .parameter__type {
word-break: keep-all;
}
/* ─── 커스텀 응답 그리드: 헤더 → 본문 → 메타 세로 적층 (opblock 마다 1개) ── */
.swagger-ui .djb-response-grid {
margin: 16px;
padding: 16px;
background: var(--djb-bg);
border: 1px solid var(--djb-border);
border-radius: 8px;
display: grid;
grid-template-columns: 1fr;
grid-template-areas:
"headers"
"body"
"meta";
gap: 12px;
color: var(--djb-text);
}
.swagger-ui .djb-response-grid.djb-error { border-color: var(--djb-err); background: #fff8f8; }
.swagger-ui .djb-response-grid .cell-headers { grid-area: headers; min-width: 0; }
.swagger-ui .djb-response-grid .cell-body { grid-area: body; min-width: 0; }
.swagger-ui .djb-response-grid .cell-meta {
grid-area: meta;
display: flex;
align-items: center;
gap: 16px;
padding-top: 8px;
border-top: 1px dashed var(--djb-border);
}
.swagger-ui .djb-response-grid .cell-title {
font-size: 12px;
font-weight: 700;
color: var(--djb-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 6px;
}
.swagger-ui .djb-response-grid .empty {
color: var(--djb-text-muted);
font-size: 13px;
padding: 8px 0;
}
/* Header 테이블 */
.swagger-ui .djb-response-grid .hdr-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
table-layout: fixed;
}
.swagger-ui .djb-response-grid .hdr-table th,
.swagger-ui .djb-response-grid .hdr-table td {
border-bottom: 1px solid var(--djb-border);
padding: 6px 8px;
text-align: left;
vertical-align: top;
word-break: break-all;
}
.swagger-ui .djb-response-grid .hdr-table thead th {
background: var(--djb-bg-alt);
font-weight: 600;
}
.swagger-ui .djb-response-grid .hdr-table td.hdr-name {
font-weight: 600;
color: var(--djb-text);
width: 40%;
}
.swagger-ui .djb-response-grid .hdr-table td.hdr-val {
font-family: var(--djb-mono);
font-size: 12px;
color: var(--djb-text);
}
/* Body 탭 */
.swagger-ui .djb-response-grid .body-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--djb-border);
margin-bottom: 8px;
}
.swagger-ui .djb-response-grid .body-tab {
background: transparent;
border: 1px solid transparent;
border-bottom: none;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: var(--djb-text-muted);
cursor: pointer;
border-radius: 4px 4px 0 0;
}
.swagger-ui .djb-response-grid .body-tab:hover { color: var(--djb-text); }
.swagger-ui .djb-response-grid .body-tab.active {
color: var(--djb-accent);
border-color: var(--djb-border);
background: var(--djb-bg);
margin-bottom: -1px;
}
.swagger-ui .djb-response-grid .body-panels {
position: relative;
min-height: 80px;
}
.swagger-ui .djb-response-grid .body-panel {
display: none;
margin: 0;
padding: 12px;
background: #0d1117;
color: #e6edf3;
border-radius: 6px;
max-height: 360px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
font-size: 12.5px;
line-height: 1.55;
}
.swagger-ui .djb-response-grid .body-panel.active { display: block; }
.swagger-ui .djb-response-grid .mono { font-family: var(--djb-mono); }
/* Meta 칩 / 시간 */
.swagger-ui .djb-response-grid .status-chip {
display: inline-block;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
font-family: var(--djb-mono);
color: #fff;
}
.swagger-ui .djb-response-grid .status-chip.ok { background: var(--djb-ok); }
.swagger-ui .djb-response-grid .status-chip.warn { background: var(--djb-warn); }
.swagger-ui .djb-response-grid .status-chip.err { background: var(--djb-err); }
.swagger-ui .djb-response-grid .status-chip.neutral { background: var(--djb-text-muted); }
.swagger-ui .djb-response-grid .time {
font-family: var(--djb-mono);
font-size: 12px;
color: var(--djb-text-muted);
}
/* ─── 다크 코드 패널 텍스트 선택 가독성 ──────────────────────────────────────
기본 ::selection 색이 어두운 배경(#0d1117)과 겹쳐 선택한 글자가 안 보이므로,
선택 영역 배경/글자색을 명시한다. (요청 스니펫 코드 + 응답 본문 패널) */
.swagger-ui #djb-snippet-panel .djb-sn-code::selection,
.swagger-ui #djb-snippet-panel .djb-sn-code ::selection,
.swagger-ui .djb-response-grid .body-panel::selection,
.swagger-ui .djb-response-grid .body-panel ::selection {
background: #2f5c9e;
color: #ffffff;
}
@@ -0,0 +1,77 @@
package com.eactive.apim.portal.djb.testbed.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.when;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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;
@ExtendWith(MockitoExtension.class)
class DjbSwaggerSpecEnricherTest {
@Mock
private DjbTestbedGatewayProperty gatewayProperty;
private final ObjectMapper objectMapper = new ObjectMapper();
private DjbSwaggerSpecEnricher enricher;
@BeforeEach
void setUp() {
enricher = new DjbSwaggerSpecEnricher(objectMapper, gatewayProperty);
}
@Test
void oauth_injects_apiKey_scheme_with_oauth_header_and_preserves_original() throws Exception {
when(gatewayProperty.oauthHeader()).thenReturn("X-AUTH-TOKEN");
String spec = "{\"openapi\":\"3.0.0\",\"info\":{\"title\":\"t\"},"
+ "\"paths\":{\"/a\":{\"get\":{\"x-original-api-id\":\"API1\"}}}}";
String result = enricher.enrich(spec, DjbAuthType.OAUTH);
JsonNode root = objectMapper.readTree(result);
JsonNode scheme = root.path("components").path("securitySchemes").path("djbOAuth");
assertEquals("apiKey", scheme.path("type").asText());
assertEquals("header", scheme.path("in").asText());
assertEquals("X-AUTH-TOKEN", scheme.path("name").asText());
assertTrue(root.path("security").get(0).has("djbOAuth"));
// 원본 paths / x- 확장 보존
assertEquals("API1", root.path("paths").path("/a").path("get").path("x-original-api-id").asText());
}
@Test
void apiKey_injects_apiKey_scheme_with_api_key_header() throws Exception {
when(gatewayProperty.apiKeyHeader()).thenReturn("X-DJB-API-Key");
String spec = "{\"openapi\":\"3.0.0\",\"paths\":{}}";
String result = enricher.enrich(spec, DjbAuthType.API_KEY);
JsonNode root = objectMapper.readTree(result);
JsonNode scheme = root.path("components").path("securitySchemes").path("djbApiKey");
assertEquals("apiKey", scheme.path("type").asText());
assertEquals("header", scheme.path("in").asText());
assertEquals("X-DJB-API-Key", scheme.path("name").asText());
assertTrue(root.path("security").get(0).has("djbApiKey"));
}
@Test
void swagger2_falls_back_to_securityDefinitions() throws Exception {
when(gatewayProperty.apiKeyHeader()).thenReturn("X-DJB-API-Key");
String spec = "{\"swagger\":\"2.0\",\"paths\":{}}";
String result = enricher.enrich(spec, DjbAuthType.API_KEY);
JsonNode root = objectMapper.readTree(result);
assertEquals("X-DJB-API-Key",
root.path("securityDefinitions").path("djbApiKey").path("name").asText());
assertFalse(root.has("components"));
}
}
@@ -0,0 +1,196 @@
package com.eactive.apim.portal.djb.testbed.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import com.eactive.apim.gateway.data.eaimessage.EAIMessageRepository;
import com.eactive.apim.portal.app.entity.Credential;
import com.eactive.apim.portal.app.repository.CredentialRepository;
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 com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialSecretDto;
import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto;
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
import com.eactive.apim.portal.djb.testbed.exception.DjbUnsupportedAuthTypeException;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.eai.data.entity.onl.message.EAIMessageEntity;
import java.util.Collections;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.security.access.AccessDeniedException;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class DjbTestbedAuthServiceTest {
@Mock private CredentialRepository credentialRepository;
@Mock private EAIMessageRepository eaiMessageRepository;
@Mock private DjbTestbedGatewayProperty gatewayProperty;
private DjbTestbedAuthService service;
private static final String ORG_ID = "ORG1";
private static final String CLIENT_ID = "CID";
private static final String API_ID = "API1";
@BeforeEach
void setUp() {
service = new DjbTestbedAuthService(credentialRepository, eaiMessageRepository, gatewayProperty);
// 게이트웨이 정보(buildContext 모든 분기에서 구성)
when(gatewayProperty.resolveGatewayMode()).thenReturn(DjbGatewayMode.PORTAL_MOCK);
when(gatewayProperty.baseUrl()).thenReturn("PortalMock");
when(gatewayProperty.resolveTokenUrl(anyString())).thenReturn("http://localhost/api/v1/oauth/token");
when(gatewayProperty.apiKeyHeader()).thenReturn("X-DJB-API-Key");
when(gatewayProperty.oauthHeader()).thenReturn("X-AUTH-TOKEN");
}
private PortalAuthenticatedUser user(RoleCode roleCode) {
PortalAuthenticatedUser u = new PortalAuthenticatedUser();
u.setRoleCode(roleCode);
return u;
}
private PortalOrgDTO org() {
PortalOrgDTO o = new PortalOrgDTO();
o.setId(ORG_ID);
return o;
}
@Test
void buildContext_anonymous() {
try (MockedStatic<SecurityUtil> sec = Mockito.mockStatic(SecurityUtil.class)) {
sec.when(SecurityUtil::isAuthenticated).thenReturn(false);
DjbTestbedContextDto ctx = service.buildContext("http://localhost:8080");
assertFalse(ctx.isEligible());
assertEquals("ANONYMOUS", ctx.getReason());
}
}
@Test
void buildContext_individualUser() {
try (MockedStatic<SecurityUtil> sec = Mockito.mockStatic(SecurityUtil.class)) {
sec.when(SecurityUtil::isAuthenticated).thenReturn(true);
sec.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(user(RoleCode.ROLE_USER));
DjbTestbedContextDto ctx = service.buildContext("http://localhost:8080");
assertFalse(ctx.isEligible());
assertEquals("INDIVIDUAL_USER", ctx.getReason());
}
}
@Test
void buildContext_noCredential() {
try (MockedStatic<SecurityUtil> sec = Mockito.mockStatic(SecurityUtil.class)) {
sec.when(SecurityUtil::isAuthenticated).thenReturn(true);
sec.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(user(RoleCode.ROLE_CORP_USER));
sec.when(SecurityUtil::getUserOrg).thenReturn(org());
when(credentialRepository.findAllByOrgid(ORG_ID)).thenReturn(Collections.emptyList());
DjbTestbedContextDto ctx = service.buildContext("http://localhost:8080");
assertFalse(ctx.isEligible());
assertEquals("NO_CREDENTIAL", ctx.getReason());
}
}
@Test
void buildContext_eligible_onlyActiveApps() {
Credential active = new Credential();
active.setClientid(CLIENT_ID);
active.setClientname("APP");
active.setAppstatus("1");
Credential blocked = new Credential();
blocked.setClientid("CID2");
blocked.setAppstatus("0");
try (MockedStatic<SecurityUtil> sec = Mockito.mockStatic(SecurityUtil.class)) {
sec.when(SecurityUtil::isAuthenticated).thenReturn(true);
sec.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(user(RoleCode.ROLE_CORP_MANAGER));
sec.when(SecurityUtil::getUserOrg).thenReturn(org());
when(credentialRepository.findAllByOrgid(ORG_ID)).thenReturn(java.util.Arrays.asList(active, blocked));
DjbTestbedContextDto ctx = service.buildContext("http://localhost:8080");
assertTrue(ctx.isEligible());
assertEquals(1, ctx.getCredentials().size());
assertEquals(CLIENT_ID, ctx.getCredentials().get(0).getClientId());
}
}
@Test
void getCredentialSecret_success() {
Credential cred = new Credential();
cred.setClientid(CLIENT_ID);
cred.setClientsecret("SECRET");
EAIMessageEntity msg = Mockito.mock(EAIMessageEntity.class);
when(msg.getAuthtype()).thenReturn("oauth");
try (MockedStatic<SecurityUtil> sec = Mockito.mockStatic(SecurityUtil.class)) {
sec.when(SecurityUtil::isAuthenticated).thenReturn(true);
sec.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(user(RoleCode.ROLE_CORP_USER));
sec.when(SecurityUtil::getUserOrg).thenReturn(org());
when(credentialRepository.findByClientidAndOrgid(CLIENT_ID, ORG_ID)).thenReturn(Optional.of(cred));
when(eaiMessageRepository.findById(API_ID)).thenReturn(Optional.of(msg));
DjbCredentialSecretDto dto = service.getCredentialSecret(CLIENT_ID, API_ID);
assertEquals("SECRET", dto.getClientSecret());
assertEquals(DjbAuthType.OAUTH, dto.getAuthType());
}
}
@Test
void getCredentialSecret_notOwned_throwsAccessDenied() {
try (MockedStatic<SecurityUtil> sec = Mockito.mockStatic(SecurityUtil.class)) {
sec.when(SecurityUtil::isAuthenticated).thenReturn(true);
sec.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(user(RoleCode.ROLE_CORP_USER));
sec.when(SecurityUtil::getUserOrg).thenReturn(org());
when(credentialRepository.findByClientidAndOrgid(CLIENT_ID, ORG_ID)).thenReturn(Optional.empty());
assertThrows(AccessDeniedException.class, () -> service.getCredentialSecret(CLIENT_ID, API_ID));
}
}
@Test
void getCredentialSecret_anonymous_throwsAccessDenied() {
try (MockedStatic<SecurityUtil> sec = Mockito.mockStatic(SecurityUtil.class)) {
sec.when(SecurityUtil::isAuthenticated).thenReturn(false);
assertThrows(AccessDeniedException.class, () -> service.getCredentialSecret(CLIENT_ID, API_ID));
}
}
@Test
void resolveAuthType_apiKey() {
EAIMessageEntity msg = Mockito.mock(EAIMessageEntity.class);
when(msg.getAuthtype()).thenReturn("api_key");
when(eaiMessageRepository.findById(API_ID)).thenReturn(Optional.of(msg));
assertEquals(DjbAuthType.API_KEY, service.resolveAuthType(API_ID));
}
@Test
void resolveAuthType_missing_throwsUnsupported() {
when(eaiMessageRepository.findById("APIX")).thenReturn(Optional.empty());
assertThrows(DjbUnsupportedAuthTypeException.class, () -> service.resolveAuthType("APIX"));
}
}