Compare commits
9 Commits
develop
...
cff0199d2d
| Author | SHA1 | Date | |
|---|---|---|---|
| cff0199d2d | |||
| 2e920eec96 | |||
| 65a262849e | |||
| 7056306718 | |||
| 05e0599b26 | |||
| c223fa3124 | |||
| 78b1edff5a | |||
| b41a81bda3 | |||
| 1ea2c89776 |
+6
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+33
-3
@@ -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) {
|
||||
|
||||
+19
@@ -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";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+13
@@ -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);
|
||||
}
|
||||
|
||||
+6
@@ -13,4 +13,10 @@ public interface PartnershipApplicationFacade {
|
||||
* 현재 로그인 사용자가 작성한 최근 3건을 조회한다. 미인증이면 빈 목록.
|
||||
*/
|
||||
List<PartnershipApplicationSummaryDTO> getMyRecentApplications();
|
||||
|
||||
/**
|
||||
* 현재 로그인 사용자가 작성한 글 1건을 삭제한다(첨부파일 포함).
|
||||
* 본인 글이 아니거나 이미 삭제된 경우 {@link IllegalArgumentException}.
|
||||
*/
|
||||
void deleteMyApplication(String id);
|
||||
}
|
||||
|
||||
+20
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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;
|
||||
}
|
||||
|
||||
+41
-6
@@ -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에 저장 후 반환
|
||||
|
||||
+2
-2
@@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+4
@@ -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);
|
||||
}
|
||||
|
||||
+260
-3
@@ -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);
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
|
||||
+286
-3
@@ -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,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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
+1
-1
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>
|
||||
</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>
|
||||
|
||||
|
||||
+65
@@ -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");
|
||||
}
|
||||
}
|
||||
+120
-2
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user