KISA 제보 3건(KVE-2026-2010/2011/2018)과 그 동일 계열 형제 결함을 전수 조치하고, 그 과정에서 드러난 두 결함군을 함께 닫는다. - 검증 시점과 연결 시점이 host 를 다르게 읽던 SSRF 통로를 정규화 SSoT 한 곳으로 모았다 - 세션을 여는 지점(2FA 완료·토큰 재발급)이 잠금 검사를 거치지 않아 계정 잠금이 우회됐다 - 인증도 서명도 없는 브라우저 리턴 콜백이 주문 상태를 바꾸던 통로를 4 PG 전부에서 닫고, 소유권을 대조하는 close-report 를 토스에도 신설했다. 그 결과 정리 주체를 잃는 결제창 미완료 주문은 만료 자동취소가 거둔다 - 저장소 A(_local)에만 쓰는 경로가 B 의 값을 조용히 덮던 회귀를 정본 writer 로 닫았다 (engine-v1.63.5). 한 방향만 보던 정적 검사에 반대 방향 축과 양방향 계약 테스트를 더했다 - 레이아웃 JSON 의 같은 객체 중복 키가 앞선 선언을 오류 없이 삼키던 결함군을 닫았다
277 lines
9.8 KiB
PHP
277 lines
9.8 KiB
PHP
<?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'));
|
|
}
|
|
}
|