중첩 라우트의 상위 리소스 ID 를 조회 스코프에 반영하지 않아 A 의 경로로 B 의 하위 리소스를 읽거나 변경할 수 있었다. 방어 위치를 Repository where 절(SSoT)로 통일하고 컨트롤러 사후 비교에 의존하지 않게 했다. 같은 리소스의 형제 엔드포인트가 서로 다른 강도로 검증하던 부분도 맞췄다. 계층 리소스는 자기 자신·자손을 부모로 지정하는 순환을 검증 계층에서 차단하고, 검증을 우회하는 경로(시더/훅/오염 데이터)를 위해 path 재귀에 방문 ID 가드를 뒀다. 설정값이 정하는 깊이 상한을 Service 가 리터럴로 재클램프하던 부분을 제거해 게시판 설정이 실제로 적용되게 했다. 쓰기 경로는 validated 기준으로 좁혀 FormRequest 미정의 필드가 fillable 로 새지 않게 했다. 기존 오염 데이터는 업그레이드 스텝이 정리한다(삭제 없이 최상위로 이동). 재발 방지로 audit 룰 3종을 추가하고, 규정 문서와 트러블슈팅 사례를 함께 갱신했다. 공개 이슈: gnuboard/g7
173 lines
5.4 KiB
PHP
173 lines
5.4 KiB
PHP
<?php
|
|
|
|
namespace Modules\Sirsoft\Board\Tests\Feature;
|
|
|
|
require_once __DIR__.'/../ModuleTestCase.php';
|
|
|
|
use App\Models\Role;
|
|
use App\Models\User;
|
|
use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
|
|
|
/**
|
|
* 대댓글 깊이 제한 테스트
|
|
*
|
|
* 검증 목적:
|
|
* - 저장되는 depth 가 항상 부모 depth + 1 (하드코딩 상한 없음)
|
|
* - 게시판 설정 max_comment_depth 를 넘는 답글은 422
|
|
* - max 를 낮게(3) / 높게(10) 설정한 두 경우 모두 설정값을 따른다
|
|
*
|
|
* @group board
|
|
* @group comment
|
|
* @group depth
|
|
*/
|
|
class CommentDepthLimitTest extends BoardTestCase
|
|
{
|
|
private User $memberUser;
|
|
|
|
protected function getTestBoardSlug(): string
|
|
{
|
|
return 'comment-depth-limit';
|
|
}
|
|
|
|
protected function getDefaultBoardAttributes(string $slug): array
|
|
{
|
|
return [
|
|
'slug' => $slug,
|
|
'name' => ['ko' => '댓글 깊이 제한 게시판', 'en' => 'Comment Depth Limit Board'],
|
|
'is_active' => true,
|
|
'use_comment' => true,
|
|
'max_comment_depth' => 10,
|
|
];
|
|
}
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->setGuestPermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']);
|
|
$this->grantUserRolePermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']);
|
|
|
|
$this->memberUser = User::factory()->create(['email' => 'comment-depth@test.com']);
|
|
|
|
$userRole = Role::where('identifier', 'user')->first();
|
|
if ($userRole) {
|
|
$this->memberUser->roles()->attach($userRole->id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* max_comment_depth=10 게시판에서 depth 는 1..10 까지 정확히 증가한다
|
|
*
|
|
* @scenario entity=board_comment, parent_target=unrelated
|
|
*
|
|
* @effects depth_equals_parent_depth_plus_one, depth_follows_board_setting_not_literal
|
|
*/
|
|
public function test_depth_increments_up_to_configured_max(): void
|
|
{
|
|
$postId = $this->createTestPost();
|
|
$parentId = $this->createTestComment($postId, ['depth' => 0]);
|
|
|
|
for ($expectedDepth = 1; $expectedDepth <= 10; $expectedDepth++) {
|
|
$response = $this->actingAs($this->memberUser)
|
|
->postJson($this->storeUrl($postId), [
|
|
'content' => "{$expectedDepth}단계 답글",
|
|
'parent_id' => $parentId,
|
|
]);
|
|
|
|
$response->assertStatus(201);
|
|
|
|
$parentId = (int) $response->json('data.id');
|
|
|
|
$this->assertDatabaseHas('board_comments', [
|
|
'id' => $parentId,
|
|
'depth' => $expectedDepth,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* max_comment_depth=10 게시판에서 11단계 답글은 422
|
|
*
|
|
* @scenario entity=board_comment, parent_target=unrelated
|
|
*
|
|
* @effects depth_beyond_board_max_rejected_422
|
|
*/
|
|
public function test_reply_beyond_configured_max_is_rejected(): void
|
|
{
|
|
$postId = $this->createTestPost();
|
|
|
|
// depth 10 인 부모 댓글을 직접 생성 → 11단계 시도
|
|
$parentId = $this->createTestComment($postId, ['depth' => 10]);
|
|
|
|
$this->actingAs($this->memberUser)
|
|
->postJson($this->storeUrl($postId), [
|
|
'content' => '11단계 답글 시도',
|
|
'parent_id' => $parentId,
|
|
])
|
|
->assertStatus(422)
|
|
->assertJsonValidationErrors(['parent_id']);
|
|
}
|
|
|
|
/**
|
|
* max_comment_depth=3 게시판에서 4단계 답글은 422
|
|
*
|
|
* @scenario entity=board_comment, parent_target=unrelated
|
|
*
|
|
* @effects depth_beyond_board_max_rejected_422, depth_follows_board_setting_not_literal
|
|
*/
|
|
public function test_reply_beyond_lowered_max_is_rejected(): void
|
|
{
|
|
$this->updateBoardSettings(['max_comment_depth' => 3]);
|
|
|
|
$postId = $this->createTestPost();
|
|
$parentId = $this->createTestComment($postId, ['depth' => 3]);
|
|
|
|
$this->actingAs($this->memberUser)
|
|
->postJson($this->storeUrl($postId), [
|
|
'content' => '4단계 답글 시도',
|
|
'parent_id' => $parentId,
|
|
])
|
|
->assertStatus(422)
|
|
->assertJsonValidationErrors(['parent_id']);
|
|
}
|
|
|
|
/**
|
|
* max_comment_depth=3 게시판에서 3단계까지는 정상 생성된다
|
|
*
|
|
* @scenario entity=board_comment, parent_target=unrelated
|
|
*
|
|
* @effects depth_equals_parent_depth_plus_one, depth_follows_board_setting_not_literal
|
|
*/
|
|
public function test_reply_within_lowered_max_is_allowed(): void
|
|
{
|
|
$this->updateBoardSettings(['max_comment_depth' => 3]);
|
|
|
|
$postId = $this->createTestPost();
|
|
$parentId = $this->createTestComment($postId, ['depth' => 2]);
|
|
|
|
$response = $this->actingAs($this->memberUser)
|
|
->postJson($this->storeUrl($postId), [
|
|
'content' => '3단계 답글',
|
|
'parent_id' => $parentId,
|
|
]);
|
|
|
|
$response->assertStatus(201);
|
|
|
|
$this->assertDatabaseHas('board_comments', [
|
|
'id' => (int) $response->json('data.id'),
|
|
'depth' => 3,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 댓글 생성 엔드포인트 URL 을 생성합니다.
|
|
*
|
|
* @param int $postId 게시글 ID
|
|
* @return string 댓글 생성 URL
|
|
*/
|
|
private function storeUrl(int $postId): string
|
|
{
|
|
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments";
|
|
}
|
|
}
|