fix(core,board,ecommerce,page): 중첩 리소스 스코프·계층 무결성 결함 12건 일괄 수정
중첩 라우트의 상위 리소스 ID 를 조회 스코프에 반영하지 않아 A 의 경로로 B 의 하위 리소스를 읽거나 변경할 수 있었다. 방어 위치를 Repository where 절(SSoT)로 통일하고 컨트롤러 사후 비교에 의존하지 않게 했다. 같은 리소스의 형제 엔드포인트가 서로 다른 강도로 검증하던 부분도 맞췄다. 계층 리소스는 자기 자신·자손을 부모로 지정하는 순환을 검증 계층에서 차단하고, 검증을 우회하는 경로(시더/훅/오염 데이터)를 위해 path 재귀에 방문 ID 가드를 뒀다. 설정값이 정하는 깊이 상한을 Service 가 리터럴로 재클램프하던 부분을 제거해 게시판 설정이 실제로 적용되게 했다. 쓰기 경로는 validated 기준으로 좁혀 FormRequest 미정의 필드가 fillable 로 새지 않게 했다. 기존 오염 데이터는 업그레이드 스텝이 정리한다(삭제 없이 최상위로 이동). 재발 방지로 audit 룰 3종을 추가하고, 규정 문서와 트러블슈팅 사례를 함께 갱신했다. 공개 이슈: gnuboard/g7
This commit is contained in:
1 parent
9dda22f8e4
commit
abb40ad210
74 files changed
+3880
-192
No files matched your search
@@ -308,9 +308,11 @@ class PluginSettingsControllerTest extends TestCase
|
||||
$this->createTestPlugin('sirsoft-daum_postcode');
|
||||
}
|
||||
|
||||
// 플러그인이 선언한 설정 스키마의 실제 키를 사용한다.
|
||||
// 스키마 밖의 키는 검증을 통과하지 못해 저장되지 않는다.
|
||||
$response = $this->authRequest()->putJson('/api/admin/plugins/sirsoft-daum_postcode/settings', [
|
||||
'animation' => true,
|
||||
'autoClose' => false,
|
||||
'display_mode' => 'popup',
|
||||
'popup_width' => 640,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
@@ -320,14 +322,51 @@ class PluginSettingsControllerTest extends TestCase
|
||||
$settingsPath = storage_path('app/plugins/sirsoft-daum_postcode/settings/setting.json');
|
||||
if (File::exists($settingsPath)) {
|
||||
$savedContent = json_decode(File::get($settingsPath), true);
|
||||
$this->assertTrue($savedContent['animation'] ?? false);
|
||||
$this->assertFalse($savedContent['autoClose'] ?? true);
|
||||
$this->assertSame('popup', $savedContent['display_mode'] ?? null);
|
||||
$this->assertSame(640, $savedContent['popup_width'] ?? null);
|
||||
}
|
||||
|
||||
// 정리
|
||||
$this->cleanupTestSettings('sirsoft-daum_postcode');
|
||||
}
|
||||
|
||||
/**
|
||||
* 스키마에 없는 필드는 저장되지 않음 (mass-assignment 방지)
|
||||
*
|
||||
* 과거 구현은 `validated()` 가 비면 `$request->all()` 로 폴백했다. 폴백의 명분이던
|
||||
* "PluginManager 미등록 플러그인" 은 `PluginSettingsService::save()` 가 이미 false 로
|
||||
* 차단하므로 도달할 수 없고, 실제로는 스키마 밖 키만 담긴 요청이 그대로 설정 파일에
|
||||
* 병합되는 경로로만 동작했다.
|
||||
*/
|
||||
public function test_update_ignores_fields_outside_settings_schema(): void
|
||||
{
|
||||
$pluginInstance = $this->pluginManager->getPlugin('sirsoft-daum_postcode');
|
||||
|
||||
if (! $pluginInstance) {
|
||||
$this->markTestSkipped('sirsoft-daum_postcode plugin not installed');
|
||||
}
|
||||
|
||||
$plugin = Plugin::where('identifier', 'sirsoft-daum_postcode')->first();
|
||||
if (! $plugin) {
|
||||
$this->createTestPlugin('sirsoft-daum_postcode');
|
||||
}
|
||||
|
||||
// 스키마에 정의되지 않은 키만 담아 전송 → validated() 가 빈 배열이 되는 조건
|
||||
$this->authRequest()->putJson('/api/admin/plugins/sirsoft-daum_postcode/settings', [
|
||||
'injected_field' => 'evil',
|
||||
'another_unknown' => ['nested' => true],
|
||||
]);
|
||||
|
||||
$settingsPath = storage_path('app/plugins/sirsoft-daum_postcode/settings/setting.json');
|
||||
if (File::exists($settingsPath)) {
|
||||
$savedContent = json_decode(File::get($settingsPath), true);
|
||||
$this->assertArrayNotHasKey('injected_field', $savedContent);
|
||||
$this->assertArrayNotHasKey('another_unknown', $savedContent);
|
||||
}
|
||||
|
||||
$this->cleanupTestSettings('sirsoft-daum_postcode');
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// layout() 테스트 - 레이아웃 조회
|
||||
// ========================================================================
|
||||
@@ -431,7 +470,7 @@ class PluginSettingsControllerTest extends TestCase
|
||||
}
|
||||
|
||||
$response = $this->authRequest()->putJson('/api/admin/plugins/sirsoft-daum_postcode/settings', [
|
||||
'animation' => true,
|
||||
'display_mode' => 'popup',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
@@ -442,8 +481,8 @@ class PluginSettingsControllerTest extends TestCase
|
||||
// 응답에 업데이트된 설정이 포함되어 있는지 확인
|
||||
$data = $response->json('data');
|
||||
$this->assertIsArray($data);
|
||||
$this->assertArrayHasKey('animation', $data);
|
||||
$this->assertTrue($data['animation']);
|
||||
$this->assertArrayHasKey('display_mode', $data);
|
||||
$this->assertSame('popup', $data['display_mode']);
|
||||
|
||||
// 정리
|
||||
$this->cleanupTestSettings('sirsoft-daum_postcode');
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Menu;
|
||||
|
||||
use App\Models\Menu;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 메뉴 수정 시 순환 참조 검증 테스트
|
||||
*
|
||||
* PUT /api/admin/menus/{menu}
|
||||
*
|
||||
* 검증 목적:
|
||||
* - 자기 자신을 부모로 지정할 수 없다 (기존 NotSelfParent 동작 유지)
|
||||
* - 자손(직계 자식 / 손자)을 부모로 지정할 수 없다 (신규 강화)
|
||||
* - 무관한 메뉴로의 이동은 허용된다
|
||||
*
|
||||
* 배경: 같은 리소스의 순서 변경 엔드포인트(UpdateMenuOrderRequest)는 이미
|
||||
* NotCircularParent 로 자손 전체를 검사하는데, 수정 엔드포인트는 자기 자신만
|
||||
* 검사해 두 엔드포인트의 검증 강도가 어긋나 있었다.
|
||||
*/
|
||||
class MenuCircularParentTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $adminUser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->adminUser = $this->createMenuAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* 자기 자신을 부모로 지정하면 422
|
||||
*
|
||||
* @scenario entity=core_menu, parent_target=self
|
||||
*
|
||||
* @effects self_parent_rejected_422, parent_unchanged_on_rejection
|
||||
*/
|
||||
public function test_cannot_set_self_as_parent(): void
|
||||
{
|
||||
$menu = $this->makeMenu('self-parent');
|
||||
|
||||
$this->actingAs($this->adminUser)
|
||||
->putJson($this->url($menu), $this->payload($menu, $menu->id))
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['parent_id']);
|
||||
|
||||
$this->assertNull($menu->fresh()->parent_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 직계 자식을 부모로 지정하면 422
|
||||
*
|
||||
* @scenario entity=core_menu, parent_target=descendant
|
||||
*
|
||||
* @effects descendant_parent_rejected_422, parent_unchanged_on_rejection
|
||||
*/
|
||||
public function test_cannot_set_direct_child_as_parent(): void
|
||||
{
|
||||
$root = $this->makeMenu('cycle-root');
|
||||
$child = $this->makeMenu('cycle-child', $root);
|
||||
|
||||
$this->actingAs($this->adminUser)
|
||||
->putJson($this->url($root), $this->payload($root, $child->id))
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['parent_id']);
|
||||
|
||||
$this->assertNull($root->fresh()->parent_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 손자를 부모로 지정하면 422
|
||||
*
|
||||
* @scenario entity=core_menu, parent_target=descendant
|
||||
*
|
||||
* @effects descendant_parent_rejected_422, parent_unchanged_on_rejection
|
||||
*/
|
||||
public function test_cannot_set_grandchild_as_parent(): void
|
||||
{
|
||||
$root = $this->makeMenu('deep-root');
|
||||
$child = $this->makeMenu('deep-child', $root);
|
||||
$grandChild = $this->makeMenu('deep-grandchild', $child);
|
||||
|
||||
$this->actingAs($this->adminUser)
|
||||
->putJson($this->url($root), $this->payload($root, $grandChild->id))
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['parent_id']);
|
||||
|
||||
$this->assertNull($root->fresh()->parent_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 무관한 메뉴로의 이동은 허용된다 (회귀 방지)
|
||||
*
|
||||
* @scenario entity=core_menu, parent_target=unrelated
|
||||
*
|
||||
* @effects unrelated_parent_move_succeeds
|
||||
*/
|
||||
public function test_can_move_under_unrelated_menu(): void
|
||||
{
|
||||
$target = $this->makeMenu('move-target');
|
||||
$moving = $this->makeMenu('move-source');
|
||||
|
||||
$this->actingAs($this->adminUser)
|
||||
->putJson($this->url($moving), $this->payload($moving, $target->id))
|
||||
->assertStatus(200);
|
||||
|
||||
$this->assertSame($target->id, $moving->fresh()->parent_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 최상위로 이동(parent_id = null)은 허용된다 (회귀 방지)
|
||||
*
|
||||
* @scenario entity=core_menu, parent_target=none
|
||||
*
|
||||
* @effects move_to_root_succeeds
|
||||
*/
|
||||
public function test_can_move_to_root(): void
|
||||
{
|
||||
$root = $this->makeMenu('to-root-parent');
|
||||
$child = $this->makeMenu('to-root-child', $root);
|
||||
|
||||
$this->actingAs($this->adminUser)
|
||||
->putJson($this->url($child), $this->payload($child, null))
|
||||
->assertStatus(200);
|
||||
|
||||
$this->assertNull($child->fresh()->parent_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정 / 순서 변경 두 엔드포인트가 동일한 순환 시나리오를 동일하게 거절한다
|
||||
*
|
||||
* 결함 8 의 본질은 "같은 리소스의 두 엔드포인트가 서로 다른 검증 강도를 갖는 것"이었다.
|
||||
* 수정 쪽만 검사하면 약한 쪽이 우회로가 되므로, 같은 자손 지정 시나리오를 양쪽에
|
||||
* 던져 두 응답이 모두 422 임을 대조한다. 한쪽 규칙이 약화되면 이 케이스가 깨진다.
|
||||
*
|
||||
* @scenario entity=core_menu, parent_target=descendant
|
||||
*
|
||||
* @effects descendant_parent_rejected_422, parent_unchanged_on_rejection
|
||||
*/
|
||||
public function test_update_and_reorder_endpoints_reject_same_descendant_move(): void
|
||||
{
|
||||
$root = $this->makeMenu('parity-root');
|
||||
$child = $this->makeMenu('parity-child', $root);
|
||||
|
||||
// ① 수정 엔드포인트 — root 를 자기 자식 아래로
|
||||
$this->actingAs($this->adminUser)
|
||||
->putJson($this->url($root), $this->payload($root, $child->id))
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['parent_id']);
|
||||
|
||||
// ② 순서 변경 엔드포인트 — 동일 시나리오
|
||||
$this->actingAs($this->adminUser)
|
||||
->putJson('/api/admin/menus/order', [
|
||||
'parent_menus' => [
|
||||
['id' => $root->id, 'order' => 1],
|
||||
],
|
||||
'moved_items' => [
|
||||
['id' => $root->id, 'new_parent_id' => $child->id],
|
||||
],
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['moved_items.0.new_parent_id']);
|
||||
|
||||
// 어느 경로로도 트리가 바뀌지 않아야 한다
|
||||
$this->assertNull($root->fresh()->parent_id);
|
||||
$this->assertSame($root->id, $child->fresh()->parent_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 수정 권한을 가진 관리자를 생성합니다.
|
||||
*
|
||||
* @return User 관리자 사용자
|
||||
*/
|
||||
private function createMenuAdmin(): User
|
||||
{
|
||||
$role = Role::create([
|
||||
'identifier' => 'admin',
|
||||
'name' => ['ko' => '관리자', 'en' => 'Administrator'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
foreach (['admin.access', 'core.menus.read', 'core.menus.update'] as $identifier) {
|
||||
$permission = Permission::firstOrCreate(
|
||||
['identifier' => $identifier],
|
||||
[
|
||||
'name' => ['ko' => $identifier, 'en' => $identifier],
|
||||
'type' => 'admin',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
]
|
||||
);
|
||||
$role->permissions()->syncWithoutDetaching([$permission->id]);
|
||||
}
|
||||
|
||||
$user = User::factory()->create();
|
||||
$user->roles()->attach($role->id);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트용 메뉴를 생성합니다.
|
||||
*
|
||||
* @param string $slug 메뉴 슬러그
|
||||
* @param Menu|null $parent 부모 메뉴
|
||||
* @return Menu 생성된 메뉴
|
||||
*/
|
||||
private function makeMenu(string $slug, ?Menu $parent = null): Menu
|
||||
{
|
||||
return Menu::create([
|
||||
'name' => ['ko' => $slug, 'en' => $slug],
|
||||
'slug' => $slug,
|
||||
'parent_id' => $parent?->id,
|
||||
'order' => 0,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 수정 요청 본문을 생성합니다.
|
||||
*
|
||||
* @param Menu $menu 대상 메뉴
|
||||
* @param int|null $parentId 지정할 부모 ID
|
||||
* @return array<string, mixed> 요청 본문
|
||||
*/
|
||||
private function payload(Menu $menu, ?int $parentId): array
|
||||
{
|
||||
return [
|
||||
'name' => ['ko' => $menu->slug, 'en' => $menu->slug],
|
||||
'slug' => $menu->slug,
|
||||
'parent_id' => $parentId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 수정 엔드포인트 URL 을 생성합니다.
|
||||
*
|
||||
* @param Menu $menu 대상 메뉴
|
||||
* @return string 엔드포인트 URL
|
||||
*/
|
||||
private function url(Menu $menu): string
|
||||
{
|
||||
return "/api/admin/menus/{$menu->id}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
feature: 계층 순환 참조 방지 및 깊이 제한
|
||||
|
||||
description: |
|
||||
이슈 #80 검수에서 확인된 "부모-자식 무결성 / 순환참조" 계열 3건과
|
||||
"설정을 무시하는 하드코딩" 1건.
|
||||
|
||||
라이브 재현 (https://g7_2.dev):
|
||||
· max_comment_depth=10 게시판에서 12단계까지 전부 201 생성, 저장 depth 는
|
||||
6단계 이후 5 로 고정 → marginLeft 5rem 고정으로 계층 시각 붕괴
|
||||
· 게시글 A 댓글을 게시글 B 댓글의 parent_id 로 지정 → 201, 생성된 댓글은
|
||||
B 목록에서 누락(고아)
|
||||
|
||||
대상 결함:
|
||||
6. sirsoft-board CommentValidationRule + CommentService — 부모 댓글의 post_id 미검증.
|
||||
Rule 만 고치면 Service 가 여전히 타 게시글 부모로 depth 계산 → 두 곳 동시 수정.
|
||||
7. sirsoft-ecommerce {Create,Update,Reorder}CategoryRequest — 자기참조·순환 검증 전무.
|
||||
사이클 생성 시 CategoryService::updateDescendantsPaths 재귀가 종료되지 않아
|
||||
요청이 실패하고 노드가 트리에서 사라져 관리자 UI 로 복구 불가.
|
||||
8. 코어 UpdateMenuRequest — NotSelfParent(자기 자신만) 사용.
|
||||
같은 리소스의 UpdateMenuOrderRequest 는 NotCircularParent(자손 전체) 사용 →
|
||||
동일 리소스 두 엔드포인트의 검증 강도 불일치.
|
||||
9. sirsoft-board CommentService — min($parent->depth + 1, 5) 하드코딩.
|
||||
설정값은 3중으로 모두 10 이라 depth 가 5 를 못 넘고, 그 결과
|
||||
CommentValidationRule 의 depth+1 > max 가 영원히 거짓 → 깊이 제한 완전 무력화.
|
||||
|
||||
조치:
|
||||
· 부모 무결성은 Rule(422, UX) + Service(훅·내부 호출 우회 차단) 이중 방어.
|
||||
· 카테고리는 모듈 로컬 Rule NotCircularCategoryParent 신설 —
|
||||
코어 NotCircularParent 는 App\Models\Menu 하드코딩이고, 일반화하면
|
||||
Category 의 path 머티리얼라이즈드 컬럼 최적화(쿼리 1회)를 표현할 수 없다.
|
||||
· 순서 변경(reorder) 엔드포인트도 동일 강도로 방어 — 약한 쪽이 우회로가 된다.
|
||||
· CategoryService::updateDescendantsPaths 에 방문 ID 가드 추가 —
|
||||
검증을 우회한 경로(시더/훅/기존 오염 데이터)에서도 무한 루프 대신 유한 실패 + 로그.
|
||||
· Service 의 리터럴 재클램프 제거 — 상한 검증은 Rule 단일 책임.
|
||||
|
||||
# axis 의미:
|
||||
# 순환 성립 여부를 가르는 축은 (엔티티) × (지정한 부모의 계보상 위치) 다.
|
||||
# parent_target 값:
|
||||
# self — 자기 자신을 부모로
|
||||
# descendant — 자기 자손(직계 자식·손자)을 부모로
|
||||
# foreign — 트리 밖 부모 (댓글 전용: 다른 게시글의 댓글)
|
||||
# unrelated — 계보상 무관한 노드를 부모로 (정상 이동)
|
||||
# none — 최상위로 이동 (parent_id = null, 정상)
|
||||
# 댓글은 부모를 옮기는 UI·API 경로가 없어 self/descendant/none 이 성립하지 않는다.
|
||||
# 댓글의 유일한 순환 유입 경로는 생성 시 parent_id 로 타 게시글 댓글을 지정하는 것(foreign)이다.
|
||||
axes:
|
||||
entity: [board_comment, ec_category, core_menu]
|
||||
parent_target: [self, descendant, foreign, unrelated, none]
|
||||
|
||||
exclusions:
|
||||
- { entity: board_comment, parent_target: self, reason: "댓글은 자기 자신을 부모로 지정하는 UI·API 경로가 없다" }
|
||||
- { entity: board_comment, parent_target: descendant, reason: "댓글은 부모를 옮기는 경로가 없어 자손 지정이 성립하지 않는다" }
|
||||
- { entity: board_comment, parent_target: none, reason: "최상위 댓글은 순환과 무관 — 기존 CRUD 시나리오가 커버" }
|
||||
- { entity: ec_category, parent_target: foreign, reason: "카테고리 트리는 단일 루트 집합이라 트리 밖 부모 개념이 없다" }
|
||||
- { entity: core_menu, parent_target: foreign, reason: "메뉴 트리는 단일 루트 집합이라 트리 밖 부모 개념이 없다" }
|
||||
|
||||
effects:
|
||||
# 순환 차단
|
||||
- self_parent_rejected_422
|
||||
- descendant_parent_rejected_422
|
||||
- foreign_parent_rejected_422
|
||||
- parent_unchanged_on_rejection
|
||||
# 정상 이동 회귀 방지
|
||||
- unrelated_parent_move_succeeds
|
||||
- move_to_root_succeeds
|
||||
- descendant_paths_rebuilt_on_valid_move
|
||||
# 깊이 (결함 9)
|
||||
- depth_equals_parent_depth_plus_one
|
||||
- depth_beyond_board_max_rejected_422
|
||||
- depth_follows_board_setting_not_literal
|
||||
# 재귀 종료성
|
||||
- path_recursion_terminates_on_polluted_data
|
||||
# 오염 데이터 복구 (업그레이드 스텝)
|
||||
- existing_cross_post_parent_detached_content_preserved
|
||||
- existing_clamped_depth_recalculated
|
||||
- existing_cycle_broken_and_paths_rebuilt
|
||||
- cleanup_is_idempotent
|
||||
|
||||
sub_flows:
|
||||
- id: comment_depth_boundary
|
||||
description: |
|
||||
댓글 깊이 경계. max_comment_depth 를 3(낮춤) / 10(기본) 두 설정에서 확인한다 —
|
||||
설정값을 실제로 따르는지가 결함 9 의 핵심이라 단일 설정 검증으로는 부족하다.
|
||||
effects:
|
||||
- depth_equals_parent_depth_plus_one
|
||||
- depth_beyond_board_max_rejected_422
|
||||
- depth_follows_board_setting_not_literal
|
||||
- id: reorder_endpoint_parity
|
||||
description: 카테고리 순서 변경 엔드포인트가 수정 엔드포인트와 동일한 순환 방지 강도를 가짐
|
||||
effects:
|
||||
- descendant_parent_rejected_422
|
||||
- unrelated_parent_move_succeeds
|
||||
- id: polluted_data_cleanup
|
||||
description: 업그레이드 스텝의 기존 오염 데이터 복구 (댓글 계층 / 카테고리 사이클)
|
||||
effects:
|
||||
- existing_cross_post_parent_detached_content_preserved
|
||||
- existing_clamped_depth_recalculated
|
||||
- existing_cycle_broken_and_paths_rebuilt
|
||||
- cleanup_is_idempotent
|
||||
- path_recursion_terminates_on_polluted_data
|
||||
|
||||
test_files:
|
||||
# 결함 6 — 부모 댓글 post_id 무결성 (HTTP 422 + Service 직접 호출)
|
||||
- modules/_bundled/sirsoft-board/tests/Feature/User/CommentParentIntegrityTest.php
|
||||
# 결함 9 — 깊이 제한이 게시판 설정을 따름 (max=3 / max=10)
|
||||
- modules/_bundled/sirsoft-board/tests/Feature/CommentDepthLimitTest.php
|
||||
# 결함 6·9 오염 데이터 정리 업그레이드 스텝
|
||||
- modules/_bundled/sirsoft-board/tests/Feature/Upgrade/CommentHierarchyCleanupTest.php
|
||||
# 결함 7 — 카테고리 순환 (update + reorder)
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/CategoryCycleTest.php
|
||||
# 결함 7 오염 데이터 정리 업그레이드 스텝
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/CategoryCycleCleanupTest.php
|
||||
# 결함 8 — 코어 메뉴 순환
|
||||
- tests/Feature/Menu/MenuCircularParentTest.php
|
||||
|
||||
notes: |
|
||||
E2E 미해당: 변경 범위가 백엔드 전용(FormRequest/Rule/Service/Repository)이며 레이아웃 JSON·
|
||||
컴포넌트·엔진 코드 변경이 없다. 순환 지정은 관리자 UI 의 선택 목록이 이미 자기 자신·자손을
|
||||
제외해 노출하므로 브라우저 조작으로 도달하는 경로가 아니고, 본 방어는 그 UI 를 우회한
|
||||
직접 요청을 대상으로 한다.
|
||||
→ audit 룰 frontend-change-requires-e2e 비적용.
|
||||
@@ -0,0 +1,89 @@
|
||||
feature: 중첩 리소스 상위 스코프 무결성
|
||||
|
||||
description: |
|
||||
이슈 #80 검수에서 확인된 "중첩 라우트의 상위 경로 파라미터 미검증" 계열 결함 5건 + mass-assignment 1건.
|
||||
라우트에 상위 리소스 ID 가 있는데 조회 스코프에 반영하지 않아, A 의 경로로 B 의
|
||||
하위 리소스를 읽거나 변경할 수 있었다.
|
||||
|
||||
라이브 재현 (https://g7_2.dev):
|
||||
· 게시글 B URL 로 게시글 A 댓글 PUT/DELETE → 둘 다 200
|
||||
· 수정 요청 본문의 post_id 가 그대로 저장되어 댓글이 다른 게시글로 이동
|
||||
|
||||
대상 결함:
|
||||
1. sirsoft-board User/CommentController — $postId 미사용 (일반 사용자 교차 수정·삭제)
|
||||
2. sirsoft-board Admin/CommentController — $postId 미사용 (update/destroy/blind/restore 4종)
|
||||
3. sirsoft-ecommerce Admin/OrderController — Order 바인딩 미사용 (타 주문 옵션 일괄 변경)
|
||||
4. sirsoft-ecommerce Admin/ProductController — $productId 미사용 (타 상품 대표 이미지 오염)
|
||||
5. sirsoft-page Admin/PageController — $id(page) 미사용 (스코프 밖 버전 열람)
|
||||
5-b. sirsoft-page PageService::restoreVersion — 형제 엔드포인트가 사후 비교라
|
||||
같은 위반에 500 (조회는 404). 계약 불일치 (Chrome MCP 시나리오 분해 중 발견)
|
||||
10. sirsoft-board User/CommentController — $request->except() mass-assignment
|
||||
|
||||
조치:
|
||||
· 조회 스코프는 Repository where 절이 SSoT (컨트롤러 사후 비교 금지).
|
||||
CommentRepository::find/findOrFail 에 `?int $postId = null` 선택 파라미터 추가.
|
||||
PageVersionRepository::findForPage(pageId, id) 신설. 조회(getVersion)와
|
||||
복원(restoreVersion) 두 형제 엔드포인트가 같은 SSoT 를 공유해 계약이 일치한다.
|
||||
· 요청 본문 배열 항목의 상위 스코프는 FormRequest 에서 422
|
||||
(BulkChangeOrderOptionStatusRequest 의 items.*.option_id → Rule::exists()->where('order_id')).
|
||||
Service 에도 2차 방어 (bulkChangeStatusWithQuantity 의 $orderId).
|
||||
· 쓰기 데이터는 $request->validated() 기준 — except()/all() 금지.
|
||||
· 선택 파라미터로 둔 이유는 Interface breaking 회피. 전달 누락은
|
||||
audit 룰 controller-unused-route-param(warn) 이 감시.
|
||||
|
||||
# axis 의미:
|
||||
# 교차 접근 성립 여부를 가르는 축은 (리소스 종류) × (상위 스코프 일치 여부) 다.
|
||||
# actor 는 결함 1(일반 사용자 노출)과 2~5(관리자 전용)의 노출 범위가 달라 분리한다.
|
||||
# guest 는 라우트 미들웨어가 401 로 선차단하며 기존 권한 테스트가 보증하므로 축에서 제외.
|
||||
axes:
|
||||
resource: [board_comment_user, board_comment_admin, order_option, product_image, page_version]
|
||||
parent_scope: [matching, mismatched]
|
||||
actor: [member, admin]
|
||||
|
||||
exclusions:
|
||||
- { resource: board_comment_user, actor: admin, reason: "일반 사용자 엔드포인트 — 관리자 경로는 board_comment_admin 이 담당" }
|
||||
- { resource: board_comment_admin, actor: member, reason: "관리자 전용 엔드포인트 — 일반 회원은 미들웨어가 403 선차단" }
|
||||
- { resource: order_option, actor: member, reason: "관리자 전용 엔드포인트" }
|
||||
- { resource: product_image, actor: member, reason: "관리자 전용 엔드포인트" }
|
||||
- { resource: page_version, actor: member, reason: "관리자 전용 엔드포인트" }
|
||||
|
||||
effects:
|
||||
# 교차 스코프 차단
|
||||
- cross_scope_read_returns_404
|
||||
- cross_scope_write_rejected
|
||||
- cross_scope_array_item_returns_422
|
||||
# 대상 리소스 불변
|
||||
- target_resource_unchanged_on_rejection
|
||||
- sibling_resource_state_unchanged
|
||||
# 정상 경로 회귀 방지
|
||||
- matching_scope_still_succeeds
|
||||
# 같은 리소스의 형제 엔드포인트가 동일 위반에 동일 상태코드를 낸다
|
||||
- sibling_endpoints_share_scope_contract
|
||||
# mass-assignment
|
||||
- unvalidated_fields_not_persisted
|
||||
|
||||
sub_flows:
|
||||
- id: mass_assignment_guard
|
||||
description: 댓글 수정 시 UpdateCommentRequest 미정의 필드가 저장되지 않음
|
||||
effects:
|
||||
- unvalidated_fields_not_persisted
|
||||
|
||||
test_files:
|
||||
# 결함 1 — 사용자 댓글 교차 경로 수정·삭제
|
||||
- modules/_bundled/sirsoft-board/tests/Feature/User/CommentScopeTest.php
|
||||
# 결함 10 — 사용자 댓글 수정 mass-assignment
|
||||
- modules/_bundled/sirsoft-board/tests/Feature/User/CommentMassAssignmentTest.php
|
||||
# 결함 2 — 관리자 댓글 4종 교차 경로
|
||||
- modules/_bundled/sirsoft-board/tests/Feature/Admin/CommentScopeTest.php
|
||||
# 결함 3 — 주문 옵션 일괄 상태 변경
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/OrderOptionScopeTest.php
|
||||
# 결함 4 — 대표 이미지 설정
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Admin/ProductImageScopeTest.php
|
||||
# 결함 5 — 페이지 버전 상세 조회
|
||||
- modules/_bundled/sirsoft-page/tests/Feature/Admin/PageVersionScopeTest.php
|
||||
|
||||
notes: |
|
||||
E2E 미해당: 변경 범위가 백엔드 전용(Controller/Service/Repository/FormRequest)이며
|
||||
레이아웃 JSON·컴포넌트·엔진 코드 변경이 없다. 교차 스코프 접근은 URL 을 직접 조작해야
|
||||
재현되는 API 계약이라 브라우저 UI 로 도달할 수 있는 경로가 아니다.
|
||||
→ audit 룰 frontend-change-requires-e2e 비적용.
|
||||
Reference in new issue
Block a user