$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 // // 응답: