Moment.js 업그레이드 및 호환성 테스트 추가:

- 최신 버전(2.30.1)으로 업데이트 및 CVE-2022-24785 대응
- 브라우저 기반 date-range 동작 검증 및 Playwright 테스트 추가
- 윤년/서머타임 처리 및 CommonJS 보안 회귀 검증
This commit is contained in:
Rinjae
2026-09-09 16:00:13 +09:00
parent 09bc3a65f5
commit d9373b00f0
13 changed files with 317 additions and 136 deletions
+46
View File
@@ -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",
+2
View File
@@ -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"
@@ -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);
}
} }
@@ -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
+35
View File
@@ -0,0 +1,35 @@
# 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`에 저장된다.
실제 서버 통합과 전체 페이지의 시각적 배치는 이 테스트 범위에 포함되지 않는다.
## 배포 파일 출처
- 버전: 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',
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' } }
]
});