Files
HeuJung 9cf9c3ff8c fix(core,board,ecommerce,payments,basic): KVE-2026 보안 게이트 6묶음 + 자격증명 전송로 정합
KISA 제보 취약점(KVE-2026-1914/1919/2019/2029/2041/2042/2043/2044)과
그 수정 과정에서 드러난 자격증명 전송로 결함을 함께 해소한다.

globalHeaders 는 데이터소스와 apiCall 핸들러에만 적용되는데, 코어 ApiClient 를
직접 부르는 경로들이 그 사실을 모른 채 게이트된 엔드포인트를 호출하고 있었다.
서버는 정당한 사용자를 거부하고 화면은 이미 버튼을 내준 뒤라, 예외도 로그도 없이
그 자리만 비는 형태로만 드러났다. 전송로 10축을 전수 열거해 6건을 고치고,
같은 실수가 반복되지 않도록 규정과 coverage 에 등재했다.

아웃바운드 프록시가 사이트 자기 자신으로 가는 내부 요청까지 가로채 저장이 수십 초씩
걸리던 문제도 함께 고쳤다. 실패가 폴백으로 삼켜져 화면에는 지연으로만 나타났다.
2026-09-06 00:42:29 +09:00

219 lines
7.3 KiB
PHP

<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http;
use Illuminate\Support\Facades\Hash;
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 Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 비회원 결제취소 기록 인증 회귀 테스트 (KVE-2026-2041).
*
* 회귀 배경: `orders/{orderNumber}/cancel-payment` 은 optional.sanctum 로 회원/비회원을
* 공유하는데, 소유권 검증이 `user_id !== null` 일 때만 수행됐다. 비회원 주문(user_id=null)은
* 그 분기를 통과해 **주문번호만 아는 익명 요청**이 결제를 cancelled 로 영속 변경할 수 있었다.
* 보호된 게스트 경로(`guest/orders/*`)와 동일하게 X-Guest-Order-Token 을 요구해야 한다.
*
* @group ecommerce
* @group security
*/
class GuestCancelPaymentAuthTest extends ModuleTestCase
{
private const PASSWORD = 'guest12';
private const PHONE = '010-5555-6666';
/**
* 결제취소 기록 엔드포인트 URL 을 만듭니다.
*/
private function cancelPaymentUrl(Order $order): string
{
return "/api/modules/sirsoft-ecommerce/orders/{$order->order_number}/cancel-payment";
}
/**
* 결제 대기 상태의 비회원 주문 + 유효 토큰을 만듭니다.
*
* @return array{0: Order, 1: string}
*/
private function makeGuestOrderWithToken(string $orderNumber): array
{
$order = Order::factory()->forGuest()->create([
'order_number' => $orderNumber,
'order_status' => OrderStatusEnum::PENDING_ORDER,
'guest_lookup_password_hash' => Hash::make(self::PASSWORD),
]);
OrderAddress::factory()->shipping()->forOrder($order)->create([
'orderer_phone' => self::PHONE,
]);
OrderPayment::factory()->create([
'order_id' => $order->id,
'payment_status' => PaymentStatusEnum::READY,
'payment_method' => PaymentMethodEnum::CARD,
]);
/** @var GuestOrderAuthService $service */
$service = app(GuestOrderAuthService::class);
$token = $service->authenticate($order->order_number, self::PHONE, self::PASSWORD, '10.0.0.9')['token'];
return [$order, $token];
}
/**
* 비회원 주문에 토큰 없이 익명 요청하면 404 이고 결제 상태가 변하지 않는다.
*/
public function test_guest_order_without_token_is_rejected(): void
{
[$order] = $this->makeGuestOrderWithToken('ORD-CP-NOTOKEN');
$response = $this->postJson($this->cancelPaymentUrl($order), [
'cancel_code' => 'USER_CANCEL',
]);
$response->assertStatus(404);
$this->assertSame(
PaymentStatusEnum::READY->value,
$order->fresh()->payment->payment_status->value,
'차단된 요청은 결제 상태를 바꾸지 않아야 한다'
);
}
/**
* 위조/무효 토큰도 차단된다.
*/
public function test_guest_order_with_invalid_token_is_rejected(): void
{
[$order] = $this->makeGuestOrderWithToken('ORD-CP-BADTOKEN');
$response = $this->postJson(
$this->cancelPaymentUrl($order),
['cancel_code' => 'USER_CANCEL'],
['X-Guest-Order-Token' => '9999999999|'.str_repeat('a', 64)]
);
$response->assertStatus(404);
$this->assertSame(
PaymentStatusEnum::READY->value,
$order->fresh()->payment->payment_status->value
);
}
/**
* 다른 비회원 주문으로 발급된 토큰은 재사용할 수 없다.
*/
public function test_guest_token_of_other_order_is_rejected(): void
{
[$order] = $this->makeGuestOrderWithToken('ORD-CP-TARGET');
[, $otherToken] = $this->makeGuestOrderWithToken('ORD-CP-OTHER');
$response = $this->postJson(
$this->cancelPaymentUrl($order),
['cancel_code' => 'USER_CANCEL'],
['X-Guest-Order-Token' => $otherToken]
);
$response->assertStatus(404);
$this->assertSame(
PaymentStatusEnum::READY->value,
$order->fresh()->payment->payment_status->value
);
}
/**
* 유효한 게스트 토큰이면 정상 취소된다 (정상 흐름 불변).
*/
public function test_guest_order_with_valid_token_succeeds(): void
{
[$order, $token] = $this->makeGuestOrderWithToken('ORD-CP-VALID');
$response = $this->postJson(
$this->cancelPaymentUrl($order),
['cancel_code' => 'USER_CANCEL', 'cancel_message' => '사용자가 결제를 취소했습니다.'],
['X-Guest-Order-Token' => $token]
);
$response->assertStatus(200)->assertJsonPath('success', true);
$this->assertSame(
PaymentStatusEnum::CANCELLED->value,
$order->fresh()->payment->payment_status->value
);
}
/**
* 회원 주문의 소유자는 게스트 토큰 없이 그대로 통과한다 (회귀 방지).
*/
public function test_member_owner_still_succeeds_without_guest_token(): void
{
$user = $this->createUser();
$this->actingAs($user);
$order = Order::factory()->forUser($user)->create([
'order_status' => OrderStatusEnum::PENDING_ORDER,
]);
OrderPayment::factory()->create([
'order_id' => $order->id,
'payment_status' => PaymentStatusEnum::READY,
'payment_method' => PaymentMethodEnum::CARD,
]);
$response = $this->postJson($this->cancelPaymentUrl($order), [
'cancel_code' => 'USER_CANCEL',
]);
$response->assertStatus(200)->assertJsonPath('success', true);
$this->assertSame(
PaymentStatusEnum::CANCELLED->value,
$order->fresh()->payment->payment_status->value
);
}
/**
* 회원 주문에 타인이 접근하면 404 (기존 동작 보존).
*/
public function test_member_order_of_another_user_is_rejected(): void
{
$user = $this->createUser();
$other = $this->createUser();
$this->actingAs($user);
$order = Order::factory()->forUser($other)->create([
'order_status' => OrderStatusEnum::PENDING_ORDER,
]);
OrderPayment::factory()->create([
'order_id' => $order->id,
'payment_status' => PaymentStatusEnum::READY,
'payment_method' => PaymentMethodEnum::CARD,
]);
$response = $this->postJson($this->cancelPaymentUrl($order));
$response->assertStatus(404);
$this->assertSame(
PaymentStatusEnum::READY->value,
$order->fresh()->payment->payment_status->value
);
}
/**
* 존재하지 않는 주문번호는 404 (정보 노출 차단 — 동일 문구).
*/
public function test_unknown_order_number_is_rejected(): void
{
$response = $this->postJson(
'/api/modules/sirsoft-ecommerce/orders/ORD-CP-NOPE/cancel-payment'
);
$response->assertStatus(404);
}
}