Files
Gnuboard7/modules/_bundled/sirsoft-board/tests/Feature/User/UserPublicPostsApiTest.php
T
HeuJung 71abdeaf60 fix(security): KVE-2026-1914/1915/1919 remediation 전건
위임 관리자(부관리자)가 권한·역할·표현식·비밀 콘텐츠 경계를 우회하던
결함군을 계층 대칭성 원칙으로 전건 차단한다. 약한 경로가 정상 응답을
내보내는 것이 유일한 증상이라, 게이트를 생산 지점 한 곳(SSoT)에 두고
같은 데이터를 내보내는 소비 경로 전부가 그 게이트를 경유하도록 맞췄다.

- 등급 상한(rank ceiling): 슈퍼관리자 보호·역할/사용자 역할 배정(추가·제거
 대칭)·일괄 상태변경·순서변경을 상세 경로와 동일 강도로 재적용. 가드는
 DB 쓰기에 선행하여 거부 시 상태 불변.
- 레이아웃 표현식: new Function/with 실행을 AST 화이트리스트 평가기로 교체.
 비-문자열 computed 키 정규화(normalizeKey)·Object facade(리플렉션 static
 제거)·legacy 접근자 차단. 저장측 검증·정적 검사와 3계층 동형.
- secret 게이트: 비밀글의 댓글·첨부·문의 독립 경로 재적용, hash 파일서빙
 소유권·비밀·발행 상태 검사 통일.
- 신뢰 스크립트 호스트: 확장 선언 기반 + same-origin 브라우저 정규화를
 런타임·저장측·정적검사 3층 동형화.
- 회귀 감지: 단위·Feature·E2E·시나리오 매니페스트 전축 + audit 룰 4종 신설.
2026-08-17 01:45:09 +09:00

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));
}
}