9 Commits

Author SHA1 Message Date
Rinjae cff0199d2d TestCleanupService 확장 - FileService/PasswordEncoder 추가 Mock 설정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-24 21:16:23 +09:00
Rinjae 2e920eec96 4010-Q&A 테스트 계정 준비 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 법인개발자 계정 소속/비밀번호 초기화(`ensureTestCorpDeveloper`)
- 비밀번호만 재설정하는 테스트 API 추가(`resetTestPassword`)
- Q&A 테스트 문서 및 시나리오 수정(계정 준비 단계 8-3 추가)
2026-08-24 18:45:50 +09:00
Rinjae 65a262849e - 4020-partnership 테스트 추가 - 등록/조회/삭제 시나리오 구현
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- e2e/admin-partnership.page.ts - admin 접근 및 확인 로직 작성
- e2e/partnership-feedback.spec.ts - e2e 테스트 케이스 작성
2026-08-24 17:51:36 +09:00
Rinjae 7056306718 - 파트너십 글 단건 조회/삭제 메서드 추가 및 본인 확인 로직 적용
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- TestCleanupService 확장 - 테스트 글 삭제 및 첨부파일 정리 추가
- 레이아웃 개선 - footer 하단 고정(sticky) 처리
2026-08-24 17:21:47 +09:00
Rinjae 05e0599b26 - AppRequestRepository에 findAllByOrgAndClientName 메서드 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 클라이언트 신청 해지 프로세스 및 테스트 클린업 로직 구현
- Playwright API 키 관리/클라이언트 관리 경로 및 셀렉터 업데이트
2026-08-24 15:25:18 +09:00
Rinjae c223fa3124 PasswordServiceTest 추가 - 최근 비밀번호 중복 검사 로직 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
TestCleanupService 확장 - 초대/회원 소속 관리 메서드 구현
API 추가 - 초대 삭제 및 소속 제외, 사용자 존재 여부 확인
2026-08-24 14:13:26 +09:00
Rinjae 78b1edff5a - 인증번호 재발송 제한 메시지 개선 - 남은 초 표시 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 비밀번호 확인 필드명 일괄 변경(password2 → confirmPassword)
- 법인 관리자 탈퇴 제한 경고 메시지 추가 및 관련 로직 수정
- 임시 비밀번호 발급 시 기존 비밀번호 이력 저장 로직 추가
2026-08-24 13:17:14 +09:00
Rinjae b41a81bda3 Webhook API 표시 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- WebhookDTO에 API 이름 포함 필드 추가(WebhookApiDTO)
- 프로필 활성화 뱃지 CSS 수정(고정 위치, 크기 축소) - 정적 자원 서빙 경로 설정 개선(staticBase 적용)
2026-08-24 10:37:12 +09:00
Rinjae 1ea2c89776 - 인덱스 통계 API 이용 건수 로직 수정 - 월누적 호출 건수 반영
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- GwAuthClient 리포지토리 다건 org용 메서드 추가
- 통계 DTO 주석 수정 및 관련 메서드 추가
2026-08-21 18:07:06 +09:00
48 changed files with 1363 additions and 123 deletions
@@ -22,4 +22,10 @@ public interface GwAuthClientRepository extends JpaRepository<GwAuthClient, Stri
*/
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId = :orgId")
List<String> findClientIdsByOrgId(@Param("orgId") String orgId);
/**
* 다건 org 소속 CLIENTID 목록 (인덱스 페이지 전체 통계 집계용).
*/
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId IN :orgIds")
List<String> findClientIdsByOrgIdIn(@Param("orgIds") List<String> orgIds);
}
@@ -41,7 +41,13 @@ public class ApprovalService {
approval.setApprovalType(ApprovalType.APP);
approval.setTargetId(request.getId());
approval.setRequester(SecurityUtil.getPortalAuthenticatedUser());
approval.setApprovalSubject("[" + request.getOrg().getOrgName() + "] " + request.getType().getDescription() + " 승인");
// 동일 법인의 신규/변경/해지 요청이 관리자 목록에서 같은 제목으로 보이면 대상 식별이 불가능하다.
// 클라이언트 이름을 제목에 포함해 운영자와 E2E 모두 정확한 승인 건을 검색할 수 있게 한다.
String clientName = request.getClientName();
String clientNamePart = clientName == null || clientName.trim().isEmpty()
? "" : " [" + clientName.trim() + "]";
approval.setApprovalSubject("[" + request.getOrg().getOrgName() + "]" + clientNamePart
+ " " + request.getType().getDescription() + " 승인");
for (PortalApprovalLineUser user : optLine.get().getPortalApprovalLineUsers()) {
this.addApprover(approval, user.getUser(), user.getApprovalOrder());
@@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
@Service
@@ -104,9 +105,15 @@ public class AuthNumberServiceImpl implements AuthNumberService {
private void validateResendTime(String recipientKey) {
storage.getAuthNumber(recipientKey).ifPresent(existingAuth -> {
LocalDateTime now = LocalDateTime.now();
if (existingAuth.getExpiresAt().minusSeconds(authNumberExpirationTime)
.plusSeconds(resendLimitSeconds).isAfter(now)) {
throw new AuthNumberException("잠시 후에 다시 시도해 주세요.");
LocalDateTime resendAvailableAt = existingAuth.getExpiresAt()
.minusSeconds(authNumberExpirationTime)
.plusSeconds(resendLimitSeconds);
if (resendAvailableAt.isAfter(now)) {
long remainingMillis = Duration.between(now, resendAvailableAt).toMillis();
long remainingSeconds = Math.max(1L, (remainingMillis + 999L) / 1000L);
throw new AuthNumberException(
String.format("인증번호 재발송 제한이 적용 중입니다. %d초 후 다시 시도해 주세요.",
remainingSeconds));
}
});
}
@@ -1,9 +1,12 @@
package com.eactive.apim.portal.apps.auth.twofactor;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.facade.UserFacade;
import com.eactive.apim.portal.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.annotation.Secured;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -11,6 +14,8 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
/**
@@ -28,8 +33,14 @@ import javax.servlet.http.HttpSession;
@RequestMapping("/auth/stepup")
public class StepUpPasswordController {
/** 비밀번호 재확인 연속 실패 허용 횟수. 초과 시 세션 종료(강제 로그아웃) */
private static final int MAX_FAIL_COUNT = 5;
/** 연속 실패 횟수 세션 attribute 키 */
private static final String ATTR_FAIL_COUNT = "STEPUP_PW_CONFIRM_FAIL_COUNT";
private final UserFacade userFacade;
private final TwoFactorService twoFactorService;
private final UserSessionService userSessionService;
@GetMapping("/password")
public String page(@RequestParam(required = false) String returnUrl, Model model) {
@@ -44,7 +55,8 @@ public class StepUpPasswordController {
@PostMapping("/password")
public String verify(@RequestParam String currentPassword,
@RequestParam(required = false) String returnUrl,
HttpSession session, Model model) {
HttpSession session, HttpServletRequest request, HttpServletResponse response,
Model model) {
String path = pathOf(returnUrl);
if (!StepUpProtectedPaths.isPasswordGated(path)) {
return "redirect:/";
@@ -52,16 +64,34 @@ public class StepUpPasswordController {
String loginId = SecurityUtil.getCurrentLoginId();
if (userFacade.verifyCurrentPassword(loginId, currentPassword)) {
// 확인 성공 → 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
// 확인 성공 → 실패 카운트 초기화, 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
session.removeAttribute(ATTR_FAIL_COUNT);
twoFactorService.grantStepUpPass(session, path);
return "redirect:" + path;
}
model.addAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
// 연속 실패 카운트 증가. 임계치 초과 시 세션을 강제 종료(로그아웃)한다(무차별 대입 방어).
int failCount = incrementFailCount(session);
if (failCount >= MAX_FAIL_COUNT) {
userSessionService.removeSession(session.getId());
new SecurityContextLogoutHandler().logout(request, response,
SecurityContextHolder.getContext().getAuthentication());
return "redirect:/login?pwFailExceeded=1";
}
model.addAttribute("error",
"현재 비밀번호가 일치하지 않습니다. (실패 " + failCount + "/" + MAX_FAIL_COUNT + "회, 초과 시 자동 로그아웃됩니다)");
model.addAttribute("returnUrl", path);
return "apps/auth/stepupPassword";
}
private static int incrementFailCount(HttpSession session) {
Integer count = (Integer) session.getAttribute(ATTR_FAIL_COUNT);
int next = (count == null ? 0 : count) + 1;
session.setAttribute(ATTR_FAIL_COUNT, next);
return next;
}
/** 쿼리스트링을 제외한 경로 부분만 추출(화이트리스트 검증용, open redirect 방지) */
private static String pathOf(String url) {
if (url == null) {
@@ -61,4 +61,23 @@ public class PartnershipApplicationController {
return "redirect:/partnership";
}
/**
* 본인이 작성한 피드백/개선요청 1건 삭제.
* 목록(최근 3건)의 삭제 버튼이 항목별 form 을 POST 한다 — 등록과 동일하게 폼 전송 + flash 메시지 방식.
*/
@PostMapping("/{id}/delete")
public String deleteMyPartnershipApplication(@PathVariable String id, RedirectAttributes redirectAttributes) {
if (!SecurityUtil.isAuthenticated()) {
return "redirect:/login?reason=auth&redirect=/partnership";
}
try {
partnershipApplicationFacade.deleteMyApplication(id);
redirectAttributes.addFlashAttribute("success", "피드백/개선요청이 삭제되었습니다.");
} catch (IllegalArgumentException e) {
redirectAttributes.addFlashAttribute("error", e.getMessage());
}
return "redirect:/partnership";
}
}
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.apps.community.partnership.repository;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
@@ -18,7 +19,19 @@ public interface PartnershipApplicationRepository extends JpaRepository<Partners
*/
List<PartnershipApplication> findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy);
/**
* 본인 글 삭제용 단건 조회. id 만으로 찾지 않고 createdBy 를 함께 걸어
* 남의 글 id 를 넣어도 조회되지 않게 한다(소유자 검증을 쿼리 단계에서 강제).
*/
Optional<PartnershipApplication> findByIdAndCreatedBy(String id, String createdBy);
/** createdBy = PortalUser.id (평문 등가 조회 가능한 이유는 위와 동일). */
@Transactional
long deleteByCreatedBy(String createdBy);
/**
* test-cleanup 전용 — 특정 작성자의 글 중 제목이 지정 접두사로 시작하는 것만 조회한다.
* (bizSubject 는 암호화 컬럼이 아니라 LIKE 조회가 가능하다.)
*/
List<PartnershipApplication> findAllByCreatedByAndBizSubjectStartingWith(String createdBy, String bizSubjectPrefix);
}
@@ -13,4 +13,10 @@ public interface PartnershipApplicationFacade {
* 현재 로그인 사용자가 작성한 최근 3건을 조회한다. 미인증이면 빈 목록.
*/
List<PartnershipApplicationSummaryDTO> getMyRecentApplications();
/**
* 현재 로그인 사용자가 작성한 글 1건을 삭제한다(첨부파일 포함).
* 본인 글이 아니거나 이미 삭제된 경우 {@link IllegalArgumentException}.
*/
void deleteMyApplication(String id);
}
@@ -20,6 +20,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
@Service
@RequiredArgsConstructor
@@ -74,4 +75,23 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
List<PartnershipApplication> recent = partnershipApplicationService.findRecentByCreatedBy(user.getId());
return partnershipApplicationMapper.toSummaryDtoList(recent);
}
@Override
public void deleteMyApplication(String id) {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user == null) {
throw new IllegalArgumentException("로그인이 필요합니다.");
}
// id 만으로 조회하지 않고 createdBy 를 함께 걸어 타인 글 삭제를 원천 차단한다.
PartnershipApplication target = partnershipApplicationService
.findOwnedByCreatedBy(id, user.getId())
.orElseThrow(() -> new IllegalArgumentException("삭제할 수 있는 피드백/개선요청이 아닙니다."));
// 첨부파일도 함께 정리한다(관리자 삭제 PortalPartnershipManService.delete 와 동일 처리).
if (StringUtils.isNotBlank(target.getFileId())) {
fileService.deleteFile(target.getFileId());
}
partnershipApplicationService.deletePartnershipApplication(target);
}
}
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.apps.community.partnership.service;
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -30,4 +31,16 @@ public class PartnershipApplicationService {
public List<PartnershipApplication> findRecentByCreatedBy(String createdBy) {
return partnershipApplicationRepository.findTop3ByCreatedByOrderByCreatedDateDesc(createdBy);
}
/**
* 작성자 본인 글 단건 조회. id 와 createdBy 를 함께 조건으로 걸어 타인 글은 조회되지 않는다.
*/
@Transactional(readOnly = true)
public Optional<PartnershipApplication> findOwnedByCreatedBy(String id, String createdBy) {
return partnershipApplicationRepository.findByIdAndCreatedBy(id, createdBy);
}
public void deletePartnershipApplication(PartnershipApplication partnershipApplication) {
partnershipApplicationRepository.delete(partnershipApplication);
}
}
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.apps.community.qna.repository;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
@@ -17,4 +18,7 @@ public interface InquiryRepository extends JpaRepository<Inquiry, String>, JpaSp
@Transactional
long deleteByInquirer_Id(String inquirerId);
/** 4010 테스트 cleanup 전용 — 작성자 + 제목 접두사로 테스트 문의글만 좁혀 조회한다. */
List<Inquiry> findAllByInquirer_IdAndInquirySubjectStartingWith(String inquirerId, String subjectPrefix);
}
@@ -9,7 +9,7 @@ import lombok.NoArgsConstructor;
* 인덱스 페이지 하단 통계 DTO
* - API 활용 기업: 법인으로 등록된 수의 합계 (정상 상태)
* - 서비스 이용 수: 전체 법인이 생성한 앱의 합계 (이용 가능 상태)
* - API 이용 건수: 전체 법인이 생성한 앱의 API 수의 합계 (이용 가능 상태)
* - API 이용 건수 (월누적): 전체 법인 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 API 호출 건수
*/
@Data
@Builder
@@ -37,8 +37,8 @@ public class IndexStatisticsDTO {
private int activeAppCount;
/**
* API 이용 건수
* 정상 상태 법인이 생성한 이용 가능 앱에 연결된 API의 총
* API 이용 건수 (월누적)
* 정상 상태 법인 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 API 호출 건
*/
private int totalApiCount;
}
@@ -1,14 +1,20 @@
package com.eactive.apim.portal.apps.main.service;
import com.eactive.apim.portal.app.entity.Credential;
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsDayRepository;
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsHourRepository;
import com.eactive.apim.gateway.data.statistics.repository.GwAuthClientRepository;
import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.apps.main.dto.IndexStatisticsDTO;
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.ApprovalStatus;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.OrgStatus;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -31,6 +37,9 @@ public class IndexStatisticsService {
private final PortalOrgRepository portalOrgRepository;
private final CredentialRepository credentialRepository;
private final GwAuthClientRepository gwAuthClientRepository;
private final ApiStatsDayRepository apiStatsDayRepository;
private final ApiStatsHourRepository apiStatsHourRepository;
private final PortalPropertyService portalPropertyService;
// 캐시된 통계 데이터
@@ -91,11 +100,13 @@ public class IndexStatisticsService {
// 3. 서비스 이용 수: 정상 기관의 이용 가능 앱 수
activeAppCount = (int) credentialRepository.countActiveAppsByOrgIds(activeOrgIds);
// 4. API 이용 건수: 정상 기관의 이용 가능 앱에 연결된 API
List<Credential> activeApps = credentialRepository.findActiveAppsByOrgIds(activeOrgIds);
totalApiCount = activeApps.stream()
.mapToInt(credential -> credential.getApiList() != null ? credential.getApiList().size() : 0)
.sum();
// 4. API 이용 건수 (월누적): 정상 기관 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 호출 건
List<String> clientIds = gwAuthClientRepository.findClientIdsByOrgIdIn(activeOrgIds).stream()
.filter(id -> id != null && !id.trim().isEmpty())
.collect(Collectors.toList());
if (!clientIds.isEmpty()) {
totalApiCount = (int) getMonthlyApiCallCount(clientIds);
}
}
cachedStatistics = IndexStatisticsDTO.builder()
@@ -125,6 +136,30 @@ public class IndexStatisticsService {
}
}
/**
* 게이트웨이 클라이언트들의 이번 달 1일~오늘 누적 API 호출 건수(TOTAL_CNT 합산).
* 어제까지는 API_STATS_DAY(일별 집계), 오늘은 아직 DAY 미집계이므로 API_STATS_HOUR로 합산한다.
* ({@link com.eactive.apim.portal.apps.statistics.service.ApiStatisticsService#combineDayAndToday}와 동일 패턴)
*/
private long getMonthlyApiCallCount(List<String> clientIds) {
LocalDate today = LocalDate.now();
LocalDate monthStart = today.withDayOfMonth(1);
LocalDate dayEnd = today.minusDays(1);
long total = 0L;
if (!monthStart.isAfter(dayEnd)) {
total += nz(apiStatsDayRepository.findSummary(clientIds, monthStart, dayEnd).getTotalCount());
}
ApiStatisticsSummaryDto todaySummary = apiStatsHourRepository.findSummary(
clientIds, today.atStartOfDay(), today.atTime(LocalTime.MAX));
total += nz(todaySummary.getTotalCount());
return total;
}
private static long nz(Long value) {
return value != null ? value : 0L;
}
/**
* PortalProperty에서 통계 노출 여부 조회
* 프로퍼티가 없으면 기본값 "Y"를 DB에 저장 후 반환
@@ -35,8 +35,8 @@ public class UserRegisterRestController {
}
@PostMapping("/check_password_match")
public ResponseEntity<ValidationResponse> checkPasswordMatch(@RequestParam String password, @RequestParam String password2) {
return ResponseEntity.ok(userRegisterFacade.checkPasswordMatch(password, password2));
public ResponseEntity<ValidationResponse> checkPasswordMatch(@RequestParam String password, @RequestParam String confirmPassword) {
return ResponseEntity.ok(userRegisterFacade.checkPasswordMatch(password, confirmPassword));
}
@PostMapping("/register/confirm_password")
@@ -10,7 +10,7 @@ import org.hibernate.validator.constraints.NotEmpty;
@AuthNumberMatch(recipient = "loginId", authField = "authNumber")
@PasswordMatch(input = "password", confirm = "password2")
@PasswordMatch(input = "password", confirm = "confirmPassword")
@Data
@PasswordRule(password = "password", loginId = "loginId", mobile = "mobileNumber")
public class PortalUserRegistrationDTO {
@@ -31,8 +31,6 @@ public class PortalUserRegistrationDTO {
*/
private String password;
private String password2;
@CellPhone
private String mobileNumber;
@@ -140,11 +140,11 @@ public class UserFacadeImpl implements UserFacade {
public void withdrawUser(String userId, String withdrawalReason) {
PortalUser user = portalUserService.findById(userId);
// 법인 관리자 탈퇴 제한
// 법인 관리자는 권한 이관 전 탈퇴할 수 없다.
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER) {
if(portalUserService.checkOrgHasOtherUsers(user.getPortalOrg())){
throw new IllegalArgumentException("법인 관리자권한을 다른 개발자에게 위임하신 후 탈퇴가 가능합니다.");
}
throw new IllegalArgumentException(
"법인 관리자는 회원 탈퇴를 할 수 없습니다. "
+ "관리자 권한을 다른 사용자에게 이관하거나 담당자에게 연락해 주세요.");
}
// 약관 동의 정보 삭제
@@ -19,7 +19,7 @@ public interface UserRegisterFacade {
ValidationResponse checkPassword(String password, String loginId, String mobileNumber);
ValidationResponse checkPasswordMatch(String password, String password2);
ValidationResponse checkPasswordMatch(String password, String confirmPassword);
ValidationResponse verifyPassword(String loginId, String confirmPassword);
@@ -94,8 +94,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
}
@Override
public ValidationResponse checkPasswordMatch(String password, String password2) {
boolean isMatch = password.equals(password2);
public ValidationResponse checkPasswordMatch(String password, String confirmPassword) {
boolean isMatch = password.equals(confirmPassword);
String message = isMatch ? "비밀번호가 일치합니다." : "비밀번호가 일치하지 않습니다.";
return new ValidationResponse(isMatch, message);
}
@@ -34,7 +34,8 @@ public class PasswordService {
.orElseThrow(() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다."));
validatePasswordUpdate(user, newPassword, confirmPassword);
checkPasswordHistory(user.getLoginId(), newPassword);
// PTL_USER_PASSWORD_HISTORY.USER_ID 에는 loginId가 아닌 PortalUser.id가 저장된다.
checkPasswordHistory(user.getId(), newPassword);
List<UserPasswordHistory> histories = passwordHistoryRepository.findRecentPasswordsByUserId(user.getId());
if(histories.isEmpty()) {
@@ -85,6 +86,17 @@ public class PasswordService {
return result;
}
/**
* {@link #updatePassword} 를 거치지 않고 비밀번호 해시를 직접 바꾸는 지점(예: 비밀번호 초기화로
* 임시 비밀번호 발급 — PortalUserAuthService.resetPassword)이 <b>덮어쓰기 직전</b>에 호출해,
* 지금 버려지는 비밀번호를 이력에 남긴다. 이걸 빼먹으면 재사용 금지(최근 5회) 검증이 그 비밀번호를
* 전혀 모른 채로 남아 있어, 초기화 이후 바로 예전 비밀번호로 되돌리는 게 허용되는 보안 허점이 된다.
*/
@Transactional
public void recordExternalPasswordChange(String userId, String previousPasswordHash) {
savePasswordHistory(userId, previousPasswordHash);
}
private void checkPasswordHistory(String userId, String newPassword) {
List<UserPasswordHistory> passwordHistories = passwordHistoryRepository.findRecentPasswordsByUserId(userId);
@@ -56,6 +56,7 @@ public class PortalUserAuthService implements UserDetailsService {
private final MessageRequestRepository messageRequestRepository;
private final EncryptionUtil encryptionUtil;
private final LoginFinalizer loginFinalizer;
private final PasswordService passwordService;
@Override
@Transactional(noRollbackFor = UsernameNotFoundException.class)
@@ -66,7 +67,9 @@ public class PortalUserAuthService implements UserDetailsService {
PortalUser portalUser = findByEmailAddr(normalizedUsername);
return buildAuthenticatedUser(portalUser);
} catch (UserNotFoundException e) {
throw new UsernameNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
// 계정 열거(user enumeration) 공격 방지: 비밀번호 불일치(BadCredentialsException, PortalAuthenticationManager)와
// 동일한 문구를 사용해 아이디 존재 여부가 노출되지 않도록 한다.
throw new UsernameNotFoundException("아이디 또는 비밀번호가 일치하지 않습니다.");
}
}
@@ -162,6 +165,10 @@ public class PortalUserAuthService implements UserDetailsService {
.orElseThrow(() -> new UserNotFoundException("일치하는 사용자 정보를 찾을 수 없습니다."));
String tempPassword = EncryptionUtil.generateNewPassword();
// 지금 버려지는(임시 비밀번호로 교체되는) 비밀번호를 이력에 남긴다 — 안 남기면 재사용 금지
// (최근 5회) 검증이 이 비밀번호를 모른 채로 남아, 초기화 직후 바로 예전 비밀번호로 되돌리는
// 것이 허용되는 보안 허점이 생긴다.
passwordService.recordExternalPasswordChange(portalUser.getId(), portalUser.getPasswordHash());
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
// 임시 비밀번호 발급 → 변경일을 null 로 초기화해 로그인 시 강제 비밀번호 변경을 유도한다
// (LoginFinalizer.applyPostLoginState 의 passwordChangeDate == null 분기)
@@ -57,6 +57,12 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
@Value("${app.resource-caching.enabled:false}")
private boolean resourceCachingEnabled;
// 정적자원 서빙 루트(application.yml: app.web-resources.static-base).
// 기본은 classpath(빌드 산출물), local_rinjaemac 프로파일은 file:${user.dir}/src/main/resources/static/
// 로 오버라이드해 소스 편집이 재빌드 없이 즉시 반영되도록 한다.
@Value("${app.web-resources.static-base:classpath:/static/}")
private String staticBase;
public PortalConfigWebDispatcherServlet(Environment environment,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties,
@@ -151,15 +157,15 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
addStaticResourceHandler(registry, "/css/**", "/css/", "classpath:/static/css/");
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", "classpath:/static/webfonts/");
addStaticResourceHandler(registry, "/font/**", "/font/", "classpath:/static/font/");
addStaticResourceHandler(registry, "/html/**", "/html/", "classpath:/static/html/");
addStaticResourceHandler(registry, "/images/**", "/images/", "classpath:/static/images/");
addStaticResourceHandler(registry, "/img/**", "/img/", "classpath:/static/img/");
addStaticResourceHandler(registry, "/js/**", "/js/", "classpath:/static/js/");
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", "classpath:/static/plugins/");
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", "classpath:/static/favicon.ico");
addStaticResourceHandler(registry, "/css/**", "/css/", staticBase + "css/");
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", staticBase + "webfonts/");
addStaticResourceHandler(registry, "/font/**", "/font/", staticBase + "font/");
addStaticResourceHandler(registry, "/html/**", "/html/", staticBase + "html/");
addStaticResourceHandler(registry, "/images/**", "/images/", staticBase + "images/");
addStaticResourceHandler(registry, "/img/**", "/img/", staticBase + "img/");
addStaticResourceHandler(registry, "/js/**", "/js/", staticBase + "js/");
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", staticBase + "plugins/");
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", staticBase + "favicon.ico");
}
/**
@@ -21,4 +21,8 @@ public interface InquiryCommentRepository extends JpaRepository<InquiryComment,
@Transactional
long deleteByInquiry_Inquirer_Id(String inquirerId);
/** 4010 테스트 cleanup 전용 — 삭제 대상 문의글 id 목록에 딸린 댓글을 함께 지운다. */
@Transactional
long deleteByInquiry_IdIn(Collection<String> inquiryIds);
}
@@ -5,11 +5,14 @@ import com.eactive.apim.portal.common.util.IpAddressMatcher;
import com.eactive.apim.portal.djb.testcleanup.service.OrphanCleanupService;
import com.eactive.apim.portal.djb.testcleanup.service.TestCleanupResult;
import com.eactive.apim.portal.djb.testcleanup.service.TestCleanupService;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -17,14 +20,17 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Playwright E2E 테스트 정리용 내부 API — local(개인 dev 프로필, 항상 {@code dev} 동반 활성화)과
* 배포된 dev 서버 양쪽에서만 존재한다({@code @Profile("dev")}, stage/prod 는 빈 자체가 없어 404).
* Playwright E2E 테스트 정리용 내부 API — 전용 {@code playwright} 프로필에서만 존재한다
* ({@code @Profile("playwright")}). 개인 local 프로필·배포된 dev 서버가 각자
* {@code spring.profiles.include: playwright} 로 이 프로필을 동반 활성화하며, stage/prod 는
* 이를 포함하지 않아 빈 자체가 없어 404.
*
* <p>가드는 {@link com.eactive.apim.portal.djb.menu.MenuInternalController} 와 동일하게 두 겹이다.</p>
* <ol>
@@ -41,7 +47,7 @@ import org.springframework.web.bind.annotation.RestController;
* 'http://127.0.0.1:39130/internal/test-cleanup/org?compRegNo=1234567890'</pre>
*/
@Slf4j
@Profile("dev")
@Profile("playwright")
@RestController
@RequestMapping("/internal/test-cleanup")
@RequiredArgsConstructor
@@ -103,6 +109,257 @@ public class TestCleanupInternalController {
return ResponseEntity.ok(body);
}
/**
* Playwright 실행 전 동일 이메일 계정의 존재 여부를 확인한다. 조회 전용이며 삭제하지 않는다.
*/
@GetMapping("/user/exists")
public ResponseEntity<Map<String, Object>> checkUserExists(
@RequestParam String email, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email)) {
return badRequest("email 은 필수입니다.");
}
TestCleanupResult result = testCleanupService.checkUserExists(email);
log.info("테스트 정리(user exists) 조회 - email: {}, found: {}, from: {}",
email, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("userId", result.getTargetId());
body.put("userFound", result.isFound());
return ResponseEntity.ok(body);
}
/**
* 2000 실행 전 법인 계정의 실제 선행 상태를 조회한다. 조회 전용이며 cleanup을 수행하지 않는다.
* API 신청 권한의 최종 렌더링 여부는 보안 권한 매핑에 따르므로, E2E는 이 응답으로 데이터 선행조건을
* 확인한 다음 /clients 화면의 생성 버튼 노출까지 함께 검증한다.
*/
@GetMapping("/user/status")
public ResponseEntity<Map<String, Object>> getUserStatus(
@RequestParam String email, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email)) {
return badRequest("email 은 필수입니다.");
}
Optional<PortalUser> userOpt = testCleanupService.findUserForStatus(email);
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("userFound", userOpt.isPresent());
if (userOpt.isPresent()) {
PortalUser user = userOpt.get();
PortalOrg org = user.getPortalOrg();
body.put("userId", user.getId());
body.put("roleCode", user.getRoleCode() == null ? null : user.getRoleCode().name());
body.put("userStatus", user.getUserStatus() == null ? null : user.getUserStatus().name());
body.put("userApprovalStatus", user.getApprovalStatus() == null ? null : user.getApprovalStatus().name());
body.put("authCompletedYn", user.getAuthCompletedYn());
body.put("orgId", org == null ? null : org.getId());
body.put("orgStatus", org == null || org.getOrgStatus() == null ? null : org.getOrgStatus().name());
body.put("orgApprovalStatus", org == null || org.getApprovalStatus() == null
? null : org.getApprovalStatus().name());
}
log.info("테스트 정리(user status) 조회 - email: {}, found: {}, from: {}",
email, userOpt.isPresent(), request.getRemoteAddr());
return ResponseEntity.ok(body);
}
/**
* 2000 재실행 전 동일 이름으로 남은 테스트 클라이언트 신청만 취소한다.
* 대상은 {@code 단위테스트앱-} 접두사와 이메일 소속 법인으로 이중 한정한다.
*/
@PostMapping("/app-request")
public ResponseEntity<Map<String, Object>> cancelPendingTestAppRequests(
@RequestParam String email, @RequestParam String clientName, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email) || StringUtils.isBlank(clientName)) {
return badRequest("email 과 clientName 은 필수입니다.");
}
TestCleanupResult result = testCleanupService.cancelPendingTestAppRequests(email, clientName);
log.info("테스트 정리(app request) 실행 - email: {}, clientName: {}, found: {}, from: {}",
email, clientName, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("clientName", clientName);
body.put("appRequestFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 4020 재실행 전, 해당 계정이 남긴 테스트 피드백/개선요청 글만 삭제한다.
* 대상은 이메일(작성자)과 {@code 단위테스트*} 제목 접두사로 이중 한정한다.
*/
@PostMapping("/partnership")
public ResponseEntity<Map<String, Object>> deletePartnershipApplications(
@RequestParam String email, @RequestParam String subjectPrefix, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email) || StringUtils.isBlank(subjectPrefix)) {
return badRequest("email 과 subjectPrefix 는 필수입니다.");
}
TestCleanupResult result = testCleanupService.deletePartnershipApplicationsByEmail(email, subjectPrefix);
log.info("테스트 정리(partnership) 실행 - email: {}, subjectPrefix: {}, found: {}, from: {}",
email, subjectPrefix, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("subjectPrefix", subjectPrefix);
body.put("partnershipFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 4010 재실행 전(또는 종료 후 최종 정리), 해당 계정이 작성한 테스트 문의글만 삭제한다(댓글 포함).
* 대상은 이메일(작성자)과 {@code 새글 작성 테스트} 로 시작하는 제목 접두사로 이중 한정한다.
*/
@PostMapping("/inquiry")
public ResponseEntity<Map<String, Object>> deleteInquiries(
@RequestParam String email, @RequestParam String subjectPrefix, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email) || StringUtils.isBlank(subjectPrefix)) {
return badRequest("email 과 subjectPrefix 는 필수입니다.");
}
TestCleanupResult result = testCleanupService.deleteInquiriesByEmail(email, subjectPrefix);
log.info("테스트 정리(inquiry) 실행 - email: {}, subjectPrefix: {}, found: {}, from: {}",
email, subjectPrefix, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("subjectPrefix", subjectPrefix);
body.put("inquiryFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 4010 실행 전, 지정 이메일 계정을 관리자 이메일로 조회한 법인의 ROLE_CORP_USER(법인개발자)로 준비한다.
* 이미 존재하면 재소속+비밀번호 재설정(heal), 없으면 신규 생성(create) — 두 경우 모두 응답한 비밀번호로
* 곧바로 로그인 가능한 상태가 된다.
*/
@PostMapping("/corp-developer")
public ResponseEntity<Map<String, Object>> ensureCorpDeveloper(
@RequestParam String managerEmail, @RequestParam String email, @RequestParam String password,
@RequestParam String mobile, @RequestParam String userName, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(managerEmail) || StringUtils.isBlank(email) || StringUtils.isBlank(password)
|| StringUtils.isBlank(mobile) || StringUtils.isBlank(userName)) {
return badRequest("managerEmail, email, password, mobile, userName 은 모두 필수입니다.");
}
TestCleanupResult result = testCleanupService.ensureTestCorpDeveloper(managerEmail, email, password, mobile, userName);
boolean created = result.getDeletedCounts().getOrDefault("PTL_USER_CREATED", 0L) > 0;
log.info("테스트 정리(corp-developer) 실행 - managerEmail: {}, email: {}, created: {}, from: {}",
managerEmail, email, created, request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("managerEmail", managerEmail);
body.put("email", email);
body.put("userId", result.getTargetId());
body.put("created", created);
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 테스트 계정의 비밀번호만 재설정한다(소속/역할/상태는 그대로). 화면 가입 절차 없이 즉시 로그인
* 가능한 값으로 되돌리는 용도 — 공용 시드 계정의 비밀번호가 정책과 안 맞게 바뀐 경우 등.
*/
@PostMapping("/password")
public ResponseEntity<Map<String, Object>> resetPassword(
@RequestParam String email, @RequestParam String password, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email) || StringUtils.isBlank(password)) {
return badRequest("email 과 password 는 필수입니다.");
}
TestCleanupResult result = testCleanupService.resetTestPassword(email, password);
log.info("테스트 정리(password) 실행 - email: {}, found: {}, from: {}",
email, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("userFound", result.isFound());
return ResponseEntity.ok(body);
}
/**
* 휴대폰 번호로 남아 있는 초대 레코드를 삭제한다. 1020 재실행 전 초대중 중복을 정리하는 용도다.
*/
@PostMapping("/invitation")
public ResponseEntity<Map<String, Object>> deleteInvitations(
@RequestParam String mobile, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(mobile)) {
return badRequest("mobile 은 필수입니다.");
}
TestCleanupResult result = testCleanupService.deleteInvitationsByMobile(mobile);
log.info("테스트 정리(invitation) 실행 - mobile: {}, found: {}, from: {}",
mobile, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("mobile", mobile);
body.put("invitationFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 휴대폰 번호로 찾은 계정을 법인 소속에서 제외한다. 계정 자체는 삭제하지 않는다.
*/
@PostMapping("/membership")
public ResponseEntity<Map<String, Object>> detachMembership(
@RequestParam String mobile, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(mobile)) {
return badRequest("mobile 은 필수입니다.");
}
TestCleanupResult result = testCleanupService.detachUsersFromOrgByMobile(mobile);
log.info("테스트 정리(membership) 실행 - mobile: {}, found: {}, from: {}",
mobile, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("mobile", mobile);
body.put("membershipFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
@PostMapping("/orphans")
public ResponseEntity<Map<String, Object>> cleanOrphans(HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
@@ -11,11 +11,11 @@ import org.springframework.transaction.annotation.Transactional;
/**
* Playwright 반복 실행으로 쌓인, 더 이상 유효한 PTL_ORG/PTL_USER 를 참조하지 않는 잔존(고아) 행을
* 13개 테이블에서 스윕 삭제한다. local(dev 동반 활성화)·dev 서버에서만 동작한다({@link TestCleanupService}
* 13개 테이블에서 스윕 삭제한다. 전용 {@code playwright} 프로필에서만 동작한다({@link TestCleanupService}
* 참고). 대상이 "이미 사라진 org/user"뿐이라 살아있는 테스트 데이터를 건드릴 위험이 없어 파라미터가 없다.
*/
@Slf4j
@Profile("dev")
@Profile("playwright")
@Service
@Transactional
public class OrphanCleanupService {
@@ -9,7 +9,7 @@ import org.springframework.stereotype.Repository;
* 독립 엔티티가 없어 JPA 파생 삭제 메서드를 쓸 수 없는 테이블(PTL_CREDENTIAL_API, PTL_WEBHOOK_SEND_LOG) 전용,
* 특정 org 소유분만 지우는 삭제. EMS 가 {@code @Primary} 이므로 기본 EntityManager 를 그대로 쓴다.
*/
@Profile("dev")
@Profile("playwright")
@Repository
class TestCleanupNativeQueries {
@@ -1,41 +1,59 @@
package com.eactive.apim.portal.djb.testcleanup.service;
import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.apprequest.entity.AppRequest;
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
import com.eactive.apim.portal.approval.statemachine.ProcessingState;
import com.eactive.apim.portal.approval.statemachine.RequestedState;
import com.eactive.apim.portal.apps.approval.service.ApprovalService;
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository;
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestApiRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestRepository;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
import com.eactive.apim.portal.djb.webhook.service.WebhookService;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.apim.portal.portaluser.repository.PortalUserPrivacyAgreementRepository;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
import com.eactive.apim.portal.portaluser.repository.UserRoleHistoryRepository;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.user.repository.UserLogRepository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Playwright E2E 테스트가 반복 실행되며 쌓이는 테스트 법인(PTL_ORG)·계정(PTL_USER)과
* 그에 딸린 데이터를 하드 삭제한다. local(dev 동반 활성화)·dev 서버에서만 존재하는 빈이며,
* stage/prod 는 {@code @Profile("dev")} 로 빈 자체가 등록되지 않는다.
* 그에 딸린 데이터를 하드 삭제한다. 전용 {@code playwright} 프로필에서만 존재하는 빈이며,
* local/dev 프로필이 {@code spring.profiles.include: playwright} 로 동반 활성화한다.
* stage/prod 는 이를 포함하지 않아 {@code @Profile("playwright")} 로 빈 자체가 등록되지 않는다.
*
* <p>모든 대상 테이블이 EMS 스키마(PTL_*)라 무지정 {@code @Transactional}(EMS, {@code @Primary})만 쓴다.</p>
*/
@Slf4j
@Profile("dev")
@Profile("playwright")
@Service
@RequiredArgsConstructor
@Transactional
@@ -52,11 +70,17 @@ public class TestCleanupService {
private final UserPasswordHistoryRepository userPasswordHistoryRepository;
private final UserLogRepository userLogRepository;
private final CredentialRepository credentialRepository;
private final AppRequestRepository appRequestRepository;
private final ApprovalService approvalService;
private final WebhookRequestRepository webhookRequestRepository;
private final WebhookRequestApiRepository webhookRequestApiRepository;
private final WebhookRequestEventRepository webhookRequestEventRepository;
private final WebhookService webhookService;
private final UserInvitationRepository userInvitationRepository;
private final FileService fileService;
private final TestCleanupNativeQueries nativeQueries;
private final PortalUserService portalUserService;
private final PasswordEncoder passwordEncoder;
/**
* 사업자등록번호로 법인을 찾아, 소속 계정 전원 + org 소유 CREDENTIAL/WEBHOOK + 법인 자체를 하드 삭제한다.
@@ -65,6 +89,12 @@ public class TestCleanupService {
assertNonProdProfile();
Optional<PortalOrg> orgOpt = portalOrgRepository.findByCompRegNo(compRegNo);
// 가입 화면은 000-00-00001처럼 입력받지만 DB에는 숫자만 저장되는 환경도 있다.
// cleanup API는 두 형식을 모두 받아 이전 E2E 실행 법인을 빠짐없이 정리해야 한다.
String digitsOnlyCompRegNo = compRegNo.replaceAll("\\D", "");
if (!orgOpt.isPresent() && !digitsOnlyCompRegNo.isEmpty() && !digitsOnlyCompRegNo.equals(compRegNo)) {
orgOpt = portalOrgRepository.findByCompRegNo(digitsOnlyCompRegNo);
}
if (!orgOpt.isPresent()) {
return TestCleanupResult.notFound();
}
@@ -72,6 +102,8 @@ public class TestCleanupService {
String orgId = org.getId();
TestCleanupResult result = TestCleanupResult.found(orgId);
result.put("PTL_USER_INVITATION", userInvitationRepository.deleteByOrgId(orgId));
List<PortalUser> users = portalUserRepository.findAllByPortalOrg_Id(orgId);
for (PortalUser user : users) {
result.merge(deleteUserCascadeInternal(user));
@@ -117,6 +149,257 @@ public class TestCleanupService {
return result;
}
/**
* 이메일 기준으로 테스트 계정 존재 여부만 조회한다. 삭제나 데이터 변경은 수행하지 않는다.
*/
@Transactional(readOnly = true)
public TestCleanupResult checkUserExists(String email) {
assertNonProdProfile();
return portalUserRepository.findPortalUserByEmailAddr(email)
.map(user -> TestCleanupResult.found(user.getId()))
.orElseGet(TestCleanupResult::notFound);
}
/**
* E2E 실행 가능 여부를 판단하기 위한 계정 조회다. 삭제나 상태 변경은 수행하지 않는다.
* 호출자는 법인 소속/승인/역할을 확인해 2000 선행 시나리오(1010/1011)가 갖춰졌는지 판단한다.
*/
@Transactional(readOnly = true)
public Optional<PortalUser> findUserForStatus(String email) {
assertNonProdProfile();
return portalUserRepository.findPortalUserByEmailAddr(email);
}
/**
* 동일 이름으로 재실행할 때 남은 테스트 APP 신청만 취소한다.
* 운영 데이터 오삭제를 막기 위해 {@code 단위테스트앱-} 접두사, 이메일의 법인 소속, 정확한 클라이언트명 세 조건을 모두 요구한다.
*/
public TestCleanupResult cancelPendingTestAppRequests(String email, String clientName) {
assertNonProdProfile();
if (clientName == null || !clientName.startsWith("단위테스트앱-")) {
throw new IllegalArgumentException("테스트 앱 이름(단위테스트앱-*)만 정리할 수 있습니다.");
}
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
if (!userOpt.isPresent() || userOpt.get().getPortalOrg() == null) {
return TestCleanupResult.notFound();
}
TestCleanupResult result = TestCleanupResult.notFound();
long cancelled = 0L;
for (AppRequest request : appRequestRepository.findAllByOrgAndClientName(userOpt.get().getPortalOrg(), clientName)) {
if (request.getApproval() == null || !(request.getApproval().getApprovalStatus() instanceof RequestedState
|| request.getApproval().getApprovalStatus() instanceof ProcessingState)) {
continue;
}
approvalService.cancelAppApproval(request);
result = TestCleanupResult.found(request.getId());
cancelled++;
}
result.put("PTL_APP_REQUEST_CANCELLED", cancelled);
return result;
}
/**
* 휴대폰 번호로 남은 초대 레코드를 전부 삭제한다. 1020 재실행 전 PENDING 초대 중복을 방지한다.
*/
public TestCleanupResult deleteInvitationsByMobile(String mobile) {
assertNonProdProfile();
String normalizedMobile = PhoneNumberUtil.normalize(mobile);
if (normalizedMobile == null) {
return TestCleanupResult.notFound();
}
long deleted = userInvitationRepository.deleteByInvitationMobile(normalizedMobile);
TestCleanupResult result = deleted > 0 ? TestCleanupResult.found(normalizedMobile) : TestCleanupResult.notFound();
result.put("PTL_USER_INVITATION", deleted);
return result;
}
/**
* 휴대폰 번호로 찾은 계정을 법인 소속에서만 제외해 개인회원으로 되돌린다.
* 1020이 수락/소속 제외 전에 중단된 경우, 1000 선행 개인회원은 보존하면서 재초대 가능 상태로 복구한다.
*/
public TestCleanupResult detachUsersFromOrgByMobile(String mobile) {
assertNonProdProfile();
String normalizedMobile = PhoneNumberUtil.normalize(mobile);
if (normalizedMobile == null) {
return TestCleanupResult.notFound();
}
TestCleanupResult result = TestCleanupResult.notFound();
for (PortalUser user : portalUserRepository.findAllByMobileNumber(normalizedMobile)) {
if (user.getPortalOrg() == null) {
continue;
}
user.setPortalOrg(null);
user.setRoleCode(RoleCode.ROLE_USER);
portalUserRepository.save(user);
result = TestCleanupResult.found(user.getId());
result.put("PTL_USER_ORG_MEMBERSHIP", 1L);
}
return result;
}
/**
* 4020 재실행 전, 특정 계정이 남긴 테스트 피드백/개선요청 글만 삭제한다(첨부파일 포함).
* 운영 글 오삭제를 막기 위해 이메일(작성자)과 {@code 단위테스트} 로 시작하는 제목 접두사를 모두 요구한다.
*/
public TestCleanupResult deletePartnershipApplicationsByEmail(String email, String subjectPrefix) {
assertNonProdProfile();
if (subjectPrefix == null || !subjectPrefix.startsWith("단위테스트")) {
throw new IllegalArgumentException("테스트 글 제목 접두사(단위테스트*)만 정리할 수 있습니다.");
}
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
if (!userOpt.isPresent()) {
return TestCleanupResult.notFound();
}
PortalUser user = userOpt.get();
List<PartnershipApplication> targets =
partnershipApplicationRepository.findAllByCreatedByAndBizSubjectStartingWith(user.getId(), subjectPrefix);
if (targets.isEmpty()) {
TestCleanupResult empty = TestCleanupResult.notFound();
empty.put("PTL_PARTNERSHIP_APPLICATION", 0L);
return empty;
}
long files = 0L;
for (PartnershipApplication target : targets) {
if (target.getFileId() != null && !target.getFileId().trim().isEmpty()) {
fileService.deleteFile(target.getFileId());
files++;
}
}
partnershipApplicationRepository.deleteAll(targets);
TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_PARTNERSHIP_APPLICATION", (long) targets.size());
result.put("PTL_FILE_INFO", files);
log.info("테스트 정리 - 피드백/개선요청 삭제 완료: email={}, prefix={}, count={}", email, subjectPrefix, targets.size());
return result;
}
/**
* 4010 재실행 전(또는 종료 후 최종 정리) 특정 계정이 작성한 테스트 문의글만 삭제한다(댓글 포함).
* 운영 글 오삭제를 막기 위해 이메일(작성자)과 {@code 새글 작성 테스트} 로 시작하는 제목 접두사를 모두 요구한다.
* 답변완료(RESPONDED)로 전환된 문의는 포털 자가삭제(PENDING + 작성자 전용)가 막히므로, 4010 종료 시점의
* 최종 정리도 이 API를 재사용한다.
*/
public TestCleanupResult deleteInquiriesByEmail(String email, String subjectPrefix) {
assertNonProdProfile();
if (subjectPrefix == null || !subjectPrefix.startsWith("새글 작성 테스트")) {
throw new IllegalArgumentException("테스트 문의글 제목 접두사(새글 작성 테스트*)만 정리할 수 있습니다.");
}
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
if (!userOpt.isPresent()) {
return TestCleanupResult.notFound();
}
PortalUser user = userOpt.get();
List<Inquiry> targets =
inquiryRepository.findAllByInquirer_IdAndInquirySubjectStartingWith(user.getId(), subjectPrefix);
if (targets.isEmpty()) {
TestCleanupResult empty = TestCleanupResult.notFound();
empty.put("PTL_INQUIRY", 0L);
empty.put("PTL_INQUIRY_COMMENT", 0L);
return empty;
}
List<String> ids = targets.stream().map(Inquiry::getId).collect(Collectors.toList());
long comments = inquiryCommentRepository.deleteByInquiry_IdIn(ids);
inquiryRepository.deleteAll(targets);
TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_INQUIRY", (long) targets.size());
result.put("PTL_INQUIRY_COMMENT", comments);
log.info("테스트 정리 - 문의글 삭제 완료: email={}, prefix={}, count={}", email, subjectPrefix, targets.size());
return result;
}
/**
* 4010 재실행 안정성을 위한 테스트 전용 API. 지정 이메일 계정을 관리자 이메일로 조회한 법인의
* ROLE_CORP_USER(법인개발자)로 만든다 — 이미 존재하면(다른 시나리오/수작업이 소속을 해제했더라도)
* 재소속·비밀번호 재설정으로 로그인 가능 상태를 보장하고(heal), 계정이 없으면 새로 만든다(create).
* 계정 생성/소속 전환 로직은 초대 수락 경로({@link PortalUserService#registerInvitedUser},
* {@link PortalUserService#updateUserToCorpUser})를 그대로 재사용한다.
*/
public TestCleanupResult ensureTestCorpDeveloper(
String managerEmail, String email, String password, String mobile, String userName) {
assertNonProdProfile();
PortalUser manager = portalUserRepository.findPortalUserByEmailAddr(managerEmail)
.orElseThrow(() -> new IllegalArgumentException("관리자 계정을 찾을 수 없습니다: " + managerEmail));
PortalOrg org = manager.getPortalOrg();
if (org == null) {
throw new IllegalArgumentException("관리자 계정이 법인에 소속되어 있지 않습니다: " + managerEmail);
}
Optional<PortalUser> existing = portalUserRepository.findPortalUserByEmailAddr(email);
boolean created;
PortalUser user;
if (existing.isPresent()) {
user = existing.get();
portalUserService.updateUserToCorpUser(user, org.getId());
user.setUserStatus(PortalUserEnums.UserStatus.ACTIVE);
user.setApprovalStatus(PortalUserEnums.ApprovalStatus.COMPLETED);
user.setAccountLockYn("N");
user.setLoginFailureCount(0);
user.setPasswordHash(passwordEncoder.encode(password));
user.setPasswordChangeDate(LocalDateTime.now());
String normalizedMobile = PhoneNumberUtil.normalize(mobile);
if (normalizedMobile != null) {
user.setMobileNumber(normalizedMobile);
}
portalUserRepository.save(user);
created = false;
} else {
PortalUserRegistrationDTO dto = new PortalUserRegistrationDTO();
dto.setLoginId(email);
dto.setUserName(userName);
dto.setPassword(password);
dto.setMobileNumber(mobile);
user = portalUserService.registerInvitedUser(dto, org.getId());
created = true;
}
TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_USER_CREATED", created ? 1L : 0L);
result.put("PTL_USER_ATTACHED", created ? 0L : 1L);
log.info("테스트 정리 - 법인개발자 계정 준비 완료: email={}, orgId={}, created={}", email, org.getId(), created);
return result;
}
/**
* 테스트 계정의 비밀번호만 재설정한다(소속/역할/상태는 건드리지 않음). 계정이 아예 없으면 not-found.
* 4010 등에서 재사용하는 공용 계정(예: REG_CORP_LOGIN_ID)의 비밀번호가 클라이언트 정책(길이/복잡도)에
* 안 맞게 바뀌었을 때, 화면 가입 절차 없이 즉시 로그인 가능한 값으로 되돌리는 용도.
*/
public TestCleanupResult resetTestPassword(String email, String password) {
assertNonProdProfile();
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
if (!userOpt.isPresent()) {
return TestCleanupResult.notFound();
}
PortalUser user = userOpt.get();
user.setPasswordHash(passwordEncoder.encode(password));
user.setPasswordChangeDate(LocalDateTime.now());
user.setLoginFailureCount(0);
user.setAccountLockYn("N");
portalUserRepository.save(user);
TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_USER_PASSWORD_RESET", 1L);
log.info("테스트 정리 - 비밀번호 재설정 완료: email={}", email);
return result;
}
private TestCleanupResult deleteUserCascadeInternal(PortalUser user) {
TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_INQUIRY_COMMENT", inquiryCommentRepository.deleteByInquiry_Inquirer_Id(user.getId()));
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.djb.webhook.dto;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 구독 대상 API ID/명칭 쌍. 화면 표시용(ID → API명 매핑은 {@code ApiService.findApisForApiIds} 사용).
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class WebhookApiDTO implements Serializable {
private static final long serialVersionUID = 1L;
private String id;
private String name;
}
@@ -18,9 +18,12 @@ public class WebhookDTO implements Serializable {
private String secretMasked;
private String createdDate;
/** 구독 API ID 목록. */
/** 구독 API ID 목록(폼 prefill 등 내부용). */
private List<String> apiIds = new ArrayList<>();
/** 구독 API ID+명칭 목록(화면 표시용). */
private List<WebhookApiDTO> apis = new ArrayList<>();
/** 구독 EventType(코드+한글명) 목록. */
private List<WebhookEventTypeDTO> eventTypes = new ArrayList<>();
}
@@ -1,5 +1,8 @@
package com.eactive.apim.portal.djb.webhook.service;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.djb.webhook.dto.WebhookApiDTO;
import com.eactive.apim.portal.djb.webhook.dto.WebhookCreatedResult;
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO;
@@ -45,6 +48,7 @@ public class WebhookService {
private final WebhookSecretGenerator secretGenerator;
private final WebhookEventTypeProvider eventTypeProvider;
private final WebhookMapper webhookMapper;
private final ApiService apiService;
@Transactional(readOnly = true)
public boolean existsByOrg(String orgId) {
@@ -175,8 +179,15 @@ public class WebhookService {
WebhookDTO dto = webhookMapper.toDto(request);
dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK);
dto.setApiIds(apiRepository.findByWebhookReqId(request.getId()).stream()
List<String> apiIds = apiRepository.findByWebhookReqId(request.getId()).stream()
.map(WebhookRequestApi::getApiId)
.collect(Collectors.toList());
dto.setApiIds(apiIds);
Map<String, String> apiNames = apiService.findApisForApiIds(apiIds).stream()
.collect(Collectors.toMap(ApiSpecInfoDto::getApiId, ApiSpecInfoDto::getApiName, (a, b) -> a));
dto.setApis(apiIds.stream()
.map(id -> new WebhookApiDTO(id, apiNames.getOrDefault(id, id)))
.collect(Collectors.toList()));
Map<String, String> names = eventTypeProvider.asMap();
+2
View File
@@ -2,6 +2,8 @@ spring:
config:
activate:
on-profile: dev
# playwright 프로필 합류는 base application.yml 의 spring.profiles.group 으로 처리한다.
# (spring.profiles.include 는 profile-specific 문서에서 금지 - InvalidConfigDataPropertyException)
jpa:
properties:
hibernate:
+12
View File
@@ -41,6 +41,14 @@ spring:
import:
- classpath:menu.yml
- classpath:roles.yml
profiles:
# dev/local_rinjaemac 단독 기동 시에도 playwright 전용 빈(test-cleanup 내부 API 등)이 뜨도록
# group 으로 자동 합류. (주의: spring.profiles.include 는 profile-specific 문서에서 금지 -
# application-{profile}.yml 의 on-profile 게이트 안에 두면 InvalidConfigDataPropertyException.
# 반드시 이 base 문서에 정의)
group:
dev: playwright
local_rinjaemac: playwright
data:
web:
pageable:
@@ -85,6 +93,10 @@ app:
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
resource-caching:
enabled: false
# 정적자원 서빙 루트. 기본은 classpath(빌드 산출물).
# local_rinjaemac 프로파일은 file: 로 소스 트리를 직접 바라보도록 오버라이드한다.
web-resources:
static-base: 'classpath:/static/'
security:
basic:
+91 -50
View File
@@ -23,6 +23,13 @@ body {
color: #1A1A2E;
background-color: #FFFFFF;
overflow-x: hidden;
min-height: 100vh;
display: flex;
flex-direction: column;
}
body > * {
flex-shrink: 0;
width: 100%;
}
ul, ol {
@@ -824,16 +831,20 @@ hr {
.env-badge {
display: inline-flex;
align-items: center;
margin-left: 10px;
padding: 2px 8px;
border-radius: 4px;
position: fixed;
top: 0;
left: 0;
padding: 1px 6px;
border-radius: 0 0 4px 0;
background: var(--accent-orange);
color: var(--white);
font-size: 11px;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
line-height: 1.6;
line-height: 1.5;
z-index: 1100;
pointer-events: none;
}
@media (max-width: 1024px) {
.env-badge {
@@ -1236,7 +1247,7 @@ hr {
transition: all 0.3s ease;
}
.mobile-drawer .drawer-welcome .btn-drawer-login:hover {
background: rgb(0%, 25.7647058824%, 63.5294117647%);
background: rgb(0, 65.7, 162);
}
.mobile-drawer .drawer-welcome.authenticated {
flex-direction: row;
@@ -1866,6 +1877,7 @@ hr {
background-color: rgb(15, 23, 42);
color: rgb(100, 116, 139);
padding: 60px 0px;
margin-top: auto;
}
.global-footer .container {
max-width: 1200px;
@@ -2547,7 +2559,7 @@ hr {
color: #FFFFFF;
}
.btn-success:hover {
background: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
background: rgb(83.2897959184, 199.3102040816, 106.493877551);
transform: translateY(-3px);
}
.btn-danger {
@@ -2555,7 +2567,7 @@ hr {
color: #FFFFFF;
}
.btn-danger:hover {
background: rgb(100%, 27.7647058824%, 27.7647058824%);
background: rgb(255, 70.8, 70.8);
transform: translateY(-3px);
}
.btn-ghost {
@@ -2821,7 +2833,7 @@ hr {
.action-btn-delete:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(100%, 34.862745098%, 34.862745098%);
background: rgb(255, 88.9, 88.9);
}
.action-btn-delete:active {
transform: translateY(0);
@@ -2939,7 +2951,7 @@ hr {
background: #a4d6ea;
}
.btn-input-action.btn-change:hover {
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
background: rgb(131.6625, 199.4303571429, 226.5375);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
}
@@ -3014,7 +3026,7 @@ hr {
border: none;
}
.btn-action-primary:hover {
background: rgb(12.4992826399%, 36.3615494978%, 80.6771879484%);
background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
transform: translateY(-2px);
color: #fff;
}
@@ -3064,7 +3076,7 @@ hr {
}
.status-badge.status-processing {
background: rgba(255, 217, 61, 0.1);
color: rgb(86.7450980392%, 69.7537901759%, 0%);
color: rgb(221.2, 177.8721649485, 0);
}
.status-badge.status-failed {
background: rgba(255, 107, 107, 0.1);
@@ -3104,7 +3116,7 @@ hr {
}
.status-badge-header.status-processing {
background: rgba(255, 217, 61, 0.1);
color: rgb(86.7450980392%, 69.7537901759%, 0%);
color: rgb(221.2, 177.8721649485, 0);
}
.badge-sm {
@@ -4292,7 +4304,7 @@ select.form-control {
.file-upload-wrapper .file-remove-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(100%, 34.862745098%, 34.862745098%);
background: rgb(255, 88.9, 88.9);
}
.file-upload-wrapper .file-remove-btn:active {
transform: translateY(0);
@@ -4613,7 +4625,7 @@ select.form-control {
transition: all 0.3s ease;
}
.form-actions--with-withdrawal .withdrawal-link:hover {
background: rgb(82.4349376114%, 91.2174688057%, 95.2709447415%);
background: rgb(210.2090909091, 232.6045454545, 242.9409090909);
}
.form-actions--with-withdrawal .withdrawal-link img {
width: 22px;
@@ -4768,7 +4780,7 @@ select.form-control {
text-decoration: underline;
}
.notice-content-box a:hover {
color: rgb(0%, 25.7647058824%, 63.5294117647%);
color: rgb(0, 65.7, 162);
}
.form-row--content .form-label-wrapper {
@@ -5704,7 +5716,7 @@ select.form-control {
font-size: 16px;
}
.drawer-logout-btn:hover {
background: rgb(100%, 27.7647058824%, 27.7647058824%);
background: rgb(255, 70.8, 70.8);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
}
@@ -6417,7 +6429,7 @@ select.form-control {
color: #64748b;
}
.list-table-btn--default:hover {
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
}
.list-table-btn--primary {
background-color: #ecf0fa;
@@ -6425,7 +6437,7 @@ select.form-control {
color: #2a69de;
}
.list-table-btn--primary:hover {
background-color: rgb(85.0049019608%, 88.1617647059%, 96.0539215686%);
background-color: rgb(216.7625, 224.8125, 244.9375);
}
.list-table-btn--secondary {
background-color: #f5f5f4;
@@ -6433,7 +6445,7 @@ select.form-control {
color: #64748b;
}
.list-table-btn--secondary:hover {
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
}
.list-table-btn--danger {
background-color: #fbe7e9;
@@ -6441,7 +6453,7 @@ select.form-control {
color: #bb1026;
}
.list-table-btn--danger:hover {
background-color: rgb(97.081232493%, 82.487394958%, 83.9467787115%);
background-color: rgb(247.5571428571, 210.3428571429, 214.0642857143);
}
.table-pagination {
@@ -7091,7 +7103,7 @@ select.form-control {
.alert.alert-error {
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
color: rgb(100%, 27.7647058824%, 27.7647058824%);
color: rgb(255, 70.8, 70.8);
align-items: center;
}
.alert.alert-error svg {
@@ -7105,7 +7117,7 @@ select.form-control {
.alert.alert-success {
background: rgba(107, 207, 127, 0.1);
border: 1px solid rgba(107, 207, 127, 0.3);
color: rgb(24.12484994%, 74.3849539816%, 34.1768707483%);
color: rgb(61.5183673469, 189.6816326531, 87.1510204082);
}
.alert.alert-info {
background: rgba(0, 73, 180, 0.1);
@@ -11476,10 +11488,10 @@ body.index-page-body {
line-height: 20px;
}
.login-button:hover {
background: rgb(10.0588235294%, 27.568627451%, 68.1764705882%);
background: rgb(25.65, 70.3, 173.85);
}
.login-button:active {
background: rgb(9.5294117647%, 26.1176470588%, 64.5882352941%);
background: rgb(24.3, 66.6, 164.7);
}
.login-button:disabled {
opacity: 0.6;
@@ -11522,10 +11534,10 @@ body.index-page-body {
border-bottom-right-radius: 8px;
}
.login-links-container .link-btn:hover {
background: rgb(86.5137254902%, 89.3529411765%, 96.4509803922%);
background: rgb(220.61, 227.85, 245.95);
}
.login-links-container .link-btn:active {
background: rgb(80.4784313725%, 84.5882352941%, 94.862745098%);
background: rgb(205.22, 215.7, 241.9);
}
.login-alert {
@@ -12034,12 +12046,12 @@ body.index-page-body {
}
.auth-request-button:hover,
.auth-verify-button:hover {
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
transform: none !important;
}
.auth-request-button:active,
.auth-verify-button:active {
background: rgb(8.3967014843%, 57.3774601429%, 91.4307494961%);
background: rgb(21.411588785, 146.3125233645, 233.148411215);
}
.auth-request-button:disabled,
.auth-verify-button:disabled {
@@ -12088,10 +12100,10 @@ body.index-page-body {
background: #f0f2f5;
}
.account-recovery-card .form-actions .cancel-button:hover {
background: rgb(88.4117647059%, 89.9568627451%, 92.2745098039%);
background: rgb(225.45, 229.39, 235.3);
}
.account-recovery-card .form-actions .cancel-button:active {
background: rgb(82.7058823529%, 85.0117647059%, 88.4705882353%);
background: rgb(210.9, 216.78, 225.6);
}
.account-recovery-card .form-actions .submit-button {
color: #FFFFFF;
@@ -12101,7 +12113,7 @@ body.index-page-body {
background: rgb(6, 54, 125);
}
.account-recovery-card .form-actions .submit-button:active {
background: rgb(0%, 25.7647058824%, 63.5294117647%);
background: rgb(0, 65.7, 162);
}
.account-recovery-card .form-actions .submit-button:disabled {
opacity: 0.6;
@@ -12337,7 +12349,7 @@ body.index-page-body {
transition: color 0.3s ease;
}
.result-info-box .info-text .info-link:hover {
color: rgb(0%, 25.7647058824%, 63.5294117647%);
color: rgb(0, 65.7, 162);
}
@media (max-width: 576px) {
.result-info-box .info-text {
@@ -17537,7 +17549,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.btn-copy-action:hover {
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
background: rgb(131.6625, 199.4303571429, 226.5375);
}
@media (max-width: 768px) {
.btn-copy-action {
@@ -17564,7 +17576,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.btn-view-secret:hover {
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
}
.btn-view-secret svg {
width: 20px;
@@ -17749,7 +17761,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border-radius: 8px;
}
.btn-copy-action:hover {
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
background: rgb(131.6625, 199.4303571429, 226.5375);
}
.btn-view-secret {
width: 100% !important;
@@ -17765,7 +17777,7 @@ input[type=checkbox]:checked + .custom-checkbox {
height: 16px;
}
.btn-view-secret:hover {
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
}
#revealedSecretBox {
width: 100%;
@@ -18038,7 +18050,7 @@ input[type=checkbox]:checked + .custom-checkbox {
flex-shrink: 0;
}
.detail-wrap .dt-btn-copy:hover {
background: rgb(74.3529411765%, 90.2296918768%, 100%);
background: rgb(189.6, 230.0857142857, 255);
}
.detail-wrap .dt-btn-copy svg {
color: #2a69de;
@@ -18219,7 +18231,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.detail-wrap .dt-btn-gray:hover {
background: rgb(66.9250773994%, 71.9364293086%, 75.9455108359%);
background: rgb(170.6589473684, 183.4378947368, 193.6610526316);
}
.detail-wrap .dt-btn-red {
width: 156px;
@@ -18237,7 +18249,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.detail-wrap .dt-btn-red:hover {
background: rgb(100%, 27.4868759774%, 25.1921568627%);
background: rgb(255, 70.0915337423, 64.24);
}
.detail-wrap .dt-btn-blue {
width: 156px;
@@ -19811,7 +19823,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-list:hover {
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
}
.btn-inquiry-list:active {
transform: scale(0.98);
@@ -19844,7 +19856,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-edit:hover {
background: rgb(0%, 27.1960784314%, 67.0588235294%);
background: rgb(0, 69.35, 171);
}
.btn-inquiry-edit:active {
transform: scale(0.98);
@@ -19877,7 +19889,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-delete:hover {
background: rgb(85.4839910648%, 16.2218912882%, 22.8577810871%);
background: rgb(217.9841772152, 41.3658227848, 58.2873417722);
}
.btn-inquiry-delete:active {
transform: scale(0.98);
@@ -19949,7 +19961,7 @@ input[type=checkbox]:checked + .custom-checkbox {
margin-left: 8px;
}
.file-upload-inline .btn-remove-file-inline:hover {
background: rgb(82.1236038719%, 14.2293373045%, 20.7341772152%);
background: rgb(209.4151898734, 36.2848101266, 52.8721518987);
}
.file-upload-inline .btn-remove-file-inline svg {
width: 12px;
@@ -19981,7 +19993,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.file-upload-inline .btn-file-attach:hover {
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
}
.file-upload-inline .btn-file-attach svg {
width: 22px;
@@ -20041,7 +20053,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none;
}
.inquiry-form-container .form-actions .btn-secondary:hover {
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
}
.inquiry-form-container .form-actions .btn-primary {
background: #0049b4;
@@ -20049,7 +20061,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none;
}
.inquiry-form-container .form-actions .btn-primary:hover {
background: rgb(0%, 27.1960784314%, 67.0588235294%);
background: rgb(0, 69.35, 171);
}
.inquiry-form-container .file-upload-inline .file-input-display {
min-height: 50px;
@@ -20838,7 +20850,7 @@ input[type=checkbox]:checked + .custom-checkbox {
cursor: pointer;
}
.djb-board-write-container .form-actions .btn-submit:hover {
background-color: rgb(13.193687231%, 38.3816355811%, 85.1592539455%);
background-color: rgb(33.643902439, 97.8731707317, 217.156097561);
}
@media (max-width: 768px) {
.djb-board-write-container .form-actions .btn-submit {
@@ -21374,7 +21386,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: all 0.3s ease;
}
.org-file-remove:hover {
background: rgb(100%, 27.7647058824%, 27.7647058824%);
background: rgb(255, 70.8, 70.8);
}
.org-file-notice {
@@ -21839,6 +21851,35 @@ input[type=checkbox]:checked + .custom-checkbox {
color: #334155;
word-break: break-word;
}
.recent-apps .recent-apps-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
}
.recent-apps .recent-apps-delete {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
background: #ffffff;
border: 1px solid #fca5a5;
border-radius: 6px;
color: #dc2626;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.recent-apps .recent-apps-delete:hover {
background: #fef2f2;
border-color: #f87171;
}
.recent-apps .recent-apps-delete:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15);
}
.page-title {
font-size: 40px;
@@ -22386,7 +22427,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.status-indicator.status-active {
background-color: rgba(107, 207, 127, 0.1);
color: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
color: rgb(83.2897959184, 199.3102040816, 106.493877551);
}
.status-indicator.status-active .status-dot {
background-color: #6BCF7F;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -30,6 +30,19 @@ body {
color: $text-dark;
background-color: $white;
overflow-x: hidden;
// 콘텐츠가 짧은 페이지에서도 footer 가 화면 하단에 붙도록(sticky footer).
// footer 는 .global-footer 의 margin-top:auto 로 밀려난다.
min-height: 100vh;
display: flex;
flex-direction: column;
// flex 컨테이너가 되면 자식이 축소(shrink)되거나 내부 콘텐츠 min-content 폭까지 늘어난다.
// block 레이아웃과 동일한 폭 계산이 되도록 고정한다(모바일 가로 스크롤 방지).
> * {
flex-shrink: 0;
width: 100%;
}
}
// Lists
@@ -163,6 +163,8 @@
background-color: rgb(15, 23, 42);
color: rgb(100, 116, 139);
padding: 60px 0px;
// body(flex column) 기준으로 남은 공간을 위쪽 여백으로 흡수 → 짧은 페이지에서 화면 하단 고정
margin-top: auto;
.container {
max-width: 1200px;
@@ -157,20 +157,26 @@
}
// 활성 Spring 프로파일 표시 (prod 제외)
// 데스크톱: 로고 옆 인라인 뱃지 / 모바일·태블릿: 최상단 floating 바(fixed, 레이아웃 미영향)
// 데스크톱: 화면 최좌측·최상단 floating 뱃지 / 모바일·태블릿: 최상단 floating 바
// 둘 다 fixed 라 헤더 레이아웃(로고·메뉴 정렬)에는 영향을 주지 않는다.
.env-badge {
display: inline-flex;
align-items: center;
margin-left: 10px;
padding: 2px 8px;
border-radius: 4px;
position: fixed;
top: 0;
left: 0;
padding: 1px 6px;
// 좌상단 모서리에 붙으므로 우/하단만 둥글게
border-radius: 0 0 4px 0;
background: var(--accent-orange);
color: var(--white);
font-size: 11px;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
line-height: 1.6;
line-height: 1.5;
z-index: 1100;
pointer-events: none;
@media (max-width: 1024px) {
display: none;
@@ -102,6 +102,40 @@
color: #334155;
word-break: break-word;
}
// 본인 글 삭제 버튼(아코디언 펼친 상태에서만 노출)
.recent-apps-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
}
.recent-apps-delete {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
background: #ffffff;
border: 1px solid #fca5a5;
border-radius: 6px;
color: #dc2626;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
&:hover {
background: #fef2f2;
border-color: #f87171;
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15);
}
}
}
.page-title {
@@ -57,6 +57,21 @@
</button>
<div class="recent-apps-body">
<p class="recent-apps-detail" th:text="${item.bizDetail}">내용</p>
<div class="recent-apps-actions">
<form class="recent-apps-delete-form" method="post"
th:action="@{/partnership/{id}/delete(id=${item.id})}">
<button type="button" class="recent-apps-delete" th:attr="data-subject=${item.bizSubject}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" aria-hidden="true">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>
<path d="M10 11v6"></path>
<path d="M14 11v6"></path>
</svg>
삭제
</button>
</form>
</div>
</div>
</li>
</ul>
@@ -265,6 +280,17 @@
item.siblings('.recent-apps-item').removeClass('active').find('.recent-apps-body').slideUp(200);
});
// 최근 글 삭제 — 확인 팝업 후 항목별 form 전송(POST /partnership/{id}/delete)
$('.recent-apps-delete').on('click', function (e) {
e.preventDefault();
e.stopPropagation();
const form = $(this).closest('form.recent-apps-delete-form').get(0);
const subject = $(this).data('subject') || '';
customPopups.showConfirm('피드백/개선요청 [' + subject + '] 을(를) 삭제하시겠습니까?', function (ok) {
if (ok) form.submit();
});
});
// Focus on subject field
document.getElementById('bizSubject').focus();
});
@@ -39,6 +39,10 @@
<i class="fas fa-info-circle"></i>
<span>인증된 사용자만 접근 가능한 페이지입니다. 로그인 후 이용해 주세요.</span>
</div>
<div th:if="${param.pwFailExceeded}" class="login-alert alert-info">
<i class="fas fa-info-circle"></i>
<span>비밀번호 확인 5회 실패로 로그아웃되었습니다. 다시 로그인해 주세요.</span>
</div>
<!-- Login Form -->
<form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post"
@@ -754,15 +754,15 @@
if (withdrawalBtn) {
withdrawalBtn.addEventListener('click', (e) => {
e.preventDefault();
customPopups.showWithdrawal();
customPopups.showAlert(
'법인 관리자는 회원 탈퇴를 할 수 없습니다.<br>' +
'관리자 권한을 다른 사용자에게 이관하거나 담당자에게 연락해 주세요.'
);
});
}
});
</script>
</th:block>
<section layout:fragment="pagePopups">
<th:block th:replace="~{fragment/popup/withdrawalPopup :: withdrawalPopup}"></th:block>
</section>
</body>
</html>
@@ -110,7 +110,7 @@
비밀번호 확인 <span class="required-badge">필수</span>
</label>
<div class="org-form-input-wrapper">
<input type="password" name="password2" id="password2" class="org-form-input"
<input type="password" name="confirmPassword" id="confirmPassword" class="org-form-input"
th:placeholder="#{portalUser.Register.passConfirm}">
<input type="hidden" name="isPasswordMatch" id="isPasswordMatch" />
<div id="password-match-validation" class="org-validation-message"></div>
@@ -411,9 +411,9 @@
});
// 비밀번호 확인 검증
$('#password2').on('blur', function () {
let password2 = $(this).val();
if (!password2) {
$('#confirmPassword').on('blur', function () {
let confirmPassword = $(this).val();
if (!confirmPassword) {
return;
}
@@ -422,7 +422,7 @@
type: 'POST',
data: {
password: $('#password').val(),
password2: password2,
confirmPassword: confirmPassword,
_csrf: $('input[name="_csrf"]').val()
},
success: function (response) {
@@ -136,7 +136,7 @@
// 시나리오별 필수 필드 정의
const requiredFieldsByScenario = {
new: {
user: ['loginId', 'userName', 'password', 'password2', 'mobileNumber', 'authNumber'],
user: ['loginId', 'userName', 'password', 'confirmPassword', 'mobileNumber', 'authNumber'],
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
},
retain: {
@@ -228,6 +228,9 @@
});
// Add confirmPassword manually based on the scenario
// (new 시나리오는 #confirmPassword 필드 자체가 있어 위 공통 user 필드 루프가 그대로 처리한다.
// 서버는 시나리오 무관하게 confirmPassword 단일 필드로 비밀번호 확인을 검증한다 —
// OrgRegisterFacadeImpl.registerNewOrgUser 참고.)
if (registrationScenario === 'retain') {
const passwordConfirmIndividual = document.getElementById('passwordConfirmIndividual');
if (passwordConfirmIndividual) {
@@ -72,7 +72,7 @@
<div class="webhook-info-group">
<span class="group-label">대상 API</span>
<div class="badges-row">
<span class="api-badge" th:each="apiId : *{apiIds}" th:text="${apiId}">API</span>
<span class="api-badge" th:each="api : *{apis}" th:text="${api.name}" th:title="${api.id}">API</span>
</div>
</div>
@@ -0,0 +1,65 @@
package com.eactive.apim.portal.apps.auth.service;
import com.eactive.apim.portal.portaluser.entity.TwoFactorAuth;
import com.eactive.apim.portal.portaluser.service.AuthNumberException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AuthNumberServiceImplTest {
private static final Pattern RETRY_SECONDS = Pattern.compile(
"인증번호 재발송 제한이 적용 중입니다\\. (\\d+)초 후 다시 시도해 주세요\\.");
@Mock
private AuthNumberStorage storage;
@Mock
private AuthNumberGenerator generator;
@Mock
private MessageSender messageSender;
private AuthNumberServiceImpl service;
@BeforeEach
void setUp() {
service = new AuthNumberServiceImpl(storage, generator, messageSender);
ReflectionTestUtils.setField(service, "authNumberExpirationTime", 300);
ReflectionTestUtils.setField(service, "resendLimitSeconds", 30);
}
@Test
void resendLimitMessageIncludesRemainingSeconds() {
String recipient = "01099121100";
TwoFactorAuth existing = new TwoFactorAuth(
recipient, "123456", LocalDateTime.now().plusSeconds(300));
when(storage.getAuthNumber(recipient)).thenReturn(Optional.of(existing));
AuthNumberException exception = assertThrows(
AuthNumberException.class,
() -> service.sendRequestAuthNumber(recipient, "SMS")
);
Matcher matcher = RETRY_SECONDS.matcher(exception.getMessage());
assertTrue(matcher.matches(), "남은 재시도 초가 안내 메시지에 포함되어야 함");
long remainingSeconds = Long.parseLong(matcher.group(1));
assertTrue(remainingSeconds >= 1 && remainingSeconds <= 30,
"남은 초는 1~30 범위여야 함: " + remainingSeconds);
assertNull(exception.getReason());
verifyNoInteractions(generator, messageSender);
}
}
@@ -0,0 +1,71 @@
package com.eactive.apim.portal.apps.user;
import com.eactive.apim.portal.apps.user.service.PasswordService;
import com.eactive.apim.portal.common.dto.PasswordValidationDTO;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
import java.util.Collections;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PasswordServiceTest {
@Mock
private PortalUserRepository portalUserRepository;
@Mock
private UserPasswordHistoryRepository passwordHistoryRepository;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private LocalValidatorFactoryBean validator;
@InjectMocks
private PasswordService passwordService;
@Test
void rejectsPasswordStoredAgainstPortalUserId() {
PortalUser user = new PortalUser();
user.setId("user-uuid");
user.setLoginId("user@example.com");
user.setMobileNumber("010-1234-5678");
user.setPasswordHash("temporary-password-hash");
UserPasswordHistory previousPassword = new UserPasswordHistory();
previousPassword.setUserId("user-uuid");
previousPassword.setPasswordHash("original-password-hash");
when(portalUserRepository.findByLoginId("user@example.com")).thenReturn(Optional.of(user));
when(passwordEncoder.matches("Original!123", "temporary-password-hash")).thenReturn(false);
when(validator.validate(any(PasswordValidationDTO.class))).thenReturn(Collections.emptySet());
when(passwordHistoryRepository.findRecentPasswordsByUserId("user-uuid"))
.thenReturn(Collections.singletonList(previousPassword));
when(passwordEncoder.matches("Original!123", "original-password-hash")).thenReturn(true);
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> passwordService.updatePassword(
"user@example.com", "Original!123", "Original!123")
);
assertEquals("최근 5회 이내에 사용한 비밀번호는 사용할 수 없습니다.", exception.getMessage());
verify(passwordHistoryRepository).findRecentPasswordsByUserId("user-uuid");
verify(passwordHistoryRepository, never()).findRecentPasswordsByUserId("user@example.com");
verify(portalUserRepository, never()).save(user);
}
}
@@ -0,0 +1,83 @@
package com.eactive.apim.portal.apps.user;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.user.facade.MessageRequestFacade;
import com.eactive.apim.portal.apps.user.facade.UserFacadeImpl;
import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
import com.eactive.apim.portal.apps.user.service.PasswordService;
import com.eactive.apim.portal.apps.user.service.PortalOrgService;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class UserFacadeImplTest {
@Mock
private PortalUserService portalUserService;
@Mock
private PortalOrgService portalOrgService;
@Mock
private PasswordService passwordService;
@Mock
private PortalUserMapper portalUserMapper;
@Mock
private MessageHandlerService messageHandlerService;
@Mock
private AgreementsFacade agreementsFacade;
@Mock
private MessageRequestFacade messageRequestFacade;
@InjectMocks
private UserFacadeImpl userFacade;
@Test
void corporateManagerCannotWithdraw() {
PortalUser manager = new PortalUser();
manager.setId("manager-1");
manager.setRoleCode(RoleCode.ROLE_CORP_MANAGER);
when(portalUserService.findById("manager-1")).thenReturn(manager);
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> userFacade.withdrawUser("manager-1", "withdrawal reason")
);
assertEquals(
"법인 관리자는 회원 탈퇴를 할 수 없습니다. "
+ "관리자 권한을 다른 사용자에게 이관하거나 담당자에게 연락해 주세요.",
exception.getMessage()
);
verifyNoInteractions(agreementsFacade, messageRequestFacade);
verify(portalUserService, never()).deleteUser(manager, "withdrawal reason");
}
@Test
void corporateUserCanWithdraw() {
PortalUser user = new PortalUser();
user.setId("user-1");
user.setLoginId("corp-user@example.com");
user.setUserName("법인 사용자");
user.setRoleCode(RoleCode.ROLE_CORP_USER);
when(portalUserService.findById("user-1")).thenReturn(user);
userFacade.withdrawUser("user-1", "withdrawal reason");
verify(agreementsFacade).deleteUserAgreements("user-1");
verify(messageRequestFacade).deleteUserMessage("법인 사용자", "corp-user@example.com");
verify(portalUserService).deleteUser(user, "withdrawal reason");
}
}
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.djb.testcleanup.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyLong;
@@ -13,15 +14,23 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.apprequest.entity.AppRequest;
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
import com.eactive.apim.portal.approval.entity.Approval;
import com.eactive.apim.portal.approval.statemachine.RequestedState;
import com.eactive.apim.portal.apps.approval.service.ApprovalService;
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestApiRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestRepository;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
import com.eactive.apim.portal.djb.webhook.service.WebhookService;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.repository.PortalUserPrivacyAgreementRepository;
@@ -40,6 +49,7 @@ import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.security.crypto.password.PasswordEncoder;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@@ -56,11 +66,17 @@ class TestCleanupServiceTest {
@Mock private UserPasswordHistoryRepository userPasswordHistoryRepository;
@Mock private UserLogRepository userLogRepository;
@Mock private CredentialRepository credentialRepository;
@Mock private AppRequestRepository appRequestRepository;
@Mock private ApprovalService approvalService;
@Mock private WebhookRequestRepository webhookRequestRepository;
@Mock private WebhookRequestApiRepository webhookRequestApiRepository;
@Mock private WebhookRequestEventRepository webhookRequestEventRepository;
@Mock private WebhookService webhookService;
@Mock private UserInvitationRepository userInvitationRepository;
@Mock private FileService fileService;
@Mock private TestCleanupNativeQueries nativeQueries;
@Mock private PortalUserService portalUserService;
@Mock private PasswordEncoder passwordEncoder;
private TestCleanupService service;
@@ -75,8 +91,10 @@ class TestCleanupServiceTest {
service = new TestCleanupService(environment, portalOrgRepository, portalUserRepository,
inquiryRepository, inquiryCommentRepository, partnershipApplicationRepository,
userRoleHistoryRepository, portalUserPrivacyAgreementRepository, userPasswordHistoryRepository,
userLogRepository, credentialRepository, webhookRequestRepository, webhookRequestApiRepository,
webhookRequestEventRepository, webhookService, nativeQueries);
userLogRepository, credentialRepository, appRequestRepository, approvalService,
webhookRequestRepository, webhookRequestApiRepository,
webhookRequestEventRepository, webhookService, userInvitationRepository, fileService,
nativeQueries, portalUserService, passwordEncoder);
when(environment.acceptsProfiles(Profiles.of("stage", "prod"))).thenReturn(false);
}
@@ -98,6 +116,24 @@ class TestCleanupServiceTest {
verifyNoInteractions(webhookService, portalUserRepository);
}
@Test
void deleteOrgCascade_fallsBackToDigitsOnlyBusinessNumber() {
String hyphenatedCompRegNo = "123-45-67890";
String digitsOnlyCompRegNo = "1234567890";
PortalOrg org = org();
when(portalOrgRepository.findByCompRegNo(hyphenatedCompRegNo)).thenReturn(Optional.empty());
when(portalOrgRepository.findByCompRegNo(digitsOnlyCompRegNo)).thenReturn(Optional.of(org));
when(portalUserRepository.findAllByPortalOrg_Id(ORG_ID)).thenReturn(Collections.emptyList());
when(webhookRequestRepository.findByOrgId(ORG_ID)).thenReturn(Optional.empty());
TestCleanupResult result = service.deleteOrgCascade(hyphenatedCompRegNo);
assertTrue(result.isFound());
assertEquals(ORG_ID, result.getTargetId());
verify(portalOrgRepository).findByCompRegNo(digitsOnlyCompRegNo);
verify(portalOrgRepository).delete(org);
}
@Test
void deleteOrgCascade_found_cascadesUsersAndOrg() {
PortalOrg org = org();
@@ -153,6 +189,88 @@ class TestCleanupServiceTest {
assertTrue(result.getDeletedCounts().isEmpty());
}
@Test
void checkUserExists_found_doesNotDeleteData() {
when(portalUserRepository.findPortalUserByEmailAddr(EMAIL)).thenReturn(Optional.of(user()));
TestCleanupResult result = service.checkUserExists(EMAIL);
assertTrue(result.isFound());
assertEquals(USER_ID, result.getTargetId());
assertTrue(result.getDeletedCounts().isEmpty());
verify(portalUserRepository, never()).delete(org.mockito.ArgumentMatchers.any(PortalUser.class));
}
@Test
void checkUserExists_notFound() {
when(portalUserRepository.findPortalUserByEmailAddr(EMAIL)).thenReturn(Optional.empty());
TestCleanupResult result = service.checkUserExists(EMAIL);
assertFalse(result.isFound());
assertTrue(result.getDeletedCounts().isEmpty());
}
@Test
void deleteInvitationsByMobile_normalizesAndDeletesAllMatches() {
when(userInvitationRepository.deleteByInvitationMobile("010-1234-5678")).thenReturn(2L);
TestCleanupResult result = service.deleteInvitationsByMobile("01012345678");
assertTrue(result.isFound());
assertEquals("010-1234-5678", result.getTargetId());
assertEquals(2L, result.getDeletedCounts().get("PTL_USER_INVITATION"));
verify(userInvitationRepository).deleteByInvitationMobile("010-1234-5678");
}
@Test
void detachUsersFromOrgByMobile_keepsUserAndRemovesOnlyMembership() {
PortalUser target = user();
target.setPortalOrg(org());
when(portalUserRepository.findAllByMobileNumber("010-1234-5678"))
.thenReturn(Collections.singletonList(target));
TestCleanupResult result = service.detachUsersFromOrgByMobile("01012345678");
assertTrue(result.isFound());
assertEquals(USER_ID, result.getTargetId());
assertEquals(1L, result.getDeletedCounts().get("PTL_USER_ORG_MEMBERSHIP"));
assertNull(target.getPortalOrg());
assertEquals(com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode.ROLE_USER, target.getRoleCode());
verify(portalUserRepository).save(target);
verify(portalUserRepository, never()).delete(target);
}
@Test
void cancelPendingTestAppRequests_cancelsOnlyExactTestAppRequest() {
PortalUser target = user();
PortalOrg org = org();
target.setPortalOrg(org);
AppRequest request = new AppRequest();
request.setId("APP_REQ_1");
request.setClientName("단위테스트앱-20260824-144907");
Approval approval = new Approval();
approval.setApprovalStatus(new RequestedState());
request.setApproval(approval);
when(portalUserRepository.findPortalUserByEmailAddr(EMAIL)).thenReturn(Optional.of(target));
when(appRequestRepository.findAllByOrgAndClientName(org, request.getClientName()))
.thenReturn(Collections.singletonList(request));
TestCleanupResult result = service.cancelPendingTestAppRequests(EMAIL, request.getClientName());
assertTrue(result.isFound());
assertEquals("APP_REQ_1", result.getTargetId());
assertEquals(1L, result.getDeletedCounts().get("PTL_APP_REQUEST_CANCELLED"));
verify(approvalService).cancelAppApproval(request);
}
@Test
void cancelPendingTestAppRequests_rejectsNonTestName() {
assertThrows(IllegalArgumentException.class,
() -> service.cancelPendingTestAppRequests(EMAIL, "운영앱"));
verifyNoInteractions(appRequestRepository, approvalService);
}
@Test
void deleteUserCascade_found_deletesOrphanDataAndUser() {
PortalUser target = user();