Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a310b671e2 | |||
| d9373b00f0 |
Generated
+46
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.63.0",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"node-forge": "^1.3.1",
|
"node-forge": "^1.3.1",
|
||||||
"sass": "^1.69.5"
|
"sass": "^1.69.5"
|
||||||
@@ -766,6 +767,22 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.63.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
@@ -899,6 +916,35 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.63.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "SASS build system + forge custom bundle for EAPIM Portal",
|
"description": "SASS build system + forge custom bundle for EAPIM Portal",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test:moment": "playwright test --config=src/test/js/playwright.config.js",
|
||||||
"sass:build": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
|
"sass:build": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
|
||||||
"sass:build:minified": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.min.css --style=compressed",
|
"sass:build:minified": "sass src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.min.css --style=compressed",
|
||||||
"sass:watch": "sass --watch src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
|
"sass:watch": "sass --watch src/main/resources/static/sass/main.scss:src/main/resources/static/css/main.css --style=expanded",
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
"forge:build": "esbuild tools/forge-entry.js --bundle --minify --format=iife --global-name=forge --target=es5 --outfile=src/main/resources/static/js/lib/forge-crypto.min.js"
|
"forge:build": "esbuild tools/forge-entry.js --bundle --minify --format=iife --global-name=forge --target=es5 --outfile=src/main/resources/static/js/lib/forge-crypto.min.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.63.0",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"node-forge": "^1.3.1",
|
"node-forge": "^1.3.1",
|
||||||
"sass": "^1.69.5"
|
"sass": "^1.69.5"
|
||||||
|
|||||||
+8
-3
@@ -52,9 +52,10 @@ public class ApiStatisticsController {
|
|||||||
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
|
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
|
||||||
|
|
||||||
// 기본 조회 (일별, 7일 전 ~ 오늘, 전체 앱)
|
// 기본 조회 (일별, 7일 전 ~ 오늘, 전체 앱)
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
|
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
|
||||||
searchDto.setStartDate(LocalDate.now().minusDays(7));
|
searchDto.setStartDate(today.minusDays(7));
|
||||||
searchDto.setEndDate(LocalDate.now());
|
searchDto.setEndDate(today);
|
||||||
searchDto.setClientId(null);
|
searchDto.setClientId(null);
|
||||||
|
|
||||||
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
|
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
|
||||||
@@ -62,6 +63,9 @@ public class ApiStatisticsController {
|
|||||||
model.addAttribute("details", result.getDetails());
|
model.addAttribute("details", result.getDetails());
|
||||||
model.addAttribute("periods", result.getPeriods());
|
model.addAttribute("periods", result.getPeriods());
|
||||||
model.addAttribute("searchDto", searchDto);
|
model.addAttribute("searchDto", searchDto);
|
||||||
|
// 브라우저 시간대/시계와 관계없이 서버와 동일한 조회 경계를 적용한다.
|
||||||
|
model.addAttribute("statsMinDate", today.minusYears(1).toString());
|
||||||
|
model.addAttribute("statsMaxDate", today.toString());
|
||||||
|
|
||||||
// 월별 선택 가능 월 + 집계 안내 문구 데이터
|
// 월별 선택 가능 월 + 집계 안내 문구 데이터
|
||||||
model.addAttribute("availableMonths", apiStatisticsService.getAvailableMonths(orgId));
|
model.addAttribute("availableMonths", apiStatisticsService.getAvailableMonths(orgId));
|
||||||
@@ -124,7 +128,8 @@ public class ApiStatisticsController {
|
|||||||
private String rangeErrorMessage(ApiStatisticsSearchDto searchDto) {
|
private String rangeErrorMessage(ApiStatisticsSearchDto searchDto) {
|
||||||
return searchDto.isMonthly()
|
return searchDto.isMonthly()
|
||||||
? "조회할 월이 올바르지 않습니다."
|
? "조회할 월이 올바르지 않습니다."
|
||||||
: "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다.";
|
: "조회 기간은 1년 전부터 오늘까지의 날짜 중 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS
|
||||||
|
+ "일(시작일·종료일 포함)로 선택해주세요.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private PortalOrg getPortalOrg() {
|
private PortalOrg getPortalOrg() {
|
||||||
|
|||||||
+9
-4
@@ -15,7 +15,7 @@ import org.springframework.format.annotation.DateTimeFormat;
|
|||||||
public class ApiStatisticsSearchDto {
|
public class ApiStatisticsSearchDto {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 최대 조회 가능 일수 (일별 모드).
|
* 한 번에 조회 가능한 일수 (일별 모드, 시작일·종료일 포함).
|
||||||
*/
|
*/
|
||||||
public static final int MAX_DATE_RANGE_DAYS = 40;
|
public static final int MAX_DATE_RANGE_DAYS = 40;
|
||||||
|
|
||||||
@@ -59,17 +59,22 @@ public class ApiStatisticsSearchDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 날짜(일별) 범위가 유효한지 검증 (최대 40일).
|
* 1년 전부터 오늘까지의 날짜 중 최대 40일인지 검증 (양 끝 포함).
|
||||||
*/
|
*/
|
||||||
public boolean isValidDateRange() {
|
public boolean isValidDateRange() {
|
||||||
|
return isValidDateRange(LocalDate.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isValidDateRange(LocalDate today) {
|
||||||
if (startDate == null || endDate == null) {
|
if (startDate == null || endDate == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (startDate.isAfter(endDate)) {
|
if (startDate.isAfter(endDate)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
long daysBetween = ChronoUnit.DAYS.between(startDate, endDate);
|
LocalDate earliestDate = today.minusYears(1);
|
||||||
return daysBetween <= MAX_DATE_RANGE_DAYS;
|
return !startDate.isBefore(earliestDate) && !endDate.isAfter(today)
|
||||||
|
&& ChronoUnit.DAYS.between(startDate, endDate) < MAX_DATE_RANGE_DAYS;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
|||||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||||
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
||||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
|
||||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.config.PortalProperties;
|
import com.eactive.apim.portal.config.PortalProperties;
|
||||||
@@ -22,21 +21,15 @@ import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
|||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
|
|
||||||
import java.security.InvalidKeyException;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import javax.crypto.BadPaddingException;
|
|
||||||
import javax.crypto.IllegalBlockSizeException;
|
|
||||||
import javax.crypto.NoSuchPaddingException;
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
import javax.servlet.http.HttpSession;
|
import javax.servlet.http.HttpSession;
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.xerces.impl.dv.util.Base64;
|
|
||||||
import org.springframework.security.access.annotation.Secured;
|
import org.springframework.security.access.annotation.Secured;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
@@ -64,7 +57,6 @@ public class UserRegisterController {
|
|||||||
private final AgreementsFacade agreementsFacade;
|
private final AgreementsFacade agreementsFacade;
|
||||||
private final PortalProperties portalProperties;
|
private final PortalProperties portalProperties;
|
||||||
private final UserInvitationRepository userInvitationRepository;
|
private final UserInvitationRepository userInvitationRepository;
|
||||||
private final EncryptionUtil encryptionUtil;
|
|
||||||
private final AgreementValidator agreementValidator;
|
private final AgreementValidator agreementValidator;
|
||||||
private final PortalUserAuthService portalUserAuthService;
|
private final PortalUserAuthService portalUserAuthService;
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import com.eactive.apim.portal.apps.user.service.PortalOrgService;
|
|||||||
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||||
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
|
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
|
||||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
||||||
import com.eactive.apim.portal.common.exception.SystemException;
|
|
||||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
|
||||||
import com.eactive.apim.portal.file.entity.FileInfo;
|
import com.eactive.apim.portal.file.entity.FileInfo;
|
||||||
import com.eactive.apim.portal.file.service.FileService;
|
import com.eactive.apim.portal.file.service.FileService;
|
||||||
import com.eactive.apim.portal.file.service.FileTypeContext;
|
import com.eactive.apim.portal.file.service.FileTypeContext;
|
||||||
@@ -20,26 +18,14 @@ import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
|||||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
|
||||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
|
||||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.xerces.impl.dv.util.Base64;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import javax.crypto.BadPaddingException;
|
|
||||||
import javax.crypto.IllegalBlockSizeException;
|
|
||||||
import javax.crypto.NoSuchPaddingException;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.InvalidKeyException;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -58,8 +44,6 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
|||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final AgreementValidator agreementValidator;
|
private final AgreementValidator agreementValidator;
|
||||||
private final ApprovalService approvalService;
|
private final ApprovalService approvalService;
|
||||||
private final MessageHandlerService messageHandlerService;
|
|
||||||
private final EncryptionUtil encryptionUtil;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -213,29 +197,10 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
|||||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||||
|
|
||||||
approvalService.createUserApproval(newUser);
|
approvalService.createUserApproval(newUser);
|
||||||
// 11.28 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
|
||||||
// sendActivationEmail(newUser);
|
|
||||||
|
|
||||||
return new ValidationResponse(true, "법인 사용자 등록 신청이 완료되었습니다.");
|
return new ValidationResponse(true, "법인 사용자 등록 신청이 완료되었습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendActivationEmail(PortalUser newUser) {
|
|
||||||
MessageRecipient recipient = new MessageRecipient();
|
|
||||||
recipient.setUsername(newUser.getUserName());
|
|
||||||
recipient.setUserId(newUser.getEmailAddr());
|
|
||||||
recipient.setPhone(newUser.getMobileNumber());
|
|
||||||
HashMap<String, Object> params = new HashMap<>();
|
|
||||||
String tokenValue = newUser.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + newUser.getId();
|
|
||||||
|
|
||||||
try {
|
|
||||||
String encToken = encryptionUtil.encrypt(tokenValue);
|
|
||||||
params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
|
|
||||||
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
|
|
||||||
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
|
|
||||||
throw new SystemException("암호화 모듈 오류");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 기존 사용자를 법인 사용자로 전환하는 메서드
|
// 기존 사용자를 법인 사용자로 전환하는 메서드
|
||||||
private ValidationResponse convertExistingUserToCorporate(
|
private ValidationResponse convertExistingUserToCorporate(
|
||||||
PortalUser existingUser,
|
PortalUser existingUser,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.eactive.apim.portal.apps.user.facade;
|
|||||||
|
|
||||||
import com.eactive.apim.portal.agreements.entity.AgreementType;
|
import com.eactive.apim.portal.agreements.entity.AgreementType;
|
||||||
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
||||||
import com.eactive.apim.portal.apps.auth.service.AuthNumberGenerator;
|
|
||||||
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
|
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
|
||||||
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
|
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
|
||||||
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
||||||
@@ -11,15 +10,11 @@ import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
|||||||
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
|
import com.eactive.apim.portal.apps.user.service.UserRegistrationValidationService;
|
||||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
||||||
import com.eactive.apim.portal.apps.user.validator.PasswordValidator;
|
import com.eactive.apim.portal.apps.user.validator.PasswordValidator;
|
||||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
|
||||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
|
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums;
|
||||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
|
||||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
|
||||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -31,7 +26,6 @@ import org.springframework.validation.BindingResult;
|
|||||||
|
|
||||||
import javax.servlet.http.HttpSession;
|
import javax.servlet.http.HttpSession;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -49,9 +43,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
|||||||
private final PasswordValidator passwordValidator;
|
private final PasswordValidator passwordValidator;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final AgreementValidator agreementValidator;
|
private final AgreementValidator agreementValidator;
|
||||||
private final MessageHandlerService messageHandlerService;
|
|
||||||
private final EncryptionUtil encryptionUtil;
|
|
||||||
private final AuthNumberGenerator authNumberGenerator;
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -159,8 +150,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||||
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
|
||||||
// sendEmailActivation(newUser);
|
|
||||||
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
|
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,28 +218,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendEmailActivation(PortalUser registeredUser) {
|
|
||||||
MessageRecipient recipient = new MessageRecipient();
|
|
||||||
recipient.setUsername(registeredUser.getUserName());
|
|
||||||
recipient.setUserId(registeredUser.getEmailAddr());
|
|
||||||
recipient.setPhone(registeredUser.getMobileNumber());
|
|
||||||
|
|
||||||
// 25.10.01 - 이메일 링크 방식으로 접근 불가이기에 SMS 인증방식과 동일하게 대체
|
|
||||||
HashMap<String, Object> params = new HashMap<>();
|
|
||||||
String tokenValue = String.valueOf(authNumberGenerator.generateAuthNumber());
|
|
||||||
// String tokenValue = registeredUser.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ":" + registeredUser.getId();
|
|
||||||
params.put("token", tokenValue);
|
|
||||||
messageHandlerService.publishEvent(MessageCode.USER_VERIFICATION_EMAIL, recipient, params);
|
|
||||||
|
|
||||||
// try {
|
|
||||||
// String encToken = encryptionUtil.encrypt(tokenValue);
|
|
||||||
// params.put("token", Base64.encode(encToken.getBytes(StandardCharsets.UTF_8)));
|
|
||||||
// messageHandlerService.publishEvent(UserEmailActivationEvent.KEY, recipient, params);
|
|
||||||
// } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
|
|
||||||
// throw new SystemException("암호화 모듈 오류");
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public ValidationResponse processInvitation(String action, UserInvitation invitation) {
|
public ValidationResponse processInvitation(String action, UserInvitation invitation) {
|
||||||
|
|||||||
@@ -16,23 +16,14 @@ import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
|||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||||
import com.eactive.apim.portal.portaluser.event.UserPasswordResetEvent;
|
import com.eactive.apim.portal.portaluser.event.UserPasswordResetEvent;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
|
||||||
import com.eactive.apim.portal.template.entity.MessageRequest;
|
import com.eactive.apim.portal.template.entity.MessageRequest;
|
||||||
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
||||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.InvalidKeyException;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import javax.crypto.BadPaddingException;
|
|
||||||
import javax.crypto.IllegalBlockSizeException;
|
|
||||||
import javax.crypto.NoSuchPaddingException;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.xerces.impl.dv.util.Base64;
|
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
@@ -54,7 +45,6 @@ public class PortalUserAuthService implements UserDetailsService {
|
|||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final MessageHandlerService messageHandlerService;
|
private final MessageHandlerService messageHandlerService;
|
||||||
private final MessageRequestRepository messageRequestRepository;
|
private final MessageRequestRepository messageRequestRepository;
|
||||||
private final EncryptionUtil encryptionUtil;
|
|
||||||
private final LoginFinalizer loginFinalizer;
|
private final LoginFinalizer loginFinalizer;
|
||||||
private final PasswordService passwordService;
|
private final PasswordService passwordService;
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,5 @@
|
|||||||
package com.eactive.apim.portal.common.util;
|
package com.eactive.apim.portal.common.util;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.InvalidKeyException;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.util.Base64;
|
|
||||||
import javax.crypto.BadPaddingException;
|
|
||||||
import javax.crypto.Cipher;
|
|
||||||
import javax.crypto.IllegalBlockSizeException;
|
|
||||||
import javax.crypto.NoSuchPaddingException;
|
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
@Component("encryptionUtil")
|
|
||||||
public class EncryptionUtil {
|
public class EncryptionUtil {
|
||||||
|
|
||||||
public static String generateNewPassword() {
|
public static String generateNewPassword() {
|
||||||
@@ -53,40 +40,4 @@ public class EncryptionUtil {
|
|||||||
return newpassword.toString();
|
return newpassword.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Value("${encryption.key:kjbank_portal_application_1357902}")
|
|
||||||
private String secretKey; // Should be 16, 24, or 32 bytes long for AES-128, AES-192, or AES-256
|
|
||||||
|
|
||||||
private static final String ALGORITHM = "AES";
|
|
||||||
|
|
||||||
private SecretKeySpec createSecretKey() {
|
|
||||||
byte[] key = secretKey.getBytes(StandardCharsets.UTF_8);
|
|
||||||
return new SecretKeySpec(key, ALGORITHM);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String encrypt(String value) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException {
|
|
||||||
if (value == null || value.isEmpty()) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
SecretKeySpec key = createSecretKey();
|
|
||||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, key);
|
|
||||||
|
|
||||||
byte[] encryptedBytes = cipher.doFinal(value.getBytes());
|
|
||||||
return Base64.getEncoder().encodeToString(encryptedBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String decrypt(String encrypted) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException {
|
|
||||||
if (encrypted == null || encrypted.isEmpty()) {
|
|
||||||
return encrypted;
|
|
||||||
}
|
|
||||||
|
|
||||||
SecretKeySpec key = createSecretKey();
|
|
||||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, key);
|
|
||||||
|
|
||||||
byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encrypted));
|
|
||||||
return new String(decryptedBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25275,6 +25275,34 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.api-statistics-container .statistics-date-range {
|
||||||
|
width: 260px;
|
||||||
|
max-width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.api-statistics-container .statistics-date-range:focus-visible {
|
||||||
|
outline: 2px solid #0049B4;
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daterangepicker.statistics-date-picker {
|
||||||
|
z-index: 1100;
|
||||||
|
max-width: calc(100vw - 16px);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
.daterangepicker.statistics-date-picker .applyBtn {
|
||||||
|
color: #fff;
|
||||||
|
background: #0049B4;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.daterangepicker.statistics-date-picker {
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.btn-search {
|
.btn-search {
|
||||||
background: #0049B4;
|
background: #0049B4;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
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
@@ -0,0 +1,22 @@
|
|||||||
|
Copyright (c) JS Foundation and other contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person
|
||||||
|
obtaining a copy of this software and associated documentation
|
||||||
|
files (the "Software"), to deal in the Software without
|
||||||
|
restriction, including without limitation the rights to use,
|
||||||
|
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the
|
||||||
|
Software is furnished to do so, subject to the following
|
||||||
|
conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be
|
||||||
|
included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||||
|
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||||
|
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||||
|
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
OTHER DEALINGS IN THE SOFTWARE.
|
||||||
+2
-1
File diff suppressed because one or more lines are too long
@@ -118,6 +118,36 @@
|
|||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.api-statistics-container .statistics-date-range {
|
||||||
|
width: 260px;
|
||||||
|
max-width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid #0049B4;
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공통 달력 CSS의 z-index: 0을 이 페이지의 팝업에만 보정한다.
|
||||||
|
.daterangepicker.statistics-date-picker {
|
||||||
|
z-index: 1100;
|
||||||
|
max-width: calc(100vw - 16px);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
|
||||||
|
.applyBtn {
|
||||||
|
color: #fff;
|
||||||
|
background: #0049B4;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.btn-search {
|
.btn-search {
|
||||||
background: #0049B4;
|
background: #0049B4;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -41,10 +41,11 @@
|
|||||||
<div class="search-filter-row">
|
<div class="search-filter-row">
|
||||||
<!-- 일별: 날짜 범위 -->
|
<!-- 일별: 날짜 범위 -->
|
||||||
<div class="date-range-picker" id="dailyFilter">
|
<div class="date-range-picker" id="dailyFilter">
|
||||||
<input type="date" id="startDate" class="date-input"
|
<input type="text" id="statisticsDateRange" class="date-input statistics-date-range"
|
||||||
|
aria-label="일별 조회 기간" title="1년 전부터 오늘까지, 시작일·종료일을 포함해 최대 40일을 선택할 수 있습니다." readonly>
|
||||||
|
<input type="hidden" id="startDate"
|
||||||
th:value="${#temporals.format(searchDto.startDate, 'yyyy-MM-dd')}">
|
th:value="${#temporals.format(searchDto.startDate, 'yyyy-MM-dd')}">
|
||||||
<span class="date-separator">-</span>
|
<input type="hidden" id="endDate"
|
||||||
<input type="date" id="endDate" class="date-input"
|
|
||||||
th:value="${#temporals.format(searchDto.endDate, 'yyyy-MM-dd')}">
|
th:value="${#temporals.format(searchDto.endDate, 'yyyy-MM-dd')}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="btn-search" id="btnSearch">
|
<button type="button" class="btn-search" id="btnSearch" aria-label="통계 조회">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="11" cy="11" r="8"></circle>
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
<path d="m21 21-4.35-4.35"></path>
|
<path d="m21 21-4.35-4.35"></path>
|
||||||
@@ -335,6 +336,8 @@
|
|||||||
let currentMode = 'DAILY';
|
let currentMode = 'DAILY';
|
||||||
const CIRCUMFERENCE = 439.8; // 2 * PI * 70
|
const CIRCUMFERENCE = 439.8; // 2 * PI * 70
|
||||||
const MAX_DATE_RANGE_DAYS = 40;
|
const MAX_DATE_RANGE_DAYS = 40;
|
||||||
|
const MIN_QUERY_DATE = moment([[${ statsMinDate }]], 'YYYY-MM-DD', true);
|
||||||
|
const MAX_QUERY_DATE = moment([[${ statsMaxDate }]], 'YYYY-MM-DD', true);
|
||||||
|
|
||||||
// ── Donut (성공/타임아웃/실패 3세그먼트) ──
|
// ── Donut (성공/타임아웃/실패 3세그먼트) ──
|
||||||
function updateChart() {
|
function updateChart() {
|
||||||
@@ -460,16 +463,70 @@
|
|||||||
return meta ? meta.getAttribute('content') : '';
|
return meta ? meta.getAttribute('content') : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate date range (max 40 days)
|
// 서버 기준 1년 전 ~ 오늘 중 최대 40일만 조회한다 (양 끝 포함).
|
||||||
function validateDateRange(startDate, endDate) {
|
function validateDateRange(startDate, endDate) {
|
||||||
if (!startDate || !endDate) return { valid: false, message: '시작일과 종료일을 입력해주세요.' };
|
if (!startDate || !endDate) return { valid: false, message: '시작일과 종료일을 입력해주세요.' };
|
||||||
var start = new Date(startDate), end = new Date(endDate);
|
var start = moment(startDate, 'YYYY-MM-DD', true), end = moment(endDate, 'YYYY-MM-DD', true);
|
||||||
if (start > end) return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
|
if (!start.isValid() || !end.isValid()) return { valid: false, message: '올바른 날짜를 선택해주세요.' };
|
||||||
var diffDays = Math.floor((end - start) / (1000 * 60 * 60 * 24));
|
if (start.isAfter(end)) return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
|
||||||
if (diffDays > MAX_DATE_RANGE_DAYS) return { valid: false, message: '조회 기간은 최대 ' + MAX_DATE_RANGE_DAYS + '일까지 가능합니다.' };
|
if (start.isBefore(MIN_QUERY_DATE, 'day') || end.isAfter(MAX_QUERY_DATE, 'day')) {
|
||||||
|
return { valid: false, message: '조회 날짜는 1년 전부터 오늘까지만 선택할 수 있습니다.' };
|
||||||
|
}
|
||||||
|
if (end.diff(start, 'days') >= MAX_DATE_RANGE_DAYS) {
|
||||||
|
return { valid: false, message: '조회 기간은 시작일·종료일을 포함해 최대 ' + MAX_DATE_RANGE_DAYS + '일로 선택해주세요.' };
|
||||||
|
}
|
||||||
return { valid: true };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initializeDateRangePicker() {
|
||||||
|
var input = $('#statisticsDateRange');
|
||||||
|
function updateRangeText() {
|
||||||
|
input.val(moment($('#startDate').val(), 'YYYY-MM-DD').format('YYYY.MM.DD') + ' - '
|
||||||
|
+ moment($('#endDate').val(), 'YYYY-MM-DD').format('YYYY.MM.DD'));
|
||||||
|
}
|
||||||
|
|
||||||
|
input.daterangepicker({
|
||||||
|
startDate: moment($('#startDate').val(), 'YYYY-MM-DD', true),
|
||||||
|
endDate: moment($('#endDate').val(), 'YYYY-MM-DD', true),
|
||||||
|
minDate: MIN_QUERY_DATE.clone(),
|
||||||
|
maxDate: MAX_QUERY_DATE.clone(),
|
||||||
|
maxSpan: { days: MAX_DATE_RANGE_DAYS - 1 },
|
||||||
|
autoUpdateInput: false,
|
||||||
|
opens: 'left',
|
||||||
|
drops: 'auto',
|
||||||
|
locale: {
|
||||||
|
format: 'YYYY.MM.DD',
|
||||||
|
separator: ' - ',
|
||||||
|
applyLabel: '조회',
|
||||||
|
cancelLabel: '취소',
|
||||||
|
daysOfWeek: ['일', '월', '화', '수', '목', '금', '토'],
|
||||||
|
monthNames: ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'],
|
||||||
|
firstDay: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.data('daterangepicker').container.addClass('statistics-date-picker');
|
||||||
|
updateRangeText();
|
||||||
|
|
||||||
|
input.on('show.daterangepicker', function (event, picker) {
|
||||||
|
// 조회하지 않고 바깥을 눌러 닫았다면 확정된 조회 조건으로 다시 연다.
|
||||||
|
picker.setStartDate(moment($('#startDate').val(), 'YYYY-MM-DD', true));
|
||||||
|
picker.setEndDate(moment($('#endDate').val(), 'YYYY-MM-DD', true));
|
||||||
|
picker.updateView();
|
||||||
|
});
|
||||||
|
input.on('apply.daterangepicker', function (event, picker) {
|
||||||
|
$('#startDate').val(picker.startDate.format('YYYY-MM-DD'));
|
||||||
|
$('#endDate').val(picker.endDate.format('YYYY-MM-DD'));
|
||||||
|
updateRangeText();
|
||||||
|
searchStatistics();
|
||||||
|
});
|
||||||
|
input.on('keydown', function (event) {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
input.data('daterangepicker').show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildPayload() {
|
function buildPayload() {
|
||||||
return {
|
return {
|
||||||
mode: currentMode,
|
mode: currentMode,
|
||||||
@@ -505,8 +562,10 @@
|
|||||||
if (p.mode === 'MONTHLY') { $('#dailyFilter').hide(); $('#monthlyFilter').show(); }
|
if (p.mode === 'MONTHLY') { $('#dailyFilter').hide(); $('#monthlyFilter').show(); }
|
||||||
else { $('#monthlyFilter').hide(); $('#dailyFilter').show(); }
|
else { $('#monthlyFilter').hide(); $('#dailyFilter').show(); }
|
||||||
}
|
}
|
||||||
if (p.startDate) $('#startDate').val(p.startDate);
|
if (validateDateRange(p.startDate, p.endDate).valid) {
|
||||||
if (p.endDate) $('#endDate').val(p.endDate);
|
$('#startDate').val(p.startDate);
|
||||||
|
$('#endDate').val(p.endDate);
|
||||||
|
}
|
||||||
if (p.month) $('#month').val(p.month);
|
if (p.month) $('#month').val(p.month);
|
||||||
if (p.clientId !== undefined && p.clientId !== null) { selectedClientId = p.clientId; $('#appSelect').val(p.clientId); }
|
if (p.clientId !== undefined && p.clientId !== null) { selectedClientId = p.clientId; $('#appSelect').val(p.clientId); }
|
||||||
return true;
|
return true;
|
||||||
@@ -563,12 +622,12 @@
|
|||||||
// Event Handlers
|
// Event Handlers
|
||||||
$('#btnSearch').on('click', searchStatistics);
|
$('#btnSearch').on('click', searchStatistics);
|
||||||
$('#appSelect').on('change', function () { selectedClientId = $(this).val(); searchStatistics(); });
|
$('#appSelect').on('change', function () { selectedClientId = $(this).val(); searchStatistics(); });
|
||||||
$('#startDate, #endDate').on('change', searchStatistics);
|
|
||||||
$('#month').on('change', searchStatistics);
|
$('#month').on('change', searchStatistics);
|
||||||
$('#startDate, #endDate').on('keypress', function (e) { if (e.which === 13) searchStatistics(); });
|
|
||||||
|
|
||||||
// 저장된 조회 파라미터가 있으면 복원 후 재조회 (없으면 서버 초기 데이터 유지)
|
// 저장된 조회 파라미터가 있으면 복원 후 재조회 (없으면 서버 초기 데이터 유지)
|
||||||
if (restoreParams()) {
|
var restored = restoreParams();
|
||||||
|
initializeDateRangePicker();
|
||||||
|
if (restored) {
|
||||||
searchStatistics();
|
searchStatistics();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
package com.eactive.apim.portal.apps.statistics.controller;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsResultDto;
|
||||||
|
import com.eactive.apim.portal.apps.statistics.service.ApiStatisticsService;
|
||||||
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
|
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
|
||||||
|
class ApiStatisticsDateRangeTest {
|
||||||
|
|
||||||
|
private ApiStatisticsService service;
|
||||||
|
private MockMvc mvc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setup() {
|
||||||
|
service = mock(ApiStatisticsService.class);
|
||||||
|
PortalOrg org = mock(PortalOrg.class);
|
||||||
|
when(org.getId()).thenReturn("test-org");
|
||||||
|
PortalAuthenticatedUser user = mock(PortalAuthenticatedUser.class);
|
||||||
|
when(user.getPortalOrg()).thenReturn(org);
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, null));
|
||||||
|
mvc = MockMvcBuilders.standaloneSetup(new ApiStatisticsController(service)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanup() {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsOutOfWindowAnd41DayRangesForSearchAndDownload() throws Exception {
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
LocalDate old = today.minusYears(1).minusDays(1);
|
||||||
|
LocalDate future = today.plusDays(1);
|
||||||
|
LocalDate[][] invalid = {{old, old}, {future, future}, {today.minusDays(40), today}};
|
||||||
|
for (LocalDate[] range : invalid) {
|
||||||
|
mvc.perform(post("/statistics/api/search").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(payload(range[0], range[1])))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
mvc.perform(get("/statistics/api/download")
|
||||||
|
.param("startDate", range[0].toString()).param("endDate", range[1].toString()))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
verifyNoInteractions(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void accepts40InclusiveDaysAndInvokesStatisticsService() throws Exception {
|
||||||
|
when(service.getStatistics(eq("test-org"), any())).thenReturn(ApiStatisticsResultDto.empty());
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
mvc.perform(post("/statistics/api/search").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(payload(today.minusDays(39), today)))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
verify(service).getStatistics(eq("test-org"), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rendersCalendarBoundsFromServerDate() throws Exception {
|
||||||
|
when(service.getStatistics(eq("test-org"), any())).thenReturn(ApiStatisticsResultDto.empty());
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
mvc.perform(get("/statistics/api"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(model().attribute("statsMinDate", today.minusYears(1).toString()))
|
||||||
|
.andExpect(model().attribute("statsMaxDate", today.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String payload(LocalDate start, LocalDate end) {
|
||||||
|
return "{\"mode\":\"DAILY\",\"startDate\":\"" + start + "\",\"endDate\":\"" + end + "\"}";
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
package com.eactive.apim.portal.apps.statistics.dto;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class ApiStatisticsSearchDtoTest {
|
||||||
|
|
||||||
|
private boolean valid(String start, String end, String today) {
|
||||||
|
ApiStatisticsSearchDto dto = new ApiStatisticsSearchDto();
|
||||||
|
dto.setStartDate(start == null ? null : LocalDate.parse(start));
|
||||||
|
dto.setEndDate(end == null ? null : LocalDate.parse(end));
|
||||||
|
return dto.isValidDateRange(LocalDate.parse(today));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsTodayAndExactlyOneYearAgoButRejectsOutsideDates() {
|
||||||
|
assertTrue(valid("2026-09-09", "2026-09-09", "2026-09-09"));
|
||||||
|
assertTrue(valid("2025-09-09", "2025-09-09", "2026-09-09"));
|
||||||
|
assertFalse(valid("2025-09-08", "2025-09-08", "2026-09-09"));
|
||||||
|
assertFalse(valid("2026-09-10", "2026-09-10", "2026-09-09"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countsBothEndpointsAndHandlesLeapDays() {
|
||||||
|
assertTrue(valid("2026-08-01", "2026-09-09", "2026-09-09"));
|
||||||
|
assertFalse(valid("2026-07-31", "2026-09-09", "2026-09-09"));
|
||||||
|
assertTrue(valid("2024-02-10", "2024-03-20", "2024-04-10"));
|
||||||
|
assertFalse(valid("2024-02-09", "2024-03-20", "2024-04-10"));
|
||||||
|
assertTrue(valid("2023-02-28", "2023-02-28", "2024-02-29"));
|
||||||
|
assertFalse(valid("2023-02-27", "2023-02-27", "2024-02-29"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingOrReversedDates() {
|
||||||
|
assertFalse(valid(null, "2026-09-09", "2026-09-09"));
|
||||||
|
assertFalse(valid("2026-09-09", null, "2026-09-09"));
|
||||||
|
assertFalse(valid("2026-09-09", "2026-09-08", "2026-09-09"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicValidatorUsesCurrentServerDate() {
|
||||||
|
ApiStatisticsSearchDto dto = new ApiStatisticsSearchDto();
|
||||||
|
dto.setStartDate(LocalDate.now());
|
||||||
|
dto.setEndDate(LocalDate.now());
|
||||||
|
assertTrue(dto.isValidDateRange());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Moment.js 호환성 테스트
|
||||||
|
|
||||||
|
Node.js 20 이상에서 실행한다.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
npx playwright install chromium
|
||||||
|
npm run test:moment
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux CI에서 브라우저 시스템 라이브러리도 설치해야 한다면
|
||||||
|
`npx playwright install --with-deps chromium`을 사용한다.
|
||||||
|
|
||||||
|
Spring 서버, DB, 외부 CDN 없이 저장소의 실제 jQuery, Moment.js,
|
||||||
|
daterangepicker.js, front2.js와 달력 CSS를 Chromium에 로드한다.
|
||||||
|
초기화와 콜백은 제품 코드를 그대로 실행하며, 테스트에서 복제하거나 모킹하지 않는다.
|
||||||
|
HTML fixture는 공통 스크립트에 필요한 레이아웃과 날짜 입력 필드를 제공한다.
|
||||||
|
|
||||||
|
서울과 뉴욕 시간대 각각에서 날짜 표시, 윤년, 월/연도 경계 이동, 선택 적용,
|
||||||
|
취소, 같은 날 선택, 서머타임 경계의 직접 입력을 검증한다.
|
||||||
|
CommonJS 격리 실행에서는 CVE-2022-24785의 경로 탐색 로케일이
|
||||||
|
`require`까지 도달하지 않는지 검사한다. 브라우저의 정상 사용과 별도의 보안 회귀 검사다.
|
||||||
|
실패 시 스크린샷과 trace는 `build/playwright`에 저장된다.
|
||||||
|
실제 서버 통합과 전체 페이지의 시각적 배치는 이 테스트 범위에 포함되지 않는다.
|
||||||
|
|
||||||
|
## APP 통계 페이지
|
||||||
|
|
||||||
|
`statistics-date-range.spec.js`는 `apiStatistics.html`의 실제 DOM과 인라인 스크립트를
|
||||||
|
실행한다. Thymeleaf가 주입할 초기 데이터와 통계 검색 API 응답만 테스트 값으로 대체한다.
|
||||||
|
일별 기간 선택/조회, 취소 및 바깥 클릭, 서버 기준 1년 전~오늘 제한,
|
||||||
|
시작일·종료일을 포함한 최대 40일 제한, 범위를 벗어난 세션 조건 폐기 및 요청 차단,
|
||||||
|
월별 전환, 앱 선택 유지, ISO 날짜와 CSRF 헤더 전송, 모바일 표시를 검증한다.
|
||||||
|
서버의 Thymeleaf 렌더링과 실제 통계 집계는 별도 통합 검증 대상이다.
|
||||||
|
|
||||||
|
통계 페이지만 실행: `npm run test:moment -- --grep 'statistics:'`
|
||||||
|
|
||||||
|
## 배포 파일 출처
|
||||||
|
|
||||||
|
- 버전: 2.30.1 (이전 버전 2.24.0)
|
||||||
|
- 공식 파일: https://raw.githubusercontent.com/moment/moment/2.30.1/min/moment.min.js
|
||||||
|
- SHA-256: `845c524969edd5b3af9aa6d8718d29fe92e8dbe25b955214a8e064a05a9a5027`
|
||||||
|
- 배포 경로: `src/main/resources/static/js/moment.min.js`
|
||||||
|
- MIT 라이선스: 동일 디렉터리의 `moment.LICENSE` (공식 태그의 LICENSE 원문)
|
||||||
|
- 보안 공지: https://github.com/moment/moment/security/advisories/GHSA-8hfj-j24r-96c4
|
||||||
|
|
||||||
|
버전 업그레이드 시 공식 배포 파일과 해시를 확인하고 버전 assertion도 갱신한다.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Portal date range compatibility</title>
|
||||||
|
<link rel="stylesheet" href="/css/daterangepicker.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- front2.js의 공통 레이아웃 이벤트에도 실제 DOM을 제공한다. -->
|
||||||
|
<header id="header"><nav id="nav"><div id="main_nav"></div><div id="sub"></div></nav></header>
|
||||||
|
<main id="wrap" style="padding: 100px 400px">
|
||||||
|
<form>
|
||||||
|
<label>조회 기간 <input type="text" name="daterange" style="width: 240px"></label>
|
||||||
|
<input type="hidden" name="startDate" value="2024.02.28">
|
||||||
|
<input type="hidden" name="endDate" value="2024.03.02">
|
||||||
|
<button class="datepicker_icon" type="button">달력 열기</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<div class="family-sites"><button id="family-sites-toggle" type="button">관련 사이트</button><ul></ul></div>
|
||||||
|
</footer>
|
||||||
|
<!-- 제품에서 사용하는 파일과 순서 그대로 실행한다. 초기화 코드를 복제하지 않는다. -->
|
||||||
|
<script src="/plugins/jquery/jquery-3.7.1.min.js"></script>
|
||||||
|
<script src="/js/moment.min.js"></script>
|
||||||
|
<script src="/js/daterangepicker.js"></script>
|
||||||
|
<script src="/js/front2.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const { readFileSync } = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
|
||||||
|
const staticRoot = path.resolve(__dirname, '../../main/resources/static');
|
||||||
|
const momentSource = readFileSync(path.join(staticRoot, 'js/moment.min.js'), 'utf8');
|
||||||
|
const fixture = readFileSync(path.join(__dirname, 'fixtures/date-range.html'), 'utf8');
|
||||||
|
const assets = new Map([
|
||||||
|
['/css/daterangepicker.css', 'text/css'],
|
||||||
|
['/plugins/jquery/jquery-3.7.1.min.js', 'text/javascript'],
|
||||||
|
['/js/moment.min.js', 'text/javascript'],
|
||||||
|
['/js/daterangepicker.js', 'text/javascript'],
|
||||||
|
['/js/front2.js', 'text/javascript'],
|
||||||
|
['/img/icon/icon_close.png', 'image/png'],
|
||||||
|
['/img/icon/icon_dp_arrow_prev.png', 'image/png'],
|
||||||
|
['/img/icon/icon_dp_arrow_next.png', 'image/png']
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function openCalendar(page, start = '2024.02.28', end = '2024.03.02') {
|
||||||
|
// 애플리케이션 서버/DB/외부 CDN 없이 저장소의 실제 정적 파일을 제공한다.
|
||||||
|
await page.route('**/*', async route => {
|
||||||
|
const pathname = new URL(route.request().url()).pathname;
|
||||||
|
if (pathname === '/date-range') {
|
||||||
|
return route.fulfill({
|
||||||
|
contentType: 'text/html',
|
||||||
|
body: fixture.replace('value="2024.02.28"', `value="${start}"`)
|
||||||
|
.replace('value="2024.03.02"', `value="${end}"`)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (assets.has(pathname)) {
|
||||||
|
return route.fulfill({
|
||||||
|
contentType: assets.get(pathname),
|
||||||
|
path: path.join(staticRoot, pathname.slice(1))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected fixture request: ${route.request().url()}`);
|
||||||
|
});
|
||||||
|
await page.goto('http://portal.test/date-range');
|
||||||
|
await expect(page.locator('[name="daterange"]')).toHaveValue(`${start} - ${end}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function day(page, side, number) {
|
||||||
|
return page.locator(`.drp-calendar.${side} td.available:not(.off)`)
|
||||||
|
.filter({ hasText: new RegExp(`^${number}$`) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectRange(page, start, end) {
|
||||||
|
await expect(page.locator('[name="daterange"]')).toHaveValue(`${start} - ${end}`);
|
||||||
|
await expect(page.locator('[name="startDate"]')).toHaveValue(start);
|
||||||
|
await expect(page.locator('[name="endDate"]')).toHaveValue(end);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// 공통 스크립트를 포함해 실행 중 JS 오류를 숨기지 않는다.
|
||||||
|
page.on('pageerror', error => { throw error; });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ships the patched release and rejects traversal before CommonJS require', () => {
|
||||||
|
const requestedModules = [];
|
||||||
|
const context = {
|
||||||
|
exports: {},
|
||||||
|
module: { exports: {} },
|
||||||
|
require(name) {
|
||||||
|
requestedModules.push(name);
|
||||||
|
throw new Error('Locale module is not installed in this isolated test');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
vm.runInNewContext(momentSource, context);
|
||||||
|
const moment = context.module.exports;
|
||||||
|
expect(moment.version).toBe('2.30.1');
|
||||||
|
// 정상 로케일은 로딩을 시도해야 한다: require 분기가 실제 실행됨을 확인한다.
|
||||||
|
moment.locale('fr');
|
||||||
|
expect(requestedModules).toContain('./locale/fr');
|
||||||
|
requestedModules.length = 0;
|
||||||
|
moment.locale('../../package');
|
||||||
|
moment.locale('..\\..\\package');
|
||||||
|
expect(requestedModules).toEqual([]);
|
||||||
|
expect(moment.locale()).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initializes the real portal calendar with its date format and Korean labels', async ({ page }) => {
|
||||||
|
await openCalendar(page);
|
||||||
|
expect(await page.evaluate(() => window.moment.version)).toBe('2.30.1');
|
||||||
|
await page.getByRole('button', { name: '달력 열기' }).click();
|
||||||
|
await expect(page.locator('.daterangepicker')).toBeVisible();
|
||||||
|
await expect(page.locator('.drp-calendar.left .month')).toHaveText('2024.02');
|
||||||
|
await expect(page.locator('.drp-calendar.right .month')).toHaveText('2024.03');
|
||||||
|
await expect(page.locator('.applyBtn')).toHaveText('확인');
|
||||||
|
await expect(page.locator('.cancelBtn')).toHaveText('취소');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selects leap day across months and updates the submitted fields', async ({ page }) => {
|
||||||
|
await openCalendar(page);
|
||||||
|
await page.locator('[name="daterange"]').click();
|
||||||
|
await day(page, 'left', 29).click();
|
||||||
|
await day(page, 'right', 3).click();
|
||||||
|
await page.locator('.applyBtn').click();
|
||||||
|
await expect(page.locator('.daterangepicker')).toBeHidden();
|
||||||
|
await expectRange(page, '2024.02.29', '2024.03.03');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('navigates across year end and preserves the selected year', async ({ page }) => {
|
||||||
|
await openCalendar(page, '2024.11.15', '2024.11.20');
|
||||||
|
await page.locator('[name="daterange"]').click();
|
||||||
|
await page.locator('.drp-calendar.right .next').click();
|
||||||
|
await expect(page.locator('.drp-calendar.left .month')).toHaveText('2024.12');
|
||||||
|
await expect(page.locator('.drp-calendar.right .month')).toHaveText('2025.01');
|
||||||
|
await day(page, 'left', 31).click();
|
||||||
|
await day(page, 'right', 2).click();
|
||||||
|
await page.locator('.applyBtn').click();
|
||||||
|
await expectRange(page, '2024.12.31', '2025.01.02');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancels a changed selection and restores the original dates', async ({ page }) => {
|
||||||
|
await openCalendar(page);
|
||||||
|
await page.locator('[name="daterange"]').click();
|
||||||
|
await day(page, 'left', 29).click();
|
||||||
|
await day(page, 'right', 4).click();
|
||||||
|
await page.locator('.cancelBtn').click();
|
||||||
|
await expectRange(page, '2024.02.28', '2024.03.02');
|
||||||
|
await page.locator('[name="daterange"]').click();
|
||||||
|
await expect(page.locator('.drp-calendar.left td.start-date')).toHaveText('28');
|
||||||
|
await expect(page.locator('.drp-calendar.right td.end-date')).toHaveText('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts a typed range across daylight saving and updates hidden fields', async ({ page }) => {
|
||||||
|
await openCalendar(page);
|
||||||
|
const input = page.locator('[name="daterange"]');
|
||||||
|
await input.fill('2024.03.09 - 2024.03.11');
|
||||||
|
// daterangepicker는 keyup에서 입력을 파싱하고 hide에서 제품 콜백을 호출한다.
|
||||||
|
await input.press('ArrowRight');
|
||||||
|
await input.press('Tab');
|
||||||
|
await expectRange(page, '2024.03.09', '2024.03.11');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports same-day selection', async ({ page }) => {
|
||||||
|
await openCalendar(page);
|
||||||
|
await page.locator('[name="daterange"]').click();
|
||||||
|
await day(page, 'left', 29).click();
|
||||||
|
await day(page, 'left', 29).click();
|
||||||
|
await page.locator('.applyBtn').click();
|
||||||
|
await expectRange(page, '2024.02.29', '2024.02.29');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates leap dates and retains month-end arithmetic', async ({ page }) => {
|
||||||
|
await openCalendar(page);
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const moment = window.moment;
|
||||||
|
return {
|
||||||
|
leap: moment('2024.02.29', 'YYYY.MM.DD', true).isValid(),
|
||||||
|
invalid: moment('2023.02.29', 'YYYY.MM.DD', true).isValid(),
|
||||||
|
malformed: moment('not-a-date', 'YYYY.MM.DD', true).isValid(),
|
||||||
|
monthEnd: moment('2024.01.31', 'YYYY.MM.DD').add(1, 'month').format('YYYY.MM.DD'),
|
||||||
|
dayAfter: moment('2024.02.29', 'YYYY.MM.DD').add(1, 'day').format('YYYY.MM.DD')
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
leap: true, invalid: false, malformed: false, monthEnd: '2024.02.29', dayAfter: '2024.03.01'
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const { defineConfig } = require('@playwright/test');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: __dirname,
|
||||||
|
testMatch: ['moment-compatibility.spec.js', 'statistics-date-range.spec.js'],
|
||||||
|
outputDir: '../../../build/playwright',
|
||||||
|
fullyParallel: true,
|
||||||
|
workers: 2,
|
||||||
|
reporter: 'list',
|
||||||
|
use: {
|
||||||
|
browserName: 'chromium',
|
||||||
|
locale: 'ko-KR',
|
||||||
|
viewport: { width: 1280, height: 800 },
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
screenshot: 'only-on-failure'
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'seoul', use: { timezoneId: 'Asia/Seoul' } },
|
||||||
|
{ name: 'new-york', use: { timezoneId: 'America/New_York' } }
|
||||||
|
]
|
||||||
|
});
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const { readFileSync, existsSync } = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const resources = path.resolve(__dirname, '../../main/resources');
|
||||||
|
const staticRoot = path.join(resources, 'static');
|
||||||
|
const template = readFileSync(path.join(resources, 'templates/views/apps/statistics/apiStatistics.html'), 'utf8');
|
||||||
|
const summary = { totalCount: 123, successCount: 123, timeoutCount: 0, errorCount: 0,
|
||||||
|
successRate: 100, timeoutRate: 0, errorRate: 0 };
|
||||||
|
|
||||||
|
function statisticsHtml() {
|
||||||
|
// 실제 템플릿의 DOM/인라인 스크립트를 실행한다. 서버가 주입하는 데이터만 고정한다.
|
||||||
|
const values = { details: [], periods: [], 'searchDto.clientId': null,
|
||||||
|
statsMinDate: '2023-04-10', statsMaxDate: '2024-04-10' };
|
||||||
|
for (const [key, value] of Object.entries(summary)) values[`summary.${key}`] = value;
|
||||||
|
return template.replace(/\[\[\$\{\s*([^}]+?)\s*\}\]\]/g, (_, expression) => {
|
||||||
|
if (!(expression in values)) throw new Error(`Missing server fixture value: ${expression}`);
|
||||||
|
return JSON.stringify(values[expression]);
|
||||||
|
}).replace('th:value="${#temporals.format(searchDto.startDate, \'yyyy-MM-dd\')}"', 'value="2024-02-28"')
|
||||||
|
.replace('th:value="${#temporals.format(searchDto.endDate, \'yyyy-MM-dd\')}"', 'value="2024-03-02"')
|
||||||
|
.replace('th:value="${m}"', 'value="202607"')
|
||||||
|
.replace('th:value="${app.clientId}"', 'value="demo-app"')
|
||||||
|
.replace('<body>', `<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="_csrf" content="test-csrf">
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<link rel="stylesheet" href="/css/daterangepicker.css">
|
||||||
|
<script src="/plugins/jquery/jquery-3.7.1.min.js"></script>
|
||||||
|
<script src="/js/moment.min.js"></script>
|
||||||
|
<script src="/js/daterangepicker.js"></script>
|
||||||
|
</head><body>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openStatistics(page, storedParams) {
|
||||||
|
const searches = [];
|
||||||
|
page.on('pageerror', error => { throw error; });
|
||||||
|
if (storedParams) {
|
||||||
|
await page.addInitScript(params => sessionStorage.setItem('apiStatsParams', JSON.stringify(params)), storedParams);
|
||||||
|
}
|
||||||
|
await page.route('**/*', async route => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
if (url.pathname === '/statistics/api/search') {
|
||||||
|
searches.push({ payload: route.request().postDataJSON(), headers: route.request().headers() });
|
||||||
|
return route.fulfill({ json: { summary, details: [], periods: [] } });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/statistics/api') {
|
||||||
|
return route.fulfill({ contentType: 'text/html', body: statisticsHtml() });
|
||||||
|
}
|
||||||
|
const asset = path.resolve(staticRoot, '.' + url.pathname);
|
||||||
|
if (url.origin === 'http://portal.test' && asset.startsWith(staticRoot + path.sep) && existsSync(asset)) {
|
||||||
|
return route.fulfill({ path: asset });
|
||||||
|
}
|
||||||
|
return route.abort();
|
||||||
|
});
|
||||||
|
await page.goto('http://portal.test/statistics/api');
|
||||||
|
await expect(page.locator('#statisticsDateRange')).not.toHaveValue('');
|
||||||
|
return searches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function day(page, side, date) {
|
||||||
|
return page.locator(`.statistics-date-picker .${side} td.available:not(.off)`)
|
||||||
|
.filter({ hasText: new RegExp(`^${date}$`) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectLeapRange(page) {
|
||||||
|
await page.getByLabel('일별 조회 기간').click();
|
||||||
|
await day(page, 'left', 29).click();
|
||||||
|
await day(page, 'right', 3).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('statistics: initializes from server dates without a redundant search', async ({ page }, testInfo) => {
|
||||||
|
const searches = await openStatistics(page);
|
||||||
|
await expect(page.getByLabel('일별 조회 기간')).toHaveValue('2024.02.28 - 2024.03.02');
|
||||||
|
await page.getByLabel('일별 조회 기간').press('Enter');
|
||||||
|
await expect(page.locator('.statistics-date-picker')).toBeVisible();
|
||||||
|
await expect(page.locator('.statistics-date-picker .left .month')).toHaveText('2월 2024');
|
||||||
|
await expect(page.locator('.statistics-date-picker .applyBtn')).toHaveText('조회');
|
||||||
|
expect(searches).toHaveLength(0);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('statistics-calendar.png'), fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('statistics: applies a leap-day range with one search and ISO payload', async ({ page }) => {
|
||||||
|
const searches = await openStatistics(page);
|
||||||
|
await selectLeapRange(page);
|
||||||
|
expect(searches).toHaveLength(0);
|
||||||
|
await page.locator('.statistics-date-picker .applyBtn').click();
|
||||||
|
await expect.poll(() => searches.length).toBe(1);
|
||||||
|
expect(searches[0].payload).toMatchObject({ mode: 'DAILY', startDate: '2024-02-29', endDate: '2024-03-03' });
|
||||||
|
expect(searches[0].headers['x-xsrf-token']).toBe('test-csrf');
|
||||||
|
await expect(page.getByLabel('일별 조회 기간')).toHaveValue('2024.02.29 - 2024.03.03');
|
||||||
|
await expect(page.locator('#startDate')).toHaveValue('2024-02-29');
|
||||||
|
await expect(page.locator('#endDate')).toHaveValue('2024-03-03');
|
||||||
|
await expect(page.locator('#totalCount')).toHaveText('123');
|
||||||
|
expect(await page.evaluate(() => JSON.parse(sessionStorage.getItem('apiStatsParams'))))
|
||||||
|
.toMatchObject({ startDate: '2024-02-29', endDate: '2024-03-03' });
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const action of ['cancel', 'outside']) {
|
||||||
|
test(`statistics: ${action} discards pending selection without searching`, async ({ page }) => {
|
||||||
|
const searches = await openStatistics(page);
|
||||||
|
await selectLeapRange(page);
|
||||||
|
if (action === 'cancel') await page.locator('.statistics-date-picker .cancelBtn').click();
|
||||||
|
else await page.locator('.statistics-notice').click();
|
||||||
|
await expect(page.locator('.statistics-date-picker')).toBeHidden();
|
||||||
|
await expect(page.getByLabel('일별 조회 기간')).toHaveValue('2024.02.28 - 2024.03.02');
|
||||||
|
await expect(page.locator('#startDate')).toHaveValue('2024-02-28');
|
||||||
|
await page.getByLabel('일별 조회 기간').click();
|
||||||
|
await expect(page.locator('.statistics-date-picker .left .start-date')).toHaveText('28');
|
||||||
|
await expect(page.locator('.statistics-date-picker .right .end-date')).toHaveText('2');
|
||||||
|
expect(searches).toHaveLength(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('statistics: restores saved dates and retains monthly mode and app selection', async ({ page }) => {
|
||||||
|
const searches = await openStatistics(page, {
|
||||||
|
mode: 'MONTHLY', startDate: '2024-03-09', endDate: '2024-03-11', month: '202607', clientId: 'demo-app'
|
||||||
|
});
|
||||||
|
await expect.poll(() => searches.length).toBe(1);
|
||||||
|
await expect(page.locator('#dailyFilter')).toBeHidden();
|
||||||
|
await expect(page.locator('#monthlyFilter')).toBeVisible();
|
||||||
|
expect(searches[0].payload).toMatchObject({ mode: 'MONTHLY', month: '202607', clientId: 'demo-app' });
|
||||||
|
await page.getByRole('button', { name: '일별', exact: true }).click();
|
||||||
|
await expect.poll(() => searches.length).toBe(2);
|
||||||
|
await expect(page.getByLabel('일별 조회 기간')).toHaveValue('2024.03.09 - 2024.03.11');
|
||||||
|
expect(searches[1].payload).toMatchObject({ mode: 'DAILY', startDate: '2024-03-09', endDate: '2024-03-11', clientId: 'demo-app' });
|
||||||
|
await page.getByRole('button', { name: '통계 조회', exact: true }).click();
|
||||||
|
await expect.poll(() => searches.length).toBe(3);
|
||||||
|
expect(searches[2].payload).toEqual(searches[1].payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('statistics: limits selection to 40 inclusive days even across DST', async ({ page }) => {
|
||||||
|
const searches = await openStatistics(page, { mode: 'DAILY', startDate: '2024-03-01', endDate: '2024-03-02' });
|
||||||
|
await expect.poll(() => searches.length).toBe(1);
|
||||||
|
await page.getByLabel('일별 조회 기간').click();
|
||||||
|
await day(page, 'left', 1).click();
|
||||||
|
await expect(page.locator('.statistics-date-picker .right td.off.disabled:not(.ends)').filter({ hasText: /^10$/ })).toBeVisible();
|
||||||
|
await day(page, 'right', 9).click();
|
||||||
|
await page.locator('.statistics-date-picker .applyBtn').click();
|
||||||
|
await expect.poll(() => searches.length).toBe(2);
|
||||||
|
expect(searches[1].payload).toMatchObject({ startDate: '2024-03-01', endDate: '2024-04-09' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('statistics: disables dates before one year ago and allows the earliest day', async ({ page }) => {
|
||||||
|
const searches = await openStatistics(page, { mode: 'DAILY', startDate: '2023-04-10', endDate: '2023-04-10' });
|
||||||
|
await expect.poll(() => searches.length).toBe(1);
|
||||||
|
await page.getByLabel('일별 조회 기간').click();
|
||||||
|
await expect(page.locator('.statistics-date-picker .left td.off.disabled:not(.ends)').filter({ hasText: /^9$/ })).toBeVisible();
|
||||||
|
await day(page, 'left', 10).click();
|
||||||
|
await day(page, 'left', 10).click();
|
||||||
|
await page.locator('.statistics-date-picker .applyBtn').click();
|
||||||
|
await expect.poll(() => searches.length).toBe(2);
|
||||||
|
expect(searches[1].payload).toMatchObject({ startDate: '2023-04-10', endDate: '2023-04-10' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('statistics: allows server today and disables future dates regardless of browser timezone', async ({ page }) => {
|
||||||
|
const searches = await openStatistics(page, { mode: 'DAILY', startDate: '2024-03-20', endDate: '2024-04-10' });
|
||||||
|
await expect.poll(() => searches.length).toBe(1);
|
||||||
|
await page.getByLabel('일별 조회 기간').click();
|
||||||
|
await expect(page.locator('.statistics-date-picker .right td.off.disabled:not(.ends)').filter({ hasText: /^11$/ })).toBeVisible();
|
||||||
|
await day(page, 'right', 10).click();
|
||||||
|
await page.locator('.statistics-date-picker td.start-date.available:not(.off)').click();
|
||||||
|
await page.locator('.statistics-date-picker .applyBtn').click();
|
||||||
|
await expect.poll(() => searches.length).toBe(2);
|
||||||
|
expect(searches[1].payload).toMatchObject({ startDate: '2024-04-10', endDate: '2024-04-10' });
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [label, startDate, endDate] of [
|
||||||
|
['before the earliest date', '2023-04-09', '2023-04-09'],
|
||||||
|
['future date', '2024-04-11', '2024-04-11'],
|
||||||
|
['41 inclusive days', '2024-03-01', '2024-04-10']
|
||||||
|
]) {
|
||||||
|
test(`statistics: discards cached ${label}`, async ({ page }) => {
|
||||||
|
const searches = await openStatistics(page, { mode: 'DAILY', startDate, endDate });
|
||||||
|
await expect.poll(() => searches.length).toBe(1);
|
||||||
|
expect(searches[0].payload).toMatchObject({ startDate: '2024-02-28', endDate: '2024-03-02' });
|
||||||
|
await expect(page.getByLabel('일별 조회 기간')).toHaveValue('2024.02.28 - 2024.03.02');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`statistics: blocks a forged ${label} before sending a request`, async ({ page }) => {
|
||||||
|
const searches = await openStatistics(page);
|
||||||
|
await page.evaluate(({ startDate, endDate }) => {
|
||||||
|
document.querySelector('#startDate').value = startDate;
|
||||||
|
document.querySelector('#endDate').value = endDate;
|
||||||
|
}, { startDate, endDate });
|
||||||
|
const messages = [];
|
||||||
|
page.once('dialog', async dialog => {
|
||||||
|
messages.push(dialog.message());
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
await page.getByRole('button', { name: '통계 조회', exact: true }).click();
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messages[0]).toContain('조회');
|
||||||
|
expect(searches).toHaveLength(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('statistics: ignores invalid cached dates and keeps server defaults', async ({ page }) => {
|
||||||
|
const searches = await openStatistics(page, { mode: 'DAILY', startDate: '2024-02-30', endDate: '2024-05-01' });
|
||||||
|
await expect.poll(() => searches.length).toBe(1);
|
||||||
|
await expect(page.getByLabel('일별 조회 기간')).toHaveValue('2024.02.28 - 2024.03.02');
|
||||||
|
expect(searches[0].payload).toMatchObject({ startDate: '2024-02-28', endDate: '2024-03-02' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('statistics: calendar fits a mobile viewport and can apply a selection', async ({ page }, testInfo) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
const searches = await openStatistics(page);
|
||||||
|
await selectLeapRange(page);
|
||||||
|
const bounds = await page.locator('.statistics-date-picker').boundingBox();
|
||||||
|
expect(bounds.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(bounds.x + bounds.width).toBeLessThanOrEqual(390);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('statistics-calendar-mobile.png'), fullPage: true });
|
||||||
|
await page.locator('.statistics-date-picker .applyBtn').click();
|
||||||
|
await expect.poll(() => searches.length).toBe(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user