fix(core,board,ecommerce,page): 중첩 리소스 스코프·계층 무결성 결함 12건 일괄 수정

중첩 라우트의 상위 리소스 ID 를 조회 스코프에 반영하지 않아 A 의 경로로 B 의
하위 리소스를 읽거나 변경할 수 있었다. 방어 위치를 Repository where 절(SSoT)로
통일하고 컨트롤러 사후 비교에 의존하지 않게 했다. 같은 리소스의 형제 엔드포인트가
서로 다른 강도로 검증하던 부분도 맞췄다.

계층 리소스는 자기 자신·자손을 부모로 지정하는 순환을 검증 계층에서 차단하고,
검증을 우회하는 경로(시더/훅/오염 데이터)를 위해 path 재귀에 방문 ID 가드를 뒀다.
설정값이 정하는 깊이 상한을 Service 가 리터럴로 재클램프하던 부분을 제거해
게시판 설정이 실제로 적용되게 했다.

쓰기 경로는 validated 기준으로 좁혀 FormRequest 미정의 필드가 fillable 로
새지 않게 했다. 기존 오염 데이터는 업그레이드 스텝이 정리한다(삭제 없이 최상위로 이동).

재발 방지로 audit 룰 3종을 추가하고, 규정 문서와 트러블슈팅 사례를 함께 갱신했다.

공개 이슈: gnuboard/g7
This commit is contained in:
HeuJung
2026-07-24 16:17:44 +09:00
parent 9dda22f8e4
commit abb40ad210
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 비적용.