2 Commits

Author SHA1 Message Date
Rinjae a310b671e2 API 통계 날짜 선택 로직 및 테스트 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 최대 40일 범위 제한 및 1년 조회 가능 기간 검증
- Date-range Picker UI 추가 및 서버 동기화 로직 구현
- Controller, DTO 유효성 테스트 및 Playwright 테스트 작성
2026-09-09 16:25:00 +09:00
Rinjae d9373b00f0 Moment.js 업그레이드 및 호환성 테스트 추가:
- 최신 버전(2.30.1)으로 업데이트 및 CVE-2022-24785 대응
- 브라우저 기반 date-range 동작 검증 및 Playwright 테스트 추가
- 윤년/서머타임 처리 및 CommonJS 보안 회귀 검증
2026-09-09 16:00:13 +09:00
24 changed files with 835 additions and 162 deletions
+46
View File
@@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "1.63.0",
"esbuild": "^0.25.0",
"node-forge": "^1.3.1",
"sass": "^1.69.5"
@@ -766,6 +767,22 @@
"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": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -899,6 +916,35 @@
"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": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
+2
View File
@@ -3,6 +3,7 @@
"version": "1.0.0",
"description": "SASS build system + forge custom bundle for EAPIM Portal",
"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: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",
@@ -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"
},
"devDependencies": {
"@playwright/test": "1.63.0",
"esbuild": "^0.25.0",
"node-forge": "^1.3.1",
"sass": "^1.69.5"
@@ -52,9 +52,10 @@ public class ApiStatisticsController {
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
// 기본 조회 (일별, 7일 전 ~ 오늘, 전체 앱)
LocalDate today = LocalDate.now();
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
searchDto.setStartDate(LocalDate.now().minusDays(7));
searchDto.setEndDate(LocalDate.now());
searchDto.setStartDate(today.minusDays(7));
searchDto.setEndDate(today);
searchDto.setClientId(null);
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
@@ -62,6 +63,9 @@ public class ApiStatisticsController {
model.addAttribute("details", result.getDetails());
model.addAttribute("periods", result.getPeriods());
model.addAttribute("searchDto", searchDto);
// 브라우저 시간대/시계와 관계없이 서버와 동일한 조회 경계를 적용한다.
model.addAttribute("statsMinDate", today.minusYears(1).toString());
model.addAttribute("statsMaxDate", today.toString());
// 월별 선택 가능 월 + 집계 안내 문구 데이터
model.addAttribute("availableMonths", apiStatisticsService.getAvailableMonths(orgId));
@@ -124,7 +128,8 @@ public class ApiStatisticsController {
private String rangeErrorMessage(ApiStatisticsSearchDto searchDto) {
return searchDto.isMonthly()
? "조회할 월이 올바르지 않습니다."
: "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다.";
: "조회 기간은 1년 전부터 오늘까지의 날짜 중 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS
+ "일(시작일·종료일 포함)로 선택해주세요.";
}
private PortalOrg getPortalOrg() {
@@ -15,7 +15,7 @@ import org.springframework.format.annotation.DateTimeFormat;
public class ApiStatisticsSearchDto {
/**
* 최대 조회 가능 일수 (일별 모드).
* 한 번에 조회 가능 일수 (일별 모드, 시작일·종료일 포함).
*/
public static final int MAX_DATE_RANGE_DAYS = 40;
@@ -59,17 +59,22 @@ public class ApiStatisticsSearchDto {
}
/**
* 날짜(일별) 범위가 유효한지 검증 (최대 40일).
* 1년 전부터 오늘까지의 날짜 중 최대 40일인지 검증 (양 끝 포함).
*/
public boolean isValidDateRange() {
return isValidDateRange(LocalDate.now());
}
boolean isValidDateRange(LocalDate today) {
if (startDate == null || endDate == null) {
return false;
}
if (startDate.isAfter(endDate)) {
return false;
}
long daysBetween = ChronoUnit.DAYS.between(startDate, endDate);
return daysBetween <= MAX_DATE_RANGE_DAYS;
LocalDate earliestDate = today.minusYears(1);
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.PortalUserService;
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.SecurityUtil;
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.repository.PortalUserRepository;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.ResponseEntity;
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.HttpSession;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.apache.xerces.impl.dv.util.Base64;
import org.springframework.security.access.annotation.Secured;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -64,7 +57,6 @@ public class UserRegisterController {
private final AgreementsFacade agreementsFacade;
private final PortalProperties portalProperties;
private final UserInvitationRepository userInvitationRepository;
private final EncryptionUtil encryptionUtil;
private final AgreementValidator agreementValidator;
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.UserRegistrationValidationService;
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.service.FileService;
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.PortalUserEnums;
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 org.apache.xerces.impl.dv.util.Base64;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
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.Optional;
@@ -58,8 +44,6 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
private final PasswordEncoder passwordEncoder;
private final AgreementValidator agreementValidator;
private final ApprovalService approvalService;
private final MessageHandlerService messageHandlerService;
private final EncryptionUtil encryptionUtil;
@Override
@Transactional
@@ -213,29 +197,10 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
approvalService.createUserApproval(newUser);
// 11.28 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
// sendActivationEmail(newUser);
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(
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.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.UserAgreementDTO;
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.validator.AgreementValidator;
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.UserInvitationEnums;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
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 org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -31,7 +26,6 @@ import org.springframework.validation.BindingResult;
import javax.servlet.http.HttpSession;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Optional;
@Service
@@ -49,9 +43,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
private final PasswordValidator passwordValidator;
private final PasswordEncoder passwordEncoder;
private final AgreementValidator agreementValidator;
private final MessageHandlerService messageHandlerService;
private final EncryptionUtil encryptionUtil;
private final AuthNumberGenerator authNumberGenerator;
@Override
@@ -159,8 +150,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
}
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
// sendEmailActivation(newUser);
return new ValidationResponse(true,"회원가입이 완료되었습니다.");
}
@@ -229,28 +218,6 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
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
@Transactional
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.event.UserPasswordResetEvent;
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.repository.MessageRequestRepository;
import com.eactive.apim.portal.template.service.MessageHandlerService;
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.List;
import java.util.stream.Collectors;
import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import lombok.RequiredArgsConstructor;
import org.apache.xerces.impl.dv.util.Base64;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
@@ -54,7 +45,6 @@ public class PortalUserAuthService implements UserDetailsService {
private final PasswordEncoder passwordEncoder;
private final MessageHandlerService messageHandlerService;
private final MessageRequestRepository messageRequestRepository;
private final EncryptionUtil encryptionUtil;
private final LoginFinalizer loginFinalizer;
private final PasswordService passwordService;
@@ -1,18 +1,5 @@
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 static String generateNewPassword() {
@@ -53,40 +40,4 @@ public class EncryptionUtil {
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);
}
}
+28
View File
@@ -25275,6 +25275,34 @@ input[type=checkbox]:checked + .custom-checkbox {
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 {
background: #0049B4;
border: none;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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.
File diff suppressed because one or more lines are too long
@@ -118,6 +118,36 @@
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 {
background: #0049B4;
border: none;
@@ -692,4 +722,4 @@
.stats-progress {
width: 100px;
}
}
}
@@ -41,10 +41,11 @@
<div class="search-filter-row">
<!-- 일별: 날짜 범위 -->
<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')}">
<span class="date-separator">-</span>
<input type="date" id="endDate" class="date-input"
<input type="hidden" id="endDate"
th:value="${#temporals.format(searchDto.endDate, 'yyyy-MM-dd')}">
</div>
@@ -57,7 +58,7 @@
</select>
</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">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
@@ -335,6 +336,8 @@
let currentMode = 'DAILY';
const CIRCUMFERENCE = 439.8; // 2 * PI * 70
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세그먼트) ──
function updateChart() {
@@ -460,16 +463,70 @@
return meta ? meta.getAttribute('content') : '';
}
// Validate date range (max 40 days)
// 서버 기준 1년 전 ~ 오늘 중 최대 40일만 조회한다 (양 끝 포함).
function validateDateRange(startDate, endDate) {
if (!startDate || !endDate) return { valid: false, message: '시작일과 종료일을 입력해주세요.' };
var start = new Date(startDate), end = new Date(endDate);
if (start > end) return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
var diffDays = Math.floor((end - start) / (1000 * 60 * 60 * 24));
if (diffDays > MAX_DATE_RANGE_DAYS) return { valid: false, message: '조회 기간은 최대 ' + MAX_DATE_RANGE_DAYS + '일까지 가능합니다.' };
var start = moment(startDate, 'YYYY-MM-DD', true), end = moment(endDate, 'YYYY-MM-DD', true);
if (!start.isValid() || !end.isValid()) return { valid: false, message: '올바른 날짜를 선택해주세요.' };
if (start.isAfter(end)) return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
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 };
}
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() {
return {
mode: currentMode,
@@ -505,8 +562,10 @@
if (p.mode === 'MONTHLY') { $('#dailyFilter').hide(); $('#monthlyFilter').show(); }
else { $('#monthlyFilter').hide(); $('#dailyFilter').show(); }
}
if (p.startDate) $('#startDate').val(p.startDate);
if (p.endDate) $('#endDate').val(p.endDate);
if (validateDateRange(p.startDate, p.endDate).valid) {
$('#startDate').val(p.startDate);
$('#endDate').val(p.endDate);
}
if (p.month) $('#month').val(p.month);
if (p.clientId !== undefined && p.clientId !== null) { selectedClientId = p.clientId; $('#appSelect').val(p.clientId); }
return true;
@@ -563,12 +622,12 @@
// Event Handlers
$('#btnSearch').on('click', searchStatistics);
$('#appSelect').on('change', function () { selectedClientId = $(this).val(); searchStatistics(); });
$('#startDate, #endDate').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();
}
});
@@ -577,4 +636,4 @@
</body>
</html>
</html>
@@ -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 + "\"}";
}
}
@@ -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());
}
}
+46
View File
@@ -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도 갱신한다.
+28
View File
@@ -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>
+161
View File
@@ -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'
});
});
+21
View File
@@ -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' } }
]
});
+215
View File
@@ -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);
});