위임 관리자(부관리자)가 권한·역할·표현식·비밀 콘텐츠 경계를 우회하던 결함군을 계층 대칭성 원칙으로 전건 차단한다. 약한 경로가 정상 응답을 내보내는 것이 유일한 증상이라, 게이트를 생산 지점 한 곳(SSoT)에 두고 같은 데이터를 내보내는 소비 경로 전부가 그 게이트를 경유하도록 맞췄다. - 등급 상한(rank ceiling): 슈퍼관리자 보호·역할/사용자 역할 배정(추가·제거 대칭)·일괄 상태변경·순서변경을 상세 경로와 동일 강도로 재적용. 가드는 DB 쓰기에 선행하여 거부 시 상태 불변. - 레이아웃 표현식: new Function/with 실행을 AST 화이트리스트 평가기로 교체. 비-문자열 computed 키 정규화(normalizeKey)·Object facade(리플렉션 static 제거)·legacy 접근자 차단. 저장측 검증·정적 검사와 3계층 동형. - secret 게이트: 비밀글의 댓글·첨부·문의 독립 경로 재적용, hash 파일서빙 소유권·비밀·발행 상태 검사 통일. - 신뢰 스크립트 호스트: 확장 선언 기반 + same-origin 브라우저 정규화를 런타임·저장측·정적검사 3층 동형화. - 회귀 감지: 단위·Feature·E2E·시나리오 매니페스트 전축 + audit 룰 4종 신설.
333 lines
12 KiB
PHP
333 lines
12 KiB
PHP
<?php
|
|
|
|
namespace Modules\Sirsoft\Board\Tests\Feature\User;
|
|
|
|
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
|
|
require_once __DIR__.'/../../ModuleTestCase.php';
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Modules\Sirsoft\Board\Enums\PostStatus;
|
|
use Modules\Sirsoft\Board\Models\Comment;
|
|
use Modules\Sirsoft\Board\Models\Post;
|
|
use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
|
|
|
/**
|
|
* 사용자 공개 게시글 API 테스트
|
|
*
|
|
* 타인의 공개 프로필에서 게시글 목록과 통계를 조회하는 API 테스트입니다.
|
|
* BoardTestCase를 통해 테스트 보드와 데이터 정리를 자동으로 처리합니다.
|
|
*
|
|
* @group board
|
|
* @group user-public-posts
|
|
*/
|
|
class UserPublicPostsApiTest extends BoardTestCase
|
|
{
|
|
/**
|
|
* 테스트용 사용자 (게시글 작성자)
|
|
*/
|
|
private User $targetUser;
|
|
|
|
/**
|
|
* 테스트 게시판 slug
|
|
*/
|
|
protected function getTestBoardSlug(): string
|
|
{
|
|
return 'user-public-posts';
|
|
}
|
|
|
|
/**
|
|
* 기본 게시판 속성
|
|
*/
|
|
protected function getDefaultBoardAttributes(string $slug): array
|
|
{
|
|
return [
|
|
'slug' => $slug,
|
|
'name' => ['ko' => '공개 게시글 테스트 게시판', 'en' => 'Public Posts Test Board'],
|
|
'is_active' => true,
|
|
'blocked_keywords' => [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 각 테스트 전 실행
|
|
*/
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
// 다른 테스트 클래스에서 user_id=1로 커밋된 posts가 user_id 기반 API에 영향을 미치므로 전체 정리
|
|
// (truncateUserTables() 후 새 user는 항상 id=1을 받으므로 타 테스트 posts와 충돌)
|
|
DB::table('board_posts')->delete();
|
|
DB::table('board_comments')->delete();
|
|
|
|
// 테스트 대상 사용자 생성 (parent::setUp()에서 users 테이블 초기화됨)
|
|
$this->targetUser = User::factory()->create();
|
|
}
|
|
|
|
/**
|
|
* 각 테스트 후 실행
|
|
*/
|
|
protected function tearDown(): void
|
|
{
|
|
parent::tearDown();
|
|
}
|
|
|
|
/**
|
|
* 비인증 사용자가 다른 사용자의 공개 게시글을 조회할 수 있다.
|
|
*/
|
|
public function test_guest_can_fetch_user_public_posts(): void
|
|
{
|
|
// Given: 사용자가 게시글을 작성했을 때
|
|
$this->createPost($this->board->slug, [
|
|
'user_id' => $this->targetUser->id,
|
|
'title' => '공개 게시글',
|
|
'status' => PostStatus::Published->value,
|
|
]);
|
|
|
|
// When: 비인증 사용자가 해당 사용자의 게시글을 조회하면
|
|
$response = $this->getJson("/api/modules/sirsoft-board/users/{$this->targetUser->uuid}/posts");
|
|
|
|
// Then: 성공 응답과 함께 게시글이 반환되어야 함
|
|
$response->assertOk();
|
|
|
|
$data = $response->json('data.data');
|
|
$this->assertIsArray($data);
|
|
$this->assertCount(1, $data);
|
|
$this->assertEquals('공개 게시글', $data[0]['title']);
|
|
}
|
|
|
|
/**
|
|
* 타인 프로필에서도 비밀글은 목록에 나오되 본문은 나가지 않는다.
|
|
*
|
|
* 이 목록은 본문 일부(content_plain)를 함께 싣는데 라우트가 optional.sanctum 이라
|
|
* **미인증 요청에 타인의 비밀글 본문이 그대로 나갔다**(KVE-2026-1914 형제).
|
|
* 행·제목은 게시판 목록에서 이미 같은 수준으로 보이므로(PostResource 의 목록 규칙)
|
|
* 행은 남기고 본문만 비운다 — UI 의 비밀글 배지도 그대로 동작한다.
|
|
*/
|
|
public function test_secret_posts_are_listed_without_content_for_other_viewers(): void
|
|
{
|
|
// Given: 공개글과 비밀글을 작성
|
|
$this->createPost($this->board->slug, [
|
|
'user_id' => $this->targetUser->id,
|
|
'title' => '공개 게시글',
|
|
'is_secret' => false,
|
|
'status' => PostStatus::Published->value,
|
|
]);
|
|
|
|
$this->createPost($this->board->slug, [
|
|
'user_id' => $this->targetUser->id,
|
|
'title' => '비밀 게시글',
|
|
'is_secret' => true,
|
|
'status' => PostStatus::Published->value,
|
|
]);
|
|
|
|
// When: 사용자 게시글을 조회하면
|
|
$response = $this->getJson("/api/modules/sirsoft-board/users/{$this->targetUser->uuid}/posts");
|
|
|
|
// Then: 두 건 모두 나오되 비밀글의 본문만 비어 있다
|
|
$response->assertOk();
|
|
|
|
$data = $response->json('data.data');
|
|
$this->assertCount(2, $data);
|
|
|
|
$secret = collect($data)->firstWhere('is_secret', true);
|
|
$public = collect($data)->firstWhere('is_secret', false);
|
|
|
|
$this->assertNotNull($secret, '비밀글도 목록에는 나와야 합니다(배지로 구분)');
|
|
$this->assertSame('', $secret['content_plain'], '비밀글 본문은 나가면 안 됩니다');
|
|
$this->assertNotSame('', $public['content_plain'], '공개글 본문은 그대로여야 합니다');
|
|
}
|
|
|
|
/**
|
|
* 타인 프로필에서도 블라인드 게시글은 목록에 나오되 본문은 나가지 않는다.
|
|
*
|
|
* 블라인드 글의 본문은 이 모듈의 다른 목록에서도 비워진다
|
|
* (PostResource::getMaskedContentPreviewForList) — 같은 규칙을 적용한다.
|
|
*/
|
|
public function test_blinded_posts_are_listed_without_content_for_other_viewers(): void
|
|
{
|
|
// Given: 공개된 게시글과 블라인드 처리된 게시글
|
|
$this->createPost($this->board->slug, [
|
|
'user_id' => $this->targetUser->id,
|
|
'title' => '발행된 게시글',
|
|
'status' => PostStatus::Published->value,
|
|
]);
|
|
|
|
$this->createPost($this->board->slug, [
|
|
'user_id' => $this->targetUser->id,
|
|
'title' => '블라인드 게시글',
|
|
'status' => PostStatus::Blinded->value,
|
|
]);
|
|
|
|
// When: 사용자 게시글을 조회하면
|
|
$response = $this->getJson("/api/modules/sirsoft-board/users/{$this->targetUser->uuid}/posts");
|
|
|
|
// Then: 두 건 모두 나오되 블라인드 글의 본문만 비어 있다
|
|
$response->assertOk();
|
|
|
|
$data = $response->json('data.data');
|
|
$this->assertCount(2, $data);
|
|
|
|
$blinded = collect($data)->firstWhere('status', 'blinded');
|
|
$this->assertNotNull($blinded, '블라인드 글도 목록에는 나와야 합니다(배지로 구분)');
|
|
$this->assertSame('', $blinded['content_plain'], '블라인드 글 본문은 나가면 안 됩니다');
|
|
}
|
|
|
|
/**
|
|
* 게시글/댓글 통계를 조회할 수 있다.
|
|
*/
|
|
public function test_can_fetch_user_posts_stats(): void
|
|
{
|
|
// Given: 게시글과 댓글 작성
|
|
$post = $this->createPost($this->board->slug, [
|
|
'user_id' => $this->targetUser->id,
|
|
'title' => '테스트 게시글',
|
|
'status' => PostStatus::Published->value,
|
|
]);
|
|
|
|
$this->createComment($this->board->slug, $post->id, [
|
|
'user_id' => $this->targetUser->id,
|
|
'content' => '내 댓글 1',
|
|
'status' => PostStatus::Published->value,
|
|
]);
|
|
|
|
$this->createComment($this->board->slug, $post->id, [
|
|
'user_id' => $this->targetUser->id,
|
|
'content' => '내 댓글 2',
|
|
'status' => PostStatus::Published->value,
|
|
]);
|
|
|
|
// When: 통계를 조회하면
|
|
$response = $this->getJson("/api/modules/sirsoft-board/users/{$this->targetUser->uuid}/posts/stats");
|
|
|
|
// Then: 통계 데이터가 반환되어야 함
|
|
$response->assertOk();
|
|
|
|
$data = $response->json('data');
|
|
$this->assertEquals(1, $data['posts_count']);
|
|
$this->assertEquals(2, $data['comments_count']);
|
|
}
|
|
|
|
/**
|
|
* 통계에서 블라인드 게시글/댓글은 제외된다.
|
|
*/
|
|
public function test_stats_exclude_blinded_items(): void
|
|
{
|
|
// Given: 발행된 게시글과 블라인드 처리된 게시글
|
|
$this->createPost($this->board->slug, [
|
|
'user_id' => $this->targetUser->id,
|
|
'title' => '발행된 게시글',
|
|
'status' => PostStatus::Published->value,
|
|
]);
|
|
|
|
$this->createPost($this->board->slug, [
|
|
'user_id' => $this->targetUser->id,
|
|
'title' => '블라인드 게시글',
|
|
'status' => PostStatus::Blinded->value,
|
|
]);
|
|
|
|
// When: 통계를 조회하면
|
|
$response = $this->getJson("/api/modules/sirsoft-board/users/{$this->targetUser->uuid}/posts/stats");
|
|
|
|
// Then: 발행된 게시글만 카운트되어야 함
|
|
$response->assertOk();
|
|
|
|
$data = $response->json('data');
|
|
$this->assertEquals(1, $data['posts_count']);
|
|
}
|
|
|
|
/**
|
|
* 페이지네이션이 정상 동작한다.
|
|
*/
|
|
public function test_pagination_works(): void
|
|
{
|
|
// Given: 25개의 게시글 작성
|
|
for ($i = 1; $i <= 25; $i++) {
|
|
$this->createPost($this->board->slug, [
|
|
'user_id' => $this->targetUser->id,
|
|
'title' => "게시글 {$i}",
|
|
'status' => PostStatus::Published->value,
|
|
]);
|
|
}
|
|
|
|
// When: 첫 페이지 조회
|
|
$response = $this->getJson("/api/modules/sirsoft-board/users/{$this->targetUser->uuid}/posts?page=1");
|
|
|
|
// Then: 20개의 항목과 페이지네이션 메타데이터가 있어야 함
|
|
$response->assertOk();
|
|
|
|
$this->assertCount(20, $response->json('data.data'));
|
|
$this->assertEquals(1, $response->json('data.current_page'));
|
|
$this->assertEquals(2, $response->json('data.last_page'));
|
|
$this->assertEquals(25, $response->json('data.total'));
|
|
}
|
|
|
|
/**
|
|
* per_page 파라미터로 페이지당 항목 수를 조절할 수 있다.
|
|
*/
|
|
public function test_per_page_parameter_works(): void
|
|
{
|
|
// Given: 10개의 게시글 작성
|
|
for ($i = 1; $i <= 10; $i++) {
|
|
$this->createPost($this->board->slug, [
|
|
'user_id' => $this->targetUser->id,
|
|
'title' => "게시글 {$i}",
|
|
'status' => PostStatus::Published->value,
|
|
]);
|
|
}
|
|
|
|
// When: per_page=5로 조회
|
|
$response = $this->getJson("/api/modules/sirsoft-board/users/{$this->targetUser->uuid}/posts?per_page=5");
|
|
|
|
// Then: 5개의 항목만 반환되어야 함
|
|
$response->assertOk();
|
|
$this->assertCount(5, $response->json('data.data'));
|
|
}
|
|
|
|
/**
|
|
* 존재하지 않는 사용자의 게시글 조회 시 빈 배열이 반환된다.
|
|
*/
|
|
public function test_returns_empty_for_non_existent_user(): void
|
|
{
|
|
// When: 존재하지 않는 사용자 UUID 로 조회 (라우트는 {user:uuid} 바인딩)
|
|
$nonExistentUuid = '00000000-0000-0000-0000-000000000000';
|
|
$response = $this->getJson("/api/modules/sirsoft-board/users/{$nonExistentUuid}/posts");
|
|
|
|
// Then: 모델 바인딩 실패로 404 또는 빈 배열 (optional.sanctum 경로)
|
|
// UUID 형식은 유효하지만 실제 사용자가 없으므로 ImplicitRouteBinding 이 404 반환
|
|
$response->assertStatus(404);
|
|
}
|
|
|
|
/**
|
|
* 헬퍼 메서드: 게시글 생성
|
|
*/
|
|
private function createPost(string $slug, array $attributes = []): Post
|
|
{
|
|
return Post::create(array_merge([
|
|
'board_id' => $this->board->id,
|
|
'title' => 'Test Post',
|
|
'content' => 'Test Content',
|
|
'user_id' => $this->targetUser->id,
|
|
'ip_address' => '127.0.0.1',
|
|
'is_notice' => false,
|
|
'is_secret' => false,
|
|
'status' => PostStatus::Published->value,
|
|
], $attributes));
|
|
}
|
|
|
|
/**
|
|
* 헬퍼 메서드: 댓글 생성
|
|
*/
|
|
private function createComment(string $slug, int $postId, array $attributes = []): Comment
|
|
{
|
|
return Comment::create(array_merge([
|
|
'board_id' => $this->board->id,
|
|
'post_id' => $postId,
|
|
'content' => 'Test Comment',
|
|
'user_id' => $this->targetUser->id,
|
|
'status' => PostStatus::Published->value,
|
|
], $attributes));
|
|
}
|
|
}
|