KISA 제보 3건(KVE-2026-2010/2011/2018)과 그 동일 계열 형제 결함을 전수 조치하고, 그 과정에서 드러난 두 결함군을 함께 닫는다. - 검증 시점과 연결 시점이 host 를 다르게 읽던 SSRF 통로를 정규화 SSoT 한 곳으로 모았다 - 세션을 여는 지점(2FA 완료·토큰 재발급)이 잠금 검사를 거치지 않아 계정 잠금이 우회됐다 - 인증도 서명도 없는 브라우저 리턴 콜백이 주문 상태를 바꾸던 통로를 4 PG 전부에서 닫고, 소유권을 대조하는 close-report 를 토스에도 신설했다. 그 결과 정리 주체를 잃는 결제창 미완료 주문은 만료 자동취소가 거둔다 - 저장소 A(_local)에만 쓰는 경로가 B 의 값을 조용히 덮던 회귀를 정본 writer 로 닫았다 (engine-v1.63.5). 한 방향만 보던 정적 검사에 반대 방향 축과 양방향 계약 테스트를 더했다 - 레이아웃 JSON 의 같은 객체 중복 키가 앞선 선언을 오류 없이 삼키던 결함군을 닫았다
1354 lines
53 KiB
PHP
1354 lines
53 KiB
PHP
<?php
|
|
|
|
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;
|
|
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
|
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
|
use Modules\Sirsoft\Ecommerce\Models\Order;
|
|
use Plugins\Sirsoft\PayNhnkcp\Services\NhnKcpApiService;
|
|
use Plugins\Sirsoft\PayNhnkcp\Tests\PluginTestCase;
|
|
|
|
class PaymentCallbackControllerTest extends PluginTestCase
|
|
{
|
|
private const TEST_SITE_CD = 'T0000';
|
|
|
|
private const TEST_SITE_KEY = 'TEST_SITE_KEY_0000';
|
|
|
|
// ===== 헬퍼 =====
|
|
|
|
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'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{taxable?: int, vat?: int, taxFree?: int} $tax
|
|
*/
|
|
private function createTestOrder(
|
|
int $totalAmount = 50000,
|
|
array $tax = [],
|
|
PaymentMethodEnum $paymentMethod = PaymentMethodEnum::CARD,
|
|
): Order {
|
|
$taxable = $tax['taxable'] ?? $totalAmount;
|
|
$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,
|
|
'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(),
|
|
]);
|
|
|
|
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,
|
|
'card_approval_number' => null,
|
|
]);
|
|
|
|
return $order;
|
|
}
|
|
|
|
private static function krwCurrencySnapshot(): array
|
|
{
|
|
return [
|
|
'base_currency' => 'KRW',
|
|
'order_currency' => 'KRW',
|
|
'base_unit' => 1,
|
|
'exchange_rates' => [
|
|
'KRW' => [
|
|
'rate' => 1,
|
|
'rounding_unit' => '1',
|
|
'rounding_method' => 'round',
|
|
'decimal_places' => 0,
|
|
'base_unit' => 1,
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
private static function invalidUsdCurrencySnapshot(): array
|
|
{
|
|
return [
|
|
'base_currency' => 'KRW',
|
|
'order_currency' => 'USD',
|
|
'base_unit' => 1000,
|
|
'exchange_rates' => [
|
|
'KRW' => [
|
|
'rate' => 1,
|
|
'rounding_unit' => '1',
|
|
'rounding_method' => 'round',
|
|
'decimal_places' => 0,
|
|
'base_unit' => 1000,
|
|
],
|
|
'USD' => [
|
|
'rate' => 0,
|
|
'rounding_unit' => '0.01',
|
|
'rounding_method' => 'round',
|
|
'decimal_places' => 2,
|
|
'base_unit' => 1,
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
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' => '',
|
|
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
|
'redirect_fail_url' => '/shop/checkout',
|
|
];
|
|
|
|
$mock = $this->createMock(PluginSettingsService::class);
|
|
$mock->method('get')->willReturn(array_merge($defaults, $overrides));
|
|
$this->app->instance(PluginSettingsService::class, $mock);
|
|
}
|
|
|
|
/**
|
|
* NhnKcpApiService::approvePayment() 를 exec() 없이 mock.
|
|
* 기존 테스트는 Http::fake()로 CLI exec를 막으려 했으나 동작하지 않음.
|
|
*/
|
|
private function mockApiService(array $cliResponse, ?int $approveCalls = null): void
|
|
{
|
|
$mock = $this->createMock(NhnKcpApiService::class);
|
|
$expectation = $approveCalls === null
|
|
? $mock->method('approvePayment')
|
|
: $mock->expects($this->exactly($approveCalls))->method('approvePayment');
|
|
$expectation->willReturn($cliResponse);
|
|
$this->app->instance(NhnKcpApiService::class, $mock);
|
|
}
|
|
|
|
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',
|
|
], $overrides);
|
|
}
|
|
|
|
private function markVbankIssued(
|
|
Order $order,
|
|
string $tno = 'KCP_VBANK_TNO_ISSUED',
|
|
string $account = 'T1234567890',
|
|
): void {
|
|
$order->payment->update([
|
|
'transaction_id' => $tno,
|
|
'vbank_name' => '테스트은행',
|
|
'vbank_number' => $account,
|
|
'vbank_holder' => 'NHN KCP',
|
|
'vbank_issued_at' => now(),
|
|
'payment_meta' => [
|
|
'tno' => $tno,
|
|
'site_cd' => self::TEST_SITE_CD,
|
|
'is_test_mode' => true,
|
|
'pg_response_sanitized' => true,
|
|
'pg_raw_response' => [
|
|
'res_cd' => 'V000',
|
|
'tno' => $tno,
|
|
'bankname' => '테스트은행',
|
|
],
|
|
],
|
|
]);
|
|
}
|
|
|
|
// ===== 성공 콜백 =====
|
|
|
|
public function test_auth_callback_redirects_to_complete_page_on_valid_payment(): void
|
|
{
|
|
$order = $this->createTestOrder(50000);
|
|
$this->mockPluginSettings();
|
|
|
|
$tno = 'KCP_TNO_'.uniqid();
|
|
$this->mockApiService($this->makeCliResponse($tno, $order->order_number, 50000));
|
|
|
|
$response = $this->post(
|
|
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
|
|
$this->makeCallbackParams($order->order_number, 50000, ['tno' => $tno])
|
|
);
|
|
|
|
$response->assertRedirect("/shop/orders/{$order->order_number}/complete");
|
|
$order->refresh();
|
|
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
|
|
|
$payment = $order->payment;
|
|
$payment->refresh();
|
|
$this->assertEquals($tno, $payment->transaction_id);
|
|
$this->assertEquals('APP12345', $payment->card_approval_number);
|
|
}
|
|
|
|
public function test_auth_callback_stores_easy_pay_method_meta(): void
|
|
{
|
|
$order = $this->createTestOrder(50000);
|
|
$this->mockPluginSettings();
|
|
|
|
$tno = 'KCP_TNO_NAVERPAY';
|
|
$this->mockApiService($this->makeCliResponse($tno, $order->order_number, 50000));
|
|
|
|
$response = $this->post(
|
|
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
|
|
$this->makeCallbackParams($order->order_number, 50000, [
|
|
'tno' => $tno,
|
|
'param_opt_1' => 'nhnkcp_naverpay',
|
|
'nhnkcp_easy_pay_method' => 'nhnkcp_naverpay',
|
|
])
|
|
);
|
|
|
|
$response->assertRedirect("/shop/orders/{$order->order_number}/complete");
|
|
|
|
$payment = $order->payment;
|
|
$payment->refresh();
|
|
$meta = $payment->payment_meta;
|
|
|
|
$this->assertSame('naverpay', $payment->embedded_pg_provider);
|
|
$this->assertSame('nhnkcp_naverpay', $meta['nhnkcp_easy_pay_method'] ?? null);
|
|
$this->assertSame('naverpay', $meta['nhnkcp_easy_pay_provider'] ?? null);
|
|
$this->assertSame('네이버페이', $meta['nhnkcp_easy_pay_label']['ko'] ?? null);
|
|
$this->assertSame('NaverPay', $meta['nhnkcp_easy_pay_label']['en'] ?? null);
|
|
}
|
|
|
|
public function test_auth_callback_skips_cli_approval_when_order_already_paid(): void
|
|
{
|
|
$order = $this->createTestOrder(50000);
|
|
$this->mockPluginSettings();
|
|
|
|
$existingTno = 'KCP_TNO_ALREADY_PAID';
|
|
$order->update([
|
|
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
|
'paid_at' => now(),
|
|
'total_paid_amount' => 50000,
|
|
'total_due_amount' => 0,
|
|
]);
|
|
$order->payment->update([
|
|
'payment_status' => PaymentStatusEnum::PAID,
|
|
'paid_at' => now(),
|
|
'paid_amount_local' => 50000,
|
|
'paid_amount_base' => 50000,
|
|
'transaction_id' => $existingTno,
|
|
'card_approval_number' => 'APP_ALREADY',
|
|
]);
|
|
|
|
$this->mockApiService($this->makeCliResponse('KCP_TNO_SHOULD_NOT_APPROVE', $order->order_number, 50000), 0);
|
|
|
|
$response = $this->post(
|
|
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
|
|
$this->makeCallbackParams($order->order_number, 50000, ['tno' => $existingTno])
|
|
);
|
|
|
|
$response->assertRedirect("/shop/orders/{$order->order_number}/complete");
|
|
|
|
$payment = $order->payment;
|
|
$payment->refresh();
|
|
$this->assertSame($existingTno, $payment->transaction_id);
|
|
$this->assertSame('APP_ALREADY', $payment->card_approval_number);
|
|
}
|
|
|
|
public function test_auth_callback_rejects_browser_amount_mismatch_before_cli_approval(): void
|
|
{
|
|
$order = $this->createTestOrder(50000);
|
|
$this->mockPluginSettings();
|
|
|
|
$mock = $this->createMock(NhnKcpApiService::class);
|
|
$mock->expects($this->never())->method('approvePayment');
|
|
$this->app->instance(NhnKcpApiService::class, $mock);
|
|
|
|
$response = $this->post(
|
|
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
|
|
$this->makeCallbackParams($order->order_number, 1)
|
|
);
|
|
|
|
$response->assertRedirect();
|
|
$this->assertStringContainsString('error=amount_mismatch', $response->headers->get('Location'));
|
|
|
|
$order->refresh();
|
|
$this->assertNotEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
|
}
|
|
|
|
public function test_auth_callback_rejects_non_krw_order_before_cli_approval(): void
|
|
{
|
|
$order = $this->createTestOrder(50000);
|
|
$order->currency = 'USD';
|
|
$order->save();
|
|
$this->mockPluginSettings();
|
|
|
|
$mock = $this->createMock(NhnKcpApiService::class);
|
|
$mock->expects($this->never())->method('approvePayment');
|
|
$this->app->instance(NhnKcpApiService::class, $mock);
|
|
|
|
$response = $this->post(
|
|
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
|
|
$this->makeCallbackParams($order->order_number, 50000)
|
|
);
|
|
|
|
$response->assertRedirect();
|
|
$this->assertStringContainsString('error=currency_not_supported', $response->headers->get('Location'));
|
|
|
|
$order->refresh();
|
|
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status);
|
|
}
|
|
|
|
public function test_auth_callback_rejects_invalid_payment_currency_before_cli_approval(): void
|
|
{
|
|
$order = $this->createTestOrder(50000);
|
|
$order->update([
|
|
'currency' => 'USD',
|
|
'currency_snapshot' => self::invalidUsdCurrencySnapshot(),
|
|
]);
|
|
$this->mockPluginSettings();
|
|
|
|
$mock = $this->createMock(NhnKcpApiService::class);
|
|
$mock->expects($this->never())->method('approvePayment');
|
|
$this->app->instance(NhnKcpApiService::class, $mock);
|
|
|
|
$response = $this->post(
|
|
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
|
|
$this->makeCallbackParams($order->order_number, 50000)
|
|
);
|
|
|
|
$response->assertRedirect();
|
|
$this->assertStringContainsString('error=invalid_payment_currency', $response->headers->get('Location'));
|
|
|
|
$order->refresh();
|
|
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status);
|
|
$this->assertEquals(PaymentStatusEnum::READY, $order->payment->payment_status);
|
|
}
|
|
|
|
public function test_auth_callback_stores_only_sanitized_pg_response_fields(): void
|
|
{
|
|
$order = $this->createTestOrder(50000);
|
|
$this->mockPluginSettings();
|
|
|
|
$tno = 'KCP_TNO_SANITIZED';
|
|
$this->mockApiService(array_merge(
|
|
$this->makeCliResponse($tno, $order->order_number, 50000),
|
|
[
|
|
'buyr_name' => '홍길동',
|
|
'buyr_mail' => 'buyer@example.test',
|
|
'buyr_tel1' => '01012345678',
|
|
'account' => '1234567890123456',
|
|
'unexpected_sensitive' => 'store-me-not',
|
|
]
|
|
));
|
|
|
|
$response = $this->post(
|
|
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
|
|
$this->makeCallbackParams($order->order_number, 50000, ['tno' => $tno])
|
|
);
|
|
|
|
$response->assertRedirect("/shop/orders/{$order->order_number}/complete");
|
|
|
|
$payment = $order->payment;
|
|
$payment->refresh();
|
|
|
|
$meta = $payment->payment_meta;
|
|
$this->assertTrue($meta['pg_response_sanitized'] ?? false);
|
|
$this->assertSame($tno, $meta['pg_raw_response']['tno'] ?? null);
|
|
$this->assertSame('APP12345', $meta['pg_raw_response']['app_no'] ?? null);
|
|
$this->assertArrayNotHasKey('card_no', $meta['pg_raw_response']);
|
|
$this->assertArrayNotHasKey('buyr_name', $meta['pg_raw_response']);
|
|
$this->assertArrayNotHasKey('buyr_mail', $meta['pg_raw_response']);
|
|
$this->assertArrayNotHasKey('buyr_tel1', $meta['pg_raw_response']);
|
|
$this->assertArrayNotHasKey('account', $meta['pg_raw_response']);
|
|
$this->assertArrayNotHasKey('unexpected_sensitive', $meta['pg_raw_response']);
|
|
}
|
|
|
|
// ===== 과세/비과세 시나리오 =====
|
|
|
|
public function test_fully_taxable_order_payment_completes(): void
|
|
{
|
|
// 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]);
|
|
$this->mockPluginSettings();
|
|
|
|
$tno = 'KCP_TNO_TAXABLE';
|
|
$this->mockApiService($this->makeCliResponse($tno, $order->order_number, $amount));
|
|
|
|
$response = $this->post(
|
|
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
|
|
$this->makeCallbackParams($order->order_number, $amount, ['tno' => $tno])
|
|
);
|
|
|
|
$response->assertRedirect("/shop/orders/{$order->order_number}/complete");
|
|
$order->refresh();
|
|
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
|
|
|
$payment = $order->payment;
|
|
$payment->refresh();
|
|
$this->assertEquals($tno, $payment->transaction_id);
|
|
}
|
|
|
|
public function test_fully_tax_free_order_payment_completes(): void
|
|
{
|
|
// 10,000원 전액 비과세 (도서, 농산물, 의료 등 면세 상품)
|
|
$amount = 10000;
|
|
$order = $this->createTestOrder($amount, ['taxable' => 0, 'vat' => 0, 'taxFree' => $amount]);
|
|
$this->mockPluginSettings();
|
|
|
|
$tno = 'KCP_TNO_TAXFREE';
|
|
$this->mockApiService($this->makeCliResponse($tno, $order->order_number, $amount));
|
|
|
|
$response = $this->post(
|
|
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
|
|
$this->makeCallbackParams($order->order_number, $amount, ['tno' => $tno])
|
|
);
|
|
|
|
$response->assertRedirect("/shop/orders/{$order->order_number}/complete");
|
|
$order->refresh();
|
|
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
|
|
|
// 비과세 주문도 paid_amount 가 올바르게 기록돼야 함
|
|
$payment = $order->payment;
|
|
$payment->refresh();
|
|
$this->assertEquals($tno, $payment->transaction_id);
|
|
}
|
|
|
|
public function test_mixed_tax_order_payment_completes(): void
|
|
{
|
|
// 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]);
|
|
$this->mockPluginSettings();
|
|
|
|
$tno = 'KCP_TNO_MIXED';
|
|
$this->mockApiService($this->makeCliResponse($tno, $order->order_number, $total));
|
|
|
|
$response = $this->post(
|
|
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
|
|
$this->makeCallbackParams($order->order_number, $total, ['tno' => $tno])
|
|
);
|
|
|
|
$response->assertRedirect("/shop/orders/{$order->order_number}/complete");
|
|
$order->refresh();
|
|
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
|
}
|
|
|
|
// ===== 실패/취소 =====
|
|
|
|
public function test_auth_callback_redirects_to_fail_on_res_cd_not_0000(): void
|
|
{
|
|
$this->mockPluginSettings();
|
|
|
|
$response = $this->post('/plugins/sirsoft-pay_nhnkcp/payment/callback', $this->makeCallbackParams('ORD-TEST-99999', 50000, [
|
|
'res_cd' => '8001',
|
|
'res_msg' => '사용자 취소',
|
|
]));
|
|
|
|
$response->assertRedirect();
|
|
$this->assertStringContainsString('error=8001', $response->headers->get('Location'));
|
|
}
|
|
|
|
public function test_auth_callback_silently_redirects_on_user_cancel_code_3001(): void
|
|
{
|
|
$this->mockPluginSettings();
|
|
|
|
$response = $this->post('/plugins/sirsoft-pay_nhnkcp/payment/callback', [
|
|
'res_cd' => '3001',
|
|
'res_msg' => '사용자취소',
|
|
'ordr_idxx' => 'ORD-TEST-CANCEL',
|
|
]);
|
|
|
|
$response->assertRedirect('/shop/checkout');
|
|
$this->assertStringNotContainsString('error=', $response->headers->get('Location'));
|
|
}
|
|
|
|
public function test_auth_callback_does_not_mutate_order_on_browser_failure_result(): void
|
|
{
|
|
$order = $this->createTestOrder(50000);
|
|
$this->mockPluginSettings();
|
|
|
|
$response = $this->post('/plugins/sirsoft-pay_nhnkcp/payment/callback', [
|
|
'res_cd' => '3001',
|
|
'res_msg' => '사용자취소',
|
|
'ordr_idxx' => $order->order_number,
|
|
'good_mny' => 50000,
|
|
]);
|
|
|
|
$response->assertRedirect('/shop/checkout');
|
|
$this->assertStringNotContainsString('error=', $response->headers->get('Location'));
|
|
|
|
$order->refresh();
|
|
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status);
|
|
$this->assertNull($order->order_meta['payment_failure_code'] ?? null);
|
|
$this->assertEquals(PaymentStatusEnum::READY, $order->payment->payment_status);
|
|
$this->assertNull($order->payment->cancelled_at);
|
|
$this->assertSame('nhnkcp', $order->payment->pg_provider);
|
|
$this->assertNull($order->payment->payment_meta['failure_source'] ?? null);
|
|
$this->assertNull($order->payment->payment_meta['failure_code'] ?? null);
|
|
$this->assertNull($order->payment->payment_meta['failure_stage'] ?? null);
|
|
}
|
|
|
|
public function test_auth_callback_silently_redirects_on_empty_res_cd(): void
|
|
{
|
|
$this->mockPluginSettings();
|
|
|
|
$response = $this->post('/plugins/sirsoft-pay_nhnkcp/payment/callback', [
|
|
'res_cd' => '',
|
|
'ordr_idxx' => 'ORD-TEST-EMPTY',
|
|
]);
|
|
|
|
$response->assertRedirect('/shop/checkout');
|
|
$this->assertStringNotContainsString('error=', $response->headers->get('Location'));
|
|
}
|
|
|
|
public function test_auth_callback_redirects_to_fail_on_order_not_found(): void
|
|
{
|
|
$this->mockPluginSettings();
|
|
$this->mockApiService($this->makeCliResponse('TNO_X', 'NON_EXISTENT', 50000));
|
|
|
|
$response = $this->post(
|
|
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
|
|
$this->makeCallbackParams('NON_EXISTENT_ORDER', 50000)
|
|
);
|
|
|
|
$response->assertRedirect();
|
|
$this->assertStringContainsString('error=order_not_found', $response->headers->get('Location'));
|
|
}
|
|
|
|
/**
|
|
* 서버 승인이 실패하면 실패 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)
|
|
);
|
|
|
|
$response->assertRedirect();
|
|
$this->assertStringContainsString('error=9999', $response->headers->get('Location'));
|
|
|
|
$order->refresh();
|
|
$payment = $order->payment;
|
|
$payment->refresh();
|
|
|
|
$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_does_not_mutate_the_order_on_browser_amount_mismatch(): void
|
|
{
|
|
$order = $this->createTestOrder(50000);
|
|
$this->mockPluginSettings();
|
|
|
|
$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])
|
|
);
|
|
|
|
$response->assertRedirect();
|
|
$this->assertStringContainsString('error=amount_mismatch', $response->headers->get('Location'));
|
|
|
|
$order->refresh();
|
|
$payment = $order->payment;
|
|
$payment->refresh();
|
|
|
|
$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
|
|
{
|
|
$this->mockPluginSettings();
|
|
|
|
$response = $this->post('/plugins/sirsoft-pay_nhnkcp/payment/callback', [
|
|
'res_cd' => '0000',
|
|
]);
|
|
|
|
$response->assertRedirect();
|
|
$this->assertStringContainsString('error=invalid_params', $response->headers->get('Location'));
|
|
}
|
|
|
|
public function test_auth_callback_redirects_to_custom_success_url(): void
|
|
{
|
|
$order = $this->createTestOrder(50000);
|
|
$this->mockPluginSettings(['redirect_success_url' => '/custom/payment/{orderId}/done']);
|
|
|
|
$tno = 'KCP_TNO_CUSTOM';
|
|
$this->mockApiService($this->makeCliResponse($tno, $order->order_number, 50000));
|
|
|
|
$response = $this->post(
|
|
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
|
|
$this->makeCallbackParams($order->order_number, 50000, ['tno' => $tno])
|
|
);
|
|
|
|
$response->assertRedirect("/custom/payment/{$order->order_number}/done");
|
|
}
|
|
|
|
public function test_auth_callback_detects_mobile_device(): void
|
|
{
|
|
$order = $this->createTestOrder(50000);
|
|
$this->mockPluginSettings();
|
|
|
|
$tno = 'KCP_TNO_MOBILE';
|
|
$this->mockApiService($this->makeCliResponse($tno, $order->order_number, 50000));
|
|
|
|
$response = $this->post(
|
|
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
|
|
$this->makeCallbackParams($order->order_number, 50000, ['tno' => $tno]),
|
|
['User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)']
|
|
);
|
|
|
|
$response->assertRedirect("/shop/orders/{$order->order_number}/complete");
|
|
$payment = $order->payment;
|
|
$payment->refresh();
|
|
$this->assertEquals('mobile', $payment->payment_device);
|
|
}
|
|
|
|
// ===== 가상계좌 입금 통보 (KCP 공식 webhook 페이로드) =====
|
|
//
|
|
// 페이로드 키 (그누보드5 settle_kcp_common.php 참고):
|
|
// tx_cd=TX00 + op_cd=50 (입금완료) / 01 (재전송) / 13 (망취소)
|
|
// tno, order_no, ipgm_mnyx, ipgm_name, remitter, bank_code, account, noti_id
|
|
//
|
|
// 응답: <form><input name="result" value="0000"> HTML
|
|
// - result=0000 → KCP 가 통보 성공으로 인정 (재시도 차단)
|
|
// - 그 외 → KCP 재통보 (최대 10회)
|
|
|
|
private function assertKcpNotifyOk(TestResponse $response): void
|
|
{
|
|
$response->assertOk();
|
|
$this->assertStringContainsString('name="result"', $response->getContent());
|
|
$this->assertStringContainsString('value="0000"', $response->getContent());
|
|
}
|
|
|
|
private function assertKcpNotifyRetry(TestResponse $response): void
|
|
{
|
|
$response->assertOk();
|
|
$this->assertStringContainsString('name="result"', $response->getContent());
|
|
$this->assertStringNotContainsString('value="0000"', $response->getContent());
|
|
}
|
|
|
|
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_'),
|
|
], $overrides);
|
|
}
|
|
|
|
/**
|
|
* KCP 공식 발신 IP 로 vbank-notify POST.
|
|
* RestrictKcpIp 미들웨어가 항상 IP 검증하므로 화이트리스트 IP 필수.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
public function test_vbank_notify_completes_payment_on_op_cd_50(): void
|
|
{
|
|
$order = $this->createTestOrder(30000, [], PaymentMethodEnum::VBANK);
|
|
$this->markVbankIssued($order, 'KCP_VBANK_TNO_50');
|
|
|
|
$response = $this->postVbankNotify(
|
|
$this->makeVbankNotifyPayload($order->order_number, 30000, [
|
|
'op_cd' => '50',
|
|
'tno' => 'KCP_VBANK_TNO_50',
|
|
])
|
|
);
|
|
|
|
$this->assertKcpNotifyOk($response);
|
|
|
|
$order->refresh();
|
|
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
|
}
|
|
|
|
/**
|
|
* 멱등성 — op_cd=01 (재전송) 시 이미 결제완료 상태이면 0000 응답으로 차단.
|
|
*/
|
|
public function test_vbank_notify_idempotent_on_op_cd_01_resend(): void
|
|
{
|
|
$order = $this->createTestOrder(30000, [], PaymentMethodEnum::VBANK);
|
|
$this->markVbankIssued($order, 'KCP_VBANK_TNO_RESEND');
|
|
|
|
// 첫 통보 — 입금완료
|
|
$this->postVbankNotify(
|
|
$this->makeVbankNotifyPayload($order->order_number, 30000, [
|
|
'op_cd' => '50',
|
|
'tno' => 'KCP_VBANK_TNO_RESEND',
|
|
])
|
|
);
|
|
|
|
// 두 번째 — 재전송 (op_cd=01), 동일 noti_id 가정
|
|
$response = $this->postVbankNotify(
|
|
$this->makeVbankNotifyPayload($order->order_number, 30000, [
|
|
'op_cd' => '01',
|
|
'tno' => 'KCP_VBANK_TNO_RESEND',
|
|
])
|
|
);
|
|
|
|
$this->assertKcpNotifyOk($response);
|
|
}
|
|
|
|
/**
|
|
* KCP testadmin 모의입금 통보는 op_cd=18 을 발신 (KCP 공식 문서엔 명시 없음, 실제 운영 확인).
|
|
* 그누보드5 와 동일하게 op_cd=13 (망취소) 외에는 모두 입금완료 처리.
|
|
*/
|
|
public function test_vbank_notify_completes_payment_on_op_cd_18_testadmin(): void
|
|
{
|
|
$order = $this->createTestOrder(30000, [], PaymentMethodEnum::VBANK);
|
|
$this->markVbankIssued($order, 'KCP_VBANK_TNO_TESTADMIN');
|
|
|
|
$response = $this->postVbankNotify(
|
|
$this->makeVbankNotifyPayload($order->order_number, 30000, [
|
|
'tno' => 'KCP_VBANK_TNO_TESTADMIN',
|
|
'op_cd' => '18',
|
|
'ipgm_stat' => 'STIY',
|
|
])
|
|
);
|
|
|
|
$this->assertKcpNotifyOk($response);
|
|
|
|
$order->refresh();
|
|
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
|
}
|
|
|
|
/**
|
|
* 망취소 — op_cd=13 시 로깅만 하고 0000 응답 (정책 미정, 자동 취소 안 함).
|
|
*/
|
|
public function test_vbank_notify_logs_op_cd_13_cancel(): void
|
|
{
|
|
$order = $this->createTestOrder(30000, [], PaymentMethodEnum::VBANK);
|
|
$this->markVbankIssued($order, 'KCP_VBANK_TNO_CANCELLED');
|
|
|
|
$response = $this->postVbankNotify(
|
|
$this->makeVbankNotifyPayload($order->order_number, 30000, [
|
|
'op_cd' => '13',
|
|
'tno' => 'KCP_VBANK_TNO_CANCELLED',
|
|
])
|
|
);
|
|
|
|
$this->assertKcpNotifyOk($response);
|
|
|
|
$order->refresh();
|
|
// 망취소는 결제완료로 전환하지 않음
|
|
$this->assertNotEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
|
}
|
|
|
|
/**
|
|
* tx_cd 가 TX00 외 (예: TX01 환불, TX02 구매확인) 일 때 무시 + 0000 응답.
|
|
* KCP 가 단일 webhook URL 로 모든 tx_cd 를 보낼 가능성에 대비.
|
|
*/
|
|
public function test_vbank_notify_ignores_non_tx00(): void
|
|
{
|
|
$response = $this->postVbankNotify(
|
|
$this->makeVbankNotifyPayload('ANY-ORDER', 30000, ['tx_cd' => 'TX01'])
|
|
);
|
|
|
|
$this->assertKcpNotifyOk($response);
|
|
}
|
|
|
|
/**
|
|
* 주문 없음 — 영구 실패라 재시도 의미 없음, 0000 응답으로 KCP 재통보 차단.
|
|
*/
|
|
public function test_vbank_notify_returns_ok_on_order_not_found(): void
|
|
{
|
|
$response = $this->postVbankNotify(
|
|
$this->makeVbankNotifyPayload('NON_EXISTENT_ORDER', 30000)
|
|
);
|
|
|
|
$this->assertKcpNotifyOk($response);
|
|
}
|
|
|
|
/**
|
|
* 회귀: 그누보드5 settle_kcp_common.php 와 동일한 KCP 공식 페이로드(order_no/ipgm_mnyx/op_cd)
|
|
* 를 사용해야 함. 옛 키(ordr_idxx/good_mny/res_cd) 사용 시 FormRequest 단계 422 차단.
|
|
*
|
|
* 본 테스트는 실제 KCP 가 보낼 페이로드가 정상 처리됨을 보장.
|
|
*/
|
|
public function test_vbank_notify_accepts_kcp_official_payload(): void
|
|
{
|
|
$order = $this->createTestOrder(50000, [], PaymentMethodEnum::VBANK);
|
|
$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',
|
|
'ipgm_mnyx' => 50000,
|
|
'ipgm_name' => '홍길동',
|
|
'remitter' => '실입금자',
|
|
'bank_code' => '04',
|
|
'account' => 'T9876543210',
|
|
'noti_id' => '26051412000018046532',
|
|
]);
|
|
|
|
$this->assertKcpNotifyOk($response);
|
|
|
|
$order->refresh();
|
|
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
|
}
|
|
|
|
public function test_vbank_notify_stores_only_sanitized_pg_response_fields(): void
|
|
{
|
|
$order = $this->createTestOrder(50000, [], PaymentMethodEnum::VBANK);
|
|
$this->markVbankIssued($order, 'KCP_VBANK_TNO_SANITIZED', 'T9876543210');
|
|
|
|
$response = $this->postVbankNotify(
|
|
$this->makeVbankNotifyPayload($order->order_number, 50000, [
|
|
'tno' => 'KCP_VBANK_TNO_SANITIZED',
|
|
'ipgm_name' => '실입금자',
|
|
'remitter' => '송금자',
|
|
'account' => 'T9876543210',
|
|
'unexpected_sensitive' => 'store-me-not',
|
|
])
|
|
);
|
|
|
|
$this->assertKcpNotifyOk($response);
|
|
|
|
$payment = $order->payment;
|
|
$payment->refresh();
|
|
|
|
$meta = $payment->payment_meta;
|
|
$this->assertTrue($meta['pg_response_sanitized'] ?? false);
|
|
$this->assertSame('KCP_VBANK_TNO_SANITIZED', $meta['pg_raw_response']['tno'] ?? null);
|
|
$this->assertSame($order->order_number, $meta['pg_raw_response']['order_no'] ?? null);
|
|
$this->assertArrayNotHasKey('ipgm_name', $meta['pg_raw_response']);
|
|
$this->assertArrayNotHasKey('remitter', $meta['pg_raw_response']);
|
|
$this->assertArrayNotHasKey('account', $meta['pg_raw_response']);
|
|
$this->assertArrayNotHasKey('unexpected_sensitive', $meta['pg_raw_response']);
|
|
}
|
|
|
|
public function test_vbank_notify_retries_when_payment_was_not_issued_by_kcp(): void
|
|
{
|
|
$order = $this->createTestOrder(30000, [], PaymentMethodEnum::VBANK);
|
|
|
|
$response = $this->postVbankNotify(
|
|
$this->makeVbankNotifyPayload($order->order_number, 30000, [
|
|
'tno' => 'ATTACKER_TNO_WITHOUT_ISSUE',
|
|
])
|
|
);
|
|
|
|
$this->assertKcpNotifyRetry($response);
|
|
|
|
$order->refresh();
|
|
$this->assertNotEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
|
}
|
|
|
|
public function test_vbank_notify_retries_on_tno_mismatch(): void
|
|
{
|
|
$order = $this->createTestOrder(30000, [], PaymentMethodEnum::VBANK);
|
|
$this->markVbankIssued($order, 'KCP_VBANK_REAL_TNO');
|
|
|
|
$response = $this->postVbankNotify(
|
|
$this->makeVbankNotifyPayload($order->order_number, 30000, [
|
|
'tno' => 'ATTACKER_TNO',
|
|
])
|
|
);
|
|
|
|
$this->assertKcpNotifyRetry($response);
|
|
|
|
$order->refresh();
|
|
$this->assertNotEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
|
}
|
|
|
|
public function test_vbank_notify_retries_on_account_mismatch(): void
|
|
{
|
|
$order = $this->createTestOrder(30000, [], PaymentMethodEnum::VBANK);
|
|
$this->markVbankIssued($order, 'KCP_VBANK_REAL_TNO', 'T1234567890');
|
|
|
|
$response = $this->postVbankNotify(
|
|
$this->makeVbankNotifyPayload($order->order_number, 30000, [
|
|
'tno' => 'KCP_VBANK_REAL_TNO',
|
|
'account' => 'T9999999999',
|
|
])
|
|
);
|
|
|
|
$this->assertKcpNotifyRetry($response);
|
|
|
|
$order->refresh();
|
|
$this->assertNotEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
|
}
|
|
|
|
public function test_vbank_notify_retries_on_site_cd_mismatch(): void
|
|
{
|
|
$order = $this->createTestOrder(30000, [], PaymentMethodEnum::VBANK);
|
|
$this->markVbankIssued($order, 'KCP_VBANK_REAL_TNO', 'T1234567890');
|
|
|
|
$response = $this->postVbankNotify(
|
|
$this->makeVbankNotifyPayload($order->order_number, 30000, [
|
|
'site_cd' => 'T9999',
|
|
'tno' => 'KCP_VBANK_REAL_TNO',
|
|
])
|
|
);
|
|
|
|
$this->assertKcpNotifyRetry($response);
|
|
|
|
$order->refresh();
|
|
$this->assertNotEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
|
}
|
|
|
|
public function test_vbank_notify_retries_on_missing_site_cd(): void
|
|
{
|
|
$order = $this->createTestOrder(30000, [], PaymentMethodEnum::VBANK);
|
|
$this->markVbankIssued($order, 'KCP_VBANK_REAL_TNO', 'T1234567890');
|
|
|
|
$payload = $this->makeVbankNotifyPayload($order->order_number, 30000, [
|
|
'tno' => 'KCP_VBANK_REAL_TNO',
|
|
]);
|
|
unset($payload['site_cd']);
|
|
|
|
$response = $this->postVbankNotify($payload);
|
|
|
|
$this->assertKcpNotifyRetry($response);
|
|
|
|
$order->refresh();
|
|
$this->assertNotEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
|
}
|
|
|
|
public function test_vbank_notify_retries_on_amount_mismatch(): void
|
|
{
|
|
$order = $this->createTestOrder(30000, [], PaymentMethodEnum::VBANK);
|
|
$this->markVbankIssued($order, 'KCP_VBANK_REAL_TNO', 'T1234567890');
|
|
|
|
$response = $this->postVbankNotify(
|
|
$this->makeVbankNotifyPayload($order->order_number, 29999, [
|
|
'tno' => 'KCP_VBANK_REAL_TNO',
|
|
])
|
|
);
|
|
|
|
$this->assertKcpNotifyRetry($response);
|
|
|
|
$order->refresh();
|
|
$this->assertNotEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
|
}
|
|
|
|
// ===== 가상계좌 발급 (handleVbankIssued) 성공 처리 =====
|
|
|
|
/**
|
|
* PC 가상계좌: KCP 가 res_cd=V000 ("가상계좌가 발급되었습니다.") + bankname/account 응답 시
|
|
* success URL 로 리다이렉트 + vbank 컬럼 정상 저장.
|
|
*
|
|
* 회귀: KCP 표준결제창은 결제수단별로 다른 정상 응답 코드를 사용한다
|
|
* (card=0000, vbank=V000). SUCCESS_RES_CD='0000' 단일 비교만으로 검증하면
|
|
* 정상 가상계좌 발급도 fail URL 로 처리됨 — 실제 운영 회귀 발견 (주문 20260513-0846191476).
|
|
* 판정 기준을 res_cd 비교에서 핵심 필드(bankname/account) 존재 여부로 변경.
|
|
*/
|
|
public function test_vbank_pc_succeeds_on_kcp_v000_with_issuance_data(): void
|
|
{
|
|
$order = $this->createTestOrder(30000, [], PaymentMethodEnum::VBANK);
|
|
$this->mockPluginSettings();
|
|
|
|
$tno = 'KCP_VBANK_V000';
|
|
$this->mockApiService([
|
|
'res_cd' => 'V000',
|
|
'res_msg' => '가상계좌가 발급되었습니다.',
|
|
'tno' => $tno,
|
|
'bankname' => 'NH농협',
|
|
'account' => 'T1109260001455',
|
|
'depositor' => 'NHN KCP',
|
|
'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,
|
|
'use_pay_method' => 'VCNT',
|
|
])
|
|
);
|
|
|
|
$response->assertRedirect("/shop/orders/{$order->order_number}/complete");
|
|
|
|
$order->refresh();
|
|
$this->assertNotEquals(OrderStatusEnum::CANCELLED, $order->order_status);
|
|
|
|
$payment = $order->payment;
|
|
$payment->refresh();
|
|
$this->assertEquals('NH농협', $payment->vbank_name);
|
|
$this->assertEquals('T1109260001455', $payment->vbank_number);
|
|
$this->assertEquals('NHN KCP', $payment->vbank_holder);
|
|
$this->assertNotNull($payment->vbank_due_at);
|
|
$this->assertNotNull($payment->vbank_issued_at);
|
|
}
|
|
|
|
public function test_vbank_issue_stores_only_sanitized_pg_response_fields(): void
|
|
{
|
|
$order = $this->createTestOrder(30000, [], PaymentMethodEnum::VBANK);
|
|
$this->mockPluginSettings();
|
|
|
|
$tno = 'KCP_VBANK_ISSUE_SANITIZED';
|
|
$this->mockApiService([
|
|
'res_cd' => 'V000',
|
|
'res_msg' => '가상계좌가 발급되었습니다.',
|
|
'tno' => $tno,
|
|
'bankname' => 'NH농협',
|
|
'account' => 'T1109260001455',
|
|
'depositor' => 'NHN KCP',
|
|
'va_date' => '20260516235959',
|
|
'bankcode' => 'BK11',
|
|
'app_time' => '20260513174624',
|
|
'buyr_name' => '홍길동',
|
|
'buyr_mail' => 'buyer@example.test',
|
|
'card_no' => '4330****1234',
|
|
'unexpected_sensitive' => 'store-me-not',
|
|
]);
|
|
|
|
$response = $this->post(
|
|
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
|
|
$this->makeCallbackParams($order->order_number, 30000, [
|
|
'tno' => $tno,
|
|
'use_pay_method' => 'VCNT',
|
|
])
|
|
);
|
|
|
|
$response->assertRedirect("/shop/orders/{$order->order_number}/complete");
|
|
|
|
$payment = $order->payment;
|
|
$payment->refresh();
|
|
|
|
$meta = $payment->payment_meta;
|
|
$this->assertTrue($meta['pg_response_sanitized'] ?? false);
|
|
$this->assertSame($tno, $meta['pg_raw_response']['tno'] ?? null);
|
|
$this->assertSame('NH농협', $meta['pg_raw_response']['bankname'] ?? null);
|
|
$this->assertArrayNotHasKey('account', $meta['pg_raw_response']);
|
|
$this->assertArrayNotHasKey('depositor', $meta['pg_raw_response']);
|
|
$this->assertArrayNotHasKey('buyr_name', $meta['pg_raw_response']);
|
|
$this->assertArrayNotHasKey('buyr_mail', $meta['pg_raw_response']);
|
|
$this->assertArrayNotHasKey('card_no', $meta['pg_raw_response']);
|
|
$this->assertArrayNotHasKey('unexpected_sensitive', $meta['pg_raw_response']);
|
|
}
|
|
|
|
// ===== 가상계좌 발급 (handleVbankIssued) 실패 처리 =====
|
|
|
|
/**
|
|
* PC 가상계좌: CLI 가 res_cd != 정상 + bankname/account 결락 응답 시 (예: 9502 연동 모듈 호출 오류)
|
|
* fail URL 로 리다이렉트 + payment_status 가 결제 완료로 전환되지 않아야 함.
|
|
*
|
|
* 회귀: 기존 코드는 "계좌 발급 자체는 성공" 가정하에 success URL 로 보내
|
|
* 사용자에게 빈 가상계좌 정보의 complete 페이지를 노출했음 (운영 사고).
|
|
*/
|
|
public function test_vbank_pc_redirects_to_fail_on_cli_non_0000(): void
|
|
{
|
|
$order = $this->createTestOrder(30000, [], PaymentMethodEnum::VBANK);
|
|
$this->mockPluginSettings();
|
|
$this->mockApiService($this->makeCliResponse('KCP_VBANK_FAIL', $order->order_number, 30000, '9502'));
|
|
|
|
$response = $this->post(
|
|
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
|
|
$this->makeCallbackParams($order->order_number, 30000, ['use_pay_method' => 'VCNT'])
|
|
);
|
|
|
|
$response->assertRedirect();
|
|
$this->assertStringContainsString('error=9502', $response->headers->get('Location'));
|
|
$this->assertStringNotContainsString('/complete', $response->headers->get('Location'));
|
|
|
|
$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);
|
|
}
|
|
|
|
/**
|
|
* PC 가상계좌: CLI 호출 자체가 예외(approvePayment throws) → fail URL.
|
|
*/
|
|
public function test_vbank_pc_redirects_to_fail_on_cli_exception(): void
|
|
{
|
|
$order = $this->createTestOrder(30000, [], PaymentMethodEnum::VBANK);
|
|
$this->mockPluginSettings();
|
|
|
|
$mock = $this->createMock(NhnKcpApiService::class);
|
|
$mock->method('approvePayment')->willThrowException(new \RuntimeException('CLI exec failed'));
|
|
$this->app->instance(NhnKcpApiService::class, $mock);
|
|
|
|
$response = $this->post(
|
|
'/plugins/sirsoft-pay_nhnkcp/payment/callback',
|
|
$this->makeCallbackParams($order->order_number, 30000, ['use_pay_method' => 'VCNT'])
|
|
);
|
|
|
|
$response->assertRedirect();
|
|
$this->assertStringNotContainsString('/complete', $response->headers->get('Location'));
|
|
|
|
$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);
|
|
}
|
|
|
|
/**
|
|
* 모바일 가상계좌: 콜백 POST 의 평문 필드를 그대로 응답으로 취급하는 경로에서
|
|
* res_cd != 0000 시 fail URL 로 리다이렉트.
|
|
*
|
|
* 모바일은 enc_data/enc_info 가 없어 CLI 미호출이므로, 콜백 res_cd 자체가 권위.
|
|
*/
|
|
public function test_vbank_mobile_redirects_to_fail_on_callback_res_cd_non_0000(): void
|
|
{
|
|
$order = $this->createTestOrder(30000, [], PaymentMethodEnum::VBANK);
|
|
$this->mockPluginSettings();
|
|
|
|
// 모바일: enc_data/enc_info 없이 res_cd 가 비-0000.
|
|
// 단 authCallback() 의 1단계 res_cd 가드를 통과해야 handleVbankIssued() 가 호출되므로
|
|
// 시나리오 재현은 1단계 가드가 vbank 비-0000 도 동일하게 처리해야 한다는 의미.
|
|
// 여기서는 res_cd=0000 으로 진입하되 handleVbankIssued 모바일 분기에서
|
|
// 평문 필드 누락으로 발급 실패(bankname/account 모두 null)인 케이스를 검증.
|
|
$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',
|
|
'use_pay_method' => 'VCNT',
|
|
// bankname / account / depositor / va_date 모두 누락 → 발급 정보 없음
|
|
]
|
|
);
|
|
|
|
$response->assertRedirect();
|
|
$this->assertStringNotContainsString('/complete', $response->headers->get('Location'));
|
|
|
|
$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);
|
|
}
|
|
}
|