Files
HeuJung cf1d8ab52c fix(security): 보안 제보 14건 + 유사 결함 전수조사 수정
sir.kr 커뮤니티 제보(Xbuilder 님 외)와 전수조사 발굴분을 코어·게시판·
이커머스·페이지·ckeditor5 전반에 반영.

- 통합 검색 하이라이트: 코어 SearchHighlighter 로 원문 이스케이프 후 강조
 (저장형 XSS 차단, board/ecommerce/page 3중복제 통합)
- 공개 최신글·인기글·관리자 대시보드: 읽기권한 없는 게시판·블라인드·비활성
 게시판 글 필터. 비밀글은 기존 공개 정책대로 제목 노출 유지(본문만 보호)
- 게시판 관리 대시보드 권한 게이트, 이커머스 쿠폰 다운로드·리뷰 대상·주문옵션
 확정 스코프 강화, 취소/환불 항목 검증 회원/비회원 대칭
- 만료 토큰 거부(SetLocale), 회원 탈퇴·정보관리 실패 시 내부 오류 원문 마스킹
- 예외→응답 매핑 정비(generic catch 상태코드), ckeditor 업로드 권한 게이트 정리

정책 정합 정정: 착수 계획이 "비밀글 제목 노출=취약점"으로 전제해 공개 최신글·
대시보드에 넣었던 is_secret 필터는, 저장소가 확정한 "제목 공개, 본문만 보호"
정책(2026-01-02/02-04/06-18/06-19)과 배치되어 되돌림. 읽기권한·블라인드 필터는 유지.
Chrome MCP 실측(T1~T10) + 회귀 테스트로 확인.
2026-08-17 15:46:38 +09:00

238 lines
8.5 KiB
PHP

<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Order;
use App\Extension\HookManager;
use Illuminate\Support\Facades\Hash;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Exceptions\OrderOptionNotConfirmableException;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderAddress;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService;
use Modules\Sirsoft\Ecommerce\Services\OrderService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 구매확정 상태 게이트 회귀 테스트 (㉙)
*
* OrderService::confirmOption 자체에 상태 가드가 내장되어 호출자(회원/비회원/훅)와 무관하게
* 강제된다: 이미 확정된 옵션 재확정 차단, 확정 가능 상태(confirmable_statuses) 미포함 차단.
* 비회원 경로는 이 예외를 422 로 응답한다.
*/
class GuestConfirmOptionGateTest extends ModuleTestCase
{
private const PASSWORD = 'guest12';
private const PHONE = '010-1234-5678';
/**
* 테스트용 주문을 생성합니다 (금액 필드 포함 — 확정 후속 훅 안정성).
*
* @param array<string, mixed> $overrides 오버라이드 속성
*/
private function makeOrder(array $overrides = []): Order
{
return Order::factory()->create(array_merge([
'order_status' => OrderStatusEnum::DELIVERED,
'subtotal_amount' => 30000,
'total_amount' => 30000,
'total_paid_amount' => 30000,
'total_cancelled_amount' => 0,
'cancellation_count' => 0,
'promotions_applied_snapshot' => [],
'shipping_policy_applied_snapshot' => [],
], $overrides));
}
/**
* 테스트용 주문 옵션을 생성합니다.
*/
private function makeOption(Order $order, OrderStatusEnum $status): OrderOption
{
return OrderOption::factory()->forOrder($order)->create([
'quantity' => 1,
'unit_price' => 15000,
'subtotal_price' => 15000,
'subtotal_paid_amount' => 15000,
'option_status' => $status,
]);
}
/**
* 비회원 주문 + 유효 토큰을 만들어 [주문, 토큰]을 반환합니다.
*
* @return array{0: Order, 1: string}
*/
private function makeGuestOrderWithToken(string $orderNumber, OrderStatusEnum $status = OrderStatusEnum::DELIVERED): array
{
$order = $this->makeOrder([
'order_number' => $orderNumber,
'order_status' => $status,
'user_id' => null,
'guest_lookup_password_hash' => Hash::make(self::PASSWORD),
'is_first_order' => false,
]);
OrderAddress::factory()->shipping()->forOrder($order)->create([
'orderer_phone' => self::PHONE,
]);
/** @var GuestOrderAuthService $service */
$service = app(GuestOrderAuthService::class);
$token = $service->authenticate($order->order_number, self::PHONE, self::PASSWORD, '10.0.0.3')['token'];
return [$order, $token];
}
private function guestConfirmUrl(Order $order, int $optionId): string
{
return "/api/modules/sirsoft-ecommerce/guest/orders/{$order->order_number}/options/{$optionId}/confirm";
}
private function memberConfirmUrl(int $orderId, int $optionId): string
{
return "/api/modules/sirsoft-ecommerce/user/orders/{$orderId}/options/{$optionId}/confirm";
}
// ============ 서비스 계층 가드 (호출자 무관) ============
/**
* 이미 확정된 옵션을 다시 확정하면 도메인 예외를 던진다.
*
* @scenario case=confirm_service_gate
*
* @effects confirm_rejected_unless_confirmable_status, double_confirm_rejected
*/
public function test_service_rejects_already_confirmed_option(): void
{
$order = $this->makeOrder(['order_status' => OrderStatusEnum::CONFIRMED]);
$option = $this->makeOption($order, OrderStatusEnum::CONFIRMED);
try {
app(OrderService::class)->confirmOption($order, $option);
$this->fail('OrderOptionNotConfirmableException 이 발생해야 한다');
} catch (OrderOptionNotConfirmableException $e) {
$this->assertSame('order_option_already_confirmed', $e->getReason());
}
}
/**
* 확정 가능 상태(shipping/delivered)가 아닌 옵션(preparing)은 확정이 거부된다.
*
* @scenario case=confirm_service_gate
*
* @effects confirm_rejected_unless_confirmable_status
*/
public function test_service_rejects_non_confirmable_status(): void
{
$order = $this->makeOrder(['order_status' => OrderStatusEnum::PREPARING]);
$option = $this->makeOption($order, OrderStatusEnum::PREPARING);
try {
app(OrderService::class)->confirmOption($order, $option);
$this->fail('OrderOptionNotConfirmableException 이 발생해야 한다');
} catch (OrderOptionNotConfirmableException $e) {
$this->assertSame('order_option_not_confirmable', $e->getReason());
}
}
/**
* 확정 가능 상태(delivered)는 확정되고 after_confirm 훅은 1회만 발화한다.
* 이후 재확정은 already_confirmed 로 거부되어 훅이 다시 발화하지 않는다.
*
* @scenario case=confirm_service_gate
*
* @effects confirm_hook_fires_once
*/
public function test_service_confirms_delivered_and_fires_after_confirm_hook_once(): void
{
$hookCount = 0;
HookManager::addAction(
'sirsoft-ecommerce.order-option.after_confirm',
function () use (&$hookCount) {
$hookCount++;
}
);
$order = $this->makeOrder(['order_status' => OrderStatusEnum::DELIVERED]);
$option = $this->makeOption($order, OrderStatusEnum::DELIVERED);
$service = app(OrderService::class);
$service->confirmOption($order, $option);
$this->assertSame(1, $hookCount);
// 재확정 시도 → 예외 + 훅 미발화
$option->refresh();
try {
$service->confirmOption($order->fresh(), $option);
$this->fail('재확정은 거부되어야 한다');
} catch (OrderOptionNotConfirmableException $e) {
$this->assertSame('order_option_already_confirmed', $e->getReason());
}
$this->assertSame(1, $hookCount);
}
// ============ 비회원 HTTP 경로 ============
/**
* 비회원: 배송 전(preparing) 옵션 확정 시 422.
*
* @scenario case=confirm_guest_http
*
* @effects confirm_rejected_unless_confirmable_status
*/
public function test_guest_confirm_preparing_returns_422(): void
{
[$order, $token] = $this->makeGuestOrderWithToken('ORD-CF-1', OrderStatusEnum::PREPARING);
$option = $this->makeOption($order, OrderStatusEnum::PREPARING);
$this->postJson($this->guestConfirmUrl($order, $option->id), [], ['X-Guest-Order-Token' => $token])
->assertStatus(422);
}
/**
* 비회원: 확정 가능 옵션의 이중 확정 시 두 번째 요청은 422.
*
* @scenario case=confirm_guest_http
*
* @effects double_confirm_rejected
*/
public function test_guest_double_confirm_returns_422_on_second(): void
{
[$order, $token] = $this->makeGuestOrderWithToken('ORD-CF-2', OrderStatusEnum::DELIVERED);
$option = $this->makeOption($order, OrderStatusEnum::DELIVERED);
$this->postJson($this->guestConfirmUrl($order, $option->id), [], ['X-Guest-Order-Token' => $token])
->assertStatus(200);
$this->postJson($this->guestConfirmUrl($order, $option->id), [], ['X-Guest-Order-Token' => $token])
->assertStatus(422);
}
// ============ 회원 HTTP 경로 ============
/**
* 회원: 배송 전(preparing) 옵션 확정 시 422.
*
* @scenario case=confirm_member_http
*
* @effects confirm_rejected_unless_confirmable_status
*/
public function test_member_confirm_preparing_returns_422(): void
{
$user = $this->createUser();
$order = $this->makeOrder([
'user_id' => $user->id,
'order_status' => OrderStatusEnum::PREPARING,
]);
$option = $this->makeOption($order, OrderStatusEnum::PREPARING);
$this->actingAs($user)
->postJson($this->memberConfirmUrl($order->id, $option->id))
->assertStatus(422);
}
}