diff --git a/AGENTS.md b/AGENTS.md index 15674943..cea217a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -313,6 +313,20 @@ Icon 은 `` 글리프라 박스 크기가 곧 `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 데이터 접근 | 금지 | 올바른 사용 | diff --git a/CHANGELOG.md b/CHANGELOG.md index cb5ba2b9..7760347c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ - 플러그인을 GitHub 주소나 ZIP 파일로 설치할 때의 입력 안내가 내부 식별자로 표시되던 문제를 수정했습니다. 주소 형식이 잘못됐거나, ZIP이 아닌 파일을 올렸거나, 파일 용량을 초과했을 때 정상 문장으로 안내합니다. - 레이아웃 편집기 화면이 정상 동작하는데도 서버가 "페이지 없음"(404)으로 응답하던 문제를 수정했습니다. 화면은 그대로 보였지만 브라우저 캐싱과 서버 접속 기록에서 없는 페이지로 취급되던 상태입니다. - 주소 끝에 슬래시가 붙은 형태(예: `/admin/`)로 접속하면 화면이 "페이지 없음"(404)으로 뜨던 문제를 수정했습니다. 특히 로그인이 풀린 상태에서 이렇게 접속하면 로그인 화면으로 넘어가지 않고 404가 나타났습니다. 이제 끝 슬래시가 있어도 정상적으로 같은 페이지로 연결됩니다. +- 메뉴를 수정할 때 자기 자신의 하위 메뉴 아래로 옮길 수 있던 문제를 수정했습니다. 이렇게 옮기면 메뉴 트리가 끊어져 관리자 화면에서 되돌릴 수 없었습니다. 메뉴 순서 변경 화면과 동일한 기준이 적용됩니다. +- 플러그인 설정을 저장할 때, 그 플러그인이 제공하지 않는 항목까지 설정 파일에 함께 기록되던 문제를 수정했습니다. 이제 플러그인이 실제로 제공하는 설정 항목만 저장됩니다. ## [7.0.5] - 2026-07-16 diff --git a/app/Http/Controllers/Api/Admin/PluginSettingsController.php b/app/Http/Controllers/Api/Admin/PluginSettingsController.php index 7ab28df7..0bdae085 100644 --- a/app/Http/Controllers/Api/Admin/PluginSettingsController.php +++ b/app/Http/Controllers/Api/Admin/PluginSettingsController.php @@ -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); diff --git a/app/Http/Requests/Menu/UpdateMenuRequest.php b/app/Http/Requests/Menu/UpdateMenuRequest.php index 050dec58..46de4417 100644 --- a/app/Http/Requests/Menu/UpdateMenuRequest.php +++ b/app/Http/Requests/Menu/UpdateMenuRequest.php @@ -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> + * @return array|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); } diff --git a/app/Rules/NotSelfParent.php b/app/Rules/NotSelfParent.php index daf3ec65..54866803 100644 --- a/app/Rules/NotSelfParent.php +++ b/app/Rules/NotSelfParent.php @@ -8,7 +8,11 @@ use Illuminate\Contracts\Validation\ValidationRule; /** * 자기 자신을 부모로 설정할 수 없도록 검증하는 Custom Rule * - * 메뉴, 카테고리 등 계층 구조에서 순환 참조를 방지합니다. + * 메뉴, 카테고리 등 계층 구조에서 자기참조를 방지합니다. + * + * @deprecated 자기 자신만 검사하므로 자손을 부모로 지정하는 순환은 막지 못합니다. + * 신규 코드는 자손 전체를 검사하는 NotCircularParent 를 사용하세요. + * 확장이 훅으로 이 규칙을 소비할 수 있어 클래스는 제거하지 않습니다. */ class NotSelfParent implements ValidationRule { diff --git a/app/Services/MenuService.php b/app/Services/MenuService.php index 9cb05b05..53a2ce12 100644 --- a/app/Services/MenuService.php +++ b/app/Services/MenuService.php @@ -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 업데이트할 데이터 diff --git a/docs/backend/api/menus.md b/docs/backend/api/menus.md index 54bdc242..fa202660 100644 --- a/docs/backend/api/menus.md +++ b/docs/backend/api/menus.md @@ -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 diff --git a/docs/backend/api/plugins.md b/docs/backend/api/plugins.md index 745557a3..502ad42c 100644 --- a/docs/backend/api/plugins.md +++ b/docs/backend/api/plugins.md @@ -954,7 +954,7 @@ Authorization: Bearer {YOUR_TOKEN} -**설명** 플러그인의 설정 값을 저장합니다. `core.plugins.update` 권한이 필요합니다. 검증된 값을 우선 사용하되, PluginManager 에 등록되지 않아 검증 규칙이 없는 플러그인의 경우 요청 본문 전체(`all()`)를 저장합니다. 저장 실패 시 500 을 반환하고, 성공 시 갱신된 설정 값을 함께 반환합니다. +**설명** 플러그인의 설정 값을 저장합니다. `core.plugins.update` 권한이 필요합니다. 저장 대상은 검증을 통과한 값뿐이며, 검증 규칙은 해당 플러그인이 선언한 설정 스키마에서 생성됩니다. 스키마에 없는 필드를 요청에 포함해도 저장되지 않습니다. PluginManager 에 등록되지 않은 플러그인은 저장 자체가 수행되지 않아 500 을 반환합니다. 성공 시 갱신된 설정 값을 함께 반환합니다. ### GET /api/admin/plugins/{identifier}/settings/layout diff --git a/docs/backend/service-repository.md b/docs/backend/service-repository.md index d91fe459..3fdb5587 100644 --- a/docs/backend/service-repository.md +++ b/docs/backend/service-repository.md @@ -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 사용 diff --git a/docs/backend/validation.md b/docs/backend/validation.md index 0bbdede5..6ab0beee 100644 --- a/docs/backend/validation.md +++ b/docs/backend/validation.md @@ -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`에 한국어 메시지 추가 diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index f10ad566..9b263342 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -10,6 +10,7 @@ - 쇼핑몰 환경설정·카테고리 이미지·결제 설정 안내 문구 일본어 번역 추가 — 저장/조회 결과 안내가 일본어 로케일에서 자연스럽게 표시됩니다. - 검색 프리셋·브랜드·배송사·배송정책 목록 안내 문구 일본어 번역 추가. +- 카테고리를 자기 자신이나 하위 카테고리 아래로 옮기려 할 때, 주문에 속하지 않은 옵션을 변경하려 할 때, 상품에 속하지 않은 이미지를 대표로 지정하려 할 때 나오는 안내 문구 일본어 번역 추가. ### Fixed diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php index 57c91a80..088c0de3 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php @@ -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' => '現在のステータスでは購入確定できません。', diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php index 162d1880..29628fc0 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php @@ -411,6 +411,10 @@ return [ 'slug_unique' => '既に使用中のスラッグです。', 'slug_format' => 'スラッグは英文小文字で始まる必要があり、英文小文字/数字/ハイフン(-)のみ使用可能です。', 'parent_not_found' => '親カテゴリが見つかりません。', + 'parent_id' => [ + 'self' => '自分自身を親カテゴリに指定することはできません。', + 'circular' => '下位カテゴリを親カテゴリに指定することはできません。', + ], ], 'product_images' => [ 'file' => [ diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 74a1b7d3..4fedbc04 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -29,6 +29,11 @@ - 게시판 이름을 바꿨을 때 활동 로그의 변경 항목 이름이 비어 보이던 문제를 수정했습니다. - 게시판 환경설정 조회·저장·일괄 적용과 관리자 대시보드 조회 화면에서 결과 안내가 실제 문장 대신 내부 식별자로 표시되던 문제를 수정했습니다. - 환경설정을 여러 게시판에 일괄 적용할 때, 개별 저장 화면과 달리 글자 수·표시 시간 등의 값 범위 제한이 적용되지 않던 문제를 수정했습니다. 이제 일괄 적용도 개별 저장과 동일한 범위를 벗어난 값을 거부합니다. +- 다른 게시글의 주소로 댓글을 수정하거나 삭제할 수 있던 문제를 수정했습니다. 이제 댓글은 자기가 달린 게시글의 주소로만 수정·삭제됩니다. 관리자 화면의 댓글 수정·삭제·블라인드·복원도 동일하게 바로잡았습니다. (#80 @jiwonpapa 님께서 제보해주셨습니다.) +- 댓글 수정 시 본문에 함께 보낸 값으로 댓글이 다른 게시글로 옮겨지거나 작성자·작성 시각 정보가 바뀔 수 있던 문제를 수정했습니다. 수정으로 반영되는 항목은 내용·비밀글 여부·상태뿐입니다. +- 다른 게시글의 댓글을 답글의 부모로 지정할 수 있던 문제를 수정했습니다. 이렇게 만들어진 답글은 어느 게시글 목록에도 나타나지 않았습니다. (#80 @jiwonpapa 님께서 제보해주셨습니다.) +- 대댓글 깊이가 게시판 설정값을 넘어 계속 달리던 문제를 수정했습니다. 예전에는 6단계부터 들여쓰기가 더 들어가지 않고 설정한 최대 깊이도 지켜지지 않았습니다. 이제 설정한 깊이까지만 답글을 달 수 있고 화면의 단계 표시도 정확해집니다. (#80 @jiwonpapa 님께서 제보해주셨습니다.) +- 기존에 쌓인 댓글 중 깊이가 어긋났거나 다른 게시글의 댓글을 부모로 가진 것을 업데이트 시 자동으로 정리합니다. 잘못 연결된 댓글은 삭제하지 않고 최상위 댓글로 옮기며, 내용은 그대로 보존됩니다. ## [1.0.2] - 2026-07-14 diff --git a/modules/_bundled/sirsoft-board/docs/api/board.md b/modules/_bundled/sirsoft-board/docs/api/board.md index bf41d182..c8e44ddb 100644 --- a/modules/_bundled/sirsoft-board/docs/api/board.md +++ b/modules/_bundled/sirsoft-board/docs/api/board.md @@ -1525,7 +1525,7 @@ HTTP/1.1 200 -**설명** 게시판 관리자가 댓글 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} -**설명** 게시판 관리자가 댓글 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 -**설명** 게시판 관리자가 댓글을 블라인드 처리합니다. `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} -**설명** 게시판 관리자가 블라인드 처리된 댓글을 복원합니다. `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를 반환하며 해당 댓글은 변경되지 않습니다. diff --git a/modules/_bundled/sirsoft-board/docs/api/boards.md b/modules/_bundled/sirsoft-board/docs/api/boards.md index f592786f..34e75197 100644 --- a/modules/_bundled/sirsoft-board/docs/api/boards.md +++ b/modules/_bundled/sirsoft-board/docs/api/boards.md @@ -3648,7 +3648,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 -**설명** 게시글에 댓글(또는 대댓글)을 작성합니다. `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 -**설명** 댓글을 삭제합니다. `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: 비회원은 헤더 생 -**설명** 댓글을 수정합니다. `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 diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/CommentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/CommentController.php index 90153bc1..d62978f1 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/CommentController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/CommentController.php @@ -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', diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php index 20131018..3ee6d65e 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php @@ -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); diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/StoreCommentRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/StoreCommentRequest.php index 12e642c9..1db3b95c 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/StoreCommentRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/StoreCommentRequest.php @@ -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'], diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/VerifyCommentPasswordRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/VerifyCommentPasswordRequest.php new file mode 100644 index 00000000..7391c8e2 --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/VerifyCommentPasswordRequest.php @@ -0,0 +1,57 @@ + 필드별 검증 규칙 + */ + public function rules(): array + { + return [ + 'password' => ['required', 'string'], + ]; + } + + /** + * 검증 오류 메시지 커스터마이징 + * + * @return array 규칙별 다국어 메시지 + */ + public function messages(): array + { + return [ + 'password.required' => __('sirsoft-board::validation.comment.password.required'), + ]; + } + + /** + * 검증할 필드의 이름을 커스터마이징 + * + * @return array 필드별 표시명 + */ + public function attributes(): array + { + return [ + 'password' => __('sirsoft-board::validation.attributes.comment.password'), + ]; + } +} diff --git a/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php index 7628b880..95e980d4 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/CommentRepository.php @@ -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); diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/CommentRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/CommentRepositoryInterface.php index 898d6926..c350c479 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/CommentRepositoryInterface.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/CommentRepositoryInterface.php @@ -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 */ diff --git a/modules/_bundled/sirsoft-board/src/Rules/CommentValidationRule.php b/modules/_bundled/sirsoft-board/src/Rules/CommentValidationRule.php index 60328842..fe05fec0 100644 --- a/modules/_bundled/sirsoft-board/src/Rules/CommentValidationRule.php +++ b/modules/_bundled/sirsoft-board/src/Rules/CommentValidationRule.php @@ -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) { diff --git a/modules/_bundled/sirsoft-board/src/Services/CommentService.php b/modules/_bundled/sirsoft-board/src/Services/CommentService.php index 7525c1ed..aa9c358e 100644 --- a/modules/_bundled/sirsoft-board/src/Services/CommentService.php +++ b/modules/_bundled/sirsoft-board/src/Services/CommentService.php @@ -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) { diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/CommentScopeTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/CommentScopeTest.php new file mode 100644 index 00000000..d9723f23 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/CommentScopeTest.php @@ -0,0 +1,205 @@ + $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}"; + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/CommentDepthLimitTest.php b/modules/_bundled/sirsoft-board/tests/Feature/CommentDepthLimitTest.php new file mode 100644 index 00000000..cc85f128 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/CommentDepthLimitTest.php @@ -0,0 +1,172 @@ + $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"; + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/CommentHierarchyCleanupTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/CommentHierarchyCleanupTest.php new file mode 100644 index 00000000..fff2ade1 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/CommentHierarchyCleanupTest.php @@ -0,0 +1,160 @@ + $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 스냅샷 배열 + */ + 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(); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/CommentMassAssignmentTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/CommentMassAssignmentTest.php new file mode 100644 index 00000000..e35ac78d --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/CommentMassAssignmentTest.php @@ -0,0 +1,131 @@ + $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}"; + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/CommentParentIntegrityTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/CommentParentIntegrityTest.php new file mode 100644 index 00000000..4fd2fd24 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/CommentParentIntegrityTest.php @@ -0,0 +1,157 @@ + $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"; + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/CommentScopeTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/CommentScopeTest.php new file mode 100644 index 00000000..8738cb20 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/CommentScopeTest.php @@ -0,0 +1,168 @@ + $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}"; + } +} diff --git a/modules/_bundled/sirsoft-board/upgrades/Upgrade_1_0_3.php b/modules/_bundled/sirsoft-board/upgrades/Upgrade_1_0_3.php new file mode 100644 index 00000000..0fe5e304 --- /dev/null +++ b/modules/_bundled/sirsoft-board/upgrades/Upgrade_1_0_3.php @@ -0,0 +1,20 @@ +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) + )); + } +} diff --git a/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/02_RecalculateCommentDepth.php b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/02_RecalculateCommentDepth.php new file mode 100644 index 00000000..11d01d34 --- /dev/null +++ b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/02_RecalculateCommentDepth.php @@ -0,0 +1,205 @@ +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 $parentIds 부모 댓글 ID 목록 + * @param array $visited 방문 ID 집합 (참조 전달, 갱신됨) + * @return array 자식 댓글 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 $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 $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) + )); + } +} diff --git a/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/03_ReportCommentsExceedingMaxDepth.php b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/03_ReportCommentsExceedingMaxDepth.php new file mode 100644 index 00000000..eb9ee411 --- /dev/null +++ b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/03_ReportCommentsExceedingMaxDepth.php @@ -0,0 +1,88 @@ +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 댓글 없음'); + } + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 58c74da6..6d1070e1 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -29,6 +29,11 @@ - 검색 프리셋의 대상 화면 이름(상품 목록·주문 목록·고객 목록)이 표시되지 않던 문제를 수정했습니다. - 상품 문의 답변 상태와 배송정책이 없는 배송 그룹의 표시명이 비어 보이던 문제를 수정했습니다. - 상품 옵션 설정, 주문·배송정책·쿠폰·브랜드·카테고리 관리 화면의 아이콘이 의도한 크기보다 크거나 작게 보이던 문제와, 다크 모드에서 일부 버튼의 색 대비가 낮던 문제를 수정했습니다. +- 주문 상세에서 상품 옵션 상태를 일괄 변경할 때 다른 주문의 옵션이 함께 변경될 수 있던 문제를 수정했습니다. 이제 해당 주문에 속하지 않은 옵션이 포함되면 변경이 거절됩니다. +- 상품의 대표 이미지를 지정할 때 다른 상품의 이미지가 대표로 바뀔 수 있던 문제를 수정했습니다. +- 카테고리를 자기 자신이나 자기 하위 카테고리 아래로 옮길 때 화면이 응답하지 않고 해당 카테고리가 목록에서 사라지던 문제를 수정했습니다. 카테고리 수정과 순서 변경 양쪽 모두에 적용됩니다. +- 카테고리를 최상위로 옮길 때 상위 연결은 풀렸지만 계층 정보(경로·단계)가 이전 값 그대로 남아, 하위 카테고리의 위치가 어긋나 보이던 문제를 수정했습니다. +- 기존에 잘못 연결된 카테고리가 있으면 업데이트 시 자동으로 정리하고 계층 정보를 다시 계산합니다. 카테고리는 삭제하지 않고 최상위로 옮깁니다. ## [1.0.4] - 2026-07-16 diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md b/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md index 098a31c3..49b129c8 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md @@ -413,7 +413,7 @@ Content-Type: application/json -**설명** 카테고리 트리 전체의 배치(부모-자식 관계와 정렬 순서)를 일괄 갱신합니다. `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 -**설명** 기존 카테고리(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} diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md index 1109b561..0a875faa 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md @@ -1039,7 +1039,7 @@ Content-Type: application/json -**설명** 관리자가 특정 주문(`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 diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md index 8464fbfa..ac8b4adf 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md @@ -1708,7 +1708,7 @@ Authorization: Bearer {YOUR_TOKEN} -**설명** 상품의 대표(썸네일) 이미지를 지정합니다. `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} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Exceptions/CategoryOperationException.php b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/CategoryOperationException.php new file mode 100644 index 00000000..f38e761e --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/CategoryOperationException.php @@ -0,0 +1,25 @@ + $replace 메시지 치환 파라미터 + */ + public function __construct(string $messageKey, array $replace = []) + { + parent::__construct(__($messageKey, $replace)); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ResourceScopeMismatchException.php b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ResourceScopeMismatchException.php new file mode 100644 index 00000000..edb7fde2 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ResourceScopeMismatchException.php @@ -0,0 +1,25 @@ +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', diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/BulkChangeOrderOptionStatusRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/BulkChangeOrderOptionStatusRequest.php index c04e68be..67bb5e4e 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/BulkChangeOrderOptionStatusRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/BulkChangeOrderOptionStatusRequest.php @@ -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')], diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/CreateCategoryRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/CreateCategoryRequest.php index a606aa82..af63ba09 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/CreateCategoryRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/CreateCategoryRequest.php @@ -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 필드별 검증 규칙 */ 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 규칙별 다국어 에러 메시지 */ public function messages(): array { diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ReorderCategoriesRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ReorderCategoriesRequest.php index 435941d8..3a56bc4b 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ReorderCategoriesRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/ReorderCategoriesRequest.php @@ -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 필드별 검증 규칙 */ 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 규칙별 다국어 에러 메시지 */ public function messages(): array { diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/UpdateCategoryRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/UpdateCategoryRequest.php index 123c9b69..eef4c90a 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/UpdateCategoryRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/UpdateCategoryRequest.php @@ -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 필드별 검증 규칙 */ 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 규칙별 다국어 에러 메시지 */ public function messages(): array { @@ -85,4 +92,4 @@ class UpdateCategoryRequest extends FormRequest 'parent_id.exists' => __('sirsoft-ecommerce::validation.category.parent_not_found'), ]; } -} \ No newline at end of file +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Rules/NotCircularCategoryParent.php b/modules/_bundled/sirsoft-ecommerce/src/Rules/NotCircularCategoryParent.php new file mode 100644 index 00000000..839373a0 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Rules/NotCircularCategoryParent.php @@ -0,0 +1,117 @@ +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; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/CategoryService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/CategoryService.php index 73660568..0562ca8c 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/CategoryService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/CategoryService.php @@ -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 $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; } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderOptionService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderOptionService.php index 124485d4..1934fd6c 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderOptionService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderOptionService.php @@ -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 $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'); + } + } + } + /** * 옵션 금액 필드를 캡처합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductImageService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductImageService.php index 40c0e8a3..d99a1d8f 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductImageService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductImageService.php @@ -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]); diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/exceptions.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/exceptions.php index eb964613..b8d9beb6 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/exceptions.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/exceptions.php @@ -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.', diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php index be9b644a..49aea08b 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php @@ -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 diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/exceptions.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/exceptions.php index c243e6be..f33088d5 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/exceptions.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/exceptions.php @@ -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' => '현재 상태에서는 구매확정할 수 없습니다.', diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php index bfa55bcd..921470f6 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php @@ -454,6 +454,10 @@ return [ 'slug_unique' => '이미 사용중인 슬러그입니다.', 'slug_format' => '슬러그는 영문 소문자로 시작해야 하며, 영문 소문자/숫자/하이픈(-)만 사용 가능합니다.', 'parent_not_found' => '상위 카테고리를 찾을 수 없습니다.', + 'parent_id' => [ + 'self' => '자기 자신을 상위 카테고리로 지정할 수 없습니다.', + 'circular' => '하위 카테고리를 상위 카테고리로 지정할 수 없습니다.', + ], ], // 상품 이미지 검증 메시지 diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/CategoryCycleTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/CategoryCycleTest.php new file mode 100644 index 00000000..7496f4c7 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/CategoryCycleTest.php @@ -0,0 +1,250 @@ +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 요청 본문 + */ + 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}"; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/OrderOptionScopeTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/OrderOptionScopeTest.php new file mode 100644 index 00000000..6c904a50 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/OrderOptionScopeTest.php @@ -0,0 +1,160 @@ +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"; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/ProductImageScopeTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/ProductImageScopeTest.php new file mode 100644 index 00000000..9a33f1a6 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/ProductImageScopeTest.php @@ -0,0 +1,130 @@ +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"; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/CategoryCycleCleanupTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/CategoryCycleCleanupTest.php new file mode 100644 index 00000000..ec64661e --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/CategoryCycleCleanupTest.php @@ -0,0 +1,167 @@ +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> 스냅샷 배열 + */ + 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(); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageEarnDeltaAndGateTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageEarnDeltaAndGateTest.php index 48716329..db1f254a 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageEarnDeltaAndGateTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageEarnDeltaAndGateTest.php @@ -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 이면 즉시적립 정상'); diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceConfirmTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceConfirmTest.php index 790c9bab..c71d06e5 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceConfirmTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceConfirmTest.php @@ -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(); diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceStockRedeductTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceStockRedeductTest.php index 7f1c8d13..c70e08ca 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceStockRedeductTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceStockRedeductTest.php @@ -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), 플래그도 차감 유지 diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceTest.php index a27f7c87..468d6c09 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceTest.php @@ -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(); diff --git a/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_0_5.php b/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_0_5.php new file mode 100644 index 00000000..6d66bfad --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_0_5.php @@ -0,0 +1,19 @@ +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 $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 $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 $nodes 노드 맵 + * @return array 도달 가능 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 $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; + } +} diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index b1a0353c..a9af21bd 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -19,6 +19,8 @@ ### Fixed - 페이지 활동 로그에서 "제목"·"내용" 변경 항목의 이름이 비어 보이던 문제를 수정했습니다. +- 다른 페이지의 주소로 특정 페이지의 이전 버전 내용을 열람할 수 있던 문제를 수정했습니다. +- 다른 페이지에 속한 버전으로 복원을 시도하면 "복원에 실패했습니다" 라는 서버 오류가 나던 문제를 수정했습니다. 이제 열람과 동일하게 "찾을 수 없음" 으로 안내합니다. ## [1.0.1] - 2026-07-08 diff --git a/modules/_bundled/sirsoft-page/docs/api/pages.md b/modules/_bundled/sirsoft-page/docs/api/pages.md index 2b586642..ae52ac0c 100644 --- a/modules/_bundled/sirsoft-page/docs/api/pages.md +++ b/modules/_bundled/sirsoft-page/docs/api/pages.md @@ -795,7 +795,7 @@ Authorization: Bearer {YOUR_TOKEN} -**설명** 특정 버전의 상세 스냅샷을 조회합니다. `{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} -**설명** 페이지를 특정 버전 시점으로 복원합니다. `{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} diff --git a/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageController.php b/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageController.php index 27cfbb68..a49a123e 100644 --- a/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageController.php +++ b/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageController.php @@ -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); diff --git a/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageVersionRepositoryInterface.php b/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageVersionRepositoryInterface.php index b5c19c62..1e7a30eb 100644 --- a/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageVersionRepositoryInterface.php +++ b/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageVersionRepositoryInterface.php @@ -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; } diff --git a/modules/_bundled/sirsoft-page/src/Repositories/PageVersionRepository.php b/modules/_bundled/sirsoft-page/src/Repositories/PageVersionRepository.php index 2dc31c1e..313dfdf3 100644 --- a/modules/_bundled/sirsoft-page/src/Repositories/PageVersionRepository.php +++ b/modules/_bundled/sirsoft-page/src/Repositories/PageVersionRepository.php @@ -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(); + } } diff --git a/modules/_bundled/sirsoft-page/src/Services/PageService.php b/modules/_bundled/sirsoft-page/src/Services/PageService.php index 05de6c11..6a5127ba 100644 --- a/modules/_bundled/sirsoft-page/src/Services/PageService.php +++ b/modules/_bundled/sirsoft-page/src/Services/PageService.php @@ -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); } /** diff --git a/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageVersionScopeTest.php b/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageVersionScopeTest.php new file mode 100644 index 00000000..7c01626d --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageVersionScopeTest.php @@ -0,0 +1,207 @@ +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 $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}"; + } +} diff --git a/tests/Feature/Api/Admin/PluginSettingsControllerTest.php b/tests/Feature/Api/Admin/PluginSettingsControllerTest.php index 85c5db1a..5226bb51 100644 --- a/tests/Feature/Api/Admin/PluginSettingsControllerTest.php +++ b/tests/Feature/Api/Admin/PluginSettingsControllerTest.php @@ -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'); diff --git a/tests/Feature/Menu/MenuCircularParentTest.php b/tests/Feature/Menu/MenuCircularParentTest.php new file mode 100644 index 00000000..94a059b5 --- /dev/null +++ b/tests/Feature/Menu/MenuCircularParentTest.php @@ -0,0 +1,253 @@ +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 요청 본문 + */ + 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}"; + } +} diff --git a/tests/scenarios/hierarchy-cycle-prevention.yaml b/tests/scenarios/hierarchy-cycle-prevention.yaml new file mode 100644 index 00000000..54b2c98a --- /dev/null +++ b/tests/scenarios/hierarchy-cycle-prevention.yaml @@ -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 비적용. diff --git a/tests/scenarios/nested-resource-scope-integrity.yaml b/tests/scenarios/nested-resource-scope-integrity.yaml new file mode 100644 index 00000000..aa8241ca --- /dev/null +++ b/tests/scenarios/nested-resource-scope-integrity.yaml @@ -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 비적용.