fix(core,extensions): 보안 결함 3건과 이중저장소·레이아웃 중복키 결함군 폐쇄

KISA 제보 3건(KVE-2026-2010/2011/2018)과 그 동일 계열 형제 결함을 전수 조치하고,
그 과정에서 드러난 두 결함군을 함께 닫는다.

- 검증 시점과 연결 시점이 host 를 다르게 읽던 SSRF 통로를 정규화 SSoT 한 곳으로 모았다
- 세션을 여는 지점(2FA 완료·토큰 재발급)이 잠금 검사를 거치지 않아 계정 잠금이 우회됐다
- 인증도 서명도 없는 브라우저 리턴 콜백이 주문 상태를 바꾸던 통로를 4 PG 전부에서 닫고,
 소유권을 대조하는 close-report 를 토스에도 신설했다. 그 결과 정리 주체를 잃는
 결제창 미완료 주문은 만료 자동취소가 거둔다
- 저장소 A(_local)에만 쓰는 경로가 B 의 값을 조용히 덮던 회귀를 정본 writer 로 닫았다
 (engine-v1.63.5). 한 방향만 보던 정적 검사에 반대 방향 축과 양방향 계약 테스트를 더했다
- 레이아웃 JSON 의 같은 객체 중복 키가 앞선 선언을 오류 없이 삼키던 결함군을 닫았다
This commit is contained in:
HeuJung
2026-09-02 17:36:11 +09:00
parent d4439ea30a
commit 21fc114f37
137 changed files with 6470 additions and 829 deletions
+3 -1
View File
@@ -193,7 +193,7 @@
| `sirsoft-pay_kginicis` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-pay_kginicis/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-pay_kginicis/docs/README.md) | 훅 6 · 라우트 35 · 모델 0 · 레이아웃 1 |
| `sirsoft-pay_nhnkcp` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-pay_nhnkcp/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/README.md) | 훅 8 · 라우트 16 · 모델 0 · 레이아웃 1 |
| `sirsoft-pay_nicepayments` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-pay_nicepayments/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-pay_nicepayments/docs/README.md) | 훅 5 · 라우트 15 · 모델 0 · 레이아웃 1 |
| `sirsoft-tosspayments` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-tosspayments/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-tosspayments/docs/README.md) | 훅 4 · 라우트 4 · 모델 0 · 레이아웃 1 |
| `sirsoft-tosspayments` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-tosspayments/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-tosspayments/docs/README.md) | 훅 4 · 라우트 5 · 모델 0 · 레이아웃 1 |
| `sirsoft-verification_kginicis` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-verification_kginicis/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-verification_kginicis/docs/README.md) | 훅 3 · 라우트 2 · 모델 2 · 레이아웃 1 |
| `sirsoft-verification_nhnkcp` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-verification_nhnkcp/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/README.md) | 훅 0 · 라우트 2 · 모델 2 · 레이아웃 1 |
| `gnuboard7-hello_admin_template` | 템플릿 | [AGENTS.md](templates/_bundled/gnuboard7-hello_admin_template/AGENTS.md) | [docs/](templates/_bundled/gnuboard7-hello_admin_template/docs/README.md) | 훅 0 · 라우트 1 · 모델 0 · 레이아웃 8 |
@@ -324,6 +324,8 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
| 두 쓰기 경로(B 쓰기 / 반환값)에 서로 다른 병합 규칙 | 같은 규칙 — 갈라지면 나중에 소비자가 생길 때 어느 경로를 탔느냐로 결과가 달라진다 |
| `__g7ForcedLocalFields` 오버레이가 있으니 `context.state` 도 최신이라고 가정 | 그 오버레이는 `extendedDataContext` **useMemo 안에서 읽는 window 전역**이라 deps 가 아니다 — memo 가 재계산되지 않으면 실리지 않는다 |
| 자동바인딩이 `__g7PendingLocalState` 에 저장소 A 스냅샷을 그대로 대입 | 렌더러와 같은 순서로 `__g7ForcedLocalFields` 를 얹고 방금 입력한 경로를 다시 적용 — pending 은 `getLocal()` 이 읽는 "화면과 같은 전체 스냅샷" 이다 |
| 저장소 A 에만 쓰는 `_local` 경로 (`context.setState(payload)` 단독) | 같은 지배 분기 안에서 B 도 갱신 — `G7Core.state.setLocal(payload, { render: false })`. B 에 이미 키가 있으면 보충 대상에서 빠져 A 의 값이 조용히 유실된다 |
| 미러를 **형제 분기**에 두고 이 분기도 지켜진다고 간주 | 미러는 그 쓰기를 **지배하는 분기 안**에 둔다 — 긴 함수를 통째로 보면 한 분기의 미러가 다른 분기를 면죄한다 |
A 가 값을 못 받는 대표 경로는 `setLocal({ render: false, selfManaged: true })`(CKEditor 등 자체 DOM 관리 플러그인)다. `render:false` 는 `updateTemplateData` 앞에서 조기 return 하고 액션 밖이라 `__g7ActionContext` 도 없으므로 **React 렌더가 0회** — memo 가 재계산되지 않아 `context.state` 가 입력 이전 스냅샷으로 고정된다. 여기에 폭 변경 리렌더가 `__g7PendingLocalState` 를 null 로 지우면(의존성 배열 없는 `useLayoutEffect`) base 가 stale A 로 떨어진다.
+6
View File
@@ -35,11 +35,17 @@
- 확장 문서에서 제품을 가리키는 이름이 「그누보드7」로 통일되었습니다. 종전에는 같은 문서 안에서도 약칭과 정식 명칭이 섞여, 확장만 내려받은 사람에게 별개 제품처럼 보였습니다.
- 번들 템플릿의 컴포넌트·핸들러·레이아웃 상세 문서와 확장이 사용하는 활동 로그 항목 목록이 각 확장의 문서로 옮겨졌습니다. 확장이 기능을 늘릴 때 코어 문서를 함께 고쳐야 하던 의존이 사라졌으며, 코어 문서에는 총계와 각 확장 문서로의 링크만 남습니다.
### Security
- 주소에 마침표처럼 보이는 특수문자(전각·표의문자 마침표 등)를 섞으면 서버가 내부 주소로 요청을 보내도록 유도할 수 있던 문제를 수정했습니다. 검사할 때와 실제로 연결할 때 주소를 읽는 방식이 달라 생긴 문제로, 이제 두 시점이 같은 방식으로 주소를 해석합니다. 스케줄의 URL 호출, 주소로 언어팩 설치, 외부 배송비 계산 API 등 서버가 대신 외부로 요청을 보내는 모든 지점이 함께 보호됩니다. 정상적인 국제화 도메인(한글·일본어 도메인 등)은 그대로 사용할 수 있습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2010)
- 2단계 인증을 켠 상태에서 계정 잠금을 우회할 수 있던 문제를 수정했습니다. 잠기기 전에 받아 둔 인증 단계를 잠긴 뒤에 마치면 로그인이 되고 잠금까지 풀렸습니다. 이제 인증번호 확인 단계에서도 잠금 여부를 다시 확인하며, 잠긴 계정은 로그인 화면과 동일한 안내를 받습니다. 잠긴 계정은 기존 로그인 상태로도 인증 기간을 연장할 수 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2011)
### Fixed
- 실제 화면 동작에 쓰이는 라이브러리(axios·laravel-echo·pusher-js)가 개발용으로 분류돼 있어 보안 점검에서 빠지던 문제를 수정했습니다. 이제 점검 대상에 포함되며, 함께 확인된 axios 취약점도 1.20.0 으로 올려 해소했습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
- 글을 쓰다 브라우저 창 크기가 바뀌면 저장 시 본문이 사라지던 문제를 수정했습니다. 새 글은 「내용은 필수입니다」로 저장에 실패했고, 글 수정에서는 저장에 성공한 것처럼 보이면서 그때까지 고친 내용이 사라졌습니다. 창 크기를 조금만 바꿔도(20픽셀 이내) 발생했으므로, 휴대폰에서 주소창이 숨겨지거나 키보드가 올라오거나 화면을 돌리는 것도 같은 상황입니다. 게시판 글쓰기(사용자·관리자), 페이지 본문, 상품 상세설명, 상품 공통정보 화면이 대상입니다. (#130 @jiwonpapa 님께서 제보해주셨습니다.)
- 창 크기가 바뀐 뒤 본문을 고치고 제목 등 다른 입력칸을 건드리면, 저장 시 본문이 고치기 전 내용으로 되돌아가던 문제를 수정했습니다. 새 글은 「내용은 필수입니다」로 저장에 실패했고, 글 수정에서는 저장에 성공한 것처럼 보이면서 그때까지 고친 내용이 사라졌습니다. 편집기에는 고친 내용이 그대로 보였기 때문에 저장 후 다시 열어보기 전까지는 알 수 없었습니다. 게시판 글쓰기(사용자·관리자), 페이지 본문, 상품 상세설명, 상품 공통정보 화면이 대상입니다.
- 상품 상세에서 옵션을 고른 뒤 「바로 구매」·「장바구니 담기」가 동작하지 않던 문제를 수정했습니다. 화면에는 고른 옵션이 목록에 담긴 것으로 보이는데 실제 요청에는 아무 옵션도 실리지 않아, 「바로 구매」는 오류로 끝나고 「장바구니 담기」는 「옵션을 선택해주세요」 안내만 반복됐습니다. 옵션 조합을 두 번 담으면 먼저 담은 조합이 사라지는 것도 같은 원인입니다. 상품 추가옵션(각인·포장 등) 선택도 함께 정상화됐습니다.
- 서버측 라이브러리 guzzle·commonmark 의 알려진 취약점 12건을 해소했습니다. 결제·본인인증·알림 발송처럼 외부와 통신하는 경로가 이 라이브러리를 사용합니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
- 초기 화면 파일을 명령줄과 웹이 번갈아 만들 때, 나중에 생기는 하위 폴더가 한쪽 계정 전용으로 남아 다른 쪽이 쓰지 못하던 문제를 수정했습니다. 게시 폴더가 그룹 권한을 하위 폴더에 물려주도록 정리합니다(Linux·macOS).
- 확장 설치가 의존성·버전 검사에서 실패해도 복사된 파일이 남아, 목록에도 보이지 않는 디렉토리가 쌓이던 문제를 수정했습니다. 실패한 설치는 이번에 만든 파일을 되돌립니다(이미 설치돼 있던 확장을 다시 설치하다 실패한 경우에는 기존 파일을 건드리지 않습니다).
@@ -110,6 +110,19 @@ class AuthController extends AdminBaseController
}
return $this->success('common.success', $data);
} catch (AccountLockedException $e) {
// 재발급도 세션을 여는 지점이다 — 사용자 경로와 같은 423 계약을 따른다.
// 이 catch 가 없으면 잠긴 계정의 재발급 시도가 500 으로 새어 나간다.
return $this->error(
$e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
423,
[
'locked_until' => $e->lockedUntil?->toIso8601String(),
'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
'permanent' => $e->isPermanent(),
],
['minutes' => $e->remainingMinutes]
);
} catch (ValidationException $e) {
return $this->unauthorized('auth.unauthenticated');
}
@@ -60,17 +60,7 @@ class AuthController extends AuthBaseController
return $this->success('auth.login_success', $data);
} catch (AccountLockedException $e) {
// 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출.
return $this->error(
$e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
423,
[
'locked_until' => $e->lockedUntil?->toIso8601String(),
'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
'permanent' => $e->isPermanent(),
],
['minutes' => $e->remainingMinutes]
);
return $this->lockedResponse($e);
} catch (ValidationException $e) {
return $this->unauthorized('auth.login_failed');
}
@@ -98,11 +88,38 @@ class AuthController extends AuthBaseController
$data['user'] = new UserResource($data['user']);
return $this->success('auth.login_success', $data);
} catch (AccountLockedException $e) {
// 세션을 여는 지점이므로 `login` 과 같은 423 계약을 따른다 — 화면은 두 경로를
// 구분하지 않으므로 한쪽만 다른 모양이면 잠금 안내가 깨진다.
return $this->lockedResponse($e);
} catch (ValidationException $e) {
return $this->unauthorized('auth.two_factor_failed');
}
}
/**
* 계정 잠금 응답(423)을 구성합니다.
*
* 세션을 발급하는 모든 엔드포인트가 같은 페이로드를 돌려주도록 단일 지점에서 만든다.
*
* @param AccountLockedException $e 잠금 예외
* @return JsonResponse 423 응답
*/
private function lockedResponse(AccountLockedException $e): JsonResponse
{
// 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출.
return $this->error(
$e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
423,
[
'locked_until' => $e->lockedUntil?->toIso8601String(),
'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
'permanent' => $e->isPermanent(),
],
['minutes' => $e->remainingMinutes]
);
}
/**
* 새로운 사용자를 등록시킵니다.
*
@@ -178,7 +195,11 @@ class AuthController extends AuthBaseController
*/
public function refresh(AuthenticatedRequest $request): JsonResponse
{
$data = $this->authService->refreshToken($request->user());
try {
$data = $this->authService->refreshToken($request->user());
} catch (AccountLockedException $e) {
return $this->lockedResponse($e);
}
// 사용자 정보는 Resource로, 토큰은 그대로
if (isset($data['user'])) {
+45 -14
View File
@@ -100,20 +100,7 @@ class AuthService
// 사전 잠금 체크 — 잠긴 계정은 Auth::attempt 자체를 시도하지 않는다.
// (실패 카운트가 0 으로 리셋된 잠금 상태에서 Failed 이벤트가 다시
// 카운트를 올려 재잠금 시각을 갱신하는 부작용 방지)
if ((bool) g7_core_settings('security.login_attempt_enabled', true)) {
$candidate = $this->userRepository->findByEmail($email);
if ($candidate !== null && $this->userRepository->isLocked($candidate)) {
// 영구 잠금은 해제 시각이 없다 — diffInSeconds(null) 로 폭발하지 않도록 분기.
$remaining = $candidate->locked_until === null
? null
: max(1, (int) ceil(now()->diffInSeconds($candidate->locked_until, false) / 60));
throw new AccountLockedException(
lockedUntil: $candidate->locked_until,
remainingMinutes: $remaining,
);
}
}
$this->assertNotLocked($this->userRepository->findByEmail($email));
if (! Auth::attempt(['email' => $email, 'password' => $password])) {
// 실패 카운트 증가/잠금 처리는 HandleFailedLoginListener 에서 담당
@@ -158,6 +145,40 @@ class AuthService
return $this->issueLoginSession($user, $email);
}
/**
* 계정이 잠겨 있으면 예외를 던집니다.
*
* 세션(토큰)을 발급하는 지점은 전부 이 검사를 거쳐야 합니다. 2단계 인증이 켜져 있으면
* 비밀번호 확인(`login`)은 challenge 만 돌려주고 실제 세션은 `completeTwoFactor()` 가
* 발급하므로, 한쪽에만 검사가 있으면 잠기기 전에 받아 둔 challenge 를 잠긴 뒤 완료하는
* 것만으로 잠금이 통째로 우회됩니다. 그 뒤 로그인 완료 훅이 실패 횟수·잠금 시각까지
* 초기화해 흔적도 남지 않습니다.
*
* @param User|null $user 검사 대상 사용자 (없으면 검사 대상 아님)
*
* @throws AccountLockedException 계정이 잠겨 있을 때
*/
private function assertNotLocked(?User $user): void
{
if (! (bool) g7_core_settings('security.login_attempt_enabled', true)) {
return;
}
if ($user === null || ! $this->userRepository->isLocked($user)) {
return;
}
// 영구 잠금은 해제 시각이 없다 — diffInSeconds(null) 로 폭발하지 않도록 분기.
$remaining = $user->locked_until === null
? null
: max(1, (int) ceil(now()->diffInSeconds($user->locked_until, false) / 60));
throw new AccountLockedException(
lockedUntil: $user->locked_until,
remainingMinutes: $remaining,
);
}
/**
* 이 사용자에게 2단계 인증을 요구해야 하는지 판정합니다.
*
@@ -268,6 +289,10 @@ class AuthService
]);
}
// 세션을 여는 것은 이 지점이다 — challenge 발급 이후에 잠겼을 수 있으므로 재검사한다.
// Auth::login() 앞에 두어야 로그인 완료 훅이 잠금 필드를 초기화하지 못한다.
$this->assertNotLocked($user);
Auth::login($user);
return $this->issueLoginSession($user, (string) $user->email);
@@ -446,9 +471,15 @@ class AuthService
*
* @param User $user 토큰을 갱신할 사용자
* @return array 새로운 토큰 정보
*
* @throws AccountLockedException 계정이 잠겨 있을 때
*/
public function refreshToken(User $user): array
{
// 재발급도 세션을 여는 지점이다. 유효한 기존 세션이 전제라 신규 로그인 우회는
// 아니지만, 관리자가 계정을 잠근 뒤에도 그 세션이 무기한 연장되면 잠금이 실효를 잃는다.
$this->assertNotLocked($user);
// 현재 토큰 삭제 (다른 디바이스는 유지)
$currentToken = $user->currentAccessToken();
+84 -1
View File
@@ -111,6 +111,75 @@ class OutboundUrlValidator
return self::extractSafeHost($url, $options + ['allowPort' => true]) !== null;
}
/**
* host 문자열을 실제 연결 계층과 같은 규칙으로 정규화한다.
*
* 이 메서드가 정규화의 SSoT 다. 검증기 밖에서 host 를 대조하는 소비자(결제 콜백
* URL 의 도메인 접미사 확인 등)도 이 메서드를 거쳐야 판정이 갈리지 않는다.
*
* 정규화 내용:
* - 점(.) 동등 유니코드 문자(U+3002·U+FF0E·U+FF61)를 ASCII 점으로 치환.
* 검증기가 ASCII 점만 구분자로 보면 `localhost。` 는 단일 라벨(공개 도메인)로
* 읽히지만 libcurl/libidn2 는 UTS#46 정규화로 `localhost` 에 연결한다.
* - IDNA/UTS#46 A-label(punycode) 변환 — 유니코드 표기와 ASCII 표기를 한 형태로 모은다.
* - 소문자화 및 완전한 DNS 이름의 후행 점 제거.
*
* @param string $host 정규화할 host 문자열 (IPv6 는 대괄호 없이)
* @return string|null 정규화된 host, 정규화할 수 없으면 null
*/
public static function normalizeHost(string $host): ?string
{
$host = strtolower(trim($host));
if ($host === '') {
return null;
}
// 점 동등 문자 사전 치환 — idn_to_ascii 는 구현에 따라 이들을 남길 수 있으므로
// 라벨 분리 자체를 여기서 확정한다.
$host = strtr($host, [
"\u{3002}" => '.', // IDEOGRAPHIC FULL STOP
"\u{FF0E}" => '.', // FULLWIDTH FULL STOP
"\u{FF61}" => '.', // HALFWIDTH IDEOGRAPHIC FULL STOP
]);
if (function_exists('idn_to_ascii')) {
$ascii = idn_to_ascii($host, IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46);
if (is_string($ascii) && $ascii !== '') {
$host = $ascii;
} elseif (preg_match('/[^\x20-\x7E]/', $host) === 1) {
// 변환에 실패했는데 비-ASCII 가 남아 있으면 연결 계층이 어떤 host 로
// 해석할지 알 수 없다 — 판정 불가는 거부로 처리한다.
return null;
}
} elseif (preg_match('/[^\x20-\x7E]/', $host) === 1) {
// polyfill 부재 환경 fail-safe: 비-ASCII host 는 판정 불가로 보고 거부.
return null;
}
$host = strtolower($host);
// 완전한 DNS 이름 표기(`example.com.`)의 후행 점 제거 — 남겨 두면 최상위 라벨이
// 빈 문자열이 되어 정상 도메인이 차단되고, `localhost.` 가 내부 이름 대조를 빠져나간다.
while (str_ends_with($host, '.')) {
$host = substr($host, 0, -1);
}
// 정규화 결과가 host 로 성립하는지 확인한다. UTS#46 은 전각 문자를 ASCII 로
// 매핑하므로 `127.0.0.1/.example.com`(U+FF0F) 은 `127.0.0.1/.example.com` 이 된다.
// parse_url 은 전각 문자를 구분자로 보지 않아 이 전체를 host 로 넘기지만, 연결
// 계층은 정규화 후 첫 구분자 앞(`127.0.0.1`)까지만 host 로 읽는다. 즉 접미사·
// 화이트리스트 대조는 뒤쪽 도메인으로 통과하는데 실제 접속은 앞쪽 주소로 간다.
// punycode(A-label)와 IP 리터럴은 항상 letter/digit/hyphen/dot 뿐이므로, 그 밖의
// 문자가 남았다면 어느 host 로 해석될지 알 수 없다 — 판정 불가는 거부로 처리한다.
if (preg_match('/^[a-z0-9._-]+$/', $host) !== 1) {
return null;
}
return $host === '' ? null : $host;
}
/**
* host 문자열(URL 이 아닌 host 단독)이 공개 인터넷 주소인지 판정한다.
*
@@ -126,6 +195,13 @@ class OutboundUrlValidator
$host = substr($host, 1, -1);
}
// 직접 호출자(URL 을 거치지 않는 경로)도 같은 정규화를 받아야 한다.
// 단 IP 리터럴은 IDNA 라벨 구조가 없어 정규화 대상이 아니다 — 특히 IPv6 의 ':' 는
// 정규화의 호스트명 문자 집합 검사에 걸려 공개 IPv6 까지 차단된다.
if (filter_var($host, FILTER_VALIDATE_IP) === false) {
$host = self::normalizeHost($host) ?? '';
}
if ($host === '' || in_array($host, self::INTERNAL_HOST_NAMES, true)) {
return false;
}
@@ -208,6 +284,13 @@ class OutboundUrlValidator
$host = strtolower(trim($parts['host']));
return $host === '' ? null : $host;
// IPv6 리터럴은 대괄호째 유지한다 — 라벨 구조가 없어 IDNA 정규화 대상이 아니다.
if (str_starts_with($host, '[') && str_ends_with($host, ']')) {
return $host === '[]' ? null : $host;
}
// 실제 연결 계층(libcurl/libidn2)과 같은 규칙으로 정규화한 뒤 상위 판정에 넘긴다.
// 정규화 없이 넘기면 `localhost。` 처럼 검증 시점과 연결 시점의 host 가 달라진다.
return self::normalizeHost($host);
}
}
+3 -3
View File
@@ -248,7 +248,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
> 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다.
<!-- @generated:start:api-readme-extensions -->
- **확장 수**: 14 · **엔드포인트 수**: 416
- **확장 수**: 14 · **엔드포인트 수**: 428
| 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 |
| --- | --- | --- | --- |
@@ -256,10 +256,10 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
| `sirsoft-board` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 |
| `sirsoft-ecommerce` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 239 |
| `sirsoft-page` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 |
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 |
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 3 / 5 |
| `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
| `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 12 |
| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 21 |
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 |
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
+7
View File
@@ -153,6 +153,7 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 423 | Locked | 계정이 잠긴 경우. 응답 형태는 로그인 엔드포인트의 423 과 동일하다 (`auth.account_locked` / `auth.account_locked_permanently` — `errors.locked_until`, `errors.retry_after_seconds`, `errors.permanent`) |
<!-- @generated:end -->
@@ -160,6 +161,8 @@ HTTP/1.1 200
현재 관리자 토큰을 새 Sanctum 토큰으로 교체한다. `AuthService::refreshToken()` 이 기존 토큰을 폐기하고 새 토큰을 발급하며, `data` 에는 새 `token` 과 `user`(UserResource) 가 담긴다. 만료 임박 토큰을 재발급하는 용도로, 세션 만료로 재인증이 필요한 경우(토큰 무효)에는 `401 auth.unauthenticated` 를 반환한다.
**재발급도 잠금 검사를 거친다.** 유효한 기존 세션이 전제이므로 신규 로그인 우회는 아니지만, 관리자가 계정을 잠근 뒤에도 그 세션이 무기한 연장되면 잠금이 실효를 잃는다. 잠긴 계정의 재발급 요청은 `423` 으로 차단되며 기존 토큰도 폐기되지 않는다.
### GET /api/admin/auth/user
<!-- @generated:start:api.admin.auth.user -->
@@ -586,6 +589,7 @@ HTTP/1.1 200
| --- | --- | --- |
| 401 | Unauthorized | 코드가 틀렸거나(`auth.two_factor_failed`), challenge 의 `purpose` 가 `login` 이 아니거나, 확인된 사용자가 없거나 `active` 상태가 아닌 경우. **세 사유를 같은 응답으로 뭉뚱그린다** — 구분해 내보내면 challenge 유효성 탐색에 쓰인다 |
| 422 | Unprocessable Entity | `challenge_id`/`code` 형식 위반 |
| 423 | Locked | 로그인 실패 누적으로 계정이 잠긴 경우. 응답 형태는 `POST /api/auth/login` 의 423 과 동일하다 (`auth.account_locked` / 무기한이면 `auth.account_locked_permanently` — `errors.locked_until`, `errors.retry_after_seconds`, `errors.permanent`) |
| 429 | Too Many Requests | `throttle:auth-login` 초과 (로그인과 같은 제한을 공유하므로 코드 대입 시도도 함께 억제된다) |
<!-- @generated:end -->
@@ -596,6 +600,8 @@ HTTP/1.1 200
challenge 의 `purpose` 가 `login` 인지 먼저 대조한다 — 대조하지 않으면 회원가입·비밀번호 재설정 등 다른 흐름에서 발급된 challenge 로 로그인할 수 있다. 코드 확인에 성공하기 전에는 어떤 경우에도 토큰이 발급되지 않는다.
**계정 잠금은 이 단계에서 다시 검사한다.** 세션을 여는 것은 비밀번호 단계가 아니라 이 엔드포인트이므로, challenge 를 받은 뒤 잠긴 계정은 여기서 `423` 으로 차단된다. 잠기기 전에 발급받은 challenge 를 잠긴 뒤에 완료하는 것만으로 잠금을 우회할 수 없다. 차단은 로그인 완료 훅(`core.auth.after_login`)보다 앞서므로 실패 횟수·잠금 해제 시각도 초기화되지 않는다.
### POST /api/auth/logout
<!-- @generated:start:api.auth.logout -->
@@ -1191,6 +1197,7 @@ HTTP/1.1 200
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`core.auth.refresh`)이 없는 경우 |
| 423 | Locked | 계정이 잠긴 경우. 응답 형태는 로그인 엔드포인트의 423 과 동일하다 (`auth.account_locked` / `auth.account_locked_permanently` — `errors.locked_until`, `errors.retry_after_seconds`, `errors.permanent`). 잠긴 계정은 기존 세션으로도 토큰을 연장할 수 없으며, 차단 시 기존 토큰도 폐기되지 않는다 |
<!-- @generated:end -->
+19
View File
@@ -87,6 +87,25 @@
| `meta.seo` | object | ❌ | SEO 페이지 생성기 설정 (아래 참조) |
| `components` | array | ✅ | 컴포넌트 배열 |
### 한 객체에 같은 키를 두 번 쓰지 않는다
JSON 은 중복 키를 문법 오류로 보지 않는다. 브라우저(`JSON.parse`)도 서버 등록(`json_decode`)도
**뒤에 온 값이 앞의 값을 덮는다.** 그래서 앞에 쓴 선언은 예외도 경고도 없이 사라지는데,
파일에는 그대로 남아 있으므로 코드를 읽는 사람에게는 반영된 것처럼 보인다.
가장 자주 걸리는 자리는 `props` 다 — 노드가 `children` **뒤**에 `"props": {}` 를 이미 갖고 있는데
작성자가 노드 앞머리에 `"props": { ... }` 를 새로 적는 경우다. 노드가 길면 앞머리와 꼬리가
한 화면에 들어오지 않아 눈으로는 발견되지 않고, 그 속성만 화면에 영영 나타나지 않는다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 한 노드에 `props`(또는 `comment`·`actions` 등)를 두 번 선언 | 하나로 합친다 — 값을 추가할 때는 그 노드에 **이미 있는** 키를 찾아 거기에 넣는다 |
| 노드 앞머리에 키를 추가하기 전에 꼬리를 확인하지 않음 | 노드 전체에서 그 키의 존재를 먼저 확인한다 |
의도적으로 재선언해야 하는 예외는 그 객체의 `comment` 에
`audit:allow layout-json-duplicate-object-key <사유>` 를 남긴다 (JSON 은 주석을 담을 수 없으므로
표식 자리가 `comment` 값이다). 정적 검사가 차단한다.
### layout_name 네이밍 규칙
```text
+7
View File
@@ -171,6 +171,9 @@ G7이 자동으로 주입하는 `_global` 속성입니다. 레이아웃에서
❌ `__g7AutoBindingPaths` 레지스트리를 건드리지 않고 자동바인딩 변형 구현
❌ setLocal의 `render:false` 자동 승격 분기를 임의로 제거하거나 조건 완화
❌ 자동바인딩의 pending 스냅샷을 저장소 A 값만으로 구성 (B 전용 값이 조용히 사라진다)
❌ 저장소 A 에만 쓰는 `_local` 쓰기 경로 추가 (`context.setState(payload)` 단독 호출)
❌ 키가 **존재하는** 것만 확인하고 그 값이 **최신인지** 보지 않기 (존재 ≠ 신선도)
❌ 하네스에서 `globalState._local` 을 손으로 채워 발산 상황을 위조 (실 writer 를 거치지 않으면 결함이 시험에 등장하지 않는다)
❌ 구독 기반 선택적 리렌더 재시도 (과거에 도입 후 롤백된 실패 경로 — 반드시 검토 후 논의)
```
@@ -182,6 +185,10 @@ G7이 자동으로 주입하는 `_global` 속성입니다. 레이아웃에서
✅ pending 에 쓰는 값이 렌더러가 만드는 `_local` 과 같은 합성 순서인지 확인
✅ DynamicRenderer의 레지스트리 useEffect 조건 변경 시 iteration/Strict Mode 이중 마운트 영향 검토
✅ SPA 네비게이션 시 레지스트리 재초기화 (new Map()) 유지
✅ 미러는 그 쓰기를 **지배하는 분기 안**에 둔다 (형제 분기의 미러는 이 분기를 면죄하지 않는다)
✅ 계약 테스트는 경로마다 A→B / B→A **양방향 쌍**으로 둔다 (한 방향만 두면 반대 방향 회귀가 초록으로 통과한다)
✅ 하네스는 실제 writer(`G7Core.state.setLocal` · 자동바인딩 · `ActionDispatcher`)를 거친다
✅ `describe.skip` 은 커버리지가 아니다 — 꺼진 시험은 한 번도 돌지 않는다
✅ 수정 후 이중 저장소 동기화 관련 회귀 테스트 전수 통과 확인
```
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.1.4] - 2026-09-02
### Added
- 주문 설정의 「결제 미완료 주문 만료 기준(분)」 입력과 그 검증 문구의 일본어 번역을 추가했습니다.
## [1.1.3] - 2026-08-24
### Fixed
@@ -1281,6 +1281,7 @@ return [
'order_settings.bank_accounts.*.is_default' => 'デフォルト口座',
'order_settings.auto_cancel_expired' => '未決済自動キャンセル',
'order_settings.auto_cancel_days' => '自動キャンセル期限(日)',
'order_settings.pending_order_expire_minutes' => '決済未完了注文の期限(分)',
'order_settings.cart_expiry_days' => 'カート保管期間(日)',
'order_settings.default_pg_provider' => 'デフォルトPG会社',
'order_settings.payment_methods.*.pg_provider' => 'PG会社',
@@ -1609,6 +1610,12 @@ return [
'min' => '自動キャンセルの期限は1日以上である必要があります。',
'max' => '自動キャンセルの期限は最大30日まで設定可能です。',
],
'pending_order_expire_minutes' => [
'required' => '決済未完了注文の期限を入力してください。',
'integer' => '決済未完了注文の期限は整数である必要があります。',
'min' => '決済未完了注文の期限は0分以上である必要があります。(0 は整理しない)',
'max' => '決済未完了注文の期限は最大20160分(14日)まで設定可能です。',
],
'cart_expiry_days' => [
'integer' => 'カートの保管期間は整数である必要があります。',
'min' => 'カートの保管期間は1日以上である必要があります。',
@@ -215,6 +215,9 @@
"toggle_hint": "無効化すると入金待機注文は自動キャンセルされません。",
"days_prefix": "入金待機ステータスの注文は注文日を含めて",
"days_suffix": "日後に自動キャンセルされます。",
"pending_minutes_prefix": "決済画面まで進んだものの決済が完了しなかった注文は",
"pending_minutes_suffix": "分後に自動キャンセルされます。",
"pending_minutes_hint": "0 にするとこの区分は自動キャンセルしません。最大 20160 分(14日)。",
"vbank_due_days_label": "仮想口座入金期限:",
"vbank_due_days_suffix": "日",
"dbank_due_days_label": "無通帳振込入金期限:",
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
},
"version": "1.1.3",
"version": "1.1.4",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-ecommerce",
@@ -6,11 +6,16 @@
## [1.2.1] - 2026-08-28
### Changed
- 입금 기한 만료 주문 자동취소가 카드 등 결제창 결제까지 대상에 포함합니다. 종전에는 무통장입금·가상계좌만 정리되어, 결제창까지 갔으나 결제가 끝나지 않은 주문은 정리하는 주체가 없어 계속 남았습니다. 이제 주문 후 24시간이 지나면 함께 취소되며 미리 차감된 마일리지도 돌아옵니다. 결제가 이미 완료된 주문과 입금 대기 중인 가상계좌 주문은 대상이 아니며, 주문설정의 「만료 주문 자동취소」를 끄면 종전처럼 동작합니다.
### Added
- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
- 환경설정 > 주문 설정 > 주문 자동취소 에 「결제 미완료 주문 만료 기준(분)」 입력이 추가되었습니다. 결제창까지 갔으나 결제가 끝나지 않은 주문을 몇 분 뒤에 정리할지 정하며, 0 으로 두면 그 부류는 정리하지 않습니다(기본 1440분 = 24시간). 종전에는 값만 있고 화면에서 조작할 수 없었습니다.
### Fixed
@@ -85,6 +85,10 @@ return [
// 주문
'auto_cancel_days_min' => 1,
'auto_cancel_days_max' => 30,
// 결제창까지 갔으나 결제가 성립하지 않은 주문의 만료 기준(분).
// 0 은 "그 부류는 정리하지 않음" 을 뜻한다 — 운영자가 끌 수 있는 여지를 남긴다.
'pending_order_expire_minutes_min' => 0,
'pending_order_expire_minutes_max' => 20160,
'cart_expiry_days_min' => 1,
'cart_expiry_days_max' => 365,
@@ -241,6 +241,7 @@
],
"auto_cancel_expired": true,
"auto_cancel_days": 3,
"pending_order_expire_minutes": 1440,
"cart_expiry_days": 30,
"stock_restore_on_cancel": true,
"cancellable_statuses": ["payment_complete"],
@@ -480,6 +480,7 @@ HTTP/1.1 200
| order_settings.bank_accounts | body | array | 아니오 | — | 무통장 입금 계좌 목록. 항목별 `bank_code`·`account_number`·`account_holder`(모두 필수)·`is_active`·`is_default`. 계좌가 있으면 최소 1건은 사용+기본 상태여야 함 |
| order_settings.auto_cancel_expired | body | boolean | 아니오 | — | 입금대기 상태 주문의 자동취소 사용 여부 |
| order_settings.auto_cancel_days | body | integer | 아니오 | min 0, max 30 | 자동취소 기한(일). 주문일 포함 이 일수 경과 시 입금대기 주문을 자동 취소 |
| order_settings.pending_order_expire_minutes | body | integer | 아니오 | min 0, max 20160 | 결제 미완료 주문 만료 기준(분). 결제창까지 갔으나 결제가 성립하지 않은 주문을 이 시간 경과 후 자동 취소. `0` 이면 그 부류를 정리하지 않음 |
| order_settings.cart_expiry_days | body | integer | 아니오 | min 1, max 365 | 장바구니 보관기간(일). 경과 시 담긴 상품 자동 삭제 |
| order_settings.stock_restore_on_cancel | body | boolean | 아니오 | — | 주문 취소 시 차감된 재고 자동 복구 여부 (반품/교환에도 적용) |
| order_settings.confirmable_statuses | body | array | 아니오 | — | 사용자가 구매확정할 수 있는 주문 옵션 상태 목록 (`payment_complete`, `shipping_hold`, `preparing`, `shipping_ready`, `shipping`, `delivered` 중 선택) |
@@ -599,6 +600,7 @@ Content-Type: application/json
],
"order_settings.auto_cancel_expired": true,
"order_settings.auto_cancel_days": 1,
"order_settings.pending_order_expire_minutes": 1440,
"order_settings.cart_expiry_days": 1,
"order_settings.stock_restore_on_cancel": true,
"order_settings.confirmable_statuses": [
@@ -35,6 +35,21 @@ _`getSettingsSchema()` 선언이 없습니다._
등록한 카탈로그의 병합**이라, 플러그인을 삭제·비활성화하면 저장값은 남아 있는데 카탈로그에서
사라지는 고아 항목이 생깁니다. 공개 응답은 고아 항목을 걸러 내보내고 관리자 응답은 그대로
노출하는 것이 규칙입니다 — 운영자는 그것을 보고 지워야 하기 때문입니다.
`order_settings.pending_order_expire_minutes` 는 결제창까지 갔으나 결제가 성립하지 않은
주문(`PENDING_ORDER`)을 만료 자동취소 대상에 넣는 기준입니다(기본 1440분 = 24시간). 0 이면 그
부류를 정리하지 않습니다.
이 값은 **환경설정 > 주문 설정 > 주문 자동취소** 카드에 있습니다. 형제 값
`auto_cancel_days` 와 달리 `auto_cancel_expired` 토글 **하위가 아닙니다** — 정리 스케줄러가 그
토글과 무관하게 이 값을 읽기 때문에, 토글을 끈 상태에서 이 입력만 사라지면 화면이 실제 동작을
설명하지 못합니다. 입력 경계(0 ~ 20160분)는 `config/ecommerce.php` 의 `limits` 가 단일 출처이며,
화면은 설정 응답의 `_meta.limits` 로 같은 값을 받습니다.
이 부류를 정리 대상에 넣은 이유는 **정리 주체가 아예 없었기** 때문입니다. 기존 자동취소는 입금
기한(`vbank_due_at`·`deposit_due_at`)이 있는 결제수단만 훑고, 임시주문 정리는 다른 테이블을 봅니다.
브라우저 리턴 콜백이 주문 상태를 바꾸지 않게 된 뒤로는 승인 거절분도 여기에 머무르므로, 이 정리가
선차감 마일리지 복원의 최종 안전망입니다.
<!-- @intent END -->
## 권한
@@ -25,6 +25,7 @@
"name": "Button",
"props": {
"className": "flex items-center gap-1.5 px-2.5 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg cursor-pointer transition-colors",
"data-testid": "currency-switcher",
"aria-haspopup": "listbox",
"aria-expanded": "{{_local.showCurrencyDropdown ?? false}}",
"aria-label": "$t:sirsoft-ecommerce.common.currency_label"
@@ -151,6 +152,7 @@
"name": "Button",
"props": {
"className": "w-full px-4 py-2.5 text-left text-sm flex items-center gap-3 cursor-pointer transition-colors {{_global.preferredCurrency === currency.code ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}}",
"data-testid": "currency-option-{{currency.code}}",
"role": "option",
"aria-selected": "{{_global.preferredCurrency === currency.code}}"
},
@@ -213,7 +213,10 @@
"toggle_description": "Automatically cancel pending payment orders after the specified period.",
"toggle_hint": "When disabled, pending payment orders will not be auto-cancelled.",
"days_prefix": "Pending payment orders will be auto-cancelled",
"days_suffix": "days after the order date."
"days_suffix": "days after the order date.",
"pending_minutes_prefix": "Orders that reached the payment window but did not complete are cancelled after",
"pending_minutes_suffix": "minutes.",
"pending_minutes_hint": "Set 0 to leave this group alone. Maximum 20160 minutes (14 days)."
},
"cart_expiry": {
"title": "Cart Expiry",
@@ -213,7 +213,10 @@
"toggle_description": "입금대기 상태의 주문을 지정 기간 후 자동으로 취소합니다.",
"toggle_hint": "비활성화하면 입금대기 주문이 자동 취소되지 않습니다.",
"days_prefix": "입금대기 상태의 주문건은 주문일 포함",
"days_suffix": "일 이후 자동 취소됩니다."
"days_suffix": "일 이후 자동 취소됩니다.",
"pending_minutes_prefix": "결제창까지 갔으나 결제가 끝나지 않은 주문은",
"pending_minutes_suffix": "분 이후 자동 취소됩니다.",
"pending_minutes_hint": "0 으로 두면 이 부류는 자동 취소하지 않습니다. 최대 20160분(14일)."
},
"cart_expiry": {
"title": "장바구니 유효기간",
@@ -976,7 +976,8 @@
"props": {
"type": "button",
"disabled": "{{_local.isSaving || (route.itemCode && product?.data?.abilities?.can_update !== true)}}",
"className": "flex-center btn btn-primary gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed"
"className": "flex-center btn btn-primary gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed",
"data-testid": "product-save"
},
"children": [
{
@@ -46,7 +46,8 @@
"props": {
"type": "button",
"variant": "secondary",
"className": "btn btn-outline"
"className": "btn btn-outline",
"data-testid": "additional-clear-cancel"
},
"text": "$t:sirsoft-ecommerce.common.cancel",
"actions": [
@@ -62,7 +63,8 @@
"props": {
"type": "button",
"variant": "danger",
"className": "btn btn-danger"
"className": "btn btn-danger",
"data-testid": "additional-clear-confirm"
},
"text": "$t:sirsoft-ecommerce.common.confirm",
"actions": [
@@ -1661,7 +1661,8 @@
"props": {
"type": "button",
"className": "btn-group-item {{(_local.form.additional_options ?? []).length > 0 ? 'active' : ''}} disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{route.itemCode && product?.data?.abilities?.can_update !== true}}"
"disabled": "{{route.itemCode && product?.data?.abilities?.can_update !== true}}",
"data-testid": "additional-option-use"
},
"text": "$t:sirsoft-ecommerce.common.use",
"actions": [
@@ -1678,7 +1679,8 @@
"props": {
"type": "button",
"className": "btn-group-item {{(_local.form.additional_options ?? []).length === 0 ? 'active' : ''}} disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{route.itemCode && product?.data?.abilities?.can_update !== true}}"
"disabled": "{{route.itemCode && product?.data?.abilities?.can_update !== true}}",
"data-testid": "additional-option-not-use"
},
"text": "$t:sirsoft-ecommerce.common.not_use",
"actions": [
@@ -1891,7 +1893,8 @@
"index_var": "addValIdx"
},
"props": {
"className": "flex flex-col gap-2 sm:flex-row sm:items-start p-3 bg-white dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded"
"className": "flex flex-col gap-2 sm:flex-row sm:items-start p-3 bg-white dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded",
"data-testid": "additional-value-{{addIdx}}-{{addValIdx}}"
},
"children": [
{
@@ -662,6 +662,64 @@
"text": "{{_local.errors?.['order_settings.auto_cancel_days']?.[0] ?? ''}}"
}
]
},
{
"comment": "결제 미완료 주문 만료 기준(분) — 결제창까지 갔으나 결제가 성립하지 않은 주문을 정리하는 스케줄러가 읽는다. 자동취소 토글과 독립이므로 토글 하위에 두지 않는다.",
"id": "pending_order_expire_minutes_row",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2 flex-wrap"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-body"
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.auto_cancel.pending_minutes_prefix"
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "number",
"name": "order_settings.pending_order_expire_minutes",
"value": "{{_local.form?.order_settings?.pending_order_expire_minutes ?? 1440}}",
"min": "{{_local?.form?._meta?.limits?.pending_order_expire_minutes_min ?? 0}}",
"max": "{{_local?.form?._meta?.limits?.pending_order_expire_minutes_max ?? 20160}}",
"step": "1",
"className": "{{_local.errors?.['order_settings.pending_order_expire_minutes'] ? 'input input-error w-24' : 'input w-24'}}",
"disabled": "{{_computed.isReadOnly}}"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-body"
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.auto_cancel.pending_minutes_suffix"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint w-full"
},
"text": "$t:sirsoft-ecommerce.admin.settings.order_settings.auto_cancel.pending_minutes_hint"
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.errors?.['order_settings.pending_order_expire_minutes']}}",
"props": {
"className": "form-error-xs w-full mt-1"
},
"text": "{{_local.errors?.['order_settings.pending_order_expire_minutes']?.[0] ?? ''}}"
}
]
}
],
"props": {
@@ -65,8 +65,19 @@ class CancelPendingPaymentOrdersCommand extends Command
);
try {
// 결제창까지 갔으나 결제가 성립하지 않은 주문(PG 카드 등)의 만료 기준(분).
// 0 이하로 두면 그 부류는 정리하지 않는다 — 운영자가 끌 수 있는 여지를 남긴다.
$pendingOrderExpireMinutes = (int) module_setting(
'sirsoft-ecommerce',
'order_settings.pending_order_expire_minutes',
1440
);
// 만료된 주문 조회
$expiredOrders = $this->orderRepository->getExpiredPendingPaymentOrders($limit);
$expiredOrders = $this->orderRepository->getExpiredPendingPaymentOrders(
$limit,
$pendingOrderExpireMinutes > 0 ? $pendingOrderExpireMinutes : null,
);
if ($expiredOrders->isEmpty()) {
$this->info('처리할 만료 주문이 없습니다.');
@@ -82,9 +93,13 @@ class CancelPendingPaymentOrdersCommand extends Command
foreach ($expiredOrders as $order) {
$paymentMethodEnum = $order->payment?->payment_method;
$paymentMethodValue = $paymentMethodEnum?->value ?? 'unknown';
$dueAt = $paymentMethodEnum === PaymentMethodEnum::DBANK
? $order->payment?->deposit_due_at
: $order->payment?->vbank_due_at;
$dueAt = match (true) {
$paymentMethodEnum === PaymentMethodEnum::DBANK => $order->payment?->deposit_due_at,
$paymentMethodEnum === PaymentMethodEnum::VBANK => $order->payment?->vbank_due_at,
// 결제창까지 갔으나 성립하지 않은 주문은 입금 기한이 없다 — 주문 시각을 보여
// 운영자가 어느 기준으로 정리되었는지 알 수 있게 한다.
default => $order->ordered_at,
};
$this->line("- 주문번호: {$order->order_number} ({$paymentMethodValue}, 기한: {$dueAt})");
@@ -286,6 +286,7 @@ class StoreEcommerceSettingsRequest extends FormRequest
// sometimes 필수: rules() 는 탭 구분 없이 적용되므로 무조건 required 로 두면
// 이 키를 보내지 않는 다른 탭(마일리지 등) 저장이 통째로 막힌다. 키가 온 경우에만 필수.
'order_settings.auto_cancel_days' => ['sometimes', 'required', 'integer', 'min:'.config('sirsoft-ecommerce.limits.auto_cancel_days_min', 1), 'max:'.config('sirsoft-ecommerce.limits.auto_cancel_days_max', 30)],
'order_settings.pending_order_expire_minutes' => ['sometimes', 'required', 'integer', 'min:'.config('sirsoft-ecommerce.limits.pending_order_expire_minutes_min', 0), 'max:'.config('sirsoft-ecommerce.limits.pending_order_expire_minutes_max', 20160)],
'order_settings.cart_expiry_days' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.cart_expiry_days_min', 1), 'max:'.config('sirsoft-ecommerce.limits.cart_expiry_days_max', 365)],
'order_settings.stock_restore_on_cancel' => ['nullable', 'boolean'],
'order_settings.confirmable_statuses' => ['nullable', 'array'],
@@ -1213,6 +1214,10 @@ class StoreEcommerceSettingsRequest extends FormRequest
'order_settings.auto_cancel_days.integer' => __('sirsoft-ecommerce::validation.custom.order_settings.auto_cancel_days.integer'),
'order_settings.auto_cancel_days.min' => __('sirsoft-ecommerce::validation.custom.order_settings.auto_cancel_days.min'),
'order_settings.auto_cancel_days.max' => __('sirsoft-ecommerce::validation.custom.order_settings.auto_cancel_days.max'),
'order_settings.pending_order_expire_minutes.required' => __('sirsoft-ecommerce::validation.custom.order_settings.pending_order_expire_minutes.required'),
'order_settings.pending_order_expire_minutes.integer' => __('sirsoft-ecommerce::validation.custom.order_settings.pending_order_expire_minutes.integer'),
'order_settings.pending_order_expire_minutes.min' => __('sirsoft-ecommerce::validation.custom.order_settings.pending_order_expire_minutes.min'),
'order_settings.pending_order_expire_minutes.max' => __('sirsoft-ecommerce::validation.custom.order_settings.pending_order_expire_minutes.max'),
'order_settings.cart_expiry_days.integer' => __('sirsoft-ecommerce::validation.custom.order_settings.cart_expiry_days.integer'),
'order_settings.cart_expiry_days.min' => __('sirsoft-ecommerce::validation.custom.order_settings.cart_expiry_days.min'),
'order_settings.cart_expiry_days.max' => __('sirsoft-ecommerce::validation.custom.order_settings.cart_expiry_days.max'),
@@ -167,14 +167,20 @@ interface OrderRepositoryInterface
public function hasOrderByUser(int $userId): bool;
/**
* 입금 기한 만료된 결제대기 주문 조회
* 기한이 지난 미결제 주문 조회
*
* vbank/dbank 결제의 입금 기한이 지난 주문들을 조회합니다.
* 두 부류를 함께 조회합니다.
* - vbank/dbank 결제의 입금 기한이 지난 결제대기 주문
* - 결제창까지 갔으나 결제가 성립하지 않은 주문대기 주문 (PG 카드 등, 경과 시간 기준)
*
* 후자는 입금 기한이라는 개념이 없어 어떤 정리 주체도 없이 남던 부류다.
* `$pendingOrderExpireMinutes` 가 null 이거나 0 이하면 후자는 조회하지 않는다.
*
* @param int $limit 최대 조회 개수
* @return Collection 입금 기한 만료된 결제대기 주문 컬렉션
* @param int|null $pendingOrderExpireMinutes 주문대기 주문의 만료 기준(분)
* @return Collection 기한이 지난 미결제 주문 컬렉션
*/
public function getExpiredPendingPaymentOrders(int $limit = 100): Collection;
public function getExpiredPendingPaymentOrders(int $limit = 100, ?int $pendingOrderExpireMinutes = null): Collection;
/**
* ID 목록으로 주문을 조회하고 ID 키 맵으로 반환합니다 (bulk activity log lookup).
@@ -17,6 +17,7 @@ use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\ShippingStatusEnum;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderAddress;
@@ -785,23 +786,47 @@ class OrderRepository implements OrderRepositoryInterface
/**
* {@inheritDoc}
*/
public function getExpiredPendingPaymentOrders(int $limit = 100): Collection
public function getExpiredPendingPaymentOrders(int $limit = 100, ?int $pendingOrderExpireMinutes = null): Collection
{
return $this->model
->with(['payment', 'user'])
->where('order_status', OrderStatusEnum::PENDING_PAYMENT->value)
->whereHas('payment', function ($query) {
$query->where(function ($q) {
// vbank 가상계좌 입금 기한 만료
$q->where('payment_method', PaymentMethodEnum::VBANK->value)
->whereNotNull('vbank_due_at')
->where('vbank_due_at', '<', now());
})->orWhere(function ($q) {
// dbank 무통장입금(수동 입금확인) 입금 기한 만료
$q->where('payment_method', PaymentMethodEnum::DBANK->value)
->whereNotNull('deposit_due_at')
->where('deposit_due_at', '<', now());
->where(function ($outer) use ($pendingOrderExpireMinutes) {
// 입금 기한이 있는 결제수단 — 기한 도과분
$outer->where(function ($scope) {
$scope->where('order_status', OrderStatusEnum::PENDING_PAYMENT->value)
->whereHas('payment', function ($query) {
$query->where(function ($q) {
// vbank 가상계좌 입금 기한 만료
$q->where('payment_method', PaymentMethodEnum::VBANK->value)
->whereNotNull('vbank_due_at')
->where('vbank_due_at', '<', now());
})->orWhere(function ($q) {
// dbank 무통장입금(수동 입금확인) 입금 기한 만료
$q->where('payment_method', PaymentMethodEnum::DBANK->value)
->whereNotNull('deposit_due_at')
->where('deposit_due_at', '<', now());
});
});
});
// 결제창까지 갔지만 결제가 성립하지 않은 주문(PG 카드 등) — 입금 기한이라는 개념이
// 없어 종전에는 어떤 정리 주체도 없이 무한히 남았다. 브라우저 리턴 콜백이 주문
// 상태를 바꾸지 않게 되면서(위조 콜백으로 남의 주문을 취소시킬 수 있었던 통로 차단)
// 승인 거절분도 여기에 머무르므로, 주문 시각 기준 경과분을 정리 대상에 포함한다.
// 선차감 마일리지 복원도 이 정리에서 함께 이루어진다.
if ($pendingOrderExpireMinutes !== null && $pendingOrderExpireMinutes > 0) {
$outer->orWhere(function ($scope) use ($pendingOrderExpireMinutes) {
$scope->where('order_status', OrderStatusEnum::PENDING_ORDER->value)
->where('ordered_at', '<', now()->subMinutes($pendingOrderExpireMinutes))
// 승인 콜백과 경쟁해 이미 결제가 성립한 주문은 건드리지 않는다.
->whereHas('payment', function ($query) {
$query->whereNotIn('payment_status', [
PaymentStatusEnum::PAID->value,
PaymentStatusEnum::WAITING_DEPOSIT->value,
]);
});
});
}
})
->orderBy('ordered_at', 'asc')
->limit($limit)
@@ -1451,6 +1451,7 @@ return [
'order_settings.bank_accounts.*.is_default' => 'Default Account',
'order_settings.auto_cancel_expired' => 'Auto Cancel Unpaid Orders',
'order_settings.auto_cancel_days' => 'Auto Cancel Days',
'order_settings.pending_order_expire_minutes' => 'Pending Payment Order Expiry (minutes)',
'order_settings.cart_expiry_days' => 'Cart Expiry Days',
'order_settings.default_pg_provider' => 'Default PG Provider',
'order_settings.payment_methods.*.pg_provider' => 'PG Provider',
@@ -1781,6 +1782,12 @@ return [
'min' => 'Auto cancel days must be at least 1.',
'max' => 'Auto cancel days cannot exceed 30.',
],
'pending_order_expire_minutes' => [
'required' => 'Please enter the pending payment order expiry.',
'integer' => 'Pending payment order expiry must be an integer.',
'min' => 'Pending payment order expiry must be at least 0 (0 disables cleanup).',
'max' => 'Pending payment order expiry cannot exceed 20160 minutes (14 days).',
],
'cart_expiry_days' => [
'integer' => 'Cart expiry days must be an integer.',
'min' => 'Cart expiry days must be at least 1.',
@@ -1451,6 +1451,7 @@ return [
'order_settings.bank_accounts.*.is_default' => '기본 계좌',
'order_settings.auto_cancel_expired' => '미결제 자동취소',
'order_settings.auto_cancel_days' => '자동취소 기한(일)',
'order_settings.pending_order_expire_minutes' => '결제 미완료 주문 만료 기준(분)',
'order_settings.cart_expiry_days' => '장바구니 보관기간(일)',
'order_settings.default_pg_provider' => '기본 PG사',
'order_settings.payment_methods.*.pg_provider' => 'PG사',
@@ -1781,6 +1782,12 @@ return [
'min' => '자동취소 기한은 1일 이상이어야 합니다.',
'max' => '자동취소 기한은 최대 30일까지 설정 가능합니다.',
],
'pending_order_expire_minutes' => [
'required' => '결제 미완료 주문 만료 기준을 입력해주세요.',
'integer' => '결제 미완료 주문 만료 기준은 정수여야 합니다.',
'min' => '결제 미완료 주문 만료 기준은 0분 이상이어야 합니다. (0 은 정리하지 않음)',
'max' => '결제 미완료 주문 만료 기준은 최대 20160분(14일)까지 설정 가능합니다.',
],
'cart_expiry_days' => [
'integer' => '장바구니 보관기간은 정수여야 합니다.',
'min' => '장바구니 보관기간은 1일 이상이어야 합니다.',
@@ -228,6 +228,54 @@ class ShippingPolicyTestApiCallTest extends ModuleTestCase
'localhost' => ['http://localhost/calc'],
'사설 IP' => ['http://192.168.0.10/calc'],
'내부 도메인' => ['http://vault.internal/calc'],
// 점 동등 유니코드 문자(U+3002·U+FF0E·U+FF61)와 전각 슬래시(U+FF0F)는 연결
// 계층의 UTS#46 정규화에서 ASCII 로 바뀐다. 게이트가 원문 host 로만 판정하면
// 검증 시점과 접속 시점의 목적지가 달라져 내부망 조회가 열린다.
'U+3002 로 감춘 localhost' => ["http://localhost\u{3002}/calc"],
'U+FF0E 로 감춘 localhost' => ["http://localhost\u{FF0E}/calc"],
'U+FF61 로 감춘 localhost' => ["http://localhost\u{FF61}/calc"],
'U+3002 로 감춘 루프백 IP' => ["http://127\u{3002}0\u{3002}0\u{3002}1/calc"],
'U+FF0E 로 감춘 메타데이터' => ["http://169\u{FF0E}254\u{FF0E}169\u{FF0E}254/latest/meta-data/"],
'U+3002 로 감춘 내부 도메인' => ["http://vault\u{3002}internal/calc"],
'전각 슬래시로 감춘 루프백' => ["http://127.0.0.1\u{FF0F}.example.com/calc"],
'후행 점 localhost' => ['http://localhost./calc'],
];
}
/**
* 정규화가 정상 외부 API 엔드포인트까지 막지는 않는다 (회귀 방지).
*
* @param string $endpoint 정상 호출 가능한 엔드포인트
*/
#[DataProvider('legitimateEndpointProvider')]
public function test_public_endpoint_is_still_callable(string $endpoint): void
{
Http::fake(['*' => Http::response(['shipping_fee' => 3000], 200)]);
$response = $this->actingAs($this->adminUser)->postJson($this->url, [
'endpoint' => $endpoint,
'method' => 'GET',
'response_format' => 'json',
'response_path' => 'shipping_fee',
]);
$response->assertOk();
Http::assertSentCount(1);
}
/**
* 정규화 후에도 통과해야 하는 정상 엔드포인트 목록.
*
* @return array<string, array{string}>
*/
public static function legitimateEndpointProvider(): array
{
return [
'공개 도메인' => ['https://api.example.com/shipping/fee'],
'비표준 포트' => ['https://api.example.com:8443/shipping/fee'],
'국제화 도메인' => ["https://\u{4F8B}\u{3048}.jp/shipping/fee"],
'punycode 도메인' => ['https://xn--r8jz45g.jp/shipping/fee'],
];
}
@@ -0,0 +1,47 @@
/**
* 관리자 상품폼 E2E 공용 조회 헬퍼.
*
* 추가옵션 선택지(values)를 보유한 상품을 실행 시점에 찾아 그 **숫자 id** 를 돌려준다.
* 상품 id 를 spec 에 상수로 박으면 실측 시드가 정리되는 순간 그 spec 이 통째로 죽는데,
* 죽었다는 사실이 "대상 없음" 과 구분되지 않는다 (실제로 id 306 이 그렇게 사라졌다).
*/
import type { Page } from '@playwright/test';
const LIST_API = '/api/modules/sirsoft-ecommerce/admin/products?per_page=40';
const DETAIL_API = '/api/modules/sirsoft-ecommerce/admin/products';
/**
* 추가옵션 선택지를 보유한 상품의 숫자 id 를 찾습니다.
*
* @param page 인증이 적용된 Playwright 페이지 (auth_token 이 localStorage 에 있어야 한다)
* @return 찾은 상품의 숫자 id (없으면 null)
*/
export async function findProductWithAdditionalOptionValues(page: Page): Promise<number | null> {
return page.evaluate(
async ({ listApi, detailApi }) => {
const token = localStorage.getItem('auth_token') ?? '';
const headers = { Accept: 'application/json', Authorization: `Bearer ${token}` };
const listRes = await fetch(listApi, { headers });
if (!listRes.ok) return null;
const listJson = await listRes.json();
const rows = listJson?.data?.data ?? listJson?.data ?? [];
for (const row of rows) {
const id = Number(row?.id);
if (!Number.isFinite(id)) continue;
const detailRes = await fetch(`${detailApi}/${id}`, { headers });
if (!detailRes.ok) continue;
const detail = (await detailRes.json())?.data;
const groups = detail?.additional_options ?? [];
const hasValues = groups.some((g: any) => (g?.values ?? []).length > 0);
if (hasValues) return id;
}
return null;
},
{ listApi: LIST_API, detailApi: DETAIL_API },
);
}
@@ -0,0 +1,186 @@
/**
* 유저 상품상세 추가옵션 흐름 공용 헬퍼 — 대상 상품 조회 + 커스텀 드롭다운 조작.
*
* `additional-options.spec.ts`(선택 payload 축)와
* `additional-option-currency-conversion.spec.ts`(표시통화 환산 축)가 같은 화면의 같은
* 조작을 한다. 사본을 각 spec 에 두면 한쪽만 고쳐졌을 때 그 차집합이 사각이 되므로
* 여기 한 곳에서 소유한다.
*
* 이 템플릿(sirsoft-basic)의 Select 는 `options` 가 있으면 네이티브 `<select>` 가 아니라
* `button[role=option]` 커스텀 드롭다운을 렌더한다 — `selectOption()` 은 동작하지 않는다.
*/
import type { Page } from '@playwright/test';
/** 공개 상품 목록 API */
export const PRODUCT_LIST_API = '/api/modules/sirsoft-ecommerce/products?per_page=40';
export interface AddOptProduct {
/** 상품 상세 경로 */
url: string;
/** 메인 옵션 그룹별로 고를 값 라벨 (그룹을 **전부** 골라야 블럭이 생긴다) */
mainValues: string[];
/** 추가옵션 그룹 id */
groupId: number;
/** 그 그룹에서 고를 선택지 이름 */
valueName: string;
/** 그 선택지의 추가금 (기본통화 기준, 0 이면 총액 증가 단언은 건너뛴다) */
priceAdjustment: number;
}
/**
* 공개 목록에서 "메인 옵션 2개 이상 + 추가옵션 그룹 보유" 상품을 찾는다.
*
* @param page Playwright 페이지
* @return 찾은 상품 (없으면 null)
*/
export async function findAdditionalOptionProduct(page: Page): Promise<AddOptProduct | null> {
const codes: string[] = await page.evaluate(async (api) => {
const res = await fetch(api, { headers: { Accept: 'application/json' } });
if (!res.ok) return [];
const json = await res.json();
const rows = json?.data?.data ?? json?.data ?? [];
return rows.map((p: any) => p.product_code).filter(Boolean);
}, PRODUCT_LIST_API);
for (const code of codes.slice(0, 20)) {
const detail = await page.evaluate(async (c) => {
const res = await fetch(`/api/modules/sirsoft-ecommerce/products/${c}`, {
headers: { Accept: 'application/json' },
});
if (!res.ok) return null;
const json = await res.json();
const d = json?.data ?? json;
return {
optionCount: (d?.options ?? []).length,
// 옵션 그룹을 전부 골라야 선택 블럭이 만들어지고 그 안에 추가옵션이 렌더된다
mainValues: (d?.option_groups ?? []).map((g: any) => g?.values_localized?.[0] ?? null),
additional: d?.additional_options ?? [],
};
}, code);
if (!detail || detail.optionCount <= 1) continue;
if (!detail.mainValues.length || detail.mainValues.some((v: any) => !v)) continue;
for (const group of detail.additional as any[]) {
const active = (group.values ?? []).filter((v: any) => v.is_active !== false);
// 총액 변화를 볼 수 있도록 추가금이 양수인 선택지를 우선한다
const value = active.find((v: any) => Number(v.price_adjustment ?? 0) > 0) ?? active[0];
if (!value) continue;
return {
url: `/shop/products/${code}`,
mainValues: detail.mainValues.map(String),
groupId: Number(group.id),
valueName: String(value.name),
priceAdjustment: Number(value.price_adjustment ?? 0),
};
}
}
return null;
}
/**
* 커스텀 드롭다운 Select 에서 라벨(부분 일치)로 항목을 고른다.
*
* @param page Playwright 페이지
* @param testId Select 래퍼의 data-testid
* @param label 고를 항목 라벨 (부분 일치)
* @return 없음
*/
export async function pickOption(page: Page, testId: string, label: string | RegExp): Promise<void> {
await page.getByTestId(testId).getByRole('button').first().click();
await page.getByRole('option', { name: label }).first().click();
}
/**
* 정규식 메타문자를 이스케이프한다.
*
* 추가옵션 이름에는 괄호가 흔하고(`연장 보증 (2년)`), 화면 라벨은 그 뒤에 추가금이 붙으므로
* **부분 일치**가 필요하다 — 이스케이프 없이 RegExp 로 감싸면 괄호가 캡처 그룹으로 해석돼
* 아무것도 매칭되지 않는다.
*
* @param text 원문
* @return 이스케이프된 문자열
*/
export function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* 메인 옵션 그룹을 순서대로 전부 고른다.
*
* 하위 그룹 Select 는 상위 그룹이 선택될 때까지 disabled 이고, **모든** 그룹이 선택돼야
* 선택 블럭(과 그 안의 추가옵션)이 만들어진다.
*
* @param page Playwright 페이지
* @param values 그룹 순서대로의 값 라벨
* @return 없음
*/
export async function pickAllMainOptions(page: Page, values: string[]): Promise<void> {
for (let i = 0; i < values.length; i++) {
await pickOption(page, `option-group-${i}`, values[i]);
}
}
/**
* 헤더 통화 셀렉터에서 **보이는** 요소를 고른다.
*
* 이 셀렉터는 데스크톱 헤더와 모바일 드로어 두 곳에 렌더되므로 같은 `data-testid` 가
* 항상 2개 이상 잡힌다. `.first()` 는 그중 숨은 쪽을 집을 수 있고, 그러면 클릭이
* 타임아웃되거나 목록이 비어 보인다 — 화면 폭에 따라 어느 쪽이 먼저인지도 달라진다.
*
* 인덱스로 고른 요소(`nth(i)`)를 돌려주면 안 된다 — 헤더는 데이터소스가 정착하며 다시
* 그려지고, 그때 순서가 바뀌면 같은 인덱스가 숨은 쪽을 가리킨다(클릭이 "not visible" 로
* 50회 재시도 후 실패). `:visible` 필터를 담은 locator 를 돌려주면 **동작 시점에** 다시
* 해석되므로 그 경합이 성립하지 않는다.
*
* @param page Playwright 페이지
* @param testId 대상 testid
* @param timeoutMs 최대 대기 (기본 15초)
* @return 보이는 요소 locator (마감까지 없으면 null)
*/
async function visibleByTestId(page: Page, testId: string, timeoutMs = 15_000) {
const locator = page.locator(`[data-testid="${testId}"]:visible`).first();
try {
await locator.waitFor({ state: 'visible', timeout: timeoutMs });
} catch {
return null;
}
return locator;
}
/**
* 헤더 통화 셀렉터로 **현재와 다른** 표시통화로 전환한다.
*
* 셀렉터는 이커머스 모듈이 `_user_base` 에 주입하는 레이아웃 확장이 소유한다(템플릿이 아니다).
* 전환 대상은 현재 트리거가 보여 주는 코드가 아닌 것 중 하나를 고른다 — 어느 통화가
* 기본인지 spec 이 알 필요가 없다.
*
* @param page Playwright 페이지
* @param exclude 이 코드들은 고르지 않는다 (기본값: 현재 표시통화만 제외)
* @return 전환한 통화 코드 (셀렉터가 없거나 고를 통화가 없으면 null)
*/
export async function switchToOtherCurrency(page: Page, exclude: string[] = []): Promise<string | null> {
const trigger = await visibleByTestId(page, 'currency-switcher');
if (trigger === null) return null;
const current = (await trigger.innerText()).replace(/\s+/g, ' ').trim();
await trigger.click();
const options = page.locator('[data-testid^="currency-option-"]');
await options.first().waitFor({ state: 'attached', timeout: 10_000 }).catch(() => {});
const codes: string[] = [];
for (let i = 0; i < (await options.count()); i++) {
const id = await options.nth(i).getAttribute('data-testid');
const code = id ? id.replace('currency-option-', '') : null;
if (code && !codes.includes(code)) codes.push(code);
}
const target = codes.find((c) => !current.includes(c) && !exclude.includes(c));
if (!target) {
await page.keyboard.press('Escape');
return null;
}
const option = await visibleByTestId(page, `currency-option-${target}`);
if (option === null) return null;
await option.click();
return target;
}
@@ -1,7 +1,5 @@
/**
* 상품폼 추가옵션 선택지 round-trip + 생성 후 id 기반 리다이렉트 회귀 (skeleton, placeholder).
*
* 세션 D 정밀 점검에서 발견·수정한 2건의 관리자 폼 회귀를 커버한다.
* 상품폼 추가옵션 선택지 round-trip (관리자 수정폼).
*
* @scenario product-additional-options
* @effects edit_form_loads_existing_option_values,
@@ -9,68 +7,81 @@
* create_redirects_to_numeric_id_edit_url,
* redirected_edit_form_loads_data
*
* e2e:allow 세션 D 회귀 수정 — 단위/구조 테스트로 1차 차단하고, 브라우저 회귀는 본 placeholder
* (test.describe.skip)가 data-testid 보강 후 활성화될 때 검증한다.
* 현재 커버리지:
* (1) 회귀#1 (관리자 수정폼이 기존 추가옵션 선택지(values)를 미로드 → 저장 시 선택지 영구 소실):
* findWithOptions 가 additionalOptions.values 를 eager-load 하고 ProductResource 가 values 를
* round-trip 형식으로 노출함을 PHPUnit
* tests/Unit/Resources/ProductResourceAdditionalOptionsTest.php
* (test_get_detail_eager_loads_additional_option_values / test_resource_exposes_additional_option_values)
* 가 검증한다. 수정 전 fail → eager-load 추가 후 green.
* (2) 회귀#2 (저장 onSuccess navigate 가 product_code URL 사용 → detail API 405 → 폼 빈 로드):
* 생성모드 navigate path 가 result.data.id 를 사용(product_code 미사용)함을 레이아웃 구조 테스트
* resources/js/__tests__/layouts/productOptionsAdditionalToggle.test.tsx
* ("회귀: 상품 생성/저장 후 navigate 는 id 기반") 가 검증한다.
* 라이브 재검(Playwright MCP, 실 도메인 g7_2.dev):
* - 회귀#1: 상품 306 edit 재로드 시 각인(없음+0/각인추가+5000)·선물포장(기본포장+3000/고급포장 off)
* 선택지가 form.additional_options[].values 로 정상 로드됨을 확인.
* - 회귀#2: 신규 상품 생성 → /admin/ecommerce/products/314/edit (숫자 id) 로 리다이렉트되고
* 리다이렉트된 폼이 데이터를 로드(isDataLoaded 진입, name/code/options 표시)함을 확인.
* 회귀#1 (관리자 수정폼이 기존 추가옵션 선택지(values)를 미로드 → 저장 시 선택지 영구 소실) 을
* 브라우저에서 직접 차단한다. 로드와 저장-후-영속 두 축을 모두 재현한다.
*
* 본 spec 은 다음 사전 작업 완료 후 활성화한다 (data-testid 보강):
* 1. 상품옵션 탭 버튼에 data-testid="product-tab-options"
* 2. 추가옵션 선택지 행(name/추가금)에 data-testid="additional-value-{groupIdx}-{valueIdx}"
* 3. 신규 등록 폼 필수 필드(상품코드/상품명/재고/카테고리/옵션) data-testid + 저장 버튼 data-testid="product-save"
* 4. test.describe.skip → test.describe 변경 + 추가옵션 선택지 보유 상품 시드(§12.B 전제)
* e2e:allow 회귀#2(생성 저장 후 navigate 가 product_code 대신 숫자 id 를 쓴다) 축은 브라우저
* 시험으로 켜지 않는다 — 상품 생성 폼은 name(다국어)·product_code·category_ids·
* list_price·selling_price·stock_quantity 에 더해 option_groups/options 를 필수로
* 요구하고, 옵션 생성은 MultilingualTagInput(모달 기반 composite) → generateOptions →
* 생성된 행별 가격·재고 입력을 거쳐야 한다. 그 흐름을 브라우저로 몰아 넣으면 검증하려는
* navigate 한 줄보다 폼 조작 자체가 훨씬 자주 깨져 회귀 신호가 묻힌다.
* 대신 레이아웃 구조 테스트
* resources/js/__tests__/layouts/productOptionsAdditionalToggle.test.tsx
* ("회귀: 상품 생성/저장 후 navigate 는 id 기반") 가 생성모드 navigate path 가
* result.data.id 를 쓰고 product_code 를 쓰지 않음을 고정한다.
* 라이브 재검(Playwright MCP)으로 신규 생성 → /admin/ecommerce/products/314/edit
* (숫자 id) 리다이렉트 + 리다이렉트된 폼의 데이터 로드를 확인했다.
*
* 탭 클릭이 없는 이유: 상품폼 탭 네비게이션은 enableScrollSpy 방식이고 추가옵션 섹션에는
* 조건부 렌더(if)가 없다 — 모든 섹션이 처음부터 DOM 에 있고 탭은 스크롤만 이동시킨다.
*
* 매트릭스 (시나리오 매니페스트 product-additional-options.yaml 와 1:1):
* - 수정폼 진입: 기존 선택지(이름/추가금/기본/활성/정렬)가 round-trip 로드된다 (회귀#1)
* - 수정폼 진입: 기존 선택지(이름/추가금)가 round-trip 로드된다 (회귀#1)
* - 저장 후 재로드: 선택지가 소실 없이 영속된다 (회귀#1)
* - 신규 생성 저장: /products/{숫자 id}/edit 로 리다이렉트된다 (회귀#2)
* - 리다이렉트된 폼: 데이터가 로드된다(405 빈 로드 아님) (회귀#2)
*/
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
import { findProductWithAdditionalOptionValues } from '../../fixtures/admin-product-lookup';
// 추가옵션 그룹 + 선택지를 보유한 시드 상품의 수정폼 (숫자 id 경로 — product_code 직접 진입은 detail API 405)
const EDIT_URL = '/admin/ecommerce/products/306/edit';
const CREATE_URL = '/admin/ecommerce/products/create';
// 대상 상품은 실행 시점에 찾는다 (숫자 id 경로 — product_code 직접 진입은 detail API 405).
const editUrl = (id: number) => `/admin/ecommerce/products/${id}/edit`;
test.describe.skip('상품폼 추가옵션 round-trip + 생성 리다이렉트 (placeholder — data-testid 보강 후 활성화)', () => {
test.describe('상품폼 추가옵션 선택지 round-trip', () => {
test('수정폼 진입 — 기존 추가옵션 선택지가 round-trip 로드된다 (회귀#1)', async ({
page,
productManageToken,
}) => {
await authenticatePage(page, productManageToken);
await page.goto(EDIT_URL);
await page.getByTestId('product-tab-options').click();
await page.goto('/admin/ecommerce/products');
const productId = await findProductWithAdditionalOptionValues(page);
test.skip(productId === null, '추가옵션 선택지를 보유한 상품이 없어 검증할 수 없습니다');
await page.goto(editUrl(productId as number));
// 그룹의 첫 선택지가 이름·추가금을 보유한 채 렌더되어야 한다 (values 미로드 시 0행 → 저장 시 소실)
await expect(page.getByTestId('additional-value-0-0')).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId('additional-value-0-0')).toBeVisible({ timeout: 15_000 });
});
test('신규 생성 저장 — 숫자 id 기반 edit URL 로 리다이렉트되고 폼이 로드된다 (회귀#2)', async ({
test('저장 후 재로드 — 추가옵션 선택지가 소실 없이 영속된다 (회귀#1)', async ({
page,
productManageToken,
}) => {
await authenticatePage(page, productManageToken);
await page.goto(CREATE_URL);
await page.goto('/admin/ecommerce/products');
const productId = await findProductWithAdditionalOptionValues(page);
test.skip(productId === null, '추가옵션 선택지를 보유한 상품이 없어 검증할 수 없습니다');
await page.goto(editUrl(productId as number));
// (필수 필드 입력 — data-testid 보강 후 구현)
await page.getByTestId('product-save').click();
const firstValue = page.getByTestId('additional-value-0-0');
await expect(firstValue).toBeVisible({ timeout: 15_000 });
// product_code 가 아닌 숫자 id 경로로 이동해야 한다 (회귀: code → detail API 405 빈 로드)
await expect(page).toHaveURL(/\/admin\/ecommerce\/products\/\d+\/edit/, { timeout: 15_000 });
// 저장 전 선택지 개수를 센다 — values 미로드 회귀에서는 저장 시 이 행들이 통째로 사라진다
const before = await page.getByTestId(/^additional-value-0-/).count();
expect(before).toBeGreaterThan(0);
const save = page.getByTestId('product-save');
await expect(save).toBeEnabled();
await save.click();
// 저장 완료를 응답으로 확인한 뒤 재로드한다 (토스트 문구는 로케일에 의존하므로 쓰지 않는다)
await page.waitForResponse(
(res) =>
res.url().includes('/api/modules/sirsoft-ecommerce/admin/products/') &&
res.request().method() === 'PUT',
{ timeout: 20_000 },
);
await page.goto(editUrl(productId as number));
await expect(page.getByTestId('additional-value-0-0')).toBeVisible({ timeout: 15_000 });
expect(await page.getByTestId(/^additional-value-0-/).count()).toBe(before);
});
});
@@ -1,54 +1,46 @@
/**
* 상품폼 추가옵션 "사용/미사용" 토글 + 비우기 확인 모달 (skeleton, placeholder).
* 상품폼 추가옵션 "사용/미사용" 토글 + 비우기 확인 모달.
*
* @scenario admin-product-additional-options-toggle
* @effects use_toggle_adds_first_option_row,
* not_use_opens_clear_confirm_modal,
* @effects not_use_opens_clear_confirm_modal,
* clear_modal_renders_cancel_and_confirm_buttons,
* confirm_clears_options_and_toggle_switches_to_not_use,
* cancel_keeps_options_and_modal_closes
*
* e2e:allow §13-D-FAIL 회귀 수정(확인 모달 footer 버튼 미렌더 + 비우기 후 토글 미갱신) 신규 시나리오 axis 부재 —
* 본 placeholder spec(test.describe.skip)이 data-testid 보강 후 활성화될 때 함께 검증된다.
* 레이아웃 렌더링 테스트(productOptionsAdditionalToggle.test.tsx)가
* (1) 확인/save_template 모달의 slots.footer 부재 + footer 버튼이 children 말미
* flex-justify-end Div 안에 존재(Modal.tsx 가 slots 미렌더 → children 만 렌더)함을,
* (2) 확인 버튼이 dot-path 인라인 setState 가 아닌 clearAdditionalOptions 전용 핸들러를
* 호출함을 구조적으로 회귀 차단한다.
* 핸들러 단위 테스트(optionHandlers.test.ts > clearAdditionalOptionsHandler)가
* clear 가 form 객체를 새 참조로 통째 교체(add 핸들러와 동일 패턴)하여
* form 을 watch 하는 토글 className 파생식의 리렌더 누락을 차단함을 검증한다.
* 라이브 재검(Playwright MCP, PW-ADDOPT-001 id 306)으로 미사용→확인 클릭 시
* 취소/확인 버튼 렌더 + 비우기 후 토글 "미사용" active 전환 + "각인 문구" 행 소멸을 확증했다.
* §13-D-FAIL 회귀(확인 모달 footer 버튼 미렌더 + 비우기 후 토글 미갱신)를 브라우저에서 직접 차단한다.
* 같은 회귀를 구조적으로 고정하는 하위 테스트는 그대로 유지된다 —
* 레이아웃 렌더링 테스트(productOptionsAdditionalToggle.test.tsx)가 footer 버튼의 children 배치와
* 확인 버튼의 clearAdditionalOptions 핸들러 호출을, 핸들러 단위 테스트
* (optionHandlers.test.ts > clearAdditionalOptionsHandler)가 form 객체 통째 교체를 검증한다.
*
* 본 spec 은 다음 사전 작업 완료 후 활성화한다 (data-testid 보강):
* 1. 상품옵션 탭 버튼에 data-testid="product-tab-options"
* 2. 추가옵션 "사용"/"미사용" 토글 버튼에 data-testid="additional-option-use" / "additional-option-not-use"
* 3. 추가옵션 행 컨테이너(additional_options_content)에 data-testid="additional-options-content"
* 4. 확인 모달(additional_options_clear_modal)의 취소/확인 버튼에
* data-testid="additional-clear-cancel" / "additional-clear-confirm"
* 5. test.describe.skip → test.describe 변경
* 추가옵션 행 컨테이너는 testid 가 아니라 레이아웃이 이미 가진 DOM id(#additional_options_content)로
* 잡는다 — 그 노드에 props 를 새로 만들어 붙이면 서빙 단계에서 props 가 [] 로 버려진다(실측).
*
* 탭 클릭이 없는 이유: 상품폼 탭 네비게이션은 enableScrollSpy 방식이고 추가옵션 섹션에는
* 조건부 렌더(if)가 없다 — 모든 섹션이 처음부터 DOM 에 있고 탭은 스크롤만 이동시킨다.
*
* 매트릭스(시나리오 매니페스트 admin-product-additional-options-toggle.yaml 와 1:1):
* - "사용" 클릭(0행) : 첫 추가옵션 행 추가 + "사용" active
* - "미사용" 클릭(N행) : 확인 모달 노출(취소/확인 버튼 렌더)
* - 확인 : 옵션 비워짐 + 토글 "미사용" active 전환 + 행 소멸 + 모달 닫힘
* - 취소 : 옵션 유지 + 모달만 닫힘
*/
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
import { findProductWithAdditionalOptionValues } from '../../fixtures/admin-product-lookup';
// 추가옵션 1행을 보유한 시드 상품의 수정폼 (숫자 id 경로 — product_code 직접 진입은 detail API 405)
const EDIT_URL = '/admin/ecommerce/products/306/edit';
// 대상 상품은 실행 시점에 찾는다 (숫자 id 경로 — product_code 직접 진입은 detail API 405).
const editUrl = (id: number) => `/admin/ecommerce/products/${id}/edit`;
test.describe.skip('상품폼 추가옵션 토글 + 비우기 확인 모달 (placeholder — data-testid 보강 후 활성화)', () => {
test.describe('상품폼 추가옵션 토글 + 비우기 확인 모달', () => {
test('미사용 클릭 — 확인 모달이 열리고 취소/확인 버튼이 렌더된다 (§13-D-FAIL footer)', async ({
page,
productManageToken,
}) => {
await authenticatePage(page, productManageToken);
await page.goto(EDIT_URL);
await page.goto('/admin/ecommerce/products');
const productId = await findProductWithAdditionalOptionValues(page);
test.skip(productId === null, '추가옵션 선택지를 보유한 상품이 없어 검증할 수 없습니다');
await page.goto(editUrl(productId as number));
await page.getByTestId('product-tab-options').click();
await page.getByTestId('additional-option-not-use').click();
// footer 버튼이 children 으로 이동했으므로 취소/확인 모두 렌더되어야 한다 (slots.footer 였을 땐 미렌더)
@@ -61,28 +53,32 @@ test.describe.skip('상품폼 추가옵션 토글 + 비우기 확인 모달 (pla
productManageToken,
}) => {
await authenticatePage(page, productManageToken);
await page.goto(EDIT_URL);
await page.goto('/admin/ecommerce/products');
const productId = await findProductWithAdditionalOptionValues(page);
test.skip(productId === null, '추가옵션 선택지를 보유한 상품이 없어 검증할 수 없습니다');
await page.goto(editUrl(productId as number));
await page.getByTestId('product-tab-options').click();
await page.getByTestId('additional-option-not-use').click();
await page.getByTestId('additional-clear-confirm').click();
// clearAdditionalOptions 가 form 을 통째 교체 → 행 소멸 + "미사용" active
await expect(page.getByTestId('additional-options-content')).not.toBeVisible();
await expect(page.locator('#additional_options_content')).not.toBeVisible();
await expect(page.getByTestId('additional-option-not-use')).toHaveClass(/active/);
await expect(page.getByTestId('additional-option-use')).not.toHaveClass(/active/);
});
test('취소 — 옵션이 유지되고 모달만 닫힌다', async ({ page, productManageToken }) => {
await authenticatePage(page, productManageToken);
await page.goto(EDIT_URL);
await page.goto('/admin/ecommerce/products');
const productId = await findProductWithAdditionalOptionValues(page);
test.skip(productId === null, '추가옵션 선택지를 보유한 상품이 없어 검증할 수 없습니다');
await page.goto(editUrl(productId as number));
await page.getByTestId('product-tab-options').click();
await page.getByTestId('additional-option-not-use').click();
await page.getByTestId('additional-clear-cancel').click();
// 비우기 미실행 — 행 유지 + 모달 닫힘
await expect(page.getByTestId('additional-clear-confirm')).not.toBeVisible();
await expect(page.getByTestId('additional-options-content')).toBeVisible();
await expect(page.locator('#additional_options_content')).toBeVisible();
});
});
@@ -1,7 +1,6 @@
/**
* 추가옵션 추가금의 표시통화 환산 — 기본통화 ≠ 표시통화 쇼핑몰에서 추가금이
* 상품가와 같은 통화로 표시되고 합계에도 환산되어 더해지는지 검증.
* 템플릿 sirsoft-basic (유저 화면). (skeleton, placeholder)
* 추가옵션 추가금의 표시통화 환산 — 추가금이 상품가·총액과 **같은 통화**로 표기되고
* 합계에도 그 통화로 더해지는지 검증. 템플릿 sirsoft-basic (유저 화면).
*
* @scenario currency-symbol-display
* @effects additional_option_adjustment_uses_configured_symbol,
@@ -15,97 +14,183 @@
* "환산된 상품가 + 환산 안 된 추가금" 이라는 통화가 섞인 합계가 나왔다
* (상품가 ₩114,000 + 추가금 ¥5,000 → ₩119,000 표기).
*
* e2e:allow 재현에 쇼핑몰 전역 통화 설정(기본통화를 KRW 가 아닌 통화로 전환 + 환율 지정)이
* 필요해 공용 개발 사이트를 흔들지 않고는 자동화할 수 없다. 통화 설정 시드가
* 갖춰지기 전까지는 아래 커버리지로 대체한다.
* (1) 서버 계약 — tests/Unit/Resources/AdditionalOptionMultiCurrencyTest.php
* (상품상세·장바구니 응답의 multi_currency_price_adjustment, 음수 추가금 부호 유지) green.
* (2) 합계 계산 — templates/_bundled/sirsoft-basic/src/handlers/__tests__/
* productOptionsAdditionalCurrency.test.ts
* (표시통화 합계에 환산 추가금 가산, 수량 배수, 미선택 0, 항목 생성 시 통화별 합계 적재) green.
* (3) 폴백 — productOptionsAdditional.test.ts 의 기존 케이스가 서버 맵이 없는
* 응답에서 기본통화 피봇 환산으로 종전 값을 유지함을 고정 green.
* 라이브 검수는 Chrome MCP 실측으로 기록됨 — 기본통화 JPY / 표시통화 KRW 상태에서
* 수정 전 ₩119,000·"+¥3,000" → 수정 후 ₩142,500·"+28,500원" 확인.
* **불변조건**: 한 화면 안에서 추가금 표기의 통화 = 그 상품가·총액 표기의 통화.
* 어느 통화가 기본이든 성립해야 하므로 이 spec 은 통화 코드를 박지 않는다 —
* 화면에서 읽은 총액의 통화 표식과 대조한다. 기본통화 = 표시통화인 쇼핑몰에서는
* 대조가 자명하게 성립하므로, 결함을 실제로 잡으려면 둘이 다른 설정이 필요하다.
* 그 상태를 이 spec 자신이 만든다 — 헤더 통화 셀렉터로 **기본통화가 아닌 통화**로
* 전환한 뒤 검증하고, 전환할 통화가 하나도 없으면 사유와 함께 스킵한다.
*
* 활성화 조건:
* 1. 기본통화가 KRW 가 아닌 쇼핑몰 통화 설정 시드(예: JPY 기본 + KRW 환율)
* 2. _purchase_card 추가옵션 Select / 추가옵션 금액 라인에 data-testid
* 3. _cart_item·_checkout_items 추가옵션 라인에 data-testid
* 4. PLAYWRIGHT_BASE_URL = 실 도메인, test.describe.skip → test.describe
* 이 spec 은 시드를 만들지 않는다 — 공개 상품 목록에서 "메인 옵션 2개 이상 + 추가금이
* 양수인 추가옵션" 상품을 찾아 쓰고, 없으면 개별 테스트가 사유와 함께 스킵된다.
*
* 매트릭스:
* T1 상품상세 선택지 라벨 — 표시통화로 환산 표기 (기준통화 기호가 남지 않는다)
* T2 상품상세 "추가옵션 금액" + 총 금액 — 상품가와 같은 통화로 합산
* T3 장바구니 줄 — 추가옵션 라벨이 그 줄의 상품가·소계와 같은 통화
* T1 상품상세 추가옵션 선택지 라벨 — 총액과 같은 통화로 표기
* T2 상품상세 "추가옵션 금액" — 총액과 같은 통화 (기준통화 표식이 남지 않는다)
* T3 장바구니 줄 — 추가옵션 라벨이 상세에서 본 표시통화와 같은 통화
* T4 주문서 줄 — 동일
* T5 통화 전환 — 표시통화를 바꾸면 추가금 표기·합계가 함께 따라간다
* T5 통화 전환 — 표시통화를 바꾸면 추가금 표기가 함께 따라간다
*/
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
import type { Page } from '@playwright/test';
import {
findAdditionalOptionProduct,
pickOption,
pickAllMainOptions,
escapeRegExp,
switchToOtherCurrency,
type AddOptProduct,
} from '../../fixtures/shop-additional-option-lookup';
// 추가옵션 보유 시드 상품 상세 (실 도메인 시드 후 경로 확정)
const PRODUCT_PATH = '/shop/products/28';
/**
* 표기 문자열에서 **통화**를 뽑는다 (글리프가 아니다).
*
* KRW 는 한 화면 안에서도 두 표기가 공존한다 — 총액은 `₩142,500`, 추가금 라인은
* `+28,500원` 이다. 앞은 다중통화 포맷터, 뒤는 레이아웃의 지역 포맷 분기가 만든다.
* 이 spec 이 재는 불변조건은 "추가금과 총액이 **같은 통화**인가" 이므로 둘을 같은 값으로
* 정규화한다. 글리프까지 맞추라고 요구하면 통화가 섞이지 않은 화면도 빨갛게 된다.
*
* @param text 화면에서 읽은 금액 표기
* @return 통화 코드 성격의 표식 (찾지 못하면 null)
*/
function currencyMarkOf(text: string): string | null {
if (text.includes('원') || text.includes('₩')) return 'KRW';
const symbol = text.match(/[$¥€元]/);
if (!symbol) return null;
return { $: 'USD', '¥': 'JPY', '€': 'EUR', 元: 'CNY' }[symbol[0]] ?? symbol[0];
}
test.describe.skip('추가옵션 추가금 표시통화 환산 (기본통화 ≠ 표시통화, skeleton)', () => {
test('T1/T2 상품상세: 추가옵션 라벨과 총 금액이 표시통화로 환산된다', async ({ page, userToken }) => {
await authenticatePage(page, userToken);
await page.goto(PRODUCT_PATH);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
/**
* 상품상세에서 메인 옵션 전부 + 추가금이 있는 추가옵션을 고른다.
*
* @param page Playwright 페이지
* @param product 대상 상품
* @return 없음
*/
async function selectOptionsWithAdditional(page: Page, product: AddOptProduct): Promise<void> {
await pickAllMainOptions(page, product.mainValues);
await pickOption(page, `add-option-0-${product.groupId}`, new RegExp(escapeRegExp(product.valueName)));
}
// 기본옵션 선택 → 추가옵션 블럭 노출
await page.getByTestId('option-select-0').selectOption({ index: 1 });
await page.getByTestId('option-select-1').selectOption({ index: 1 });
test.describe('추가옵션 추가금 표시통화 환산', () => {
test('T1/T2 상품상세: 추가옵션 라벨과 추가금 합계가 총액과 같은 통화로 표기된다', async ({ page }) => {
await page.goto('/shop');
const product = await findAdditionalOptionProduct(page);
test.skip(product === null, '메인 옵션 2개 이상 + 추가옵션을 가진 공개 상품이 없어 검증할 수 없습니다');
test.skip(
(product?.priceAdjustment ?? 0) <= 0,
'추가금이 양수인 추가옵션 선택지가 없어 통화 표기를 검증할 수 없습니다',
);
// 추가옵션 선택지 라벨에 기준통화 기호(¥)가 남아 있으면 안 된다
const addOption = page.getByTestId('add-option-0-10');
await expect(addOption).toBeVisible();
await expect(addOption).not.toContainText('¥');
await page.goto(product!.url);
const switched = await switchToOtherCurrency(page);
test.skip(switched === null, '기본통화 외 표시통화가 없어 기본통화 ≠ 표시통화 상태를 만들 수 없습니다');
await addOption.selectOption({ label: /선물 포장/ });
await selectOptionsWithAdditional(page, product!);
// 추가옵션 금액 라인과 총 금액이 같은 통화(표시통화)로 표기된다
const additionalAmount = page.getByTestId('additional-options-amount');
await expect(additionalAmount).toContainText('원');
const totalMark = currencyMarkOf(await page.getByTestId('purchase-total').innerText());
expect(totalMark, '총액에서 통화 표식을 읽을 수 있어야 대조가 성립한다').not.toBeNull();
const total = page.getByTestId('purchase-total-amount');
await expect(total).toContainText('₩');
// T1 — 선택지 라벨의 추가금이 총액과 같은 통화
const label = await page.getByTestId(`add-option-0-${product!.groupId}`).innerText();
expect(currencyMarkOf(label), '추가옵션 선택지 라벨의 추가금이 총액과 다른 통화로 표기됐다').toBe(totalMark);
// T2 — "추가옵션 금액" 합계가 총액과 같은 통화
const amount = await page.getByTestId('additional-options-amount').innerText();
expect(currencyMarkOf(amount), '추가옵션 금액 합계가 총액과 다른 통화로 표기됐다').toBe(totalMark);
});
test('T3 장바구니: 추가옵션 라벨이 그 줄의 소계와 같은 통화로 표기된다', async ({ page, userToken }) => {
await authenticatePage(page, userToken);
test('T3 장바구니: 추가옵션 줄이 상세에서 본 표시통화로 표기된다', async ({ page }) => {
await page.goto('/shop');
const product = await findAdditionalOptionProduct(page);
test.skip(product === null, '메인 옵션 2개 이상 + 추가옵션을 가진 공개 상품이 없어 검증할 수 없습니다');
test.skip(
(product?.priceAdjustment ?? 0) <= 0,
'추가금이 양수인 추가옵션 선택지가 없어 통화 표기를 검증할 수 없습니다',
);
await page.goto(product!.url);
const switched = await switchToOtherCurrency(page);
test.skip(switched === null, '기본통화 외 표시통화가 없어 기본통화 ≠ 표시통화 상태를 만들 수 없습니다');
await selectOptionsWithAdditional(page, product!);
const totalMark = currencyMarkOf(await page.getByTestId('purchase-total').innerText());
const [response] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/cart') && r.request().method() === 'POST'),
page.getByTestId('add-to-cart').click(),
]);
expect(response.status(), '담기가 성공해야 장바구니를 확인할 수 있다').toBeLessThan(300);
await page.goto('/shop/cart');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const line = page.getByTestId('cart-item-additional-option').first();
await expect(line).toContainText('원');
await expect(line).not.toContainText('¥');
await expect(line, '장바구니에 추가옵션 줄이 있어야 한다').toBeVisible();
expect(
currencyMarkOf(await line.innerText()),
'장바구니 추가옵션 줄이 표시통화가 아닌 통화로 표기됐다',
).toBe(totalMark);
});
test('T4 주문서: 추가옵션 라벨이 표시통화로 표기된다', async ({ page, userToken }) => {
test('T4 주문서: 추가옵션 줄이 표시통화로 표기된다', async ({ page, userToken }) => {
await authenticatePage(page, userToken);
await page.goto('/shop/checkout');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.goto('/shop');
const product = await findAdditionalOptionProduct(page);
test.skip(product === null, '메인 옵션 2개 이상 + 추가옵션을 가진 공개 상품이 없어 검증할 수 없습니다');
test.skip(
(product?.priceAdjustment ?? 0) <= 0,
'추가금이 양수인 추가옵션 선택지가 없어 통화 표기를 검증할 수 없습니다',
);
await page.goto(product!.url);
const switched = await switchToOtherCurrency(page);
test.skip(switched === null, '기본통화 외 표시통화가 없어 기본통화 ≠ 표시통화 상태를 만들 수 없습니다');
await selectOptionsWithAdditional(page, product!);
const totalMark = currencyMarkOf(await page.getByTestId('purchase-total').innerText());
// 주문서는 "바로 구매" 로 진입한다 — 장바구니를 거치지 않아 사전 적재가 필요 없다
await Promise.all([
page.waitForResponse((r) => r.url().includes('/checkout') && r.request().method() === 'POST'),
page.getByTestId('buy-now').click(),
]);
await page.waitForURL(/\/shop\/checkout/, { timeout: 30_000 });
const line = page.getByTestId('checkout-item-additional-option').first();
await expect(line).toContainText('원');
await expect(line).not.toContainText('¥');
await expect(line, '주문서에 추가옵션 줄이 있어야 한다').toBeVisible();
expect(
currencyMarkOf(await line.innerText()),
'주문서 추가옵션 줄이 표시통화가 아닌 통화로 표기됐다',
).toBe(totalMark);
});
test('T5 통화 전환: 표시통화를 바꾸면 추가금 표기와 합계가 함께 따라간다', async ({ page, userToken }) => {
await authenticatePage(page, userToken);
await page.goto(PRODUCT_PATH);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
test('T5 통화 전환: 표시통화를 바꾸면 추가금 표기가 함께 따라간다', async ({ page }) => {
await page.goto('/shop');
const product = await findAdditionalOptionProduct(page);
test.skip(product === null, '메인 옵션 2개 이상 + 추가옵션을 가진 공개 상품이 없어 검증할 수 없습니다');
test.skip(
(product?.priceAdjustment ?? 0) <= 0,
'추가금이 양수인 추가옵션 선택지가 없어 통화 표기를 검증할 수 없습니다',
);
await page.getByTestId('option-select-0').selectOption({ index: 1 });
await page.getByTestId('option-select-1').selectOption({ index: 1 });
await page.getByTestId('add-option-0-10').selectOption({ label: /선물 포장/ });
await page.goto(product!.url);
const first = await switchToOtherCurrency(page);
test.skip(first === null, '기본통화 외 표시통화가 없어 전환을 검증할 수 없습니다');
await selectOptionsWithAdditional(page, product!);
const before = await page.getByTestId('additional-options-amount').innerText();
const beforeMark = currencyMarkOf(before);
await page.getByTestId('currency-switcher').click();
await page.getByRole('option', { name: 'USD' }).click();
const second = await switchToOtherCurrency(page);
test.skip(second === null || second === first, '전환할 세 번째 통화가 없어 검증할 수 없습니다');
await expect(page.getByTestId('additional-options-amount')).not.toHaveText(before);
await expect(page.getByTestId('additional-options-amount')).toContainText('$');
await expect
.poll(async () => currencyMarkOf(await page.getByTestId('additional-options-amount').innerText()))
.not.toBe(beforeMark);
// 전환 후에도 총액과 같은 통화여야 한다 (한쪽만 따라가면 통화가 섞인다)
const totalMark = currencyMarkOf(await page.getByTestId('purchase-total').innerText());
expect(
currencyMarkOf(await page.getByTestId('additional-options-amount').innerText()),
'통화 전환 후 추가금과 총액의 통화가 어긋났다',
).toBe(totalMark);
});
});
@@ -1,6 +1,6 @@
/**
* 유저 상품 추가옵션(유료) 흐름 — 상품상세 블럭 선택 → 담기/바로구매 → 장바구니 모달 재선택 → 체크아웃/주문 표시.
* 템플릿 sirsoft-basic (유저 화면). (skeleton, placeholder)
* 유저 상품 추가옵션(유료) 흐름 — 상품상세 블럭 선택 → 실시간 합계 → 담기 payload → 장바구니 표시.
* 템플릿 sirsoft-basic (유저 화면).
*
* @scenario product-additional-options
* @effects detail_block_renders_active_values_only,
@@ -11,66 +11,89 @@
* cart_modal_reselect_and_patch,
* order_display_snapshot_additional_rows
*
* e2e:allow 세션 C(유저 흐름 + 표시) 신규 UI — 단위/레이아웃 렌더 테스트로 결함을 1차 차단하고,
* 브라우저 회귀는 본 placeholder(test.describe.skip)가 data-testid 보강 + 실 도메인 시드 후 활성화될 때 검증한다.
* 현재 커버리지:
* (1) 핸들러 로직 — templates/_bundled/sirsoft-basic/src/handlers/__tests__/productOptionsAdditional.test.ts
* (블럭별 추가옵션 선택/해제, 추가금×수량 배수 D6, 다통화 환산, payload 변환) green.
* (2) 레이아웃 구조/렌더 — templates/_bundled/sirsoft-basic/src/__tests__/layouts/shopAdditionalOptions.test.tsx
* (블럭 내부 그룹 iteration, setBlockAdditionalOption 호출, 담기 body 의 additional_option_selections,
* 필수 미선택 가드, 레거시 자유텍스트 스텁 제거, 장바구니/체크아웃/주문완료/마이페이지 스냅샷 표시) green.
* (3) 관리자 주문서 스냅샷 별행 — resources/js/__tests__/layouts/adminOrderInfoAdditionalOptions.test.ts green.
* 백엔드 계약(담기/옵션변경/체크아웃 입력, CartItemResource/OrderOptionResource 출력, 422 reason)은
* 세션 A PHPUnit 으로 검증됨.
* 배경: 추가옵션 선택은 템플릿 커스텀 핸들러가 `context.setState` 로 기록한다. engine-v1.63.5
* (트러블슈팅 사례 42) 이전에는 그 쓰기가 저장소 B 에 닿지 않아, 화면에는 선택이 보이는데
* 담기 요청의 `additional_option_selections` 가 비어 나갔다. 예외도 콘솔 에러도 없었다.
* 요청 body 를 보는 이 spec 이 그 결함을 잡는 종단 통로다.
*
* 본 spec 은 다음 사전 작업 완료 후 활성화한다:
* 1. 추가옵션 그룹 2개(필수 "각인" / 선택 "포장") × 선택지 변종(추가금 0/양수/비활성) 보유 상품 시드 (§12.C 전제)
* 2. _purchase_card 블럭 추가옵션 Select 에 data-testid="add-option-{itemIndex}-{groupId}"
* 3. 담기/바로구매 버튼 + 필수 미선택 토스트에 data-testid
* 4. _cart_item 추가옵션 라인 + _modal_cart_option_change 재선택 Select 에 data-testid
* 5. PLAYWRIGHT_BASE_URL = 실 도메인, test.describe.skip → test.describe
* 이 spec 은 시드를 만들지 않는다 — 공개 상품 목록에서 "메인 옵션 2개 이상 + 추가옵션 그룹 보유"
* 상품을 찾아 쓰고, 없으면 개별 테스트가 사유와 함께 스킵된다(`test.skip`). describe 를 통째로
* 끄면 커버리지가 0 이 되므로 그렇게 하지 않는다.
*
* 이 템플릿의 Select 는 `options` 가 있으면 네이티브 `<select>` 가 아니라
* `button[role=option]` 커스텀 드롭다운을 렌더한다 — `selectOption()` 은 동작하지 않는다.
*
* 매트릭스 (시나리오 매니페스트 product-additional-options.yaml ui_surface 축과 1:1):
* T1 상품상세: 기본옵션 미선택 → 추가옵션 미노출 (D10)
* T2 기본옵션 선택 → 블럭 내부 활성 선택지만 렌더(V6 비활성 제외), 추가옵션 선택 → 소계·총액 실시간(옵션가+추가옵션×수량)
* T3 같은 옵션 2블럭 → 블럭별 독립 추가옵션, 수량 3 → 추가금×3 (D6)
* T4 담기/바로구매 → additional_option_selections 전송, 필수 미선택 → 422 additional_option_required / 잘못된 value → 422 additional_option_invalid
* T5 장바구니 합산 키 — (옵션+추가옵션 해시) 동일 합산 / 상이 별개 행 (D3)
* T6 새로고침 → 장바구니 추가옵션 영속(CartItemResource.additional_options)
* T7 옵션변경 모달 추가옵션 재선택 → 실시간 재계산 → PATCH → 부모 정합
* 표시: 체크아웃/주문완료/마이페이지/관리자주문서 스냅샷 별행 (D14), 과거 주문(추가옵션 없음) 깨짐 0
* T2 기본옵션 선택 → 블럭 내부 활성 선택지만 렌더, 추가옵션 선택 → 총액 실시간 반영
* T4 담기 요청이 additional_option_selections 를 전송한다
* T6 담은 뒤 장바구니 행에 선택한 추가옵션이 표시된다
*/
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
import { test, expect } from '@playwright/test';
import {
findAdditionalOptionProduct,
pickOption,
pickAllMainOptions,
escapeRegExp,
} from '../../fixtures/shop-additional-option-lookup';
// 추가옵션 보유 시드 상품 상세 (실 도메인 시드 후 경로 확정)
const PRODUCT_URL = '/shop/products/{ADDOPT_PRODUCT_ID}';
test.describe('유저 추가옵션 흐름', () => {
test('T2 기본옵션 선택 → 추가옵션 선택 시 총액에 추가금이 반영된다', async ({ page }) => {
await page.goto('/shop');
const product = await findAdditionalOptionProduct(page);
test.skip(product === null, '메인 옵션 2개 이상 + 추가옵션을 가진 공개 상품이 없어 검증할 수 없습니다');
test.skip(
(product?.priceAdjustment ?? 0) <= 0,
'추가금이 양수인 추가옵션 선택지가 없어 총액 증가를 검증할 수 없습니다',
);
test.describe.skip('유저 추가옵션 흐름 (placeholder — data-testid 보강 + 시드 후 활성화)', () => {
test('T2 기본옵션 선택 → 블럭 내부 추가옵션 선택 시 총액이 추가금만큼 증가한다', async ({ page }) => {
await page.goto(PRODUCT_URL);
// 기본옵션 선택 → 블럭 생성
await page.getByTestId('option-group-0').selectOption({ index: 1 });
// 추가옵션(각인 추가 +5000) 선택
await page.getByTestId('add-option-0-1').selectOption({ label: /각인 추가/ });
// 총액에 +5,000 반영
await expect(page.getByTestId('purchase-total')).toContainText('5,000');
await page.goto(product!.url);
await pickAllMainOptions(page, product!.mainValues);
const before = (await page.getByTestId('purchase-total').innerText()).replace(/[^\d]/g, '');
await pickOption(page, `add-option-0-${product!.groupId}`, new RegExp(escapeRegExp(product!.valueName)));
await expect
.poll(async () => (await page.getByTestId('purchase-total').innerText()).replace(/[^\d]/g, ''))
.not.toBe(before);
});
test('T4 필수 추가옵션 미선택 시 담기 차단 토스트', async ({ page }) => {
await page.goto(PRODUCT_URL);
await page.getByTestId('option-group-0').selectOption({ index: 1 });
// 필수 그룹 미선택 상태로 담기
await page.getByTestId('add-to-cart').click();
await expect(page.getByText(/필수 추가옵션/)).toBeVisible();
test('T4 담기 요청이 additional_option_selections 를 전송한다', async ({ page }) => {
await page.goto('/shop');
const product = await findAdditionalOptionProduct(page);
test.skip(product === null, '메인 옵션 2개 이상 + 추가옵션을 가진 공개 상품이 없어 검증할 수 없습니다');
await page.goto(product!.url);
await pickAllMainOptions(page, product!.mainValues);
await pickOption(page, `add-option-0-${product!.groupId}`, new RegExp(escapeRegExp(product!.valueName)));
const [request] = await Promise.all([
page.waitForRequest((r) => r.url().includes('/cart') && r.method() === 'POST'),
page.getByTestId('add-to-cart').click(),
]);
const body = request.postDataJSON();
expect(body.items?.length, '선택한 옵션이 요청 body 에 실려야 한다').toBeGreaterThan(0);
const selections = body.items?.[0]?.additional_option_selections ?? [];
expect(
selections.some((s: any) => Number(s.additional_option_id) === product!.groupId),
'선택한 추가옵션이 요청 body 에 실려야 한다',
).toBe(true);
});
test('T7 장바구니 옵션변경 모달에서 추가옵션 재선택 후 PATCH 정합', async ({ page, customerToken }) => {
await authenticatePage(page, customerToken);
test('T6 담은 뒤 장바구니 행에 선택한 추가옵션이 표시된다', async ({ page }) => {
await page.goto('/shop');
const product = await findAdditionalOptionProduct(page);
test.skip(product === null, '메인 옵션 2개 이상 + 추가옵션을 가진 공개 상품이 없어 검증할 수 없습니다');
await page.goto(product!.url);
await pickAllMainOptions(page, product!.mainValues);
await pickOption(page, `add-option-0-${product!.groupId}`, new RegExp(escapeRegExp(product!.valueName)));
const [response] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/cart') && r.request().method() === 'POST'),
page.getByTestId('add-to-cart').click(),
]);
expect(response.status(), '담기가 성공해야 장바구니를 확인할 수 있다').toBeLessThan(300);
await page.goto('/shop/cart');
await page.getByTestId('cart-change-option').first().click();
await page.getByTestId('modal-add-option-1').selectOption({ label: /각인 추가/ });
await page.getByTestId('modal-apply').click();
// 장바구니 행에 변경된 추가옵션 반영
await expect(page.getByTestId('cart-item').first()).toContainText(/각인 추가/);
await expect(page.getByTestId('cart-item').first()).toContainText(product!.valueName);
});
});
@@ -5,12 +5,14 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Console;
use App\Contracts\Extension\ModuleInterface;
use App\Contracts\Extension\ModuleManagerInterface;
use App\Contracts\Extension\ModuleSettingsInterface;
use App\Extension\HookManager;
use App\Models\User;
use App\Services\ModuleSettingsService;
use Carbon\Carbon;
use Mockery;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
@@ -286,4 +288,164 @@ class CancelPendingPaymentOrdersCommandTest extends ModuleTestCase
->expectsOutput('처리할 만료 주문이 없습니다.')
->assertSuccessful();
}
/**
* 결제창까지 갔으나 성립하지 않은 주문(PG 카드 등)도 경과 후 정리한다.
*
* 이 부류는 입금 기한이라는 개념이 없어 종전에는 어떤 정리 주체도 없었다. 브라우저 리턴
* 콜백이 주문 상태를 바꾸지 않게 되면서 승인 거절분도 여기에 머무르므로, 경과 기준으로
* 정리해 선차감 마일리지가 무기한 묶이지 않게 한다.
*/
public function test_cancels_stale_pending_order_that_never_completed_payment(): void
{
$user = User::factory()->create();
$order = Order::factory()->create([
'user_id' => $user->id,
'order_status' => OrderStatusEnum::PENDING_ORDER,
'ordered_at' => Carbon::now()->subDays(2),
]);
OrderPayment::factory()->create([
'order_id' => $order->id,
'payment_method' => PaymentMethodEnum::CARD,
'payment_status' => PaymentStatusEnum::READY,
]);
$this->artisan('sirsoft-ecommerce:cancel-pending-orders')
->assertSuccessful();
$order->refresh();
$this->assertEquals(OrderStatusEnum::CANCELLED, $order->order_status);
}
/**
* 아직 기한이 지나지 않은 주문대기 주문은 건드리지 않는다 — 구매자가 결제창을 열어 둔
* 상태일 수 있으므로, 진행 중인 결제를 취소해 버리면 안 된다.
*/
public function test_does_not_cancel_recent_pending_order(): void
{
$user = User::factory()->create();
$order = Order::factory()->create([
'user_id' => $user->id,
'order_status' => OrderStatusEnum::PENDING_ORDER,
'ordered_at' => Carbon::now()->subMinutes(5),
]);
OrderPayment::factory()->create([
'order_id' => $order->id,
'payment_method' => PaymentMethodEnum::CARD,
'payment_status' => PaymentStatusEnum::READY,
]);
$this->artisan('sirsoft-ecommerce:cancel-pending-orders')
->assertSuccessful();
$order->refresh();
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status);
}
/**
* 승인 콜백과 경쟁해 이미 결제가 성립한 주문은 정리 대상이 아니다.
*/
public function test_does_not_cancel_stale_pending_order_whose_payment_is_paid(): void
{
$user = User::factory()->create();
$order = Order::factory()->create([
'user_id' => $user->id,
'order_status' => OrderStatusEnum::PENDING_ORDER,
'ordered_at' => Carbon::now()->subDays(2),
]);
OrderPayment::factory()->create([
'order_id' => $order->id,
'payment_method' => PaymentMethodEnum::CARD,
'payment_status' => PaymentStatusEnum::PAID,
'paid_at' => Carbon::now()->subDay(),
]);
$this->artisan('sirsoft-ecommerce:cancel-pending-orders')
->assertSuccessful();
$order->refresh();
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status);
}
/**
* 만료 기준을 0 으로 두면 주문대기 주문 정리를 끌 수 있다 (운영자 선택권).
*/
public function test_pending_order_cleanup_can_be_disabled_by_setting(): void
{
$this->moduleSettings['order_settings.pending_order_expire_minutes'] = 0;
$user = User::factory()->create();
$order = Order::factory()->create([
'user_id' => $user->id,
'order_status' => OrderStatusEnum::PENDING_ORDER,
'ordered_at' => Carbon::now()->subDays(2),
]);
OrderPayment::factory()->create([
'order_id' => $order->id,
'payment_method' => PaymentMethodEnum::CARD,
'payment_status' => PaymentStatusEnum::READY,
]);
$this->artisan('sirsoft-ecommerce:cancel-pending-orders')
->assertSuccessful();
$order->refresh();
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status);
}
/**
* 정리 시 선차감 마일리지가 복원된다 — 이것이 이 정리를 넓힌 이유다.
*
* 마일리지 차감 시점을 '주문할 때'로 설정한 상점에서 카드 승인이 거절되면, 종전에는
* 콜백의 실패 처리가 복원했다. 그 경로가 위조 가능해 막힌 뒤로는 이 정리가 복원을 맡는다.
*/
public function test_cancelling_stale_pending_order_restores_deducted_mileage(): void
{
$user = User::factory()->create();
$order = Order::factory()->create([
'user_id' => $user->id,
'order_status' => OrderStatusEnum::PENDING_ORDER,
'ordered_at' => Carbon::now()->subDays(2),
'is_mileage_deducted' => true,
'total_points_used_amount' => 500,
]);
OrderPayment::factory()->create([
'order_id' => $order->id,
'payment_method' => PaymentMethodEnum::CARD,
'payment_status' => PaymentStatusEnum::READY,
]);
// 복원은 마일리지 리스너에 위임되므로, 복원이 일어났다는 신호는 이 훅의 발화다
// (취소 서비스는 플래그를 되돌리지 않고 취소 레코드 기준으로 멱등성을 보장한다).
$restoredAmounts = [];
HookManager::addAction(
'sirsoft-ecommerce.mileage.restore',
function ($amount) use (&$restoredAmounts) {
$restoredAmounts[] = $amount;
},
10,
['sync' => true],
);
$this->artisan('sirsoft-ecommerce:cancel-pending-orders')
->assertSuccessful();
$order->refresh();
$this->assertEquals(OrderStatusEnum::CANCELLED, $order->order_status);
$this->assertNotEmpty(
$restoredAmounts,
'정리된 주문의 선차감 마일리지 복원이 일어나지 않았습니다.'
);
$this->assertSame(500, (int) $restoredAmounts[0]);
}
}
@@ -0,0 +1,75 @@
# audit:allow test-scenario-coverage reason: |
# 조합·효과 마킹 면제. 룰은 이 매니페스트의 축을 정상 전개한다 — 파서 한계가 아니라
# 커버 방식이 이유다. 정리 대상 판정은 주문 부류 × 경과 × 결제상태의 곱인데 테스트는
# 그 곱을 메서드 하나당 한 점씩 찌르는 형태라 조합 단위 @scenario 마킹으로 표현되지
# 않는다. 본 매니페스트는 "무엇을 반드시 시험해야 하는가" 의 SSoT 로 두고, 실제 커버는
# test_files 의 통과 테스트가 담당한다. test_files 실재 검사는 면제 대상이 아니다.
feature: 미결제 주문 만료 자동 정리 (입금기한 부류 + 주문대기 부류)
description: |
결제가 성립하지 않은 주문을 정리 배치가 취소로 거두는 계약.
종전에는 입금 기한이 있는 결제수단(vbank·dbank)만 정리 대상이었다. 결제창까지 갔으나
승인되지 않은 주문(PG 카드 등)은 입금 기한이라는 개념이 없어 어떤 정리 주체도 없이 남았고,
실무에서는 브라우저 리턴 콜백의 실패 처리가 그 자리를 대신하고 있었다.
그 콜백 경로는 인증도 서명도 없어 주문번호만 아는 제3자가 남의 주문을 취소시킬 수 있었고,
그래서 주문 상태를 바꾸지 않도록 막았다. 그 결과 승인 거절분이 주문대기에 머무르게 되므로,
이 정리가 그 부류까지 거두고 **선차감 마일리지 복원**도 함께 책임진다.
경계: 승인 콜백과 경쟁해 이미 결제가 성립한 주문(paid·입금대기)은 정리하지 않는다.
운영자는 만료 기준(분)을 0 으로 두어 주문대기 부류의 정리만 끌 수 있고, 그때도 입금기한
부류의 정리는 그대로 동작한다.
axes:
order_class:
- deposit_due_vbank # 가상계좌 — vbank_due_at 도과분
- deposit_due_dbank # 무통장입금 — deposit_due_at 도과분
- pending_order_never_paid # 결제창까지 갔으나 승인되지 않은 주문 (PG 카드 등)
elapsed:
- past_threshold # 기준 경과 → 대상
- within_threshold # 기준 이내 → 구매자가 결제창을 열어 둔 상태일 수 있어 제외
payment_status:
- ready_or_failed # 결제 미성립 → 대상
- paid # 승인 콜백과 경쟁해 이미 성립 → 제외
- waiting_deposit # 입금 대기 → 제외
expire_minutes_setting:
- positive # 주문대기 부류 정리 활성 (기본 1440)
- zero # 주문대기 부류만 끔 — 입금기한 부류는 계속 정리
run_mode:
- apply
- dry_run
exclusions:
- order_class: deposit_due_vbank
expire_minutes_setting: zero
reason: 만료 기준 설정은 주문대기 부류에만 걸린다 — 입금기한 부류의 동작을 바꾸지 않는다
- order_class: deposit_due_dbank
expire_minutes_setting: zero
reason: 동일
effects:
- stale_pending_order_is_cancelled # 기준 경과 + 미성립 → 취소
- recent_pending_order_is_left_alone # 기준 이내 → 무변경 (진행 중 결제 보호)
- paid_pending_order_is_left_alone # 승인 경쟁 보호
- waiting_deposit_pending_order_is_left_alone
- expired_vbank_order_is_cancelled
- expired_dbank_order_is_cancelled
- non_expired_order_is_left_alone
- zero_setting_disables_pending_class_only # 운영자 선택권
- deducted_mileage_is_restored_on_cleanup # 이 정리를 넓힌 이유
- dry_run_mutates_nothing
- limit_option_is_respected
- disabled_toggle_skips_the_whole_run
- pending_class_due_display_falls_back_to_ordered_at # 입금 기한이 없는 부류의 표기 기준
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Console/CancelPendingPaymentOrdersCommandTest.php
manual_verification:
- description: "만료 정리 대상 검출 — 운영 데이터 무변경 확인"
steps:
- "php artisan sirsoft-ecommerce:cancel-pending-orders --dry-run --limit=5"
- "검출 목록에 주문대기 부류가 포함되고, 기한 표기가 주문 시각으로 나오는지 확인"
- "dry-run 이므로 어떤 주문도 상태가 바뀌지 않아야 한다"
@@ -132,6 +132,12 @@ CBT 인증 URL 로 폼 POST → KG 이니시스가 `sid` 를 콜백으로 전달
| 결제창 서명/모바일 해시/CBT 해시 요청에 타임스탬프 검증 생략 | 타임스탬프 신선도 검증 유지 | 오래된 서명 재사용(replay)으로 위조 결제 요청이 통과할 수 있다 |
| 일본 결제 설정 미완료 시 한국 표준결제로 조용히 대체 | 설정 미완료면 결제 자체를 중단 | 통화·수수료·정산 구조가 다른 결제가 잘못된 흐름으로 승인될 수 있다 |
| 라이브 키(사인키·INIAPI 키/IV·해시키)를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 결제창 서명을 위조할 수 있다 |
| 서버 승인 실패 분기(PC `authorizePayment` · 모바일 `P_STATUS` · CBT `approveCbtPayment`)에서 `failPayment()` 호출 | 로그 + `resolveFailUrl()` 만. 주문 상태는 건드리지 않는다 | 세 콜백 모두 PG 서명도 IP 증명도 없는 비인증 브라우저 요청이고 주문번호(`MOID`/`P_OID`/`oid`)도 요청자가 고른 값이다. 승인 실패는 위조 `authToken`/`P_TID`/`sid` 만으로 만들어낼 수 있으므로, 그것을 근거로 실패 처리하면 타인의 결제대기 주문이 취소된다 |
| 정당한 결제 실패 기록을 콜백에서 처리 | 소유권을 검증하는 `close-report`(`requestMatchesOrderBuyer`) 경유 | 구매자 이메일·전화 대조를 통과한 요청만 주문 상태를 바꿔야 한다 |
| PG 대상 `netCancel` 을 로컬 주문 실패 처리와 같은 것으로 취급 | `sendNetCancel()` 은 PG 잔존 승인 해제이므로 유지, 로컬 주문 mutation 은 별개 판단 | 두 동작을 묶으면 PG 정합성을 지키려다 주문 취소 통로를 다시 연다 |
| 결제창 컨텍스트를 `window` 전역에만 보관 | `markStandardPaymentCloseReportContext()` 가 sessionStorage 에도 남기고, 부팅 시 `reportStandardPaymentFailureOnReturn()` 으로 보고 | 결제창은 전체 페이지 이동으로 열리고 돌아와 전역이 소실된다. 승인 거절은 fail URL 리다이렉트로 끝나므로, 남겨 둔 정보가 없으면 정당한 결제 실패가 어디에도 기록되지 않는다 |
| 리턴 콜백 복귀 보고에 체크아웃 경로 검사를 강제 | `reportStandardPaymentWindowClosed($reason, requireCheckoutPage: false)` | 상점이 `redirect_fail_url` 을 바꿔 두면 경로 검사가 보고를 통째로 막는다. 닫힘 메시지 경로(체크아웃 화면 전용)와 리턴 복귀 경로는 판정 기준이 다르다 |
| 실패 화면에서 보고가 닿지 못한 주문을 방치 | 이커머스 모듈의 만료 주문 자동 정리가 최종 안전망 | 브라우저를 바로 닫으면 보고가 나가지 않는다. 두 경로가 함께 있어야 선차감 마일리지가 무기한 묶이지 않는다 |
<!-- @intent END -->
## 7. 테스트 실행
@@ -141,7 +147,7 @@ CBT 인증 URL 로 폼 POST → KG 이니시스가 `sid` 를 콜백으로 전달
|---|---|---|
| PHPUnit | 35개 | `plugins/_bundled/sirsoft-pay_kginicis/tests` |
| Vitest | 12개 | `vitest.config.ts` |
| Playwright | 0개 | — |
| Playwright | 1개 | `tests/Playwright` |
| 시나리오 매니페스트 | 2개 | `tests/scenarios` |
기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
@@ -153,6 +159,9 @@ php vendor/bin/phpunit plugins/_bundled/sirsoft-pay_kginicis/tests --filter='<
# Vitest (확장 디렉토리에서) (PowerShell)
cd plugins/_bundled/sirsoft-pay_kginicis && powershell -Command "npm run test:run -- <대상>"
# Playwright E2E (확장 디렉토리에서) (Bash)
cd plugins/_bundled/sirsoft-pay_kginicis && npm run test:e2e -- specs/<대상>.spec.ts
```
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
@@ -6,8 +6,13 @@
## [1.1.3] - 2026-08-31
### Security
- 제3자가 남의 주문번호만 알면 결제창을 거치지 않고도 그 주문을 취소시킬 수 있던 문제를 수정했습니다. 결제 결과 콜백은 로그인도 서명 확인도 거치지 않는 경로여서, 위조한 인증 정보를 보내 승인을 일부러 실패시키면 그 주문이 결제 실패로 처리되었습니다. 이제 승인이 성립하지 않은 콜백은 주문 상태를 바꾸지 않고 결제 화면으로 되돌려 보내기만 합니다. PC·모바일·해외결제(CBT) 결제창 모두에 적용됩니다. 구매자가 결제창을 닫아 생기는 정상적인 결제 실패는 종전처럼 기록됩니다.
### Added
- 결제가 거절되어 실패 화면으로 돌아오면 그 사실이 자동으로 서버에 기록됩니다. 구매자 본인인지 확인한 뒤에만 주문을 실패로 처리하므로, 남의 주문번호를 아는 것만으로는 그 주문을 건드릴 수 없습니다. PC·모바일·해외결제 결제창 모두에 적용되며, 상점이 실패 안내 주소를 바꿔 두었어도 동작합니다.
- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-pay_kginicis",
"version": "1.1.0",
"version": "1.1.3",
"components": {
"basic": [],
"composite": [],
File diff suppressed because one or more lines are too long
@@ -225,6 +225,8 @@ _단건 응답: `data` 객체의 필드._
PC 표준결제창(KRW)에서 사용자가 결제를 완료하지 않고 창을 닫았을 때 프론트엔드가 이를 서버에 보고하는 엔드포인트로, 해당 주문의 결제 실패/취소 이력을 기록합니다. `OrderProcessingService::failPayment()` 로 주문을 `USER_CANCEL` 사유로 실패 처리하고 `recordPaymentCancellation()` 으로 취소 이력을 남기며, 간편결제 선택 정보가 있으면 결제 메타에 병합합니다. 인증은 불필요하나(결제창 컨텍스트에서 호출) FormRequest 검증과 `oid` 기준 IP별 분당 20회 레이트리밋, 주문 존재·통화 KRW·구매자 일치·금액 일치를 검증하고, 이미 결제 가능 상태가 아니거나(`order_not_payable`) 이미 결제 완료(`payment_already_paid`)면 성공 응답에 `status: ignored` 로 무시 처리해 결제 성공 콜백과의 경쟁 상태를 차단합니다. 검증 규칙 위반 시 422, 레이트리밋 초과 429, 주문 미존재 404, 구매자 검증 실패 403 으로 응답합니다.
이 엔드포인트가 **주문을 실패로 전이시키는 유일한 결제창 경로**입니다. 브라우저 리턴 콜백(PC `/payment/callback`, 모바일 `/payment/mobile/callback`, 해외결제 `/payment/cbt/callback`)은 PG 서명도 IP 증명도 없고 주문번호도 요청자가 고른 값이므로, 서버 승인이 실패해도 주문 상태를 바꾸지 않고 실패 URL 로 되돌려 보내기만 합니다. 정당한 결제창 닫힘은 구매자 대조를 통과한 이 요청으로만 기록됩니다.
### POST /api/plugins/sirsoft-pay_kginicis/payment/mobile/signature
<!-- @generated:start:api.plugins.sirsoft-pay_kginicis.payment.mobile.signature -->
@@ -9,6 +9,7 @@ import {
clearStandardPaymentCloseReportContext,
installPaymentCloseMessageListener,
markStandardPaymentCloseReportContext,
reportStandardPaymentFailureOnReturn,
resetCheckoutSubmittingState,
} from '../paymentCloseMessageListener';
@@ -217,3 +218,123 @@ describe('paymentCloseMessageListener', () => {
expect(hasMobilePaymentReturnPending()).toBe(false);
});
});
/**
* 결제 실패 화면 복귀 시 보고
*
* 브라우저 리턴 콜백(PC·모바일·해외결제)은 PG 서명도 IP 증명도 없어 주문 상태를 바꾸지 않는다.
* 승인이 거절된 정당한 실패는 이 경로(소유권 대조 close-report)로만 기록된다.
* 전체 페이지 이동으로 window 컨텍스트가 사라지므로 sessionStorage 로 이어받는다.
*/
describe('결제 실패 화면 복귀 보고', () => {
const CONTEXT = {
closeReportUrl: '/plugins/sirsoft-pay_kginicis/payment/close-report',
oid: 'ORD-KGI-RETURN-001',
price: 10000,
buyer_email: 'buyer@example.com',
buyer_phone: '01012345678',
};
/**
* 화면 주소를 바꿔 결제 리턴 상황을 재현한다.
*/
function setLocation(pathname: string, search: string): void {
Object.defineProperty(window, 'location', {
configurable: true,
value: { pathname, search, origin: 'https://shop.example' },
});
}
beforeEach(() => {
window.sessionStorage.clear();
clearStandardPaymentCloseReportContext();
});
afterEach(() => {
delete windowRecord().G7Core;
vi.restoreAllMocks();
window.sessionStorage.clear();
clearStandardPaymentCloseReportContext();
});
it('실패 화면으로 돌아오면 저장해 둔 구매자 정보로 보고한다', async () => {
const apiPost = vi.fn().mockResolvedValue({ success: true });
windowRecord().G7Core = { api: { post: apiPost } };
markStandardPaymentCloseReportContext(CONTEXT);
// 전체 페이지 이동으로 window 컨텍스트가 사라진 상황을 재현한다.
delete windowRecord()['__sirsoftKginicisActiveStandardPaymentCloseContext'];
setLocation('/shop/checkout', '?error=9999&message=fail&orderId=ORD-KGI-RETURN-001');
await reportStandardPaymentFailureOnReturn();
expect(apiPost).toHaveBeenCalledWith(
'/plugins/sirsoft-pay_kginicis/payment/close-report',
expect.objectContaining({ oid: 'ORD-KGI-RETURN-001', price: 10000 }),
);
});
it('두 번 호출해도 한 번만 보고한다', async () => {
const apiPost = vi.fn().mockResolvedValue({ success: true });
windowRecord().G7Core = { api: { post: apiPost } };
markStandardPaymentCloseReportContext(CONTEXT);
setLocation('/shop/checkout', '?error=9999&orderId=ORD-KGI-RETURN-001');
await reportStandardPaymentFailureOnReturn();
await reportStandardPaymentFailureOnReturn();
expect(apiPost).toHaveBeenCalledTimes(1);
});
it('다른 주문번호로 돌아왔으면 보고하지 않는다', async () => {
const apiPost = vi.fn();
windowRecord().G7Core = { api: { post: apiPost } };
markStandardPaymentCloseReportContext(CONTEXT);
setLocation('/shop/checkout', '?error=9999&orderId=ORD-KGI-OTHER');
await reportStandardPaymentFailureOnReturn();
expect(apiPost).not.toHaveBeenCalled();
});
it('결제 완료 화면으로 돌아오면 보고하지 않고 저장분만 지운다', async () => {
const apiPost = vi.fn();
windowRecord().G7Core = { api: { post: apiPost } };
markStandardPaymentCloseReportContext(CONTEXT);
setLocation('/shop/orders/ORD-KGI-RETURN-001/complete', '');
await reportStandardPaymentFailureOnReturn();
expect(apiPost).not.toHaveBeenCalled();
expect(window.sessionStorage.getItem('g7:sirsoft-pay_kginicis:pendingClose')).toBeNull();
});
it('상점이 실패 주소를 바꿔 체크아웃 경로가 아니어도 보고한다', async () => {
const apiPost = vi.fn().mockResolvedValue({ success: true });
windowRecord().G7Core = { api: { post: apiPost } };
markStandardPaymentCloseReportContext(CONTEXT);
delete windowRecord()['__sirsoftKginicisActiveStandardPaymentCloseContext'];
setLocation('/store/payment-failed', '?error=9999&orderId=ORD-KGI-RETURN-001');
await reportStandardPaymentFailureOnReturn();
expect(apiPost).toHaveBeenCalled();
});
it('저장분이 없으면 아무것도 보내지 않는다', async () => {
const apiPost = vi.fn();
windowRecord().G7Core = { api: { post: apiPost } };
setLocation('/shop/checkout', '?error=9999&orderId=ORD-KGI-RETURN-001');
await reportStandardPaymentFailureOnReturn();
expect(apiPost).not.toHaveBeenCalled();
});
});
@@ -4,7 +4,10 @@ import { installMypageOrderShowInjector } from './mypageOrderShowInjector';
import { installAdminOrderPaymentDisplayInjector } from './adminOrderPaymentDisplayInjector';
import { installOrderCompleteReceiptInjector } from './orderCompleteReceiptInjector';
import { installVbankInfoInjector } from './vbankInfoInjector';
import { installPaymentCloseMessageListener } from './paymentCloseMessageListener';
import {
installPaymentCloseMessageListener,
reportStandardPaymentFailureOnReturn,
} from './paymentCloseMessageListener';
import { installCheckoutJpyPaymentMethodRestrictor } from './checkoutJpyPaymentMethodRestrictor';
import { installAdminPaymentMethodBrandInjector } from './adminPaymentMethodBrandInjector';
@@ -95,6 +98,11 @@ installOrderCompleteReceiptInjector();
installVbankInfoInjector();
installPaymentCloseMessageListener();
// 결제 실패로 돌아온 화면이면 서버에 보고한다. 브라우저 리턴 콜백(PC·모바일·해외결제)은
// PG 서명도 IP 증명도 없어 주문 상태를 바꾸지 않으므로, 소유권을 대조하는 close-report 가
// 정당한 결제 실패를 기록하는 유일한 경로다. 저장해 둔 정보가 없으면 아무 일도 하지 않는다.
void reportStandardPaymentFailureOnReturn();
initPlugin();
(window as Record<string, unknown>).__SirsoftKginicis = {
@@ -76,6 +76,26 @@ function resolveApiUrl(url: string): string {
return url;
}
/**
* 결제 실패 화면으로 돌아왔을 때 보고에 쓸 컨텍스트를 남겨 두는 저장소 키.
*
* window 전역만 쓰면 결제창이 전체 페이지 이동으로 열리고 돌아올 때 컨텍스트가 소실된다.
* sessionStorage 는 같은 탭에서 외부 도메인을 다녀와도 유지되므로 함께 저장한다.
*/
const PENDING_CLOSE_STORAGE_KEY = 'g7:sirsoft-pay_kginicis:pendingClose';
/**
* sessionStorage 접근은 브라우저 설정(사이트 데이터 차단·시크릿 모드)에 따라 예외를 던진다.
* 보고는 편의 장치이므로 실패해도 결제 흐름을 막지 않는다.
*/
function safeSessionStorage(): Storage | null {
try {
return window.sessionStorage ?? null;
} catch {
return null;
}
}
export function markStandardPaymentCloseReportContext(
context: StandardPaymentCloseReportContext,
): void {
@@ -87,10 +107,99 @@ export function markStandardPaymentCloseReportContext(
...context,
reported: false,
};
// 전체 페이지 이동(모바일·PC 리턴 콜백)을 건너 살아남도록 함께 보관한다.
try {
safeSessionStorage()?.setItem(
PENDING_CLOSE_STORAGE_KEY,
JSON.stringify({ ...context, reported: false }),
);
} catch {
// 저장 실패는 무시 — 만료 자동 정리가 최종 안전망이다.
}
}
export function clearStandardPaymentCloseReportContext(): void {
delete windowRecord()[ACTIVE_STANDARD_PAYMENT_CLOSE_CONTEXT_KEY];
try {
safeSessionStorage()?.removeItem(PENDING_CLOSE_STORAGE_KEY);
} catch {
// 무시
}
}
/**
* 저장해 둔 보고용 컨텍스트를 읽습니다.
*
* @returns 저장된 컨텍스트, 없거나 형식이 깨졌으면 null
*/
function readPendingCloseFromStorage(): StandardPaymentCloseReportContext | null {
try {
const raw = safeSessionStorage()?.getItem(PENDING_CLOSE_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as StandardPaymentCloseReportContext;
return parsed && typeof parsed.oid === 'string' && parsed.oid !== '' && parsed.closeReportUrl
? parsed
: null;
} catch {
return null;
}
}
/**
* 결제 실패 화면으로 돌아왔으면 저장해 둔 정보로 서버에 보고합니다.
*
* 브라우저 리턴 콜백(PC·모바일·해외결제)은 PG 서명도 IP 증명도 없어 주문 상태를 바꾸지 않는다.
* 소유권을 대조하는 close-report 만이 정당한 결제 실패를 기록할 수 있으므로, 실패 화면에
* 도착한 이 시점에 그 경로로 보고한다. 플러그인 부팅 시 1회 호출한다.
*/
export async function reportStandardPaymentFailureOnReturn(): Promise<void> {
const pending = readPendingCloseFromStorage();
if (!pending) {
return;
}
let params: URLSearchParams;
try {
params = new URLSearchParams(window.location.search);
} catch {
return;
}
const orderIdInUrl = params.get('orderId') ?? '';
// 저장분과 화면의 주문번호가 다르면 이번 이동과 무관한 잔여물이다.
if (orderIdInUrl !== '' && orderIdInUrl !== pending.oid) {
return;
}
// 결제 완료 화면으로 돌아왔으면 보고 대상이 아니다 — 성공 확정은 서버가 이미 했다.
if (/\/(complete|success)(\/|$|\?)/.test(window.location.pathname)) {
clearStandardPaymentCloseReportContext();
return;
}
const code = params.get('error') ?? '';
const message = params.get('message') ?? '';
// 실패 표시가 전혀 없으면 결제창을 열기만 하고 돌아온 경우일 수 있다 — 판단하지 않는다.
if (code === '' && orderIdInUrl === '') {
return;
}
// window 전역 컨텍스트를 복원해 기존 보고 경로를 그대로 태운다 (중복 보고 가드 포함).
windowRecord()[ACTIVE_STANDARD_PAYMENT_CLOSE_CONTEXT_KEY] = { ...pending, reported: false };
await reportStandardPaymentWindowClosed(
message !== '' ? message : code || 'payment-window-closed',
false,
);
}
export function markStandardPaymentCompletionStarted(): void {
@@ -99,9 +208,12 @@ export function markStandardPaymentCompletionStarted(): void {
export async function reportStandardPaymentWindowClosed(
reason = 'payment-window-closed',
requireCheckoutPage = true,
): Promise<void> {
// 결제창 닫힘 메시지 경로는 체크아웃 화면에서만 유효하다. 반면 리턴 콜백에서 돌아온 경우는
// 상점이 실패 주소를 바꿨을 수 있어 화면 경로로 판정할 수 없다 — 그때는 이 검사를 건너뛴다.
const context = getActiveStandardPaymentCloseContext();
if (!context || context.reported || !isCheckoutPage()) {
if (!context || context.reported || (requireCheckoutPage && !isCheckoutPage())) {
return;
}
@@ -198,8 +198,9 @@ class CbtCallbackController
'result_msg' => $resultMsg,
]);
$this->orderService->failPayment($order, $resultCode, $resultMsg);
// 승인이 성립하지 않았다 — 실제로 결제된 것이 없으므로 주문 상태를 바꾸지 않는다.
// 브라우저 실패(authResultCode)는 이미 무변경으로 하드닝되어 있으나, 서버 승인
// 실패 분기는 여전히 비인증 입력(sid + oid)으로 도달할 수 있어 같은 통로가 열려 있었다.
return redirect($this->resolveFailUrl($this->buildCbtFailureRedirectParams(
(string) $resultCode,
(string) $resultMsg,
@@ -203,8 +203,10 @@ class MobileCallbackController
'P_RMESG1' => $result['P_RMESG1'] ?? '',
]);
$this->orderService->failPayment($order, $resultStatus, $result['P_RMESG1'] ?? '');
// 승인이 성립하지 않았다 — 실제로 결제된 것이 없으므로 주문 상태를 바꾸지 않는다.
// 비인증 브라우저 콜백이라 주문번호(P_OID)와 P_TID 는 요청자가 고른 값이고,
// 그 조합으로 만든 "승인 실패" 로 타인의 결제대기 주문을 취소시킬 수 있다.
// 정당한 결제창 닫힘은 소유권을 검증하는 close-report 가 기록한다.
return redirect($this->resolveFailUrl([
'error' => $resultStatus,
'message' => $result['P_RMESG1'] ?? '',
@@ -255,8 +255,11 @@ class PaymentCallbackController
'result_msg' => $pgResponse['resultMsg'] ?? '',
]);
$this->orderService->failPayment($order, $pgResultCode, $pgResponse['resultMsg'] ?? '');
// 승인이 성립하지 않았다 — 실제로 결제된 것이 없으므로 주문 상태를 바꾸지 않는다.
// 이 엔드포인트는 PG 서명도 IP 증명도 없는 비인증 브라우저 POST 이고 주문번호(moid)도
// 요청자가 고른 값이라, "승인 실패" 는 남의 주문번호와 위조 authToken 을 보내기만 해도
// 만들어낼 수 있는 상태다. 그것을 근거로 실패 처리하면 타인의 결제대기 주문이 취소된다.
// 정당한 결제창 닫힘은 소유권을 검증하는 close-report 가 기록한다.
return redirect($this->resolveFailUrl([
'error' => $pgResultCode,
'message' => $pgResponse['resultMsg'] ?? '',
@@ -294,10 +294,53 @@ class PaymentCallbackControllerTest extends PluginTestCase
], 200),
]);
$statusBefore = $order->order_status;
$response = $this->post('/plugins/sirsoft-pay_kginicis/payment/callback', $params);
$response->assertRedirect();
$this->assertStringContainsString('error=9999', $response->headers->get('Location'));
// 계약 변경(KVE-2026-2018 형제): 승인이 성립하지 않은 비인증 브라우저 콜백은
// 주문 상태를 바꾸지 않는다. 정당한 결제창 닫힘은 close-report 가 기록한다.
$order->refresh();
$this->assertEquals($statusBefore, $order->order_status, '비인증 콜백이 주문 상태를 바꿨습니다.');
$this->assertNotEquals(OrderStatusEnum::CANCELLED, $order->order_status);
}
/**
* 무인증 위조 콜백으로 타인의 결제대기 주문을 취소할 수 없다.
*
* 공격자는 로그인하지 않고 피해자의 주문번호(moid)와 위조 authToken 만으로 이 엔드포인트를
* 친다. 서버 승인은 당연히 실패하는데, 그 실패를 주문 실패로 오인 처리하면 남의 주문이 취소된다.
*/
public function test_forged_unauthenticated_callback_cannot_cancel_another_users_order(): void
{
$victimOrder = $this->createTestOrder(50000);
$this->mockPluginSettings();
$params = $this->makeCallbackParams($victimOrder->order_number, 50000);
$params['authToken'] = 'FORGED_AUTH_TOKEN';
Http::fake([
'fcstdpay.inicis.com/api/payAuth' => Http::response([
'resultCode' => '9999',
'resultMsg' => '인증정보가 올바르지 않습니다',
], 200),
'*' => Http::response('OK', 200),
]);
$response = $this->post('/plugins/sirsoft-pay_kginicis/payment/callback', $params);
$response->assertRedirect();
$victimOrder->refresh();
$this->assertEquals(
OrderStatusEnum::PENDING_ORDER,
$victimOrder->order_status,
'무인증 위조 콜백이 피해자의 주문을 취소시켰습니다.'
);
$this->assertArrayNotHasKey('payment_failure_code', $victimOrder->order_meta ?? []);
}
public function test_auth_callback_sends_net_cancel_and_redirects_to_fail_on_authorize_http_error(): void
@@ -0,0 +1,102 @@
/**
* KG이니시스 플러그인 Playwright E2E 설정.
*
* 코어 `playwright.config.ts` 와 동일한 base URL 해석 우선순위를 따른다 — 플러그인도 활성 호스트가
* 가변(개발자/CI/운영 환경별로 다른 도메인)이므로 하드코딩 회피.
*
* Base URL 해석:
* 1. PLAYWRIGHT_BASE_URL 환경변수 (CI/명시적 오버라이드)
* 2. .env (코어 루트) 의 APP_URL — 단 localhost 류는 fallback 부적합
* 3. 그 외 — 명시 에러
*
* 실행 예시:
* PowerShell — $env:PLAYWRIGHT_BASE_URL='https://g7.dev'; npm run test:e2e
* Bash — PLAYWRIGHT_BASE_URL=https://g7.dev npm run test:e2e
*
* 플러그인은 코어 fixture 의 `issueToken` / `authenticatePage` 헬퍼를 재사용 — 권한 식별자는
* `sirsoft-pay_kginicis.*` 등 임의 string.
*/
import { defineConfig, devices } from '@playwright/test';
import { readFileSync, existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// ESM 환경(package.json "type": "module")에서는 __dirname 이 정의되지 않으므로
// import.meta.url 로 재구성한다.
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* 코어 루트 (artisan / .env / Playwright 산출물의 기준 경로).
*
* 확장 config 는 확장 디렉토리에서 실행되지만, 산출물을 그 안에 쓰면 Windows 에서
* `plugin:update` 의 디렉토리 이동이 열린 핸들에 걸려 실패한다.
* 산출물은 코어 루트 아래로 모아 update 경로와 분리한다 (.gitignore 가 이미 덮는 위치).
*/
const CORE_ROOT = process.env.G7_ROOT || resolve(__dirname, '../../../../../');
/** 확장별 산출물 격리 — 확장끼리 리포트를 덮어쓰지 않도록 slug 로 네임스페이스. */
const ARTIFACT_SLUG = 'plugins/sirsoft-pay_kginicis';
function readEnvFile(filePath: string, key: string): string | null {
if (!existsSync(filePath)) return null;
const content = readFileSync(filePath, { encoding: 'utf-8' });
const pattern = new RegExp(`^${key}=(.*)$`, 'm');
const match = content.match(pattern);
if (!match) return null;
let value = match[1].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
return value || null;
}
function resolveBaseUrl(): string {
if (process.env.PLAYWRIGHT_BASE_URL) {
return process.env.PLAYWRIGHT_BASE_URL;
}
const appUrl = readEnvFile(resolve(CORE_ROOT, '.env'), 'APP_URL');
if (appUrl && !/^https?:\/\/localhost(:\d+)?\/?$/i.test(appUrl)) {
return appUrl;
}
throw new Error(
'KG이니시스 플러그인 E2E base URL 미설정. PLAYWRIGHT_BASE_URL 환경변수를 지정하거나 코어 .env 의 APP_URL 을 활성 호스트로 설정하세요.'
);
}
export default defineConfig({
testDir: './specs',
outputDir: resolve(CORE_ROOT, 'test-results', ARTIFACT_SLUG),
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { outputFolder: resolve(CORE_ROOT, 'playwright-report', ARTIFACT_SLUG), open: 'never' }],
['list'],
],
use: {
// 실제 브라우저 UA 를 지정한다.
//
// Playwright 기본 UA 에는 `HeadlessChrome` 이 들어 있어 `SeoMiddleware` 의 봇 판정에
// 걸린다. 그러면 공개 사용자 경로 요청이 SPA 가 아니라 **검색엔진용 정적 HTML** 을
// 받는다 — `window.G7Core` 도 엔진 스크립트도 없는 화면이다. 그 상태에서도 서버가
// 심은 글꼴·아이콘은 정상이라 "페이지가 잘 뜬다" 로 보이고, 정작 재려던 SPA 동작
// (테마 적용·핸들러·확장 번들 로드)은 한 번도 실행되지 않은 채 통과한다.
//
// 봇 경로를 의도적으로 재는 spec 은 UA 가 아니라 `?_escaped_fragment_=` 로 유발하므로
// 여기서 실제 UA 를 고정해도 그 검증은 그대로 동작한다.
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
baseURL: resolveBaseUrl(),
// spec 이 한국어 화면 문구를 단언하므로 로케일을 고정한다.
// 로케일 우선순위는 localStorage g7_locale → 서버 응답값 → 'ko' 이고, 서버값은 미인증
// 요청에서 Accept-Language 로 결정된다(SetLocale 미들웨어). Playwright 의 locale 옵션이
// 그 헤더를 만들므로, 지정하지 않으면 첫 페이지 로드가 en-US 로 나가 화면이 영어로 렌더되고
// 엔진이 그 값을 localStorage 에 저장해 이후 인증해도 세션 전체가 영어로 고정된다.
locale: 'ko-KR',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
ignoreHTTPSErrors: true,
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
@@ -0,0 +1,114 @@
/**
* E2E: 결제 실패 화면 복귀 시 close-report 보고 회귀 가드
*
* @scenario payment_failure_return_reports_to_close_report
* @effects close_report_posted_on_failure_return, no_report_without_pending_context,
* no_report_on_success_return
*
* 배경: 브라우저 리턴 콜백(PC·모바일·해외결제)은 PG 서명도 IP 증명도 없고 주문번호가
* 요청자가 고른 값이라, 승인 실패를 근거로 주문을 취소하면 남의 주문번호를 아는 것만으로
* 그 주문이 취소된다. 그래서 콜백에서 주문 상태 변경을 걷어냈고, 정당한 결제 실패는
* 구매자 정보를 대조하는 close-report 가 기록하도록 바꿨다.
*
* 결제창은 전체 페이지 이동으로 열리고 돌아와 JS 컨텍스트가 소실되므로, 결제 요청 직전에
* sessionStorage 에 남긴 정보로 실패 화면에서 보고한다. 이 spec 은 그 복귀 보고가 실제
* 브라우저에서 발화하는지를 지킨다 — 발화하지 않으면 정당한 결제 실패가 어디에도 기록되지
* 않는데, 화면에는 아무 증상도 나타나지 않는다.
*
* PG 결제창 자체는 자동화할 수 없으므로, 결제 요청 직전 상태(sessionStorage 마커)를 직접
* 심어 "실패 화면으로 돌아온 순간" 부터를 재현한다. 서버 응답(403/404 등)은 이 spec 의
* 관심사가 아니다 — 브라우저가 보고를 보내는지가 계약이다.
*/
import { expect, test } from '@playwright/test';
const STORAGE_KEY = 'g7:sirsoft-pay_kginicis:pendingClose';
const CLOSE_REPORT_URL = '/api/plugins/sirsoft-pay_kginicis/payment/close-report';
const ORDER_NUMBER = 'E2E-KGI-FAILRETURN-0001';
/**
* 결제 요청 직전에 저장되는 보고용 컨텍스트를 심는다.
*/
const PENDING_CONTEXT = {
closeReportUrl: '/plugins/sirsoft-pay_kginicis/payment/close-report',
oid: ORDER_NUMBER,
price: 10000,
buyer_email: 'e2e-buyer@example.com',
buyer_phone: '01012345678',
payment_method: 'card',
reported: false,
};
test.describe('KG이니시스 결제 실패 복귀 보고', () => {
test('실패 화면으로 돌아오면 close-report 로 보고한다', async ({ page }) => {
// 오리진을 확보한 뒤 sessionStorage 를 심는다 (하드코딩 회피).
await page.goto('/shop/checkout');
await page.evaluate(
([key, value]) => window.sessionStorage.setItem(key, value),
[STORAGE_KEY, JSON.stringify(PENDING_CONTEXT)] as const,
);
const reportRequest = page.waitForRequest(
(request) => request.url().includes(CLOSE_REPORT_URL) && request.method() === 'POST',
{ timeout: 20_000 },
);
// 결제 승인이 거절되어 실패 URL 로 돌아온 상황.
await page.goto(`/shop/checkout?error=9999&message=fail&orderId=${ORDER_NUMBER}`);
const request = await reportRequest;
const body = request.postDataJSON() as Record<string, unknown>;
expect(body.oid).toBe(ORDER_NUMBER);
expect(body.price).toBe(10000);
// 소유권 대조에 쓰이는 구매자 정보가 실려야 서버가 자격을 판정할 수 있다.
expect(body.buyer_email).toBe('e2e-buyer@example.com');
expect(body.buyer_phone).toBe('01012345678');
});
test('저장해 둔 정보가 없으면 보고하지 않는다', async ({ page }) => {
await page.goto('/shop/checkout');
await page.evaluate((key) => window.sessionStorage.removeItem(key), STORAGE_KEY);
let reported = false;
page.on('request', (request) => {
if (request.url().includes(CLOSE_REPORT_URL) && request.method() === 'POST') {
reported = true;
}
});
await page.goto(`/shop/checkout?error=9999&orderId=${ORDER_NUMBER}`);
await page.waitForLoadState('networkidle', { timeout: 20_000 });
// 결제창을 연 적이 없는데 실패 파라미터만 붙은 주소로 들어온 경우다 — 보고 대상이 아니다.
expect(reported).toBe(false);
});
test('보고 후에는 저장분이 지워져 중복 보고하지 않는다', async ({ page }) => {
await page.goto('/shop/checkout');
await page.evaluate(
([key, value]) => window.sessionStorage.setItem(key, value),
[STORAGE_KEY, JSON.stringify(PENDING_CONTEXT)] as const,
);
await page.goto(`/shop/checkout?error=9999&orderId=${ORDER_NUMBER}`);
await page.waitForLoadState('networkidle', { timeout: 20_000 });
const remaining = await page.evaluate((key) => window.sessionStorage.getItem(key), STORAGE_KEY);
expect(remaining).toBeNull();
// 같은 주소를 다시 열어도 보고가 반복되지 않는다.
let reportedAgain = false;
page.on('request', (request) => {
if (request.url().includes(CLOSE_REPORT_URL) && request.method() === 'POST') {
reportedAgain = true;
}
});
await page.goto(`/shop/checkout?error=9999&orderId=${ORDER_NUMBER}`);
await page.waitForLoadState('networkidle', { timeout: 20_000 });
expect(reportedAgain).toBe(false);
});
});
@@ -1,6 +1,6 @@
# audit:allow test-scenario-coverage reason: Phase 2 SSoT — 본 매니페스트는 KG 이니시스 콜백 보안 매트릭스의 명세 문서. 단위 테스트(ValidatesTimestampFreshnessTest 13건) 가 freshness 핵심 경로를 커버하며, replay/net-cancel 의 cross product 전체 자동 검증은 Phase 3 (침투 PoC) 에서 처리.
feature: 콜백 보안 방어 (replay attack / signature timestamp freshness / post-approve net cancel)
feature: 콜백 보안 방어 (replay attack / signature timestamp freshness / post-approve net cancel / 비인증 브라우저 콜백의 주문 변조)
description: |
KG 이니시스 결제 콜백 처리 경로의 3가지 핵심 보안 위협에 대한 방어 매트릭스.
@@ -18,8 +18,9 @@ description: |
axes:
context: [authCallback_card, vbankNotify, mobileApproval_card, mobileVbankNotify, cbtCallback]
threat: [replay, stale_timestamp, post_approve_failure]
callback_state: [first_arrival, second_arrival_same_tid, signature_expired, signature_fresh, domain_succeeds, domain_fails]
threat: [replay, stale_timestamp, post_approve_failure, forged_browser_callback]
callback_state: [first_arrival, second_arrival_same_tid, signature_expired, signature_fresh, domain_succeeds, domain_fails, authorize_not_yet_attempted, authorize_failed, authorize_succeeded]
caller: [pg_server_verified, unauthenticated_browser, ownership_verified_buyer]
exclusions:
- { threat: replay, callback_state: signature_expired, reason: "replay 시나리오는 timestamp 와 무관" }
@@ -38,6 +39,26 @@ exclusions:
- { threat: post_approve_failure, context: vbankNotify, reason: "vbank 입금통보는 post-approve 단계 없음 (PG 측 비동기 입금)" }
- { threat: post_approve_failure, context: mobileVbankNotify, reason: "vbank 입금통보는 post-approve 단계 없음" }
- { threat: post_approve_failure, context: cbtCallback, reason: "CBT 는 별도 일본 결제 API — net cancel 로직 동일 아님 (수동 정산 분기)" }
- { threat: forged_browser_callback, callback_state: first_arrival, reason: "위조 콜백은 도착 차수가 아니라 승인 성립 여부로 갈린다" }
- { threat: forged_browser_callback, callback_state: second_arrival_same_tid, reason: "동일" }
- { threat: forged_browser_callback, callback_state: signature_expired, reason: "위조 축은 timestamp 신선도 단계가 아니다" }
- { threat: forged_browser_callback, callback_state: signature_fresh, reason: "동일" }
- { threat: forged_browser_callback, callback_state: domain_succeeds, reason: "도메인 처리 이전에 승인 성립 여부로 판정한다" }
- { threat: forged_browser_callback, callback_state: domain_fails, reason: "동일" }
- { threat: replay, callback_state: authorize_not_yet_attempted, reason: "승인 성립 축은 위조 콜백 전용 — replay 는 도착 차수 분기" }
- { threat: replay, callback_state: authorize_failed, reason: "동일" }
- { threat: replay, callback_state: authorize_succeeded, reason: "동일" }
- { threat: stale_timestamp, callback_state: authorize_not_yet_attempted, reason: "timestamp 는 승인 이전 별개 게이트" }
- { threat: stale_timestamp, callback_state: authorize_failed, reason: "동일" }
- { threat: stale_timestamp, callback_state: authorize_succeeded, reason: "동일" }
- { threat: post_approve_failure, callback_state: authorize_not_yet_attempted, reason: "정의상 승인 이후 분기" }
- { threat: post_approve_failure, callback_state: authorize_failed, reason: "동일" }
- { threat: post_approve_failure, callback_state: authorize_succeeded, reason: "post-approve 실패는 domain_fails 로 표현한다" }
- { caller: ownership_verified_buyer, threat: replay, reason: "close-report 는 결제창 콜백이 아니라 별도 소유권 검증 엔드포인트" }
- { caller: ownership_verified_buyer, threat: stale_timestamp, reason: "동일 — PG 서명 타임스탬프를 받지 않는다" }
- { caller: ownership_verified_buyer, threat: post_approve_failure, reason: "동일 — 승인 단계가 없다" }
- { caller: pg_server_verified, threat: forged_browser_callback, reason: "위조 축의 전제가 비인증 브라우저 입력이다" }
- { caller: unauthenticated_browser, threat: stale_timestamp, reason: "타임스탬프 신선도는 PG 서명 페이로드 축" }
effects:
# Replay defense — PreventsReplayCallback trait
@@ -63,6 +84,17 @@ effects:
- timestamp_long_length_rejected
- signature_endpoint_returns_422_on_stale
# 비인증 브라우저 콜백의 주문 변조 차단 (KVE-2026-2018 형제)
- unauthenticated_callback_cannot_cancel_another_users_order
- browser_result_code_alone_never_transitions_order_state
- pc_auth_callback_failure_branch_does_not_call_fail_payment
- mobile_callback_failure_branch_does_not_call_fail_payment
- cbt_callback_server_authorize_failure_does_not_call_fail_payment
- pg_side_net_cancel_is_kept_because_it_targets_the_pg_not_the_order
- legitimate_failure_is_recorded_by_ownership_verified_close_report
- close_report_rejects_buyer_mismatch
- normal_approval_still_completes_the_order
# Post-approve net cancel
- post_approve_failure_triggers_auto_cancel
- net_cancel_sends_pg_cancel_api_call
@@ -71,6 +103,7 @@ effects:
test_files:
- plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Concerns/ValidatesTimestampFreshnessTest.php
- plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/PaymentCallbackControllerTest.php
manual_verification:
- description: "Replay PoC — vbankNotify 두 번 전송"
@@ -136,6 +136,11 @@ OS 판별 후 `executeCliWindows()`/`executeCliLinux()` 로 분기 → CLI 인
| 동일 `transaction_id` 콜백을 매번 재처리 | `PreventsReplayCallback::wasAlreadyPaid()` 로 이미 `paid` 상태면 멱등 응답 | KCP 서버의 재전송·사용자의 새로고침으로 같은 콜백이 두 번 오면 결제완료 알림·마일리지가 중복 적립될 수 있다 |
| 에스크로 공통통보의 `tx_cd`/`cl_status` 매핑을 컨트롤러 밖(리스너 등)에서 다시 판정 | `EscrowCommonNotifyController` 의 매핑표(§핵심 흐름)를 SSoT 로 유지 | 판정 로직이 두 곳에 있으면 KCP 가 새 `cl_status` 값을 보낼 때 한쪽만 갱신되어 조용히 어긋난다 |
| 라이브 사이트 키(`live_site_key`)를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 결제창 요청을 위조할 수 있다 |
| 승인 성립 전 실패 분기(브라우저 결과코드·금액 불일치·CLI 승인 거절)에서 `failPayment()` 호출 | 로그 + `resolveFailUrl()` 만. 주문 상태는 건드리지 않는다 | `authCallback` 은 PG 서명도 IP 증명도 없는 비인증 브라우저 POST 이고 `ordr_idxx` 도 요청자가 고른 값이다. 승인 실패는 남의 주문번호와 위조 암호문만으로 만들어낼 수 있으므로, 그것을 근거로 실패 처리하면 타인의 결제대기 주문이 취소된다 |
| catch 블록에서 `$approvedTno` 확인 없이 주문을 실패 처리 | `hasApproval($approvedTno)` 가 참일 때만 — tno 는 승인 성공 후에만 채워진다 | 승인 전에 터진 예외까지 반영하면 위 금지 패턴과 같은 통로가 catch 경로로 다시 열린다 |
| 정당한 결제 실패 기록을 콜백에서 처리 | 소유권을 검증하는 `close-report`(`requestMatchesOrderBuyer`) 경유 | 구매자 이메일·전화 대조를 통과한 요청만 주문 상태를 바꿔야 한다 |
| 결제창 컨텍스트(구매자 정보)를 메모리에만 보관 | `rememberPendingClose()` 로 sessionStorage 에 남기고, 부팅 시 `reportPaymentFailureOnReturn()` 으로 보고 | 결제창은 전체 페이지 이동으로 열리고 돌아와 JS 컨텍스트가 소실된다. 승인 거절은 fail URL 리다이렉트로 끝나므로, 남겨 둔 정보가 없으면 정당한 결제 실패가 어디에도 기록되지 않는다 |
| 실패 화면에서 보고가 닿지 못한 주문을 방치 | 이커머스 모듈의 만료 주문 자동 정리가 최종 안전망 | 브라우저를 바로 닫으면 보고가 나가지 않는다. 두 경로가 함께 있어야 선차감 마일리지가 무기한 묶이지 않는다 |
<!-- @intent END -->
## 7. 테스트 실행
@@ -6,8 +6,15 @@
## [1.0.4] - 2026-08-31
### Security
- 제3자가 남의 주문번호만 알면 결제창을 거치지 않고도 그 주문을 취소시킬 수 있던 문제를 수정했습니다. 결제 결과 콜백은 로그인도 서명 확인도 거치지 않는 경로여서, 위조한 결제 정보를 보내 승인을 일부러 실패시키면 그 주문이 결제 실패로 처리되었습니다. 이제 실제 결제 승인이 이루어진 뒤의 실패만 주문에 반영하며, 승인 전 단계의 실패는 결제 화면으로 되돌려 보내기만 합니다. 구매자가 결제창을 닫아 생기는 정상적인 결제 실패는 종전처럼 기록됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2018)
- 같은 결제 결과 콜백에서 가상계좌 경로를 통해서도 남의 주문을 건드릴 수 있던 문제를 수정했습니다. 결제수단을 가상계좌라고 주장하는 값을 요청에 섞으면, 카드로 주문한 건도 결제사 확인을 거치지 않는 경로로 흘러 그 주문이 취소되거나 위조된 입금 계좌가 그 주문에 기록될 수 있었습니다. 이제 결제수단은 요청에 실려 온 값이 아니라 주문에 저장된 값으로만 판단하며, 가상계좌 발급이 확인되지 않은 경우에는 주문을 그대로 두고 결제 화면으로 되돌려 보냅니다.
- 취소된 주문을 다시 결제 대기 상태로 되돌리는 처리가, 결제사 승인이 확인되기 전에 이루어지던 문제를 수정했습니다. 주문번호만 알면 남의 취소된 주문을 되살릴 수 있었습니다. 이제 승인이나 계좌 발급이 확인된 뒤에만 되돌립니다.
### Added
- 결제가 거절되어 실패 화면으로 돌아오면 그 사실이 자동으로 서버에 기록됩니다. 구매자 본인인지 확인한 뒤에만 주문을 실패로 처리하므로, 남의 주문번호를 아는 것만으로는 그 주문을 건드릴 수 없습니다.
- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-pay_nhnkcp",
"version": "1.0.1",
"version": "1.0.4",
"components": {
"basic": [],
"composite": [],
File diff suppressed because one or more lines are too long
@@ -4,6 +4,7 @@
| 문서 | 도메인 | 설명 |
| --- | --- | --- |
| [payment.md](payment.md) | `payment` | 브라우저 리턴 콜백·결제창 닫힘 보고·결제 재시도. 주문 상태를 바꾸는 경로가 하나뿐인 이유 |
| [vbank.md](vbank.md) | `payment` | 가상계좌 입금통보·에스크로 공통통보 수신 경로와 발신 서버(IP) 확인 |
| [admin-orders.md](admin-orders.md) | `admin` | 관리자 주문 연동 경로 전체와 각 경로의 요구 권한 |
| [transaction-status.md](transaction-status.md) | `admin` | 관리자 주문 상세의 거래 상태·취소·환불 조회 |
@@ -0,0 +1,198 @@
# Payment API 레퍼런스
> **소유**: 플러그인 `sirsoft-pay_nhnkcp`. 이 문서는 결제창과 주고받는 세 경로 — 브라우저 리턴 콜백, 결제창 닫힘 보고, 결제 재시도 준비 — 를 서술한다. NHN KCP 서버가 직접 보내는 통보(가상계좌·에스크로) 경로는 [vbank.md](vbank.md) 가 소유한다.
---
## TL;DR (5초 요약)
```text
1. 브라우저 리턴 콜백은 주문 상태를 바꾸지 않는다 — 승인 전 실패는 실패 URL 로 되돌려 보내기만 한다
2. 주문을 실패로 전이시키는 결제창 경로는 close-report 하나뿐이다 (구매자·금액 대조를 통과한 요청)
3. 승인(tno) 이후에 터진 실패만 콜백이 주문에 반영한다
4. close-report / retry 는 인증 불필요하나 구매자 대조 + 금액 대조 + IP·주문번호별 분당 20회 제한
5. 결제 성공 콜백과의 경쟁은 주문 락 + `status: ignored` 로 차단한다
```
---
## 주문 상태를 바꾸는 경로는 하나뿐이다
이 플러그인에서 **주문을 결제 실패로 전이시키는 결제창 경로는 `POST /api/plugins/sirsoft-pay_nhnkcp/payment/close-report` 하나**다.
브라우저 리턴 콜백(`POST /plugins/sirsoft-pay_nhnkcp/payment/callback`)은 PG 서명도 발신 IP 증명도 없고, 주문번호(`ordr_idxx`)·금액(`good_mny`)·결과코드(`res_cd`)가 전부 **요청자가 고른 값**이다. 그 입력을 근거로 주문을 실패 처리하면, 남의 주문번호와 임의 금액·위조 결과코드를 담은 POST 한 번으로 **타인의 결제대기 주문을 취소시킬 수 있다** (KVE-2026-2018).
그래서 콜백은 승인이 성립하기 전의 실패에서는 주문 상태를 건드리지 않고 실패 URL 로 되돌려 보내기만 한다.
| 시점 | 판정 근거 | 주문 상태 |
| --- | --- | --- |
| 금액 불일치 (승인 전) | 브라우저가 보낸 `good_mny` | **변경 없음** — 실패 URL 리다이렉트 |
| 승인 실패 (`res_cd` 비정상) | 브라우저가 보낸 결과코드 | **변경 없음** — 실패 URL 리다이렉트 |
| 승인 이후의 예외 (금액 불일치·후속 처리 실패) | KCP 승인 응답의 `tno` 존재 | **실패 처리 + 자동 취소** |
승인 여부는 `PaymentCallbackController::hasApproval()` 이 `tno`(승인 확정 시에만 채워지는 거래번호) 로 판정한다. `tno` 가 비어 있으면 승인 전에 흐름이 끊긴 것이므로 그 시점까지의 입력은 주문 상태 변경의 근거가 될 수 없다.
정당한 결제창 닫힘은 구매자 대조를 통과한 close-report 요청으로만 기록된다.
> 형제 플러그인도 같은 규약이다 — `sirsoft-pay_kginicis` · `sirsoft-tosspayments` 의 `docs/api/payment.md` 를 참조.
---
## POST /api/plugins/sirsoft-pay_nhnkcp/payment/close-report
- **라우트명**: `api.plugins.sirsoft-pay_nhnkcp.payment.close-report`
- **컨트롤러**: `Plugins\Sirsoft\PayNhnkcp\Controllers\PaymentCloseReportController@store`
- **인증/권한**: 공개 (인증 불필요 — 결제창 컨텍스트에서 호출)
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| oid | body | string | 예 | max 40 | 결제창을 닫은 대상 주문의 주문번호. 서버가 이 값으로 주문을 조회해 결제 실패/취소 이력을 기록한다. |
| price | body | integer | 예 | min 1 | 주문 결제 금액. 저장된 주문 청구액과 일치하는지 검증해 위변조된 닫힘 보고를 차단한다. |
| buyer_email | body | string | 아니오 | max 255 | 구매자 이메일. 주문 배송지의 주문자 이메일이 있으면 **일치해야 한다**(불일치·미제공 시 403). |
| buyer_phone | body | string | 아니오 | max 30 | 구매자 전화번호. 주문 배송지의 주문자 전화가 있으면 숫자만 추출해 **일치해야 한다**(불일치·미제공 시 403). |
| payment_method | body | string | 아니오 | max 50 | 사용자가 결제창에서 선택했던 간편결제 등 결제수단 식별값. 결제 메타에 병합해 어떤 수단에서 창을 닫았는지 남긴다. |
| reason | body | string | 아니오 | max 160 | 결제창 닫힘 사유 문자열. 비어 있으면 기본 문구로 대체되어 취소 이력에 기록된다. |
**요청 예시**
```http
POST /api/plugins/sirsoft-pay_nhnkcp/payment/close-report HTTP/1.1
Host: api.example.com
Accept: application/json
Content-Type: application/json
{
"oid": "ORD20260902001",
"price": 10000,
"buyer_email": "buyer@example.com",
"buyer_phone": "010-1234-5678",
"payment_method": "kakaopay",
"reason": "사용자가 결제창을 닫음"
}
```
**응답 필드** (`data` 내부)
| 필드 | 타입 | 값 | 용도/설명 |
| --- | --- | --- | --- |
| status | string | `recorded` \| `ignored` | 닫힘 보고 처리 결과. `recorded` = 주문을 `USER_CANCEL` 로 실패 처리하고 취소 이력을 남김. `ignored` = 결제 성공 콜백과의 경쟁 등으로 처리하지 않고 무시. |
| reason | string | `order_not_payable` \| `payment_already_paid` \| `callback_in_progress` | `status: ignored` 일 때만 포함되는 무시 사유. 주문이 이미 결제 가능 상태가 아님 / 결제가 이미 완료됨 / 같은 주문의 승인 콜백이 락을 쥐고 처리 중임. |
**응답 예시**
```json
{
"success": true,
"message": "성공적으로 처리되었습니다.",
"data": {
"status": "recorded"
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 403 | Forbidden | 요청의 구매자 정보(`buyer_email` / `buyer_phone`)가 주문의 주문자와 일치하지 않는 경우 |
| 404 | Not Found | `oid` 에 해당하는 주문이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우, 주문 통화가 청구 가능한 통화가 아닌 경우, 금액이 주문 청구액과 불일치하는 경우 |
| 429 | Too Many Requests | 동일 IP·`oid` 조합에서 분당 20회를 초과해 요청한 경우 |
**설명**
PC 표준결제창에서 사용자가 결제를 완료하지 않고 창을 닫았을 때 프론트엔드가 이를 서버에 보고하는 엔드포인트다. `OrderProcessingService::failPayment()` 로 주문을 `USER_CANCEL` 사유로 실패 처리하고 취소 이력을 남기며, 간편결제 선택 정보가 있으면 결제 메타에 병합한다.
인증은 불필요하지만 **이 경로가 주문 상태를 바꾸는 유일한 결제창 경로**이므로 방어가 네 겹이다 — FormRequest 검증, `oid` 기준 IP별 분당 20회 레이트리밋, 주문의 주문자 정보(이메일·전화) 대조, 주문 청구액과의 금액 대조.
승인 콜백(`authCallback`)과 같은 주문 락을 공유한다. 카드 주문은 승인 직전까지 주문 상태가 결제 전이라 상태 가드를 통과하므로, 결제가 이미 완료된 경우(`payment_already_paid`)를 별도로 차단해 결제 성공과 닫힘 보고가 경쟁할 때 옵션 상태가 어긋나는 것을 막는다. 락 획득에 실패하면 `callback_in_progress` 로 무시한다.
---
## POST /api/plugins/sirsoft-pay_nhnkcp/payment/retry
- **라우트명**: `api.plugins.sirsoft-pay_nhnkcp.payment.retry`
- **컨트롤러**: `Plugins\Sirsoft\PayNhnkcp\Controllers\PaymentRetryController@store`
- **인증/권한**: 공개 (인증 불필요 — 결제 실패 화면에서 호출)
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| oid | body | string | 예 | max 40 | 재결제할 주문의 주문번호. |
| price | body | integer | 예 | min 1 | 주문 결제 금액. 저장된 주문 청구액과 일치해야 한다. |
| buyer_email | body | string | 아니오 | max 255 | 구매자 이메일. close-report 와 동일한 주문자 대조에 사용된다. |
| buyer_phone | body | string | 아니오 | max 30 | 구매자 전화번호. 동일. |
| payment_method | body | string | 아니오 | max 50 | 재시도 시 선택한 결제수단 식별값. |
**응답 필드** (`data` 내부)
| 필드 | 타입 | 값 | 용도/설명 |
| --- | --- | --- | --- |
| status | string | `ready` \| `restored` | `ready` = 주문이 이미 결제 가능 상태라 복구할 것이 없음. `restored` = 실패/취소 상태였던 주문을 같은 주문번호로 재결제 가능하도록 되돌림. |
**응답 예시**
```json
{
"success": true,
"message": "성공적으로 처리되었습니다.",
"data": {
"status": "restored"
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 403 | Forbidden | 요청의 구매자 정보가 주문의 주문자와 일치하지 않는 경우 |
| 404 | Not Found | `oid` 에 해당하는 주문이 없는 경우 |
| 409 | Conflict | 주문이 재결제 가능한 상태가 아닌 경우 (이미 결제 완료·배송 진행 등) |
| 422 | Unprocessable Entity | 요청 파라미터 검증 위반, 청구 불가 통화, 금액 불일치 |
| 429 | Too Many Requests | 동일 IP·`oid` 조합에서 분당 20회 초과 |
**설명**
결제에 실패했거나 결제창을 닫아 취소된 주문을 **같은 주문번호로** 다시 결제할 수 있는 상태로 되돌린다. close-report 와 동일한 대조(주문자·금액·레이트리밋)를 거치므로, 남의 주문번호만으로 상태를 되돌릴 수 없다.
---
## POST /plugins/sirsoft-pay_nhnkcp/payment/callback
- **라우트명**: `web.plugins.sirsoft-pay_nhnkcp.payment.callback`
- **컨트롤러**: `Plugins\Sirsoft\PayNhnkcp\Controllers\PaymentCallbackController@authCallback`
- **인증/권한**: 공개 · CSRF 검증 제외 (KCP 표준결제창이 브라우저 POST 로 리턴) · **IP 화이트리스트 미적용** (정상 사용자의 임의 IP 에서 도달)
**요청 파라미터** (KCP 결제창이 브라우저 POST 로 전달)
| 이름 | 타입 | 필수 | 용도 |
| --- | --- | --- | --- |
| ordr_idxx | string | 예 | 주문번호 |
| res_cd | string | 예(nullable 허용) | KCP 인증 결과코드 |
| res_msg | string | 아니오 | KCP 결과 메시지 |
| enc_data / enc_info | string | 아니오 | 승인 요청에 쓰는 암호화 데이터·정보 |
| tno | string | 아니오 | 거래번호. **승인이 확정된 뒤에만 채워진다** — 주문 상태 변경 가부의 판정 기준 |
| good_mny | numeric | 아니오 | 결제 금액 (min 1) |
| use_pay_method | string | 아니오 | 사용된 결제수단 |
| nhnkcp_easy_pay_method | string | 아니오 | 간편결제 수단 식별값 (max 50) |
| bankname · bank_name · account · depositor · account_holder · va_date | string | 아니오 | 모바일 가상계좌 콜백이 평문으로 전달하는 계좌 정보 변종 키 |
**응답**
JSON 이 아니라 **리다이렉트**다. 성공 시 상점 성공 페이지로, 실패 시 실패 페이지로 이동하며 실패 사유를 쿼리스트링(`error` · `message` · `orderId`)으로 전달한다. 쿼리 규약과 예외 원문 비노출 원칙은 [vbank.md "결제 실패 리다이렉트 규약"](vbank.md) 에 있다.
고정 `error` 값: `amount_mismatch` · `callback_locked` · `cli_exception` · `confirm_failed` · `currency_not_supported` · `invalid_payment_currency` · `order_not_found` · `order_not_retryable` · `vbank_save_failed`.
이 밖에 KCP 가 돌려준 결과코드(`res_cd`)가 그대로 실리는 분기가 있다. 화면이 `error` 로 분기할 때는 위 고정값만 신뢰하고, 그 밖의 값은 미확정 실패로 다룬다.
**설명**
KCP 표준결제창이 인증을 마치고 브라우저를 통해 가맹점으로 되돌려 보내는 경로다. 서버는 여기서 승인(approve)을 요청하고, 성공하면 주문을 결제 완료로 확정한다.
이 문서 상단 **"주문 상태를 바꾸는 경로는 하나뿐이다"** 가 이 엔드포인트의 핵심 계약이다 — 승인 전에 판정되는 실패(금액 불일치·인증 결과코드 비정상)에서는 주문 상태를 바꾸지 않는다. 승인이 이미 일어난 뒤(`tno` 존재)의 실패만 주문에 반영하고, 그 경우 KCP 측에 잔존한 승인을 자동 취소한다.
이 엔드포인트를 수정할 때는 실패 분기마다 **"이 판정의 근거가 브라우저가 보낸 값인가"** 를 먼저 확인한다. 근거가 브라우저 입력뿐이면 주문 상태를 바꾸지 않는다.
@@ -14,9 +14,9 @@
| `sirsoft-pay_nhnkcp.escrow.purchase_cancelled` | action | — | `src/Controllers/EscrowCommonNotifyController.php:97` |
| `sirsoft-pay_nhnkcp.escrow.purchase_confirmed` | action | — | `src/Controllers/EscrowCommonNotifyController.php:96` |
| `sirsoft-pay_nhnkcp.payment.after_cancel` | action | KCP 결제 취소 완료 후 | `src/Services/NhnKcpApiService.php:250` |
| `sirsoft-pay_nhnkcp.payment.after_confirm` | action | KCP 결제 승인 확인 완료 후 | `src/Controllers/PaymentCallbackController.php:279` |
| `sirsoft-pay_nhnkcp.payment.after_confirm` | action | KCP 결제 승인 확인 완료 후 | `src/Controllers/PaymentCallbackController.php:276` |
| `sirsoft-pay_nhnkcp.payment.before_cancel` | action | KCP 결제 취소 API 호출 전 (본인인증 등 확장 지점) | `src/Services/NhnKcpApiService.php:229` |
| `sirsoft-pay_nhnkcp.payment.before_confirm` | action | KCP 결제 승인 확인 전 | `src/Controllers/PaymentCallbackController.php:274` |
| `sirsoft-pay_nhnkcp.payment.before_confirm` | action | KCP 결제 승인 확인 전 | `src/Controllers/PaymentCallbackController.php:271` |
<!-- @generated:hooks-published END -->
<!-- @intent START -->
@@ -1,5 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { preparePaymentRetry, reportPaymentWindowClosed } from '../paymentCloseReport';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
preparePaymentRetry,
rememberPendingClose,
reportPaymentFailureOnReturn,
reportPaymentWindowClosed,
} from '../paymentCloseReport';
function windowRecord(): Record<string, any> {
return window as unknown as Record<string, any>;
@@ -90,3 +95,116 @@ describe('paymentCloseReport', () => {
})).rejects.toThrow('Order is not retryable for NHN KCP payment.');
});
});
/**
* 결제 실패 화면 복귀 시 보고
*
* 브라우저 리턴 콜백(authCallback)은 PG 서명도 IP 증명도 없어 주문 상태를 바꾸지 않는다.
* 승인이 거절된 정당한 실패는 이 경로(소유권 대조 close-report)로만 기록된다.
*/
describe('결제 실패 화면 복귀 보고', () => {
const CONTEXT = {
closeReportUrl: '/plugins/sirsoft-pay_nhnkcp/payment/close-report',
oid: 'ORD-KCP-RETURN-001',
price: 10000,
buyer_email: 'buyer@example.com',
buyer_phone: '01012345678',
};
/**
* 화면 주소를 바꿔 결제 리턴 상황을 재현한다.
*/
function setLocation(pathname: string, search: string): void {
Object.defineProperty(window, 'location', {
configurable: true,
value: { pathname, search, origin: 'https://shop.example' },
});
}
beforeEach(() => {
window.sessionStorage.clear();
});
afterEach(() => {
delete windowRecord().G7Core;
vi.restoreAllMocks();
window.sessionStorage.clear();
});
it('실패 화면으로 돌아오면 저장해 둔 구매자 정보로 보고한다', async () => {
const apiPost = vi.fn().mockResolvedValue({ success: true });
windowRecord().G7Core = { api: { post: apiPost } };
rememberPendingClose(CONTEXT);
setLocation('/shop/checkout', '?error=9999&message=%EC%8B%A4%ED%8C%A8&orderId=ORD-KCP-RETURN-001');
await reportPaymentFailureOnReturn();
expect(apiPost).toHaveBeenCalledWith(
'/plugins/sirsoft-pay_nhnkcp/payment/close-report',
expect.objectContaining({ oid: 'ORD-KCP-RETURN-001', price: 10000 }),
);
});
it('두 번 호출해도 한 번만 보고한다', async () => {
const apiPost = vi.fn().mockResolvedValue({ success: true });
windowRecord().G7Core = { api: { post: apiPost } };
rememberPendingClose(CONTEXT);
setLocation('/shop/checkout', '?error=9999&orderId=ORD-KCP-RETURN-001');
await reportPaymentFailureOnReturn();
await reportPaymentFailureOnReturn();
expect(apiPost).toHaveBeenCalledTimes(1);
});
it('다른 주문번호로 돌아왔으면 보고하지 않는다', async () => {
const apiPost = vi.fn();
windowRecord().G7Core = { api: { post: apiPost } };
rememberPendingClose(CONTEXT);
setLocation('/shop/checkout', '?error=9999&orderId=ORD-KCP-OTHER');
await reportPaymentFailureOnReturn();
expect(apiPost).not.toHaveBeenCalled();
});
it('결제 완료 화면으로 돌아오면 보고하지 않고 저장분만 지운다', async () => {
const apiPost = vi.fn();
windowRecord().G7Core = { api: { post: apiPost } };
rememberPendingClose(CONTEXT);
setLocation('/shop/orders/ORD-KCP-RETURN-001/complete', '');
await reportPaymentFailureOnReturn();
expect(apiPost).not.toHaveBeenCalled();
expect(window.sessionStorage.getItem('g7:sirsoft-pay_nhnkcp:pendingClose')).toBeNull();
});
it('저장분이 없으면 아무것도 보내지 않는다', async () => {
const apiPost = vi.fn();
windowRecord().G7Core = { api: { post: apiPost } };
setLocation('/shop/checkout', '?error=9999&orderId=ORD-KCP-RETURN-001');
await reportPaymentFailureOnReturn();
expect(apiPost).not.toHaveBeenCalled();
});
it('실패 표시가 없으면 판단하지 않고 저장분을 남겨 둔다', async () => {
const apiPost = vi.fn();
windowRecord().G7Core = { api: { post: apiPost } };
rememberPendingClose(CONTEXT);
setLocation('/shop/checkout', '');
await reportPaymentFailureOnReturn();
expect(apiPost).not.toHaveBeenCalled();
expect(window.sessionStorage.getItem('g7:sirsoft-pay_nhnkcp:pendingClose')).not.toBeNull();
});
});
@@ -3,6 +3,7 @@
import {
PaymentCloseReportContext,
preparePaymentRetry,
rememberPendingClose,
reportPaymentWindowClosed,
} from '../paymentCloseReport';
import {
@@ -291,6 +292,11 @@ export async function requestPaymentHandler(action: any, _context?: any): Promis
await preparePaymentRetry(closeReportContext);
// 결제창은 전체 페이지 이동으로 열리고 돌아오므로, 실패 화면에서 서버에 보고할 때 쓸
// 구매자 정보를 미리 남겨 둔다. 브라우저 리턴 콜백은 인증이 없어 주문 상태를 바꾸지 않고,
// 소유권을 대조하는 close-report 만이 정당한 결제 실패를 기록할 수 있다.
rememberPendingClose(closeReportContext);
if (isMobileDevice()) {
await handleMobilePayment(G7Core, pgPaymentData, paymentMethod, isEasyPay, callbackUrl);
} else {
@@ -5,6 +5,7 @@ import { installMypageOrderShowInjector } from './mypageOrderShowInjector';
import { installAdminApplePayNoticeInjector } from './adminApplePayNoticeInjector';
import { installAdminPaymentMethodBrandInjector } from './adminPaymentMethodBrandInjector';
import { installAdminOrderPaymentDisplayInjector } from './adminOrderPaymentDisplayInjector';
import { reportPaymentFailureOnReturn } from './paymentCloseReport';
class KcpReceiptPopup {
constructor(params: { url?: string; cash_url?: string }) {
@@ -60,6 +61,11 @@ function registerHandlers(): number {
}
function initPlugin(): void {
// 결제 실패로 돌아온 화면이면 서버에 보고한다. 브라우저 리턴 콜백은 PG 서명도 IP 증명도
// 없어 주문 상태를 바꾸지 않으므로, 소유권을 대조하는 close-report 가 정당한 결제 실패를
// 기록하는 유일한 경로다. 저장해 둔 정보가 없으면 아무 일도 하지 않는다.
void reportPaymentFailureOnReturn();
const doInit = () => {
const count = registerHandlers();
@@ -81,6 +81,133 @@ export async function preparePaymentRetry(context: PaymentCloseReportContext): P
});
}
/**
* 결제 실패 화면으로 돌아왔을 때 보고에 쓸 구매자 정보를 남겨 두는 저장소 키.
*
* 결제창은 전체 페이지 이동으로 열리고 돌아오므로 JS 컨텍스트가 소실된다. sessionStorage 는
* 같은 탭에서 외부 도메인을 다녀와도 유지되므로, 결제 요청 직전에 저장해 두었다가 꺼내 쓴다.
*/
const PENDING_CLOSE_STORAGE_KEY = 'g7:sirsoft-pay_nhnkcp:pendingClose';
/**
* sessionStorage 접근은 브라우저 설정(사이트 데이터 차단·시크릿 모드)에 따라 예외를 던진다.
* 보고는 편의 장치이므로 실패해도 결제 흐름을 막지 않는다.
*/
function safeSessionStorage(): Storage | null {
try {
return window.sessionStorage ?? null;
} catch {
return null;
}
}
/**
* 결제창을 열기 직전에 보고용 컨텍스트를 저장합니다.
*
* @param context 결제창 닫힘 보고에 필요한 주문·구매자 정보
*/
export function rememberPendingClose(context: PaymentCloseReportContext): void {
const storage = safeSessionStorage();
if (!storage) {
return;
}
try {
storage.setItem(PENDING_CLOSE_STORAGE_KEY, JSON.stringify(context));
} catch {
// 저장 실패는 무시 — 만료 자동 정리가 최종 안전망이다.
}
}
/**
* 저장해 둔 보고용 컨텍스트를 지웁니다.
*/
export function forgetPendingClose(): void {
const storage = safeSessionStorage();
if (!storage) {
return;
}
try {
storage.removeItem(PENDING_CLOSE_STORAGE_KEY);
} catch {
// 무시
}
}
/**
* 저장해 둔 보고용 컨텍스트를 읽습니다.
*
* @returns 저장된 컨텍스트, 없거나 형식이 깨졌으면 null
*/
function readPendingClose(): PaymentCloseReportContext | null {
const storage = safeSessionStorage();
if (!storage) {
return null;
}
try {
const raw = storage.getItem(PENDING_CLOSE_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as PaymentCloseReportContext;
return parsed && typeof parsed.oid === 'string' && parsed.oid !== '' ? parsed : null;
} catch {
return null;
}
}
/**
* 결제 실패 화면으로 돌아왔으면 저장해 둔 정보로 서버에 보고합니다.
*
* 브라우저 리턴 콜백(`authCallback`)은 PG 서명도 IP 증명도 없어 주문 상태를 바꾸지 않는다.
* 소유권을 대조하는 close-report 만이 정당한 결제 실패를 기록할 수 있으므로, 실패 화면에
* 도착한 이 시점에 그 경로로 보고한다. 플러그인 부팅 시 1회 호출한다.
*/
export async function reportPaymentFailureOnReturn(): Promise<void> {
const pending = readPendingClose();
if (!pending) {
return;
}
let params: URLSearchParams;
try {
params = new URLSearchParams(window.location.search);
} catch {
return;
}
const orderIdInUrl = params.get('orderId') ?? '';
// 저장분과 화면의 주문번호가 다르면 이번 이동과 무관한 잔여물이다.
if (orderIdInUrl !== '' && orderIdInUrl !== pending.oid) {
return;
}
// 결제 완료 화면으로 돌아왔으면 보고 대상이 아니다 — 성공 확정은 서버가 이미 했다.
if (/\/(complete|success)(\/|$|\?)/.test(window.location.pathname)) {
forgetPendingClose();
return;
}
const code = params.get('error') ?? '';
const message = params.get('message') ?? '';
// 실패 표시가 전혀 없으면 결제창을 열기만 하고 돌아온 경우일 수 있다 — 판단하지 않는다.
if (code === '' && orderIdInUrl === '') {
return;
}
// 중복 보고를 막기 위해 요청 전에 먼저 지운다.
forgetPendingClose();
await reportPaymentWindowClosed(pending, message !== '' ? message : code);
}
export async function reportPaymentWindowClosed(
context: PaymentCloseReportContext,
reason = 'kcp-window-closed',
@@ -200,18 +200,18 @@ class PaymentCallbackController
$callbackLock = $this->acquireOrderCallbackLock('authCallback', $ordrIdxx);
$order = $order->fresh('payment') ?? $order;
if ($order->order_status === OrderStatusEnum::CANCELLED) {
$restored = $this->restoreRetryableKcpOrder($order, $goodMny > 0 ? $goodMny : null);
if (! $restored) {
Log::warning('KCP: cancelled order is not retryable', ['ordr_idxx' => $ordrIdxx]);
// 취소 주문 재결제 — 여기서는 되살리지 않고 "되살릴 수 있는가" 만 읽는다.
// 되살리기는 상태 변경이고 이 시점의 입력은 전부 비인증 브라우저 값이라,
// 여기서 수행하면 주문번호만 아는 제3자가 남의 취소 주문을 결제대기로
// 되돌릴 수 있다. 실제 되살리기는 승인·발급이 확정된 뒤에 수행한다.
if ($order->order_status === OrderStatusEnum::CANCELLED
&& ! $this->isRetryableKcpFailure($order, $order->payment)) {
Log::warning('KCP: cancelled order is not retryable', ['ordr_idxx' => $ordrIdxx]);
return redirect($this->resolveFailUrl([
'error' => 'order_not_retryable',
'orderId' => $ordrIdxx,
]));
}
$order = $order->fresh('payment') ?? $order;
return redirect($this->resolveFailUrl([
'error' => 'order_not_retryable',
'orderId' => $ordrIdxx,
]));
}
if (($order->payment?->isPaid() ?? false) || $order->order_status === OrderStatusEnum::PAYMENT_COMPLETE) {
@@ -252,21 +252,20 @@ class PaymentCallbackController
'actual' => $goodMny,
]);
$failedOrder = $this->orderService->failPayment($order, 'AMOUNT_MISMATCH', 'KCP callback amount mismatch');
$this->markKcpPaymentFailureRecord(
$failedOrder,
'AMOUNT_MISMATCH',
'KCP callback amount mismatch',
'amount_mismatch',
);
// 승인 전이다 — 이 시점의 입력은 전부 브라우저가 보낸 값이고 이 엔드포인트는
// PG 서명도 IP 증명도 없다. 주문번호도 요청자가 고른 값이므로, 금액 불일치를
// 근거로 주문을 실패 처리하면 남의 주문번호와 임의 금액만으로 그 주문을
// 취소시킬 수 있다. 상태는 바꾸지 않고 결제창으로 되돌린다.
// 정당한 결제창 닫힘은 소유권을 검증하는 close-report 가 기록한다.
return redirect($this->resolveFailUrl(['error' => 'amount_mismatch', 'orderId' => $ordrIdxx]));
}
// 가상계좌: 계좌 발급 완료 처리 (실제 입금은 vbankNotify에서 처리)
// KCP 콜백의 use_pay_method=VCNT 또는 주문의 payment_method=vbank 로 감지
$isVbank = ($validated['use_pay_method'] ?? '') === 'VCNT'
|| (bool) $order->payment?->isVirtualAccount();
// 분기 판정은 주문에 저장된 결제수단만 본다 — 요청의 use_pay_method 를 함께 보면
// 비인증 브라우저가 use_pay_method=VCNT 한 줄로 카드 주문을 가상계좌 분기로 몰아
// 승인 검증이 없는 경로를 고를 수 있다 (KVE-2026-2018 동일 계열).
// 결제창은 결제수단을 확정한 뒤 열리므로 정상 흐름은 주문 값만으로 판정된다.
$isVbank = (bool) $order->payment?->isVirtualAccount();
if ($isVbank) {
return $this->handleVbankIssued($validated, $order, $encData, $encInfo, $ordrIdxx, $custIp, $request);
}
@@ -287,14 +286,11 @@ class PaymentCallbackController
'res_msg' => $pgResponse['res_msg'] ?? '',
]);
$failedOrder = $this->orderService->failPayment($order, $pgResCd, $pgResponse['res_msg'] ?? '');
$this->markKcpPaymentFailureRecord(
$failedOrder,
$pgResCd,
$pgResponse['res_msg'] ?? '',
'approval_failed',
);
// 승인이 성립하지 않았다 — 실제로 결제된 것이 없으므로 주문 상태를 바꾸지 않는다.
// 이 엔드포인트는 비인증 브라우저 POST 라 "승인 실패" 는 공격자가 남의 주문번호와
// 위조 암호문을 보내기만 해도 만들어낼 수 있는 상태다. 그것을 근거로 실패 처리하면
// 타인의 결제대기 주문을 임의로 취소시킬 수 있다 (KVE-2026-2018).
// 실제 승인이 일어난 뒤의 실패만 아래 catch 블록에서 주문에 반영한다.
return redirect($this->resolveFailUrl([
'error' => $pgResCd,
'message' => $pgResponse['res_msg'] ?? '',
@@ -324,6 +320,16 @@ class PaymentCallbackController
$approvedTno = $tno;
$approvedAmtForCancel = $approvedAmt;
// 승인이 확정된 뒤에만 취소 주문을 되살린다. 실패하면 예외로 아래 catch 에 넘겨
// PG 잔존 승인을 자동 취소한다(사용자 환불 보장).
if ($order->order_status === OrderStatusEnum::CANCELLED) {
if (! $this->restoreRetryableKcpOrder($order, $approvedAmt)) {
throw new \RuntimeException('cancelled order is not retryable after approval');
}
$order = $order->fresh('payment') ?? $order;
}
$isEscrow = ($pgResponse['escw_yn'] ?? '') === 'Y';
$easyPayMeta = $this->resolveEasyPayMeta($validated);
@@ -397,7 +403,10 @@ class PaymentCallbackController
// Approve 가 이미 KCP 측에서 발생했으면 자동 취소로 PG 잔존 승인 해제
$this->autoCancelIfApproved($approvedTno, $ordrIdxx, $approvedAmtForCancel, 'amount_mismatch');
if ($order instanceof Order) {
// 주문 상태를 바꾸는 것은 KCP 승인이 실제로 일어난 뒤의 실패뿐이다. tno 가 비어 있으면
// 승인 전에 터진 예외이고, 그 시점의 입력은 전부 비인증 브라우저가 보낸 값이라
// 남의 주문을 취소시키는 통로가 된다.
if ($order instanceof Order && $this->hasApproval($approvedTno)) {
$failedOrder = $this->orderService->failPayment($order, 'AMOUNT_MISMATCH', $e->getMessage());
$this->markKcpPaymentFailureRecord(
$failedOrder,
@@ -417,7 +426,8 @@ class PaymentCallbackController
$this->autoCancelIfApproved($approvedTno, $ordrIdxx, $approvedAmtForCancel, 'confirm_failed');
if ($order instanceof Order) {
// 승인 이후의 후속 처리 실패만 주문에 반영한다 (위 amount_mismatch catch 와 동일 기준).
if ($order instanceof Order && $this->hasApproval($approvedTno)) {
$failedOrder = $this->orderService->failPayment($order, 'CONFIRM_FAILED', $e->getMessage());
$this->markKcpPaymentFailureRecord(
$failedOrder,
@@ -577,8 +587,11 @@ class PaymentCallbackController
* Mobile SmartPhone Pay: enc_data 없이 평문 필드(bankname/account/depositor/va_date)가
* 콜백 POST 에 직접 포함되므로 CLI 호출을 건너뛰고 요청에서 직접 읽음.
*
* 발급 성공 = res_cd == 0000 AND bankname/account 가 모두 채워진 경우만.
* 그 외 (CLI 9502 / 예외 / 평문 필드 결락) 는 발급 실패로 판정해 failPayment + fail URL.
* 발급 성공 = bankname/account 가 모두 채워진 경우만 (정상 res_cd 는 결제수단별로 다르다).
* 그 외 (CLI 9502 / 예외 / 평문 필드 결락) 는 발급 실패로 판정하되 **주문 상태는 바꾸지
* 않고** fail URL 로 되돌려 보낸다 — 이 엔드포인트는 비인증 브라우저 POST 라, 발급 미완을
* 근거로 실패 처리하면 주문번호만 아는 제3자가 남의 주문을 취소시킬 수 있다
* (KVE-2026-2018 과 동일 기준). 정당한 실패는 소유권을 검증하는 close-report 가 기록한다.
*/
private function handleVbankIssued(
array $validated,
@@ -615,14 +628,10 @@ class PaymentCallbackController
'error' => $e->getMessage(),
]);
$failedOrder = $this->orderService->failPayment($order, 'cli_exception', $e->getMessage());
$this->markKcpPaymentFailureRecord(
$failedOrder,
'cli_exception',
$e->getMessage(),
'vbank_cli_exception',
);
// 승인이 성립하지 않았다 — 주문 상태를 바꾸지 않는다. 이 엔드포인트는 비인증
// 브라우저 POST 라, CLI 실패를 근거로 실패 처리하면 남의 주문번호와 위조 입력만으로
// 그 주문을 취소시킬 수 있다 (카드 경로와 동일 기준 · KVE-2026-2018).
// 정당한 결제 실패는 소유권을 검증하는 close-report 가 기록한다.
return redirect($this->resolveFailUrl([
'error' => 'cli_exception',
'message' => __('sirsoft-pay_nhnkcp::messages.errors.payment_failed'),
@@ -660,14 +669,12 @@ class PaymentCallbackController
'is_mobile' => $isMobile,
]);
$failedOrder = $this->orderService->failPayment($order, $effectiveCode, $effectiveMsg);
$this->markKcpPaymentFailureRecord(
$failedOrder,
$effectiveCode,
$effectiveMsg,
'vbank_issuance_failed',
);
// 발급이 성립하지 않았다 — 주문 상태를 바꾸지 않는다.
// 모바일 분기의 $pgResponse 는 콜백 POST 평문을 그대로 옮긴 값이라 PG 서버 호출이
// 아예 없고, PC 분기의 CLI 실패도 위조 암호문으로 유도할 수 있다. 어느 쪽이든
// 비인증 브라우저 입력이므로 이를 근거로 실패 처리하면 주문번호만 아는 제3자가
// 남의 결제대기 주문을 취소시킬 수 있다 (카드 경로와 동일 기준 · KVE-2026-2018).
// 정당한 발급 실패는 소유권을 검증하는 close-report 가 기록한다.
return redirect($this->resolveFailUrl([
'error' => $effectiveCode,
'message' => $effectiveMsg,
@@ -693,8 +700,28 @@ class PaymentCallbackController
]);
}
// 취소 주문 재결제 — 되살리기는 상태 변경이므로 PG 서버가 발급을 확인해 준 PC 분기에서만
// 수행한다. 모바일 분기는 서버 호출이 없어 발급 증거가 콜백 평문뿐이라, 되살리면
// 주문번호만 아는 제3자가 남의 취소 주문을 되돌릴 수 있다.
if ($order->order_status === OrderStatusEnum::CANCELLED) {
if ($isMobile || ! $this->restoreRetryableKcpOrder($order, null)) {
Log::warning('KCP: cancelled vbank order not restored (no server-side issuance proof)', [
'ordr_idxx' => $ordrIdxx,
'is_mobile' => $isMobile,
]);
return redirect($this->resolveFailUrl([
'error' => 'order_not_retryable',
'orderId' => $ordrIdxx,
]));
}
$order = $order->fresh('payment') ?? $order;
}
// 가상계좌 발급 정보를 OrderPayment vbank 전용 컬럼에 저장 (PENDING_PAYMENT 상태 유지).
// 저장 실패 시도 부분 저장으로 사용자에게 잘못된 정보가 노출되지 않도록 failPayment 처리.
// 저장이 실패하면 부분 저장으로 잘못된 계좌가 노출되지 않도록 실패로 되돌린다. 단
// failPayment 는 PG 서버가 발급을 확인해 준 PC 분기에서만 부른다 (아래 catch 참조).
try {
$expireRaw = $pgResponse['va_date'] ?? null;
$vbankDueAt = null;
@@ -735,13 +762,17 @@ class PaymentCallbackController
'error' => $e->getMessage(),
]);
$failedOrder = $this->orderService->failPayment($order, 'vbank_save_failed', $e->getMessage());
$this->markKcpPaymentFailureRecord(
$failedOrder,
'vbank_save_failed',
$e->getMessage(),
'vbank_save_failed',
);
// PG 서버가 발급을 확인해 준 PC 분기에서만 주문에 반영한다. 모바일 분기의 발급 데이터는
// 콜백 평문이라 위조할 수 있어, 저장 실패를 근거로 주문을 취소시키는 통로가 된다.
if (! $isMobile) {
$failedOrder = $this->orderService->failPayment($order, 'vbank_save_failed', $e->getMessage());
$this->markKcpPaymentFailureRecord(
$failedOrder,
'vbank_save_failed',
$e->getMessage(),
'vbank_save_failed',
);
}
return redirect($this->resolveFailUrl([
'error' => 'vbank_save_failed',
@@ -850,6 +881,20 @@ class PaymentCallbackController
* cancel 자체가 실패해도 사용자 응답 흐름은 막지 않음 — 로깅만 수행하고
* 운영자가 KCP 가맹점 관리자에서 수동 처리하도록 신호.
*/
/**
* KCP 승인이 실제로 발생했는지 판정합니다.
*
* tno 는 승인 응답이 성공한 뒤에만 채워진다. 비어 있으면 승인 전에 흐름이 끊긴 것이고,
* 그 시점까지의 입력은 전부 비인증 브라우저가 보낸 값이라 주문 상태 변경의 근거가 될 수 없다.
*
* @param string|null $approvedTno 승인 확정 시 기록되는 거래번호
* @return bool 승인이 발생했으면 true
*/
private function hasApproval(?string $approvedTno): bool
{
return $approvedTno !== null && $approvedTno !== '';
}
private function autoCancelIfApproved(
?string $tno,
string $ordrIdxx,
@@ -3,6 +3,8 @@
namespace Plugins\Sirsoft\PayNhnkcp\Tests\Feature\Controllers;
use App\Models\User;
use App\Services\PluginSettingsService;
use Illuminate\Testing\TestResponse;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
@@ -23,22 +25,22 @@ class PaymentCallbackControllerTest extends PluginTestCase
private function makeCliResponse(string $tno, string $ordrIdxx, int $amount, string $resCd = '0000'): array
{
return [
'res_cd' => $resCd,
'res_msg' => $resCd === '0000' ? '정상처리' : '승인실패',
'tno' => $tno,
'ordr_idxx' => $ordrIdxx,
'good_mny' => $amount,
'app_no' => 'APP12345',
'card_no' => '4330****1234',
'card_name' => '신한카드',
'quota' => '00',
'use_pay_method' => 'CARD',
'app_time' => now()->format('YmdHis'),
'res_cd' => $resCd,
'res_msg' => $resCd === '0000' ? '정상처리' : '승인실패',
'tno' => $tno,
'ordr_idxx' => $ordrIdxx,
'good_mny' => $amount,
'app_no' => 'APP12345',
'card_no' => '4330****1234',
'card_name' => '신한카드',
'quota' => '00',
'use_pay_method' => 'CARD',
'app_time' => now()->format('YmdHis'),
];
}
/**
* @param array{taxable?: int, vat?: int, taxFree?: int} $tax
* @param array{taxable?: int, vat?: int, taxFree?: int} $tax
*/
private function createTestOrder(
int $totalAmount = 50000,
@@ -46,45 +48,45 @@ class PaymentCallbackControllerTest extends PluginTestCase
PaymentMethodEnum $paymentMethod = PaymentMethodEnum::CARD,
): Order {
$taxable = $tax['taxable'] ?? $totalAmount;
$vat = $tax['vat'] ?? (int) round($taxable * 10 / 110);
$vat = $tax['vat'] ?? (int) round($taxable * 10 / 110);
$taxFree = $tax['taxFree'] ?? 0;
$user = User::factory()->create();
$order = OrderFactory::new()->create([
'user_id' => $user->id,
'order_number' => 'ORD-TEST-' . random_int(10000, 99999),
'order_status' => OrderStatusEnum::PENDING_ORDER,
'subtotal_amount' => $totalAmount,
'total_discount_amount' => 0,
'total_coupon_discount_amount' => 0,
'user_id' => $user->id,
'order_number' => 'ORD-TEST-'.random_int(10000, 99999),
'order_status' => OrderStatusEnum::PENDING_ORDER,
'subtotal_amount' => $totalAmount,
'total_discount_amount' => 0,
'total_coupon_discount_amount' => 0,
'total_product_coupon_discount_amount' => 0,
'total_order_coupon_discount_amount' => 0,
'total_code_discount_amount' => 0,
'base_shipping_amount' => 0,
'extra_shipping_amount' => 0,
'shipping_discount_amount' => 0,
'total_shipping_amount' => 0,
'total_amount' => $totalAmount,
'total_due_amount' => $totalAmount,
'total_points_used_amount' => 0,
'total_deposit_used_amount' => 0,
'total_paid_amount' => 0,
'total_tax_amount' => $taxable,
'total_vat_amount' => $vat,
'total_tax_free_amount' => $taxFree,
'currency' => 'KRW',
'currency_snapshot' => self::krwCurrencySnapshot(),
'total_code_discount_amount' => 0,
'base_shipping_amount' => 0,
'extra_shipping_amount' => 0,
'shipping_discount_amount' => 0,
'total_shipping_amount' => 0,
'total_amount' => $totalAmount,
'total_due_amount' => $totalAmount,
'total_points_used_amount' => 0,
'total_deposit_used_amount' => 0,
'total_paid_amount' => 0,
'total_tax_amount' => $taxable,
'total_vat_amount' => $vat,
'total_tax_free_amount' => $taxFree,
'currency' => 'KRW',
'currency_snapshot' => self::krwCurrencySnapshot(),
]);
OrderPaymentFactory::new()->create([
'order_id' => $order->id,
'payment_status' => PaymentStatusEnum::READY,
'payment_method' => $paymentMethod,
'pg_provider' => 'nhnkcp',
'paid_amount_local' => 0,
'paid_at' => null,
'transaction_id' => null,
'order_id' => $order->id,
'payment_status' => PaymentStatusEnum::READY,
'payment_method' => $paymentMethod,
'pg_provider' => 'nhnkcp',
'paid_amount_local' => 0,
'paid_at' => null,
'transaction_id' => null,
'card_approval_number' => null,
]);
@@ -137,18 +139,18 @@ class PaymentCallbackControllerTest extends PluginTestCase
private function mockPluginSettings(array $overrides = []): void
{
$defaults = [
'is_test_mode' => true,
'test_site_cd' => self::TEST_SITE_CD,
'test_site_key' => self::TEST_SITE_KEY,
'live_site_cd' => '',
'live_site_key' => '',
'is_test_mode' => true,
'test_site_cd' => self::TEST_SITE_CD,
'test_site_key' => self::TEST_SITE_KEY,
'live_site_cd' => '',
'live_site_key' => '',
'redirect_success_url' => '/shop/orders/{orderId}/complete',
'redirect_fail_url' => '/shop/checkout',
'redirect_fail_url' => '/shop/checkout',
];
$mock = $this->createMock(\App\Services\PluginSettingsService::class);
$mock = $this->createMock(PluginSettingsService::class);
$mock->method('get')->willReturn(array_merge($defaults, $overrides));
$this->app->instance(\App\Services\PluginSettingsService::class, $mock);
$this->app->instance(PluginSettingsService::class, $mock);
}
/**
@@ -168,14 +170,14 @@ class PaymentCallbackControllerTest extends PluginTestCase
private function makeCallbackParams(string $ordrIdxx, int $goodMny, array $overrides = []): array
{
return array_merge([
'res_cd' => '0000',
'res_msg' => '정상처리',
'tno' => 'KCP_TNO_' . uniqid(),
'ordr_idxx' => $ordrIdxx,
'good_mny' => $goodMny,
'enc_data' => base64_encode('encrypted_payment_data'),
'enc_info' => base64_encode('encrypted_info'),
'use_pay_method' => 'CARD',
'res_cd' => '0000',
'res_msg' => '정상처리',
'tno' => 'KCP_TNO_'.uniqid(),
'ordr_idxx' => $ordrIdxx,
'good_mny' => $goodMny,
'enc_data' => base64_encode('encrypted_payment_data'),
'enc_info' => base64_encode('encrypted_info'),
'use_pay_method' => 'CARD',
], $overrides);
}
@@ -211,7 +213,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
$order = $this->createTestOrder(50000);
$this->mockPluginSettings();
$tno = 'KCP_TNO_' . uniqid();
$tno = 'KCP_TNO_'.uniqid();
$this->mockApiService($this->makeCliResponse($tno, $order->order_number, 50000));
$response = $this->post(
@@ -410,8 +412,8 @@ class PaymentCallbackControllerTest extends PluginTestCase
{
// 11,000원 = 공급가 10,000 + 부가세 1,000 (전액 과세)
$amount = 11000;
$vat = (int) round($amount * 10 / 110); // 1,000
$order = $this->createTestOrder($amount, ['taxable' => $amount, 'vat' => $vat, 'taxFree' => 0]);
$vat = (int) round($amount * 10 / 110); // 1,000
$order = $this->createTestOrder($amount, ['taxable' => $amount, 'vat' => $vat, 'taxFree' => 0]);
$this->mockPluginSettings();
$tno = 'KCP_TNO_TAXABLE';
@@ -435,7 +437,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
{
// 10,000원 전액 비과세 (도서, 농산물, 의료 등 면세 상품)
$amount = 10000;
$order = $this->createTestOrder($amount, ['taxable' => 0, 'vat' => 0, 'taxFree' => $amount]);
$order = $this->createTestOrder($amount, ['taxable' => 0, 'vat' => 0, 'taxFree' => $amount]);
$this->mockPluginSettings();
$tno = 'KCP_TNO_TAXFREE';
@@ -461,9 +463,9 @@ class PaymentCallbackControllerTest extends PluginTestCase
// 21,000원 = 과세 11,000(공급가 10,000 + 부가세 1,000) + 비과세 10,000
$taxable = 11000;
$taxFree = 10000;
$total = $taxable + $taxFree;
$vat = (int) round($taxable * 10 / 110); // 1,000
$order = $this->createTestOrder($total, ['taxable' => $taxable, 'vat' => $vat, 'taxFree' => $taxFree]);
$total = $taxable + $taxFree;
$vat = (int) round($taxable * 10 / 110); // 1,000
$order = $this->createTestOrder($total, ['taxable' => $taxable, 'vat' => $vat, 'taxFree' => $taxFree]);
$this->mockPluginSettings();
$tno = 'KCP_TNO_MIXED';
@@ -486,7 +488,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
$this->mockPluginSettings();
$response = $this->post('/plugins/sirsoft-pay_nhnkcp/payment/callback', $this->makeCallbackParams('ORD-TEST-99999', 50000, [
'res_cd' => '8001',
'res_cd' => '8001',
'res_msg' => '사용자 취소',
]));
@@ -499,8 +501,8 @@ class PaymentCallbackControllerTest extends PluginTestCase
$this->mockPluginSettings();
$response = $this->post('/plugins/sirsoft-pay_nhnkcp/payment/callback', [
'res_cd' => '3001',
'res_msg' => '사용자취소',
'res_cd' => '3001',
'res_msg' => '사용자취소',
'ordr_idxx' => 'ORD-TEST-CANCEL',
]);
@@ -539,7 +541,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
$this->mockPluginSettings();
$response = $this->post('/plugins/sirsoft-pay_nhnkcp/payment/callback', [
'res_cd' => '',
'res_cd' => '',
'ordr_idxx' => 'ORD-TEST-EMPTY',
]);
@@ -561,12 +563,23 @@ class PaymentCallbackControllerTest extends PluginTestCase
$this->assertStringContainsString('error=order_not_found', $response->headers->get('Location'));
}
public function test_auth_callback_redirects_to_fail_when_cli_approval_fails(): void
/**
* 서버 승인이 실패하면 실패 URL 로 돌아가되 주문 상태는 건드리지 않는다.
*
* 계약 변경(KVE-2026-2018): `authCallback` 은 PG 서명도 IP 증명도 없는 비인증 브라우저
* POST 다. 주문번호는 요청자가 고른 값이라, 승인 실패를 근거로 주문을 취소하면 남의
* 주문번호와 아무 암호문이나 실어 보내는 것만으로 그 주문을 취소시킬 수 있다.
* 승인이 성공하지 않은 이상 이 엔드포인트는 상태를 바꾸지 않는다.
*/
public function test_auth_callback_does_not_mutate_the_order_when_cli_approval_fails(): void
{
$order = $this->createTestOrder(50000);
$this->mockPluginSettings();
$this->mockApiService($this->makeCliResponse('TNO_FAIL', $order->order_number, 50000, '9999'));
$statusBefore = $order->order_status;
$paymentStatusBefore = $order->payment->payment_status;
$response = $this->post(
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
$this->makeCallbackParams($order->order_number, 50000)
@@ -579,14 +592,16 @@ class PaymentCallbackControllerTest extends PluginTestCase
$payment = $order->payment;
$payment->refresh();
$this->assertEquals(OrderStatusEnum::CANCELLED, $order->order_status);
$this->assertEquals(PaymentStatusEnum::FAILED, $payment->payment_status);
$this->assertSame('nhnkcp', $payment->payment_meta['failure_source'] ?? null);
$this->assertSame('9999', $payment->payment_meta['failure_code'] ?? null);
$this->assertSame('approval_failed', $payment->payment_meta['failure_stage'] ?? null);
$this->assertEquals($statusBefore, $order->order_status, '비인증 콜백이 주문 상태를 바꿨습니다.');
$this->assertEquals($paymentStatusBefore, $payment->payment_status, '비인증 콜백이 결제 상태를 바꿨습니다.');
$this->assertNotEquals(OrderStatusEnum::CANCELLED, $order->order_status);
$this->assertArrayNotHasKey('failure_stage', $payment->payment_meta ?? []);
}
public function test_auth_callback_records_amount_mismatch_as_non_retryable_kcp_failure(): void
/**
* 브라우저가 보낸 금액이 주문 금액과 다르면 승인 자체를 하지 않고, 주문도 건드리지 않는다.
*/
public function test_auth_callback_does_not_mutate_the_order_on_browser_amount_mismatch(): void
{
$order = $this->createTestOrder(50000);
$this->mockPluginSettings();
@@ -594,6 +609,9 @@ class PaymentCallbackControllerTest extends PluginTestCase
$tno = 'KCP_TNO_AMOUNT_MISMATCH';
$this->mockApiService($this->makeCliResponse($tno, $order->order_number, 60000));
$statusBefore = $order->order_status;
$paymentStatusBefore = $order->payment->payment_status;
$response = $this->post(
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
$this->makeCallbackParams($order->order_number, 60000, ['tno' => $tno])
@@ -606,11 +624,120 @@ class PaymentCallbackControllerTest extends PluginTestCase
$payment = $order->payment;
$payment->refresh();
$this->assertEquals(OrderStatusEnum::CANCELLED, $order->order_status);
$this->assertEquals(PaymentStatusEnum::FAILED, $payment->payment_status);
$this->assertSame('nhnkcp', $payment->payment_meta['failure_source'] ?? null);
$this->assertSame('AMOUNT_MISMATCH', $payment->payment_meta['failure_code'] ?? null);
$this->assertSame('amount_mismatch', $payment->payment_meta['failure_stage'] ?? null);
$this->assertEquals($statusBefore, $order->order_status, '비인증 콜백이 주문 상태를 바꿨습니다.');
$this->assertEquals($paymentStatusBefore, $payment->payment_status, '비인증 콜백이 결제 상태를 바꿨습니다.');
$this->assertNotEquals(OrderStatusEnum::CANCELLED, $order->order_status);
}
/**
* 무인증 위조 콜백으로 타인의 결제대기 주문을 취소할 수 없다 (KVE-2026-2018 회귀).
*
* 공격자는 로그인하지 않고, 피해자의 주문번호와 아무 암호문만으로 이 엔드포인트를 친다.
* 서버 승인은 당연히 실패하는데, 그 실패를 피해 주문의 결제 실패로 오인 처리하면
* 남의 주문이 취소된다.
*/
public function test_forged_unauthenticated_callback_cannot_cancel_another_users_order(): void
{
$victimOrder = $this->createTestOrder(50000);
$this->mockPluginSettings();
$this->mockApiService($this->makeCliResponse('', $victimOrder->order_number, 0, '6003'));
$response = $this->post(
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
$this->makeCallbackParams($victimOrder->order_number, 0, [
'enc_data' => 'FORGED_CIPHERTEXT',
'enc_info' => 'FORGED_INFO',
])
);
$response->assertRedirect();
$victimOrder->refresh();
$payment = $victimOrder->payment;
$payment->refresh();
$this->assertEquals(
OrderStatusEnum::PENDING_ORDER,
$victimOrder->order_status,
'무인증 위조 콜백이 피해자의 주문을 취소시켰습니다.'
);
$this->assertNotEquals(PaymentStatusEnum::FAILED, $payment->payment_status);
$this->assertArrayNotHasKey('payment_failure_code', $victimOrder->order_meta ?? []);
}
/**
* 카드 주문에 use_pay_method=VCNT 를 실어 가상계좌 분기로 우회하는 통로 차단.
*
* 가상계좌 모바일 분기는 enc_data/enc_info 가 비면 PG 서버를 호출하지 않고 콜백 평문을
* 그대로 승인 응답으로 취급한다. 분기 판정이 요청값을 보면 공격자가 카드 주문을 그 경로로
* 몰아, 은행명·계좌번호를 비운 것만으로 "발급 실패" 를 만들어 남의 주문을 취소시킬 수 있다.
*/
public function test_forged_vbank_routed_callback_cannot_cancel_a_card_order(): void
{
$victimOrder = $this->createTestOrder(50000);
$this->mockPluginSettings();
$response = $this->post(
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
[
'res_cd' => '0000',
'ordr_idxx' => $victimOrder->order_number,
'tno' => 'FORGED',
// 금액을 비워 승인 전 금액 검증을 건너뛴다
// enc_data/enc_info 를 비워 CLI 호출 자체를 우회한다
// bankname/account 를 비워 "발급 실패" 판정을 유도한다
'use_pay_method' => 'VCNT',
]
);
$response->assertRedirect();
$victimOrder->refresh();
$payment = $victimOrder->payment;
$payment->refresh();
$this->assertEquals(
OrderStatusEnum::PENDING_ORDER,
$victimOrder->order_status,
'위조 콜백이 use_pay_method=VCNT 우회로 피해자의 카드 주문을 취소시켰습니다.'
);
$this->assertNotEquals(PaymentStatusEnum::FAILED, $payment->payment_status);
$this->assertArrayNotHasKey('payment_failure_code', $victimOrder->order_meta ?? []);
}
/**
* 분기 판정이 주문의 결제수단만 본다는 계약 (요청값으로 경로를 고를 수 없다).
*
* 카드 주문에 VCNT + 위조 발급정보를 실어도 가상계좌 발급으로 기록되지 않아야 한다.
* 요청값으로 분기가 갈리면 공격자가 피해자 카드 주문에 임의 계좌번호를 심을 수 있다.
*/
public function test_request_pay_method_cannot_route_a_card_order_into_the_vbank_branch(): void
{
$victimOrder = $this->createTestOrder(50000);
$this->mockPluginSettings();
$this->mockApiService($this->makeCliResponse('', $victimOrder->order_number, 0, '6003'));
$this->post(
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
[
'res_cd' => '0000',
'ordr_idxx' => $victimOrder->order_number,
'tno' => 'FORGED',
'use_pay_method' => 'VCNT',
'bankname' => '공격자은행',
'account' => '110-000-999999',
'depositor' => '공격자',
]
);
$payment = $victimOrder->payment;
$payment->refresh();
$this->assertNull(
$payment->vbank_number,
'요청의 use_pay_method 로 카드 주문이 가상계좌 분기로 흘러 위조 계좌가 기록됐습니다.'
);
$this->assertNull($payment->vbank_name);
}
public function test_auth_callback_redirects_to_fail_url_on_missing_params(): void
@@ -671,14 +798,14 @@ class PaymentCallbackControllerTest extends PluginTestCase
// - result=0000 → KCP 가 통보 성공으로 인정 (재시도 차단)
// - 그 외 → KCP 재통보 (최대 10회)
private function assertKcpNotifyOk(\Illuminate\Testing\TestResponse $response): void
private function assertKcpNotifyOk(TestResponse $response): void
{
$response->assertOk();
$this->assertStringContainsString('name="result"', $response->getContent());
$this->assertStringContainsString('value="0000"', $response->getContent());
}
private function assertKcpNotifyRetry(\Illuminate\Testing\TestResponse $response): void
private function assertKcpNotifyRetry(TestResponse $response): void
{
$response->assertOk();
$this->assertStringContainsString('name="result"', $response->getContent());
@@ -688,18 +815,18 @@ class PaymentCallbackControllerTest extends PluginTestCase
private function makeVbankNotifyPayload(string $orderNo, int $amount, array $overrides = []): array
{
return array_merge([
'site_cd' => 'T0000',
'tno' => 'KCP_VBANK_TNO_' . uniqid(),
'order_no' => $orderNo,
'tx_cd' => 'TX00',
'tx_tm' => now()->format('YmdHis'),
'op_cd' => '50',
'ipgm_mnyx' => $amount,
'ipgm_name' => '홍길동',
'remitter' => '홍길동',
'bank_code' => 'BK04',
'account' => 'T1234567890',
'noti_id' => uniqid('NOTI_'),
'site_cd' => 'T0000',
'tno' => 'KCP_VBANK_TNO_'.uniqid(),
'order_no' => $orderNo,
'tx_cd' => 'TX00',
'tx_tm' => now()->format('YmdHis'),
'op_cd' => '50',
'ipgm_mnyx' => $amount,
'ipgm_name' => '홍길동',
'remitter' => '홍길동',
'bank_code' => 'BK04',
'account' => 'T1234567890',
'noti_id' => uniqid('NOTI_'),
], $overrides);
}
@@ -707,7 +834,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
* KCP 공식 발신 IP 로 vbank-notify POST.
* RestrictKcpIp 미들웨어가 항상 IP 검증하므로 화이트리스트 IP 필수.
*/
private function postVbankNotify(array $payload, string $kcpIp = '203.238.36.58'): \Illuminate\Testing\TestResponse
private function postVbankNotify(array $payload, string $kcpIp = '203.238.36.58'): TestResponse
{
return $this->withServerVariables(['REMOTE_ADDR' => $kcpIp])
->post('/plugins/sirsoft-pay_nhnkcp/payment/vbank-notify', $payload);
@@ -840,18 +967,18 @@ class PaymentCallbackControllerTest extends PluginTestCase
$this->markVbankIssued($order, 'KCP_TNO_REAL', 'T9876543210');
$response = $this->postVbankNotify([
'site_cd' => 'T0000',
'tno' => 'KCP_TNO_REAL',
'order_no' => $order->order_number,
'tx_cd' => 'TX00',
'tx_tm' => '20260514120000',
'op_cd' => '50',
'site_cd' => 'T0000',
'tno' => 'KCP_TNO_REAL',
'order_no' => $order->order_number,
'tx_cd' => 'TX00',
'tx_tm' => '20260514120000',
'op_cd' => '50',
'ipgm_mnyx' => 50000,
'ipgm_name' => '홍길동',
'remitter' => '실입금자',
'remitter' => '실입금자',
'bank_code' => '04',
'account' => 'T9876543210',
'noti_id' => '26051412000018046532',
'account' => 'T9876543210',
'noti_id' => '26051412000018046532',
]);
$this->assertKcpNotifyOk($response);
@@ -1012,21 +1139,21 @@ class PaymentCallbackControllerTest extends PluginTestCase
$tno = 'KCP_VBANK_V000';
$this->mockApiService([
'res_cd' => 'V000',
'res_msg' => '가상계좌가 발급되었습니다.',
'tno' => $tno,
'bankname' => 'NH농협',
'account' => 'T1109260001455',
'res_cd' => 'V000',
'res_msg' => '가상계좌가 발급되었습니다.',
'tno' => $tno,
'bankname' => 'NH농협',
'account' => 'T1109260001455',
'depositor' => 'NHN KCP',
'va_date' => '20260516235959',
'bankcode' => 'BK11',
'app_time' => '20260513174624',
'va_date' => '20260516235959',
'bankcode' => 'BK11',
'app_time' => '20260513174624',
]);
$response = $this->post(
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
$this->makeCallbackParams($order->order_number, 30000, [
'tno' => $tno,
'tno' => $tno,
'use_pay_method' => 'VCNT',
])
);
@@ -1119,10 +1246,20 @@ class PaymentCallbackControllerTest extends PluginTestCase
$order->refresh();
$this->assertNotEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
// "완료되지 않았다" 만으로는 부족하다 — 이 콜백은 비인증 브라우저 경로이므로,
// 발급 실패를 근거로 주문을 취소하면 주문번호만 아는 제3자가 남의 주문을
// 취소시킬 수 있다 (KVE-2026-2018 동일 계열). 취소되지 않았음까지 단언한다.
$this->assertNotEquals(
OrderStatusEnum::CANCELLED,
$order->order_status,
'승인 증거 없는 가상계좌 콜백이 주문을 취소시켰습니다.'
);
$payment = $order->payment;
$payment->refresh();
$this->assertNull($payment->vbank_name);
$this->assertNull($payment->vbank_number);
$this->assertNotEquals(PaymentStatusEnum::FAILED, $payment->payment_status);
}
/**
@@ -1148,10 +1285,20 @@ class PaymentCallbackControllerTest extends PluginTestCase
$order->refresh();
$this->assertNotEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
// "완료되지 않았다" 만으로는 부족하다 — 이 콜백은 비인증 브라우저 경로이므로,
// 발급 실패를 근거로 주문을 취소하면 주문번호만 아는 제3자가 남의 주문을
// 취소시킬 수 있다 (KVE-2026-2018 동일 계열). 취소되지 않았음까지 단언한다.
$this->assertNotEquals(
OrderStatusEnum::CANCELLED,
$order->order_status,
'승인 증거 없는 가상계좌 콜백이 주문을 취소시켰습니다.'
);
$payment = $order->payment;
$payment->refresh();
$this->assertNull($payment->vbank_name);
$this->assertNull($payment->vbank_number);
$this->assertNotEquals(PaymentStatusEnum::FAILED, $payment->payment_status);
}
/**
@@ -1173,10 +1320,10 @@ class PaymentCallbackControllerTest extends PluginTestCase
$response = $this->post(
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
[
'res_cd' => '0000',
'ordr_idxx' => $order->order_number,
'good_mny' => 30000,
'tno' => 'KCP_VBANK_MOBILE_FAIL',
'res_cd' => '0000',
'ordr_idxx' => $order->order_number,
'good_mny' => 30000,
'tno' => 'KCP_VBANK_MOBILE_FAIL',
'use_pay_method' => 'VCNT',
// bankname / account / depositor / va_date 모두 누락 → 발급 정보 없음
]
@@ -1188,9 +1335,19 @@ class PaymentCallbackControllerTest extends PluginTestCase
$order->refresh();
$this->assertNotEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
// "완료되지 않았다" 만으로는 부족하다 — 이 콜백은 비인증 브라우저 경로이므로,
// 발급 실패를 근거로 주문을 취소하면 주문번호만 아는 제3자가 남의 주문을
// 취소시킬 수 있다 (KVE-2026-2018 동일 계열). 취소되지 않았음까지 단언한다.
$this->assertNotEquals(
OrderStatusEnum::CANCELLED,
$order->order_status,
'승인 증거 없는 가상계좌 콜백이 주문을 취소시켰습니다.'
);
$payment = $order->payment;
$payment->refresh();
$this->assertNull($payment->vbank_name);
$this->assertNull($payment->vbank_number);
$this->assertNotEquals(PaymentStatusEnum::FAILED, $payment->payment_status);
}
}
@@ -470,10 +470,17 @@ class NhnKcpApiServiceTest extends PluginTestCase
$reflection->getConstant('LOG_DISABLED_PATH'),
'gnuboard5 의 검증된 패턴(/home100/kcp) 과 일치해야 함',
);
$this->assertFalse(
is_dir($reflection->getConstant('LOG_DISABLED_PATH')),
'경로가 실제 시스템에 존재하지 않아야 (로그 작성 silent skip 보장)',
);
// 존재 여부는 **배포 OS(리눅스)** 기준 계약이다. KCP CLI 는 리눅스 바이너리이고
// (`test_cli_args_include_disabled_log_path_constant` 도 같은 이유로 Windows 를 제외한다),
// Windows 는 선행 `/` 를 현재 드라이브 기준으로 해석해 `C:\home100\kcp` 를 본다.
// 그래서 개발자 PC 에 우연히 그 폴더가 있으면 배포 계약과 무관하게 붉은불이 뜬다.
// 계약 자체는 그대로 두고, 그 계약이 성립하는 OS 에서만 파일시스템을 확인한다.
if (DIRECTORY_SEPARATOR === '/') {
$this->assertFalse(
is_dir($reflection->getConstant('LOG_DISABLED_PATH')),
'경로가 실제 시스템에 존재하지 않아야 (로그 작성 silent skip 보장)',
);
}
}
public function test_log_level_is_minimal_to_prevent_pii_leak(): void
@@ -1,6 +1,6 @@
# audit:allow test-scenario-coverage reason: Phase 2 SSoT — 본 매니페스트는 NHN KCP 콜백 보안 매트릭스의 명세 문서. CLI sanitization 단위 테스트가 핵심 경로를 커버하며, replay/net-cancel 의 cross product 전체 자동 검증은 Phase 3 (침투 PoC) 에서 처리.
feature: 콜백 보안 방어 (replay attack / CLI command injection / post-approve net cancel)
feature: 콜백 보안 방어 (replay attack / CLI command injection / post-approve net cancel / 비인증 브라우저 콜백의 주문 변조)
description: |
NHN KCP 결제 콜백 처리 경로의 3가지 핵심 보안 위협에 대한 방어 매트릭스.
@@ -18,8 +18,9 @@ description: |
axes:
context: [paymentCallback_card, paymentCallback_vbank, mobileApproval]
threat: [replay, cli_injection, post_approve_failure]
callback_state: [first_arrival, second_arrival_same_tno, cli_arg_normal, cli_arg_unsafe, domain_succeeds, domain_fails]
threat: [replay, cli_injection, post_approve_failure, forged_browser_callback]
callback_state: [first_arrival, second_arrival_same_tno, cli_arg_normal, cli_arg_unsafe, domain_succeeds, domain_fails, approval_not_yet_attempted, approval_failed, approval_succeeded]
caller: [pg_server_verified, unauthenticated_browser, ownership_verified_buyer]
exclusions:
- { threat: replay, callback_state: cli_arg_normal, reason: "replay 는 CLI 단계 이전 분기" }
@@ -36,6 +37,26 @@ exclusions:
- { threat: post_approve_failure, callback_state: cli_arg_normal, reason: "post-approve 실패는 CLI 와 무관" }
- { threat: post_approve_failure, callback_state: cli_arg_unsafe, reason: "post-approve 실패는 CLI 와 무관" }
- { threat: post_approve_failure, context: paymentCallback_vbank, reason: "vbank 통보는 PG 측 비동기 입금 — post-approve 단계 없음" }
- { threat: forged_browser_callback, callback_state: first_arrival, reason: "위조 콜백은 도착 차수가 아니라 승인 성립 여부로 갈린다" }
- { threat: forged_browser_callback, callback_state: second_arrival_same_tno, reason: "동일" }
- { threat: forged_browser_callback, callback_state: cli_arg_normal, reason: "위조 콜백 축은 CLI 인수 정제 단계가 아니다" }
- { threat: forged_browser_callback, callback_state: cli_arg_unsafe, reason: "동일" }
- { threat: forged_browser_callback, callback_state: domain_succeeds, reason: "도메인 처리 이전에 승인 성립 여부로 판정한다" }
- { threat: forged_browser_callback, callback_state: domain_fails, reason: "동일" }
- { threat: replay, callback_state: approval_not_yet_attempted, reason: "승인 성립 축은 위조 콜백 전용 — replay 는 도착 차수 분기" }
- { threat: replay, callback_state: approval_failed, reason: "동일" }
- { threat: replay, callback_state: approval_succeeded, reason: "동일" }
- { threat: cli_injection, callback_state: approval_not_yet_attempted, reason: "CLI 인젝션은 인수 정제 단계 분기" }
- { threat: cli_injection, callback_state: approval_failed, reason: "동일" }
- { threat: cli_injection, callback_state: approval_succeeded, reason: "동일" }
- { threat: post_approve_failure, callback_state: approval_not_yet_attempted, reason: "정의상 승인 이후 분기" }
- { threat: post_approve_failure, callback_state: approval_failed, reason: "동일" }
- { threat: post_approve_failure, callback_state: approval_succeeded, reason: "post-approve 실패는 domain_fails 로 표현한다" }
- { caller: ownership_verified_buyer, threat: replay, reason: "close-report 는 결제창 콜백이 아니라 별도 소유권 검증 엔드포인트" }
- { caller: ownership_verified_buyer, threat: cli_injection, reason: "동일 — CLI 를 호출하지 않는다" }
- { caller: ownership_verified_buyer, threat: post_approve_failure, reason: "동일 — 승인 단계가 없다" }
- { caller: pg_server_verified, threat: forged_browser_callback, reason: "위조 축의 전제가 비인증 브라우저 입력이다" }
- { caller: unauthenticated_browser, threat: cli_injection, reason: "CLI 인수는 상점 설정·주문 저장값에서 오고 브라우저 입력이 아니다" }
effects:
# Replay defense — PreventsReplayCallback trait
@@ -62,15 +83,41 @@ effects:
- cli_exec_uses_escapeshellarg
- cli_no_shell_executed_on_unsafe_input
# 비인증 브라우저 콜백의 주문 변조 차단 (KVE-2026-2018 + 형제)
- unauthenticated_callback_cannot_cancel_another_users_order
- browser_result_code_alone_never_transitions_order_state
- pre_approval_failure_sink_does_not_call_fail_payment
- pre_approval_amount_mismatch_does_not_mutate_the_order
- post_approval_failure_still_records_failure_with_tno
- payment_method_branch_is_decided_by_stored_order_value_only
- request_pay_method_cannot_route_a_card_order_into_vbank_branch
- forged_vbank_callback_cannot_cancel_a_card_order
- forged_vbank_callback_cannot_write_an_account_number_to_the_order
- mobile_vbank_issuance_failure_does_not_mutate_the_order
- pc_vbank_cli_exception_does_not_mutate_the_order
- vbank_save_failure_mutates_only_on_pc_server_confirmed_issuance
- cancelled_order_is_restored_only_after_approval_or_issuance_is_confirmed
- legitimate_failure_is_recorded_by_ownership_verified_close_report
- close_report_rejects_buyer_mismatch
- normal_approval_still_completes_the_order
# Post-approve net cancel
- post_approve_failure_triggers_auto_cancel
- net_cancel_sends_pg_cancel_api_call
- net_cancel_passes_approved_tno
- net_cancel_passes_full_amount
test_files: []
test_files:
- plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentCallbackControllerTest.php
manual_verification:
- description: "위조 콜백 PoC — 남의 주문번호로 결제 실패 유도"
steps:
- "피해자 계정으로 결제대기 주문 1건 생성 (pending_order / ready)"
- "로그아웃 상태에서 authCallback 에 피해 ordr_idxx + res_cd=0000 + 위조 암호문 POST"
- "302 리다이렉트만 발생하고 주문 상태·결제 상태가 불변인지 재조회로 확인"
- "요청에 use_pay_method=VCNT 를 섞어도 카드 주문이 가상계좌 분기로 흐르지 않는지 확인"
- "위조 bankname/account 가 주문에 기록되지 않는지 확인"
- description: "Replay PoC — paymentCallback 두 번 전송"
steps:
- "정상 카드 콜백 1차 전송 → 200 OK, payment_status=paid"
@@ -128,6 +128,8 @@ API 와 동기화하는 역할만 합니다. 등록은 훅 기반입니다
| 가상계좌 입금통보(`vbank-notify`)에 IP 화이트리스트 미부착 | `VbankNotifyIpWhitelist` 미들웨어 유지 | 통보 엔드포인트는 나이스페이먼츠 서버만 호출해야 하며, 화이트리스트가 없으면 제3자가 위조 입금통보를 보내 결제 상태를 조작할 수 있다 |
| 부분취소인데 가상계좌 입금 완료 건을 일반 취소 API로 처리 | 환불 계좌 정보가 필요한 가상계좌 건은 별도 어드민 환불 계좌 API 경로로 처리 | 가상계좌는 카드와 달리 PG가 자동으로 환불할 계좌를 모르므로 일반 취소 API를 호출하면 실패하거나 환불이 누락된다 |
| 라이브 가맹점 키를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 결제 요청을 위조할 수 있다 |
| 콜백이 넘겨준 `NextAppURL`/`NetCancelURL` 의 도메인을 원문 host 로 대조 (`str_ends_with` 등) | `OutboundUrlValidator::normalizeHost()` 로 정규화한 뒤 대조 (사본 금지 — 코어가 SSoT) | UTS#46 정규화에서 전각 문자가 ASCII 구분자로 바뀐다. `evil.example/.nicepay.co.kr`(U+FF0F)은 접미사 검사를 통과하지만 실제 연결 host 는 `evil.example` 이 되어, 인증 토큰과 MID 가 실린 POST 가 외부로 나간다 |
| 그 URL 에 userinfo(`user@host`)가 있어도 통과 | userinfo 존재만으로 거부 | `@` 앞부분이 신뢰 도메인처럼 보이게 위장할 수 있다 |
<!-- @intent END -->
## 7. 테스트 실행
@@ -6,6 +6,10 @@
## [1.0.3] - 2026-08-31
### Security
- 결제창이 넘겨준 승인 요청 주소에 마침표·빗금처럼 보이는 특수문자를 섞으면, 나이스페이먼츠 도메인 검사를 통과하면서 실제로는 다른 서버로 승인 요청이 나갈 수 있던 문제를 수정했습니다. 그 요청에는 인증 토큰과 상점 아이디가 실려 있어 외부로 유출될 수 있었습니다. 이제 검사와 실제 연결이 주소를 같은 방식으로 해석하며, 로그인 정보가 포함된 주소도 거부합니다. 정상적인 나이스페이먼츠 주소는 그대로 사용됩니다.
### Added
- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
@@ -10,9 +10,9 @@
| 훅 이름 | 유형 | 설명 | 발행 위치 |
|---|---|---|---|
| `sirsoft-pay_nicepayments.payment.after_authorize` | action | 나이스페이먼츠 서버 승인 완료 후 | `src/Controllers/PaymentCallbackController.php:257` |
| `sirsoft-pay_nicepayments.payment.after_cancel` | action | 나이스페이먼츠 결제 취소 완료 후 | `src/Services/NicePaymentsApiService.php:308` |
| `sirsoft-pay_nicepayments.payment.after_cancel` | action | 나이스페이먼츠 결제 취소 완료 후 | `src/Services/NicePaymentsApiService.php:312` |
| `sirsoft-pay_nicepayments.payment.before_authorize` | action | 나이스페이먼츠 서버 승인 API 호출 전 | `src/Controllers/PaymentCallbackController.php:252` |
| `sirsoft-pay_nicepayments.payment.before_cancel` | action | 나이스페이먼츠 결제 취소 API 호출 전 (본인인증 등 확장 지점) | `src/Services/NicePaymentsApiService.php:285` |
| `sirsoft-pay_nicepayments.payment.before_cancel` | action | 나이스페이먼츠 결제 취소 API 호출 전 (본인인증 등 확장 지점) | `src/Services/NicePaymentsApiService.php:289` |
| `sirsoft-pay_nicepayments.payment.refund_failed` | action | — | `src/Listeners/PaymentRefundListener.php:131` |
<!-- @generated:hooks-published END -->
@@ -6,6 +6,7 @@ namespace Plugins\Sirsoft\PayNicepayments\Services;
use App\Extension\HookManager;
use App\Services\PluginSettingsService;
use App\Support\OutboundUrlValidator;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Plugins\Sirsoft\PayNicepayments\Exceptions\NicePayApiException;
@@ -52,7 +53,7 @@ class NicePaymentsApiService
return '';
}
return str_starts_with($suffix, 'SR') ? $suffix : 'SR' . $suffix;
return str_starts_with($suffix, 'SR') ? $suffix : 'SR'.$suffix;
}
/**
@@ -116,6 +117,7 @@ class NicePaymentsApiService
* @param string $buyerAddress 구매자 주소
* @param string $registerName 등록자명
* @return array NicePay 응답 (UTF-8 변환 + JSON 파싱)
*
* @throws \Exception API 호출 실패 또는 PG 오류 시
*/
public function registerEscrowDelivery(
@@ -127,7 +129,7 @@ class NicePaymentsApiService
): array {
$reqType = '03';
$ediDate = $this->computeEdiDate();
$signData = bin2hex(hash('sha256', $tid . $this->mid . $reqType . $ediDate . $this->merchantKey, true));
$signData = bin2hex(hash('sha256', $tid.$this->mid.$reqType.$ediDate.$this->merchantKey, true));
$response = Http::timeout(15)->asForm()->post(self::DELIVERY_REG_URL, [
'MID' => $this->mid,
@@ -144,7 +146,7 @@ class NicePaymentsApiService
]);
if ($response->failed()) {
throw new NicePayApiException('NicePayments delivery reg API error: HTTP ' . $response->status());
throw new NicePayApiException('NicePayments delivery reg API error: HTTP '.$response->status());
}
$result = $response->json() ?? [];
@@ -172,7 +174,7 @@ class NicePaymentsApiService
*/
public function verifyCallbackSignature(string $authToken, string $mid, int $amt, string $signature): bool
{
$expected = bin2hex(hash('sha256', $authToken . $mid . (string) $amt . $this->merchantKey, true));
$expected = bin2hex(hash('sha256', $authToken.$mid.(string) $amt.$this->merchantKey, true));
return hash_equals($expected, $signature);
}
@@ -187,7 +189,7 @@ class NicePaymentsApiService
*/
public function verifyVbankNotifySignature(string $tid, int $amt, string $signature): bool
{
$expected = bin2hex(hash('sha256', $tid . $this->mid . (string) $amt . $this->merchantKey, true));
$expected = bin2hex(hash('sha256', $tid.$this->mid.(string) $amt.$this->merchantKey, true));
return hash_equals($expected, $signature);
}
@@ -195,22 +197,23 @@ class NicePaymentsApiService
/**
* 서버 승인 API 호출 (2단계 인증)
*
* @param string $nextAppUrl 나이스페이먼츠가 전달한 승인 URL
* @param string $txTid 임시 거래번호
* @param string $authToken 인증 토큰
* @param int $amt 결제 금액
* @param string $nextAppUrl 나이스페이먼츠가 전달한 승인 URL
* @param string $txTid 임시 거래번호
* @param string $authToken 인증 토큰
* @param int $amt 결제 금액
* @return array PG 응답 데이터
*
* @throws \Exception API 호출 실패 시
*/
public function authorizePayment(string $nextAppUrl, string $txTid, string $authToken, int $amt): array
{
// SSRF 방지: NextAppURL은 반드시 나이스페이먼츠 공식 도메인이어야 함
if (! $this->isNicePayUrl($nextAppUrl)) {
throw new NicePayApiException('Invalid NextAppURL host: ' . parse_url($nextAppUrl, PHP_URL_HOST));
throw new NicePayApiException('Invalid NextAppURL host: '.parse_url($nextAppUrl, PHP_URL_HOST));
}
$ediDate = $this->computeEdiDate();
$signData = bin2hex(hash('sha256', $authToken . $this->mid . (string) $amt . $ediDate . $this->merchantKey, true));
$signData = bin2hex(hash('sha256', $authToken.$this->mid.(string) $amt.$ediDate.$this->merchantKey, true));
$response = Http::timeout(15)->asForm()->post($nextAppUrl, [
'TID' => $txTid,
@@ -223,7 +226,7 @@ class NicePaymentsApiService
]);
if ($response->failed()) {
throw new NicePayApiException('NicePayments authorize API error: HTTP ' . $response->status());
throw new NicePayApiException('NicePayments authorize API error: HTTP '.$response->status());
}
return $response->json() ?? [];
@@ -244,6 +247,7 @@ class NicePaymentsApiService
* @param string|null $refundBankCd 환불 은행 코드
* @param string|null $refundAcctNm 환불 계좌 예금주명
* @return array PG 응답 데이터
*
* @throws \Exception API 호출 실패 시
*/
public function cancelPayment(
@@ -257,7 +261,7 @@ class NicePaymentsApiService
?string $refundAcctNm = null,
): array {
$ediDate = $this->computeEdiDate();
$signData = bin2hex(hash('sha256', $this->mid . (string) $cancelAmt . $ediDate . $this->merchantKey, true));
$signData = bin2hex(hash('sha256', $this->mid.(string) $cancelAmt.$ediDate.$this->merchantKey, true));
// NicePay 취소 API는 EUC-KR 인코딩 요구
$cancelMsgEuc = mb_convert_encoding($cancelMsg, 'EUC-KR', 'UTF-8');
@@ -287,7 +291,7 @@ class NicePaymentsApiService
$response = Http::timeout(15)->asForm()->post(self::CANCEL_URL, $params);
if ($response->failed()) {
throw new NicePayApiException('NicePayments cancel API error: HTTP ' . $response->status());
throw new NicePayApiException('NicePayments cancel API error: HTTP '.$response->status());
}
// 취소 API는 EUC-KR 응답을 반환하므로 UTF-8로 변환 후 JSON 파싱
@@ -313,15 +317,16 @@ class NicePaymentsApiService
/**
* 단건 거래 조회 API 호출
*
* @param string $tid 조회할 거래번호
* @param string $tid 조회할 거래번호
* @return array PG 응답 데이터
*
* @throws \Exception API 호출 실패 시
*/
public function queryTransaction(string $tid): array
{
$ediDate = $this->computeEdiDate();
// SignData: hex(sha256(TID + MID + EdiDate + MerchantKey)) — TID 먼저
$signData = bin2hex(hash('sha256', $tid . $this->mid . $ediDate . $this->merchantKey, true));
$signData = bin2hex(hash('sha256', $tid.$this->mid.$ediDate.$this->merchantKey, true));
$response = Http::timeout(15)->asForm()->post(self::QUERY_URL, [
'TID' => $tid,
@@ -333,7 +338,7 @@ class NicePaymentsApiService
]);
if ($response->failed()) {
throw new NicePayApiException('NicePayments query API error: HTTP ' . $response->status());
throw new NicePayApiException('NicePayments query API error: HTTP '.$response->status());
}
return $response->json() ?? [];
@@ -342,10 +347,10 @@ class NicePaymentsApiService
/**
* 망취소 요청 (서버 승인 중 예외 발생 시 결제 원천 취소)
*
* @param string $netCancelUrl 나이스페이먼츠가 전달한 망취소 URL
* @param string $txTid 임시 거래번호 (TxTid)
* @param string $authToken 인증 토큰
* @param int $amt 결제 금액
* @param string $netCancelUrl 나이스페이먼츠가 전달한 망취소 URL
* @param string $txTid 임시 거래번호 (TxTid)
* @param string $authToken 인증 토큰
* @param int $amt 결제 금액
*/
public function sendNetCancel(string $netCancelUrl, string $txTid, string $authToken, int $amt): void
{
@@ -357,7 +362,7 @@ class NicePaymentsApiService
try {
$ediDate = $this->computeEdiDate();
$signData = bin2hex(hash('sha256', $authToken . $this->mid . (string) $amt . $ediDate . $this->merchantKey, true));
$signData = bin2hex(hash('sha256', $authToken.$this->mid.(string) $amt.$ediDate.$this->merchantKey, true));
Http::timeout(10)->asForm()->post($netCancelUrl, [
'TID' => $txTid,
@@ -399,7 +404,7 @@ class NicePaymentsApiService
*/
public function generateSignData(string $ediDate, int $amt): string
{
return bin2hex(hash('sha256', $ediDate . $this->mid . (string) $amt . $this->merchantKey, true));
return bin2hex(hash('sha256', $ediDate.$this->mid.(string) $amt.$this->merchantKey, true));
}
private function computeEdiDate(): string
@@ -407,13 +412,37 @@ class NicePaymentsApiService
return $this->generateEdiDate();
}
/** NextAppURL / NetCancelURL이 나이스페이먼츠 공식 도메인인지 검증 (SSRF 방지) */
/**
* NextAppURL / NetCancelURL이 나이스페이먼츠 공식 도메인인지 검증 (SSRF 방지)
*
* 이 URL 은 결제창이 콜백으로 넘겨준 값이라 공격자가 지정할 수 있고, 서버는 여기에
* 인증 토큰·MID 를 실어 POST 한다. 접미사 대조를 원문 host 로 수행하면 연결 계층의
* 해석과 어긋난다 — `evil.example/.nicepay.co.kr`(U+FF0F)은 접미사 검사를 통과하지만
* UTS#46 정규화 후에는 host 가 `evil.example` 이 된다. 코어 검증기의 정규화를 그대로
* 재사용해(사본 금지) 판정 기준을 연결 계층과 일치시킨다.
*
* @param string $url 검증 대상 URL (결제 콜백이 제공)
* @return bool 나이스페이먼츠 공식 도메인이면 true
*/
private function isNicePayUrl(string $url): bool
{
$parsed = parse_url($url);
$scheme = $parsed['scheme'] ?? '';
$host = $parsed['host'] ?? '';
return $scheme === 'https' && str_ends_with($host, '.nicepay.co.kr');
if (($parsed['scheme'] ?? '') !== 'https') {
return false;
}
// userinfo(`user:pass@host`)는 host 위조의 핵심 벡터 — 존재만으로 거부
if (isset($parsed['user']) || isset($parsed['pass'])) {
return false;
}
$host = OutboundUrlValidator::normalizeHost((string) ($parsed['host'] ?? ''));
if ($host === null) {
return false;
}
return $host === 'nicepay.co.kr' || str_ends_with($host, '.nicepay.co.kr');
}
}
@@ -4,6 +4,8 @@ namespace Plugins\Sirsoft\PayNicepayments\Tests\Unit\Services;
use App\Services\PluginSettingsService;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\DataProvider;
use Plugins\Sirsoft\PayNicepayments\Exceptions\NicePayApiException;
use Plugins\Sirsoft\PayNicepayments\Services\NicePaymentsApiService;
use Plugins\Sirsoft\PayNicepayments\Tests\PluginTestCase;
@@ -66,7 +68,7 @@ class NicePaymentsApiServiceTest extends PluginTestCase
$authToken = 'AUTH_TOKEN_TEST';
$mid = self::TEST_MID;
$amt = 50000;
$signature = bin2hex(hash('sha256', $authToken . $mid . (string) $amt . self::TEST_MERCHANT_KEY, true));
$signature = bin2hex(hash('sha256', $authToken.$mid.(string) $amt.self::TEST_MERCHANT_KEY, true));
$this->assertTrue($service->verifyCallbackSignature($authToken, $mid, $amt, $signature));
}
@@ -84,7 +86,7 @@ class NicePaymentsApiServiceTest extends PluginTestCase
$authToken = 'AUTH_TOKEN_TEST';
$amt = 50000;
$signature = bin2hex(hash('sha256', $authToken . self::TEST_MID . (string) $amt . self::TEST_MERCHANT_KEY, true));
$signature = bin2hex(hash('sha256', $authToken.self::TEST_MID.(string) $amt.self::TEST_MERCHANT_KEY, true));
// 금액을 변조하여 서명 검증
$this->assertFalse($service->verifyCallbackSignature($authToken, self::TEST_MID, 99999, $signature));
@@ -250,4 +252,86 @@ class NicePaymentsApiServiceTest extends PluginTestCase
$service->queryTransaction('TID_TEST');
}
/**
* 결제창이 넘겨준 콜백 URL 은 공격자가 지정할 수 있고, 서버는 여기에 인증 토큰과
* MID 를 실어 POST 한다. 도메인 대조가 연결 계층의 host 해석과 어긋나면 그 자격증명이
* 외부로 나가고 내부망 호출까지 가능해진다.
*
* @param string $url 차단되어야 하는 NextAppURL
* @param string $reason 차단 사유 (실패 메시지용)
*/
#[DataProvider('forgedCallbackUrlProvider')]
public function test_authorize_payment_rejects_urls_that_only_look_like_nicepay(string $url, string $reason): void
{
$service = $this->makeService();
Http::fake(['*' => Http::response(['ResultCode' => '3001'], 200)]);
try {
$service->authorizePayment($url, 'TID', 'TOKEN', 50000);
$this->fail("차단되어야 하는 NextAppURL 이 통과함 ({$reason}): {$url}");
} catch (NicePayApiException) {
// 기대 동작
}
Http::assertNothingSent();
}
/**
* 정규화 후 나이스페이먼츠 공식 도메인인 URL 은 그대로 통과한다.
*
* @param string $url 허용되어야 하는 NextAppURL
*/
#[DataProvider('legitimateCallbackUrlProvider')]
public function test_authorize_payment_still_accepts_official_nicepay_urls(string $url): void
{
$service = $this->makeService();
Http::fake(['*' => Http::response(['ResultCode' => '3001', 'TID' => 'T'], 200)]);
$result = $service->authorizePayment($url, 'TID', 'TOKEN', 50000);
$this->assertSame('3001', $result['ResultCode']);
}
/**
* 공식 도메인으로 위장한 콜백 URL 목록.
*
* @return array<string, array{string, string}>
*/
public static function forgedCallbackUrlProvider(): array
{
return [
// U+FF0F 는 UTS#46 에서 ASCII `/` 로 매핑된다 — 접미사 대조는 통과하지만
// 연결 계층은 `evil.example` 을 host 로 읽는다.
'전각 슬래시로 감춘 외부 호스트' => ["https://evil.example\u{FF0F}.nicepay.co.kr/v1/authorize", '정규화 시 host 는 evil.example'],
'전각 슬래시로 감춘 루프백' => ["https://127.0.0.1\u{FF0F}.nicepay.co.kr/v1/authorize", '정규화 시 host 는 127.0.0.1'],
'전각 슬래시로 감춘 메타데이터' => ["https://169.254.169.254\u{FF0F}.nicepay.co.kr/latest/meta-data/", '정규화 시 host 는 169.254.169.254'],
'userinfo 위장' => ['https://pay.nicepay.co.kr@evil.example/v1/authorize', 'userinfo(@) 뒤가 실제 host'],
'접미사 확장 도메인' => ['https://pay.nicepay.co.kr.evil.example/v1/authorize', '화이트리스트가 접두사일 뿐'],
'http scheme' => ['http://pay.nicepay.co.kr/v1/authorize', 'https 아님'],
'완전 무관 도메인' => ['https://evil.example/v1/authorize', '공식 도메인 아님'],
'하이픈 접미사' => ['https://pay.nicepay.co.kr-evil.example/v1/authorize', '접미사 확장'],
];
}
/**
* 정상 통과해야 하는 공식 콜백 URL 목록.
*
* @return array<string, array{string}>
*/
public static function legitimateCallbackUrlProvider(): array
{
return [
'표준 인증 URL' => ['https://pay.nicepay.co.kr/v1/authorize'],
'다른 서브도메인' => ['https://webapi.nicepay.co.kr/webapi/cancel_process.jsp'],
'대소문자 혼용' => ['https://PAY.NicePay.CO.KR/v1/authorize'],
'후행 점 표기' => ['https://pay.nicepay.co.kr./v1/authorize'],
// U+00AD SOFT HYPHEN 은 UTS#46 에서 제거된다. 정규화 후 host 는
// `evil.example.nicepay.co.kr` — 나이스페이먼츠가 소유한 서브도메인이므로
// 검증기와 연결 계층의 판정이 일치한다(위장 통로가 아니다).
'soft hyphen 제거 후 공식 서브도메인' => ["https://evil.example\u{00AD}.nicepay.co.kr/v1/authorize"],
];
}
}
@@ -1,6 +1,6 @@
# audit:allow test-scenario-coverage reason: Phase 2 SSoT — 본 매니페스트는 나이스페이먼츠 콜백 보안 매트릭스의 명세 문서. AuthToken Cache 기반 dedupe 와 replay/net-cancel 의 cross product 전체 자동 검증은 Phase 3 (침투 PoC) 에서 처리.
feature: 콜백 보안 방어 (replay attack / authToken dedupe / post-approve net cancel)
feature: 콜백 보안 방어 (replay attack / authToken dedupe / post-approve net cancel / 승인 URL 호스트 정규화)
description: |
나이스페이먼츠 결제 콜백 처리 경로의 3가지 핵심 보안 위협에 대한 방어 매트릭스.
@@ -18,7 +18,7 @@ description: |
axes:
context: [authorize_card, authorize_vbank, naverpay_callback, kakaopay_callback]
threat: [replay_db_long, replay_token_short, post_approve_failure]
threat: [replay_db_long, replay_token_short, post_approve_failure, forged_approval_url_host]
callback_state: [first_arrival, second_arrival_outside_60s, second_arrival_within_60s, domain_succeeds, domain_fails]
exclusions:
@@ -33,6 +33,13 @@ exclusions:
- { threat: post_approve_failure, callback_state: second_arrival_outside_60s, reason: "post-approve 실패는 도메인 결과 분기" }
- { threat: post_approve_failure, callback_state: second_arrival_within_60s, reason: "post-approve 실패는 도메인 결과 분기" }
- { threat: post_approve_failure, context: authorize_vbank, reason: "vbank 는 비동기 입금 — post-approve 단계 없음" }
- { threat: forged_approval_url_host, callback_state: first_arrival, reason: "호스트 판정은 도착 차수와 무관하다" }
- { threat: forged_approval_url_host, callback_state: second_arrival_outside_60s, reason: "동일" }
- { threat: forged_approval_url_host, callback_state: second_arrival_within_60s, reason: "동일" }
- { threat: forged_approval_url_host, callback_state: domain_succeeds, reason: "호스트가 거부되면 도메인 처리에 도달하지 못한다" }
- { threat: forged_approval_url_host, callback_state: domain_fails, reason: "동일" }
- { threat: forged_approval_url_host, context: naverpay_callback, reason: "간편결제 콜백은 승인 URL 을 결제창에서 받지 않는다" }
- { threat: forged_approval_url_host, context: kakaopay_callback, reason: "동일" }
effects:
# DB 영구 replay defense — PreventsReplayCallback trait
@@ -51,6 +58,14 @@ effects:
- auth_token_blocked_returns_4xx_or_idempotent
- auth_token_logged_when_dedupe_hit
# 승인 URL 호스트 정규화 (KVE-2026-2010 형제 — 검증기 미경유 독립 경로)
- approval_url_host_check_reuses_core_normalize_host_not_a_copy
- host_that_only_looks_like_nicepay_is_rejected
- dot_equivalent_characters_cannot_disguise_the_host
- url_carrying_userinfo_is_rejected
- official_nicepay_hosts_are_still_accepted
- rejected_url_never_receives_the_auth_token_or_merchant_id
# Post-approve net cancel
- post_approve_failure_triggers_send_net_cancel
- net_cancel_sends_pg_cancel_api_call
@@ -58,7 +73,8 @@ effects:
- net_cancel_passes_approved_tid
- net_cancel_passes_full_amount
test_files: []
test_files:
- plugins/_bundled/sirsoft-pay_nicepayments/tests/Unit/Services/NicePaymentsApiServiceTest.php
manual_verification:
- description: "Replay DB 영구 PoC — authorize_card 재전송"
@@ -137,6 +137,10 @@
| 가상계좌 웹훅의 secret 대조를 생략하거나 항상 통과 | `payment_meta.toss_secret`과 웹훅 본문의 secret을 항상 대조 | 토스는 notify IP 목록·서명을 제공하지 않아 secret 대조가 유일한 위조 방지 수단이다 — 생략하면 제3자가 임의 주문에 대해 위조 입금통보를 보낼 수 있다 |
| `core.plugin_settings.before_save` 리스너에 `sync: true` 없이 등록 | 저장을 막아야 하는 검증 훅은 반드시 `sync: true` | 기본값(비동기 큐)이면 `ValidationException`이 워커 안에서 죽고 저장이 그대로 진행되어 검증이 무력화된다 |
| 라이브 시크릿 키를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 서버측 API를 위조 호출할 수 있다 |
| `fail()` 에서 `failPayment()` 등 주문 상태를 바꾸는 호출 | 로그 + `resolveFailUrl()` 만 | 이 엔드포인트는 인증도 서명도 없는 GET 이고 `orderId`·`code` 가 전부 쿼리스트링에서 온다. 실패 처리를 수행하면 링크 하나로 남의 결제대기 주문을 취소시킬 수 있다 |
| 결제 실패를 `fail()` 이 기록해 줄 것으로 가정 | 구매자 정보를 대조하는 `POST /api/plugins/sirsoft-tosspayments/payment/close-report` 가 기록한다 | 결제 성립은 `success()` 의 서버 `confirmPayment`, 결제완료 후 취소는 secret 대조를 통과한 웹훅이 담당한다. 주문을 실패로 전이시키는 결제창 경로는 close-report 하나뿐이다 |
| 결제창 컨텍스트(구매자 정보)를 `window` 전역에만 보관 | `rememberPendingClose()` 로 sessionStorage 에 남긴다 | 결제창은 전체 페이지 이동으로 열리고 돌아와 JS 컨텍스트가 소실된다. 전역에만 두면 실패 화면에서 보고할 근거가 사라져 결제 실패가 어디에도 기록되지 않는다 |
| 실패 화면에서 보고가 닿지 못한 주문을 방치 | 이커머스 모듈의 만료 주문 자동 정리가 최종 안전망 | 브라우저를 바로 닫으면 보고가 나가지 않는다. 두 경로가 함께 있어야 선차감 마일리지가 무기한 묶이지 않는다 |
<!-- @intent END -->
## 7. 테스트 실행
@@ -144,8 +148,8 @@
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 12개 | `plugins/_bundled/sirsoft-tosspayments/tests` |
| Vitest | 5개 | `vitest.config.ts` |
| PHPUnit | 13개 | `plugins/_bundled/sirsoft-tosspayments/tests` |
| Vitest | 6개 | `vitest.config.ts` |
| Playwright | 0개 | — |
| 시나리오 매니페스트 | 3개 | `tests/scenarios` |
@@ -6,8 +6,14 @@
## [1.0.3] - 2026-08-31
### Security
- 제3자가 남의 주문번호만 알면 주소 하나로 그 주문을 취소시킬 수 있던 문제를 수정했습니다. 결제 실패 안내 주소는 로그인도 서명 확인도 거치지 않고 주문번호와 실패 사유를 주소에 담아 받는 경로여서, 그 주소를 열기만 해도 해당 주문이 결제 실패로 처리되었습니다. 이제 이 주소는 주문 상태를 바꾸지 않고 결제 화면으로 되돌려 보내기만 합니다. 결제 성립은 종전처럼 서버 승인 확인으로, 결제완료 후 취소는 서명이 확인된 입금·취소 통보로 처리됩니다.
### Added
- 결제창을 닫거나 결제가 거절됐을 때 그 사실을 서버에 알리는 경로를 추가했습니다. 구매자 본인인지 확인한 뒤에만 주문을 실패로 기록하므로, 남의 주문번호를 아는 것만으로는 그 주문을 건드릴 수 없습니다. 다른 결제사 플러그인이 이미 제공하던 것과 같은 방식입니다.
- 결제가 거절되어 실패 화면으로 돌아오면 그 사실이 자동으로 서버에 기록됩니다. 종전에는 결제 실패가 어디에도 남지 않았습니다.
- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-tosspayments",
"version": "1.0.1",
"version": "1.0.3",
"components": {
"basic": [],
"composite": [],
File diff suppressed because one or more lines are too long
@@ -3,7 +3,7 @@
> plugins/_bundled/sirsoft-tosspayments · 플러그인
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**훅 수**: 4 · **구독 훅 수**: 9 · **라우트 수**: 4 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 1 · **핸들러 수**: 1
**훅 수**: 4 · **구독 훅 수**: 9 · **라우트 수**: 5 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 1 · **핸들러 수**: 1
<!-- @generated:stats END -->
## 문서 목차
@@ -71,10 +71,92 @@ Location: /shop/checkout?error=PAY_PROCESS_CANCELED&orderId=20260711-000001
주의사항:
- 이 엔드포인트는 **주문 상태를 변경하지 않는다**. 결제 취소 이력 기록은 프론트엔드가 별도 API(`/modules/sirsoft-ecommerce/orders/{orderNumber}/cancel-payment`)로 수행한다.
- 이 엔드포인트는 **주문 상태를 변경하지 않는다**. 인증도 서명도 없는 GET 이고 `orderId`·`code` 가 전부 쿼리스트링에서 오므로, 실패 처리를 수행하면 링크 하나로 남의 결제대기 주문을 취소시킬 수 있다. 결제 실패 기록은 구매자 정보를 대조하는 `POST /api/plugins/sirsoft-tosspayments/payment/close-report` 가 담당하며, 실패 화면에 도착한 프론트엔드가 그 경로로 보고한다.
- 결제창을 띄우기 전 단계에서 사용자가 취소한 경우(SDK `USER_CANCEL`)는 이 콜백을 타지 않고 프론트엔드에서 직접 처리된다.
### POST /api/plugins/sirsoft-tosspayments/payment/close-report
<!-- @generated:start:api.plugins.sirsoft-tosspayments.payment.close-report -->
- **라우트명**: `api.plugins.sirsoft-tosspayments.payment.close-report`
- **컨트롤러**: `Plugins\Sirsoft\Tosspayments\Controllers\PaymentCloseReportController@store`
- **인증/권한**: 공개 (인증 불필요)
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| orderId | body | string | 예 | max 40 | 결제창을 닫거나 결제가 거절된 대상 주문의 주문번호. 서버가 이 값으로 주문을 조회해 결제 실패/취소 이력을 기록한다. |
| amount | body | integer | 예 | min 1 | 결제 금액. 저장된 주문 청구액과 일치하는지 검증한다. |
| buyer_email | body | string | 아니오 | max 255 | 구매자 이메일. 주문의 구매자 정보와 대조해 본인 요청인지 확인한다. |
| buyer_phone | body | string | 아니오 | max 30 | 구매자 전화번호. 주문의 구매자 정보와 대조해 본인 요청인지 확인한다. |
| code | body | string | 아니오 | max 60 | 토스 실패 코드. 값이 있으면 결제 거절로, 없으면 결제창 닫힘으로 기록한다. |
| reason | body | string | 아니오 | max 160 | 사람이 읽을 실패 사유. 취소 이력에 남는다. |
**요청 예시**
```http
POST /api/plugins/sirsoft-tosspayments/payment/close-report HTTP/1.1
Host: api.example.com
Accept: application/json
Content-Type: application/json
{
"orderId": "20260711-000001",
"amount": 10000,
"buyer_email": "buyer@example.com",
"buyer_phone": "01012345678",
"code": "REJECT_CARD_COMPANY",
"reason": "카드사에서 승인을 거절했습니다."
}
```
**응답 필드** (`data` 내부)
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| status | string | `recorded` | 처리 결과. 기록했으면 `recorded`, 대상이 아니어서 넘어갔으면 `ignored`. |
| reason | string | `order_not_payable` | `status` 가 `ignored` 일 때만 포함. 무시 사유(`order_not_payable` / `payment_already_paid`). |
**응답 예시**
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "성공적으로 처리되었습니다.",
"data": {
"status": "recorded"
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 403 | Forbidden | 요청의 구매자 정보(`buyer_email` / `buyer_phone`)가 주문의 구매자와 일치하지 않는 경우 |
| 404 | Not Found | `orderId` 에 해당하는 주문이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지), 주문 통화가 청구 불가, 금액이 주문 청구액과 불일치 |
| 429 | Too Many Requests | 동일 IP·`orderId` 조합에서 분당 20회를 초과해 요청한 경우 |
<!-- @generated:end -->
**설명**
**주문을 실패로 전이시키는 유일한 결제창 경로**입니다. 브라우저 리턴 콜백(`/payment/fail`)은 인증도 서명도 없고 주문번호가 쿼리스트링으로 오므로 주문 상태를 바꾸지 않습니다. 정당한 결제 실패는 구매자 이메일·전화 대조를 통과한 이 요청으로만 기록되며, 이는 다른 결제사 플러그인(KCP·KG이니시스·나이스페이)의 close-report 와 같은 계약입니다.
프론트엔드는 결제창을 열기 직전에 구매자 정보를 브라우저 세션에 남겨 두었다가, 결제가 거절되어 실패 화면으로 돌아왔을 때 그 정보로 이 엔드포인트를 호출합니다. 결제창은 전체 페이지 이동으로 열리고 돌아오므로 화면의 컨텍스트는 그 사이 소실되기 때문입니다.
`code` 유무로 기록이 갈립니다 — 값이 있으면 결제 거절(`failure_stage=payment_failed`), 없으면 결제창 닫힘(`failure_stage=window_closed`)으로 남아 운영자가 원인을 구분할 수 있습니다.
이미 결제가 성립한 주문(`payment_status=paid`)과 결제 가능 상태가 아닌 주문은 성공 응답에 `status: ignored` 로 무시합니다 — 결제 성공 콜백(`success` → `confirmPayment`)과 경쟁할 때 주문/옵션 상태가 어긋나는 것을 차단합니다.
보고가 끝내 도달하지 못한 주문(브라우저를 바로 닫는 등)은 이커머스 모듈의 만료 주문 자동 정리가 최종 안전망으로 처리합니다.
### GET /plugins/sirsoft-tosspayments/payment/success
<!-- @generated:start:web.plugins.sirsoft-tosspayments.payment.success -->
- **라우트명**: `web.plugins.sirsoft-tosspayments.payment.success`
@@ -71,6 +71,7 @@ _등록하는 메뉴가 없습니다._
<!-- @generated:routes START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 파일 | URL prefix |
|---|---|---|
| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-tosspayments/...` |
| `web` | `src/routes/web.php` | `/plugins/sirsoft-tosspayments/...` |
확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
@@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
forgetPendingClose,
rememberPendingClose,
reportPaymentFailureOnReturn,
} from '../paymentCloseReport';
function windowRecord(): Record<string, any> {
return window as unknown as Record<string, any>;
}
/**
* 화면 주소를 바꿔 결제 리턴 상황을 재현한다.
*/
function setLocation(pathname: string, search: string): void {
Object.defineProperty(window, 'location', {
configurable: true,
value: { pathname, search, origin: 'https://shop.example' },
});
}
const CONTEXT = {
orderId: 'ORD-TOSS-1001',
amount: 10000,
buyer_email: 'buyer@example.com',
buyer_phone: '01012345678',
};
describe('토스페이먼츠 결제 실패 보고', () => {
beforeEach(() => {
window.sessionStorage.clear();
});
afterEach(() => {
delete windowRecord().G7Core;
vi.restoreAllMocks();
window.sessionStorage.clear();
});
it('실패 화면으로 돌아오면 저장해 둔 구매자 정보로 보고한다', async () => {
const apiPost = vi.fn().mockResolvedValue({ success: true });
windowRecord().G7Core = { api: { post: apiPost } };
rememberPendingClose(CONTEXT);
setLocation('/shop/checkout', '?error=PAY_PROCESS_CANCELED&message=cancelled&orderId=ORD-TOSS-1001');
await reportPaymentFailureOnReturn();
expect(apiPost).toHaveBeenCalledWith(
'/api/plugins/sirsoft-tosspayments/payment/close-report',
expect.objectContaining({
orderId: 'ORD-TOSS-1001',
amount: 10000,
buyer_email: 'buyer@example.com',
buyer_phone: '01012345678',
code: 'PAY_PROCESS_CANCELED',
}),
);
});
it('보고 후에는 저장분을 지워 중복 보고하지 않는다', async () => {
const apiPost = vi.fn().mockResolvedValue({ success: true });
windowRecord().G7Core = { api: { post: apiPost } };
rememberPendingClose(CONTEXT);
setLocation('/shop/checkout', '?error=PAY_PROCESS_CANCELED&orderId=ORD-TOSS-1001');
await reportPaymentFailureOnReturn();
await reportPaymentFailureOnReturn();
expect(apiPost).toHaveBeenCalledTimes(1);
});
it('저장해 둔 정보가 없으면 아무것도 보내지 않는다', async () => {
const apiPost = vi.fn();
windowRecord().G7Core = { api: { post: apiPost } };
setLocation('/shop/checkout', '?error=PAY_PROCESS_CANCELED&orderId=ORD-TOSS-1001');
await reportPaymentFailureOnReturn();
expect(apiPost).not.toHaveBeenCalled();
});
it('다른 주문번호로 돌아왔으면 보고하지 않는다', async () => {
const apiPost = vi.fn();
windowRecord().G7Core = { api: { post: apiPost } };
rememberPendingClose(CONTEXT);
setLocation('/shop/checkout', '?error=PAY_PROCESS_CANCELED&orderId=ORD-TOSS-OTHER');
await reportPaymentFailureOnReturn();
expect(apiPost).not.toHaveBeenCalled();
});
it('결제 완료 화면으로 돌아오면 보고하지 않고 저장분만 지운다', async () => {
const apiPost = vi.fn();
windowRecord().G7Core = { api: { post: apiPost } };
rememberPendingClose(CONTEXT);
setLocation('/shop/orders/ORD-TOSS-1001/complete', '');
await reportPaymentFailureOnReturn();
expect(apiPost).not.toHaveBeenCalled();
expect(window.sessionStorage.getItem('g7:sirsoft-tosspayments:pendingClose')).toBeNull();
});
it('실패 표시가 전혀 없으면 판단하지 않고 저장분을 남겨 둔다', async () => {
const apiPost = vi.fn();
windowRecord().G7Core = { api: { post: apiPost } };
rememberPendingClose(CONTEXT);
setLocation('/shop/checkout', '');
await reportPaymentFailureOnReturn();
expect(apiPost).not.toHaveBeenCalled();
expect(window.sessionStorage.getItem('g7:sirsoft-tosspayments:pendingClose')).not.toBeNull();
});
it('G7Core API 가 없으면 fetch 로 보고한다', async () => {
const fetchSpy = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
windowRecord().fetch = fetchSpy;
rememberPendingClose(CONTEXT);
setLocation('/shop/checkout', '?error=REJECT_CARD_COMPANY&orderId=ORD-TOSS-1001');
await reportPaymentFailureOnReturn();
expect(fetchSpy).toHaveBeenCalledWith(
'/api/plugins/sirsoft-tosspayments/payment/close-report',
expect.objectContaining({ method: 'POST' }),
);
});
it('보고가 실패해도 예외를 던지지 않는다', async () => {
const apiPost = vi.fn().mockRejectedValue(new Error('network down'));
windowRecord().G7Core = { api: { post: apiPost } };
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
rememberPendingClose(CONTEXT);
setLocation('/shop/checkout', '?error=PAY_PROCESS_CANCELED&orderId=ORD-TOSS-1001');
await expect(reportPaymentFailureOnReturn()).resolves.toBeUndefined();
});
it('forgetPendingClose 는 저장분을 지운다', () => {
rememberPendingClose(CONTEXT);
expect(window.sessionStorage.getItem('g7:sirsoft-tosspayments:pendingClose')).not.toBeNull();
forgetPendingClose();
expect(window.sessionStorage.getItem('g7:sirsoft-tosspayments:pendingClose')).toBeNull();
});
});
@@ -15,6 +15,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { rememberPendingClose } from '../paymentCloseReport';
interface EscrowProduct {
id: string;
name: string;
@@ -295,6 +297,16 @@ export async function requestPaymentHandler(action: any, _context?: any): Promis
attachEscrowProducts(requestPayload, config, pgPaymentData);
}
// 결제창은 전체 페이지 이동으로 열리고 돌아오므로, 실패 화면에서 서버에 보고할 때 쓸
// 구매자 정보를 미리 남겨 둔다. 브라우저 리턴 콜백은 인증이 없어 주문 상태를 바꾸지 않고,
// 소유권을 대조하는 close-report 만이 정당한 결제 실패를 기록할 수 있다.
rememberPendingClose({
orderId: pgPaymentData.order_number,
amount: pgPaymentData.amount,
buyer_email: pgPaymentData.customer_email ?? '',
buyer_phone: pgPaymentData.customer_phone ?? '',
});
await payment.requestPayment(requestPayload);
// → 브라우저가 successUrl 또는 failUrl로 리다이렉트됨
@@ -7,6 +7,7 @@
import { handlerMap } from './handlers';
import { installAdminPaymentMethodBrandInjector } from './adminPaymentMethodBrandInjector';
import { reportPaymentFailureOnReturn } from './paymentCloseReport';
const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';
@@ -67,6 +68,11 @@ function initPlugin(): void {
// (관리자 화면은 결제 핸들러를 쓰지 않으므로 ActionDispatcher 준비를 기다릴 이유가 없다).
installAdminPaymentMethodBrandInjector();
// 결제 실패로 돌아온 화면이면 서버에 보고한다. 브라우저 리턴 콜백은 인증이 없어 주문
// 상태를 바꾸지 않으므로, 소유권을 대조하는 close-report 가 정당한 실패를 기록하는
// 유일한 경로다. 저장해 둔 정보가 없으면 아무 일도 하지 않는다.
void reportPaymentFailureOnReturn();
const doInit = () => {
const count = registerHandlers();
@@ -0,0 +1,187 @@
/**
* 토스페이먼츠 결제창 닫힘·결제 실패 보고
*
* 브라우저 리턴 콜백(`/payment/fail`)은 인증도 서명도 없고 주문번호가 쿼리스트링으로 오므로
* 주문 상태를 바꾸지 않는다. 정당한 결제 실패를 기록하는 것은 구매자 정보를 대조하는
* close-report 엔드포인트뿐이며, 이 모듈이 그 호출을 담당한다.
*
* 결제창은 전체 페이지 이동으로 열리고 돌아오므로, 결제 요청 직전에 구매자 정보를
* sessionStorage 에 남겨 두었다가 실패 화면으로 돌아왔을 때 꺼내 쓴다. sessionStorage 는
* 같은 탭에서 외부 도메인을 다녀와도 유지된다.
*/
const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';
const STORAGE_KEY = 'g7:sirsoft-tosspayments:pendingClose';
const CLOSE_REPORT_PATH = '/api/plugins/sirsoft-tosspayments/payment/close-report';
export interface PaymentCloseReportContext {
orderId: string;
amount: number;
buyer_email?: string;
buyer_phone?: string;
}
/**
* sessionStorage 접근은 브라우저 설정(사이트 데이터 차단·시크릿 모드)에 따라 예외를 던진다.
* 보고는 편의 장치이므로 실패해도 결제 흐름을 막지 않는다.
*/
function safeSessionStorage(): Storage | null {
try {
return window.sessionStorage ?? null;
} catch {
return null;
}
}
/**
* 결제창을 열기 직전에 구매자 정보를 저장합니다.
*
* @param context 결제창 닫힘 보고에 필요한 주문·구매자 정보
*/
export function rememberPendingClose(context: PaymentCloseReportContext): void {
const storage = safeSessionStorage();
if (!storage) {
return;
}
try {
storage.setItem(STORAGE_KEY, JSON.stringify(context));
} catch {
// 저장 실패는 무시 — 만료 자동 정리가 최종 안전망이다.
}
}
/**
* 저장해 둔 구매자 정보를 지웁니다 (보고 완료 또는 결제 성공 시).
*/
export function forgetPendingClose(): void {
const storage = safeSessionStorage();
if (!storage) {
return;
}
try {
storage.removeItem(STORAGE_KEY);
} catch {
// 무시
}
}
/**
* 저장해 둔 구매자 정보를 읽습니다.
*
* @returns 저장된 정보, 없거나 형식이 깨졌으면 null
*/
function readPendingClose(): PaymentCloseReportContext | null {
const storage = safeSessionStorage();
if (!storage) {
return null;
}
try {
const raw = storage.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as PaymentCloseReportContext;
return parsed && typeof parsed.orderId === 'string' && parsed.orderId !== ''
? parsed
: null;
} catch {
return null;
}
}
/**
* close-report 엔드포인트에 보고합니다.
*
* @param context 주문·구매자 정보
* @param code 결제 실패 코드 (결제창을 닫은 경우 빈 값)
* @param reason 사람이 읽을 실패 사유
*/
async function postCloseReport(
context: PaymentCloseReportContext,
code: string,
reason: string,
): Promise<void> {
const payload = {
orderId: context.orderId,
amount: Number(context.amount),
buyer_email: context.buyer_email ?? '',
buyer_phone: context.buyer_phone ?? '',
code: code.slice(0, 60),
reason: reason.trim().slice(0, 160),
};
const apiClient = ((window as any).G7Core)?.api;
if (typeof apiClient?.post === 'function') {
await apiClient.post(CLOSE_REPORT_PATH, payload);
return;
}
await fetch(CLOSE_REPORT_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
});
}
/**
* 결제 실패 화면으로 돌아왔으면 저장해 둔 정보로 서버에 보고합니다.
*
* 결제 성공으로 돌아온 경우에는 보고하지 않고 저장분만 지운다 — 성공 확정은 서버의
* `confirmPayment` 가 담당하므로 여기서 관여할 것이 없다.
*
* 플러그인 부팅 시 1회 호출한다.
*/
export async function reportPaymentFailureOnReturn(): Promise<void> {
const pending = readPendingClose();
if (!pending) {
return;
}
let params: URLSearchParams;
try {
params = new URLSearchParams(window.location.search);
} catch {
return;
}
const orderIdInUrl = params.get('orderId') ?? '';
// 저장분과 화면의 주문번호가 다르면 이번 이동과 무관한 잔여물이다 — 보고하지 않는다.
if (orderIdInUrl !== '' && orderIdInUrl !== pending.orderId) {
return;
}
// 결제 완료 화면으로 돌아왔으면 보고 대상이 아니다.
if (/\/(complete|success)(\/|$|\?)/.test(window.location.pathname)) {
forgetPendingClose();
return;
}
const code = params.get('error') ?? '';
const message = params.get('message') ?? '';
// 실패 표시가 전혀 없으면 결제창을 열기만 하고 돌아온 경우일 수 있다 — 판단하지 않는다.
if (code === '' && orderIdInUrl === '') {
return;
}
// 중복 보고를 막기 위해 요청 전에 먼저 지운다. 서버도 멱등하게 처리하지만
// (이미 취소된 주문은 order_not_payable 로 무시) 불필요한 요청 자체를 줄인다.
forgetPendingClose();
try {
await postCloseReport(pending, code, message !== '' ? message : code);
} catch (error) {
console.warn(`[${PLUGIN_IDENTIFIER}] failed to report payment failure`, error);
}
}
@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\Tosspayments\Concerns;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderAddress;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
/**
* 결제창 닫힘·결제 실패를 소유권 검증 후 기록하는 공통 절차.
*
* 브라우저 리턴 콜백(`/payment/success`·`/payment/fail`)은 인증도 서명도 없고 주문번호가
* 쿼리스트링으로 오므로 주문 상태를 바꾸지 않는다. 주문을 실패로 전이시키는 것은 구매자
* 정보를 대조한 이 경로뿐이며, 다른 결제사 플러그인(KCP·KG이니시스·나이스페이)과 같은 계약이다.
*/
trait RecordsPaymentWindowClosure
{
private const TOSS_PROVIDER = 'tosspayments';
/**
* 주문의 결제 청구액을 결제 통화 기준으로 계산합니다.
*
* 결제 청구액 SSoT 는 결제 통화(order_currency) 환산액이다. base(total_due_amount)를 직접
* 비교하면 base≠결제 통화인 주문에서 PG 청구 통화와 단위가 어긋난다.
*
* @param Order $order 대상 주문
* @return int 결제 통화 기준 청구액
*/
protected function expectedPaymentPrice(Order $order): int
{
return app(CurrencyConversionService::class)->resolveOrderPaymentChargeAmount($order);
}
/**
* 결제 청구액을 계산하되 통화가 청구 불가하면 null 을 반환합니다.
*
* @param Order $order 대상 주문
* @param string $context 로그 문맥
* @param array<string, mixed> $logContext 추가 로그 항목
* @return int|null 청구액, 통화가 청구 불가하면 null
*/
protected function resolveExpectedPaymentPriceOrNull(Order $order, string $context, array $logContext = []): ?int
{
try {
return $this->expectedPaymentPrice($order);
} catch (InvalidArgumentException $e) {
Log::error('TossPayments: payment currency is not chargeable', array_merge([
'context' => $context,
'order_id' => $order->id,
'order_number' => $order->order_number,
'currency' => $order->currency,
'error' => $e->getMessage(),
], $logContext));
return null;
}
}
/**
* 요청이 주문의 구매자 본인에게서 온 것인지 대조합니다.
*
* 이 대조가 주문 상태를 바꿀 자격의 근거다. 배송지가 없으면 대조할 기준이 없으므로 통과시킨다
* (다른 결제사 플러그인과 동일 계약).
*
* @param Request $request 검증 대상 요청
* @param Order $order 대상 주문
* @return bool 구매자 정보가 일치하면 true
*/
protected function requestMatchesOrderBuyer(Request $request, Order $order): bool
{
/** @var OrderAddress|null $address */
$address = $order->shippingAddress;
if (! $address) {
return true;
}
$expectedEmail = strtolower(trim((string) $address->orderer_email));
if ($expectedEmail !== '') {
$receivedEmail = strtolower(trim((string) $request->input('buyer_email', '')));
if ($receivedEmail === '' || $receivedEmail !== $expectedEmail) {
return false;
}
}
$expectedPhone = $this->digitsOnly((string) $address->orderer_phone);
if ($expectedPhone !== '') {
$receivedPhone = $this->digitsOnly((string) $request->input('buyer_phone', ''));
if ($receivedPhone === '' || $receivedPhone !== $expectedPhone) {
return false;
}
}
return true;
}
/**
* 결제창 닫힘/실패를 주문에 반영합니다.
*
* @param OrderProcessingService $orderService 주문 처리 서비스
* @param Order $order 대상 주문
* @param string $failureCode 실패 코드
* @param string $failureMessage 실패 메시지
* @param string|null $cancelMessage 취소 이력에 남길 메시지 (미전달 시 실패 메시지)
* @param string $failureStage 실패 단계 (window_closed | payment_failed)
* @return Order 반영된 주문
*/
protected function markPaymentWindowClosed(
OrderProcessingService $orderService,
Order $order,
string $failureCode,
string $failureMessage,
?string $cancelMessage = null,
string $failureStage = 'window_closed',
): Order {
if (! $order->order_status->isBeforePayment()) {
return $order;
}
$failedOrder = $orderService->failPayment($order, $failureCode, $failureMessage);
$cancelledOrder = $orderService->recordPaymentCancellation(
$failedOrder,
$failureCode,
$cancelMessage ?: $failureMessage,
);
return $this->markTossPaymentFailureRecord(
$cancelledOrder,
$failureCode,
$cancelMessage ?: $failureMessage,
$failureStage,
PaymentStatusEnum::CANCELLED,
);
}
/**
* 결제 실패 이력을 결제 레코드에 남깁니다.
*
* @param Order $order 대상 주문
* @param string $failureCode 실패 코드
* @param string $failureMessage 실패 메시지
* @param string $failureStage 실패 단계
* @param PaymentStatusEnum $paymentStatus 기록할 결제 상태
* @return Order 갱신된 주문
*/
protected function markTossPaymentFailureRecord(
Order $order,
string $failureCode,
string $failureMessage,
string $failureStage,
PaymentStatusEnum $paymentStatus = PaymentStatusEnum::FAILED,
): Order {
/** @var OrderPayment|null $payment */
$payment = $order->payment;
if (! $payment || ! $payment->exists) {
return $order;
}
$now = now()->toIso8601String();
$paymentMeta = $payment->payment_meta ?? [];
$history = $paymentMeta['failure_history'] ?? [];
$history = is_array($history) ? $history : [];
$history[] = [
'code' => $failureCode,
'message' => $failureMessage,
'stage' => $failureStage,
'failed_at' => $now,
];
$paymentMeta['failure_history'] = array_slice($history, -5);
$paymentMeta['failure_source'] = self::TOSS_PROVIDER;
$paymentMeta['failure_code'] = $failureCode;
$paymentMeta['failure_message'] = $failureMessage;
$paymentMeta['failure_stage'] = $failureStage;
$paymentMeta['failed_at'] = $now;
$payment->update([
'pg_provider' => self::TOSS_PROVIDER,
'payment_status' => $paymentStatus->value,
'payment_meta' => $paymentMeta,
]);
return $order->fresh('payment') ?? $order;
}
/**
* 문자열에서 숫자만 남깁니다 (전화번호 대조용).
*
* @param string $value 원본 문자열
* @return string 숫자만 남은 문자열
*/
protected function digitsOnly(string $value): string
{
return preg_replace('/[^0-9]/', '', $value) ?? '';
}
}
@@ -176,14 +176,11 @@ class PaymentCallbackController
'orderId' => $orderId,
]);
if ($orderId) {
$order = $this->orderService->findByOrderNumber($orderId);
if ($order) {
$this->orderService->failPayment($order, $code, $message);
}
}
// 이 엔드포인트는 인증도 서명도 없는 GET 이고, orderId·code·message 는 전부 쿼리스트링에서
// 온다. 즉 "실패했다" 는 주장 자체를 누구나 만들 수 있고 대상 주문도 마음대로 고를 수 있다.
// 그 주장만으로 주문을 실패 처리하면 링크 하나로 남의 결제대기 주문을 취소시킬 수 있으므로,
// 여기서는 주문 상태를 바꾸지 않고 기록과 안내만 한다.
// 실제 결제 성립은 success() 의 서버 confirm 이, 결제완료 후 취소는 서명 검증된 웹훅이 담당한다.
return redirect($this->resolveFailUrl([
'error' => $code,
'message' => $message,
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\Tosspayments\Controllers;
use App\Helpers\ResponseHelper;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\RateLimiter;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Plugins\Sirsoft\Tosspayments\Concerns\RecordsPaymentWindowClosure;
use Plugins\Sirsoft\Tosspayments\Http\Requests\PaymentCloseReportRequest;
/**
* 토스페이먼츠 결제창 닫힘·결제 실패 보고 컨트롤러
*
* 브라우저 리턴 콜백(`/payment/fail`)은 인증도 서명도 없고 주문번호가 쿼리스트링으로 오므로
* 주문 상태를 바꾸지 않는다. 주문을 실패로 전이시키는 것은 구매자 정보를 대조한 이 경로뿐이며,
* KCP·KG이니시스·나이스페이 플러그인의 close-report 와 같은 계약이다.
*/
class PaymentCloseReportController
{
use RecordsPaymentWindowClosure;
private const FAILURE_CODE = 'USER_CANCEL';
private const FAILURE_MESSAGE = '사용자가 토스페이먼츠 결제창을 닫았습니다.';
public function __construct(
private readonly OrderProcessingService $orderService,
) {}
/**
* 결제창 닫힘·결제 실패 보고를 검증하고 결제 실패/취소 이력을 기록합니다.
*
* @param PaymentCloseReportRequest $request 결제창 닫힘 보고 요청
* @return JsonResponse 닫힘 보고 처리 결과
*/
public function store(PaymentCloseReportRequest $request): JsonResponse
{
$validated = $request->validated();
$orderId = $validated['orderId'];
$amount = (int) $validated['amount'];
$rateLimitKey = $this->rateLimitKey($request->ip() ?? '', $orderId);
if (RateLimiter::tooManyAttempts($rateLimitKey, 20)) {
return ResponseHelper::error('common.failed', 429, [
'message' => ['Too many TossPayments payment close reports. Please try again later.'],
]);
}
RateLimiter::hit($rateLimitKey, 60);
$order = $this->orderService->findByOrderNumber($orderId);
if (! $order) {
return ResponseHelper::error('common.failed', 404, [
'message' => ['Order not found.'],
]);
}
if (! $order->order_status->isBeforePayment()) {
return ResponseHelper::success('common.success', [
'status' => 'ignored',
'reason' => 'order_not_payable',
]);
}
// 결제 성공 콜백(success → confirmPayment)과 이 보고가 경쟁할 수 있다. 카드 주문은 승인
// 직전까지 order_status=PENDING_ORDER 라 위 가드를 통과하므로, 결제가 이미 성공했으면
// 여기서 차단해 옵션이 취소로 덮이는 것을 막는다.
if ($order->payment?->isPaid()) {
return ResponseHelper::success('common.success', [
'status' => 'ignored',
'reason' => 'payment_already_paid',
]);
}
if (! $this->requestMatchesOrderBuyer($request, $order)) {
return ResponseHelper::error('common.failed', 403, [
'message' => ['Order buyer verification failed.'],
]);
}
$expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'close_report', [
'orderId' => $orderId,
'received_amount' => $amount,
'ip' => $request->ip(),
]);
if ($expectedAmount === null) {
return ResponseHelper::error('common.failed', 422, [
'message' => ['Payment currency is not chargeable.'],
]);
}
if ($amount !== $expectedAmount) {
return ResponseHelper::error('common.failed', 422, [
'message' => ['Payment amount does not match the order amount.'],
]);
}
// 결제창을 닫은 것인지 결제가 거절된 것인지는 실패 코드 유무로 갈린다. 두 경우 모두
// 주문을 실패로 전이시키지만, 운영자가 원인을 구분할 수 있도록 단계를 나눠 기록한다.
$failureCode = trim((string) ($validated['code'] ?? ''));
$closeReason = trim((string) ($validated['reason'] ?? ''));
$this->markPaymentWindowClosed(
$this->orderService,
$order,
$failureCode !== '' ? $failureCode : self::FAILURE_CODE,
self::FAILURE_MESSAGE,
$closeReason !== '' ? $closeReason : self::FAILURE_MESSAGE,
$failureCode !== '' ? 'payment_failed' : 'window_closed',
);
return ResponseHelper::success('common.success', [
'status' => 'recorded',
]);
}
/**
* 레이트리밋 키를 생성합니다 (IP + 주문번호 조합).
*
* @param string $ip 요청 IP
* @param string $orderId 주문번호
* @return string 레이트리밋 키
*/
private function rateLimitKey(string $ip, string $orderId): string
{
return 'sirsoft-tosspayments:payment-close-report:'.sha1($ip.'|'.$orderId);
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\Tosspayments\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class PaymentCloseReportRequest extends FormRequest
{
/**
* 결제창 닫힘 보고 요청을 허용합니다.
*
* 인증은 요구하지 않는다 — 결제창 컨텍스트에서 호출되기 때문이다. 대신 컨트롤러가
* 구매자 정보·금액 대조로 자격을 검증한다.
*
* @return bool 요청 허용 여부
*/
public function authorize(): bool
{
return true;
}
/**
* 결제창 닫힘 보고 요청 검증 규칙을 반환합니다.
*
* @return array<string, array<int, string>> 필드별 검증 규칙
*/
public function rules(): array
{
return [
'orderId' => ['required', 'string', 'max:40'],
'amount' => ['required', 'integer', 'min:1'],
'buyer_email' => ['nullable', 'string', 'max:255'],
'buyer_phone' => ['nullable', 'string', 'max:30'],
'reason' => ['nullable', 'string', 'max:160'],
'code' => ['nullable', 'string', 'max:60'],
];
}
}
@@ -0,0 +1,20 @@
<?php
use Illuminate\Support\Facades\Route;
use Plugins\Sirsoft\Tosspayments\Controllers\PaymentCloseReportController;
/*
|--------------------------------------------------------------------------
| TossPayments Plugin API Routes
|--------------------------------------------------------------------------
|
| 프리픽스: /api/plugins/sirsoft-tosspayments (PluginRouteServiceProvider 자동 적용)
| 미들웨어: api (PluginRouteServiceProvider 자동 적용)
|
*/
// 결제창 닫힘·결제 실패 보고 — 구매자 정보 대조 후 결제 실패/취소 이력 기록.
// 브라우저 리턴 콜백(/payment/fail)은 인증·서명이 없어 주문 상태를 바꾸지 않으므로,
// 주문을 실패로 전이시키는 유일한 결제창 경로다 (다른 결제사 플러그인과 동일 계약).
Route::post('/payment/close-report', [PaymentCloseReportController::class, 'store'])
->name('payment.close-report');
@@ -345,9 +345,18 @@ class PaymentCallbackControllerTest extends PluginTestCase
/**
* 결제 실패 시 주문이 존재하면 failPayment 처리되는지 확인
*/
public function test_fail_calls_fail_payment_when_order_exists(): void
/**
* 실패 콜백은 주문 상태를 바꾸지 않는다.
*
* 계약 변경(KVE-2026-2018 형제): 이 엔드포인트는 인증도 서명도 없는 GET 이고
* `orderId`·`code` 가 전부 쿼리스트링에서 온다. 실패 처리를 수행하면 링크 하나로
* 남의 결제대기 주문을 취소시킬 수 있다. 결제 성립은 `success()` 의 서버 confirm 이,
* 결제완료 후 취소는 서명 검증된 웹훅이 담당한다.
*/
public function test_fail_does_not_mutate_the_order(): void
{
$order = $this->createTestOrder(30000);
$statusBefore = $order->order_status;
$response = $this->get('/plugins/sirsoft-tosspayments/payment/fail?'.http_build_query([
'code' => 'PAY_PROCESS_CANCELED',
@@ -357,13 +366,35 @@ class PaymentCallbackControllerTest extends PluginTestCase
$response->assertRedirect();
// 주문 상태가 CANCELLED로 변경되었는지 확인
$order->refresh();
$this->assertEquals(OrderStatusEnum::CANCELLED, $order->order_status);
$this->assertEquals($statusBefore, $order->order_status, '비인증 실패 콜백이 주문 상태를 바꿨습니다.');
$this->assertNotEquals(OrderStatusEnum::CANCELLED, $order->order_status);
$this->assertArrayNotHasKey('payment_failure_code', $order->order_meta ?? []);
}
// 주문 메타에 실패 정보가 저장되었는지 확인
$meta = $order->order_meta;
$this->assertEquals('PAY_PROCESS_CANCELED', $meta['payment_failure_code']);
/**
* 제3자가 링크 하나로 타인의 결제대기 주문을 취소할 수 없다 (KVE-2026-2018 형제 회귀).
*/
public function test_unauthenticated_fail_link_cannot_cancel_another_users_order(): void
{
$victimOrder = $this->createTestOrder(30000);
// 공격자는 로그인하지 않고 피해자의 주문번호만 실어 이 URL 을 연다.
$response = $this->get('/plugins/sirsoft-tosspayments/payment/fail?'.http_build_query([
'code' => 'PAY_PROCESS_CANCELED',
'message' => 'forged',
'orderId' => $victimOrder->order_number,
]));
$response->assertRedirect();
$victimOrder->refresh();
$this->assertEquals(
OrderStatusEnum::PENDING_ORDER,
$victimOrder->order_status,
'무인증 GET 하나로 피해자의 주문이 취소되었습니다.'
);
$this->assertNotEquals(PaymentStatusEnum::FAILED, $victimOrder->payment->refresh()->payment_status);
}
/**
@@ -0,0 +1,276 @@
<?php
namespace Plugins\Sirsoft\Tosspayments\Tests\Feature\Controllers;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderAddress;
use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
/**
* 토스페이먼츠 결제창 닫힘·결제 실패 보고 테스트
*
* 브라우저 리턴 콜백(`/payment/fail`)이 주문 상태를 바꾸지 않게 되면서, 정당한 결제 실패를
* 기록하는 책임은 전적으로 이 엔드포인트에 있다. 그 자격은 구매자 정보 대조가 정한다.
*/
class PaymentCloseReportControllerTest extends PluginTestCase
{
private const URL = '/api/plugins/sirsoft-tosspayments/payment/close-report';
private const BUYER_EMAIL = 'toss-buyer@example.com';
private const BUYER_PHONE = '01012345678';
/**
* 원화 통화 스냅샷 (자릿수 명시).
*
* 팩토리 기본값은 상점 통화 설정에서 읽으므로 선행 스위트가 남긴 설정 상태에 좌우된다.
* 특히 `decimal_places` 가 비면 KRW(0자리)가 2자리로 해석돼 청구액이 100배가 되고,
* 이 파일의 금액 대조가 실제와 다른 것을 측정하게 된다.
*
* @return array<string, mixed> 통화 스냅샷
*/
private static function krwSnapshot(): array
{
return [
'base_currency' => 'KRW',
'order_currency' => 'KRW',
'exchange_rate' => 1.0,
'exchange_rates' => [
'KRW' => [
'rate' => 1.0,
'decimal_places' => 0,
'rounding_unit' => '1',
'rounding_method' => 'round',
],
],
'snapshot_at' => '2026-01-01T00:00:00+00:00',
];
}
/**
* 결제 대기 주문과 배송지(구매자 정보)를 생성합니다.
*
* @param string $orderNumber 주문번호
* @param int $amount 주문 금액
* @param bool $withAddress 구매자 정보를 담은 배송지 생성 여부
* @return Order 생성된 주문
*/
private function makeOrder(string $orderNumber, int $amount = 10000, bool $withAddress = true): Order
{
$order = OrderFactory::new()->create([
'order_number' => $orderNumber,
'order_status' => OrderStatusEnum::PENDING_ORDER,
'currency' => 'KRW',
'currency_snapshot' => self::krwSnapshot(),
'subtotal_amount' => $amount,
'total_amount' => $amount,
'total_due_amount' => $amount,
'total_paid_amount' => 0,
]);
OrderPaymentFactory::new()->create([
'order_id' => $order->id,
'payment_status' => PaymentStatusEnum::READY,
'payment_method' => PaymentMethodEnum::CARD,
'pg_provider' => 'tosspayments',
'paid_amount_local' => 0,
]);
if ($withAddress) {
OrderAddress::create([
'order_id' => $order->id,
'address_type' => 'shipping',
'orderer_name' => '홍길동',
'orderer_phone' => self::BUYER_PHONE,
'orderer_email' => self::BUYER_EMAIL,
'recipient_name' => '홍길동',
'recipient_phone' => self::BUYER_PHONE,
'zipcode' => '06234',
'address' => '서울시 강남구',
'address_detail' => '101호',
]);
}
return $order->fresh('payment');
}
/**
* 구매자 정보가 일치하면 결제창 닫힘이 주문 취소로 기록된다.
*/
public function test_close_report_marks_pending_order_cancelled(): void
{
$order = $this->makeOrder('ORD-TOSS-CLOSE-001');
$response = $this->postJson(self::URL, [
'orderId' => 'ORD-TOSS-CLOSE-001',
'amount' => 10000,
'buyer_email' => self::BUYER_EMAIL,
'buyer_phone' => self::BUYER_PHONE,
'reason' => 'user closed the payment window',
]);
$response->assertOk();
$this->assertSame('recorded', $response->json('data.status'));
$order->refresh();
$this->assertEquals(OrderStatusEnum::CANCELLED, $order->order_status);
$this->assertSame('USER_CANCEL', $order->order_meta['payment_failure_code'] ?? null);
$payment = $order->payment;
$payment->refresh();
$this->assertEquals(PaymentStatusEnum::CANCELLED, $payment->payment_status);
$this->assertSame('tosspayments', $payment->payment_meta['failure_source'] ?? null);
$this->assertSame('window_closed', $payment->payment_meta['failure_stage'] ?? null);
}
/**
* 결제 거절(실패 코드 동반)은 원인을 구분할 수 있게 기록된다.
*/
public function test_close_report_records_declined_payment_with_its_code(): void
{
$order = $this->makeOrder('ORD-TOSS-CLOSE-002');
$response = $this->postJson(self::URL, [
'orderId' => 'ORD-TOSS-CLOSE-002',
'amount' => 10000,
'buyer_email' => self::BUYER_EMAIL,
'buyer_phone' => self::BUYER_PHONE,
'code' => 'REJECT_CARD_COMPANY',
'reason' => '카드사에서 승인을 거절했습니다.',
]);
$response->assertOk();
$order->refresh();
$this->assertEquals(OrderStatusEnum::CANCELLED, $order->order_status);
$this->assertSame('REJECT_CARD_COMPANY', $order->order_meta['payment_failure_code'] ?? null);
$payment = $order->payment;
$payment->refresh();
$this->assertSame('payment_failed', $payment->payment_meta['failure_stage'] ?? null);
}
/**
* 구매자 정보가 다르면 주문을 건드리지 않는다 — 이 대조가 유일한 자격 근거다.
*/
public function test_close_report_rejects_buyer_mismatch(): void
{
$order = $this->makeOrder('ORD-TOSS-CLOSE-003');
$response = $this->postJson(self::URL, [
'orderId' => 'ORD-TOSS-CLOSE-003',
'amount' => 10000,
'buyer_email' => 'attacker@evil.example',
'buyer_phone' => '01099999999',
]);
$response->assertStatus(403);
$order->refresh();
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status);
$this->assertArrayNotHasKey('payment_failure_code', $order->order_meta ?? []);
}
/**
* 구매자 정보를 아예 보내지 않아도 통과시키지 않는다.
*/
public function test_close_report_rejects_missing_buyer_information(): void
{
$order = $this->makeOrder('ORD-TOSS-CLOSE-004');
$response = $this->postJson(self::URL, [
'orderId' => 'ORD-TOSS-CLOSE-004',
'amount' => 10000,
]);
$response->assertStatus(403);
$order->refresh();
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status);
}
/**
* 금액이 주문 청구액과 다르면 거부한다.
*/
public function test_close_report_rejects_amount_mismatch(): void
{
$order = $this->makeOrder('ORD-TOSS-CLOSE-005');
$response = $this->postJson(self::URL, [
'orderId' => 'ORD-TOSS-CLOSE-005',
'amount' => 999,
'buyer_email' => self::BUYER_EMAIL,
'buyer_phone' => self::BUYER_PHONE,
]);
$response->assertStatus(422);
$order->refresh();
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status);
}
/**
* 존재하지 않는 주문번호는 404 로 응답한다.
*/
public function test_close_report_returns_not_found_for_unknown_order(): void
{
$response = $this->postJson(self::URL, [
'orderId' => 'ORD-TOSS-DOES-NOT-EXIST',
'amount' => 10000,
'buyer_email' => self::BUYER_EMAIL,
]);
$response->assertStatus(404);
}
/**
* 이미 결제가 성공한 주문은 무시한다 — 성공 콜백과의 경쟁에서 주문을 덮지 않는다.
*/
public function test_close_report_ignores_order_whose_payment_already_paid(): void
{
$order = $this->makeOrder('ORD-TOSS-CLOSE-006');
$order->payment->update([
'payment_status' => PaymentStatusEnum::PAID->value,
'paid_at' => now(),
]);
$response = $this->postJson(self::URL, [
'orderId' => 'ORD-TOSS-CLOSE-006',
'amount' => 10000,
'buyer_email' => self::BUYER_EMAIL,
'buyer_phone' => self::BUYER_PHONE,
]);
$response->assertOk();
$this->assertSame('ignored', $response->json('data.status'));
$this->assertSame('payment_already_paid', $response->json('data.reason'));
$order->refresh();
$this->assertNotEquals(OrderStatusEnum::CANCELLED, $order->order_status);
}
/**
* 이미 결제 가능 상태가 아닌 주문은 무시한다.
*/
public function test_close_report_ignores_order_that_is_no_longer_payable(): void
{
$order = $this->makeOrder('ORD-TOSS-CLOSE-007');
$order->update(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE->value]);
$response = $this->postJson(self::URL, [
'orderId' => 'ORD-TOSS-CLOSE-007',
'amount' => 10000,
'buyer_email' => self::BUYER_EMAIL,
'buyer_phone' => self::BUYER_PHONE,
]);
$response->assertOk();
$this->assertSame('ignored', $response->json('data.status'));
$this->assertSame('order_not_payable', $response->json('data.reason'));
}
}
@@ -240,6 +240,16 @@ abstract class PluginTestCase extends TestCase
->middleware('web')
->group($webRoutesFile);
}
$apiRoutesFile = dirname(__DIR__).'/src/routes/api.php';
if (file_exists($apiRoutesFile)) {
// 프로덕션 PluginRouteServiceProvider 와 동일한 프리픽스·이름 규약을 따른다.
Route::prefix('api/plugins/sirsoft-tosspayments')
->name('api.plugins.sirsoft-tosspayments.')
->middleware('api')
->group($apiRoutesFile);
}
}
/**
@@ -0,0 +1,100 @@
# audit:allow test-scenario-coverage reason: 형제 결제 플러그인(nhnkcp·kginicis·nicepayments)의 security-callback-defense 와 같은 형태의 명세 SSoT. 조합 커버는 test_files 의 통과 테스트가 담당하고, 본 매니페스트는 "무엇을 반드시 담아야 하는가" 를 고정한다.
feature: 콜백 보안 방어 (비인증 브라우저 콜백의 주문 변조 / 소유권 검증 실패 기록)
description: |
토스페이먼츠 결제 콜백 처리 경로의 주문 상태 전이 계약.
근본 결함: 실패 리다이렉트(`fail`)는 로그인도 서명 확인도 거치지 않는 GET 이면서
주소에 담겨 온 `orderId` 로 주문을 찾아 결제 실패 처리했다. 주문번호만 아는 제3자가
그 주소를 열기만 해도 남의 결제대기 주문이 취소됐다 — 보고된 NHN KCP 건보다 조건이
가볍다(위조 암호문조차 필요 없다).
불변조건: **비인증 브라우저-리턴 콜백은 주문/결제 상태를 전이하지 않는다.**
상태 전이가 허용되는 경로는 셋뿐이다 —
(a) 서버 승인(`confirmPayment`)이 성립한 이후,
(b) 소유권을 검증한 close-report,
(c) 서명이 검증된 웹훅.
형제 대비: kcp·kginicis·nicepay 는 close-report 를 이미 갖고 있었으나 토스에는 없어,
`fail` 에서 mutation 을 떼면 정당한 결제 실패가 어디에도 기록되지 않는 공백이 생겼다.
그래서 형제와 동형인 소유권 검증 close-report 를 함께 신설했다(구매자 연락처 + 금액 대조).
축 설계: 판정에 실제로 영향을 주는 것은 "누가 불렀는가"와 "서버 승인이 성립했는가"다.
리다이렉트 주소 커스터마이즈·쿼리 병합은 표시 계층이므로 축이 아니다.
axes:
endpoint: [success_redirect, fail_redirect, close_report, webhook]
caller: [pg_server_confirmed, unauthenticated_browser, ownership_verified_buyer, signed_webhook]
order_owner: [self, another_user]
order_state: [payable_pending, already_paid, no_longer_payable, not_found]
exclusions:
- { endpoint: success_redirect, caller: unauthenticated_browser, reason: "성공 경로는 서버 confirmPayment 결과로만 완료되므로 호출자 축이 판정에 관여하지 않는다" }
- { endpoint: success_redirect, caller: ownership_verified_buyer, reason: "동일 — 성공 확정은 소유권이 아니라 서버 승인이 정한다" }
- { endpoint: success_redirect, caller: signed_webhook, reason: "성공 리다이렉트와 웹훅은 별개 엔드포인트" }
- { endpoint: fail_redirect, caller: pg_server_confirmed, reason: "실패 리다이렉트는 서버 승인을 거치지 않는다 — 그것이 이 결함의 전제다" }
- { endpoint: fail_redirect, caller: ownership_verified_buyer, reason: "실패 리다이렉트에는 구매자 확인 수단이 없다 — 확인은 close-report 가 한다" }
- { endpoint: fail_redirect, caller: signed_webhook, reason: "브라우저 리턴 경로에는 서명이 없다" }
- { endpoint: close_report, caller: pg_server_confirmed, reason: "close-report 는 결제창을 닫은 구매자가 부르는 경로" }
- { endpoint: close_report, caller: signed_webhook, reason: "동일" }
- { endpoint: webhook, caller: unauthenticated_browser, reason: "서명 없는 요청은 웹훅으로 수용되지 않는다" }
- { endpoint: webhook, caller: ownership_verified_buyer, reason: "웹훅은 PG 서버가 부른다" }
- { endpoint: webhook, caller: pg_server_confirmed, reason: "웹훅의 신뢰 근거는 승인 결과가 아니라 서명이다" }
- { endpoint: close_report, order_owner: another_user, order_state: not_found, reason: "주문이 없으면 소유권 판정 이전에 404 로 끝난다" }
- { endpoint: fail_redirect, order_state: not_found, order_owner: another_user, reason: "동일 — 주문이 없으면 소유권 축이 성립하지 않는다" }
- { endpoint: success_redirect, order_owner: another_user, reason: "성공 경로의 주문은 서버 승인 응답이 지목한다 — 주소로 고를 수 없다" }
effects:
# 비인증 브라우저 콜백의 주문 변조 차단
- fail_redirect_does_not_mutate_the_order
- unauthenticated_fail_link_cannot_cancel_another_users_order
- fail_redirect_still_carries_error_params_to_the_checkout_screen
- fail_redirect_handles_missing_order_id_without_error
- fail_redirect_honors_custom_fail_url_and_existing_query_params
# 성공 경로 회귀 방지 (서버 승인만이 완료를 만든다)
- success_completes_the_order_only_after_server_confirm
- success_redirects_to_checkout_on_confirm_api_failure
- success_redirects_to_checkout_on_amount_mismatch
- success_redirects_to_checkout_on_order_not_found
- success_redirect_follows_shop_route_path_setting
# 소유권 검증 실패 기록 (형제 플러그인과 동형으로 신설)
- close_report_marks_pending_order_cancelled
- close_report_records_declined_payment_with_its_code
- close_report_rejects_buyer_mismatch
- close_report_rejects_missing_buyer_information
- close_report_rejects_amount_mismatch
- close_report_returns_not_found_for_unknown_order
- close_report_ignores_order_whose_payment_already_paid
- close_report_ignores_order_that_is_no_longer_payable
- close_report_restores_mileage_and_option_state_through_fail_payment
test_files:
- plugins/_bundled/sirsoft-tosspayments/tests/Feature/Controllers/PaymentCallbackControllerTest.php
- plugins/_bundled/sirsoft-tosspayments/tests/Feature/Controllers/PaymentCloseReportControllerTest.php
manual_verification:
- description: "위조 실패 링크 PoC — 주소 하나로 남의 주문 취소"
steps:
- "피해자 계정으로 결제대기 주문 1건 생성 (pending_order / ready)"
- "로그아웃 상태에서 `/api/plugins/sirsoft-tosspayments/payment/fail?orderId={피해주문번호}&code=...` GET"
- "302 리다이렉트만 발생하고 주문 상태·결제 상태가 불변인지 재조회로 확인"
- "피해자 재로그인 후 주문 상세에서 여전히 결제대기인지 확인"
- description: "정상 실패 기록 — 소유권 검증 close-report"
steps:
- "구매자 본인 정보(주문 연락처)와 금액을 담아 close-report 호출 → 주문 취소 기록"
- "같은 요청에서 연락처만 다른 사람 것으로 바꿔 호출 → 거부되고 주문 불변"
- "취소 기록 시 선차감 마일리지 복원·옵션 취소 동기가 함께 일어나는지 확인"
notes: |
`fail` 은 사용자에게 보여 줄 화면을 정하는 표시 경로다. 상태 기록은 close-report 가 맡는다.
둘을 한 엔드포인트에 겹치면, 화면을 여는 행위가 곧 상태 변경이 되어 인증 없는 주소가
그대로 조작 통로가 된다.
관련 코드:
- src/Controllers/PaymentCallbackController.php (success / fail)
- src/Controllers/PaymentCloseReportController.php (소유권 검증 실패 기록)
- src/Controllers/WebhookController.php (서명 검증 웹훅)
- 형제 참조: plugins/_bundled/sirsoft-pay_nhnkcp · sirsoft-pay_kginicis 의 close-report
@@ -189,6 +189,40 @@ class KcpCallbackResolverTest extends PluginTestCase
Http::assertNothingSent();
}
/**
* 브라우저 실패 코드만으로는 대상 인증을 고를 수 없다.
*
* 결제 콜백은 주문번호가 사실상 공개값이라 공격자가 피해 대상을 지목할 수 있었지만
* (KVE-2026-2018), 이 콜백은 거래를 `reg_cert_key`(KCP 발급 비밀) 또는 challenge UUID
* 로만 찾는다. 둘 다 추측할 수 없으므로 res_cd 를 위조해도 남의 인증 로그에 닿지 못한다.
* 이 성질이 깨지면 여기도 결제 콜백과 같은 통로가 된다.
*/
public function test_forged_failure_callback_cannot_select_another_users_verification(): void
{
$log = $this->createChallenge(User::factory()->create()->id);
Http::fake();
// 공격자는 비밀값을 모르므로 임의 키·임의 challenge id 로 시도할 수밖에 없다.
foreach ([
['res_cd' => '9999', 'reg_cert_key' => 'GUESSED-KEY'],
['res_cd' => 'CS12', 'param_opt_1' => (string) Str::uuid()],
['res_cd' => '9999'],
] as $forged) {
$outcome = $this->resolver->resolve($forged);
$this->assertFalse($outcome->success);
$this->assertSame('NOT_FOUND', $outcome->failureCode);
}
// 피해자의 인증 로그는 그대로 남아 있어야 한다.
$this->assertSame(
IdentityVerificationStatus::Sent->value,
$this->logRepository->findById($log->id)->status->value,
'위조 콜백이 타인의 인증 로그 상태를 바꿨습니다.'
);
Http::assertNothingSent();
}
/**
* @scenario outcome=provider_error,lookup=by_reg_cert_key,missing_field=none
*
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More