Merge pull request from gnuboard:HeuJung/issue485

fix(core,board,ecommerce,page): 중첩 리소스 스코프·계층 무결성 결함 12건 일괄 수정
This commit is contained in:
정정홍
2026-07-24 16:19:35 +09:00
committed by GitHub
74 changed files with 3880 additions and 192 deletions
+14
View File
@@ -313,6 +313,20 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
정규식 location 은 프리픽스 location 보다 먼저 매칭되므로, 정적 최적화 블록(`location ~* \.(js|css|json)$`)이 있는 서버에서는 확장자 붙은 동적 응답이 `try_files ... /index.php` 폴백 기회 없이 404 가 된다. 서버측 `AssetUrl` 과 프론트측 `assetUrl.ts` 는 동일 규칙을 공유하므로 한쪽만 바꾸면 그 자산만 404 가 된다. 상세: [routing.md](docs/backend/routing.md) "정적 확장자로 끝나는 동적 엔드포인트", [api/README.md](docs/backend/api/README.md) "자산 URL 이중 모드".
### 중첩 리소스 스코프 / 계층 무결성
| 금지 | 올바른 사용 |
|------|------------|
| 중첩 라우트의 상위 리소스 ID 를 받아만 두고 조회에 미반영 | Repository where 절에 상위 스코프 반영(SSoT) + Service 가 상위 ID 전달 → 교차 접근 시 404 |
| `$request->except(...)` / `->all()` 결과를 Service 쓰기 메서드로 전달 | `$request->validated()` 기준 (FormRequest 미정의 필드가 `$fillable` 로 새는 것 차단) |
| 요청 배열 항목의 `Rule::exists` 에 상위 스코프 미부착 | `Rule::exists(Model::class,'id')->where('order_id', $order->id)` → 422 |
| 수정/순서변경 FormRequest 의 `parent_id` 에 `Rule::exists` 만 부착 | 자손 전체를 검사하는 순환 방지 Rule 부착 (자기참조만 막는 Rule 은 `A→B→A` 통과) |
| 같은 리소스의 두 엔드포인트가 서로 다른 검증 강도 | 부모 변경 경로 전부 동일 강도 — 약한 쪽이 우회로가 된다 |
| 설정값이 정하는 한계를 Service 에서 리터럴로 재클램프 | Service 는 계산만, 상한 검증은 Rule 단일 책임 (이중 클램프 시 깊이 제한이 통째로 무력화) |
| 계층 재귀(path/depth 재계산)에 방문 ID 가드 없음 | 방문 집합으로 유한 종료 — 검증 우회 경로/오염 데이터에서도 무한 루프 금지 |
> 상세: [validation.md "계층 리소스 순환 참조" / "배열 항목의 상위 스코프"](docs/backend/validation.md), [service-repository.md "중첩 리소스 스코프" / "설정 기반 한계값"](docs/backend/service-repository.md)
### Listener 데이터 접근
| 금지 | 올바른 사용 |
+2
View File
@@ -41,6 +41,8 @@
- 플러그인을 GitHub 주소나 ZIP 파일로 설치할 때의 입력 안내가 내부 식별자로 표시되던 문제를 수정했습니다. 주소 형식이 잘못됐거나, ZIP이 아닌 파일을 올렸거나, 파일 용량을 초과했을 때 정상 문장으로 안내합니다.
- 레이아웃 편집기 화면이 정상 동작하는데도 서버가 "페이지 없음"(404)으로 응답하던 문제를 수정했습니다. 화면은 그대로 보였지만 브라우저 캐싱과 서버 접속 기록에서 없는 페이지로 취급되던 상태입니다.
- 주소 끝에 슬래시가 붙은 형태(예: `/admin/`)로 접속하면 화면이 "페이지 없음"(404)으로 뜨던 문제를 수정했습니다. 특히 로그인이 풀린 상태에서 이렇게 접속하면 로그인 화면으로 넘어가지 않고 404가 나타났습니다. 이제 끝 슬래시가 있어도 정상적으로 같은 페이지로 연결됩니다.
- 메뉴를 수정할 때 자기 자신의 하위 메뉴 아래로 옮길 수 있던 문제를 수정했습니다. 이렇게 옮기면 메뉴 트리가 끊어져 관리자 화면에서 되돌릴 수 없었습니다. 메뉴 순서 변경 화면과 동일한 기준이 적용됩니다.
- 플러그인 설정을 저장할 때, 그 플러그인이 제공하지 않는 항목까지 설정 파일에 함께 기록되던 문제를 수정했습니다. 이제 플러그인이 실제로 제공하는 설정 항목만 저장됩니다.
## [7.0.5] - 2026-07-16
@@ -51,12 +51,11 @@ class PluginSettingsController extends AdminBaseController
*/
public function update(UpdatePluginSettingsRequest $request, string $identifier): JsonResponse
{
// validated()가 빈 배열이면 all()에서 설정값을 가져옴
// (PluginManager에 등록되지 않은 플러그인의 경우)
// 검증을 통과한 필드만 저장한다. 과거에는 validated() 가 비면 all() 로 폴백했으나,
// 그 명분이던 "PluginManager 미등록 플러그인" 은 PluginSettingsService::save() 가
// 이미 false 로 차단하므로 도달할 수 없었고, 실제로는 설정 스키마 밖의 키가
// 그대로 설정 파일에 병합되는 경로로만 동작했다 (mass-assignment).
$settings = $request->validated();
if (empty($settings)) {
$settings = $request->all();
}
$result = $this->pluginSettingsService->save($identifier, $settings);
+12 -4
View File
@@ -4,10 +4,10 @@ namespace App\Http\Requests\Menu;
use App\Extension\HookManager;
use App\Models\Menu;
use App\Models\Module;
use App\Models\Role;
use App\Rules\LocaleRequiredTranslatable;
use App\Rules\NotSelfParent;
use App\Rules\NotCircularParent;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -17,6 +17,8 @@ class UpdateMenuRequest extends FormRequest
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 권한 검증 결과 (항상 true)
*/
public function authorize(): bool
{
@@ -26,7 +28,7 @@ class UpdateMenuRequest extends FormRequest
/**
* 요청에 적용할 검증 규칙
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
@@ -38,7 +40,9 @@ class UpdateMenuRequest extends FormRequest
'slug' => ['required', 'string', 'max:255', Rule::unique(Menu::class, 'slug')->ignore($menuId)],
'url' => 'nullable|string|max:500',
'icon' => 'nullable|string|max:100',
'parent_id' => ['nullable', 'integer', Rule::exists(Menu::class, 'id'), new NotSelfParent($menuId)],
// 순서 변경 엔드포인트(UpdateMenuOrderRequest)와 동일하게 자손 전체를 검사한다.
// NotCircularParent 생성자는 int non-nullable 이므로 $menuId 가 있을 때만 부착한다.
'parent_id' => ['nullable', 'integer', Rule::exists(Menu::class, 'id')],
'order' => 'nullable|integer|min:0',
'is_active' => 'nullable|boolean',
'extension_type' => 'nullable|string|in:core,module,plugin',
@@ -47,6 +51,10 @@ class UpdateMenuRequest extends FormRequest
'roles.*' => ['integer', Rule::exists(Role::class, 'id')],
];
if ($menuId !== null) {
$rules['parent_id'][] = new NotCircularParent((int) $menuId);
}
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
return HookManager::applyFilters('core.menu.update_validation_rules', $rules, $this);
}
+5 -1
View File
@@ -8,7 +8,11 @@ use Illuminate\Contracts\Validation\ValidationRule;
/**
* 자기 자신을 부모로 설정할 수 없도록 검증하는 Custom Rule
*
* 메뉴, 카테고리 등 계층 구조에서 순환 참조를 방지합니다.
* 메뉴, 카테고리 등 계층 구조에서 자기참조를 방지합니다.
*
* @deprecated 자기 자신만 검사하므로 자손을 부모로 지정하는 순환은 막지 못합니다.
* 신규 코드는 자손 전체를 검사하는 NotCircularParent 를 사용하세요.
* 확장이 훅으로 이 규칙을 소비할 수 있어 클래스는 제거하지 않습니다.
*/
class NotSelfParent implements ValidationRule
{
+4 -4
View File
@@ -45,7 +45,7 @@ class MenuService
/**
* 메뉴 관리 화면용 최상위 메뉴 목록을 반환합니다 (활성 모듈/플러그인 식별자 기준 필터링).
*
* @param User|null $user 권한 평가 기준 사용자 (null 이면 권한 필터 미적용)
* @param User|null $user 권한 평가 기준 사용자 (null 이면 권한 필터 미적용)
* @return Collection 메뉴 관리 화면용 최상위 메뉴 컬렉션
*/
public function getTopLevelMenusForManagement(?User $user = null): Collection
@@ -59,8 +59,8 @@ class MenuService
/**
* 검색/필터 조건이 적용된 메뉴 관리 화면용 최상위 메뉴 목록을 반환합니다.
*
* @param array $filters 검색 조건 (예: q, source 등)
* @param User|null $user 권한 평가 기준 사용자
* @param array $filters 검색 조건 (예: q, source 등)
* @param User|null $user 권한 평가 기준 사용자
* @return Collection 필터링된 최상위 메뉴 컬렉션
*/
public function getFilteredMenusForManagement(array $filters, ?User $user = null): Collection
@@ -127,7 +127,7 @@ class MenuService
* 검증은 UpdateMenuRequest에서 수행됩니다.
* - slug unique 검증: FormRequest rules (자기 자신 제외)
* - parent_id exists 검증: FormRequest rules
* - 자기 자신을 부모로 설정 방지: NotSelfParent Custom Rule
* - 자기 자신·자손을 부모로 설정 방지: NotCircularParent Custom Rule
*
* @param Menu $menu 업데이트할 메뉴 모델
* @param array $data 업데이트할 데이터
+2
View File
@@ -1199,6 +1199,8 @@ Content-Type: application/json
기존 메뉴 정보를 수정한다. `{menu}` 는 라우트 모델 바인딩으로 해석되며, 전달된 필드만 갱신한다. `slug` 는 여전히 유일해야 하고, `roles` 배열을 보내면 이 메뉴 노출이 허용된 역할 목록을 교체한다. 성공 시 갱신된 메뉴를 관계 eager-load 포함해 `MenuResource` 로 반환하고, 실패 시 `menu.update_failed`(422 검증 오류 포함)를 반환한다. 관리자 메뉴 관리 화면의 메뉴 편집 폼 저장에서 소비된다. 인증 계약: `auth:sanctum` + `permission:core.menus.update`.
`parent_id` 에는 자기 자신이나 자신의 하위 메뉴를 지정할 수 없다. 순환이 만들어지는 요청은 422(`parent_id`)로 거절되며, 순서 변경 엔드포인트(`PUT /api/admin/menus/order`)와 동일한 기준이 적용된다.
### PATCH /api/admin/menus/{menu}/toggle-status
<!-- @generated:start:api.admin.menus.toggle-status -->
+1 -1
View File
@@ -954,7 +954,7 @@ Authorization: Bearer {YOUR_TOKEN}
<!-- @generated:end -->
**설명** 플러그인의 설정 값을 저장합니다. `core.plugins.update` 권한이 필요합니다. 검증된 값을 우선 사용하되, PluginManager 에 등록되지 않아 검증 규칙이 없는 플러그인의 경우 요청 본문 전체(`all()`)를 저장합니다. 저장 실패 시 500 을 반환하고, 성공 시 갱신된 설정 값을 함께 반환합니다.
**설명** 플러그인의 설정 값을 저장합니다. `core.plugins.update` 권한이 필요합니다. 저장 대상은 검증을 통과한 값뿐이며, 검증 규칙은 해당 플러그인이 선언한 설정 스키마에서 생성됩니다. 스키마에 없는 필드를 요청에 포함해도 저장되지 않습니다. PluginManager 에 등록되지 않은 플러그인은 저장 자체가 수행되지 않아 500 을 반환합니다. 성공 시 갱신된 설정 값을 함께 반환합니다.
### GET /api/admin/plugins/{identifier}/settings/layout
+58
View File
@@ -27,6 +27,8 @@
- [Repository 클래스](#repository-클래스)
- [다중 검색 필터 Trait](#다중-검색-필터-trait-hasmultiplesearchfilters)
- [모듈에서 Repository 인터페이스 바인딩](#모듈에서-repository-인터페이스-바인딩)
- [중첩 리소스 스코프](#중첩-리소스-스코프)
- [설정 기반 한계값](#설정-기반-한계값)
- [관련 문서](#관련-문서)
---
@@ -1066,6 +1068,62 @@ class ProductService
---
## 중첩 리소스 스코프
라우트에 상위 리소스 ID 가 있는 중첩 엔드포인트(`/posts/{postId}/comments/{id}`,
`/products/{productId}/images/{imageId}` 등)에서는 **Repository 의 where 절이 스코프의
SSoT** 다. 컨트롤러에서 조회 후 `if ($comment->post_id !== $postId)` 로 비교하는 방식은
신규 호출처가 생기면 그대로 무력화된다.
```php
// ❌ 게시판 범위로만 조회 — 다른 게시글의 댓글도 잡힌다
public function findOrFail(string $slug, int $id): Comment
// ✅ 상위 스코프를 선택 파라미터로 받아 where 절에 반영
public function findOrFail(string $slug, int $id, ?int $postId = null): Comment
{
return Comment::query()
->where('board_id', $board->id)
->when($postId !== null, fn ($q) => $q->where('post_id', $postId))
->findOrFail($id);
}
```
### 규율
| 항목 | 규칙 |
| --- | --- |
| 방어 위치 | Repository where 절 (SSoT) + Service 가 상위 ID 를 전달 |
| 파라미터 형태 | **선택 파라미터**(`?int $postId = null`) — 필수화는 Interface breaking 이라 이 확장에 의존하는 확장을 전부 흔든다 |
| 전달 누락 방지 | 컨트롤러가 라우트 파라미터를 실제로 소비하는지 audit 룰 `controller-unused-route-param` (warn) 이 감시 |
| 응답 코드 | 조회 실패이므로 404. 요청 본문 배열 항목의 스코프 위반은 422 (validation.md "배열 항목의 상위 스코프") |
| 공통 추상화 | 도입하지 않는다 — 모듈마다 상위 키와 조회 경로가 다르고(`board_id`+`post_id` / `product_id` / `page_id` / `order_id`), 코어 표면이 늘면 확장 버전 제약 동기화가 연쇄된다 |
---
## 설정 기반 한계값
설정값으로 정해지는 한계(최대 깊이, 최대 개수 등)의 **검증 책임은 검증 계층 단일**이다.
Service 에서 리터럴로 다시 클램프하지 않는다.
```php
// ❌ Service 재클램프 — 설정값이 10 이어도 5 를 넘지 못한다
$data['depth'] = min(($parent->depth ?? 0) + 1, 5);
// ✅ 계산만 하고, 상한 검증은 Rule 이 게시판 설정으로 판정
$data['depth'] = ($parent->depth ?? 0) + 1;
```
이중 클램프는 두 가지를 동시에 망가뜨린다. 저장값이 설정과 무관하게 고정되고(계층 표시 붕괴),
검증 계층의 `depth + 1 > max` 조건이 영원히 거짓이 되어 **깊이 제한 자체가 무력화**된다.
값이 상한을 넘으면 조용히 깎지 말고 422 로 거절해야 한다.
이 패턴은 리터럴과 설정 키를 잇는 의미 추론이 필요해 정적 검출이 어렵다
(`coverage.json` 의 `service-hardcoded-limit-vs-settings` = `not-applicable`).
코드 리뷰에서 리터럴 상한을 볼 때마다 "이 값을 정하는 설정이 따로 있는가" 를 확인한다.
---
## 관련 문서
- [컨트롤러 계층 구조](controllers.md) - Controller에서 Service 사용
+62
View File
@@ -32,6 +32,8 @@
- [사용자 입력 다국어 필드 정규화 (prepareForValidation)](#사용자-입력-다국어-필드-정규화-prepareforvalidation)
- [동적 스키마 기반 FormRequest 패턴](#동적-스키마-기반-formrequest-패턴)
- [exists/unique 검증 규칙](#existsunique-검증-규칙)
- [계층 리소스 순환 참조](#계층-리소스-순환-참조)
- [배열 항목의 상위 스코프](#배열-항목의-상위-스코프)
- [Custom Rule 개발 체크리스트](#custom-rule-개발-체크리스트)
---
@@ -1026,6 +1028,66 @@ Rule::exists("board_{$slug}_posts", 'id'),
---
## 계층 리소스 순환 참조
`parent_id` 로 자기 자신을 참조하는 계층 리소스(메뉴, 카테고리 등)의 **수정·순서 변경**
요청은 `Rule::exists` 만으로 부족하다. `exists` 는 행의 존재만 보장하므로 자기 자신이나
자신의 자손을 부모로 지정해 사이클을 만들 수 있고, DB 의 FK 제약도 이를 막지 못한다.
사이클이 생기면 `path`/`depth` 재계산 재귀가 종료되지 않아 요청이 스택 오버플로로 실패하고,
사이클 노드는 루트에서 도달할 수 없어 트리에서 사라진다. 관리자 화면으로는 복구할 수 없다.
```php
// ❌ 존재만 확인 — 자기 자신/자손을 부모로 지정 가능
'parent_id' => ['nullable', Rule::exists(Category::class, 'id')],
// ✅ 자손 전체를 검사하는 Rule 부착
'parent_id' => [
'nullable',
Rule::exists(Category::class, 'id'),
new NotCircularCategoryParent($categoryId),
],
```
### 규율
| 항목 | 규칙 |
| --- | --- |
| 적용 대상 | `Update*Request` / `Reorder*Request`. 생성 요청은 자기 자신이 아직 없어 순환이 불가하나, 두 엔드포인트의 규칙 구성을 같게 두면 검증 강도가 갈라지지 않는다 |
| 검사 범위 | 자기 자신만이 아니라 **자손 전체**. 자기참조만 막는 규칙(`NotSelfParent`)은 `A → B → A` 를 통과시킨다 |
| 동일 리소스 일관성 | 한 리소스의 모든 부모 변경 경로(수정 / 순서 변경 / 일괄 이동)가 같은 강도를 가져야 한다. 한 곳만 강화하면 나머지가 우회로가 된다 |
| 자손 판정 구현 | `path` 머티리얼라이즈드 컬럼이 있으면 prefix 매칭(쿼리 1회). 없으면 `parent_id` 재귀 — 이때 방문 ID 집합으로 유한 종료를 보장한다 |
| Service 2차 방어 | 검증을 우회하는 경로(시더 / 훅 / 기존 오염 데이터)를 위해 재귀 함수에 방문 ID 가드를 둔다. 무한 루프 대신 유한 실패 + 로그 |
| 코어 Rule 재사용 | `App\Rules\NotCircularParent` 는 `App\Models\Menu` 하드코딩이다. 다른 모델에는 모듈 로컬 Rule 을 신설한다 (코어 시그니처 변경 = 확장 버전 제약 연쇄) |
자동 차단: audit 룰 `parent-id-requires-cycle-rule` (error).
---
## 배열 항목의 상위 스코프
요청 본문의 배열 항목이 하위 리소스 ID 를 담고, 라우트에 상위 리소스 ID 가 있다면
`Rule::exists` 에 상위 스코프 `where` 절을 붙인다. 붙이지 않으면 A 의 경로로 B 의
하위 리소스를 조작할 수 있다.
```php
// ❌ 전역 존재 확인 — 다른 주문의 옵션도 통과
'items.*.option_id' => ['required', 'integer', Rule::exists(OrderOption::class, 'id')],
// ✅ 경로의 상위 리소스로 스코프
$orderId = $this->route('order')?->id;
'items.*.option_id' => [
'required',
'integer',
Rule::exists(OrderOption::class, 'id')->where('order_id', $orderId),
],
```
422 가 의미적으로 옳다 — 입력값이 이 요청 맥락에서 유효하지 않다는 뜻이기 때문이다.
Service 에도 동일한 검증을 두어 FormRequest 를 우회한 내부/훅 호출을 막는다.
---
## Custom Rule 개발 체크리스트
- [ ] `/lang/ko/validation.php`에 한국어 메시지 추가
@@ -10,6 +10,7 @@
- 쇼핑몰 환경설정·카테고리 이미지·결제 설정 안내 문구 일본어 번역 추가 — 저장/조회 결과 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 검색 프리셋·브랜드·배송사·배송정책 목록 안내 문구 일본어 번역 추가.
- 카테고리를 자기 자신이나 하위 카테고리 아래로 옮기려 할 때, 주문에 속하지 않은 옵션을 변경하려 할 때, 상품에 속하지 않은 이미지를 대표로 지정하려 할 때 나오는 안내 문구 일본어 번역 추가.
### Fixed
@@ -31,6 +31,8 @@ return [
'order_already_cancelled' => '既にキャンセルされた注文です。',
'order_already_paid' => '既に決済が完了した注文です。',
'order_option_not_found' => '注文オプションが見つかりません。',
'order_option_not_in_order' => 'この注文に属していない注文オプションです。',
'product_image_not_in_product' => 'この商品に属していない画像です。',
'order_option_already_cancelled' => '既にキャンセルされた注文オプションです。',
'order_option_already_confirmed' => '既に購入確定された注文オプションです。',
'order_option_cannot_confirm' => '現在のステータスでは購入確定できません。',
@@ -411,6 +411,10 @@ return [
'slug_unique' => '既に使用中のスラッグです。',
'slug_format' => 'スラッグは英文小文字で始まる必要があり、英文小文字/数字/ハイフン(-)のみ使用可能です。',
'parent_not_found' => '親カテゴリが見つかりません。',
'parent_id' => [
'self' => '自分自身を親カテゴリに指定することはできません。',
'circular' => '下位カテゴリを親カテゴリに指定することはできません。',
],
],
'product_images' => [
'file' => [
@@ -29,6 +29,11 @@
- 게시판 이름을 바꿨을 때 활동 로그의 변경 항목 이름이 비어 보이던 문제를 수정했습니다.
- 게시판 환경설정 조회·저장·일괄 적용과 관리자 대시보드 조회 화면에서 결과 안내가 실제 문장 대신 내부 식별자로 표시되던 문제를 수정했습니다.
- 환경설정을 여러 게시판에 일괄 적용할 때, 개별 저장 화면과 달리 글자 수·표시 시간 등의 값 범위 제한이 적용되지 않던 문제를 수정했습니다. 이제 일괄 적용도 개별 저장과 동일한 범위를 벗어난 값을 거부합니다.
- 다른 게시글의 주소로 댓글을 수정하거나 삭제할 수 있던 문제를 수정했습니다. 이제 댓글은 자기가 달린 게시글의 주소로만 수정·삭제됩니다. 관리자 화면의 댓글 수정·삭제·블라인드·복원도 동일하게 바로잡았습니다. (#80 @jiwonpapa 님께서 제보해주셨습니다.)
- 댓글 수정 시 본문에 함께 보낸 값으로 댓글이 다른 게시글로 옮겨지거나 작성자·작성 시각 정보가 바뀔 수 있던 문제를 수정했습니다. 수정으로 반영되는 항목은 내용·비밀글 여부·상태뿐입니다.
- 다른 게시글의 댓글을 답글의 부모로 지정할 수 있던 문제를 수정했습니다. 이렇게 만들어진 답글은 어느 게시글 목록에도 나타나지 않았습니다. (#80 @jiwonpapa 님께서 제보해주셨습니다.)
- 대댓글 깊이가 게시판 설정값을 넘어 계속 달리던 문제를 수정했습니다. 예전에는 6단계부터 들여쓰기가 더 들어가지 않고 설정한 최대 깊이도 지켜지지 않았습니다. 이제 설정한 깊이까지만 답글을 달 수 있고 화면의 단계 표시도 정확해집니다. (#80 @jiwonpapa 님께서 제보해주셨습니다.)
- 기존에 쌓인 댓글 중 깊이가 어긋났거나 다른 게시글의 댓글을 부모로 가진 것을 업데이트 시 자동으로 정리합니다. 잘못 연결된 댓글은 삭제하지 않고 최상위 댓글로 옮기며, 내용은 그대로 보존됩니다.
## [1.0.2] - 2026-07-14
@@ -1525,7 +1525,7 @@ HTTP/1.1 200
<!-- @generated:end -->
**설명** 게시판 관리자가 댓글 1건을 삭제합니다. `auth:sanctum` + admin 인증이 필요하며, 라우트 권한은 `comments.write` 또는 `manage`입니다. 컨트롤러가 댓글을 조회한 뒤 권한을 적용합니다: `admin.manage`는 모든 댓글(비회원 댓글 포함), `admin.write`는 본인 댓글만 삭제할 수 있습니다. 게시판의 `use_comment`가 꺼져 있으면 403이며, `CommentService::deleteComment()`가 'admin' 컨텍스트로 삭제를 수행합니다.
**설명** 게시판 관리자가 댓글 1건을 삭제합니다. `auth:sanctum` + admin 인증이 필요하며, 라우트 권한은 `comments.write` 또는 `manage`입니다. 컨트롤러가 댓글을 조회한 뒤 권한을 적용합니다: `admin.manage`는 모든 댓글(비회원 댓글 포함), `admin.write`는 본인 댓글만 삭제할 수 있습니다. 게시판의 `use_comment`가 꺼져 있으면 403이며, `CommentService::deleteComment()`가 'admin' 컨텍스트로 삭제를 수행합니다. 경로의 `{postId}`에 속한 댓글만 대상이 됩니다 — 다른 게시글의 댓글 ID를 지정하면 404를 반환하며 해당 댓글은 변경되지 않습니다.
### PUT /api/modules/sirsoft-board/admin/board/{slug}/posts/{postId}/comments/{id}
@@ -1572,7 +1572,7 @@ Authorization: Bearer {YOUR_TOKEN}
<!-- @generated:end -->
**설명** 게시판 관리자가 댓글 1건을 수정합니다. `auth:sanctum` + admin + 게시판별 `comments.write` 권한이 필요하며, 컨트롤러가 댓글을 조회한 뒤 권한을 적용합니다: `admin.manage`는 모든 댓글, `admin.write`는 본인 댓글만 수정할 수 있습니다. 게시판의 `use_comment`가 꺼져 있으면 403이며, `UpdateCommentRequest` 검증 값으로 `CommentService::updateComment()`가 갱신을 수행합니다.
**설명** 게시판 관리자가 댓글 1건을 수정합니다. `auth:sanctum` + admin + 게시판별 `comments.write` 권한이 필요하며, 컨트롤러가 댓글을 조회한 뒤 권한을 적용합니다: `admin.manage`는 모든 댓글, `admin.write`는 본인 댓글만 수정할 수 있습니다. 게시판의 `use_comment`가 꺼져 있으면 403이며, `UpdateCommentRequest` 검증 값으로 `CommentService::updateComment()`가 갱신을 수행합니다. 경로의 `{postId}`에 속한 댓글만 대상이 됩니다 — 다른 게시글의 댓글 ID를 지정하면 404를 반환하며 해당 댓글은 변경되지 않습니다.
### PATCH /api/modules/sirsoft-board/admin/board/{slug}/posts/{postId}/comments/{id}/blind
@@ -1700,7 +1700,7 @@ HTTP/1.1 200
<!-- @generated:end -->
**설명** 게시판 관리자가 댓글을 블라인드 처리합니다. `auth:sanctum` + admin + 게시판별 `admin.manage` 권한이 필요하며, 게시판의 `use_comment`가 꺼져 있으면 403으로 차단됩니다. 선택적 `reason`(최대 1000자)을 사유로 받아 `CommentService::blindComment()`가 댓글을 숨김 처리하되 관리 목적으로 보존하며, 복원(restore)으로 되돌릴 수 있습니다.
**설명** 게시판 관리자가 댓글을 블라인드 처리합니다. `auth:sanctum` + admin + 게시판별 `admin.manage` 권한이 필요하며, 게시판의 `use_comment`가 꺼져 있으면 403으로 차단됩니다. 선택적 `reason`(최대 1000자)을 사유로 받아 `CommentService::blindComment()`가 댓글을 숨김 처리하되 관리 목적으로 보존하며, 복원(restore)으로 되돌릴 수 있습니다. 경로의 `{postId}`에 속한 댓글만 대상이 됩니다 — 다른 게시글의 댓글 ID를 지정하면 404를 반환하며 해당 댓글은 변경되지 않습니다.
### PATCH /api/modules/sirsoft-board/admin/board/{slug}/posts/{postId}/comments/{id}/restore
@@ -1744,6 +1744,6 @@ Authorization: Bearer {YOUR_TOKEN}
<!-- @generated:end -->
**설명** 게시판 관리자가 블라인드 처리된 댓글을 복원합니다. `auth:sanctum` + admin + 게시판별 `admin.manage` 권한이 필요하며, 게시판의 `use_comment`가 꺼져 있으면 403으로 차단됩니다. 요청 본문의 선택적 `reason`을 사유로 받아 `CommentService::restoreComment()`가 블라인드 상태를 해제해 댓글을 다시 노출합니다.
**설명** 게시판 관리자가 블라인드 처리된 댓글을 복원합니다. `auth:sanctum` + admin + 게시판별 `admin.manage` 권한이 필요하며, 게시판의 `use_comment`가 꺼져 있으면 403으로 차단됩니다. 요청 본문의 선택적 `reason`을 사유로 받아 `CommentService::restoreComment()`가 블라인드 상태를 해제해 댓글을 다시 노출합니다. 경로의 `{postId}`에 속한 댓글만 대상이 됩니다 — 다른 게시글의 댓글 ID를 지정하면 404를 반환하며 해당 댓글은 변경되지 않습니다.
@@ -3648,7 +3648,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생
<!-- @generated:end -->
**설명** 게시글에 댓글(또는 대댓글)을 작성합니다. `auth:sanctum` + `sirsoft-board.{slug}.comments.write` 권한이 필요하며, 게시판의 댓글 기능이 꺼져 있으면 403을 반환합니다. 현재 로그인 사용자를 작성자로 저장하고 요청 IP를 기록하며, 스팸 방지 쿨다운이 설정되어 있으면 작성 성공 후 쿨다운을 기록합니다.
**설명** 게시글에 댓글(또는 대댓글)을 작성합니다. `auth:sanctum` + `sirsoft-board.{slug}.comments.write` 권한이 필요하며, 게시판의 댓글 기능이 꺼져 있으면 403을 반환합니다. 현재 로그인 사용자를 작성자로 저장하고 요청 IP를 기록하며, 스팸 방지 쿨다운이 설정되어 있으면 작성 성공 후 쿨다운을 기록합니다. `parent_id`는 경로의 `{postId}`에 속한 댓글이어야 하며, 다른 게시글의 댓글을 지정하면 422를 반환합니다. 저장되는 `depth`는 부모 댓글 `depth + 1`이고, 게시판 설정 `max_comment_depth`를 넘는 답글은 422로 거절됩니다.
### DELETE /api/modules/sirsoft-board/boards/{slug}/posts/{postId}/comments/{commentId}
@@ -3703,7 +3703,7 @@ HTTP/1.1 200
<!-- @generated:end -->
**설명** 댓글을 삭제합니다. `auth:sanctum` + `sirsoft-board.{slug}.comments.write` 또는 게시판 manager 권한이 필요하며, 게시판의 댓글 기능이 꺼져 있으면 403을 반환합니다. 서비스의 `canDelete` 가 작성자 본인·게시판 관리자·비회원 댓글의 비밀번호 확인 여부를 판정하며, 조건 미충족 시 403을 반환합니다.
**설명** 댓글을 삭제합니다. `auth:sanctum` + `sirsoft-board.{slug}.comments.write` 또는 게시판 manager 권한이 필요하며, 게시판의 댓글 기능이 꺼져 있으면 403을 반환합니다. 서비스의 `canDelete` 가 작성자 본인·게시판 관리자·비회원 댓글의 비밀번호 확인 여부를 판정하며, 조건 미충족 시 403을 반환합니다. 경로의 `{postId}`에 속한 댓글만 대상이 됩니다 — 다른 게시글의 댓글 ID를 지정하면 404를 반환하며 해당 댓글은 변경되지 않습니다.
### PUT /api/modules/sirsoft-board/boards/{slug}/posts/{postId}/comments/{commentId}
@@ -3749,7 +3749,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생
<!-- @generated:end -->
**설명** 댓글을 수정합니다. `auth:sanctum` + `sirsoft-board.{slug}.comments.write` 또는 게시판 manager 권한이 필요하며, 게시판의 댓글 기능이 꺼져 있으면 403을 반환합니다. 서비스의 `canUpdate` 가 작성자 본인·게시판 관리자·비회원 댓글의 비밀번호 확인 여부를 판정하며, 검증용 `password` 는 저장에서 제외됩니다.
**설명** 댓글을 수정합니다. `auth:sanctum` + `sirsoft-board.{slug}.comments.write` 또는 게시판 manager 권한이 필요하며, 게시판의 댓글 기능이 꺼져 있으면 403을 반환합니다. 서비스의 `canUpdate` 가 작성자 본인·게시판 관리자·비회원 댓글의 비밀번호 확인 여부를 판정하며, 검증용 `password` 는 저장에서 제외됩니다. 경로의 `{postId}`에 속한 댓글만 대상이 됩니다 — 다른 게시글의 댓글 ID를 지정하면 404를 반환하며 해당 댓글은 변경되지 않습니다. 요청 본문에서는 `content`·`is_secret`·`status`만 반영되며, `post_id`·`parent_id`·`depth` 등 그 밖의 필드를 함께 보내도 무시됩니다.
### POST /api/modules/sirsoft-board/boards/{slug}/posts/{postId}/reports
@@ -127,8 +127,8 @@ class CommentController extends AdminBaseController
public function update(UpdateCommentRequest $request, string $slug, int $postId, int $id): JsonResponse
{
try {
// 댓글 조회
$comment = $this->commentService->getComment($slug, $id);
// 경로의 게시글에 속한 댓글만 조회 (교차 게시글 접근 차단)
$comment = $this->commentService->getComment($slug, $id, $postId);
// 권한 체크: 본인 댓글 또는 admin.manage
if ($response = $this->authorizeCommentModification($slug, $comment->user_id)) {
@@ -146,7 +146,7 @@ class CommentController extends AdminBaseController
}
$data = $request->validated();
$updatedComment = $this->commentService->updateComment($slug, $id, $data);
$updatedComment = $this->commentService->updateComment($slug, $id, $data, $postId);
return $this->successWithResource(
'sirsoft-board::messages.comment.update_success',
@@ -170,8 +170,8 @@ class CommentController extends AdminBaseController
public function destroy(string $slug, int $postId, int $id): JsonResponse
{
try {
// 댓글 조회
$comment = $this->commentService->getComment($slug, $id);
// 경로의 게시글에 속한 댓글만 조회 (교차 게시글 접근 차단)
$comment = $this->commentService->getComment($slug, $id, $postId);
// 권한 체크: 본인 댓글 또는 admin.manage
if ($response = $this->authorizeCommentModification($slug, $comment->user_id)) {
@@ -188,7 +188,7 @@ class CommentController extends AdminBaseController
return $this->error('sirsoft-board::messages.comments.comments_disabled', 403);
}
$this->commentService->deleteComment($slug, $id, 'admin');
$this->commentService->deleteComment($slug, $id, 'admin', $postId);
return $this->success('sirsoft-board::messages.comment.delete_success');
} catch (ModelNotFoundException) {
@@ -223,7 +223,7 @@ class CommentController extends AdminBaseController
$validated = $request->validated();
$reason = $validated['reason'] ?? '';
$comment = $this->commentService->blindComment($slug, $id, $reason);
$comment = $this->commentService->blindComment($slug, $id, $reason, null, $postId);
return $this->successWithResource(
'sirsoft-board::messages.comment.blind_success',
@@ -259,7 +259,7 @@ class CommentController extends AdminBaseController
}
$reason = $request->validated()['reason'] ?? null;
$comment = $this->commentService->restoreComment($slug, $id, $reason);
$comment = $this->commentService->restoreComment($slug, $id, $reason, null, $postId);
return $this->successWithResource(
'sirsoft-board::messages.comment.restore_success',
@@ -5,14 +5,14 @@ namespace Modules\Sirsoft\Board\Http\Controllers\User;
use App\Http\Controllers\Api\Base\PublicBaseController;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Modules\Sirsoft\Board\Http\Requests\StoreCommentRequest;
use Modules\Sirsoft\Board\Http\Requests\UpdateCommentRequest;
use Modules\Sirsoft\Board\Http\Requests\VerifyCommentPasswordRequest;
use Modules\Sirsoft\Board\Http\Resources\CommentResource;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Services\BoardService;
use Modules\Sirsoft\Board\Services\CommentService;
/**
@@ -26,9 +26,11 @@ class CommentController extends PublicBaseController
* CommentController 생성자
*
* @param CommentService $commentService 댓글 서비스
* @param BoardService $boardService 게시판 서비스
*/
public function __construct(
private CommentService $commentService
private CommentService $commentService,
private BoardService $boardService
) {
parent::__construct();
}
@@ -43,7 +45,11 @@ class CommentController extends PublicBaseController
public function index(string $slug, int $postId): JsonResponse
{
try {
$board = Board::where('slug', $slug)->firstOrFail();
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
if (! $board) {
return $this->error('sirsoft-board::messages.boards.not_found', 404);
}
if (! $board->use_comment) {
return $this->error('sirsoft-board::messages.comments.comments_disabled', 403);
@@ -73,13 +79,19 @@ class CommentController extends PublicBaseController
public function store(StoreCommentRequest $request, string $slug, int $postId): JsonResponse
{
try {
$board = Board::where('slug', $slug)->firstOrFail();
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
if (! $board) {
return $this->error('sirsoft-board::messages.boards.not_found', 404);
}
if (! $board->use_comment) {
return $this->error('sirsoft-board::messages.comments.comments_disabled', 403);
}
$data = $request->validated();
// 경로의 게시글을 SSoT 로 고정 (요청 본문 값이 경로와 갈라지지 않도록)
$data['post_id'] = $postId;
$data['ip_address'] = $request->ip();
// user_id 설정 (인증 필수)
@@ -119,13 +131,18 @@ class CommentController extends PublicBaseController
public function update(UpdateCommentRequest $request, string $slug, int $postId, int $commentId): JsonResponse
{
try {
$board = Board::where('slug', $slug)->firstOrFail();
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
if (! $board) {
return $this->error('sirsoft-board::messages.boards.not_found', 404);
}
if (! $board->use_comment) {
return $this->error('sirsoft-board::messages.comments.comments_disabled', 403);
}
$comment = $this->commentService->getComment($slug, $commentId);
// 경로의 게시글에 속한 댓글만 조회 (교차 게시글 접근 차단)
$comment = $this->commentService->getComment($slug, $commentId, $postId);
// 권한 확인 (Service에서 처리)
$canUpdate = $this->commentService->canUpdate(
@@ -139,9 +156,9 @@ class CommentController extends PublicBaseController
return $this->forbidden('sirsoft-board::messages.comment.update_forbidden');
}
// password는 검증용이므로 제거
$data = $request->except('password');
$updatedComment = $this->commentService->updateComment($slug, $commentId, $data);
// 검증된 필드만 반영 (미검증 입력의 대량 할당 차단). password는 검증용이므로 제거
$data = collect($request->validated())->except('password')->toArray();
$updatedComment = $this->commentService->updateComment($slug, $commentId, $data, $postId);
return $this->successWithResource(
'sirsoft-board::messages.comment.update_success',
@@ -165,13 +182,18 @@ class CommentController extends PublicBaseController
public function destroy(string $slug, int $postId, int $commentId): JsonResponse
{
try {
$board = Board::where('slug', $slug)->firstOrFail();
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
if (! $board) {
return $this->error('sirsoft-board::messages.boards.not_found', 404);
}
if (! $board->use_comment) {
return $this->error('sirsoft-board::messages.comments.comments_disabled', 403);
}
$comment = $this->commentService->getComment($slug, $commentId);
// 경로의 게시글에 속한 댓글만 조회 (교차 게시글 접근 차단)
$comment = $this->commentService->getComment($slug, $commentId, $postId);
// 비회원인 경우 password 파라미터 필요
$password = request()->input('password');
@@ -188,7 +210,7 @@ class CommentController extends PublicBaseController
return $this->forbidden('sirsoft-board::messages.comment.delete_forbidden');
}
$this->commentService->deleteComment($slug, $commentId, 'user');
$this->commentService->deleteComment($slug, $commentId, 'user', $postId);
return $this->success(
'sirsoft-board::messages.comment.delete_success'
@@ -203,17 +225,13 @@ class CommentController extends PublicBaseController
/**
* 비회원 댓글 비밀번호를 검증합니다.
*
* @param Request $request 요청
* @param VerifyCommentPasswordRequest $request 비밀번호 확인 요청
* @param string $slug 게시판 slug
* @param int $commentId 댓글 ID
* @return JsonResponse 비밀번호 검증 결과 응답
*/
public function verifyPassword(Request $request, string $slug, int $commentId): JsonResponse
public function verifyPassword(VerifyCommentPasswordRequest $request, string $slug, int $commentId): JsonResponse
{
$request->validate([
'password' => ['required', 'string'],
]);
try {
$comment = $this->commentService->getComment($slug, $commentId);
@@ -76,7 +76,8 @@ class StoreCommentRequest extends FormRequest
'parent_id' => [
'nullable',
'integer',
new CommentValidationRule($slug, 'parent_comment'),
// 부모 댓글은 요청 경로의 게시글에 속해야 한다 (교차 게시글 부모 차단)
new CommentValidationRule($slug, 'parent_comment', (int) $this->route('postId')),
],
// depth는 서비스에서 자동 계산되므로 요청 데이터에서 받지 않음 (보안상 이유)
'is_secret' => ['boolean'],
@@ -0,0 +1,57 @@
<?php
namespace Modules\Sirsoft\Board\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 비회원 댓글 비밀번호 확인 요청 폼 검증
*/
class VerifyCommentPasswordRequest extends FormRequest
{
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* @return bool 권한 검증 결과 (권한 체크는 미들웨어 위임, 항상 true)
*/
public function authorize(): bool
{
return true;
}
/**
* 요청에 적용할 검증 규칙
*
* @return array<string, mixed> 필드별 검증 규칙
*/
public function rules(): array
{
return [
'password' => ['required', 'string'],
];
}
/**
* 검증 오류 메시지 커스터마이징
*
* @return array<string, string> 규칙별 다국어 메시지
*/
public function messages(): array
{
return [
'password.required' => __('sirsoft-board::validation.comment.password.required'),
];
}
/**
* 검증할 필드의 이름을 커스터마이징
*
* @return array<string, string> 필드별 표시명
*/
public function attributes(): array
{
return [
'password' => __('sirsoft-board::validation.attributes.comment.password'),
];
}
}
@@ -252,16 +252,20 @@ class CommentRepository implements CommentRepositoryInterface
/**
* ID로 댓글을 조회합니다.
*
* $postId 를 전달하면 해당 게시글에 속한 댓글만 조회합니다 (교차 게시글 접근 차단).
*
* @param string $slug 게시판 슬러그
* @param int $id 댓글 ID
* @param int|null $postId 상위 게시글 ID (null 이면 게시판 범위 전체)
* @return Comment|null 댓글 또는 null
*/
public function find(string $slug, int $id): ?Comment
public function find(string $slug, int $id, ?int $postId = null): ?Comment
{
$board = Board::where('slug', $slug)->first();
return Comment::query()
->where('board_id', $board?->id)
->when($postId !== null, fn ($query) => $query->where('post_id', $postId))
->withTrashed()
->with(['user'])
->find($id);
@@ -270,18 +274,22 @@ class CommentRepository implements CommentRepositoryInterface
/**
* ID로 댓글을 조회하며, 없으면 예외를 발생시킵니다.
*
* $postId 를 전달하면 해당 게시글에 속한 댓글만 조회합니다 (교차 게시글 접근 차단).
*
* @param string $slug 게시판 슬러그
* @param int $id 댓글 ID
* @param int|null $postId 상위 게시글 ID (null 이면 게시판 범위 전체)
* @return Comment 댓글 모델
*
* @throws ModelNotFoundException
*/
public function findOrFail(string $slug, int $id): Comment
public function findOrFail(string $slug, int $id, ?int $postId = null): Comment
{
$board = Board::where('slug', $slug)->firstOrFail();
return Comment::query()
->where('board_id', $board->id)
->when($postId !== null, fn ($query) => $query->where('post_id', $postId))
->withTrashed()
->with(['user'])
->findOrFail($id);
@@ -19,6 +19,8 @@ interface CommentRepositoryInterface
* @param int $postId 게시글 ID
* @param bool $withTrashed 삭제된 댓글 포함 여부 (기본값: false)
* @param string $orderDirection 정렬 방향 (ASC 또는 DESC, 기본값: DESC)
* @param string|null $scopePermission 스코프 권한 식별자 (null 이면 스코프 필터 미적용)
* @param int|null $boardId 게시판 ID (전달 시 Board 재조회 생략)
* @return Collection 정렬된 댓글 컬렉션
*/
public function getByPostId(string $slug, int $postId, bool $withTrashed = false, string $orderDirection = 'DESC', ?string $scopePermission = null, ?int $boardId = null): Collection;
@@ -35,22 +37,28 @@ interface CommentRepositoryInterface
/**
* ID로 댓글을 조회합니다.
*
* $postId 를 전달하면 해당 게시글에 속한 댓글만 조회합니다 (교차 게시글 접근 차단).
*
* @param string $slug 게시판 슬러그
* @param int $id 댓글 ID
* @param int|null $postId 상위 게시글 ID (null 이면 게시판 범위 전체)
* @return Comment|null 댓글 모델 또는 null
*/
public function find(string $slug, int $id): ?Comment;
public function find(string $slug, int $id, ?int $postId = null): ?Comment;
/**
* ID로 댓글을 조회하며, 없으면 예외를 발생시킵니다.
*
* $postId 를 전달하면 해당 게시글에 속한 댓글만 조회합니다 (교차 게시글 접근 차단).
*
* @param string $slug 게시판 슬러그
* @param int $id 댓글 ID
* @param int|null $postId 상위 게시글 ID (null 이면 게시판 범위 전체)
* @return Comment 댓글 모델
*
* @throws ModelNotFoundException
*/
public function findOrFail(string $slug, int $id): Comment;
public function findOrFail(string $slug, int $id, ?int $postId = null): Comment;
/**
* 댓글을 수정합니다.
@@ -94,6 +102,7 @@ interface CommentRepositoryInterface
* @param string $status 변경할 상태 (blinded/deleted)
* @param array $actionLog 작업 이력 데이터
* @param string|null $triggerType 트리거 유형 (report, admin, auto_hide 등)
* @return Comment 상태가 변경된 댓글 모델
*
* @throws ModelNotFoundException
*/
@@ -23,10 +23,12 @@ class CommentValidationRule implements ValidationRule
*
* @param string $slug 게시판 슬러그
* @param string $validationType 검증 타입 ('post' 또는 'parent_comment')
* @param int|null $postId 상위 게시글 ID (parent_comment 검증 시 부모 댓글의 소속 게시글 확인용)
*/
public function __construct(
private string $slug,
private string $validationType = 'post'
private string $validationType = 'post',
private ?int $postId = null
) {}
/**
@@ -89,7 +91,10 @@ class CommentValidationRule implements ValidationRule
{
$board = Board::where('slug', $this->slug)->first();
$parentComment = $board
? Comment::where('board_id', $board->id)->withTrashed()->find($parentId)
? Comment::where('board_id', $board->id)
->when($this->postId !== null, fn ($query) => $query->where('post_id', $this->postId))
->withTrashed()
->find($parentId)
: null;
if (! $parentComment) {
@@ -89,12 +89,14 @@ class CommentService
*
* @param string $slug 게시판 슬러그
* @param int $id 댓글 ID
* @param int|null $postId 상위 게시글 ID (전달 시 해당 게시글의 댓글로 조회 범위 제한)
* @return Comment 댓글 모델
*
* @throws ModelNotFoundException
*/
public function getComment(string $slug, int $id): Comment
public function getComment(string $slug, int $id, ?int $postId = null): Comment
{
return $this->commentRepository->findOrFail($slug, $id);
return $this->commentRepository->findOrFail($slug, $id, $postId);
}
/**
@@ -228,6 +230,7 @@ class CommentService
*
* @param string $slug 게시판 슬러그
* @param array $data 댓글 생성 데이터
* @return Comment 생성된 댓글 모델
*
* @throws \Exception 블라인드/삭제된 게시글에 댓글 작성 시
*/
@@ -251,10 +254,11 @@ class CommentService
// depth 자동 계산 (답글인 경우)
if (! empty($data['parent_id'])) {
$parentComment = $this->commentRepository->find($slug, $data['parent_id']);
// 같은 게시글에 속한 부모 댓글만 인정 (교차 게시글 부모 차단)
$parentComment = $this->commentRepository->find($slug, $data['parent_id'], (int) $data['post_id']);
if ($parentComment) {
// 부모 댓글의 depth + 1 (최대 5까지)
$data['depth'] = min(($parentComment->depth ?? 0) + 1, 5);
// 부모 댓글의 depth + 1 — 상한은 CommentValidationRule 이 게시판 설정으로 검증
$data['depth'] = ($parentComment->depth ?? 0) + 1;
} else {
// 부모 댓글을 찾을 수 없으면 0으로 설정
$data['depth'] = 0;
@@ -282,12 +286,14 @@ class CommentService
* @param string $slug 게시판 슬러그
* @param int $id 댓글 ID
* @param array $data 수정할 데이터
* @param int|null $postId 상위 게시글 ID (전달 시 해당 게시글의 댓글로 조회 범위 제한)
* @return Comment 수정된 댓글 모델
*
* @throws ModelNotFoundException
*/
public function updateComment(string $slug, int $id, array $data): Comment
public function updateComment(string $slug, int $id, array $data, ?int $postId = null): Comment
{
$comment = $this->commentRepository->findOrFail($slug, $id);
$comment = $this->commentRepository->findOrFail($slug, $id, $postId);
// 훅: before_update
HookManager::doAction('sirsoft-board.comment.before_update', $comment, $data, $slug);
@@ -312,12 +318,14 @@ class CommentService
* @param string $slug 게시판 슬러그
* @param int $id 댓글 ID
* @param string|null $triggerType 트리거 유형 (admin, user, report 등)
* @param int|null $postId 상위 게시글 ID (전달 시 해당 게시글의 댓글로 조회 범위 제한)
* @return bool 삭제 성공 여부
*
* @throws ModelNotFoundException
*/
public function deleteComment(string $slug, int $id, ?string $triggerType = null): bool
public function deleteComment(string $slug, int $id, ?string $triggerType = null, ?int $postId = null): bool
{
$comment = $this->commentRepository->findOrFail($slug, $id);
$comment = $this->commentRepository->findOrFail($slug, $id, $postId);
// 훅: before_delete
HookManager::doAction('sirsoft-board.comment.before_delete', $comment, $slug);
@@ -345,12 +353,14 @@ class CommentService
* @param int $id 댓글 ID
* @param string $reason 블라인드 사유
* @param string|null $triggerType 트리거 유형 (report, admin, auto_hide 등)
* @param int|null $postId 상위 게시글 ID (전달 시 해당 게시글의 댓글로 조회 범위 제한)
* @return Comment 블라인드 처리된 댓글 모델
*
* @throws ModelNotFoundException
*/
public function blindComment(string $slug, int $id, string $reason, ?string $triggerType = null): Comment
public function blindComment(string $slug, int $id, string $reason, ?string $triggerType = null, ?int $postId = null): Comment
{
$comment = $this->commentRepository->findOrFail($slug, $id);
$comment = $this->commentRepository->findOrFail($slug, $id, $postId);
// 멱등성: 이미 블라인드 상태이면 중복 처리 방지
if ($comment->status === PostStatus::Blinded) {
@@ -379,12 +389,14 @@ class CommentService
* @param int $id 댓글 ID
* @param string|null $reason 복원 사유
* @param string|null $triggerType 트리거 유형 (report, admin, auto_hide 등)
* @param int|null $postId 상위 게시글 ID (전달 시 해당 게시글의 댓글로 조회 범위 제한)
* @return Comment 복원된 댓글 모델
*
* @throws ModelNotFoundException
*/
public function restoreComment(string $slug, int $id, ?string $reason = null, ?string $triggerType = null): Comment
public function restoreComment(string $slug, int $id, ?string $reason = null, ?string $triggerType = null, ?int $postId = null): Comment
{
$comment = $this->commentRepository->findOrFail($slug, $id);
$comment = $this->commentRepository->findOrFail($slug, $id, $postId);
// 멱등성: 이미 게시됨 상태이면 중복 처리 방지
if ($comment->status === PostStatus::Published) {
@@ -0,0 +1,205 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\Admin;
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\User;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 관리자 댓글 API 상위 게시글 스코프 검증 테스트
*
* 검증 목적:
* - update / destroy / blind / restore 4종 모두 교차 게시글 URL 로 접근할 수 없다
* - 정상 경로로는 기존과 동일하게 동작한다
*
* @group board
* @group admin
* @group comment
* @group scope
*/
class CommentScopeTest extends BoardTestCase
{
private User $adminWithManage;
protected function getTestBoardSlug(): string
{
return 'admin-comment-scope';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '관리자 댓글 스코프 게시판', 'en' => 'Admin Comment Scope Board'],
'is_active' => true,
'use_comment' => true,
'max_comment_depth' => 10,
];
}
protected function setUp(): void
{
parent::setUp();
$slug = $this->board->slug;
$this->adminWithManage = $this->createAdminUser([
"sirsoft-board.{$slug}.admin.manage",
"sirsoft-board.{$slug}.admin.comments.write",
]);
}
/**
* 교차 게시글 URL 로 댓글 수정 → 404
*
* @scenario resource=board_comment_admin, parent_scope=mismatched, actor=admin
*
* @effects cross_scope_write_rejected, target_resource_unchanged_on_rejection
*/
public function test_cannot_update_comment_through_other_post_url(): void
{
[$postA, $postB] = $this->createTwoPosts();
$commentId = $this->createTestComment($postA, ['content' => '원래 내용']);
$this->actingAs($this->adminWithManage)
->putJson($this->url($postB, $commentId), ['content' => '교차 수정 시도'])
->assertStatus(404);
$this->assertDatabaseHas('board_comments', [
'id' => $commentId,
'content' => '원래 내용',
]);
}
/**
* 교차 게시글 URL 로 댓글 삭제 → 404
*
* @scenario resource=board_comment_admin, parent_scope=mismatched, actor=admin
*
* @effects cross_scope_write_rejected, target_resource_unchanged_on_rejection
*/
public function test_cannot_delete_comment_through_other_post_url(): void
{
[$postA, $postB] = $this->createTwoPosts();
$commentId = $this->createTestComment($postA);
$this->actingAs($this->adminWithManage)
->deleteJson($this->url($postB, $commentId))
->assertStatus(404);
$this->assertDatabaseHas('board_comments', [
'id' => $commentId,
'deleted_at' => null,
]);
}
/**
* 교차 게시글 URL 로 댓글 블라인드 → 404
*
* @scenario resource=board_comment_admin, parent_scope=mismatched, actor=admin
*
* @effects cross_scope_write_rejected, target_resource_unchanged_on_rejection
*/
public function test_cannot_blind_comment_through_other_post_url(): void
{
[$postA, $postB] = $this->createTwoPosts();
$commentId = $this->createTestComment($postA, ['status' => 'published']);
$this->actingAs($this->adminWithManage)
->patchJson($this->url($postB, $commentId).'/blind', ['reason' => '교차 블라인드 시도'])
->assertStatus(404);
$this->assertDatabaseHas('board_comments', [
'id' => $commentId,
'status' => 'published',
]);
}
/**
* 교차 게시글 URL 로 댓글 복원 → 404
*
* @scenario resource=board_comment_admin, parent_scope=mismatched, actor=admin
*
* @effects cross_scope_write_rejected, target_resource_unchanged_on_rejection
*/
public function test_cannot_restore_comment_through_other_post_url(): void
{
[$postA, $postB] = $this->createTwoPosts();
$commentId = $this->createTestComment($postA, ['status' => 'blinded']);
$this->actingAs($this->adminWithManage)
->patchJson($this->url($postB, $commentId).'/restore', [])
->assertStatus(404);
$this->assertDatabaseHas('board_comments', [
'id' => $commentId,
'status' => 'blinded',
]);
}
/**
* 정상 경로로는 4종 모두 동작한다 (회귀 방지)
*
* @scenario resource=board_comment_admin, parent_scope=matching, actor=admin
*
* @effects matching_scope_still_succeeds
*/
public function test_own_post_url_still_works_for_all_operations(): void
{
$postId = $this->createTestPost();
$updateTarget = $this->createTestComment($postId, ['content' => '원래 내용']);
$this->actingAs($this->adminWithManage)
->putJson($this->url($postId, $updateTarget), ['content' => '정상 수정'])
->assertStatus(200);
$blindTarget = $this->createTestComment($postId, ['status' => 'published']);
$this->actingAs($this->adminWithManage)
->patchJson($this->url($postId, $blindTarget).'/blind', ['reason' => '정상 블라인드'])
->assertStatus(200);
$restoreTarget = $this->createTestComment($postId, ['status' => 'blinded']);
$this->actingAs($this->adminWithManage)
->patchJson($this->url($postId, $restoreTarget).'/restore', [])
->assertStatus(200);
$deleteTarget = $this->createTestComment($postId);
$this->actingAs($this->adminWithManage)
->deleteJson($this->url($postId, $deleteTarget))
->assertStatus(200);
$this->assertDatabaseHas('board_comments', ['id' => $updateTarget, 'content' => '정상 수정']);
$this->assertDatabaseHas('board_comments', ['id' => $blindTarget, 'status' => 'blinded']);
$this->assertDatabaseHas('board_comments', ['id' => $restoreTarget, 'status' => 'published']);
$this->assertSoftDeleted('board_comments', ['id' => $deleteTarget]);
}
/**
* 서로 다른 두 게시글을 생성합니다.
*
* @return array{0: int, 1: int} [게시글 A ID, 게시글 B ID]
*/
private function createTwoPosts(): array
{
return [
$this->createTestPost(['title' => '게시글 A']),
$this->createTestPost(['title' => '게시글 B']),
];
}
/**
* 관리자 댓글 API URL 을 생성합니다.
*
* @param int $postId 게시글 ID
* @param int $commentId 댓글 ID
* @return string 댓글 엔드포인트 URL
*/
private function url(int $postId, int $commentId): string
{
$slug = $this->board->slug;
return "/api/modules/sirsoft-board/admin/board/{$slug}/posts/{$postId}/comments/{$commentId}";
}
}
@@ -0,0 +1,172 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature;
require_once __DIR__.'/../ModuleTestCase.php';
use App\Models\Role;
use App\Models\User;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 대댓글 깊이 제한 테스트
*
* 검증 목적:
* - 저장되는 depth 가 항상 부모 depth + 1 (하드코딩 상한 없음)
* - 게시판 설정 max_comment_depth 를 넘는 답글은 422
* - max 를 낮게(3) / 높게(10) 설정한 두 경우 모두 설정값을 따른다
*
* @group board
* @group comment
* @group depth
*/
class CommentDepthLimitTest extends BoardTestCase
{
private User $memberUser;
protected function getTestBoardSlug(): string
{
return 'comment-depth-limit';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '댓글 깊이 제한 게시판', 'en' => 'Comment Depth Limit Board'],
'is_active' => true,
'use_comment' => true,
'max_comment_depth' => 10,
];
}
protected function setUp(): void
{
parent::setUp();
$this->setGuestPermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']);
$this->grantUserRolePermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']);
$this->memberUser = User::factory()->create(['email' => 'comment-depth@test.com']);
$userRole = Role::where('identifier', 'user')->first();
if ($userRole) {
$this->memberUser->roles()->attach($userRole->id);
}
}
/**
* max_comment_depth=10 게시판에서 depth 는 1..10 까지 정확히 증가한다
*
* @scenario entity=board_comment, parent_target=unrelated
*
* @effects depth_equals_parent_depth_plus_one, depth_follows_board_setting_not_literal
*/
public function test_depth_increments_up_to_configured_max(): void
{
$postId = $this->createTestPost();
$parentId = $this->createTestComment($postId, ['depth' => 0]);
for ($expectedDepth = 1; $expectedDepth <= 10; $expectedDepth++) {
$response = $this->actingAs($this->memberUser)
->postJson($this->storeUrl($postId), [
'content' => "{$expectedDepth}단계 답글",
'parent_id' => $parentId,
]);
$response->assertStatus(201);
$parentId = (int) $response->json('data.id');
$this->assertDatabaseHas('board_comments', [
'id' => $parentId,
'depth' => $expectedDepth,
]);
}
}
/**
* max_comment_depth=10 게시판에서 11단계 답글은 422
*
* @scenario entity=board_comment, parent_target=unrelated
*
* @effects depth_beyond_board_max_rejected_422
*/
public function test_reply_beyond_configured_max_is_rejected(): void
{
$postId = $this->createTestPost();
// depth 10 인 부모 댓글을 직접 생성 → 11단계 시도
$parentId = $this->createTestComment($postId, ['depth' => 10]);
$this->actingAs($this->memberUser)
->postJson($this->storeUrl($postId), [
'content' => '11단계 답글 시도',
'parent_id' => $parentId,
])
->assertStatus(422)
->assertJsonValidationErrors(['parent_id']);
}
/**
* max_comment_depth=3 게시판에서 4단계 답글은 422
*
* @scenario entity=board_comment, parent_target=unrelated
*
* @effects depth_beyond_board_max_rejected_422, depth_follows_board_setting_not_literal
*/
public function test_reply_beyond_lowered_max_is_rejected(): void
{
$this->updateBoardSettings(['max_comment_depth' => 3]);
$postId = $this->createTestPost();
$parentId = $this->createTestComment($postId, ['depth' => 3]);
$this->actingAs($this->memberUser)
->postJson($this->storeUrl($postId), [
'content' => '4단계 답글 시도',
'parent_id' => $parentId,
])
->assertStatus(422)
->assertJsonValidationErrors(['parent_id']);
}
/**
* max_comment_depth=3 게시판에서 3단계까지는 정상 생성된다
*
* @scenario entity=board_comment, parent_target=unrelated
*
* @effects depth_equals_parent_depth_plus_one, depth_follows_board_setting_not_literal
*/
public function test_reply_within_lowered_max_is_allowed(): void
{
$this->updateBoardSettings(['max_comment_depth' => 3]);
$postId = $this->createTestPost();
$parentId = $this->createTestComment($postId, ['depth' => 2]);
$response = $this->actingAs($this->memberUser)
->postJson($this->storeUrl($postId), [
'content' => '3단계 답글',
'parent_id' => $parentId,
]);
$response->assertStatus(201);
$this->assertDatabaseHas('board_comments', [
'id' => (int) $response->json('data.id'),
'depth' => 3,
]);
}
/**
* 댓글 생성 엔드포인트 URL 을 생성합니다.
*
* @param int $postId 게시글 ID
* @return string 댓글 생성 URL
*/
private function storeUrl(int $postId): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments";
}
}
@@ -0,0 +1,160 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\Upgrade;
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
use Modules\Sirsoft\Board\Upgrades\Upgrade_1_0_3;
/**
* 1.0.3 댓글 계층 오염 데이터 정리 업그레이드 스텝 테스트
*
* 검증 목적:
* - 교차 게시글 부모를 가진 고아 댓글이 최상위로 승격된다 (본문 보존)
* - depth 가 루트부터 부모 depth + 1 로 재계산된다
* - 재실행해도 결과가 동일하다 (멱등)
*
* @group board
* @group upgrade
*/
class CommentHierarchyCleanupTest extends BoardTestCase
{
protected function getTestBoardSlug(): string
{
return 'comment-hierarchy-cleanup';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '댓글 계층 정리 게시판', 'en' => 'Comment Hierarchy Cleanup Board'],
'is_active' => true,
'use_comment' => true,
'max_comment_depth' => 10,
];
}
/**
* 교차 게시글 부모 댓글이 최상위로 승격되고 본문은 보존된다
*
* @scenario entity=board_comment, parent_target=foreign
*
* @effects existing_cross_post_parent_detached_content_preserved
*/
public function test_cross_post_parent_is_detached_and_content_preserved(): void
{
$postA = $this->createTestPost(['title' => '게시글 A']);
$postB = $this->createTestPost(['title' => '게시글 B']);
$parentInA = $this->createTestComment($postA, ['depth' => 0]);
$orphan = $this->createTestComment($postB, [
'parent_id' => $parentInA,
'depth' => 1,
'content' => '보존되어야 하는 본문',
]);
$this->runCleanup();
$row = DB::table('board_comments')->where('id', $orphan)->first();
$this->assertNull($row->parent_id, '교차 게시글 부모는 해제되어야 합니다.');
$this->assertSame(0, (int) $row->depth, '승격된 댓글의 depth 는 0 이어야 합니다.');
$this->assertSame('보존되어야 하는 본문', $row->content, '본문은 보존되어야 합니다.');
$this->assertSame($postB, (int) $row->post_id, 'post_id 는 바뀌지 않아야 합니다.');
}
/**
* 클램프로 뭉개진 depth 가 부모 depth + 1 로 재계산된다
*
* @scenario entity=board_comment, parent_target=unrelated
*
* @effects existing_clamped_depth_recalculated
*/
public function test_clamped_depth_is_recalculated_from_root(): void
{
$postId = $this->createTestPost();
// 8단계 체인을 만들되, 6단계 이후 depth 를 5 로 뭉개 둔다 (구 클램프 재현)
$parentId = null;
$ids = [];
for ($i = 0; $i < 8; $i++) {
$storedDepth = min($i, 5);
$parentId = $this->createTestComment($postId, [
'parent_id' => $parentId,
'depth' => $storedDepth,
]);
$ids[$i] = $parentId;
}
$this->runCleanup();
foreach ($ids as $expectedDepth => $id) {
$this->assertSame(
$expectedDepth,
(int) DB::table('board_comments')->where('id', $id)->value('depth'),
"{$expectedDepth} 단계 댓글의 depth 가 재계산되어야 합니다."
);
}
}
/**
* 재실행해도 결과가 동일하다 (멱등)
*
* @scenario entity=board_comment, parent_target=unrelated
*
* @effects cleanup_is_idempotent
*/
public function test_cleanup_is_idempotent(): void
{
$postA = $this->createTestPost(['title' => '게시글 A']);
$postB = $this->createTestPost(['title' => '게시글 B']);
$parentInA = $this->createTestComment($postA, ['depth' => 0]);
$this->createTestComment($postB, ['parent_id' => $parentInA, 'depth' => 1]);
$chainRoot = $this->createTestComment($postA, ['parent_id' => $parentInA, 'depth' => 5]);
$this->createTestComment($postA, ['parent_id' => $chainRoot, 'depth' => 5]);
$this->runCleanup();
$first = $this->snapshot();
$this->runCleanup();
$second = $this->snapshot();
$this->assertSame($first, $second, '재실행 결과가 동일해야 합니다.');
}
/**
* 1.0.3 정리 마이그레이션을 실행합니다.
*/
private function runCleanup(): void
{
$context = new UpgradeContext('1.0.2', '1.0.3', '1.0.3', 'extension-upgrade');
$step = new Upgrade_1_0_3;
$step->run($context);
}
/**
* 이 게시판 댓글의 (id, parent_id, depth) 스냅샷을 반환합니다.
*
* @return array<int, array{id: int, parent_id: int|null, depth: int}> 스냅샷 배열
*/
private function snapshot(): array
{
return DB::table('board_comments')
->where('board_id', $this->board->id)
->orderBy('id')
->get(['id', 'parent_id', 'depth'])
->map(fn ($row) => [
'id' => (int) $row->id,
'parent_id' => $row->parent_id === null ? null : (int) $row->parent_id,
'depth' => (int) $row->depth,
])
->all();
}
}
@@ -0,0 +1,131 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\User;
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 댓글 수정 mass-assignment 방어 테스트
*
* 검증 목적:
* - UpdateCommentRequest 가 정의하지 않은 필드(post_id, board_id, user_id, parent_id,
* author_name, trigger_type, action_logs, depth, ip_address, replies_count)를 요청에
* 동봉해도 저장되지 않는다 — Comment::$fillable 의 초과 10개 전수
* - 정의된 필드(content, is_secret, status)는 정상 반영된다
*
* @group board
* @group comment
* @group security
*/
class CommentMassAssignmentTest extends BoardTestCase
{
private User $memberUser;
private User $otherUser;
protected function getTestBoardSlug(): string
{
return 'comment-mass-assignment';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '댓글 대량할당 게시판', 'en' => 'Comment Mass Assignment Board'],
'is_active' => true,
'use_comment' => true,
'max_comment_depth' => 10,
];
}
protected function setUp(): void
{
parent::setUp();
$this->setGuestPermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']);
$this->grantUserRolePermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']);
$this->memberUser = User::factory()->create(['email' => 'mass-assign-owner@test.com']);
$this->otherUser = User::factory()->create(['email' => 'mass-assign-other@test.com']);
$userRole = Role::where('identifier', 'user')->first();
if ($userRole) {
$this->memberUser->roles()->attach($userRole->id);
$this->otherUser->roles()->attach($userRole->id);
}
}
/**
* 검증되지 않은 필드는 저장되지 않는다
*
* @scenario resource=board_comment_user, parent_scope=matching, actor=member
*
* @effects unvalidated_fields_not_persisted, sibling_resource_state_unchanged
*/
public function test_update_ignores_fields_outside_form_request(): void
{
$postA = $this->createTestPost(['title' => '게시글 A']);
$postB = $this->createTestPost(['title' => '게시글 B']);
$siblingComment = $this->createTestComment($postA, ['depth' => 0]);
$commentId = $this->createTestComment($postA, [
'user_id' => $this->memberUser->id,
'author_name' => '원래 작성자',
'content' => '원래 내용',
'depth' => 0,
'ip_address' => '127.0.0.1',
]);
$before = DB::table('board_comments')->where('id', $commentId)->first();
$this->actingAs($this->memberUser)
->putJson($this->url($postA, $commentId), [
'content' => '수정된 내용',
// 아래는 UpdateCommentRequest 에 정의되지 않은 필드 — 전부 무시되어야 함
'post_id' => $postB,
'board_id' => $this->board->id + 999,
'user_id' => $this->otherUser->id,
'parent_id' => $siblingComment,
'author_name' => '탈취된 작성자',
'trigger_type' => 'admin',
'action_logs' => [['action' => 'forged']],
'depth' => 9,
'ip_address' => '10.0.0.1',
'replies_count' => 100,
])
->assertStatus(200);
$after = DB::table('board_comments')->where('id', $commentId)->first();
$this->assertSame('수정된 내용', $after->content, '검증된 필드는 반영되어야 합니다.');
$this->assertEquals($before->post_id, $after->post_id, 'post_id 가 변경되면 안 됩니다.');
$this->assertEquals($before->board_id, $after->board_id, 'board_id 가 변경되면 안 됩니다.');
$this->assertEquals($before->user_id, $after->user_id, 'user_id 가 변경되면 안 됩니다.');
$this->assertEquals($before->parent_id, $after->parent_id, 'parent_id 가 변경되면 안 됩니다.');
$this->assertEquals($before->author_name, $after->author_name, 'author_name 이 변경되면 안 됩니다.');
$this->assertEquals($before->trigger_type, $after->trigger_type, 'trigger_type 이 변경되면 안 됩니다.');
$this->assertEquals($before->action_logs, $after->action_logs, 'action_logs 가 변경되면 안 됩니다.');
$this->assertEquals($before->depth, $after->depth, 'depth 가 변경되면 안 됩니다.');
$this->assertEquals($before->ip_address, $after->ip_address, 'ip_address 가 변경되면 안 됩니다.');
$this->assertEquals($before->replies_count, $after->replies_count, 'replies_count 가 변경되면 안 됩니다.');
}
/**
* 사용자 댓글 API URL 을 생성합니다.
*
* @param int $postId 게시글 ID
* @param int $commentId 댓글 ID
* @return string 댓글 수정 URL
*/
private function url(int $postId, int $commentId): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments/{$commentId}";
}
}
@@ -0,0 +1,157 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\User;
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\Role;
use App\Models\User;
use Modules\Sirsoft\Board\Services\CommentService;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 대댓글 부모-자식 무결성 테스트
*
* 검증 목적:
* - 다른 게시글의 댓글을 parent_id 로 지정할 수 없다 (HTTP 경로 422)
* - Service 직접 호출 경로에서도 타 게시글 부모로 depth 가 계산되지 않는다
*
* @group board
* @group comment
* @group integrity
*/
class CommentParentIntegrityTest extends BoardTestCase
{
private User $memberUser;
protected function getTestBoardSlug(): string
{
return 'comment-parent-integrity';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '댓글 부모 무결성 게시판', 'en' => 'Comment Parent Integrity Board'],
'is_active' => true,
'use_comment' => true,
'max_comment_depth' => 10,
];
}
protected function setUp(): void
{
parent::setUp();
$this->setGuestPermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']);
$this->grantUserRolePermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']);
$this->memberUser = User::factory()->create(['email' => 'parent-integrity@test.com']);
$userRole = Role::where('identifier', 'user')->first();
if ($userRole) {
$this->memberUser->roles()->attach($userRole->id);
}
}
/**
* 다른 게시글의 댓글을 parent_id 로 지정하면 422
*
* @scenario entity=board_comment, parent_target=foreign
*
* @effects foreign_parent_rejected_422, parent_unchanged_on_rejection
*/
public function test_cannot_reply_to_comment_of_other_post(): void
{
$postA = $this->createTestPost(['title' => '게시글 A']);
$postB = $this->createTestPost(['title' => '게시글 B']);
$parentInPostA = $this->createTestComment($postA, ['depth' => 0]);
$this->actingAs($this->memberUser)
->postJson($this->storeUrl($postB), [
'content' => '교차 부모 답글 시도',
'parent_id' => $parentInPostA,
])
->assertStatus(422)
->assertJsonValidationErrors(['parent_id']);
$this->assertDatabaseMissing('board_comments', [
'post_id' => $postB,
'parent_id' => $parentInPostA,
]);
}
/**
* 같은 게시글의 댓글을 parent_id 로 지정하면 201 (회귀 방지)
*
* @scenario entity=board_comment, parent_target=unrelated
*
* @effects unrelated_parent_move_succeeds, depth_equals_parent_depth_plus_one
*/
public function test_can_reply_to_comment_of_same_post(): void
{
$postId = $this->createTestPost();
$parentId = $this->createTestComment($postId, ['depth' => 0]);
$this->actingAs($this->memberUser)
->postJson($this->storeUrl($postId), [
'content' => '정상 답글',
'parent_id' => $parentId,
])
->assertStatus(201);
$this->assertDatabaseHas('board_comments', [
'post_id' => $postId,
'parent_id' => $parentId,
'depth' => 1,
]);
}
/**
* Service 직접 호출 시에도 타 게시글 부모로 depth 가 계산되지 않는다
*
* FormRequest 를 우회하는 훅/내부 호출 경로에 대한 2차 방어를 검증합니다.
*
* @scenario entity=board_comment, parent_target=foreign
*
* @effects foreign_parent_rejected_422
*/
public function test_service_does_not_inherit_depth_from_other_post_parent(): void
{
$postA = $this->createTestPost(['title' => '게시글 A']);
$postB = $this->createTestPost(['title' => '게시글 B']);
// 게시글 A 에 depth 3 인 부모 댓글
$parentInPostA = $this->createTestComment($postA, ['depth' => 3]);
/** @var CommentService $service */
$service = app(CommentService::class);
$comment = $service->createComment($this->board->slug, [
'post_id' => $postB,
'parent_id' => $parentInPostA,
'content' => 'Service 직접 호출 교차 부모',
'author_name' => '테스트',
'ip_address' => '127.0.0.1',
]);
$this->assertSame(
0,
(int) $comment->depth,
'타 게시글 부모의 depth(3)를 상속해서는 안 됩니다.'
);
}
/**
* 댓글 생성 엔드포인트 URL 을 생성합니다.
*
* @param int $postId 게시글 ID
* @return string 댓글 생성 URL
*/
private function storeUrl(int $postId): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments";
}
}
@@ -0,0 +1,168 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Feature\User;
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\Role;
use App\Models\User;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 사용자 댓글 API 상위 게시글 스코프 검증 테스트
*
* 검증 목적:
* - 게시글 A 의 댓글을 게시글 B 의 URL 로 수정/삭제할 수 없다 (교차 경로 차단)
* - 정상 경로(자기 게시글 URL)로는 기존과 동일하게 수정/삭제된다
*
* @group board
* @group comment
* @group scope
*/
class CommentScopeTest extends BoardTestCase
{
private User $memberUser;
protected function getTestBoardSlug(): string
{
return 'comment-scope';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '댓글 스코프 테스트 게시판', 'en' => 'Comment Scope Test Board'],
'is_active' => true,
'use_comment' => true,
'max_comment_depth' => 10,
];
}
protected function setUp(): void
{
parent::setUp();
$this->setGuestPermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']);
$this->grantUserRolePermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']);
$this->memberUser = User::factory()->create(['email' => 'comment-scope-member@test.com']);
$userRole = Role::where('identifier', 'user')->first();
if ($userRole) {
$this->memberUser->roles()->attach($userRole->id);
}
}
/**
* 게시글 B 의 URL 로 게시글 A 의 댓글을 수정할 수 없다 → 404
*
* @scenario resource=board_comment_user, parent_scope=mismatched, actor=member
*
* @effects cross_scope_write_rejected, target_resource_unchanged_on_rejection
*/
public function test_cannot_update_comment_through_other_post_url(): void
{
$postA = $this->createTestPost(['title' => '게시글 A']);
$postB = $this->createTestPost(['title' => '게시글 B']);
$commentId = $this->createTestComment($postA, [
'user_id' => $this->memberUser->id,
'content' => '원래 내용',
]);
$this->actingAs($this->memberUser)
->putJson($this->url($postB, $commentId), ['content' => '교차 경로 수정 시도'])
->assertStatus(404);
$this->assertDatabaseHas('board_comments', [
'id' => $commentId,
'content' => '원래 내용',
]);
}
/**
* 게시글 B 의 URL 로 게시글 A 의 댓글을 삭제할 수 없다 → 404
*
* @scenario resource=board_comment_user, parent_scope=mismatched, actor=member
*
* @effects cross_scope_write_rejected, target_resource_unchanged_on_rejection
*/
public function test_cannot_delete_comment_through_other_post_url(): void
{
$postA = $this->createTestPost(['title' => '게시글 A']);
$postB = $this->createTestPost(['title' => '게시글 B']);
$commentId = $this->createTestComment($postA, [
'user_id' => $this->memberUser->id,
]);
$this->actingAs($this->memberUser)
->deleteJson($this->url($postB, $commentId))
->assertStatus(404);
$this->assertDatabaseHas('board_comments', [
'id' => $commentId,
'deleted_at' => null,
]);
}
/**
* 정상 경로(자기 게시글 URL)로는 댓글이 수정된다 → 200
*
* @scenario resource=board_comment_user, parent_scope=matching, actor=member
*
* @effects matching_scope_still_succeeds
*/
public function test_can_update_comment_through_own_post_url(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, [
'user_id' => $this->memberUser->id,
'content' => '원래 내용',
]);
$this->actingAs($this->memberUser)
->putJson($this->url($postId, $commentId), ['content' => '정상 수정'])
->assertStatus(200);
$this->assertDatabaseHas('board_comments', [
'id' => $commentId,
'content' => '정상 수정',
]);
}
/**
* 정상 경로(자기 게시글 URL)로는 댓글이 삭제된다 → 200
*
* @scenario resource=board_comment_user, parent_scope=matching, actor=member
*
* @effects matching_scope_still_succeeds
*/
public function test_can_delete_comment_through_own_post_url(): void
{
$postId = $this->createTestPost();
$commentId = $this->createTestComment($postId, [
'user_id' => $this->memberUser->id,
]);
$this->actingAs($this->memberUser)
->deleteJson($this->url($postId, $commentId))
->assertStatus(200);
$this->assertSoftDeleted('board_comments', ['id' => $commentId]);
}
/**
* 사용자 댓글 API URL 을 생성합니다.
*
* @param int $postId 게시글 ID
* @param int $commentId 댓글 ID
* @return string 댓글 수정/삭제 엔드포인트 URL
*/
private function url(int $postId, int $commentId): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments/{$commentId}";
}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Sirsoft\Board\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* Board 모듈 1.0.3 업그레이드 스텝
*
* 댓글 계층 오염 데이터를 정리한다.
*
* - 다른 게시글의 댓글을 부모로 가진 고아 댓글: 부모 연결 해제(최상위로 승격)
* - 부모 depth + 1 과 어긋난 depth: 루트부터 재계산
* - 게시판 설정 max_comment_depth 초과 댓글: 리포트만 (자동 이동 없음)
*
* 모든 비즈니스 로직은 data/1.0.3/migrations/ 로 격리(AbstractUpgradeStep 규약).
*
* @upgrade-path A
*/
class Upgrade_1_0_3 extends AbstractUpgradeStep {}
@@ -0,0 +1,89 @@
<?php
namespace App\Upgrades\Data\Ext\Modules\SirsoftBoard\V1_0_3\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 다른 게시글의 댓글을 부모로 가진 고아 댓글의 부모 연결을 해제합니다.
*
* 부모 댓글의 소속 게시글을 검증하지 않던 시기에, 게시글 A 의 댓글을 게시글 B 댓글의
* parent_id 로 지정하면 그대로 저장되었습니다. 이렇게 만들어진 댓글은 어느 게시글의
* 목록에도 나타나지 않는 고아 상태가 됩니다.
*
* 본문 손실을 피하기 위해 삭제하지 않고 parent_id 를 NULL 로 되돌려 최상위 댓글로
* 승격시킵니다. depth 는 후속 마이그레이션(02_RecalculateCommentDepth)이 재계산합니다.
*
* idempotent: 교차 부모가 없으면 no-op. V-1 안전: Facades\DB/Schema 만 사용.
*/
class ReparentCrossPostComments implements DataMigration
{
private const TABLE = 'board_comments';
/**
* 마이그레이션 식별자를 반환합니다.
*
* @return string 마이그레이션 이름
*/
public function name(): string
{
return 'ReparentCrossPostComments';
}
/**
* 교차 게시글 부모를 가진 댓글의 부모 연결을 해제합니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
*/
public function run(UpgradeContext $context): void
{
if (! Schema::hasTable(self::TABLE)
|| ! Schema::hasColumn(self::TABLE, 'parent_id')
|| ! Schema::hasColumn(self::TABLE, 'post_id')) {
$context->logger->info('[board:1.0.3] 댓글 부모 정리 — 대상 스키마 부재로 스킵');
return;
}
$orphans = DB::table(self::TABLE.' as c')
->join(self::TABLE.' as p', 'c.parent_id', '=', 'p.id')
->whereColumn('c.post_id', '<>', 'p.post_id')
->select('c.id', 'c.post_id', 'c.parent_id', 'c.depth', 'p.post_id as parent_post_id')
->orderBy('c.id')
->get();
if ($orphans->isEmpty()) {
$context->logger->info('[board:1.0.3] 교차 게시글 부모 댓글 없음 — 정리 스킵');
return;
}
// 변경 전 스냅샷 기록 (복구 근거)
foreach ($orphans as $row) {
$context->logger->info(sprintf(
'[board:1.0.3] 스냅샷 comment_id=%d post_id=%d parent_id=%d parent_post_id=%d depth=%d',
$row->id,
$row->post_id,
$row->parent_id,
$row->parent_post_id,
(int) $row->depth
));
}
$ids = $orphans->pluck('id')->all();
foreach (array_chunk($ids, 500) as $chunk) {
DB::table(self::TABLE)
->whereIn('id', $chunk)
->update(['parent_id' => null, 'depth' => 0]);
}
$context->logger->info(sprintf(
'[board:1.0.3] 교차 게시글 부모 댓글 %d 건을 최상위 댓글로 승격 완료',
count($ids)
));
}
}
@@ -0,0 +1,205 @@
<?php
namespace App\Upgrades\Data\Ext\Modules\SirsoftBoard\V1_0_3\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 댓글 depth 를 루트부터 BFS 로 재계산합니다.
*
* depth 를 5 로 고정 클램프하던 시기에 6단계 이후 답글이 모두 depth 5 로 저장되어,
* 화면상 들여쓰기가 무너지고 부모 depth + 1 규칙이 깨진 행이 남아 있습니다.
*
* 최상위 댓글(parent_id IS NULL)을 depth 0 으로 두고 자식 세대마다 +1 을 부여합니다.
* 콘텐츠는 건드리지 않으며 depth 컬럼만 갱신하므로 결정론적입니다.
*
* idempotent: 이미 정합한 행은 갱신 대상에서 제외되어 재실행해도 결과가 같습니다.
* 순환 참조가 남아 있어도 방문 ID 집합으로 유한 종료합니다.
* V-1 안전: Facades\DB/Schema 만 사용.
*/
class RecalculateCommentDepth implements DataMigration
{
private const TABLE = 'board_comments';
/** @var int 세대 순회 상한 (순환 참조 방어) */
private const MAX_LEVELS = 1000;
/** @var int whereIn 청크 크기 */
private const CHUNK = 500;
/**
* 마이그레이션 식별자를 반환합니다.
*
* @return string 마이그레이션 이름
*/
public function name(): string
{
return 'RecalculateCommentDepth';
}
/**
* 댓글 depth 를 루트부터 재계산합니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
*/
public function run(UpgradeContext $context): void
{
if (! Schema::hasTable(self::TABLE)
|| ! Schema::hasColumn(self::TABLE, 'parent_id')
|| ! Schema::hasColumn(self::TABLE, 'depth')) {
$context->logger->info('[board:1.0.3] depth 재계산 — 대상 스키마 부재로 스킵');
return;
}
$updated = $this->assignRootDepth($context);
$visited = [];
$parentIds = DB::table(self::TABLE)->whereNull('parent_id')->pluck('id')->all();
foreach ($parentIds as $id) {
$visited[(int) $id] = true;
}
$level = 0;
while ($parentIds !== [] && $level < self::MAX_LEVELS) {
$level++;
$childIds = $this->childIdsOf($parentIds, $visited);
if ($childIds === []) {
break;
}
$updated += $this->assignDepth($childIds, $level, $context);
$parentIds = $childIds;
}
if ($level >= self::MAX_LEVELS) {
$context->logger->warning(sprintf(
'[board:1.0.3] depth 재계산이 세대 상한(%d)에 도달 — 순환 참조 잔존 가능. 수동 확인 필요',
self::MAX_LEVELS
));
}
$context->logger->info(sprintf(
'[board:1.0.3] depth 재계산 완료 — %d 건 갱신 (최대 세대 %d)',
$updated,
$level
));
}
/**
* 최상위 댓글의 depth 를 0 으로 맞춥니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
* @return int 갱신된 행 수
*/
private function assignRootDepth(UpgradeContext $context): int
{
$stale = DB::table(self::TABLE)
->whereNull('parent_id')
->where('depth', '<>', 0)
->pluck('id')
->all();
if ($stale === []) {
return 0;
}
$this->logSnapshot($context, 0, $stale);
return DB::table(self::TABLE)
->whereNull('parent_id')
->where('depth', '<>', 0)
->update(['depth' => 0]);
}
/**
* 주어진 부모 ID 목록의 자식 ID 를 조회합니다 (이미 방문한 ID 는 제외).
*
* @param array<int, int> $parentIds 부모 댓글 ID 목록
* @param array<int, bool> $visited 방문 ID 집합 (참조 전달, 갱신됨)
* @return array<int, int> 자식 댓글 ID 목록
*/
private function childIdsOf(array $parentIds, array &$visited): array
{
$children = [];
foreach (array_chunk($parentIds, self::CHUNK) as $chunk) {
$ids = DB::table(self::TABLE)
->whereIn('parent_id', $chunk)
->pluck('id')
->all();
foreach ($ids as $id) {
$id = (int) $id;
if (isset($visited[$id])) {
continue;
}
$visited[$id] = true;
$children[] = $id;
}
}
return $children;
}
/**
* 주어진 댓글들의 depth 를 지정 값으로 갱신합니다.
*
* @param array<int, int> $ids 댓글 ID 목록
* @param int $depth 설정할 depth
* @param UpgradeContext $context 업그레이드 컨텍스트
* @return int 갱신된 행 수
*/
private function assignDepth(array $ids, int $depth, UpgradeContext $context): int
{
$updated = 0;
foreach (array_chunk($ids, self::CHUNK) as $chunk) {
$stale = DB::table(self::TABLE)
->whereIn('id', $chunk)
->where('depth', '<>', $depth)
->pluck('id')
->all();
if ($stale === []) {
continue;
}
$this->logSnapshot($context, $depth, $stale);
$updated += DB::table(self::TABLE)
->whereIn('id', $stale)
->update(['depth' => $depth]);
}
return $updated;
}
/**
* 갱신 대상의 변경 전 상태를 로그로 남깁니다 (복구 근거).
*
* @param UpgradeContext $context 업그레이드 컨텍스트
* @param int $newDepth 새로 부여할 depth
* @param array<int, int> $ids 갱신 대상 댓글 ID 목록
*/
private function logSnapshot(UpgradeContext $context, int $newDepth, array $ids): void
{
$before = DB::table(self::TABLE)
->whereIn('id', $ids)
->pluck('depth', 'id')
->map(fn ($depth) => (int) $depth)
->all();
$context->logger->info(sprintf(
'[board:1.0.3] 스냅샷 depth→%d %d 건 — 변경 전 %s',
$newDepth,
count($ids),
json_encode($before, JSON_UNESCAPED_UNICODE)
));
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Upgrades\Data\Ext\Modules\SirsoftBoard\V1_0_3\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 게시판 설정 max_comment_depth 를 초과한 기존 댓글을 리포트합니다.
*
* 자동 이동/삭제는 하지 않습니다. 관리자가 나중에 max_comment_depth 를 낮춘 결과일 수도
* 있어 데이터가 오염이라 단정할 수 없고, 자동 재부모화는 대화 맥락을 파괴하기 때문입니다.
* 규모만 로그로 남겨 운영자가 판단할 근거를 제공합니다.
*
* idempotent: 읽기 전용. V-1 안전: Facades\DB/Schema 만 사용.
*/
class ReportCommentsExceedingMaxDepth implements DataMigration
{
private const COMMENTS_TABLE = 'board_comments';
private const BOARDS_TABLE = 'boards';
/**
* 마이그레이션 식별자를 반환합니다.
*
* @return string 마이그레이션 이름
*/
public function name(): string
{
return 'ReportCommentsExceedingMaxDepth';
}
/**
* 설정 초과 depth 댓글 규모를 게시판별로 로그에 남깁니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
*/
public function run(UpgradeContext $context): void
{
if (! Schema::hasTable(self::COMMENTS_TABLE)
|| ! Schema::hasTable(self::BOARDS_TABLE)
|| ! Schema::hasColumn(self::BOARDS_TABLE, 'max_comment_depth')) {
$context->logger->info('[board:1.0.3] 설정 초과 depth 리포트 — 대상 스키마 부재로 스킵');
return;
}
// 별칭 조인 대신 게시판 단위 순회 — DB 프리픽스가 별칭에도 적용되어
// raw 표현식의 별칭 참조가 깨지는 것을 회피한다 (게시판 수는 소규모).
$boards = DB::table(self::BOARDS_TABLE)
->select('id', 'slug', 'max_comment_depth')
->orderBy('id')
->get();
$reported = 0;
foreach ($boards as $board) {
$max = (int) $board->max_comment_depth;
$query = DB::table(self::COMMENTS_TABLE)
->where('board_id', $board->id)
->where('depth', '>', $max);
$overCount = (clone $query)->count();
if ($overCount === 0) {
continue;
}
$reported++;
$context->logger->warning(sprintf(
'[board:1.0.3] 게시판 %s(id=%d) 설정 max_comment_depth=%d 초과 댓글 %d 건 (최대 depth=%d) — 자동 이동하지 않음, 운영자 확인 필요',
$board->slug,
$board->id,
$max,
$overCount,
(int) (clone $query)->max('depth')
));
}
if ($reported === 0) {
$context->logger->info('[board:1.0.3] 게시판 설정을 초과한 depth 댓글 없음');
}
}
}
@@ -29,6 +29,11 @@
- 검색 프리셋의 대상 화면 이름(상품 목록·주문 목록·고객 목록)이 표시되지 않던 문제를 수정했습니다.
- 상품 문의 답변 상태와 배송정책이 없는 배송 그룹의 표시명이 비어 보이던 문제를 수정했습니다.
- 상품 옵션 설정, 주문·배송정책·쿠폰·브랜드·카테고리 관리 화면의 아이콘이 의도한 크기보다 크거나 작게 보이던 문제와, 다크 모드에서 일부 버튼의 색 대비가 낮던 문제를 수정했습니다.
- 주문 상세에서 상품 옵션 상태를 일괄 변경할 때 다른 주문의 옵션이 함께 변경될 수 있던 문제를 수정했습니다. 이제 해당 주문에 속하지 않은 옵션이 포함되면 변경이 거절됩니다.
- 상품의 대표 이미지를 지정할 때 다른 상품의 이미지가 대표로 바뀔 수 있던 문제를 수정했습니다.
- 카테고리를 자기 자신이나 자기 하위 카테고리 아래로 옮길 때 화면이 응답하지 않고 해당 카테고리가 목록에서 사라지던 문제를 수정했습니다. 카테고리 수정과 순서 변경 양쪽 모두에 적용됩니다.
- 카테고리를 최상위로 옮길 때 상위 연결은 풀렸지만 계층 정보(경로·단계)가 이전 값 그대로 남아, 하위 카테고리의 위치가 어긋나 보이던 문제를 수정했습니다.
- 기존에 잘못 연결된 카테고리가 있으면 업데이트 시 자동으로 정리하고 계층 정보를 다시 계산합니다. 카테고리는 삭제하지 않고 최상위로 옮깁니다.
## [1.0.4] - 2026-07-16
@@ -413,7 +413,7 @@ Content-Type: application/json
<!-- @generated:end -->
**설명** 카테고리 트리 전체의 배치(부모-자식 관계와 정렬 순서)를 일괄 갱신합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, SortableMenuList 컴포넌트가 보내는 `parent_menus`/`child_menus` 형식을 컨트롤러가 `{id, parent_id, sort_order}` 목록으로 변환해 `CategoryService::reorder()`(트랜잭션)에 전달합니다. `parent_id`가 바뀐 항목은 depth와 materialized path가 함께 재계산됩니다. 관리자 카테고리 관리 화면에서 드래그 앤 드롭으로 계층 구조를 재정렬할 때 사용합니다.
**설명** 카테고리 트리 전체의 배치(부모-자식 관계와 정렬 순서)를 일괄 갱신합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, SortableMenuList 컴포넌트가 보내는 `parent_menus`/`child_menus` 형식을 컨트롤러가 `{id, parent_id, sort_order}` 목록으로 변환해 `CategoryService::reorder()`(트랜잭션)에 전달합니다. `parent_id`가 바뀐 항목은 depth와 materialized path가 함께 재계산됩니다. 관리자 카테고리 관리 화면에서 드래그 앤 드롭으로 계층 구조를 재정렬할 때 사용합니다. `child_menus`의 각 항목은 지정한 부모의 조상이 될 수 없습니다 — 순환이 만들어지는 이동은 422(`child_menus.{parentId}.{index}.id`)로 거절됩니다.
### GET /api/modules/sirsoft-ecommerce/admin/categories/tree
@@ -722,7 +722,7 @@ Content-Type: application/json
<!-- @generated:end -->
**설명** 기존 카테고리(path의 `category`)를 수정합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, `CategoryService::updateCategory()`가 검증된 데이터로 갱신한 뒤 `CategoryResource`를 반환합니다. `name`과 `slug`는 필수이고, `parent_id`를 변경하면 계층 위치(path/depth)가 재계산됩니다. `temp_key`로 임시 업로드한 이미지를 이 시점에 연결할 수 있으며, 대상이 없거나 처리 중 예외가 발생하면 404 또는 400을 반환합니다. `sirsoft-ecommerce.category.update_validation_rules` 필터로 확장이 검증 규칙을 추가할 수 있습니다.
**설명** 기존 카테고리(path의 `category`)를 수정합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, `CategoryService::updateCategory()`가 검증된 데이터로 갱신한 뒤 `CategoryResource`를 반환합니다. `name`과 `slug`는 필수이고, `parent_id`를 변경하면 계층 위치(path/depth)가 재계산됩니다. `temp_key`로 임시 업로드한 이미지를 이 시점에 연결할 수 있으며, 대상이 없거나 처리 중 예외가 발생하면 404 또는 400을 반환합니다. `sirsoft-ecommerce.category.update_validation_rules` 필터로 확장이 검증 규칙을 추가할 수 있습니다. `parent_id`에는 자기 자신이나 자신의 하위 카테고리를 지정할 수 없습니다 — 순환이 만들어지는 요청은 422(`parent_id`)로 거절됩니다.
### GET /api/modules/sirsoft-ecommerce/admin/categories/{id}
@@ -1039,7 +1039,7 @@ Content-Type: application/json
<!-- @generated:end -->
**설명** 관리자가 특정 주문(`order`)의 여러 주문 옵션 상태를 수량 분할까지 지원해 일괄 변경합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\OrderController@bulkChangeOptionStatus`가 `status`를 `OrderStatusEnum`으로 변환한 뒤 `OrderOptionService::bulkChangeStatusWithQuantity()`로 처리합니다. 배송중으로 전환 시 `carrier_id`·`tracking_number`(택배사·송장번호)를 함께 넘길 수 있습니다. 한 옵션의 일부 수량만 상태 전환(부분 배송 등)하는 시나리오를 지원합니다.
**설명** 관리자가 특정 주문(`order`)의 여러 주문 옵션 상태를 수량 분할까지 지원해 일괄 변경합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\OrderController@bulkChangeOptionStatus`가 `status`를 `OrderStatusEnum`으로 변환한 뒤 `OrderOptionService::bulkChangeStatusWithQuantity()`로 처리합니다. 배송중으로 전환 시 `carrier_id`·`tracking_number`(택배사·송장번호)를 함께 넘길 수 있습니다. 한 옵션의 일부 수량만 상태 전환(부분 배송 등)하는 시나리오를 지원합니다. `items[].option_id`는 경로의 `{order}`에 속한 옵션이어야 합니다. 다른 주문의 옵션 ID를 포함하면 422(`items.N.option_id`)로 거절되며, 요청 전체가 반영되지 않습니다. 검증을 거치지 않는 내부·훅 호출 경로에서 같은 불일치가 감지되면 400으로 응답합니다.
### POST /api/modules/sirsoft-ecommerce/admin/orders/{order}/reset-guest-lookup-password
@@ -1708,7 +1708,7 @@ Authorization: Bearer {YOUR_TOKEN}
<!-- @generated:end -->
**설명** 상품의 대표(썸네일) 이미지를 지정합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, `ProductImageService::setThumbnail()`가 같은 상품의 기존 대표 이미지의 `is_thumbnail`을 해제하고 `imageId` 이미지에 대표 플래그를 부여합니다. 목록·상세에서 노출될 기본 이미지를 교체할 때 사용하며, 지정 대상 상품/이미지가 없으면 404를 반환합니다.
**설명** 상품의 대표(썸네일) 이미지를 지정합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, `ProductImageService::setThumbnail()`가 같은 상품의 기존 대표 이미지의 `is_thumbnail`을 해제하고 `imageId` 이미지에 대표 플래그를 부여합니다. 목록·상세에서 노출될 기본 이미지를 교체할 때 사용하며, 지정 대상 상품/이미지가 없으면 404를 반환합니다. `{imageId}`는 경로의 `{productId}`에 속한 이미지여야 합니다. 다른 상품의 이미지 ID를 지정하면 400을 반환하며, 두 상품의 대표 이미지 상태는 모두 그대로 유지됩니다.
### DELETE /api/modules/sirsoft-ecommerce/admin/products/{product}
@@ -0,0 +1,25 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Exceptions;
use RuntimeException;
/**
* 카테고리 조작 실패 예외
*
* 카테고리를 찾을 수 없거나, 하위 카테고리·연결 상품이 있어 삭제할 수 없는 경우에 사용합니다.
* 컨트롤러의 기존 catch (\Exception) 흐름에서 400 응답으로 처리됩니다.
*/
class CategoryOperationException extends RuntimeException
{
/**
* CategoryOperationException 생성자
*
* @param string $messageKey 다국어 메시지 키 (sirsoft-ecommerce::exceptions.*)
* @param array<string, mixed> $replace 메시지 치환 파라미터
*/
public function __construct(string $messageKey, array $replace = [])
{
parent::__construct(__($messageKey, $replace));
}
}
@@ -0,0 +1,25 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Exceptions;
use RuntimeException;
/**
* 하위 리소스가 요청 경로의 상위 리소스에 속하지 않을 때 발생하는 예외
*
* 주문 옵션이 다른 주문에 속하거나, 상품 이미지가 다른 상품에 속하는 등
* 중첩 리소스의 소유권이 어긋난 경우에 사용합니다.
* 컨트롤러의 기존 catch (\Exception) 흐름에서 400 응답으로 처리됩니다.
*/
class ResourceScopeMismatchException extends RuntimeException
{
/**
* ResourceScopeMismatchException 생성자
*
* @param string $messageKey 다국어 메시지 키 (sirsoft-ecommerce::exceptions.*)
*/
public function __construct(string $messageKey)
{
parent::__construct(__($messageKey));
}
}
@@ -10,8 +10,10 @@ use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
use Modules\Sirsoft\Ecommerce\Exceptions\ResourceScopeMismatchException;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\BulkChangeOrderOptionStatusRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\BulkUpdateOrdersRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\CancelOrderRequest;
@@ -221,6 +223,7 @@ class OrderController extends AdminBaseController
$result = $this->orderOptionService->bulkChangeStatusWithQuantity(
$validated['items'],
$newStatus,
$order->id,
$metadata
);
@@ -235,6 +238,15 @@ class OrderController extends AdminBaseController
'messages.orders.option_status_change_failed',
422
);
} catch (ResourceScopeMismatchException $e) {
// 상위 주문 스코프 불일치는 잘못된 요청이다. FormRequest 가 1차로 422 를 내지만,
// 훅·내부 호출로 Service 2차 방어가 트립한 경우에도 500 이 아닌 400 으로 응답해
// 같은 모듈의 ProductController::setThumbnail 과 계약을 일치시킨다.
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.orders.option_status_change_failed',
400
);
} catch (Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
@@ -32,9 +32,16 @@ class BulkChangeOrderOptionStatusRequest extends FormRequest
*/
public function rules(): array
{
// 경로의 주문에 속한 옵션만 허용 (교차 주문 옵션 변경 차단)
$orderId = $this->route('order')?->id;
return [
'items' => ['required', 'array', 'min:1'],
'items.*.option_id' => ['required', 'integer', Rule::exists(OrderOption::class, 'id')],
'items.*.option_id' => [
'required',
'integer',
Rule::exists(OrderOption::class, 'id')->where('order_id', $orderId),
],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'status' => ['required', 'string', Rule::in(OrderStatusEnum::values())],
'carrier_id' => ['nullable', 'integer', Rule::exists(ShippingCarrier::class, 'id')],
@@ -8,6 +8,7 @@ use App\Rules\TranslatableField;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Sirsoft\Ecommerce\Models\Category;
use Modules\Sirsoft\Ecommerce\Rules\NotCircularCategoryParent;
/**
* 카테고리 생성 요청
@@ -18,6 +19,8 @@ class CreateCategoryRequest extends FormRequest
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 권한 검증 결과 (항상 true)
*/
public function authorize(): bool
{
@@ -43,14 +46,16 @@ class CreateCategoryRequest extends FormRequest
/**
* 요청에 적용할 검증 규칙
*
* @return array
* @return array<string, mixed> 필드별 검증 규칙
*/
public function rules(): array
{
$rules = [
'name' => ['required', 'array', new LocaleRequiredTranslatable(maxLength: 100)],
'description' => ['nullable', 'array', new TranslatableField()],
'parent_id' => ['nullable', Rule::exists(Category::class, 'id')],
'description' => ['nullable', 'array', new TranslatableField],
// 생성 시에는 자기 자신이 아직 없어 순환이 불가하나, 수정 요청과 규칙 구성을
// 동일하게 유지해 두 엔드포인트의 검증 강도가 갈라지는 것을 막는다.
'parent_id' => ['nullable', Rule::exists(Category::class, 'id'), new NotCircularCategoryParent],
'slug' => [
'required',
'string',
@@ -71,7 +76,7 @@ class CreateCategoryRequest extends FormRequest
/**
* 검증 오류 메시지 커스터마이징
*
* @return array
* @return array<string, string> 규칙별 다국어 에러 메시지
*/
public function messages(): array
{
@@ -5,7 +5,9 @@ namespace Modules\Sirsoft\Ecommerce\Http\Requests\Admin;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
use Modules\Sirsoft\Ecommerce\Models\Category;
use Modules\Sirsoft\Ecommerce\Rules\NotCircularCategoryParent;
/**
* 카테고리 순서 변경 요청
@@ -20,6 +22,8 @@ class ReorderCategoriesRequest extends FormRequest
{
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* @return bool 권한 검증 결과 (항상 true)
*/
public function authorize(): bool
{
@@ -29,7 +33,7 @@ class ReorderCategoriesRequest extends FormRequest
/**
* 요청에 적용할 검증 규칙
*
* @return array
* @return array<string, mixed> 필드별 검증 규칙
*/
public function rules(): array
{
@@ -46,10 +50,41 @@ class ReorderCategoriesRequest extends FormRequest
return HookManager::applyFilters('sirsoft-ecommerce.category.reorder_validation_rules', $rules, $this);
}
/**
* 추가 검증 — 자식으로 지정된 카테고리가 대상 부모의 조상이면 순환이 된다.
*
* 개별 이동 단위로 현재 DB 상태와 대조한다. 한 요청 안의 여러 이동이 누적되어 만들어지는
* 순환은 CategoryService 의 방문 ID 가드가 최종 방어한다.
*
* @param Validator $validator 검증기
*/
public function withValidator($validator): void
{
$validator->after(function ($validator) {
foreach ((array) $this->input('child_menus', []) as $parentId => $children) {
foreach ((array) $children as $index => $child) {
$childId = $child['id'] ?? null;
if ($childId === null) {
continue;
}
$attribute = "child_menus.{$parentId}.{$index}.id";
(new NotCircularCategoryParent((int) $childId))->validate(
$attribute,
(int) $parentId,
fn (string $message) => $validator->errors()->add($attribute, $message)
);
}
}
});
}
/**
* 검증 오류 메시지 커스터마이징
*
* @return array
* @return array<string, string> 규칙별 다국어 에러 메시지
*/
public function messages(): array
{
@@ -8,6 +8,7 @@ use App\Rules\TranslatableField;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Sirsoft\Ecommerce\Models\Category;
use Modules\Sirsoft\Ecommerce\Rules\NotCircularCategoryParent;
/**
* 카테고리 수정 요청
@@ -18,6 +19,8 @@ class UpdateCategoryRequest extends FormRequest
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 권한 검증 결과 (항상 true)
*/
public function authorize(): bool
{
@@ -43,7 +46,7 @@ class UpdateCategoryRequest extends FormRequest
/**
* 요청에 적용할 검증 규칙
*
* @return array
* @return array<string, mixed> 필드별 검증 규칙
*/
public function rules(): array
{
@@ -51,8 +54,12 @@ class UpdateCategoryRequest extends FormRequest
$rules = [
'name' => ['required', 'array', new LocaleRequiredTranslatable(maxLength: 100)],
'description' => ['nullable', 'array', new TranslatableField()],
'parent_id' => ['nullable', Rule::exists(Category::class, 'id')],
'description' => ['nullable', 'array', new TranslatableField],
'parent_id' => [
'nullable',
Rule::exists(Category::class, 'id'),
new NotCircularCategoryParent($categoryId !== null ? (int) $categoryId : null),
],
'slug' => [
'required',
'string',
@@ -73,7 +80,7 @@ class UpdateCategoryRequest extends FormRequest
/**
* 검증 오류 메시지 커스터마이징
*
* @return array
* @return array<string, string> 규칙별 다국어 에러 메시지
*/
public function messages(): array
{
@@ -85,4 +92,4 @@ class UpdateCategoryRequest extends FormRequest
'parent_id.exists' => __('sirsoft-ecommerce::validation.category.parent_not_found'),
];
}
}
}
@@ -0,0 +1,117 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Modules\Sirsoft\Ecommerce\Models\Category;
/**
* 카테고리 순환 참조를 방지하는 검증 규칙
*
* 카테고리를 이동할 때 자기 자신 또는 자신의 자손(descendant)을 부모로 지정하는 것을 막습니다.
* 순환이 만들어지면 path 재계산 재귀가 종료되지 않아 요청이 실패하고, 해당 노드가 트리에서
* 사라져 관리자 화면으로 복구할 수 없게 됩니다.
*
* 자손 판정은 머티리얼라이즈드 path 컬럼의 prefix 매칭(쿼리 1회)으로 수행하며,
* path 가 비어 있는 레거시 행에 대비해 parent_id 재귀 fallback 을 둡니다.
*/
class NotCircularCategoryParent implements ValidationRule
{
/**
* NotCircularCategoryParent 생성자
*
* @param int|null $categoryId 이동하려는 카테고리 ID (생성 시 null → 자기참조 불가하므로 통과)
*/
public function __construct(
private ?int $categoryId = null
) {}
/**
* 검증을 수행합니다.
*
* @param string $attribute 검증 대상 속성명
* @param mixed $value 검증 대상 값 (새 부모 ID)
* @param Closure $fail 실패 콜백
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// 최상위로 이동 → 순환 없음
if ($value === null || $value === '') {
return;
}
// 신규 생성 → 자기 자신이 아직 없으므로 순환 불가
if ($this->categoryId === null) {
return;
}
$newParentId = (int) $value;
if ($newParentId === $this->categoryId) {
$fail(__('sirsoft-ecommerce::validation.category.parent_id.self'));
return;
}
if ($this->isDescendant($newParentId)) {
$fail(__('sirsoft-ecommerce::validation.category.parent_id.circular'));
}
}
/**
* 지정한 카테고리가 이동 대상의 자손인지 판정합니다.
*
* @param int $candidateId 새 부모 후보 카테고리 ID
* @return bool 자손이면 true
*/
private function isDescendant(int $candidateId): bool
{
$self = Category::find($this->categoryId);
if (! $self) {
return false;
}
// path 가 채워져 있으면 prefix 매칭 1회로 판정
if (! empty($self->path)) {
return Category::where('id', $candidateId)
->where('path', 'like', $self->path.'/%')
->exists();
}
// path 가 비어 있는 레거시 행 — parent_id 를 거슬러 올라가며 판정
return $this->hasAncestor($candidateId, $this->categoryId);
}
/**
* 후보 카테고리의 조상 중에 대상 ID 가 있는지 확인합니다.
*
* @param int $candidateId 탐색 시작 카테고리 ID
* @param int $ancestorId 찾으려는 조상 ID
* @return bool 조상에 포함되면 true
*/
private function hasAncestor(int $candidateId, int $ancestorId): bool
{
$visited = [];
$currentId = $candidateId;
while ($currentId !== null && ! isset($visited[$currentId])) {
$visited[$currentId] = true;
$parentId = Category::where('id', $currentId)->value('parent_id');
if ($parentId === null) {
return false;
}
if ((int) $parentId === $ancestorId) {
return true;
}
$currentId = (int) $parentId;
}
return false;
}
}
@@ -5,6 +5,8 @@ namespace Modules\Sirsoft\Ecommerce\Services;
use App\Extension\HookManager;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Exceptions\CategoryOperationException;
use Modules\Sirsoft\Ecommerce\Models\Category;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryImageRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryRepositoryInterface;
@@ -22,8 +24,8 @@ class CategoryService
/**
* 계층형 카테고리 목록 조회
*
* @param array $filters 필터 조건
* @return Collection
* @param array $filters 필터 조건
* @return Collection 계층 구조가 적용된 카테고리 컬렉션
*/
public function getHierarchicalCategories(array $filters = []): Collection
{
@@ -34,9 +36,9 @@ class CategoryService
$filters = HookManager::applyFilters('sirsoft-ecommerce.category.filter_list_query', $filters);
// hierarchical 플래그가 true이면 전체 트리 구조 반환
if (!empty($filters['hierarchical'])) {
if (! empty($filters['hierarchical'])) {
$categories = Category::getTree(null, $filters['is_active'] ?? false);
} elseif (!empty($filters['flat'])) {
} elseif (! empty($filters['flat'])) {
// flat 플래그가 true이면 평면 리스트 반환 (TagInput 등에 사용)
$categories = $this->repository->getFlatList($filters, ['images']);
} else {
@@ -57,7 +59,7 @@ class CategoryService
*
* 프론트엔드 사용자 페이지에서 카테고리 필터에 사용합니다.
*
* @return Collection
* @return Collection 활성 카테고리로만 구성된 공개 트리
*/
public function getPublicCategoryTree(): Collection
{
@@ -75,8 +77,8 @@ class CategoryService
/**
* slug로 공개 카테고리 조회 (활성 카테고리만)
*
* @param string $slug 카테고리 slug
* @return Category|null
* @param string $slug 카테고리 slug
* @return Category|null 활성 카테고리 또는 null (비활성·미존재)
*/
public function getPublicCategoryBySlug(string $slug): ?Category
{
@@ -104,8 +106,8 @@ class CategoryService
/**
* 카테고리 상세 조회
*
* @param int $id 카테고리 ID
* @return Category|null
* @param int $id 카테고리 ID
* @return Category|null 카테고리 모델 또는 null (미존재)
*/
public function getCategory(int $id): ?Category
{
@@ -132,8 +134,8 @@ class CategoryService
/**
* 카테고리 생성
*
* @param array $data 카테고리 데이터
* @return Category
* @param array $data 카테고리 데이터
* @return Category 생성된 카테고리 모델
*/
public function createCategory(array $data): Category
{
@@ -150,7 +152,7 @@ class CategoryService
$data['path'] = $depthAndPath['path'];
// sort_order가 없으면 자동 계산
if (!isset($data['sort_order'])) {
if (! isset($data['sort_order'])) {
$data['sort_order'] = $this->repository->getNextSortOrder($data['parent_id'] ?? null);
}
@@ -162,7 +164,7 @@ class CategoryService
$this->updatePath($category);
// 이미지 처리 (temp_key 방식)
if (!empty($data['temp_key'])) {
if (! empty($data['temp_key'])) {
$this->imageRepository->linkTempImages($data['temp_key'], $category->id);
}
@@ -178,16 +180,16 @@ class CategoryService
/**
* 카테고리 수정
*
* @param int $id 카테고리 ID
* @param array $data 수정할 데이터
* @return Category
* @param int $id 카테고리 ID
* @param array $data 수정할 데이터
* @return Category 수정된 카테고리 모델
*/
public function updateCategory(int $id, array $data): Category
{
$category = $this->repository->findById($id);
if (!$category) {
throw new \Exception(__('sirsoft-ecommerce::exceptions.category_not_found', ['category_id' => $id]));
if (! $category) {
throw new CategoryOperationException('sirsoft-ecommerce::exceptions.category_not_found', ['category_id' => $id]);
}
// Before 훅
@@ -201,7 +203,8 @@ class CategoryService
$category = DB::transaction(function () use ($category, $data) {
// parent_id가 변경되었으면 depth/path 재계산
if (isset($data['parent_id']) && $data['parent_id'] !== $category->parent_id) {
// 루트로 이동(parent_id = null)도 변경이므로 array_key_exists 사용 (isset 은 null 을 미설정으로 취급)
if (array_key_exists('parent_id', $data) && $data['parent_id'] !== $category->parent_id) {
$depthAndPath = $this->calculateDepthAndPath($data['parent_id']);
$data['depth'] = $depthAndPath['depth'];
$data['path'] = $depthAndPath['path'];
@@ -211,15 +214,15 @@ class CategoryService
$categoryData = collect($data)->except(['temp_key'])->toArray();
$category = $this->repository->update($category->id, $categoryData);
// path 업데이트 (parent_id 변경 시)
if (isset($data['parent_id'])) {
// path 업데이트 (parent_id 변경 시 — 루트 이동 포함)
if (array_key_exists('parent_id', $data)) {
$this->updatePath($category);
// 하위 카테고리들의 path도 재계산
$this->updateDescendantsPaths($category);
}
// 이미지 처리
if (!empty($data['temp_key'])) {
if (! empty($data['temp_key'])) {
$this->imageRepository->linkTempImages($data['temp_key'], $category->id);
}
@@ -235,33 +238,34 @@ class CategoryService
/**
* 카테고리 삭제
*
* @param int $id 카테고리 ID
* @param int $id 카테고리 ID
* @return array 삭제 결과 정보
* @throws \Exception
*
* @throws CategoryOperationException 카테고리 미존재 또는 삭제 불가 조건
*/
public function deleteCategory(int $id): array
{
$category = $this->repository->findById($id);
if (!$category) {
throw new \Exception(__('sirsoft-ecommerce::exceptions.category_not_found', ['category_id' => $id]));
if (! $category) {
throw new CategoryOperationException('sirsoft-ecommerce::exceptions.category_not_found', ['category_id' => $id]);
}
// 하위 카테고리 확인
if ($this->repository->hasChildren($id)) {
$childrenCount = $category->children()->count();
throw new \Exception(__('sirsoft-ecommerce::exceptions.category_has_children', [
throw new CategoryOperationException('sirsoft-ecommerce::exceptions.category_has_children', [
'category_id' => $id,
'count' => $childrenCount,
]));
]);
}
// 연결된 상품 수 확인 - 상품이 있으면 삭제 차단
$productsCount = $this->repository->getProductCount($id);
if ($productsCount > 0) {
throw new \Exception(__('sirsoft-ecommerce::exceptions.category_has_products', [
throw new CategoryOperationException('sirsoft-ecommerce::exceptions.category_has_products', [
'count' => $productsCount,
]));
]);
}
// Before 훅
@@ -286,7 +290,7 @@ class CategoryService
/**
* depth와 Materialized Path를 계산합니다.
*
* @param int|null $parentId 부모 카테고리 ID
* @param int|null $parentId 부모 카테고리 ID
* @return array ['depth' => int, 'path' => string]
*/
private function calculateDepthAndPath(?int $parentId): array
@@ -297,7 +301,7 @@ class CategoryService
$parent = $this->repository->findById($parentId);
if (!$parent) {
if (! $parent) {
return ['depth' => 0, 'path' => ''];
}
@@ -309,9 +313,6 @@ class CategoryService
/**
* 카테고리의 path를 업데이트합니다 (ID 포함).
*
* @param Category $category
* @return void
*/
private function updatePath(Category $category): void
{
@@ -329,39 +330,52 @@ class CategoryService
/**
* 하위 카테고리들의 path를 재계산합니다 (재귀).
*
* @param Category $category
* @return void
* 검증(NotCircularCategoryParent)을 우회한 경로(시더/훅/기존 오염 데이터)에서
* 순환 참조가 남아 있어도 무한 재귀 대신 유한 실패로 끝나도록 방문 ID 를 추적한다.
*
* @param Category $category 기준 카테고리
* @param array<int, bool> $visited 방문한 카테고리 ID 집합 (재귀 내부 전달용)
*/
private function updateDescendantsPaths(Category $category): void
private function updateDescendantsPaths(Category $category, array $visited = []): void
{
$children = $category->children;
$visited[$category->id] = true;
foreach ($category->children as $child) {
if (isset($visited[$child->id])) {
Log::warning('[sirsoft-ecommerce] 카테고리 순환 참조 감지 — path 재계산 중단', [
'category_id' => $category->id,
'child_id' => $child->id,
]);
continue;
}
foreach ($children as $child) {
$this->updatePath($child);
$this->updateDescendantsPaths($child); // 재귀 호출
$this->updateDescendantsPaths($child, $visited);
}
}
/**
* 카테고리 상태 토글
*
* @param int $id 카테고리 ID
* @return Category
* @throws \Exception
* @param int $id 카테고리 ID
* @return Category 상태가 토글된 카테고리 모델
*
* @throws CategoryOperationException 카테고리 미존재 또는 삭제 불가 조건
*/
public function toggleStatus(int $id): Category
{
$category = $this->repository->findById($id);
if (!$category) {
throw new \Exception(__('sirsoft-ecommerce::exceptions.category_not_found', ['category_id' => $id]));
if (! $category) {
throw new CategoryOperationException('sirsoft-ecommerce::exceptions.category_not_found', ['category_id' => $id]);
}
// Before 훅
HookManager::doAction('sirsoft-ecommerce.category.before_toggle_status', $category);
$category = $this->repository->update($id, [
'is_active' => !$category->is_active,
'is_active' => ! $category->is_active,
]);
// After 훅
@@ -373,8 +387,7 @@ class CategoryService
/**
* 카테고리 순서 변경
*
* @param array $orders 순서 변경 데이터 [['id' => 1, 'parent_id' => null, 'sort_order' => 0], ...]
* @return void
* @param array $orders 순서 변경 데이터 [['id' => 1, 'parent_id' => null, 'sort_order' => 0], ...]
*/
public function reorder(array $orders): void
{
@@ -384,7 +397,7 @@ class CategoryService
DB::transaction(function () use ($orders) {
foreach ($orders as $order) {
$category = $this->repository->findById($order['id']);
if (!$category) {
if (! $category) {
continue;
}
@@ -8,6 +8,7 @@ use Modules\Sirsoft\Ecommerce\Enums\OrderOptionSourceTypeEnum;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Exceptions\OrderProcessingException;
use Modules\Sirsoft\Ecommerce\Exceptions\ResourceScopeMismatchException;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageTransactionRepositoryInterface;
@@ -295,18 +296,26 @@ class OrderOptionService
*
* @param array $items [{option_id, quantity}] 변경 대상
* @param OrderStatusEnum $newStatus 변경할 상태
* @param int $orderId 상위 주문 ID (옵션 소속 검증에 사용)
* @param array $metadata 추가 정보
* @return array 변경 결과
*
* @throws ResourceScopeMismatchException 옵션이 상위 주문에 속하지 않는 경우
*/
public function bulkChangeStatusWithQuantity(
array $items,
OrderStatusEnum $newStatus,
int $orderId,
array $metadata = []
): array {
// 스냅샷 캡처 (ChangeDetector용)
$optionIds = collect($items)->pluck('option_id')->filter()->unique()->toArray();
$snapshots = $this->orderOptionRepository->getSnapshotsByIds($optionIds);
// 상위 주문 스코프 2차 방어 — FormRequest 를 우회한 내부/훅 호출도 차단.
// 선택 파라미터로 두면 신규 호출처가 빠뜨렸을 때 방어가 조용히 꺼지므로 필수로 받는다.
$this->assertOptionsBelongToOrder($optionIds, $orderId);
// 결제완료(payment_complete) 목표 전이 시 본인인증(IDV) 정책 가드 (A8 / N4).
// 관리자 주문상세 "주문상태 변경"(옵션 일괄변경)이 결제완료로 부모 주문을 전이시키는,
// 실무에서 가장 흔한 결제승인 경로다. OrderService::update / bulkUpdate 와 동일하게
@@ -351,8 +360,8 @@ class OrderOptionService
// 부모 주문 상태 동기화
$orderIds = $this->orderOptionRepository->getOrderIdsByOptionIds($optionIds);
foreach ($orderIds as $orderId) {
$this->syncParentOrderStatus($orderId);
foreach ($orderIds as $parentOrderId) {
$this->syncParentOrderStatus($parentOrderId);
}
// after 훅 (스냅샷 전달)
@@ -365,6 +374,29 @@ class OrderOptionService
];
}
/**
* 주어진 옵션들이 모두 지정 주문에 속하는지 검증합니다.
*
* @param array<int, int> $optionIds 검증할 주문 옵션 ID 목록
* @param int $orderId 상위 주문 ID
*
* @throws ResourceScopeMismatchException 하나라도 다른 주문에 속하는 경우
*/
private function assertOptionsBelongToOrder(array $optionIds, int $orderId): void
{
if ($optionIds === []) {
return;
}
$orderIds = $this->orderOptionRepository->getOrderIdsByOptionIds($optionIds);
foreach ($orderIds as $foundOrderId) {
if ((int) $foundOrderId !== $orderId) {
throw new ResourceScopeMismatchException('sirsoft-ecommerce::exceptions.order_option_not_in_order');
}
}
}
/**
* 옵션 금액 필드를 캡처합니다.
*
@@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Modules\Sirsoft\Ecommerce\Enums\ProductImageCollection;
use Modules\Sirsoft\Ecommerce\Exceptions\ProductImageUploadLimitException;
use Modules\Sirsoft\Ecommerce\Exceptions\ResourceScopeMismatchException;
use Modules\Sirsoft\Ecommerce\Models\ProductImage;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductImageRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
@@ -306,11 +307,19 @@ class ProductImageService
* @param int $productId 상품 ID
* @param int $imageId 이미지 ID
* @return bool 성공 여부
*
* @throws ResourceScopeMismatchException 이미지가 해당 상품에 속하지 않는 경우
*/
public function setThumbnail(int $productId, int $imageId): bool
{
// 기존 대표 이미지 해제
$images = $this->repository->getByProductId($productId);
// 대상 이미지가 이 상품에 속하는지 먼저 확인 (교차 상품 오염 차단)
if ($images->firstWhere('id', $imageId) === null) {
throw new ResourceScopeMismatchException('sirsoft-ecommerce::exceptions.product_image_not_in_product');
}
// 기존 대표 이미지 해제
foreach ($images as $image) {
if ($image->is_thumbnail) {
$this->repository->update($image->id, ['is_thumbnail' => false]);
@@ -60,6 +60,8 @@ return [
'order_already_cancelled' => 'This order has already been cancelled.',
'order_already_paid' => 'This order has already been paid.',
'order_option_not_found' => 'Order option not found.',
'order_option_not_in_order' => 'This order option does not belong to the specified order.',
'product_image_not_in_product' => 'This image does not belong to the specified product.',
'order_option_already_cancelled' => 'This order option has already been cancelled.',
'order_option_already_confirmed' => 'This order option has already been confirmed.',
'order_option_cannot_confirm' => 'Cannot confirm purchase in current status.',
@@ -454,6 +454,10 @@ return [
'slug_unique' => 'This slug is already in use.',
'slug_format' => 'The slug must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens (-).',
'parent_not_found' => 'Parent category not found.',
'parent_id' => [
'self' => 'A category cannot be its own parent.',
'circular' => 'A descendant category cannot be set as the parent.',
],
],
// Product image validation messages
@@ -60,6 +60,8 @@ return [
'order_already_cancelled' => '이미 취소된 주문입니다.',
'order_already_paid' => '이미 결제가 완료된 주문입니다.',
'order_option_not_found' => '주문 옵션을 찾을 수 없습니다.',
'order_option_not_in_order' => '해당 주문에 속하지 않는 주문 옵션입니다.',
'product_image_not_in_product' => '해당 상품에 속하지 않는 이미지입니다.',
'order_option_already_cancelled' => '이미 취소된 주문 옵션입니다.',
'order_option_already_confirmed' => '이미 구매확정된 주문 옵션입니다.',
'order_option_cannot_confirm' => '현재 상태에서는 구매확정할 수 없습니다.',
@@ -454,6 +454,10 @@ return [
'slug_unique' => '이미 사용중인 슬러그입니다.',
'slug_format' => '슬러그는 영문 소문자로 시작해야 하며, 영문 소문자/숫자/하이픈(-)만 사용 가능합니다.',
'parent_not_found' => '상위 카테고리를 찾을 수 없습니다.',
'parent_id' => [
'self' => '자기 자신을 상위 카테고리로 지정할 수 없습니다.',
'circular' => '하위 카테고리를 상위 카테고리로 지정할 수 없습니다.',
],
],
// 상품 이미지 검증 메시지
@@ -0,0 +1,250 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Admin;
use App\Models\User;
use Modules\Sirsoft\Ecommerce\Models\Category;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 카테고리 순환 참조 방지 테스트
*
* 검증 목적:
* - 자기 자신을 부모로 지정할 수 없다 (422)
* - 직계 자식 / 손자를 부모로 지정할 수 없다 (422)
* - 무관한 카테고리로의 정상 이동은 허용되고 path 가 갱신된다 (200)
* - 최상위로 이동(parent_id = null)은 허용된다 (200)
*/
class CategoryCycleTest extends ModuleTestCase
{
private User $adminUser;
protected function setUp(): void
{
parent::setUp();
$this->adminUser = $this->createAdminUser([
'sirsoft-ecommerce.categories.update',
'sirsoft-ecommerce.categories.create',
]);
}
/**
* 자기 자신을 부모로 지정하면 422
*
* @scenario entity=ec_category, parent_target=self
*
* @effects self_parent_rejected_422, parent_unchanged_on_rejection
*/
public function test_cannot_set_self_as_parent(): void
{
$category = $this->makeCategory('self-parent');
$this->actingAs($this->adminUser)
->putJson($this->url($category), $this->payload($category, $category->id))
->assertStatus(422)
->assertJsonValidationErrors(['parent_id']);
$this->assertNull($category->fresh()->parent_id, 'parent_id 가 변경되면 안 됩니다.');
}
/**
* 직계 자식을 부모로 지정하면 422
*
* @scenario entity=ec_category, parent_target=descendant
*
* @effects descendant_parent_rejected_422, parent_unchanged_on_rejection
*/
public function test_cannot_set_direct_child_as_parent(): void
{
$root = $this->makeCategory('cycle-root');
$child = $this->makeCategory('cycle-child', $root);
$this->actingAs($this->adminUser)
->putJson($this->url($root), $this->payload($root, $child->id))
->assertStatus(422)
->assertJsonValidationErrors(['parent_id']);
$this->assertNull($root->fresh()->parent_id, 'root 의 parent_id 가 변경되면 안 됩니다.');
}
/**
* 손자(3노드 사이클)를 부모로 지정하면 422
*
* @scenario entity=ec_category, parent_target=descendant
*
* @effects descendant_parent_rejected_422, parent_unchanged_on_rejection
*/
public function test_cannot_set_grandchild_as_parent(): void
{
$root = $this->makeCategory('deep-root');
$child = $this->makeCategory('deep-child', $root);
$grandChild = $this->makeCategory('deep-grandchild', $child);
$this->actingAs($this->adminUser)
->putJson($this->url($root), $this->payload($root, $grandChild->id))
->assertStatus(422)
->assertJsonValidationErrors(['parent_id']);
$this->assertNull($root->fresh()->parent_id);
$this->assertSame($child->id, $grandChild->fresh()->parent_id);
}
/**
* 무관한 카테고리로의 이동은 허용되고 path 가 갱신된다
*
* @scenario entity=ec_category, parent_target=unrelated
*
* @effects unrelated_parent_move_succeeds, descendant_paths_rebuilt_on_valid_move
*/
public function test_can_move_under_unrelated_category(): void
{
$target = $this->makeCategory('move-target');
$moving = $this->makeCategory('move-source');
$movingChild = $this->makeCategory('move-source-child', $moving);
$this->actingAs($this->adminUser)
->putJson($this->url($moving), $this->payload($moving, $target->id))
->assertStatus(200);
$moved = $moving->fresh();
$this->assertSame($target->id, $moved->parent_id);
$this->assertSame("{$target->id}/{$moved->id}", $moved->path, '이동된 카테고리의 path 가 갱신되어야 합니다.');
$this->assertSame(
"{$target->id}/{$moved->id}/{$movingChild->id}",
$movingChild->fresh()->path,
'자손의 path 도 함께 갱신되어야 합니다.'
);
}
/**
* 최상위로 이동(parent_id = null)은 허용된다
*
* @scenario entity=ec_category, parent_target=none
*
* @effects move_to_root_succeeds, path_and_depth_rebuilt_on_root_move, descendant_paths_rebuilt_on_root_move
*/
public function test_can_move_to_root(): void
{
$root = $this->makeCategory('to-root-parent');
$child = $this->makeCategory('to-root-child', $root);
$grandChild = $this->makeCategory('to-root-grandchild', $child);
$this->actingAs($this->adminUser)
->putJson($this->url($child), $this->payload($child, null))
->assertStatus(200);
$movedChild = $child->fresh();
$this->assertNull($movedChild->parent_id);
// 루트 이동 시 path/depth 가 재계산되어야 한다 (isset 이 null 을 미설정으로 취급하던 회귀 방지)
$this->assertSame((string) $child->id, $movedChild->path, '루트로 이동한 카테고리의 path 는 자기 ID 여야 합니다.');
$this->assertSame(0, $movedChild->depth, '루트로 이동한 카테고리의 depth 는 0 이어야 합니다.');
// 자손 path 도 새 루트 기준으로 재계산되어야 한다
$this->assertSame("{$child->id}/{$grandChild->id}", $grandChild->fresh()->path, '루트 이동 시 자손 path 도 갱신되어야 합니다.');
}
/**
* 순서 변경 엔드포인트로도 조상을 자식으로 만들 수 없다 (동일 리소스 검증 강도 일치)
*
* @scenario entity=ec_category, parent_target=descendant
*
* @effects descendant_parent_rejected_422, parent_unchanged_on_rejection
*/
public function test_reorder_cannot_create_cycle(): void
{
$root = $this->makeCategory('reorder-root');
$child = $this->makeCategory('reorder-child', $root);
// root 를 child 의 자식으로 밀어 넣으려는 시도
$this->actingAs($this->adminUser)
->putJson('/api/modules/sirsoft-ecommerce/admin/categories/order', [
'child_menus' => [
(string) $child->id => [
['id' => $root->id, 'order' => 1],
],
],
])
->assertStatus(422)
->assertJsonValidationErrors(["child_menus.{$child->id}.0.id"]);
$this->assertNull($root->fresh()->parent_id, 'root 의 parent_id 가 변경되면 안 됩니다.');
}
/**
* 순서 변경으로 무관한 카테고리를 자식으로 지정하는 것은 허용된다 (회귀 방지)
*
* @scenario entity=ec_category, parent_target=unrelated
*
* @effects unrelated_parent_move_succeeds
*/
public function test_reorder_allows_valid_move(): void
{
$target = $this->makeCategory('reorder-target');
$moving = $this->makeCategory('reorder-moving');
$this->actingAs($this->adminUser)
->putJson('/api/modules/sirsoft-ecommerce/admin/categories/order', [
'child_menus' => [
(string) $target->id => [
['id' => $moving->id, 'order' => 1],
],
],
])
->assertStatus(200);
$this->assertSame($target->id, $moving->fresh()->parent_id);
}
/**
* 테스트용 카테고리를 생성합니다.
*
* @param string $slug 카테고리 슬러그
* @param Category|null $parent 부모 카테고리
* @return Category 생성된 카테고리
*/
private function makeCategory(string $slug, ?Category $parent = null): Category
{
$category = Category::create([
'name' => ['ko' => $slug, 'en' => $slug],
'slug' => $slug,
'parent_id' => $parent?->id,
'path' => '',
'depth' => $parent ? $parent->depth + 1 : 0,
'is_active' => true,
'sort_order' => 0,
]);
$category->update([
'path' => $parent ? $parent->path.'/'.$category->id : (string) $category->id,
]);
return $category->fresh();
}
/**
* 카테고리 수정 요청 본문을 생성합니다.
*
* @param Category $category 대상 카테고리
* @param int|null $parentId 지정할 부모 ID
* @return array<string, mixed> 요청 본문
*/
private function payload(Category $category, ?int $parentId): array
{
return [
'name' => ['ko' => $category->slug, 'en' => $category->slug],
'slug' => $category->slug,
'parent_id' => $parentId,
];
}
/**
* 카테고리 수정 엔드포인트 URL 을 생성합니다.
*
* @param Category $category 대상 카테고리
* @return string 엔드포인트 URL
*/
private function url(Category $category): string
{
return "/api/modules/sirsoft-ecommerce/admin/categories/{$category->id}";
}
}
@@ -0,0 +1,160 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Admin;
use App\Models\User;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderOptionFactory;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Exceptions\ResourceScopeMismatchException;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Services\OrderOptionService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 주문 옵션 일괄 상태 변경의 상위 주문 스코프 검증 테스트
*
* PATCH /api/modules/sirsoft-ecommerce/admin/orders/{order}/options/bulk-status
*
* 검증 목적:
* - 주문 A 의 경로로 주문 B 의 옵션을 일괄 변경할 수 없다 (422)
* - FormRequest 를 우회한 Service 직접 호출도 2차 방어로 차단된다
* - 대상 주문의 옵션은 기존과 동일하게 변경된다
*/
class OrderOptionScopeTest extends ModuleTestCase
{
private User $adminUser;
protected function setUp(): void
{
parent::setUp();
$this->adminUser = $this->createAdminUser(['sirsoft-ecommerce.orders.update']);
}
/**
* 다른 주문의 옵션 ID 를 넣으면 422 이고 그 옵션은 변경되지 않는다
*
* @scenario resource=order_option, parent_scope=mismatched, actor=admin
*
* @effects cross_scope_array_item_returns_422, target_resource_unchanged_on_rejection
*/
public function test_cannot_change_option_of_other_order(): void
{
$orderA = $this->makeOrder();
$orderB = $this->makeOrder();
$optionOfB = OrderOptionFactory::new()->forOrder($orderB)->create([
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'quantity' => 2,
]);
$this->actingAs($this->adminUser)
->patchJson($this->url($orderA), [
'items' => [
['option_id' => $optionOfB->id, 'quantity' => 1],
],
'status' => OrderStatusEnum::PREPARING->value,
])
->assertStatus(422)
->assertJsonValidationErrors(['items.0.option_id']);
$this->assertSame(
OrderStatusEnum::PAYMENT_COMPLETE->value,
$optionOfB->fresh()->option_status->value,
'다른 주문의 옵션 상태가 변경되면 안 됩니다.'
);
}
/**
* FormRequest 를 우회한 Service 직접 호출도 2차 방어로 차단된다
*
* 컨트롤러 경로는 FormRequest 가 422 로 선차단하므로, 훅·내부 호출처럼 검증을 거치지
* 않는 경로에서도 스코프가 지켜지는지 Service 를 직접 호출해 확인한다.
*
* @scenario resource=order_option, parent_scope=mismatched, actor=admin
*
* @effects cross_scope_write_rejected, target_resource_unchanged_on_rejection
*/
public function test_service_rejects_option_of_other_order_when_called_directly(): void
{
$orderA = $this->makeOrder();
$orderB = $this->makeOrder();
$optionOfB = OrderOptionFactory::new()->forOrder($orderB)->create([
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'quantity' => 2,
]);
$this->expectException(ResourceScopeMismatchException::class);
try {
app(OrderOptionService::class)->bulkChangeStatusWithQuantity(
[['option_id' => $optionOfB->id, 'quantity' => 1]],
OrderStatusEnum::PREPARING,
$orderA->id,
);
} finally {
$this->assertSame(
OrderStatusEnum::PAYMENT_COMPLETE->value,
$optionOfB->fresh()->option_status->value,
'Service 직접 호출에서도 다른 주문의 옵션이 변경되면 안 됩니다.'
);
}
}
/**
* 자기 주문의 옵션은 정상 변경된다 (회귀 방지)
*
* @scenario resource=order_option, parent_scope=matching, actor=admin
*
* @effects matching_scope_still_succeeds
*/
public function test_can_change_option_of_own_order(): void
{
$order = $this->makeOrder();
$option = OrderOptionFactory::new()->forOrder($order)->create([
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'quantity' => 2,
]);
$this->actingAs($this->adminUser)
->patchJson($this->url($order), [
'items' => [
['option_id' => $option->id, 'quantity' => 2],
],
'status' => OrderStatusEnum::PREPARING->value,
])
->assertStatus(200);
$this->assertSame(
OrderStatusEnum::PREPARING->value,
$option->fresh()->option_status->value,
'자기 주문의 옵션은 변경되어야 합니다.'
);
}
/**
* 테스트용 주문을 생성합니다.
*
* @return Order 생성된 주문
*/
private function makeOrder(): Order
{
return OrderFactory::new()->create([
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
])->fresh();
}
/**
* 일괄 상태 변경 엔드포인트 URL 을 생성합니다.
*
* @param Order $order 대상 주문
* @return string 엔드포인트 URL
*/
private function url(Order $order): string
{
return "/api/modules/sirsoft-ecommerce/admin/orders/{$order->order_number}/options/bulk-status";
}
}
@@ -0,0 +1,130 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Admin;
use App\Models\User;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductFactory;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductImage;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 대표 이미지 설정의 상위 상품 스코프 검증 테스트
*
* PATCH /api/modules/sirsoft-ecommerce/admin/products/{productId}/images/{imageId}/thumbnail
*
* 검증 목적:
* - 상품 A 의 경로로 상품 B 의 이미지를 대표로 지정할 수 없다
* - 상품 B 의 대표 이미지 상태가 오염되지 않는다
*/
class ProductImageScopeTest extends ModuleTestCase
{
private User $adminUser;
protected function setUp(): void
{
parent::setUp();
$this->adminUser = $this->createAdminUser(['sirsoft-ecommerce.products.update']);
}
/**
* 다른 상품의 이미지를 대표로 지정할 수 없다
*
* @scenario resource=product_image, parent_scope=mismatched, actor=admin
*
* @effects cross_scope_write_rejected, target_resource_unchanged_on_rejection, sibling_resource_state_unchanged
*/
public function test_cannot_set_thumbnail_of_other_product(): void
{
$productA = $this->makeProduct();
$productB = $this->makeProduct();
$imageOfA = $this->makeImage($productA, isThumbnail: true);
$imageOfB = $this->makeImage($productB, isThumbnail: true);
$otherImageOfB = $this->makeImage($productB, isThumbnail: false);
$this->actingAs($this->adminUser)
->patchJson($this->url($productA->id, $otherImageOfB->id))
->assertStatus(400);
$this->assertTrue(
(bool) $imageOfB->fresh()->is_thumbnail,
'상품 B 의 기존 대표 이미지가 유지되어야 합니다.'
);
$this->assertFalse(
(bool) $otherImageOfB->fresh()->is_thumbnail,
'상품 B 의 다른 이미지가 대표로 바뀌면 안 됩니다.'
);
$this->assertTrue(
(bool) $imageOfA->fresh()->is_thumbnail,
'상품 A 의 대표 이미지도 해제되면 안 됩니다.'
);
}
/**
* 자기 상품의 이미지는 대표로 지정된다 (회귀 방지)
*
* @scenario resource=product_image, parent_scope=matching, actor=admin
*
* @effects matching_scope_still_succeeds
*/
public function test_can_set_thumbnail_of_own_product(): void
{
$product = $this->makeProduct();
$current = $this->makeImage($product, isThumbnail: true);
$target = $this->makeImage($product, isThumbnail: false);
$this->actingAs($this->adminUser)
->patchJson($this->url($product->id, $target->id))
->assertStatus(200);
$this->assertTrue((bool) $target->fresh()->is_thumbnail, '대상 이미지가 대표가 되어야 합니다.');
$this->assertFalse((bool) $current->fresh()->is_thumbnail, '기존 대표는 해제되어야 합니다.');
}
/**
* 테스트용 상품을 생성합니다.
*
* @return Product 생성된 상품
*/
private function makeProduct(): Product
{
return ProductFactory::new()->create();
}
/**
* 테스트용 상품 이미지를 생성합니다.
*
* @param Product $product 소속 상품
* @param bool $isThumbnail 대표 이미지 여부
* @return ProductImage 생성된 이미지
*/
private function makeImage(Product $product, bool $isThumbnail): ProductImage
{
return ProductImage::create([
'product_id' => $product->id,
'original_filename' => 'test.jpg',
'stored_filename' => 'test-'.uniqid().'.jpg',
'disk' => 'public',
'path' => 'ecommerce/products/test.jpg',
'mime_type' => 'image/jpeg',
'file_size' => 1024,
'is_thumbnail' => $isThumbnail,
'sort_order' => 0,
]);
}
/**
* 대표 이미지 설정 엔드포인트 URL 을 생성합니다.
*
* @param int $productId 상품 ID
* @param int $imageId 이미지 ID
* @return string 엔드포인트 URL
*/
private function url(int $productId, int $imageId): string
{
return "/api/modules/sirsoft-ecommerce/admin/products/{$productId}/images/{$imageId}/thumbnail";
}
}
@@ -0,0 +1,167 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Upgrade;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use Modules\Sirsoft\Ecommerce\Upgrades\Upgrade_1_0_5;
/**
* 1.0.5 카테고리 순환 데이터 정리 업그레이드 스텝 테스트
*
* 검증 목적:
* - 자기참조(parent_id = id) 노드가 절단된다
* - 다중 노드 사이클이 절단되어 트리에 복귀한다
* - 정상 트리의 depth/path 가 재구축된다
* - 재실행해도 결과가 동일하다 (멱등)
*/
class CategoryCycleCleanupTest extends ModuleTestCase
{
/**
* 자기참조 노드가 절단되고 최상위로 복귀한다
*
* @scenario entity=ec_category, parent_target=self
*
* @effects existing_cycle_broken_and_paths_rebuilt
*/
public function test_self_referencing_node_is_detached(): void
{
$id = $this->insertCategory('self-cycle', parentId: null, depth: 3, path: 'garbage');
DB::table('ecommerce_categories')->where('id', $id)->update(['parent_id' => $id]);
$this->runCleanup();
$row = DB::table('ecommerce_categories')->where('id', $id)->first();
$this->assertNull($row->parent_id, '자기참조는 절단되어야 합니다.');
$this->assertSame(0, (int) $row->depth);
$this->assertSame((string) $id, $row->path);
}
/**
* 3노드 사이클이 절단되고 계층이 복구된다
*
* @scenario entity=ec_category, parent_target=descendant
*
* @effects existing_cycle_broken_and_paths_rebuilt, path_recursion_terminates_on_polluted_data
*/
public function test_multi_node_cycle_is_broken(): void
{
$a = $this->insertCategory('cycle-a');
$b = $this->insertCategory('cycle-b', parentId: $a);
$c = $this->insertCategory('cycle-c', parentId: $b);
// a 의 부모를 c 로 만들어 a → b → c → a 사이클 구성
DB::table('ecommerce_categories')->where('id', $a)->update(['parent_id' => $c]);
$this->runCleanup();
$rows = DB::table('ecommerce_categories')
->whereIn('id', [$a, $b, $c])
->pluck('parent_id', 'id')
->all();
$rootCount = count(array_filter($rows, fn ($parentId) => $parentId === null));
$this->assertSame(1, $rootCount, '사이클은 정확히 한 지점에서 절단되어야 합니다.');
// 절단 후 모든 노드가 루트에서 도달 가능해야 한다 (path 가 루트 ID 로 시작)
foreach ([$a, $b, $c] as $id) {
$path = DB::table('ecommerce_categories')->where('id', $id)->value('path');
$this->assertNotSame('', (string) $path, "category {$id} 의 path 가 재구축되어야 합니다.");
}
}
/**
* 정상 트리의 depth/path 가 재구축된다
*
* @scenario entity=ec_category, parent_target=unrelated
*
* @effects descendant_paths_rebuilt_on_valid_move
*/
public function test_healthy_tree_paths_are_rebuilt(): void
{
$root = $this->insertCategory('healthy-root', depth: 9, path: 'wrong');
$child = $this->insertCategory('healthy-child', parentId: $root, depth: 9, path: 'wrong');
$this->runCleanup();
$this->assertSame(0, (int) DB::table('ecommerce_categories')->where('id', $root)->value('depth'));
$this->assertSame((string) $root, DB::table('ecommerce_categories')->where('id', $root)->value('path'));
$this->assertSame(1, (int) DB::table('ecommerce_categories')->where('id', $child)->value('depth'));
$this->assertSame("{$root}/{$child}", DB::table('ecommerce_categories')->where('id', $child)->value('path'));
}
/**
* 재실행해도 결과가 동일하다 (멱등)
*
* @scenario entity=ec_category, parent_target=unrelated
*
* @effects cleanup_is_idempotent
*/
public function test_cleanup_is_idempotent(): void
{
$a = $this->insertCategory('idem-a');
$b = $this->insertCategory('idem-b', parentId: $a);
DB::table('ecommerce_categories')->where('id', $a)->update(['parent_id' => $b]);
$this->runCleanup();
$first = $this->snapshot();
$this->runCleanup();
$second = $this->snapshot();
$this->assertSame($first, $second, '재실행 결과가 동일해야 합니다.');
}
/**
* 1.0.5 정리 마이그레이션을 실행합니다.
*/
private function runCleanup(): void
{
(new Upgrade_1_0_5)->run(new UpgradeContext('1.0.4', '1.0.5', '1.0.5', 'extension-upgrade'));
}
/**
* 테스트용 카테고리를 직접 삽입합니다.
*
* @param string $slug 슬러그
* @param int|null $parentId 부모 ID
* @param int $depth 초기 depth
* @param string $path 초기 path
* @return int 생성된 카테고리 ID
*/
private function insertCategory(string $slug, ?int $parentId = null, int $depth = 0, string $path = ''): int
{
return DB::table('ecommerce_categories')->insertGetId([
'name' => json_encode(['ko' => $slug, 'en' => $slug], JSON_UNESCAPED_UNICODE),
'slug' => $slug,
'parent_id' => $parentId,
'depth' => $depth,
'path' => $path,
'sort_order' => 0,
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
/**
* 전체 카테고리의 (id, parent_id, depth, path) 스냅샷을 반환합니다.
*
* @return array<int, array<string, mixed>> 스냅샷 배열
*/
private function snapshot(): array
{
return DB::table('ecommerce_categories')
->orderBy('id')
->get(['id', 'parent_id', 'depth', 'path'])
->map(fn ($row) => [
'id' => (int) $row->id,
'parent_id' => $row->parent_id === null ? null : (int) $row->parent_id,
'depth' => (int) $row->depth,
'path' => (string) $row->path,
])
->all();
}
}
@@ -74,7 +74,6 @@ class MileageEarnDeltaAndGateTest extends ModuleTestCase
*
* @param int $quantity 옵션 수량
* @param float $earnPoints 적립예정 포인트 소계
* @return Order
*/
private function makeDeliveredOrder(int $quantity, float $earnPoints): Order
{
@@ -98,7 +97,6 @@ class MileageEarnDeltaAndGateTest extends ModuleTestCase
* 해당 주문의 구매적립(purchase_earn) 총액을 반환합니다.
*
* @param int $orderId 주문 ID
* @return float
*/
private function purchaseEarnTotal(int $orderId): float
{
@@ -123,7 +121,8 @@ class MileageEarnDeltaAndGateTest extends ModuleTestCase
// 1차: 1개만 구매확정 → 분할 + 100P 적립
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => 1]],
OrderStatusEnum::CONFIRMED
OrderStatusEnum::CONFIRMED,
$order->id,
);
$this->assertEqualsWithDelta(100.0, $this->purchaseEarnTotal($order->id), 0.5, '1차 확정 후 100P 적립되어야 함');
@@ -135,7 +134,8 @@ class MileageEarnDeltaAndGateTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $remaining->id, 'quantity' => 1]],
OrderStatusEnum::CONFIRMED
OrderStatusEnum::CONFIRMED,
$order->id,
);
$this->assertEqualsWithDelta(200.0, $this->purchaseEarnTotal($order->id), 0.5, '나눠 확정 총합은 200P 여야 함');
@@ -157,7 +157,8 @@ class MileageEarnDeltaAndGateTest extends ModuleTestCase
$this->assertNotNull($target, "{$i}회차 확정 대상(배송완료)이 있어야 함");
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $target->id, 'quantity' => 1]],
OrderStatusEnum::CONFIRMED
OrderStatusEnum::CONFIRMED,
$order->id,
);
}
@@ -176,7 +177,8 @@ class MileageEarnDeltaAndGateTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => 1]],
OrderStatusEnum::CONFIRMED
OrderStatusEnum::CONFIRMED,
$order->id,
);
$this->assertEqualsWithDelta(150.0, $this->purchaseEarnTotal($order->id), 0.5);
@@ -200,7 +202,8 @@ class MileageEarnDeltaAndGateTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => 1]],
OrderStatusEnum::CONFIRMED
OrderStatusEnum::CONFIRMED,
$order->id,
);
$this->assertSame(0.0, $this->purchaseEarnTotal($order->id), '기능 OFF 면 즉시적립도 발생하지 않아야 함');
@@ -220,7 +223,8 @@ class MileageEarnDeltaAndGateTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => 1]],
OrderStatusEnum::CONFIRMED
OrderStatusEnum::CONFIRMED,
$order->id,
);
$this->assertEqualsWithDelta(120.0, $this->purchaseEarnTotal($order->id), 0.5, '기능 ON 이면 즉시적립 정상');
@@ -78,7 +78,6 @@ class OrderOptionServiceConfirmTest extends ModuleTestCase
*
* @param int $quantity 옵션 수량
* @param int|null $userId 주문자 (마일리지 적립 대상, null 이면 게스트)
* @return Order
*/
private function makeDeliveredOrder(int $quantity = 1, ?int $userId = null): Order
{
@@ -210,7 +209,8 @@ class OrderOptionServiceConfirmTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::CONFIRMED
OrderStatusEnum::CONFIRMED,
$order->id,
);
$this->assertSame(1, MileageTransaction::where('order_option_id', $option->id)
@@ -238,7 +238,8 @@ class OrderOptionServiceConfirmTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::CONFIRMED
OrderStatusEnum::CONFIRMED,
$order->id,
);
$freshOrder = $order->fresh();
@@ -8,6 +8,7 @@ use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Services\OrderOptionService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
@@ -65,7 +66,8 @@ class OrderOptionServiceStockRedeductTest extends ModuleTestCase
// 취소 옵션 전체(3개)를 결제완료로 되돌림
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $orderOption->id, 'quantity' => 3]],
OrderStatusEnum::PAYMENT_COMPLETE
OrderStatusEnum::PAYMENT_COMPLETE,
$orderOption->order_id,
);
// 재차감으로 재고 -3 (8 → 5)
@@ -85,7 +87,8 @@ class OrderOptionServiceStockRedeductTest extends ModuleTestCase
// 취소 옵션 3개 중 2개만 결제완료로 되돌림 (부분 분할)
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $orderOption->id, 'quantity' => 2]],
OrderStatusEnum::PAYMENT_COMPLETE
OrderStatusEnum::PAYMENT_COMPLETE,
$orderOption->order_id,
);
// 되돌린 수량(2)만 재차감 (8 → 6)
@@ -117,7 +120,8 @@ class OrderOptionServiceStockRedeductTest extends ModuleTestCase
try {
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $orderOption->id, 'quantity' => 3]],
OrderStatusEnum::PAYMENT_COMPLETE
OrderStatusEnum::PAYMENT_COMPLETE,
$orderOption->order_id,
);
$this->fail('재고 부족 시 InsufficientStockException 이 발생해야 합니다.');
} catch (InsufficientStockException $e) {
@@ -140,7 +144,8 @@ class OrderOptionServiceStockRedeductTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $orderOption->id, 'quantity' => 3]],
OrderStatusEnum::CANCELLED
OrderStatusEnum::CANCELLED,
$orderOption->order_id,
);
$productOption->refresh();
@@ -183,7 +188,8 @@ class OrderOptionServiceStockRedeductTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $orderOption->id, 'quantity' => 3]],
OrderStatusEnum::CANCELLED
OrderStatusEnum::CANCELLED,
$orderOption->order_id,
);
// PG 환불 동반 경로가 아니어도 재고가 복원되어야 한다 (5 → 8)
@@ -203,7 +209,8 @@ class OrderOptionServiceStockRedeductTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $orderOption->id, 'quantity' => 2]],
OrderStatusEnum::CANCELLED
OrderStatusEnum::CANCELLED,
$orderOption->order_id,
);
// 취소 수량(2)만 복원 (5 → 7)
@@ -228,14 +235,15 @@ class OrderOptionServiceStockRedeductTest extends ModuleTestCase
public function test_per_line_cancel_does_not_restore_when_setting_off(): void
{
// 설정 OFF 시 per-line 취소해도 재고 복원 안 함 (취소 모달 경로와 동일 정책)
app(\Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService::class)
app(EcommerceSettingsService::class)
->saveSettings(['order_settings' => ['stock_restore_on_cancel' => false]]);
[, $orderOption, $productOption] = $this->createPaidOrderOption(stock: 5, quantity: 3);
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $orderOption->id, 'quantity' => 3]],
OrderStatusEnum::CANCELLED
OrderStatusEnum::CANCELLED,
$orderOption->order_id,
);
// 설정 OFF → 재고 불변 (5), 플래그도 차감 유지
@@ -72,7 +72,7 @@ class OrderOptionServiceTest extends ModuleTestCase
$order = $this->makePaidOrderWithOptions(2);
$items = $order->options->map(fn ($o) => ['option_id' => $o->id, 'quantity' => $o->quantity])->all();
$this->service->bulkChangeStatusWithQuantity($items, OrderStatusEnum::SHIPPING);
$this->service->bulkChangeStatusWithQuantity($items, OrderStatusEnum::SHIPPING, $order->id);
$this->assertContains(OrderStatusEnum::PAYMENT_COMPLETE->value, $this->fired);
$this->assertEquals(OrderStatusEnum::SHIPPING, $order->fresh()->order_status);
@@ -93,7 +93,8 @@ class OrderOptionServiceTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $firstOption->id, 'quantity' => $firstOption->quantity]],
OrderStatusEnum::SHIPPING
OrderStatusEnum::SHIPPING,
$order->id,
);
$this->assertSame([], $this->fired, '부모가 전이되지 않으면 after_status_change 미발화여야 함');
@@ -121,6 +122,7 @@ class OrderOptionServiceTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::SHIPPING,
$order->id,
['carrier_id' => 1, 'tracking_number' => 'SAVED-TRACK-108']
);
@@ -153,6 +155,7 @@ class OrderOptionServiceTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::PAYMENT_COMPLETE,
$order->id,
['carrier_id' => 1, 'tracking_number' => 'SHOULD-NOT-SAVE']
);
@@ -211,7 +214,8 @@ class OrderOptionServiceTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::PAYMENT_COMPLETE
OrderStatusEnum::PAYMENT_COMPLETE,
$order->id,
);
$cleanup();
@@ -241,7 +245,8 @@ class OrderOptionServiceTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::PAYMENT_COMPLETE
OrderStatusEnum::PAYMENT_COMPLETE,
$order->id,
);
$cleanup();
@@ -271,7 +276,8 @@ class OrderOptionServiceTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::PAYMENT_COMPLETE
OrderStatusEnum::PAYMENT_COMPLETE,
$order->id,
);
$cleanup();
@@ -296,7 +302,8 @@ class OrderOptionServiceTest extends ModuleTestCase
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::SHIPPING
OrderStatusEnum::SHIPPING,
$order->id,
);
$cleanup();
@@ -0,0 +1,19 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* Ecommerce 모듈 1.0.5 업그레이드 스텝
*
* 카테고리 계층의 순환 참조를 절단하고 path 를 전면 재구축한다.
*
* 순환이 남아 있으면 카테고리 수정·순서 변경 요청이 path 재계산 재귀에서 종료되지 않아
* 실패하고, 해당 노드가 트리에서 사라져 관리자 화면으로 복구할 수 없다.
*
* 모든 비즈니스 로직은 data/1.0.5/migrations/ 로 격리(AbstractUpgradeStep 규약).
*
* @upgrade-path A
*/
class Upgrade_1_0_5 extends AbstractUpgradeStep {}
@@ -0,0 +1,230 @@
<?php
namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_5\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 카테고리 순환 참조를 절단하고 depth/path 를 전면 재구축합니다.
*
* 순환 참조를 막는 검증이 없던 시기에 자기참조(parent_id = id) 또는 다중 노드 사이클이
* 저장될 수 있었습니다. 사이클이 남아 있으면 카테고리 수정·순서 변경이 path 재계산 재귀에서
* 종료되지 않아 요청이 실패합니다.
*
* 처리 순서:
* 1. 루트에서 도달 가능한 노드를 수집하며 depth/path 재계산
* 2. 루트에서 도달하지 못한 노드(= 사이클 구성원)의 parent_id 를 NULL 로 절단 후 다시 재계산
*
* 카테고리는 삭제하지 않으며 계층만 복구합니다.
*
* idempotent: 사이클이 없고 path 가 정합하면 no-op. V-1 안전: Facades\DB/Schema 만 사용.
*/
class BreakCategoryCyclesAndRebuildPaths implements DataMigration
{
private const TABLE = 'ecommerce_categories';
/** @var int 세대 순회 상한 (방어적 종료 조건) */
private const MAX_LEVELS = 1000;
/**
* 마이그레이션 식별자를 반환합니다.
*
* @return string 마이그레이션 이름
*/
public function name(): string
{
return 'BreakCategoryCyclesAndRebuildPaths';
}
/**
* 순환을 절단하고 계층 컬럼을 재구축합니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
*/
public function run(UpgradeContext $context): void
{
if (! Schema::hasTable(self::TABLE)
|| ! Schema::hasColumn(self::TABLE, 'parent_id')
|| ! Schema::hasColumn(self::TABLE, 'path')
|| ! Schema::hasColumn(self::TABLE, 'depth')) {
$context->logger->info('[ecommerce:1.0.5] 카테고리 계층 재구축 — 대상 스키마 부재로 스킵');
return;
}
$rows = DB::table(self::TABLE)
->orderBy('id')
->get(['id', 'parent_id', 'depth', 'path']);
if ($rows->isEmpty()) {
$context->logger->info('[ecommerce:1.0.5] 카테고리 없음 — 계층 재구축 스킵');
return;
}
$nodes = [];
foreach ($rows as $row) {
$nodes[(int) $row->id] = [
'parent_id' => $row->parent_id === null ? null : (int) $row->parent_id,
'depth' => (int) $row->depth,
'path' => (string) $row->path,
];
}
$detached = $this->breakCycles($nodes, $context);
$updated = $this->rebuild($nodes, $context);
$context->logger->info(sprintf(
'[ecommerce:1.0.5] 카테고리 계층 재구축 완료 — 순환 절단 %d 건, depth/path 갱신 %d 건',
$detached,
$updated
));
}
/**
* 루트에서 도달하지 못하는 노드(순환 구성원)의 부모 연결을 끊습니다.
*
* @param array<int, array{parent_id: int|null, depth: int, path: string}> $nodes 노드 맵 (참조 전달, 갱신됨)
* @param UpgradeContext $context 업그레이드 컨텍스트
* @return int 절단된 노드 수
*/
private function breakCycles(array &$nodes, UpgradeContext $context): int
{
$detached = 0;
// 존재하지 않는 부모를 가리키는 행도 고아이므로 함께 절단 대상에 포함된다.
while (($orphan = $this->findUnreachableNode($nodes)) !== null) {
$context->logger->warning(sprintf(
'[ecommerce:1.0.5] 스냅샷 순환 절단 category_id=%d parent_id=%s depth=%d path=%s',
$orphan,
var_export($nodes[$orphan]['parent_id'], true),
$nodes[$orphan]['depth'],
$nodes[$orphan]['path']
));
DB::table(self::TABLE)->where('id', $orphan)->update(['parent_id' => null]);
$nodes[$orphan]['parent_id'] = null;
$detached++;
if ($detached >= self::MAX_LEVELS) {
$context->logger->warning('[ecommerce:1.0.5] 순환 절단이 상한에 도달 — 수동 확인 필요');
break;
}
}
return $detached;
}
/**
* 루트에서 도달할 수 없는 노드 하나를 찾습니다.
*
* @param array<int, array{parent_id: int|null, depth: int, path: string}> $nodes 노드 맵
* @return int|null 도달 불가 노드 ID (없으면 null)
*/
private function findUnreachableNode(array $nodes): ?int
{
$reachable = $this->reachableIds($nodes);
foreach (array_keys($nodes) as $id) {
if (! isset($reachable[$id])) {
return $id;
}
}
return null;
}
/**
* 루트(parent_id = null)에서 도달 가능한 노드 ID 집합을 반환합니다.
*
* @param array<int, array{parent_id: int|null, depth: int, path: string}> $nodes 노드 맵
* @return array<int, bool> 도달 가능 ID 집합
*/
private function reachableIds(array $nodes): array
{
$childrenOf = [];
$reachable = [];
$queue = [];
foreach ($nodes as $id => $node) {
if ($node['parent_id'] === null) {
$reachable[$id] = true;
$queue[] = $id;
continue;
}
$childrenOf[$node['parent_id']][] = $id;
}
while ($queue !== []) {
$current = array_shift($queue);
foreach ($childrenOf[$current] ?? [] as $childId) {
if (isset($reachable[$childId])) {
continue;
}
$reachable[$childId] = true;
$queue[] = $childId;
}
}
return $reachable;
}
/**
* 루트부터 depth/path 를 재계산해 저장합니다.
*
* @param array<int, array{parent_id: int|null, depth: int, path: string}> $nodes 노드 맵
* @param UpgradeContext $context 업그레이드 컨텍스트
* @return int 갱신된 행 수
*/
private function rebuild(array $nodes, UpgradeContext $context): int
{
$childrenOf = [];
$queue = [];
foreach ($nodes as $id => $node) {
if ($node['parent_id'] === null) {
$queue[] = [$id, 0, (string) $id];
continue;
}
$childrenOf[$node['parent_id']][] = $id;
}
$updated = 0;
while ($queue !== []) {
[$id, $depth, $path] = array_shift($queue);
if ($nodes[$id]['depth'] !== $depth || $nodes[$id]['path'] !== $path) {
$context->logger->info(sprintf(
'[ecommerce:1.0.5] 스냅샷 category_id=%d depth %d→%d path %s→%s',
$id,
$nodes[$id]['depth'],
$depth,
$nodes[$id]['path'],
$path
));
DB::table(self::TABLE)
->where('id', $id)
->update(['depth' => $depth, 'path' => $path]);
$updated++;
}
foreach ($childrenOf[$id] ?? [] as $childId) {
$queue[] = [$childId, $depth + 1, $path.'/'.$childId];
}
}
return $updated;
}
}
@@ -19,6 +19,8 @@
### Fixed
- 페이지 활동 로그에서 "제목"·"내용" 변경 항목의 이름이 비어 보이던 문제를 수정했습니다.
- 다른 페이지의 주소로 특정 페이지의 이전 버전 내용을 열람할 수 있던 문제를 수정했습니다.
- 다른 페이지에 속한 버전으로 복원을 시도하면 "복원에 실패했습니다" 라는 서버 오류가 나던 문제를 수정했습니다. 이제 열람과 동일하게 "찾을 수 없음" 으로 안내합니다.
## [1.0.1] - 2026-07-08
@@ -795,7 +795,7 @@ Authorization: Bearer {YOUR_TOKEN}
<!-- @generated:end -->
**설명** 특정 버전의 상세 스냅샷을 조회합니다. `{page}` 는 페이지 ID, `{versionId}` 는 버전 ID 입니다. 해당 버전의 전체 본문(`content`)과 메타를 담은 `PageVersionResource` 를 반환하며, 복원 전 미리보기·버전 간 비교에 사용합니다. `{versionId}` 는 라우트-모델 바인딩이 아니어서 문서 생성 시 자동 실측에서 제외되며, 응답 shape 은 `versions.index` 항목과 동일합니다. 미존재 404, 스코프 밖 403.
**설명** 특정 버전의 상세 스냅샷을 조회합니다. `{page}` 는 페이지 ID, `{versionId}` 는 버전 ID 입니다. 해당 버전의 전체 본문(`content`)과 메타를 담은 `PageVersionResource` 를 반환하며, 복원 전 미리보기·버전 간 비교에 사용합니다. `{versionId}` 는 라우트-모델 바인딩이 아니어서 문서 생성 시 자동 실측에서 제외되며, 응답 shape 은 `versions.index` 항목과 동일합니다. 미존재 404, 스코프 밖 403. `{versionId}`는 경로의 `{page}`에 속한 버전이어야 합니다 — 다른 페이지의 버전 ID를 지정하면 404를 반환합니다.
### POST /api/modules/sirsoft-page/admin/pages/{page}/versions/{versionId}/restore
@@ -838,7 +838,7 @@ Authorization: Bearer {YOUR_TOKEN}
<!-- @generated:end -->
**설명** 페이지를 특정 버전 시점으로 복원합니다. `{page}` 는 페이지 ID, `{versionId}` 는 복원 대상 버전 ID 입니다. `PageService::restoreVersion()` 이 그 버전의 내용을 현재 페이지에 반영하고(복원 자체도 새 버전으로 적재), 복원된 `PageResource` 를 반환합니다. 편집 화면 버전 이력 패널의 "이 버전으로 복원" 액션에 사용합니다. 미존재 404, 스코프 밖 403.
**설명** 페이지를 특정 버전 시점으로 복원합니다. `{page}` 는 페이지 ID, `{versionId}` 는 복원 대상 버전 ID 입니다. `PageService::restoreVersion()` 이 그 버전의 내용을 현재 페이지에 반영하고(복원 자체도 새 버전으로 적재), 복원된 `PageResource` 를 반환합니다. 편집 화면 버전 이력 패널의 "이 버전으로 복원" 액션에 사용합니다. 미존재 404, 스코프 밖 403. `{versionId}`는 경로의 `{page}`에 속한 버전이어야 합니다 — 다른 페이지의 버전 ID를 지정하면 `versions.show` 와 동일하게 404를 반환합니다.
### GET /api/modules/sirsoft-page/pages/attachment/{hash}
@@ -3,6 +3,7 @@
namespace Modules\Sirsoft\Page\Http\Controllers\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Modules\Sirsoft\Page\Http\Requests\BulkChangePageStatusRequest;
use Modules\Sirsoft\Page\Http\Requests\ChangePageStatusRequest;
@@ -97,7 +98,7 @@ class PageController extends AdminBaseController
'sirsoft-page::messages.page.fetch_success',
new PageResource($page)
);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException) {
} catch (ModelNotFoundException) {
return $this->notFound('sirsoft-page::messages.page.not_found');
} catch (AccessDeniedHttpException) {
return $this->error('auth.scope_denied', 403);
@@ -124,7 +125,7 @@ class PageController extends AdminBaseController
'sirsoft-page::messages.page.update_success',
new PageResource($page)
);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException) {
} catch (ModelNotFoundException) {
return $this->notFound('sirsoft-page::messages.page.not_found');
} catch (AccessDeniedHttpException) {
return $this->error('auth.scope_denied', 403);
@@ -146,7 +147,7 @@ class PageController extends AdminBaseController
$this->pageService->deletePage($page);
return $this->success('sirsoft-page::messages.page.delete_success');
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException) {
} catch (ModelNotFoundException) {
return $this->notFound('sirsoft-page::messages.page.not_found');
} catch (AccessDeniedHttpException) {
return $this->error('auth.scope_denied', 403);
@@ -191,7 +192,7 @@ class PageController extends AdminBaseController
'sirsoft-page::messages.page.publish_success',
new PageResource($page)
);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException) {
} catch (ModelNotFoundException) {
return $this->notFound('sirsoft-page::messages.page.not_found');
} catch (AccessDeniedHttpException) {
return $this->error('auth.scope_denied', 403);
@@ -239,7 +240,7 @@ class PageController extends AdminBaseController
'sirsoft-page::messages.page.fetch_success',
PageVersionResource::collection($versions)
);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException) {
} catch (ModelNotFoundException) {
return $this->notFound('sirsoft-page::messages.page.not_found');
} catch (AccessDeniedHttpException) {
return $this->error('auth.scope_denied', 403);
@@ -259,14 +260,15 @@ class PageController extends AdminBaseController
{
try {
$this->pageService->getPage($id);
$version = $this->pageService->getVersion($versionId);
// 경로의 페이지에 속한 버전만 조회 (교차 페이지 접근 차단)
$version = $this->pageService->getVersion($id, $versionId);
$version->load('creator');
return $this->successWithResource(
'sirsoft-page::messages.page.fetch_success',
new PageVersionResource($version)
);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException) {
} catch (ModelNotFoundException) {
return $this->notFound('sirsoft-page::messages.page.not_found');
} catch (AccessDeniedHttpException) {
return $this->error('auth.scope_denied', 403);
@@ -293,7 +295,7 @@ class PageController extends AdminBaseController
'sirsoft-page::messages.page.restore_success',
new PageResource($page)
);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException) {
} catch (ModelNotFoundException) {
return $this->notFound('sirsoft-page::messages.page.not_found');
} catch (AccessDeniedHttpException) {
return $this->error('auth.scope_denied', 403);
@@ -3,6 +3,7 @@
namespace Modules\Sirsoft\Page\Repositories\Contracts;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Modules\Sirsoft\Page\Models\Page;
use Modules\Sirsoft\Page\Models\PageVersion;
@@ -42,7 +43,18 @@ interface PageVersionRepositoryInterface
* @param int $id 버전 ID
* @return PageVersion 버전 모델
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
* @throws ModelNotFoundException
*/
public function findOrFail(int $id): PageVersion;
/**
* 페이지에 속한 버전을 ID로 조회하며, 없으면 예외를 발생시킵니다.
*
* @param int $pageId 페이지 ID
* @param int $id 버전 ID
* @return PageVersion 버전 모델
*
* @throws ModelNotFoundException
*/
public function findForPage(int $pageId, int $id): PageVersion;
}
@@ -3,6 +3,7 @@
namespace Modules\Sirsoft\Page\Repositories;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Modules\Sirsoft\Page\Models\Page;
use Modules\Sirsoft\Page\Models\PageVersion;
use Modules\Sirsoft\Page\Repositories\Contracts\PageVersionRepositoryInterface;
@@ -59,10 +60,26 @@ class PageVersionRepository implements PageVersionRepositoryInterface
* @param int $id 버전 ID
* @return PageVersion 버전 모델
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
* @throws ModelNotFoundException
*/
public function findOrFail(int $id): PageVersion
{
return PageVersion::findOrFail($id);
}
/**
* 페이지에 속한 버전을 ID로 조회하며, 없으면 예외를 발생시킵니다.
*
* @param int $pageId 페이지 ID
* @param int $id 버전 ID
* @return PageVersion 버전 모델
*
* @throws ModelNotFoundException
*/
public function findForPage(int $pageId, int $id): PageVersion
{
return PageVersion::where('page_id', $pageId)
->where('id', $id)
->firstOrFail();
}
}
@@ -259,6 +259,8 @@ class PageService
* @param Page $page 페이지 모델
* @param int $versionId 복원할 버전 ID
* @return Page 복원된 페이지 모델
*
* @throws ModelNotFoundException 버전이 해당 페이지에 속하지 않을 때
*/
public function restoreVersion(Page $page, int $versionId): Page
{
@@ -266,13 +268,10 @@ class PageService
throw new AccessDeniedHttpException(__('auth.scope_denied'));
}
$version = $this->pageVersionRepository->findOrFail($versionId);
if ($version->page_id !== $page->id) {
throw new \InvalidArgumentException(
__('sirsoft-page::messages.errors.version_belongs_to_different_page')
);
}
// 상위 페이지 스코프를 Repository where 절에 반영한다 (SSoT).
// 사후 비교로 처리하면 예외 타입이 컨트롤러 catch 사슬과 어긋나 500 이 되고,
// 같은 위반에 404 를 내는 조회(getVersion)와 계약이 갈린다.
$version = $this->pageVersionRepository->findForPage($page->id, $versionId);
return DB::transaction(function () use ($page, $version) {
$userId = Auth::id();
@@ -350,16 +349,17 @@ class PageService
}
/**
* 버전 ID로 페이지 버전을 조회합니다.
* 페이지에 속한 버전을 조회합니다.
*
* @param int $pageId 페이지 ID
* @param int $versionId 버전 ID
* @return PageVersion 버전 모델
*
* @throws ModelNotFoundException
*/
public function getVersion(int $versionId): PageVersion
public function getVersion(int $pageId, int $versionId): PageVersion
{
return $this->pageVersionRepository->findOrFail($versionId);
return $this->pageVersionRepository->findForPage($pageId, $versionId);
}
/**
@@ -0,0 +1,207 @@
<?php
namespace Modules\Sirsoft\Page\Tests\Feature\Admin;
// FeatureTestCase를 수동으로 require (autoload 전에 로드 필요)
require_once __DIR__.'/../../FeatureTestCase.php';
use App\Models\User;
use Modules\Sirsoft\Page\Models\Page;
use Modules\Sirsoft\Page\Models\PageVersion;
use Modules\Sirsoft\Page\Tests\FeatureTestCase;
/**
* 페이지 버전 엔드포인트의 상위 페이지 스코프 검증 테스트
*
* GET /api/modules/sirsoft-page/admin/pages/{page}/versions/{versionId}
* POST /api/modules/sirsoft-page/admin/pages/{page}/versions/{versionId}/restore
*
* 검증 목적:
* - 페이지 A 의 경로로 페이지 B 의 버전을 조회·복원할 수 없다 (404)
* - 자기 페이지의 버전은 기존과 동일하게 조회·복원된다 (200)
* - 조회와 복원 두 엔드포인트가 같은 위반에 같은 상태코드를 낸다 (계약 일치)
*/
class PageVersionScopeTest extends FeatureTestCase
{
protected User $adminUser;
protected function setUp(): void
{
parent::setUp();
$this->adminUser = $this->createAdminUser([
'sirsoft-page.pages.read',
'sirsoft-page.pages.update',
]);
}
protected function tearDown(): void
{
Page::where('slug', 'like', 'test-version-scope-%')->forceDelete();
parent::tearDown();
}
/**
* 다른 페이지의 버전은 조회할 수 없다
*
* @scenario resource=page_version, parent_scope=mismatched, actor=admin
*
* @effects cross_scope_read_returns_404
*/
public function test_cannot_show_version_of_other_page(): void
{
$pageA = $this->makePage('test-version-scope-a');
$pageB = $this->makePage('test-version-scope-b');
$versionOfB = $this->makeVersion($pageB);
$this->actingAs($this->adminUser)
->getJson($this->url($pageA->id, $versionOfB->id))
->assertStatus(404);
}
/**
* 자기 페이지의 버전은 조회된다 (회귀 방지)
*
* @scenario resource=page_version, parent_scope=matching, actor=admin
*
* @effects matching_scope_still_succeeds
*/
public function test_can_show_version_of_own_page(): void
{
$page = $this->makePage('test-version-scope-own');
$version = $this->makeVersion($page);
$this->actingAs($this->adminUser)
->getJson($this->url($page->id, $version->id))
->assertStatus(200)
->assertJsonPath('data.id', $version->id);
}
/**
* 다른 페이지의 버전으로 복원할 수 없다
*
* 조회(showVersion)와 동일한 위반이므로 동일한 404 여야 한다. 종전에는
* Service 의 사후 비교가 `\InvalidArgumentException` 을 던지고 컨트롤러
* catch 사슬에 그 타입이 없어 500 으로 떨어졌다.
*
* @scenario resource=page_version, parent_scope=mismatched, actor=admin
*
* @effects cross_scope_write_rejected
*/
public function test_cannot_restore_version_of_other_page(): void
{
$pageA = $this->makePage('test-version-scope-restore-a');
$pageB = $this->makePage('test-version-scope-restore-b');
$versionOfB = $this->makeVersion($pageB, [
'title' => ['ko' => '페이지 B 의 버전 제목', 'en' => 'Version title of page B'],
]);
$this->actingAs($this->adminUser)
->postJson($this->url($pageA->id, $versionOfB->id).'/restore')
->assertStatus(404);
// 페이지 A 가 B 의 내용으로 오염되지 않았다
$pageA->refresh();
$this->assertNotSame('페이지 B 의 버전 제목', $pageA->getRawOriginal('title'));
$this->assertStringNotContainsString('페이지 B 의 버전 제목', (string) $pageA->getRawOriginal('title'));
$this->assertSame(1, $pageA->current_version);
}
/**
* 조회와 복원 두 엔드포인트가 같은 위반에 같은 상태코드를 낸다
*
* @scenario resource=page_version, parent_scope=mismatched, actor=admin
*
* @effects sibling_endpoints_share_scope_contract
*/
public function test_show_and_restore_endpoints_reject_cross_page_alike(): void
{
$pageA = $this->makePage('test-version-scope-parity-a');
$pageB = $this->makePage('test-version-scope-parity-b');
$versionOfB = $this->makeVersion($pageB);
$showStatus = $this->actingAs($this->adminUser)
->getJson($this->url($pageA->id, $versionOfB->id))
->getStatusCode();
$restoreStatus = $this->actingAs($this->adminUser)
->postJson($this->url($pageA->id, $versionOfB->id).'/restore')
->getStatusCode();
$this->assertSame($showStatus, $restoreStatus);
}
/**
* 자기 페이지의 버전으로는 복원된다 (과차단 회귀 방지)
*
* @scenario resource=page_version, parent_scope=matching, actor=admin
*
* @effects matching_scope_still_succeeds
*/
public function test_can_restore_version_of_own_page(): void
{
$page = $this->makePage('test-version-scope-restore-own');
$version = $this->makeVersion($page, [
'title' => ['ko' => '복원 대상 제목', 'en' => 'Restore target title'],
]);
$this->actingAs($this->adminUser)
->postJson($this->url($page->id, $version->id).'/restore')
->assertStatus(200);
$page->refresh();
$this->assertStringContainsString('복원 대상 제목', (string) $page->getRawOriginal('title'));
$this->assertSame(2, $page->current_version);
}
/**
* 테스트용 페이지를 생성합니다.
*
* @param string $slug 페이지 슬러그
* @return Page 생성된 페이지
*/
private function makePage(string $slug): Page
{
return Page::factory()->create([
'slug' => $slug,
'current_version' => 1,
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
}
/**
* 테스트용 페이지 버전을 생성합니다.
*
* @param Page $page 소속 페이지
* @param array<string, mixed> $overrides 덮어쓸 속성
* @return PageVersion 생성된 버전
*/
private function makeVersion(Page $page, array $overrides = []): PageVersion
{
return PageVersion::create(array_merge([
'page_id' => $page->id,
'version' => 1,
'title' => $page->title,
'content' => $page->content,
'content_mode' => $page->content_mode,
'created_by' => $this->adminUser->id,
], $overrides));
}
/**
* 버전 상세 조회 엔드포인트 URL 을 생성합니다.
*
* @param int $pageId 페이지 ID
* @param int $versionId 버전 ID
* @return string 엔드포인트 URL
*/
private function url(int $pageId, int $versionId): string
{
return "/api/modules/sirsoft-page/admin/pages/{$pageId}/versions/{$versionId}";
}
}
@@ -308,9 +308,11 @@ class PluginSettingsControllerTest extends TestCase
$this->createTestPlugin('sirsoft-daum_postcode');
}
// 플러그인이 선언한 설정 스키마의 실제 키를 사용한다.
// 스키마 밖의 키는 검증을 통과하지 못해 저장되지 않는다.
$response = $this->authRequest()->putJson('/api/admin/plugins/sirsoft-daum_postcode/settings', [
'animation' => true,
'autoClose' => false,
'display_mode' => 'popup',
'popup_width' => 640,
]);
$response->assertStatus(200)
@@ -320,14 +322,51 @@ class PluginSettingsControllerTest extends TestCase
$settingsPath = storage_path('app/plugins/sirsoft-daum_postcode/settings/setting.json');
if (File::exists($settingsPath)) {
$savedContent = json_decode(File::get($settingsPath), true);
$this->assertTrue($savedContent['animation'] ?? false);
$this->assertFalse($savedContent['autoClose'] ?? true);
$this->assertSame('popup', $savedContent['display_mode'] ?? null);
$this->assertSame(640, $savedContent['popup_width'] ?? null);
}
// 정리
$this->cleanupTestSettings('sirsoft-daum_postcode');
}
/**
* 스키마에 없는 필드는 저장되지 않음 (mass-assignment 방지)
*
* 과거 구현은 `validated()` 가 비면 `$request->all()` 로 폴백했다. 폴백의 명분이던
* "PluginManager 미등록 플러그인" 은 `PluginSettingsService::save()` 가 이미 false 로
* 차단하므로 도달할 수 없고, 실제로는 스키마 밖 키만 담긴 요청이 그대로 설정 파일에
* 병합되는 경로로만 동작했다.
*/
public function test_update_ignores_fields_outside_settings_schema(): void
{
$pluginInstance = $this->pluginManager->getPlugin('sirsoft-daum_postcode');
if (! $pluginInstance) {
$this->markTestSkipped('sirsoft-daum_postcode plugin not installed');
}
$plugin = Plugin::where('identifier', 'sirsoft-daum_postcode')->first();
if (! $plugin) {
$this->createTestPlugin('sirsoft-daum_postcode');
}
// 스키마에 정의되지 않은 키만 담아 전송 → validated() 가 빈 배열이 되는 조건
$this->authRequest()->putJson('/api/admin/plugins/sirsoft-daum_postcode/settings', [
'injected_field' => 'evil',
'another_unknown' => ['nested' => true],
]);
$settingsPath = storage_path('app/plugins/sirsoft-daum_postcode/settings/setting.json');
if (File::exists($settingsPath)) {
$savedContent = json_decode(File::get($settingsPath), true);
$this->assertArrayNotHasKey('injected_field', $savedContent);
$this->assertArrayNotHasKey('another_unknown', $savedContent);
}
$this->cleanupTestSettings('sirsoft-daum_postcode');
}
// ========================================================================
// layout() 테스트 - 레이아웃 조회
// ========================================================================
@@ -431,7 +470,7 @@ class PluginSettingsControllerTest extends TestCase
}
$response = $this->authRequest()->putJson('/api/admin/plugins/sirsoft-daum_postcode/settings', [
'animation' => true,
'display_mode' => 'popup',
]);
$response->assertStatus(200)
@@ -442,8 +481,8 @@ class PluginSettingsControllerTest extends TestCase
// 응답에 업데이트된 설정이 포함되어 있는지 확인
$data = $response->json('data');
$this->assertIsArray($data);
$this->assertArrayHasKey('animation', $data);
$this->assertTrue($data['animation']);
$this->assertArrayHasKey('display_mode', $data);
$this->assertSame('popup', $data['display_mode']);
// 정리
$this->cleanupTestSettings('sirsoft-daum_postcode');
@@ -0,0 +1,253 @@
<?php
namespace Tests\Feature\Menu;
use App\Models\Menu;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 메뉴 수정 시 순환 참조 검증 테스트
*
* PUT /api/admin/menus/{menu}
*
* 검증 목적:
* - 자기 자신을 부모로 지정할 수 없다 (기존 NotSelfParent 동작 유지)
* - 자손(직계 자식 / 손자)을 부모로 지정할 수 없다 (신규 강화)
* - 무관한 메뉴로의 이동은 허용된다
*
* 배경: 같은 리소스의 순서 변경 엔드포인트(UpdateMenuOrderRequest)는 이미
* NotCircularParent 로 자손 전체를 검사하는데, 수정 엔드포인트는 자기 자신만
* 검사해 두 엔드포인트의 검증 강도가 어긋나 있었다.
*/
class MenuCircularParentTest extends TestCase
{
use RefreshDatabase;
private User $adminUser;
protected function setUp(): void
{
parent::setUp();
$this->adminUser = $this->createMenuAdmin();
}
/**
* 자기 자신을 부모로 지정하면 422
*
* @scenario entity=core_menu, parent_target=self
*
* @effects self_parent_rejected_422, parent_unchanged_on_rejection
*/
public function test_cannot_set_self_as_parent(): void
{
$menu = $this->makeMenu('self-parent');
$this->actingAs($this->adminUser)
->putJson($this->url($menu), $this->payload($menu, $menu->id))
->assertStatus(422)
->assertJsonValidationErrors(['parent_id']);
$this->assertNull($menu->fresh()->parent_id);
}
/**
* 직계 자식을 부모로 지정하면 422
*
* @scenario entity=core_menu, parent_target=descendant
*
* @effects descendant_parent_rejected_422, parent_unchanged_on_rejection
*/
public function test_cannot_set_direct_child_as_parent(): void
{
$root = $this->makeMenu('cycle-root');
$child = $this->makeMenu('cycle-child', $root);
$this->actingAs($this->adminUser)
->putJson($this->url($root), $this->payload($root, $child->id))
->assertStatus(422)
->assertJsonValidationErrors(['parent_id']);
$this->assertNull($root->fresh()->parent_id);
}
/**
* 손자를 부모로 지정하면 422
*
* @scenario entity=core_menu, parent_target=descendant
*
* @effects descendant_parent_rejected_422, parent_unchanged_on_rejection
*/
public function test_cannot_set_grandchild_as_parent(): void
{
$root = $this->makeMenu('deep-root');
$child = $this->makeMenu('deep-child', $root);
$grandChild = $this->makeMenu('deep-grandchild', $child);
$this->actingAs($this->adminUser)
->putJson($this->url($root), $this->payload($root, $grandChild->id))
->assertStatus(422)
->assertJsonValidationErrors(['parent_id']);
$this->assertNull($root->fresh()->parent_id);
}
/**
* 무관한 메뉴로의 이동은 허용된다 (회귀 방지)
*
* @scenario entity=core_menu, parent_target=unrelated
*
* @effects unrelated_parent_move_succeeds
*/
public function test_can_move_under_unrelated_menu(): void
{
$target = $this->makeMenu('move-target');
$moving = $this->makeMenu('move-source');
$this->actingAs($this->adminUser)
->putJson($this->url($moving), $this->payload($moving, $target->id))
->assertStatus(200);
$this->assertSame($target->id, $moving->fresh()->parent_id);
}
/**
* 최상위로 이동(parent_id = null)은 허용된다 (회귀 방지)
*
* @scenario entity=core_menu, parent_target=none
*
* @effects move_to_root_succeeds
*/
public function test_can_move_to_root(): void
{
$root = $this->makeMenu('to-root-parent');
$child = $this->makeMenu('to-root-child', $root);
$this->actingAs($this->adminUser)
->putJson($this->url($child), $this->payload($child, null))
->assertStatus(200);
$this->assertNull($child->fresh()->parent_id);
}
/**
* 수정 / 순서 변경 두 엔드포인트가 동일한 순환 시나리오를 동일하게 거절한다
*
* 결함 8 의 본질은 "같은 리소스의 두 엔드포인트가 서로 다른 검증 강도를 갖는 것"이었다.
* 수정 쪽만 검사하면 약한 쪽이 우회로가 되므로, 같은 자손 지정 시나리오를 양쪽에
* 던져 두 응답이 모두 422 임을 대조한다. 한쪽 규칙이 약화되면 이 케이스가 깨진다.
*
* @scenario entity=core_menu, parent_target=descendant
*
* @effects descendant_parent_rejected_422, parent_unchanged_on_rejection
*/
public function test_update_and_reorder_endpoints_reject_same_descendant_move(): void
{
$root = $this->makeMenu('parity-root');
$child = $this->makeMenu('parity-child', $root);
// ① 수정 엔드포인트 — root 를 자기 자식 아래로
$this->actingAs($this->adminUser)
->putJson($this->url($root), $this->payload($root, $child->id))
->assertStatus(422)
->assertJsonValidationErrors(['parent_id']);
// ② 순서 변경 엔드포인트 — 동일 시나리오
$this->actingAs($this->adminUser)
->putJson('/api/admin/menus/order', [
'parent_menus' => [
['id' => $root->id, 'order' => 1],
],
'moved_items' => [
['id' => $root->id, 'new_parent_id' => $child->id],
],
])
->assertStatus(422)
->assertJsonValidationErrors(['moved_items.0.new_parent_id']);
// 어느 경로로도 트리가 바뀌지 않아야 한다
$this->assertNull($root->fresh()->parent_id);
$this->assertSame($root->id, $child->fresh()->parent_id);
}
/**
* 메뉴 수정 권한을 가진 관리자를 생성합니다.
*
* @return User 관리자 사용자
*/
private function createMenuAdmin(): User
{
$role = Role::create([
'identifier' => 'admin',
'name' => ['ko' => '관리자', 'en' => 'Administrator'],
'is_active' => true,
]);
foreach (['admin.access', 'core.menus.read', 'core.menus.update'] as $identifier) {
$permission = Permission::firstOrCreate(
['identifier' => $identifier],
[
'name' => ['ko' => $identifier, 'en' => $identifier],
'type' => 'admin',
'extension_type' => 'core',
'extension_identifier' => 'core',
]
);
$role->permissions()->syncWithoutDetaching([$permission->id]);
}
$user = User::factory()->create();
$user->roles()->attach($role->id);
return $user;
}
/**
* 테스트용 메뉴를 생성합니다.
*
* @param string $slug 메뉴 슬러그
* @param Menu|null $parent 부모 메뉴
* @return Menu 생성된 메뉴
*/
private function makeMenu(string $slug, ?Menu $parent = null): Menu
{
return Menu::create([
'name' => ['ko' => $slug, 'en' => $slug],
'slug' => $slug,
'parent_id' => $parent?->id,
'order' => 0,
'is_active' => true,
]);
}
/**
* 메뉴 수정 요청 본문을 생성합니다.
*
* @param Menu $menu 대상 메뉴
* @param int|null $parentId 지정할 부모 ID
* @return array<string, mixed> 요청 본문
*/
private function payload(Menu $menu, ?int $parentId): array
{
return [
'name' => ['ko' => $menu->slug, 'en' => $menu->slug],
'slug' => $menu->slug,
'parent_id' => $parentId,
];
}
/**
* 메뉴 수정 엔드포인트 URL 을 생성합니다.
*
* @param Menu $menu 대상 메뉴
* @return string 엔드포인트 URL
*/
private function url(Menu $menu): string
{
return "/api/admin/menus/{$menu->id}";
}
}
@@ -0,0 +1,121 @@
feature: 계층 순환 참조 방지 및 깊이 제한
description: |
이슈 #80 검수에서 확인된 "부모-자식 무결성 / 순환참조" 계열 3건과
"설정을 무시하는 하드코딩" 1건.
라이브 재현 (https://g7_2.dev):
· max_comment_depth=10 게시판에서 12단계까지 전부 201 생성, 저장 depth 는
6단계 이후 5 로 고정 → marginLeft 5rem 고정으로 계층 시각 붕괴
· 게시글 A 댓글을 게시글 B 댓글의 parent_id 로 지정 → 201, 생성된 댓글은
B 목록에서 누락(고아)
대상 결함:
6. sirsoft-board CommentValidationRule + CommentService — 부모 댓글의 post_id 미검증.
Rule 만 고치면 Service 가 여전히 타 게시글 부모로 depth 계산 → 두 곳 동시 수정.
7. sirsoft-ecommerce {Create,Update,Reorder}CategoryRequest — 자기참조·순환 검증 전무.
사이클 생성 시 CategoryService::updateDescendantsPaths 재귀가 종료되지 않아
요청이 실패하고 노드가 트리에서 사라져 관리자 UI 로 복구 불가.
8. 코어 UpdateMenuRequest — NotSelfParent(자기 자신만) 사용.
같은 리소스의 UpdateMenuOrderRequest 는 NotCircularParent(자손 전체) 사용 →
동일 리소스 두 엔드포인트의 검증 강도 불일치.
9. sirsoft-board CommentService — min($parent->depth + 1, 5) 하드코딩.
설정값은 3중으로 모두 10 이라 depth 가 5 를 못 넘고, 그 결과
CommentValidationRule 의 depth+1 > max 가 영원히 거짓 → 깊이 제한 완전 무력화.
조치:
· 부모 무결성은 Rule(422, UX) + Service(훅·내부 호출 우회 차단) 이중 방어.
· 카테고리는 모듈 로컬 Rule NotCircularCategoryParent 신설 —
코어 NotCircularParent 는 App\Models\Menu 하드코딩이고, 일반화하면
Category 의 path 머티리얼라이즈드 컬럼 최적화(쿼리 1회)를 표현할 수 없다.
· 순서 변경(reorder) 엔드포인트도 동일 강도로 방어 — 약한 쪽이 우회로가 된다.
· CategoryService::updateDescendantsPaths 에 방문 ID 가드 추가 —
검증을 우회한 경로(시더/훅/기존 오염 데이터)에서도 무한 루프 대신 유한 실패 + 로그.
· Service 의 리터럴 재클램프 제거 — 상한 검증은 Rule 단일 책임.
# axis 의미:
# 순환 성립 여부를 가르는 축은 (엔티티) × (지정한 부모의 계보상 위치) 다.
# parent_target 값:
# self — 자기 자신을 부모로
# descendant — 자기 자손(직계 자식·손자)을 부모로
# foreign — 트리 밖 부모 (댓글 전용: 다른 게시글의 댓글)
# unrelated — 계보상 무관한 노드를 부모로 (정상 이동)
# none — 최상위로 이동 (parent_id = null, 정상)
# 댓글은 부모를 옮기는 UI·API 경로가 없어 self/descendant/none 이 성립하지 않는다.
# 댓글의 유일한 순환 유입 경로는 생성 시 parent_id 로 타 게시글 댓글을 지정하는 것(foreign)이다.
axes:
entity: [board_comment, ec_category, core_menu]
parent_target: [self, descendant, foreign, unrelated, none]
exclusions:
- { entity: board_comment, parent_target: self, reason: "댓글은 자기 자신을 부모로 지정하는 UI·API 경로가 없다" }
- { entity: board_comment, parent_target: descendant, reason: "댓글은 부모를 옮기는 경로가 없어 자손 지정이 성립하지 않는다" }
- { entity: board_comment, parent_target: none, reason: "최상위 댓글은 순환과 무관 — 기존 CRUD 시나리오가 커버" }
- { entity: ec_category, parent_target: foreign, reason: "카테고리 트리는 단일 루트 집합이라 트리 밖 부모 개념이 없다" }
- { entity: core_menu, parent_target: foreign, reason: "메뉴 트리는 단일 루트 집합이라 트리 밖 부모 개념이 없다" }
effects:
# 순환 차단
- self_parent_rejected_422
- descendant_parent_rejected_422
- foreign_parent_rejected_422
- parent_unchanged_on_rejection
# 정상 이동 회귀 방지
- unrelated_parent_move_succeeds
- move_to_root_succeeds
- descendant_paths_rebuilt_on_valid_move
# 깊이 (결함 9)
- depth_equals_parent_depth_plus_one
- depth_beyond_board_max_rejected_422
- depth_follows_board_setting_not_literal
# 재귀 종료성
- path_recursion_terminates_on_polluted_data
# 오염 데이터 복구 (업그레이드 스텝)
- existing_cross_post_parent_detached_content_preserved
- existing_clamped_depth_recalculated
- existing_cycle_broken_and_paths_rebuilt
- cleanup_is_idempotent
sub_flows:
- id: comment_depth_boundary
description: |
댓글 깊이 경계. max_comment_depth 를 3(낮춤) / 10(기본) 두 설정에서 확인한다 —
설정값을 실제로 따르는지가 결함 9 의 핵심이라 단일 설정 검증으로는 부족하다.
effects:
- depth_equals_parent_depth_plus_one
- depth_beyond_board_max_rejected_422
- depth_follows_board_setting_not_literal
- id: reorder_endpoint_parity
description: 카테고리 순서 변경 엔드포인트가 수정 엔드포인트와 동일한 순환 방지 강도를 가짐
effects:
- descendant_parent_rejected_422
- unrelated_parent_move_succeeds
- id: polluted_data_cleanup
description: 업그레이드 스텝의 기존 오염 데이터 복구 (댓글 계층 / 카테고리 사이클)
effects:
- existing_cross_post_parent_detached_content_preserved
- existing_clamped_depth_recalculated
- existing_cycle_broken_and_paths_rebuilt
- cleanup_is_idempotent
- path_recursion_terminates_on_polluted_data
test_files:
# 결함 6 — 부모 댓글 post_id 무결성 (HTTP 422 + Service 직접 호출)
- modules/_bundled/sirsoft-board/tests/Feature/User/CommentParentIntegrityTest.php
# 결함 9 — 깊이 제한이 게시판 설정을 따름 (max=3 / max=10)
- modules/_bundled/sirsoft-board/tests/Feature/CommentDepthLimitTest.php
# 결함 6·9 오염 데이터 정리 업그레이드 스텝
- modules/_bundled/sirsoft-board/tests/Feature/Upgrade/CommentHierarchyCleanupTest.php
# 결함 7 — 카테고리 순환 (update + reorder)
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/CategoryCycleTest.php
# 결함 7 오염 데이터 정리 업그레이드 스텝
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/CategoryCycleCleanupTest.php
# 결함 8 — 코어 메뉴 순환
- tests/Feature/Menu/MenuCircularParentTest.php
notes: |
E2E 미해당: 변경 범위가 백엔드 전용(FormRequest/Rule/Service/Repository)이며 레이아웃 JSON·
컴포넌트·엔진 코드 변경이 없다. 순환 지정은 관리자 UI 의 선택 목록이 이미 자기 자신·자손을
제외해 노출하므로 브라우저 조작으로 도달하는 경로가 아니고, 본 방어는 그 UI 를 우회한
직접 요청을 대상으로 한다.
→ audit 룰 frontend-change-requires-e2e 비적용.
@@ -0,0 +1,89 @@
feature: 중첩 리소스 상위 스코프 무결성
description: |
이슈 #80 검수에서 확인된 "중첩 라우트의 상위 경로 파라미터 미검증" 계열 결함 5건 + mass-assignment 1건.
라우트에 상위 리소스 ID 가 있는데 조회 스코프에 반영하지 않아, A 의 경로로 B 의
하위 리소스를 읽거나 변경할 수 있었다.
라이브 재현 (https://g7_2.dev):
· 게시글 B URL 로 게시글 A 댓글 PUT/DELETE → 둘 다 200
· 수정 요청 본문의 post_id 가 그대로 저장되어 댓글이 다른 게시글로 이동
대상 결함:
1. sirsoft-board User/CommentController — $postId 미사용 (일반 사용자 교차 수정·삭제)
2. sirsoft-board Admin/CommentController — $postId 미사용 (update/destroy/blind/restore 4종)
3. sirsoft-ecommerce Admin/OrderController — Order 바인딩 미사용 (타 주문 옵션 일괄 변경)
4. sirsoft-ecommerce Admin/ProductController — $productId 미사용 (타 상품 대표 이미지 오염)
5. sirsoft-page Admin/PageController — $id(page) 미사용 (스코프 밖 버전 열람)
5-b. sirsoft-page PageService::restoreVersion — 형제 엔드포인트가 사후 비교라
같은 위반에 500 (조회는 404). 계약 불일치 (Chrome MCP 시나리오 분해 중 발견)
10. sirsoft-board User/CommentController — $request->except() mass-assignment
조치:
· 조회 스코프는 Repository where 절이 SSoT (컨트롤러 사후 비교 금지).
CommentRepository::find/findOrFail 에 `?int $postId = null` 선택 파라미터 추가.
PageVersionRepository::findForPage(pageId, id) 신설. 조회(getVersion)와
복원(restoreVersion) 두 형제 엔드포인트가 같은 SSoT 를 공유해 계약이 일치한다.
· 요청 본문 배열 항목의 상위 스코프는 FormRequest 에서 422
(BulkChangeOrderOptionStatusRequest 의 items.*.option_id → Rule::exists()->where('order_id')).
Service 에도 2차 방어 (bulkChangeStatusWithQuantity 의 $orderId).
· 쓰기 데이터는 $request->validated() 기준 — except()/all() 금지.
· 선택 파라미터로 둔 이유는 Interface breaking 회피. 전달 누락은
audit 룰 controller-unused-route-param(warn) 이 감시.
# axis 의미:
# 교차 접근 성립 여부를 가르는 축은 (리소스 종류) × (상위 스코프 일치 여부) 다.
# actor 는 결함 1(일반 사용자 노출)과 2~5(관리자 전용)의 노출 범위가 달라 분리한다.
# guest 는 라우트 미들웨어가 401 로 선차단하며 기존 권한 테스트가 보증하므로 축에서 제외.
axes:
resource: [board_comment_user, board_comment_admin, order_option, product_image, page_version]
parent_scope: [matching, mismatched]
actor: [member, admin]
exclusions:
- { resource: board_comment_user, actor: admin, reason: "일반 사용자 엔드포인트 — 관리자 경로는 board_comment_admin 이 담당" }
- { resource: board_comment_admin, actor: member, reason: "관리자 전용 엔드포인트 — 일반 회원은 미들웨어가 403 선차단" }
- { resource: order_option, actor: member, reason: "관리자 전용 엔드포인트" }
- { resource: product_image, actor: member, reason: "관리자 전용 엔드포인트" }
- { resource: page_version, actor: member, reason: "관리자 전용 엔드포인트" }
effects:
# 교차 스코프 차단
- cross_scope_read_returns_404
- cross_scope_write_rejected
- cross_scope_array_item_returns_422
# 대상 리소스 불변
- target_resource_unchanged_on_rejection
- sibling_resource_state_unchanged
# 정상 경로 회귀 방지
- matching_scope_still_succeeds
# 같은 리소스의 형제 엔드포인트가 동일 위반에 동일 상태코드를 낸다
- sibling_endpoints_share_scope_contract
# mass-assignment
- unvalidated_fields_not_persisted
sub_flows:
- id: mass_assignment_guard
description: 댓글 수정 시 UpdateCommentRequest 미정의 필드가 저장되지 않음
effects:
- unvalidated_fields_not_persisted
test_files:
# 결함 1 — 사용자 댓글 교차 경로 수정·삭제
- modules/_bundled/sirsoft-board/tests/Feature/User/CommentScopeTest.php
# 결함 10 — 사용자 댓글 수정 mass-assignment
- modules/_bundled/sirsoft-board/tests/Feature/User/CommentMassAssignmentTest.php
# 결함 2 — 관리자 댓글 4종 교차 경로
- modules/_bundled/sirsoft-board/tests/Feature/Admin/CommentScopeTest.php
# 결함 3 — 주문 옵션 일괄 상태 변경
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/OrderOptionScopeTest.php
# 결함 4 — 대표 이미지 설정
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/ProductImageScopeTest.php
# 결함 5 — 페이지 버전 상세 조회
- modules/_bundled/sirsoft-page/tests/Feature/Admin/PageVersionScopeTest.php
notes: |
E2E 미해당: 변경 범위가 백엔드 전용(Controller/Service/Repository/FormRequest)이며
레이아웃 JSON·컴포넌트·엔진 코드 변경이 없다. 교차 스코프 접근은 URL 을 직접 조작해야
재현되는 API 계약이라 브라우저 UI 로 도달할 수 있는 경로가 아니다.
→ audit 룰 frontend-change-requires-e2e 비적용.