From 6c231c418912a141adce60ea80dfe89c59cb5f19 Mon Sep 17 00:00:00 2001 From: Rinjae Date: Thu, 30 Jul 2026 16:22:45 +0900 Subject: [PATCH] =?UTF-8?q?API=20=ED=95=B4=EC=A7=80=20=ED=8C=9D=EC=97=85?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EC=98=A4=EB=A5=98=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20-=20API=20=ED=95=B4=EC=A7=80?= =?UTF-8?q?=20=EC=8B=A0=EC=B2=AD=20=ED=8C=9D=EC=97=85=20UI=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20-=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=B9=9C=ED=99=94=EC=A0=81=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=ED=97=AC=ED=8D=BC(UserErrorMe?= =?UTF-8?q?ssageResolver)=20=EC=B6=94=EA=B0=80=20-=20Webhook=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD/=EC=88=98=EC=A0=95,=20=EA=B8=80=EB=A1=9C=EB=B2=8C=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=EC=97=90=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/app/controller/MyAppController.java | 46 ++-- .../apps/app/service/AppServiceFacade.java | 97 +++++-- .../PortalGlobalExceptionHandler.java | 50 ++-- .../exception/PortalRestExceptionHandler.java | 8 +- .../exception/UserErrorMessageResolver.java | 238 ++++++++++++++++++ .../webhook/controller/WebhookController.java | 9 +- .../static/js/popup/custom-popups.js | 104 ++++++++ .../views/apps/community/mainInquiryForm.html | 4 +- .../apps/community/mainPartnershipForm.html | 2 +- .../views/apps/mypage/apiKeyList.html | 105 ++++---- .../views/apps/mypage/credentialDetail.html | 30 +-- .../views/apps/register/userRegister.html | 4 +- .../fragment/auth-number-validation.html | 2 +- .../fragment/corp-reg-no-validation.html | 4 +- .../views/fragment/email-validation.html | 4 +- .../views/fragment/popup/customPopups.html | 1 + .../fragment/popup/terminateRequestPopup.html | 49 ++++ .../fragment/register-auth-validation.html | 2 +- .../UserErrorMessageResolverTest.java | 76 ++++++ 19 files changed, 696 insertions(+), 139 deletions(-) create mode 100644 src/main/java/com/eactive/apim/portal/common/exception/UserErrorMessageResolver.java create mode 100644 src/main/resources/templates/views/fragment/popup/terminateRequestPopup.html create mode 100644 src/test/java/com/eactive/apim/portal/common/exception/UserErrorMessageResolverTest.java diff --git a/src/main/java/com/eactive/apim/portal/apps/app/controller/MyAppController.java b/src/main/java/com/eactive/apim/portal/apps/app/controller/MyAppController.java index 40727cf..873c07c 100644 --- a/src/main/java/com/eactive/apim/portal/apps/app/controller/MyAppController.java +++ b/src/main/java/com/eactive/apim/portal/apps/app/controller/MyAppController.java @@ -11,11 +11,11 @@ import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService; import com.eactive.apim.portal.apps.app.dto.ApiKeyRegistrationDTO; import com.eactive.apim.portal.apps.app.dto.AppRequestDTO; import com.eactive.apim.portal.apps.app.dto.ClientDTO; -import com.eactive.apim.portal.apps.app.service.AdminGatewayClient; import com.eactive.apim.portal.apps.app.service.AppServiceFacade; import com.eactive.apim.portal.apps.auth.twofactor.StepUpProtectedPaths; import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties; import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService; +import com.eactive.apim.portal.common.exception.UserErrorMessageResolver; import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; import com.eactive.apim.portal.common.util.ApiServiceHelper; import com.eactive.apim.portal.common.util.SecurityUtil; @@ -84,7 +84,6 @@ public class MyAppController { private final ApiService apiService; private final ApiServiceHelper apiServiceHelper; private final FileTypeDetector fileTypeDetector; - private final AdminGatewayClient adminGatewayClient; private final TwoFactorService twoFactorService; private final TwoFactorProperties twoFactorProperties; @@ -184,6 +183,7 @@ public class MyAppController { model.addAttribute("apiKey", apiKey); model.addAttribute("secretAvailable", secretAvailable); + model.addAttribute("pendingDeleteRequest", appServiceFacade.hasPendingDeleteRequest(id)); return new ModelAndView(CREDENTIAL_DETAIL); } @@ -239,10 +239,12 @@ public class MyAppController { } /** - * API Key를 삭제합니다. - * AJAX 요청을 지원하기 위해 @ResponseBody를 사용하여 JSON 응답 반환 + * API 이용 해지를 신청합니다. (AppRequestType.DELETE 결재 신청 생성) + * 즉시 차단/삭제하지 않으며, eapim-admin 관리자 승인 시점에 GW 차단/삭제와 + * PTL_CREDENTIAL 삭제가 실행됩니다. 승인 전까지 API는 정상 동작합니다. + * 본인 확인은 step-up 2FA({@link StepUpProtectedPaths#APP_KEY_DELETE} 인터셉터 가드)가 담당합니다. * - * @param requestData 요청 데이터 (clientId와 type 포함) + * @param requestData 요청 데이터 (clientId, reason) * @return 성공/실패 결과를 담은 Map */ @PostMapping("/api_key_delete") @@ -258,38 +260,44 @@ public class MyAppController { return result; } + String reason = requestData.get("reason"); + if (reason == null || reason.trim().isEmpty()) { + result.put("success", false); + result.put("msg", "해지 사유를 입력해 주세요."); + return result; + } + if (reason.length() > 1000) { + result.put("success", false); + result.put("msg", "해지 사유는 1000자 이내로 입력해 주세요."); + return result; + } + PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser(); String orgId = user.getPortalOrg().getId(); - // 1. 소유권 확인 (다른 조직의 인증키 차단/삭제 방지) + // 1. 소유권 확인 (다른 조직의 인증키 해지 방지) if (appServiceFacade.getApiKey(orgId, clientId) == null) { result.put("success", false); result.put("msg", "해당 인증키를 찾을 수 없습니다."); return result; } - // 2. GW 차단(appstatus=0)+리로드를 admin 에 위임. 실패하면 포털 레코드를 삭제하지 않는다. + // 2. 해지 신청 생성 + 결재 개시 (GW/credential 은 승인 시점에 admin 이 처리) try { - adminGatewayClient.blockClient(clientId); - } catch (Exception e) { - log.error("GW 차단/리로드 실패로 인증키 삭제 중단 - clientId={}", clientId, e); + appServiceFacade.createDeleteRequest(clientId, reason.trim(), user.getPortalOrg()); + } catch (IllegalStateException e) { result.put("success", false); - result.put("msg", "게이트웨이 차단 처리에 실패하여 삭제를 중단했습니다. 잠시 후 다시 시도해 주세요."); + result.put("msg", e.getMessage()); return result; - } - - // 3. GW 차단 성공 시에만 포털 credential 삭제 - try { - appServiceFacade.deleteApp(orgId, clientId); } catch (Exception e) { - log.error("포털 credential 삭제 실패 - clientId={}", clientId, e); + log.error("API 이용 해지 신청 실패 - clientId={}", clientId, e); result.put("success", false); - result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage()); + result.put("msg", UserErrorMessageResolver.resolveAsHtml(e)); return result; } result.put("success", true); - result.put("msg", "API Key가 삭제되었습니다."); + result.put("msg", "해지 신청이 접수되었습니다. 관리자 승인 후 인증키가 삭제됩니다."); return result; } diff --git a/src/main/java/com/eactive/apim/portal/apps/app/service/AppServiceFacade.java b/src/main/java/com/eactive/apim/portal/apps/app/service/AppServiceFacade.java index dfb46fb..7469c8f 100644 --- a/src/main/java/com/eactive/apim/portal/apps/app/service/AppServiceFacade.java +++ b/src/main/java/com/eactive/apim/portal/apps/app/service/AppServiceFacade.java @@ -31,6 +31,7 @@ import com.eactive.apim.portal.portalorg.entity.PortalOrg; import java.io.IOException; import java.time.LocalDateTime; import java.util.Arrays; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Optional; @@ -64,7 +65,11 @@ public class AppServiceFacade { public List getApikeyList(PortalOrg portalOrg) { List clients = credentialRepository.findAllByOrgid(portalOrg.getId()); - return clients.stream().map(credentialMapper::toVo).collect(Collectors.toList()); + // 최근 수정(발급/변경) 순으로 정렬. 수정일 없는 건은 뒤로. + return clients.stream() + .sorted(Comparator.comparing(Credential::getModifiedon, + Comparator.nullsLast(Comparator.reverseOrder()))) + .map(credentialMapper::toVo).collect(Collectors.toList()); } @@ -76,9 +81,26 @@ public class AppServiceFacade { // 승인정보(approval) 없는 신청도 목록에 노출`한다. (사용자가 직접 삭제 가능) appRequests.addAll(appRequestRepository .findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types)); + // 진행중(PROCESSING) → 요청됨(REQUESTED) → 승인정보 없음 순, 같은 상태끼리는 최근 신청 순 + appRequests.sort(Comparator.comparingInt(this::pendingStatusRank) + .thenComparing(AppRequest::getCreatedDate, Comparator.nullsLast(Comparator.reverseOrder()))); + return appRequests; } + private int pendingStatusRank(AppRequest request) { + if (request.getApproval() == null) { + return 3; + } + if (request.getApproval().getApprovalStatus() instanceof ProcessingState) { + return 1; + } + if (request.getApproval().getApprovalStatus() instanceof RequestedState) { + return 2; + } + return 3; + } + public ClientDTO getApiKey(String orgid, String clientId) { return credentialRepository.findByClientidAndOrgid(clientId, orgid).map(credentialMapper::toVo).orElse(null); } @@ -138,13 +160,70 @@ public class AppServiceFacade { approvalService.beginApproval(approvalId); } + /** + * API 이용 해지(DELETE) 결재 신청을 생성하고 결재를 개시합니다. + * GW 차단/삭제와 PTL_CREDENTIAL 삭제는 여기서 하지 않으며, + * eapim-admin 승인 시점에 PortalAppApprovalListener 가 수행합니다. + * + * @throws IllegalStateException 중복 신청, 변경 신청 진행 중, 승인라인 미등록 등 사용자에게 안내할 상황 + */ + public void createDeleteRequest(String clientId, String reason, PortalOrg portalOrg) { + // 1. 진행 중(REQUESTED/PROCESSING)인 해지·변경 신청 중복 가드 + List related = appRequestRepository.findAllByClientIdsContainsAndTypeIsIn( + clientId, Arrays.asList(AppRequestType.MODIFY, AppRequestType.DELETE)); + for (AppRequest r : related) { + if (r.getApproval() == null) { + continue; + } + boolean inProgress = r.getApproval().getApprovalStatus() instanceof RequestedState + || r.getApproval().getApprovalStatus() instanceof ProcessingState; + if (!inProgress) { + continue; + } + if (AppRequestType.DELETE.equals(r.getType())) { + throw new IllegalStateException("이미 해지 신청이 진행 중입니다. 결재 완료 후 다시 확인해 주세요."); + } + throw new IllegalStateException("해당 인증키의 변경 신청이 진행 중이라 해지를 신청할 수 없습니다. 변경 결재 완료 또는 취소 후 다시 시도해 주세요."); + } + + // 2. DELETE 신청 생성 (createAppRequest 의 DELETE 분기가 clientName/prevApiList/apiList 를 채운다) + AppRequestDTO dto = new AppRequestDTO(); + dto.setType(AppRequestType.DELETE); + dto.setClientId(clientId); + dto.setReason(reason); + dto.setOrg(portalOrgMapper.toVo(portalOrg)); + + AppRequestDTO saved = createAppRequest(dto); + + // 3. 승인라인 미등록이면 approval 이 null — 결재 없는 해지 신청은 만들지 않는다(트랜잭션 롤백) + if (saved.getApproval() == null || saved.getApproval().getId() == null) { + throw new IllegalStateException("APP 승인라인이 등록되어 있지 않아 해지를 신청할 수 없습니다. 관리자에게 문의해 주세요."); + } + + beginApproval(saved.getApproval().getId()); + } + + /** + * 해당 클라이언트의 해지 신청이 결재 진행 중(REQUESTED/PROCESSING)인지 확인합니다. + * 상세 화면의 해지 버튼 비활성화에 사용됩니다. + */ + public boolean hasPendingDeleteRequest(String clientId) { + return appRequestRepository.findAllByClientIdsContainsAndTypeIsIn( + clientId, Arrays.asList(AppRequestType.DELETE)).stream() + .anyMatch(r -> r.getApproval() != null + && (r.getApproval().getApprovalStatus() instanceof RequestedState + || r.getApproval().getApprovalStatus() instanceof ProcessingState)); + } + public void cancelApiRequest(String id, PortalOrg portalOrg) { appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(request -> { if (request.getApproval() == null) { // 승인정보 없는 신청은 결재 워크플로우가 없으므로 즉시 삭제. // 단, GW에 클라이언트가 존재할 수 있으므로 차단(appstatus=0)+리로드를 먼저 수행하고 // 실패 시 삭제를 중단한다. (/api_key_delete 와 동일한 순서) - if (StringUtils.isNotBlank(request.getClientId())) { + // DELETE(해지) 신청은 살아있는 클라이언트가 대상이므로 취소 시 GW 를 건드리면 안 된다. + if (StringUtils.isNotBlank(request.getClientId()) + && !AppRequestType.DELETE.equals(request.getType())) { try { adminGatewayClient.blockClient(request.getClientId()); } catch (Exception e) { @@ -343,18 +422,4 @@ public class AppServiceFacade { return secret; } - /** - * API Key(Credential)를 즉시 삭제합니다. - * 승인 프로세스 없이 바로 삭제 처리됩니다. - * - * @param orgId 조직 ID - * @param clientId 삭제할 클라이언트 ID - * @throws NotFoundException 클라이언트를 찾을 수 없는 경우 - */ - public void deleteApp(String orgId, String clientId) { - Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId) - .orElseThrow(() -> new NotFoundException("Client not found: " + clientId)); - - credentialRepository.delete(credential); - } } diff --git a/src/main/java/com/eactive/apim/portal/common/exception/PortalGlobalExceptionHandler.java b/src/main/java/com/eactive/apim/portal/common/exception/PortalGlobalExceptionHandler.java index 528ac15..8afd94e 100644 --- a/src/main/java/com/eactive/apim/portal/common/exception/PortalGlobalExceptionHandler.java +++ b/src/main/java/com/eactive/apim/portal/common/exception/PortalGlobalExceptionHandler.java @@ -2,8 +2,6 @@ package com.eactive.apim.portal.common.exception; import java.util.Arrays; -import java.util.HashMap; -import java.util.Map; import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; @@ -13,11 +11,12 @@ import com.eactive.apim.portal.common.util.StringMaskingUtil; import com.eactive.apim.portal.config.PortalProperties; import com.eactive.apim.portal.file.exception.InvalidFileException; import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.exception.ExceptionUtils; import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.annotation.Order; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Controller; import org.springframework.web.HttpMediaTypeNotSupportedException; @@ -39,6 +38,7 @@ public class PortalGlobalExceptionHandler { private final Logger log = LoggerFactory.getLogger(getClass()); private final PortalProperties portalProperties; + private final Environment environment; @ExceptionHandler(value = NotFoundException.class) public ModelAndView handleINotFoundException(HttpServletRequest request, NotFoundException ex) { @@ -129,10 +129,10 @@ public class PortalGlobalExceptionHandler { @ExceptionHandler(value = {IllegalArgumentException.class}) public ModelAndView handleIllegalArgumentException(HttpServletRequest request, IllegalArgumentException ex) { - log.error(ex.getMessage()); - ModelAndView modelAndView = new ModelAndView(); - modelAndView.addObject("errorMessage", ex.getMessage()); - modelAndView.setViewName("error"); + log.error("잘못된 요청 - uri={}, message={}", request.getRequestURI(), ex.getMessage()); + ModelAndView modelAndView = new ModelAndView("error"); + modelAndView.addObject("errorTitle", "요청을 처리할 수 없습니다."); + modelAndView.addObject("errorDescription", UserErrorMessageResolver.resolve(ex)); return modelAndView; } @@ -146,34 +146,40 @@ public class PortalGlobalExceptionHandler { @ExceptionHandler(value = HttpMediaTypeNotSupportedException.class) public ModelAndView handleHttpMediaTypeNotSupportedException(HttpServletRequest request, HttpMediaTypeNotSupportedException ex) { - log.error(ex.getMessage()); - ModelAndView modelAndView = new ModelAndView(); - modelAndView.addObject("errorMessage", ex.getMessage()); - modelAndView.setViewName("error"); + log.error("지원하지 않는 요청 형식 - uri={}, message={}", request.getRequestURI(), ex.getMessage()); + ModelAndView modelAndView = new ModelAndView("error"); + modelAndView.addObject("errorTitle", "요청을 처리할 수 없습니다."); + modelAndView.addObject("errorDescription", "지원하지 않는 요청 형식입니다.\n잠시 후 다시 시도해 주세요."); return modelAndView; } + /** + * 처리되지 않은 예외. 예외 원문은 로그에만 남기고 화면에는 사용자 안내 문구를 표시한다. + * 원문(클래스명/메시지)은 운영(prod) 이외 환경의 상세 영역에만 노출한다. + */ @ExceptionHandler(value = Exception.class) public ModelAndView handleException(HttpServletRequest request, Exception ex) { - Map params = new HashMap<>(2); - params.put("errorMessage", ex.getLocalizedMessage()); - params.put("stackTrace", ExceptionUtils.getStackTrace(ex)); // Apache Commons Lang - params.put("requestURL", request.getRequestURL().toString()); - - String mapAsString = request.getParameterMap().entrySet() + String requestParams = request.getParameterMap().entrySet() .stream() .map(entry -> entry.getKey() + "=" + Arrays.toString(entry.getValue())) .collect(Collectors.joining(", ")); - params.put("requestParams", mapAsString); + log.error("Exception occurred - url={}, params={}", request.getRequestURL(), requestParams, ex); - log.error("Exception occurred: ", ex); - ModelAndView modelAndView = new ModelAndView(); - modelAndView.addObject("errorMessage", ex.getMessage()); - modelAndView.setViewName("error"); + ModelAndView modelAndView = new ModelAndView("error"); + modelAndView.addObject("errorTitle", "서비스 처리 중 오류가 발생했습니다."); + modelAndView.addObject("errorDescription", UserErrorMessageResolver.resolve(ex)); + if (!isProd()) { + modelAndView.addObject("errorMessage", ex.getClass().getName() + ": " + ex.getMessage()); + modelAndView.addObject("activeProfile", String.join(", ", environment.getActiveProfiles())); + } return modelAndView; } + private boolean isProd() { + return environment.acceptsProfiles(Profiles.of("prod")); + } + @ExceptionHandler(value = InvalidFileException.class) public ModelAndView handleInvalidFileException(HttpServletRequest request, RedirectAttributes redirectAttributes, InvalidFileException ex) { ModelAndView modelAndView = new ModelAndView(); diff --git a/src/main/java/com/eactive/apim/portal/common/exception/PortalRestExceptionHandler.java b/src/main/java/com/eactive/apim/portal/common/exception/PortalRestExceptionHandler.java index 1b69b4f..915e19b 100644 --- a/src/main/java/com/eactive/apim/portal/common/exception/PortalRestExceptionHandler.java +++ b/src/main/java/com/eactive/apim/portal/common/exception/PortalRestExceptionHandler.java @@ -53,10 +53,14 @@ public class PortalRestExceptionHandler { return new ResponseEntity<>(response, HttpStatus.NOT_FOUND); } + /** + * 처리되지 않은 예외. 원문 메시지는 로그에만 남기고, 화면에는 사용자 안내 문구를 내려준다. + * (JTA 롤백/DB 락 같은 인프라 예외의 영문 원문이 팝업에 그대로 노출되지 않도록) + */ @ExceptionHandler(value = Exception.class) public ResponseEntity handleUnknownxception(HttpServletRequest request, Exception ex) { - ex.printStackTrace(); - ResponseDTO response = new ResponseDTO(500, "500", ex.getMessage()); + log.error("처리되지 않은 예외 - uri={}", request.getRequestURI(), ex); + ResponseDTO response = new ResponseDTO(500, "500", UserErrorMessageResolver.resolveAsHtml(ex)); return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); } diff --git a/src/main/java/com/eactive/apim/portal/common/exception/UserErrorMessageResolver.java b/src/main/java/com/eactive/apim/portal/common/exception/UserErrorMessageResolver.java new file mode 100644 index 0000000..9e43f66 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/exception/UserErrorMessageResolver.java @@ -0,0 +1,238 @@ +package com.eactive.apim.portal.common.exception; + +import org.springframework.dao.CannotAcquireLockException; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.dao.PessimisticLockingFailureException; +import org.springframework.dao.QueryTimeoutException; +import org.springframework.transaction.CannotCreateTransactionException; +import org.springframework.transaction.TransactionException; + +/** + * 예외를 사용자에게 보여줄 안내 문구로 변환한다. + * + *

JTA 롤백/DB 락/타임아웃 같은 인프라 예외는 원문(예: {@code "JTA transaction unexpectedly rolled back + * (maybe due to a timeout); nested exception is javax.transaction.RollbackException"})이 그대로 + * 팝업·에러 화면에 노출되면 사용자가 이해할 수 없고 내부 구조까지 드러난다. 이 클래스가 정해진 한글 문구로 치환한다. + * + *

서비스 코드가 의도적으로 던진 한글 안내문(예: {@code IllegalStateException("이미 초대가 진행 중입니다.")})은 + * 그대로 유지한다. 판단은 {@link #looksUserFacing(String)} 의 휴리스틱(한글 포함 + 기술 토큰 없음)을 따른다. + * + *

반환 문구는 평문이며 줄바꿈은 {@code \n} 이다. HTML 팝업으로 내려줄 때는 {@link #toHtml(String)} 을 쓴다. + */ +public final class UserErrorMessageResolver { + + /** 원인을 특정할 수 없을 때의 기본 문구. */ + public static final String DEFAULT_MESSAGE = + "요청을 처리하는 중 오류가 발생했습니다.\n잠시 후 다시 시도해 주세요."; + + private static final String ROLLBACK_MESSAGE = + "요청 처리가 정상적으로 끝나지 않아 변경 내용이 저장되지 않았습니다.\n잠시 후 다시 시도해 주세요. 같은 문제가 반복되면 관리자에게 문의해 주세요."; + + private static final String TIMEOUT_MESSAGE = + "처리 시간이 초과되어 요청이 취소되었습니다.\n잠시 후 다시 시도해 주세요."; + + private static final String CONFLICT_MESSAGE = + "이미 등록된 정보이거나 다른 정보와 충돌하여 저장할 수 없습니다.\n입력 내용을 확인해 주세요."; + + private static final String LOCK_MESSAGE = + "다른 사용자가 동일한 정보를 변경하고 있습니다.\n잠시 후 다시 시도해 주세요."; + + private static final String STALE_MESSAGE = + "다른 사용자가 먼저 정보를 변경했습니다.\n화면을 새로 고친 뒤 다시 시도해 주세요."; + + private static final String CONNECTION_MESSAGE = + "시스템 연결이 원활하지 않아 요청을 처리하지 못했습니다.\n잠시 후 다시 시도해 주세요."; + + /** 원문 노출을 막아야 하는 기술 토큰. 메시지에 하나라도 있으면 사용자 안내문으로 보지 않는다. */ + private static final String[] TECHNICAL_TOKENS = { + "exception", "Exception", "rollback", "Rollback", "transaction", "Transaction", + "SQL", "ORA-", "JTA", "JDBC", "Hibernate", "hibernate", "com.eactive", "org.springframework", + "javax.", "java.", "oracle.", "at com.", "Caused by", "null pointer", "NullPointer", + "constraint", "Constraint", "statement", "Statement", "SocketTimeout", "Connection" + }; + + private UserErrorMessageResolver() { + } + + /** + * 예외에서 사용자 안내 문구를 만든다. + * + * @param ex 발생한 예외 (null 허용) + * @return 사용자에게 보여줄 평문 문구. 줄바꿈은 {@code \n} + */ + public static String resolve(Throwable ex) { + if (ex == null) { + return DEFAULT_MESSAGE; + } + + String infraMessage = resolveInfrastructureMessage(ex); + if (infraMessage != null) { + return infraMessage; + } + + // 서비스가 의도적으로 던진 한글 안내문은 그대로 전달 + String original = ex.getMessage(); + if (looksUserFacing(original)) { + return original; + } + + return DEFAULT_MESSAGE; + } + + /** {@link #resolve(Throwable)} 결과를 팝업(HTML)용으로 변환한다. */ + public static String resolveAsHtml(Throwable ex) { + return toHtml(resolve(ex)); + } + + /** 평문 줄바꿈을 {@code
} 로 바꾼다. */ + public static String toHtml(String plainMessage) { + if (plainMessage == null) { + return null; + } + return plainMessage.replace("\n", "
"); + } + + /** + * 트랜잭션/DB/연결 계열 인프라 예외인지 원인 체인을 따라가며 판별한다. + * + * @return 해당 문구, 인프라 예외가 아니면 null + */ + private static String resolveInfrastructureMessage(Throwable ex) { + String chainText = causeChainText(ex); + + // 1. 데이터 충돌(유니크/FK/NOT NULL) — 롤백 판정보다 먼저: 롤백 예외가 이를 감싸고 있어도 원인이 더 구체적이다. + if (hasType(ex, DataIntegrityViolationException.class) + || containsAny(chainText, "org.hibernate.exception.ConstraintViolationException", + "SQLIntegrityConstraintViolationException", + "ORA-00001", "ORA-01400", "ORA-02291", "ORA-02292", "ORA-12899")) { + return CONFLICT_MESSAGE; + } + + // 2. 낙관적 락 충돌 + if (hasType(ex, OptimisticLockingFailureException.class) + || containsAny(chainText, "OptimisticLockException", "StaleObjectStateException", "StaleStateException")) { + return STALE_MESSAGE; + } + + // 3. 비관적 락 / 데드락 + if (hasType(ex, PessimisticLockingFailureException.class) + || hasType(ex, CannotAcquireLockException.class) + || containsAny(chainText, "ORA-00060", "ORA-02049", "ORA-00054", "deadlock")) { + return LOCK_MESSAGE; + } + + // 4. 타임아웃 (쿼리/소켓/사용자 취소) + if (hasType(ex, QueryTimeoutException.class) + || containsAny(chainText, "SocketTimeoutException", "QueryTimeoutException", "ORA-01013")) { + return TIMEOUT_MESSAGE; + } + + // 5. 연결 실패 + if (hasType(ex, DataAccessResourceFailureException.class) + || hasType(ex, CannotCreateTransactionException.class) + || containsAny(chainText, "SQLRecoverableException", "ConnectException", "UnknownHostException", + "ORA-03113", "ORA-03114", "ORA-12541", "ORA-12170")) { + return CONNECTION_MESSAGE; + } + + // 6. JTA 롤백 계열 — 원인을 특정하지 못한 트랜잭션 실패 + if (hasType(ex, TransactionException.class) + || containsAny(chainText, "RollbackException", "HeuristicMixedException", "HeuristicRollbackException", + "rolled back", "rollback only")) { + // "Transaction set to rollback only" 은 내부 예외가 삼켜진 경우다. Spring 원문에 "maybe due to a + // timeout" 이 붙어 있어도 실제 타임아웃이 아니므로 타임아웃 문구를 쓰지 않는다. + if (!containsAny(chainText, "rollback only") + && containsAny(chainText, "timeout", "timed out")) { + return TIMEOUT_MESSAGE; + } + return ROLLBACK_MESSAGE; + } + + return null; + } + + /** + * 원문을 그대로 사용자에게 보여줘도 되는 안내문인지 판단한다. + * 한글이 포함되고 기술 토큰이 없어야 한다. + */ + private static boolean looksUserFacing(String message) { + if (message == null) { + return false; + } + String trimmed = message.trim(); + if (trimmed.isEmpty() || trimmed.length() > 200) { + return false; + } + if (!containsHangul(trimmed)) { + return false; + } + for (String token : TECHNICAL_TOKENS) { + if (trimmed.contains(token)) { + return false; + } + } + return true; + } + + private static boolean containsHangul(String text) { + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c >= 0xAC00 && c <= 0xD7A3) { // 한글 음절 + return true; + } + if (c >= 0x1100 && c <= 0x11FF) { // 한글 자모 + return true; + } + } + return false; + } + + /** 원인 체인의 클래스명 + 메시지를 한 문자열로 모은다(순환 참조 방지). */ + private static String causeChainText(Throwable ex) { + StringBuilder sb = new StringBuilder(); + Throwable current = ex; + int depth = 0; + while (current != null && depth < 10) { + sb.append(current.getClass().getName()); + if (current.getMessage() != null) { + sb.append(' ').append(current.getMessage()); + } + sb.append('\n'); + Throwable cause = current.getCause(); + if (cause == current) { + break; + } + current = cause; + depth++; + } + return sb.toString(); + } + + private static boolean hasType(Throwable ex, Class type) { + Throwable current = ex; + int depth = 0; + while (current != null && depth < 10) { + if (type.isInstance(current)) { + return true; + } + Throwable cause = current.getCause(); + if (cause == current) { + return false; + } + current = cause; + depth++; + } + return false; + } + + private static boolean containsAny(String text, String... keywords) { + for (String keyword : keywords) { + if (text.contains(keyword)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/eactive/apim/portal/djb/webhook/controller/WebhookController.java b/src/main/java/com/eactive/apim/portal/djb/webhook/controller/WebhookController.java index 1eb949d..84b0320 100644 --- a/src/main/java/com/eactive/apim/portal/djb/webhook/controller/WebhookController.java +++ b/src/main/java/com/eactive/apim/portal/djb/webhook/controller/WebhookController.java @@ -3,6 +3,7 @@ package com.eactive.apim.portal.djb.webhook.controller; import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch; import com.eactive.apim.portal.apps.app.service.AppServiceFacade; import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService; +import com.eactive.apim.portal.common.exception.UserErrorMessageResolver; import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; import com.eactive.apim.portal.common.util.SecurityUtil; import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO; @@ -163,8 +164,8 @@ public class WebhookController { redirectAttributes.addFlashAttribute("registrationSuccess", true); return new ModelAndView("redirect:/webhook/register/step3"); } catch (RuntimeException e) { - log.warn("Webhook 신청 실패 orgId={} : {}", currentOrgId(), e.getMessage()); - redirectAttributes.addFlashAttribute("error", e.getMessage()); + log.warn("Webhook 신청 실패 orgId={}", currentOrgId(), e); + redirectAttributes.addFlashAttribute("error", UserErrorMessageResolver.resolve(e)); return new ModelAndView("redirect:/webhook/register/step2"); } } @@ -270,8 +271,8 @@ public class WebhookController { redirectAttributes.addFlashAttribute("modifySuccess", true); return new ModelAndView("redirect:/webhook/modify/step3"); } catch (RuntimeException e) { - log.warn("Webhook 수정 실패 orgId={} : {}", currentOrgId(), e.getMessage()); - redirectAttributes.addFlashAttribute("error", e.getMessage()); + log.warn("Webhook 수정 실패 orgId={}", currentOrgId(), e); + redirectAttributes.addFlashAttribute("error", UserErrorMessageResolver.resolve(e)); return new ModelAndView("redirect:/webhook/modify/step2"); } } diff --git a/src/main/resources/static/js/popup/custom-popups.js b/src/main/resources/static/js/popup/custom-popups.js index 79c36f4..e4e90ba 100644 --- a/src/main/resources/static/js/popup/custom-popups.js +++ b/src/main/resources/static/js/popup/custom-popups.js @@ -246,6 +246,110 @@ const customPopups = { $('#passwordPopupError').removeClass('show'); $('#passwordPopupInput').removeClass('error'); }, + /** + * API 이용 해지 신청 팝업 표시 (경고문 + 사유 필수 — 본인 확인은 step-up 2FA가 담당) + * @param {Object} options - 팝업 옵션 + * @param {Function} options.onConfirm - 해지 신청 버튼 클릭 시 호출되는 콜백 (파라미터: reason) + * @param {Function} options.onCancel - 취소 버튼 클릭 시 호출되는 콜백 (선택사항) + */ + showTerminateRequest: function (options) { + options = options || {}; + + const onConfirm = options.onConfirm; + const onCancel = options.onCancel; + + // 입력 필드 및 에러 초기화 + $('#terminateReasonInput').val('').removeClass('error'); + $('#terminatePopupError').removeClass('show').text(''); + + // 팝업 표시 (modal 구조 사용) + $('#terminateRequestPopup').show(); + setTimeout(function() { + $('#terminateModalBackdrop').addClass('show'); + $('#terminateModal').addClass('show'); + }, 10); + + // Body 스크롤 방지 + $('body').css('overflow', 'hidden'); + + // 사유 입력 필드에 포커스 + setTimeout(function() { + $('#terminateReasonInput').focus(); + }, 350); + + // 확인 버튼 이벤트 (기존 이벤트 제거 후 재등록) + $('#terminatePopupConfirmButton').off('click').on('click', function () { + const reason = $('#terminateReasonInput').val().trim(); + + if (!reason) { + customPopups.showTerminateError('해지 사유를 입력해주세요.'); + $('#terminateReasonInput').addClass('error').focus(); + return; + } + + if (typeof onConfirm === 'function') { + onConfirm(reason); + } + }); + + // 취소 버튼 이벤트 + $('#terminatePopupCancelButton').off('click').on('click', function () { + customPopups.hideTerminateRequest(); + if (typeof onCancel === 'function') { + onCancel(); + } + }); + + // 닫기 버튼 이벤트 + $('#terminatePopupCloseButton').off('click').on('click', function () { + customPopups.hideTerminateRequest(); + if (typeof onCancel === 'function') { + onCancel(); + } + }); + + // 입력 시 에러 초기화 + $('#terminateReasonInput').off('input').on('input', function () { + $(this).removeClass('error'); + $('#terminatePopupError').removeClass('show').text(''); + }); + }, + + /** + * 해지 신청 팝업 숨기기 + */ + hideTerminateRequest: function () { + // Modal 숨김 애니메이션 + $('#terminateModalBackdrop').removeClass('show'); + $('#terminateModal').removeClass('show'); + + // 애니메이션 완료 후 숨김 + setTimeout(function() { + $('#terminateRequestPopup').hide(); + }, 300); + + // Body 스크롤 복원 + $('body').css('overflow', ''); + + // 입력 필드 초기화 + $('#terminateReasonInput').val('').removeClass('error'); + $('#terminatePopupError').removeClass('show').text(''); + + // 이벤트 리스너 제거 + $('#terminatePopupConfirmButton').off('click'); + $('#terminatePopupCancelButton').off('click'); + $('#terminatePopupCloseButton').off('click'); + $('#terminateReasonInput').off('input'); + }, + + /** + * 해지 신청 팝업 에러 메시지 표시 + * @param {string} message - 에러 메시지 + */ + showTerminateError: function (message) { + $('#terminatePopupError').text(message).addClass('show'); + }, + /** * 확인 팝업 표시 * @param {string} message - 확인 메시지 diff --git a/src/main/resources/templates/views/apps/community/mainInquiryForm.html b/src/main/resources/templates/views/apps/community/mainInquiryForm.html index c86919e..4352abd 100644 --- a/src/main/resources/templates/views/apps/community/mainInquiryForm.html +++ b/src/main/resources/templates/views/apps/community/mainInquiryForm.html @@ -49,7 +49,7 @@

- +
@@ -85,7 +85,7 @@ jpg, jpeg, png, gif 이미지 1개만 첨부할 수 있습니다. - 현재 첨부된 이미지가 있습니다. 새 파일을 선택하면 교체됩니다.
diff --git a/src/main/resources/templates/views/apps/community/mainPartnershipForm.html b/src/main/resources/templates/views/apps/community/mainPartnershipForm.html index f781c3a..a17f080 100644 --- a/src/main/resources/templates/views/apps/community/mainPartnershipForm.html +++ b/src/main/resources/templates/views/apps/community/mainPartnershipForm.html @@ -206,7 +206,7 @@ const file = fileInput.files[0]; if (file) { - /*[# th:if="${!isInternalUser}"]*/ + /*[# th:unless="${isInternalUser}"]*/ const allowedExtensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.gif', '.jpg', '.jpeg', '.png']; const fileExt = '.' + file.name.split('.').pop().toLowerCase(); diff --git a/src/main/resources/templates/views/apps/mypage/apiKeyList.html b/src/main/resources/templates/views/apps/mypage/apiKeyList.html index b3b0392..7971f2d 100644 --- a/src/main/resources/templates/views/apps/mypage/apiKeyList.html +++ b/src/main/resources/templates/views/apps/mypage/apiKeyList.html @@ -32,57 +32,7 @@
- - - - - -
- App Icon - - - - - -
- - -
-
- -

앱 이름

- - - 승인정보 없음 - -
- - -
-
-
- - + @@ -119,6 +69,59 @@ + + + + + +
+ App Icon + + + + + +
+ + +
+
+ +

앱 이름

+ + 해지 신청 + + + 승인정보 없음 + +
+ + +
+
+
+
diff --git a/src/main/resources/templates/views/apps/mypage/credentialDetail.html b/src/main/resources/templates/views/apps/mypage/credentialDetail.html index ec62443..2899f45 100644 --- a/src/main/resources/templates/views/apps/mypage/credentialDetail.html +++ b/src/main/resources/templates/views/apps/mypage/credentialDetail.html @@ -190,8 +190,10 @@ @@ -295,44 +297,44 @@ document.body.removeChild(textArea); } - // Delete API Key function - called from button with data-client-id attribute + // API 이용 해지 신청 진입 - 경고 + 사유 모달 (본인 확인은 step-up 2FA가 담당) function deleteApiKeyFromButton(button) { var clientId = $(button).data('client-id'); - customPopups.showConfirm('정말로 이 인증키를 삭제하시겠습니까?', function (confirmed) { - if (!confirmed) { - return; + customPopups.showTerminateRequest({ + onConfirm: function (reason) { + doDeleteApiKey(clientId, reason); } - doDeleteApiKey(clientId); }); } - // 인증키 삭제 요청 (step-up 필요 시 2FA 후 동일 요청 재시도) - function doDeleteApiKey(clientId) { + // 해지 신청 요청 (step-up 필요 시 2FA 후 동일 요청 재시도) + function doDeleteApiKey(clientId, reason) { $('.loading-overlay').show(); $.ajax({ url: /*[[@{/clients/api_key_delete}]]*/ '/clients/api_key_delete', type: 'POST', contentType: 'application/json', - data: JSON.stringify({ clientId: clientId }), + data: JSON.stringify({ clientId: clientId, reason: reason }), headers: { 'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token' } }).done(function (response) { if (response && response.success === false) { - customPopups.showAlert(response.msg || 'API 삭제에 실패했습니다.'); + customPopups.showTerminateError(response.msg || '해지 신청에 실패했습니다.'); return; } - customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function () { + customPopups.hideTerminateRequest(); + customPopups.showAlert(response.msg || '해지 신청이 접수되었습니다. 관리자 승인 후 인증키가 삭제됩니다.', function () { window.location.href = /*[[@{/clients}]]*/ '/clients'; }); }).fail(function (jqXHR, textStatus, errorThrown) { if (isStepUpRequired(jqXHR)) { - requireStepUp('/clients/api_key_delete', function () { doDeleteApiKey(clientId); }); + requireStepUp('/clients/api_key_delete', function () { doDeleteApiKey(clientId, reason); }); return; } - customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown); + customPopups.showTerminateError('해지 신청 요청 중 오류가 발생했습니다: ' + errorThrown); }).always(function () { $('.loading-overlay').hide(); }); diff --git a/src/main/resources/templates/views/apps/register/userRegister.html b/src/main/resources/templates/views/apps/register/userRegister.html index dfbd686..6997b82 100644 --- a/src/main/resources/templates/views/apps/register/userRegister.html +++ b/src/main/resources/templates/views/apps/register/userRegister.html @@ -6,7 +6,7 @@
개인회원가입 -

개인회원가입

+

개인회원가입

@@ -23,7 +23,7 @@
-
+
diff --git a/src/main/resources/templates/views/fragment/auth-number-validation.html b/src/main/resources/templates/views/fragment/auth-number-validation.html index 3c55cd4..df72122 100644 --- a/src/main/resources/templates/views/fragment/auth-number-validation.html +++ b/src/main/resources/templates/views/fragment/auth-number-validation.html @@ -1,5 +1,5 @@
-
+
올바른 인증번호를 입력해주세요.
diff --git a/src/main/resources/templates/views/fragment/corp-reg-no-validation.html b/src/main/resources/templates/views/fragment/corp-reg-no-validation.html index d658562..ae4fd74 100644 --- a/src/main/resources/templates/views/fragment/corp-reg-no-validation.html +++ b/src/main/resources/templates/views/fragment/corp-reg-no-validation.html @@ -1,6 +1,6 @@
+ th:text="${isValid != true} ? '올바른 법인 등록번호 형식이 아닙니다. 앞 6자리, 뒤 7자리의 숫자를 입력해주세요.' : '유효한 법인 등록번호입니다.'" + th:class="${isValid != true} ? 'invalid-feedback d-block' : 'valid-feedback d-block'">
\ No newline at end of file diff --git a/src/main/resources/templates/views/fragment/email-validation.html b/src/main/resources/templates/views/fragment/email-validation.html index 297b94a..91bc592 100644 --- a/src/main/resources/templates/views/fragment/email-validation.html +++ b/src/main/resources/templates/views/fragment/email-validation.html @@ -1,5 +1,5 @@
-
+
올바른 이메일 형식이 아닙니다.
@@ -11,6 +11,6 @@
\ No newline at end of file diff --git a/src/main/resources/templates/views/fragment/popup/customPopups.html b/src/main/resources/templates/views/fragment/popup/customPopups.html index 8497226..ac16373 100644 --- a/src/main/resources/templates/views/fragment/popup/customPopups.html +++ b/src/main/resources/templates/views/fragment/popup/customPopups.html @@ -4,4 +4,5 @@
+
diff --git a/src/main/resources/templates/views/fragment/popup/terminateRequestPopup.html b/src/main/resources/templates/views/fragment/popup/terminateRequestPopup.html new file mode 100644 index 0000000..b58c2d2 --- /dev/null +++ b/src/main/resources/templates/views/fragment/popup/terminateRequestPopup.html @@ -0,0 +1,49 @@ + + diff --git a/src/main/resources/templates/views/fragment/register-auth-validation.html b/src/main/resources/templates/views/fragment/register-auth-validation.html index fe526a2..a2cc437 100644 --- a/src/main/resources/templates/views/fragment/register-auth-validation.html +++ b/src/main/resources/templates/views/fragment/register-auth-validation.html @@ -2,7 +2,7 @@
인증번호를 발송하였습니다.
-
+
인증번호 발송 중 오류가 발생했습니다.
\ No newline at end of file diff --git a/src/test/java/com/eactive/apim/portal/common/exception/UserErrorMessageResolverTest.java b/src/test/java/com/eactive/apim/portal/common/exception/UserErrorMessageResolverTest.java new file mode 100644 index 0000000..626f978 --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/common/exception/UserErrorMessageResolverTest.java @@ -0,0 +1,76 @@ +package com.eactive.apim.portal.common.exception; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.TransactionSystemException; +import org.springframework.transaction.UnexpectedRollbackException; + +import java.net.SocketTimeoutException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class UserErrorMessageResolverTest { + + @Test + @DisplayName("JTA 롤백 원문은 노출하지 않고 롤백 안내 문구로 치환한다") + void jtaRollbackIsReplaced() { + // 실제로 팝업에 노출됐던 원문 + Exception ex = new UnexpectedRollbackException( + "JTA transaction unexpectedly rolled back (maybe due to a timeout); nested exception is " + + "javax.transaction.RollbackException: Transaction set to rollback only"); + + String message = UserErrorMessageResolver.resolve(ex); + + assertTrue(message.contains("변경 내용이 저장되지 않았습니다"), message); + assertTrue(!message.contains("JTA") && !message.contains("Rollback"), message); + // "maybe due to a timeout" 이 붙어 있어도 rollback only 면 타임아웃 문구를 쓰지 않는다 + assertTrue(!message.contains("시간이 초과"), message); + } + + @Test + @DisplayName("실제 타임아웃으로 롤백된 경우 타임아웃 안내를 준다") + void transactionTimeoutIsReported() { + Exception ex = new TransactionSystemException("Could not commit JTA transaction", + new SocketTimeoutException("Read timed out")); + + assertTrue(UserErrorMessageResolver.resolve(ex).contains("시간이 초과")); + } + + @Test + @DisplayName("제약 위반은 데이터 충돌 안내로 치환한다 (롤백 예외로 감싸여 있어도)") + void constraintViolationIsReported() { + Exception ex = new UnexpectedRollbackException("JTA transaction unexpectedly rolled back", + new DataIntegrityViolationException("ORA-00001: unique constraint violated")); + + assertTrue(UserErrorMessageResolver.resolve(ex).contains("충돌")); + } + + @Test + @DisplayName("서비스가 던진 한글 안내문은 그대로 전달한다") + void koreanBusinessMessageIsKept() { + String original = "이미 초대가 진행 중인 휴대폰 번호입니다."; + + assertEquals(original, UserErrorMessageResolver.resolve(new IllegalStateException(original))); + } + + @Test + @DisplayName("영문/기술 메시지와 메시지 없는 예외는 기본 안내로 대체한다") + void technicalMessageIsMasked() { + assertEquals(UserErrorMessageResolver.DEFAULT_MESSAGE, + UserErrorMessageResolver.resolve(new NullPointerException())); + assertEquals(UserErrorMessageResolver.DEFAULT_MESSAGE, + UserErrorMessageResolver.resolve(new IllegalStateException("Invitation is not in a cancelable state"))); + assertEquals(UserErrorMessageResolver.DEFAULT_MESSAGE, UserErrorMessageResolver.resolve(null)); + } + + @Test + @DisplayName("팝업용 변환은 줄바꿈을
로 바꾼다") + void htmlConversionUsesBrTag() { + String html = UserErrorMessageResolver.resolveAsHtml(new NullPointerException()); + + assertTrue(html.contains("
"), html); + assertTrue(!html.contains("\n"), html); + } +}