diff --git a/src/main/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDB.java b/src/main/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDB.java index 1e3e3b1..e602393 100644 --- a/src/main/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDB.java +++ b/src/main/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDB.java @@ -384,6 +384,39 @@ public class AccessTokenManagerByDB implements Lifecycle { lifecycle.removeLifecycleListener(listener); } + /** + * 1. 기능 : 등록된 OAuth 인증 정보의 어댑터그룹명 목록을 반환한다. + * 2. 처리 개요 : 상태 조회 API 에서 사용한다. + * + * @return 어댑터그룹명 목록 + **/ + public Set getRegisteredAdapterGroupNames() { + return new TreeSet<>(outboundOAuthCredentialVos.keySet()); + } + + /** + * 1. 기능 : 어댑터그룹의 OAuth 인증 정보를 반환한다. + * 2. 처리 개요 : 상태 조회 API 에서 사용한다. + * 3. 주의사항 : clientSecret 이 들어있으므로 응답 전문에 그대로 담지 말 것. + * + * @return OAuth 인증 정보. 등록돼 있지 않으면 null. + **/ + public OutboundOAuthCredentialVo getOutboundOAuthCredentialVo(String adapterGroupName) { + return outboundOAuthCredentialVos.get(adapterGroupName); + } + + /** + * 1. 기능 : 캐시에 있는 토큰만 조회한다. + * 2. 처리 개요 : getAccessTokenVO() 와 달리 캐시에 없거나 만료됐어도 새로 발급하지 않는다. + * 상태 조회가 토큰 발급을 유발하면 안 되므로 조회 API 는 이 메서드를 쓴다. + * + * @return 캐시에 있는 토큰. 없으면 null. + * @exception UnsupportedOperationException 아웃바운드 토큰 캐시를 지원하지 않는 SessionManager 백엔드 + **/ + public AccessTokenVO peekAccessTokenVO(String adapterGroupName) { + return SessionManager.getInstance().peekOutboundAccessToken(adapterGroupName); + } + public boolean isOAuthCredentialRegistered(String adapterGroupName){ OutboundOAuthCredentialVo outboundOAuthCredentialVo = outboundOAuthCredentialVos.get(adapterGroupName); return outboundOAuthCredentialVo != null && "Y".equals(outboundOAuthCredentialVo.getUseYn()); diff --git a/src/main/java/com/eactive/eai/common/session/SessionManager.java b/src/main/java/com/eactive/eai/common/session/SessionManager.java index d237e1e..671ea97 100644 --- a/src/main/java/com/eactive/eai/common/session/SessionManager.java +++ b/src/main/java/com/eactive/eai/common/session/SessionManager.java @@ -214,6 +214,14 @@ public abstract class SessionManager implements Lifecycle { //Outbound Access Token Cache public abstract AccessTokenVO getOutboundAccessToken(String key, Function getNewToken); + /** + * 캐시에 있는 토큰만 조회한다. 없거나 만료됐어도 새로 발급하지 않는다. (상태 조회용) + * + * @param key 어댑터그룹명 + * @return 캐시에 있는 토큰. 없으면 null. + */ + public abstract AccessTokenVO peekOutboundAccessToken(String key); + public abstract void removeOutboundAccessToken(String key); public abstract void clearOutboundAccessToken(); diff --git a/src/main/java/com/eactive/eai/common/session/SessionManagerForEhcache.java b/src/main/java/com/eactive/eai/common/session/SessionManagerForEhcache.java index e3ae82e..6f628eb 100644 --- a/src/main/java/com/eactive/eai/common/session/SessionManagerForEhcache.java +++ b/src/main/java/com/eactive/eai/common/session/SessionManagerForEhcache.java @@ -951,6 +951,11 @@ public class SessionManagerForEhcache extends SessionManager { throw new UnsupportedOperationException(); } + @Override + public AccessTokenVO peekOutboundAccessToken(String key) { + throw new UnsupportedOperationException(); + } + @Override public void removeOutboundAccessToken(String key) { throw new UnsupportedOperationException(); diff --git a/src/main/java/com/eactive/eai/common/session/SessionManagerForIgnite.java b/src/main/java/com/eactive/eai/common/session/SessionManagerForIgnite.java index 16b5fa6..2411524 100644 --- a/src/main/java/com/eactive/eai/common/session/SessionManagerForIgnite.java +++ b/src/main/java/com/eactive/eai/common/session/SessionManagerForIgnite.java @@ -823,6 +823,14 @@ public class SessionManagerForIgnite extends SessionManager { public CacheInfoVO getOutboundAccessTokenCache() { return convert(cacheOutBoundAccessToken); } + + @Override + public AccessTokenVO peekOutboundAccessToken(String key) { + if (cacheOutBoundAccessToken == null) { + return null; + } + return cacheOutBoundAccessToken.get(key); + } @Override public void putWebSocketTimeout(String key, SessionVO value) { diff --git a/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusController.java b/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusController.java new file mode 100644 index 0000000..01fa6c3 --- /dev/null +++ b/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusController.java @@ -0,0 +1,62 @@ +package com.eactive.eai.manage.oauthtoken; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +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.RestController; + +/** + * 아웃바운드 OAuth 토큰 현황 조회 API. (HsmStatusController 와 동일한 응답 형태) + * + * 조회 전용이며 토큰을 발급하거나 캐시를 건드리지 않는다. + * accessToken 은 앞 8자만 남기고 마스킹하며 clientSecret 은 응답에 담지 않는다. + * + * GET /manage/oauth-token/status → 등록된 전체 어댑터그룹의 토큰 현황 + * GET /manage/oauth-token/status/{adapterGroupName} → 어댑터그룹 하나의 토큰 현황 + */ +@RestController +@RequestMapping("/manage/oauth-token") +public class OAuthTokenStatusController { + + private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8); + + @Autowired + private OAuthTokenStatusService oAuthTokenStatusService; + + @GetMapping("/status") + public ResponseEntity status() { + return respond(() -> { + List list = oAuthTokenStatusService.getStatusList(); + Map data = new HashMap<>(); + data.put("count", list.size()); + data.put("tokens", list); + return data; + }); + } + + @GetMapping("/status/{adapterGroupName}") + public ResponseEntity status(@PathVariable("adapterGroupName") String adapterGroupName) { + return respond(() -> oAuthTokenStatusService.getStatus(adapterGroupName)); + } + + private ResponseEntity respond(Callable action) { + Map result = new HashMap<>(); + try { + result.put("success", true); + result.put("data", action.call()); + } catch (Exception e) { + result.put("success", false); + result.put("message", e.getMessage()); + } + return ResponseEntity.ok().contentType(APPLICATION_JSON_UTF8).body(result); + } +} diff --git a/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusDTO.java b/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusDTO.java new file mode 100644 index 0000000..7b7806b --- /dev/null +++ b/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusDTO.java @@ -0,0 +1,60 @@ +package com.eactive.eai.manage.oauthtoken; + +import lombok.Data; + +/** + * 어댑터그룹별 아웃바운드 OAuth 토큰 현황. + * + * accessToken 값은 마스킹해서 담는다. clientSecret 은 담지 않는다. + */ +@Data +public class OAuthTokenStatusDTO { + + // ---- 등록 정보 ---- + + /** 어댑터그룹명 */ + String adapterGroupName; + + /** OAuth 인증 정보 사용 여부 (Y/N) */ + String useYn; + + /** 토큰 발급에 사용하는 구현체 클래스명 (어댑터 속성 ADAPTER_TOKEN_ISSUING_CLIENT_TYPE) */ + String tokenServiceClass; + + /** 토큰 발급 URL */ + String tokenUrl; + + /** 토큰 재발급 주기(초) */ + int intervalSec; + + // ---- 캐시 상태 ---- + + /** 캐시에 토큰이 있는지 */ + boolean cached; + + /** 캐시된 토큰이 만료됐는지 */ + boolean expired; + + /** 만료 시각 */ + String expiration; + + /** 만료까지 남은 초. 이미 만료됐으면 음수. */ + Long remainSec; + + // ---- 토큰 정보 ---- + + /** 앞 8자만 남기고 마스킹한 accessToken */ + String accessTokenMasked; + + /** accessToken 전체 길이 */ + Integer accessTokenLength; + + String tokenType; + + String scope; + + String clientId; + + /** 조회 불가 사유 등 부가 메시지 */ + String message; +} diff --git a/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusService.java b/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusService.java new file mode 100644 index 0000000..131e33f --- /dev/null +++ b/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusService.java @@ -0,0 +1,157 @@ +package com.eactive.eai.manage.oauthtoken; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Properties; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +import com.eactive.eai.adapter.AdapterGroupVO; +import com.eactive.eai.adapter.AdapterManager; +import com.eactive.eai.adapter.AdapterPropManager; +import com.eactive.eai.adapter.AdapterVO; +import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo; +import com.eactive.eai.common.authoutbound.AccessTokenManagerByDB; +import com.eactive.eai.common.util.Logger; +import com.openbanking.eai.common.token.AccessTokenVO; +import com.openbanking.eai.common.token.OAuth2AccessTokenVO; + +/** + * 아웃바운드 OAuth 토큰 현황 조회 서비스. + * + * 캐시에 있는 토큰만 읽는다(peek). 조회 때문에 토큰이 새로 발급되지 않도록 + * AccessTokenManagerByDB.getAccessTokenVO() 가 아니라 peekAccessTokenVO() 를 쓴다. + * + * accessToken 은 앞 8자만 남기고 마스킹하며 clientSecret 은 담지 않는다. + */ +@Service +public class OAuthTokenStatusService { + + private static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT); + + private static final String DATE_FORMAT = "yyyy-MM-dd HH:mm:ss"; + + /** 토큰 발급 구현체 클래스명을 담고 있는 어댑터 속성 키 */ + private static final String PROP_TOKEN_SERVICE_CLASS = "ADAPTER_TOKEN_ISSUING_CLIENT_TYPE"; + + /** accessToken 중 노출할 앞자리 수 */ + private static final int UNMASKED_LENGTH = 8; + + /** + * 등록된 모든 어댑터그룹의 토큰 현황을 반환한다. + * + * @return 어댑터그룹명 순으로 정렬된 현황 목록 + */ + public List getStatusList() { + List result = new ArrayList(); + for (String adapterGroupName : AccessTokenManagerByDB.getInstance().getRegisteredAdapterGroupNames()) { + result.add(getStatus(adapterGroupName)); + } + return result; + } + + /** + * 어댑터그룹 하나의 토큰 현황을 반환한다. + * + * @param adapterGroupName 어댑터그룹명 + * @return 토큰 현황. 등록돼 있지 않으면 message 만 채워서 반환한다. + */ + public OAuthTokenStatusDTO getStatus(String adapterGroupName) { + OAuthTokenStatusDTO dto = new OAuthTokenStatusDTO(); + dto.setAdapterGroupName(adapterGroupName); + + AccessTokenManagerByDB manager = AccessTokenManagerByDB.getInstance(); + OutboundOAuthCredentialVo credential = manager.getOutboundOAuthCredentialVo(adapterGroupName); + + if (credential == null) { + dto.setMessage("등록된 OAuth 인증 정보가 없습니다."); + return dto; + } + + dto.setUseYn(credential.getUseYn()); + dto.setTokenUrl(credential.getUrl()); + dto.setIntervalSec(credential.getIntervalSec()); + dto.setTokenServiceClass(findTokenServiceClass(adapterGroupName)); + + AccessTokenVO accessToken; + try { + accessToken = manager.peekAccessTokenVO(adapterGroupName); + } catch (UnsupportedOperationException e) { + dto.setMessage("현재 SessionManager 백엔드는 아웃바운드 토큰 캐시를 지원하지 않습니다."); + return dto; + } catch (Exception e) { + if (logger.isWarn()) { + logger.warn("토큰 캐시 조회 실패. adapterGroupName : " + adapterGroupName, e); + } + dto.setMessage("토큰 캐시 조회 실패 : " + e.getMessage()); + return dto; + } + + if (accessToken == null) { + dto.setMessage("캐시에 토큰이 없습니다."); + return dto; + } + + dto.setCached(true); + dto.setExpired(accessToken.isExpired()); + dto.setAccessTokenMasked(mask(accessToken.getAccessToken())); + dto.setAccessTokenLength(accessToken.getAccessToken() == null ? 0 : accessToken.getAccessToken().length()); + + Date expiration = accessToken.getExpiration(); + if (expiration != null) { + dto.setExpiration(new SimpleDateFormat(DATE_FORMAT).format(expiration)); + dto.setRemainSec((expiration.getTime() - System.currentTimeMillis()) / 1000L); + } + + if (accessToken instanceof OAuth2AccessTokenVO) { + OAuth2AccessTokenVO oauth2Token = (OAuth2AccessTokenVO) accessToken; + dto.setTokenType(oauth2Token.getTokenType()); + dto.setScope(oauth2Token.getScope()); + dto.setClientId(oauth2Token.getClientId()); + } + + return dto; + } + + /** + * 어댑터 속성에서 토큰 발급 구현체 클래스명을 찾는다. + * + * @param adapterGroupName 어댑터그룹명 + * @return 구현체 클래스명. 찾지 못하면 null. + */ + private String findTokenServiceClass(String adapterGroupName) { + try { + AdapterGroupVO gvo = AdapterManager.getInstance().getAdapterGroupVO(adapterGroupName); + if (gvo == null || !gvo.getAdapters().hasNext()) { + return null; + } + AdapterVO avo = gvo.getAdapters().next(); + Properties properties = AdapterPropManager.getInstance().getProperties(avo.getPropGroupName()); + return properties.getProperty(PROP_TOKEN_SERVICE_CLASS); + } catch (Exception e) { + if (logger.isDebug()) { + logger.debug("토큰 발급 구현체 조회 실패. adapterGroupName : " + adapterGroupName); + } + return null; + } + } + + /** + * accessToken 앞 8자만 남기고 마스킹한다. + * + * @param accessToken 토큰 값 + * @return 마스킹된 토큰 값 + */ + private String mask(String accessToken) { + if (StringUtils.isBlank(accessToken)) { + return null; + } + if (accessToken.length() <= UNMASKED_LENGTH) { + return StringUtils.repeat('*', accessToken.length()); + } + return StringUtils.substring(accessToken, 0, UNMASKED_LENGTH) + "***"; + } +} diff --git a/src/test/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusServiceTest.java b/src/test/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusServiceTest.java new file mode 100644 index 0000000..28d3708 --- /dev/null +++ b/src/test/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusServiceTest.java @@ -0,0 +1,210 @@ +package com.eactive.eai.manage.oauthtoken; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.context.ApplicationContext; + +import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo; +import com.eactive.eai.common.authoutbound.AccessTokenManagerByDB; +import com.eactive.eai.common.util.ApplicationContextProvider; +import com.openbanking.eai.common.token.OAuth2AccessTokenVO; + +/** + * OAuthTokenStatusService 단위테스트 + * + * AccessTokenManagerByDB 는 ApplicationContextProvider 를 통해 조회되므로 + * mock ApplicationContext 를 리플렉션으로 주입해 격리한다. + */ +class OAuthTokenStatusServiceTest { + + private static final String GROUP = "TESTGRP"; + + private OAuthTokenStatusService service; + private AccessTokenManagerByDB mockManager; + + @BeforeEach + void setUp() throws Exception { + service = new OAuthTokenStatusService(); + mockManager = Mockito.mock(AccessTokenManagerByDB.class); + + ApplicationContext mockCtx = Mockito.mock(ApplicationContext.class); + Mockito.when(mockCtx.getBean(AccessTokenManagerByDB.class)).thenReturn(mockManager); + Field ctxField = ApplicationContextProvider.class.getDeclaredField("context"); + ctxField.setAccessible(true); + ctxField.set(null, mockCtx); + } + + /** 인증 정보가 등록된 것으로 mock 설정한다. */ + private OutboundOAuthCredentialVo registerCredential(String adapterGroupName) { + OutboundOAuthCredentialVo credential = new OutboundOAuthCredentialVo(); + credential.setAdapterGroupName(adapterGroupName); + credential.setUseYn("Y"); + credential.setUrl("https://token.example.com/oauth/token"); + credential.setIntervalSec(1800); + credential.setClientId("client-1"); + credential.setClientSecret("super-secret"); + Mockito.when(mockManager.getOutboundOAuthCredentialVo(adapterGroupName)).thenReturn(credential); + return credential; + } + + /** 만료까지 expiresInSec 남은 토큰 */ + private OAuth2AccessTokenVO token(String accessToken, long expiresInSec) { + OAuth2AccessTokenVO vo = new OAuth2AccessTokenVO(); + vo.setAccessToken(accessToken); + vo.setTokenType("Bearer"); + vo.setScope("read"); + vo.setClientId("client-1"); + vo.setExpiration(new Date(System.currentTimeMillis() + expiresInSec * 1000L)); + return vo; + } + + @Test + @DisplayName("1. 등록되지 않은 어댑터그룹이면 사유만 반환한다") + void 미등록그룹() { + Mockito.when(mockManager.getOutboundOAuthCredentialVo(anyString())).thenReturn(null); + + OAuthTokenStatusDTO dto = service.getStatus(GROUP); + + assertEquals(GROUP, dto.getAdapterGroupName()); + assertFalse(dto.isCached()); + assertNotNull(dto.getMessage()); + } + + @Test + @DisplayName("2. 캐시에 토큰이 없으면 등록 정보만 반환한다") + void 캐시에토큰없음() { + registerCredential(GROUP); + Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(null); + + OAuthTokenStatusDTO dto = service.getStatus(GROUP); + + assertFalse(dto.isCached()); + assertEquals("Y", dto.getUseYn()); + assertEquals(1800, dto.getIntervalSec()); + assertEquals("https://token.example.com/oauth/token", dto.getTokenUrl()); + assertNotNull(dto.getMessage()); + } + + @Test + @DisplayName("3. 토큰 캐시를 지원하지 않는 백엔드면 사유를 담아 반환한다 (Ehcache)") + void 백엔드미지원() { + registerCredential(GROUP); + Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenThrow(new UnsupportedOperationException()); + + OAuthTokenStatusDTO dto = service.getStatus(GROUP); + + assertFalse(dto.isCached()); + assertTrue(dto.getMessage().contains("지원하지 않습니다")); + } + + @Test + @DisplayName("4. 캐시된 토큰의 만료/잔여시간/부가정보를 반환한다") + void 캐시된토큰() { + registerCredential(GROUP); + Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijklmnop", 600)); + + OAuthTokenStatusDTO dto = service.getStatus(GROUP); + + assertTrue(dto.isCached()); + assertFalse(dto.isExpired()); + assertNotNull(dto.getExpiration()); + assertTrue(dto.getRemainSec() > 0 && dto.getRemainSec() <= 600); + assertEquals("Bearer", dto.getTokenType()); + assertEquals("read", dto.getScope()); + assertEquals("client-1", dto.getClientId()); + } + + @Test + @DisplayName("5. accessToken 은 앞 8자만 남기고 마스킹한다") + void 토큰마스킹() { + registerCredential(GROUP); + Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijklmnop", 600)); + + OAuthTokenStatusDTO dto = service.getStatus(GROUP); + + assertEquals("abcdefgh***", dto.getAccessTokenMasked()); + assertEquals(16, dto.getAccessTokenLength()); + assertFalse(dto.getAccessTokenMasked().contains("ijklmnop"), "토큰 뒷부분이 노출되면 안 된다"); + } + + @Test + @DisplayName("6. 8자 이하 토큰은 전부 마스킹한다") + void 짧은토큰마스킹() { + registerCredential(GROUP); + Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abc", 600)); + + OAuthTokenStatusDTO dto = service.getStatus(GROUP); + + assertEquals("***", dto.getAccessTokenMasked()); + assertEquals(3, dto.getAccessTokenLength()); + } + + @Test + @DisplayName("7. 만료된 토큰은 expired=true, 잔여시간 음수") + void 만료된토큰() { + registerCredential(GROUP); + Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijkl", -60)); + + OAuthTokenStatusDTO dto = service.getStatus(GROUP); + + assertTrue(dto.isCached()); + assertTrue(dto.isExpired()); + assertTrue(dto.getRemainSec() < 0); + } + + @Test + @DisplayName("8. 응답에 clientSecret 은 담기지 않는다") + void secret비노출() { + registerCredential(GROUP); + Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijkl", 600)); + + OAuthTokenStatusDTO dto = service.getStatus(GROUP); + + assertFalse(dto.toString().contains("super-secret"), "clientSecret 이 응답에 포함되면 안 된다"); + } + + @Test + @DisplayName("9. 전체 목록은 등록된 어댑터그룹 수만큼 반환한다") + void 전체목록() { + Set groups = new LinkedHashSet<>(Arrays.asList("GRP-A", "GRP-B")); + Mockito.when(mockManager.getRegisteredAdapterGroupNames()).thenReturn(groups); + registerCredential("GRP-A"); + registerCredential("GRP-B"); + Mockito.when(mockManager.peekAccessTokenVO("GRP-A")).thenReturn(token("abcdefghijkl", 600)); + Mockito.when(mockManager.peekAccessTokenVO("GRP-B")).thenReturn(null); + + List list = service.getStatusList(); + + assertEquals(2, list.size()); + assertEquals("GRP-A", list.get(0).getAdapterGroupName()); + assertTrue(list.get(0).isCached()); + assertFalse(list.get(1).isCached()); + } + + @Test + @DisplayName("10. 조회가 토큰 발급을 유발하지 않는다 (peek 만 호출)") + void 발급유발없음() { + registerCredential(GROUP); + Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijkl", 600)); + + service.getStatus(GROUP); + + Mockito.verify(mockManager).peekAccessTokenVO(GROUP); + Mockito.verify(mockManager, Mockito.never()).getAccessTokenVO(anyString()); + } +}