Files
Gnuboard7/plugins/_bundled/sirsoft-pay_kginicis/tests/Feature/Controllers/VbankNotifyReplayTest.php
T
thisgunandCodex 8429d8ada5 fix(payment): KG 이니시스 결제 콜백 안정성 보강
KG 이니시스 결제에서 통화 스냅샷 이상 시 서버 오류로 새지 않도록 검증 실패/재통보 가능한 응답으로 처리하고, 주문 단위 콜백 락으로 동시 완료 콜백의 중복 후속 처리를 줄입니다.

백엔드 테스트의 통화 스냅샷 fixture도 최신 계약에 맞춰 복구합니다.

Co-Authored-By: Codex <codex@openai.com>
2026-07-06 13:46:35 +09:00

240 lines
10 KiB
PHP

<?php
namespace Plugins\Sirsoft\PayKginicis\Tests\Feature\Controllers;
use App\Models\User;
use Illuminate\Support\Facades\Log;
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\OrderPayment;
use Plugins\Sirsoft\PayKginicis\Tests\PluginTestCase;
/**
* Phase 3 PoC (E2E) — KG 이니시스 PC 가상계좌 입금통보 replay 방어 침투 테스트
*
* 본 테스트는 시나리오 매니페스트 (security-callback-defense.yaml) 의
* manual_verification "Replay PoC — vbankNotify 두 번 전송" 항목을 자동화한다.
*
* 공격 시나리오:
* 1. 정상 입금통보가 1차 도착 → payment_status=paid + transaction_id=$tid 저장
* 2. 동일 페이로드 (no_tid 동일) 가 2차 도착 (재처리/replay)
*
* 기대 동작:
* - 2차 응답도 "OK" 반환 (PG 재시도 차단 위해 idempotent 응답)
* - completePayment 가 2번째에는 미호출 (paid_amount_local 미변경, paid_at 미갱신)
* - Log 에 'replay detected' INFO 기록
* - g7_ecommerce_order_payments 의 transaction_id 가 동일 row 그대로 (중복 row 미생성)
*/
class VbankNotifyReplayTest extends PluginTestCase
{
private function createPaidOrder(string $tid, int $amount = 50000): Order
{
$user = User::factory()->create();
$order = OrderFactory::new()->create([
'user_id' => $user->id,
'order_number' => 'ORD-VR-' . random_int(10000, 99999),
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'currency' => 'KRW',
'currency_snapshot' => self::krwCurrencySnapshot(),
'subtotal_amount' => $amount,
'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' => $amount,
'total_due_amount' => $amount,
'total_points_used_amount' => 0,
'total_deposit_used_amount' => 0,
'total_paid_amount' => $amount,
]);
OrderPaymentFactory::new()->create([
'order_id' => $order->id,
'payment_status' => PaymentStatusEnum::PAID,
'payment_method' => PaymentMethodEnum::VBANK,
'pg_provider' => 'kginicis',
'paid_amount_local' => $amount,
'paid_at' => now(),
'transaction_id' => $tid,
]);
return $order;
}
private function makeVbankNotifyParams(string $tid, string $moid, int $amount): array
{
return [
'no_tid' => $tid,
'no_oid' => $moid,
'id_merchant' => 'INIpayTest',
'dt_trans' => '20260516',
'tm_trans' => '120000',
'cd_bank' => '003',
'no_vacct' => '1234567890123',
'amt_input' => (string) $amount,
'nm_inputbank' => '기업은행',
'nm_input' => '홍길동',
];
}
/**
* @scenario context=vbankNotify, threat=replay, callback_state=second_arrival_same_tid
* @effects replay_detected_on_second_arrival, replay_response_is_idempotent_ok, replay_no_double_completion, replay_logged_with_context
*/
public function test_replay_returns_idempotent_ok_and_does_not_double_complete(): void
{
$tid = 'TID_REPLAY_001';
$order = $this->createPaidOrder($tid, 50000);
// 1차 결제는 이미 완료 상태 (createPaidOrder 가 paid 로 seed).
// 실제 시나리오는 1차 vbankNotify 가 도착해 paid 처리한 후, 2차 vbankNotify 가 도착하는 흐름.
// 본 테스트는 그 2차 도착만 시뮬레이션 (1차 효과는 seed 로 표현).
$beforePaidAt = $order->payment->paid_at;
$beforePaymentId = $order->payment->id;
$beforeAmount = $order->payment->paid_amount_local;
$payload = $this->makeVbankNotifyParams($tid, $order->order_number, 50000);
$response = $this->post('/plugins/sirsoft-pay_kginicis/payment/vbank-notify', $payload);
// 1. 응답: 200 OK + body "OK" (KG 이니시스 재시도 차단 위해 멱등 응답)
$response->assertStatus(200);
$this->assertSame('OK', $response->getContent());
$this->assertStringContainsString('text/plain', (string) $response->headers->get('Content-Type'));
// 2. payment row 가 중복 생성되지 않음 (transaction_id unique 기반)
$paymentCount = OrderPayment::query()->where('transaction_id', $tid)->count();
$this->assertSame(1, $paymentCount, 'transaction_id 가 동일한 row 가 1건만 존재해야 함');
// 3. completePayment 가 재호출되지 않음 — paid_at / paid_amount_local 보존
$order->refresh();
$order->payment->refresh();
$this->assertSame($beforePaymentId, $order->payment->id);
$this->assertEquals($beforeAmount, $order->payment->paid_amount_local);
$this->assertEquals(
$beforePaidAt?->format('Y-m-d H:i:s'),
$order->payment->paid_at?->format('Y-m-d H:i:s'),
'paid_at 가 2차 콜백으로 갱신되지 않아야 함'
);
}
/**
* @scenario context=vbankNotify, threat=replay, callback_state=first_arrival
* @effects replay_first_arrival_proceeds_normally
*/
public function test_first_arrival_with_issued_tid_proceeds_to_complete_payment_path(): void
{
// 가상계좌 발급 완료 상태의 결제 row 를 가진 주문 — 1차 vbankNotify 도착 시 처리 경로 검증
$user = User::factory()->create();
$order = OrderFactory::new()->create([
'user_id' => $user->id,
'order_number' => 'ORD-FA-' . random_int(10000, 99999),
'order_status' => OrderStatusEnum::PENDING_ORDER,
'currency' => 'KRW',
'currency_snapshot' => self::krwCurrencySnapshot(),
'subtotal_amount' => 30000,
'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' => 30000,
'total_due_amount' => 30000,
'total_points_used_amount' => 0,
'total_deposit_used_amount' => 0,
'total_paid_amount' => 0,
]);
$tid = 'TID_FIRST_001';
OrderPaymentFactory::new()->create([
'order_id' => $order->id,
'payment_status' => PaymentStatusEnum::WAITING_DEPOSIT,
'payment_method' => PaymentMethodEnum::VBANK,
'pg_provider' => 'kginicis',
'paid_amount_local' => 0,
'paid_at' => null,
'transaction_id' => $tid,
'vbank_number' => '1234567890123',
'payment_meta' => [
'mid' => 'INIpayTest',
'is_test_mode' => true,
],
]);
$payload = $this->makeVbankNotifyParams($tid, $order->order_number, 30000);
$response = $this->post('/plugins/sirsoft-pay_kginicis/payment/vbank-notify', $payload);
// 정상 흐름 통과 — 200 OK 응답 (재시도 차단). 내부 처리 성공/실패와 무관하게
// KG 이니시스 manual 에 따라 OK 만 응답하면 PG 측 재시도가 중단됨.
$response->assertStatus(200);
$this->assertSame('OK', $response->getContent());
}
/**
* @scenario context=vbankNotify, threat=replay, callback_state=first_arrival
* @effects replay_db_unique_blocks_concurrent_insert
*/
public function test_db_unique_index_blocks_duplicate_transaction_id_insert(): void
{
// DB 레벨 보조 방어 — plugin trait 가드를 우회하더라도 DB unique 가 마지막 방벽
$tid = 'TID_DB_UNIQUE_001';
// 1차 row insert
$order1 = $this->createPaidOrder($tid, 10000);
$this->assertNotNull($order1->payment->id);
// 2차 동일 tid 직접 insert 시도 — MySQL 1062 차단 확인
$this->expectException(\Illuminate\Database\QueryException::class);
$this->expectExceptionMessageMatches('/Duplicate entry|Integrity constraint/');
$order2 = OrderFactory::new()->create([
'user_id' => User::factory()->create()->id,
'order_number' => 'ORD-DUP-' . random_int(10000, 99999),
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'currency' => 'KRW',
'currency_snapshot' => self::krwCurrencySnapshot(),
'subtotal_amount' => 20000,
'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' => 20000,
'total_due_amount' => 20000,
'total_points_used_amount' => 0,
'total_deposit_used_amount' => 0,
'total_paid_amount' => 20000,
]);
OrderPaymentFactory::new()->create([
'order_id' => $order2->id,
'payment_status' => PaymentStatusEnum::PAID,
'payment_method' => PaymentMethodEnum::VBANK,
'pg_provider' => 'kginicis',
'paid_amount_local' => 20000,
'paid_at' => now(),
'transaction_id' => $tid, // 동일 tid → 1062 위반
]);
}
}