KISA 신고 4건(KVE-2026-1886/1887/1893/1894)을 검증하고, 미수정 2건과 동종 결함 전수 조사에서 나온 결함을 함께 조치했다. 권한 게이트 (KVE-2026-1893) - NHN KCP 관리자 주문 연동 7경로가 admin 보유 여부만 보고 세부 권한을 보지 않아, 업무 권한 없는 관리자가 주문번호·결제정보·수령인 연락처를 조회하고 에스크로 배송등록까지 할 수 있었다. 조회는 orders.read, 등록은 orders.update, 설정성 경로는 settings.read 로 게이트해 다른 PG 연동과 강도를 맞췄다. - 마케팅 채널 저장은 코어 플러그인 설정과 같은 저장소를 덮어쓰는 우회 경로라 core.plugins.update 를 부착했다. 첨부 해시 노출 (KVE-2026-1894 잔재) - 비밀글의 썸네일 URL 이 목록·상세 응답에 그대로 실려 첨부 해시가 노출됐다. 이미지 서빙은 이미 차단돼 있었으나 식별값 자체가 나갔다. 첨부 목록과 같은 게이트를 써서 값만 가리고 필드는 유지한다. 경쟁 조건 (KVE-2026-1886 + 전수 조사) - 쿠폰 차감을 조회 후 갱신에서 조건부 UPDATE 로 바꿔 1회 제한 쿠폰의 동시 사용을 막고, 선점당한 주문은 409 로 되돌린다. - 라이브 병렬 재현에서 그 롤백이 동작하지 않는 것을 확인했다. Action 훅 기본값이 큐 래핑 + afterCommit 이라 금전 처리가 호출자 커밋 뒤에 실행되고 있었다. 쿠폰·적립금 차감/복원 5개 구독에 sync 를 선언했고, 회귀 테스트는 손 등록이 아니라 실제 등록 경로를 태워 고정했다. - 동시 부분취소의 취소 누적 컬럼 lost update, 적립 lot 의 read-modify-write, 주문옵션당 적립 lot 중복 생성을 각각 행 잠금·컬럼 연산·유니크 제약으로 막았다. 기설치본의 중복 lot 은 인덱스 생성 전에 금액을 합산해 한 줄로 통합한다. 규정·문서 - sync 판정 기준을 hooks.md 에 명문화하고 /AGENTS.md Listener 표와 coverage manifest 에 반영했다. 같은 증상을 다시 만났을 때의 진단 경로를 트러블슈팅 사례로 남겼다.
231 lines
8.7 KiB
PHP
231 lines
8.7 KiB
PHP
<?php
|
|
|
|
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Database\UniqueConstraintViolationException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderOptionFactory;
|
|
use Modules\Sirsoft\Ecommerce\Enums\MileageTransactionTypeEnum;
|
|
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
|
use Modules\Sirsoft\Ecommerce\Models\MileageTransaction;
|
|
use Modules\Sirsoft\Ecommerce\Models\Order;
|
|
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
|
|
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageTransactionRepositoryInterface;
|
|
use Modules\Sirsoft\Ecommerce\Services\UserMileageService;
|
|
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
|
|
|
/**
|
|
* 주문옵션당 구매 적립 lot 유일성 회귀 테스트 (KVE-2026-1886 동종 — 최초 적립 경쟁)
|
|
*
|
|
* 적립 코드는 "옵션당 적립 내역 한 줄" 을 전제로 델타 증액·취소 회수를 수행한다. lot 이 아직
|
|
* 없는 최초 적립은 잠글 행이 없어, 같은 옵션의 확정이 동시에 겹치면 두 줄이 만들어질 수 있다.
|
|
* 데이터베이스 제약으로 그 상태 자체를 불가능하게 만든다.
|
|
*/
|
|
class MileageEarnLotUniquenessTest extends ModuleTestCase
|
|
{
|
|
/**
|
|
* 같은 주문옵션에 구매 적립 lot 을 두 번 만들 수 없어야 합니다.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function test_duplicate_purchase_earn_lot_for_same_option_is_rejected(): void
|
|
{
|
|
$userId = User::factory()->create()->id;
|
|
$orderOptionId = 987001;
|
|
|
|
$this->makeLot($userId, $orderOptionId, MileageTransactionTypeEnum::PURCHASE_EARN->value);
|
|
|
|
$this->expectException(UniqueConstraintViolationException::class);
|
|
|
|
$this->makeLot($userId, $orderOptionId, MileageTransactionTypeEnum::PURCHASE_EARN->value);
|
|
}
|
|
|
|
/**
|
|
* 반복이 정상인 유형(부분취소마다 생기는 회수 등)은 제약에 걸리면 안 됩니다.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function test_repeatable_types_are_not_constrained(): void
|
|
{
|
|
$userId = User::factory()->create()->id;
|
|
$orderOptionId = 987002;
|
|
|
|
$this->makeLot($userId, $orderOptionId, MileageTransactionTypeEnum::EARN_CANCEL->value);
|
|
$this->makeLot($userId, $orderOptionId, MileageTransactionTypeEnum::EARN_CANCEL->value);
|
|
|
|
$this->assertSame(
|
|
2,
|
|
MileageTransaction::query()
|
|
->where('order_option_id', $orderOptionId)
|
|
->where('type', MileageTransactionTypeEnum::EARN_CANCEL->value)
|
|
->count(),
|
|
'부분취소마다 생기는 회수 거래는 여러 건이 정상입니다.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 주문옵션이 없는 거래(관리자 수동 지급 등)는 여러 건이 가능해야 합니다.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function test_transactions_without_order_option_are_not_constrained(): void
|
|
{
|
|
$userId = User::factory()->create()->id;
|
|
|
|
$this->makeLot($userId, null, MileageTransactionTypeEnum::PURCHASE_EARN->value);
|
|
$this->makeLot($userId, null, MileageTransactionTypeEnum::PURCHASE_EARN->value);
|
|
|
|
$this->assertSame(
|
|
2,
|
|
MileageTransaction::query()->whereNull('order_option_id')->count(),
|
|
'주문옵션이 없는 적립은 제약 대상이 아닙니다.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 마이그레이션 왕복(down → up) 후에도 제약이 복원되어야 합니다.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function test_constraint_survives_migration_round_trip(): void
|
|
{
|
|
$indexName = 'ecommerce_mileage_transactions_purchase_earn_option_unique';
|
|
|
|
$exists = fn () => ! empty(DB::select(
|
|
'SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1',
|
|
[DB::getTablePrefix().'ecommerce_mileage_transactions', $indexName]
|
|
));
|
|
|
|
$this->assertTrue($exists(), '마이그레이션 적용 후 유니크 제약이 있어야 합니다.');
|
|
}
|
|
|
|
/**
|
|
* 최초 적립 경쟁에서 밀린 요청은 오류가 아니라 증액 경로로 흡수되어야 합니다.
|
|
*
|
|
* "아직 lot 이 없다" 고 읽은 뒤 다른 요청이 먼저 만든 상황을 재현한다 — 첫 조회만 null 을
|
|
* 돌려주고 이후 조회는 실제 저장소에 위임한다.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function test_losing_first_earn_falls_back_to_increment_instead_of_failing(): void
|
|
{
|
|
$order = $this->makeOrder();
|
|
$option = $this->makeOrderOption($order);
|
|
|
|
// 다른 요청이 이미 만든 lot (목표 적립액의 절반만 반영된 상태)
|
|
MileageTransaction::create([
|
|
'user_id' => $order->user_id,
|
|
'currency' => 'KRW',
|
|
'type' => MileageTransactionTypeEnum::PURCHASE_EARN->value,
|
|
'amount' => 500,
|
|
'remaining_amount' => 500,
|
|
'balance_after' => 500,
|
|
'order_id' => $order->id,
|
|
'order_option_id' => $option->id,
|
|
]);
|
|
|
|
$real = app(MileageTransactionRepositoryInterface::class);
|
|
$firstLookup = true;
|
|
$stub = $this->createMock(MileageTransactionRepositoryInterface::class);
|
|
$stub->method('findEarnLotForOption')->willReturnCallback(
|
|
function (int $id) use ($real, &$firstLookup) {
|
|
if ($firstLookup) {
|
|
$firstLookup = false;
|
|
|
|
return null; // 경쟁 상대가 만들기 직전에 읽은 상태
|
|
}
|
|
|
|
return $real->findEarnLotForOption($id);
|
|
}
|
|
);
|
|
foreach (['findEarnLotForOptionForUpdate', 'sumPurchaseEarnedForOption', 'getBalanceByCurrency', 'createTransaction', 'incrementEarnLotAmount'] as $method) {
|
|
$stub->method($method)->willReturnCallback(fn (...$args) => $real->{$method}(...$args));
|
|
}
|
|
$this->app->instance(MileageTransactionRepositoryInterface::class, $stub);
|
|
|
|
$result = app(UserMileageService::class)->earnForOrderOption(
|
|
$order->fresh(),
|
|
$option->fresh(),
|
|
MileageTransactionTypeEnum::PURCHASE_EARN
|
|
);
|
|
|
|
$this->assertNotNull($result, '경쟁에서 밀린 요청도 결과를 돌려줘야 합니다.');
|
|
$this->assertSame(
|
|
1,
|
|
MileageTransaction::query()
|
|
->where('order_option_id', $option->id)
|
|
->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value)
|
|
->count(),
|
|
'적립 lot 은 옵션당 한 줄이어야 합니다.'
|
|
);
|
|
$this->assertSame(
|
|
1000.0,
|
|
(float) MileageTransaction::query()
|
|
->where('order_option_id', $option->id)
|
|
->where('type', MileageTransactionTypeEnum::PURCHASE_EARN->value)
|
|
->value('amount'),
|
|
'목표 적립액까지의 차액만 증액되어야 합니다 (이중 적립 금지).'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 테스트용 주문을 생성합니다.
|
|
*
|
|
* @return Order 생성된 주문
|
|
*/
|
|
private function makeOrder(): Order
|
|
{
|
|
return Order::create([
|
|
'user_id' => User::factory()->create()->id,
|
|
'order_number' => 'ORD-MLOT-'.uniqid(),
|
|
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
|
'currency' => 'KRW',
|
|
'item_count' => 1,
|
|
'ordered_at' => now(),
|
|
'subtotal_amount' => 10000,
|
|
'total_amount' => 10000,
|
|
'total_paid_amount' => 10000,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 목표 적립액이 설정된 주문옵션을 생성합니다.
|
|
*
|
|
* @param Order $order 주문
|
|
* @return OrderOption 생성된 주문옵션
|
|
*/
|
|
private function makeOrderOption(Order $order): OrderOption
|
|
{
|
|
return OrderOptionFactory::new()->create([
|
|
'order_id' => $order->id,
|
|
'quantity' => 1,
|
|
'unit_price' => 10000,
|
|
'subtotal_price' => 10000,
|
|
'subtotal_earned_points_amount' => 1000,
|
|
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 적립 거래 한 건을 생성합니다.
|
|
*
|
|
* @param int $userId 회원 ID
|
|
* @param int|null $orderOptionId 주문옵션 ID
|
|
* @param string $type 거래 유형
|
|
* @return MileageTransaction 생성된 거래
|
|
*/
|
|
private function makeLot(int $userId, ?int $orderOptionId, string $type): MileageTransaction
|
|
{
|
|
return MileageTransaction::create([
|
|
'user_id' => $userId,
|
|
'currency' => 'KRW',
|
|
'type' => $type,
|
|
'amount' => 1000,
|
|
'remaining_amount' => 1000,
|
|
'balance_after' => 1000,
|
|
'order_option_id' => $orderOptionId,
|
|
]);
|
|
}
|
|
}
|