Files
Gnuboard7/modules/_bundled/sirsoft-board/tests/Feature/SecretPostChildWriteGateTest.php
T
HeuJung 9cf9c3ff8c fix(core,board,ecommerce,payments,basic): KVE-2026 보안 게이트 6묶음 + 자격증명 전송로 정합
KISA 제보 취약점(KVE-2026-1914/1919/2019/2029/2041/2042/2043/2044)과
그 수정 과정에서 드러난 자격증명 전송로 결함을 함께 해소한다.

globalHeaders 는 데이터소스와 apiCall 핸들러에만 적용되는데, 코어 ApiClient 를
직접 부르는 경로들이 그 사실을 모른 채 게이트된 엔드포인트를 호출하고 있었다.
서버는 정당한 사용자를 거부하고 화면은 이미 버튼을 내준 뒤라, 예외도 로그도 없이
그 자리만 비는 형태로만 드러났다. 전송로 10축을 전수 열거해 6건을 고치고,
같은 실수가 반복되지 않도록 규정과 coverage 에 등재했다.

아웃바운드 프록시가 사이트 자기 자신으로 가는 내부 요청까지 가로채 저장이 수십 초씩
걸리던 문제도 함께 고쳤다. 실패가 폴백으로 삼켜져 화면에는 지연으로만 나타났다.
2026-09-06 00:42:29 +09:00

711 lines
24 KiB
PHP

<?php
namespace Modules\Sirsoft\Board\Tests\Feature;
// 테스트 베이스 클래스 수동 require (autoload 전에 로드 필요)
require_once __DIR__.'/../ModuleTestCase.php';
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Exceptions\PostNotCommentableException;
use Modules\Sirsoft\Board\Services\CommentService;
use Modules\Sirsoft\Board\Support\SecretContentGate;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 비밀글 하위 쓰기 게이트 테스트 (KVE-2026-2044 + 동형 전수).
*
* 읽기 경로는 이미 게이트되어 있었지만(비밀글 원문 마스킹), 하위 생성 경로는 부모
* 게시글의 열람 권한을 재적용하지 않았다. 그래서 원문을 볼 수 없는 사용자가
* 비밀글에 댓글·대댓글·답글을 달고 신고까지 남길 수 있었다.
*
* 대상 경로 6종:
* - C1 댓글 생성 (CommentService 최종 관문)
* - C2 댓글 생성 (요청 단계 규칙)
* - C3 대댓글 생성 (부모 게시글 비밀 여부)
* - C4 답글 생성 (부모 게시글 비밀 여부)
* - C5 게시글 신고
* - C6 댓글 신고
*
* 통과해야 하는 주체: 작성자 본인 · 게시판 관리자(manager) · 비밀글 읽기 권한자.
*
* @scenario secret-post-child-write-gate
*
* @effects secret_post_comment_blocked_for_outsider,
* secret_post_reply_comment_blocked_for_outsider,
* secret_post_reply_post_blocked_for_outsider,
* secret_post_report_blocked_for_outsider,
* secret_comment_report_blocked_for_outsider,
* secret_post_comment_allowed_for_author,
* secret_post_comment_allowed_for_manager,
* normal_post_comment_unaffected
*/
class SecretPostChildWriteGateTest extends BoardTestCase
{
private User $outsider;
private User $author;
private User $manager;
/**
* 테스트 게시판 slug
*/
protected function getTestBoardSlug(): string
{
return 'secret-child-write';
}
/**
* 기본 게시판 속성 (비밀글 허용 + 답글/댓글/신고 활성)
*
* @param string $slug 게시판 슬러그
* @return array<string, mixed> 게시판 속성
*/
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '비밀글 하위 쓰기', 'en' => 'Secret Child Write'],
'is_active' => true,
'secret_mode' => 'enabled',
'use_comment' => true,
'use_reply' => true,
'use_report' => true,
'max_comment_depth' => 3,
'max_reply_depth' => 3,
'blocked_keywords' => [],
];
}
/**
* 테스트 사전 준비를 수행합니다.
*/
protected function setUp(): void
{
parent::setUp();
$this->outsider = User::factory()->create();
$this->author = User::factory()->create();
$this->manager = User::factory()->create();
$this->grantDefaultGuestPermissions();
$this->grantUserRolePermissions([
'posts.read', 'posts.write', 'comments.read', 'comments.write',
]);
$userRole = Role::where('identifier', 'user')->first();
if ($userRole) {
foreach ([$this->outsider, $this->author, $this->manager] as $user) {
$user->roles()->syncWithoutDetaching([$userRole->id]);
}
}
$this->setupManagerRole();
$this->resetPermissionMiddlewareCache();
}
/**
* 게시판 관리자(manager) 역할을 만들어 manager 사용자에게 부여합니다.
*/
private function setupManagerRole(): void
{
$slug = $this->board->slug;
$permIds = [];
foreach (['manager', 'posts.read', 'posts.write', 'comments.read', 'comments.write'] as $action) {
$perm = Permission::firstOrCreate(
['identifier' => "sirsoft-board.{$slug}.{$action}"],
[
'name' => ['ko' => $action, 'en' => $action],
'slug' => "sirsoft-board.{$slug}.{$action}",
'type' => 'user',
]
);
$permIds[] = $perm->id;
}
$managerRole = Role::firstOrCreate(
['identifier' => "{$slug}-manager"],
['name' => ['ko' => '게시판 관리(사용자)', 'en' => 'Board Manager']]
);
$managerRole->permissions()->syncWithoutDetaching($permIds);
$this->manager->roles()->attach($managerRole->id);
}
/**
* 비밀 게시글을 만듭니다.
*
* @param array<string, mixed> $attributes 덮어쓸 속성
* @return int 생성된 게시글 ID
*/
private function createSecretPost(array $attributes = []): int
{
return $this->createTestPost(array_merge([
'user_id' => $this->author->id,
'author_name' => '작성자',
'title' => '비밀 게시글',
'content' => '비밀 원문입니다.',
'is_secret' => true,
'status' => 'published',
], $attributes));
}
private function commentsUrl(int $postId): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments";
}
private function postsUrl(): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts";
}
private function postReportUrl(int $postId): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/reports";
}
private function commentReportUrl(int $commentId): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/comments/{$commentId}/reports";
}
/**
* 지정 사용자로 요청 컨텍스트를 준비합니다.
*/
private function asUser(User $user): self
{
$this->resetPermissionMiddlewareCache();
$this->actingAs($user, 'sanctum');
return $this;
}
/**
* board_comments 총 건수를 반환합니다.
*/
private function commentCount(): int
{
return (int) DB::table('board_comments')->where('board_id', $this->board->id)->count();
}
/**
* board_posts 총 건수를 반환합니다.
*/
private function postCount(): int
{
return (int) DB::table('board_posts')->where('board_id', $this->board->id)->count();
}
/**
* board_reports 총 건수를 반환합니다.
*/
private function reportCount(): int
{
return (int) DB::table('boards_reports')->where('board_id', $this->board->id)->count();
}
// =========================================================================
// 차단 매트릭스 (무권한 사용자)
// =========================================================================
/**
* C1/C2 — 무권한 사용자는 비밀글에 댓글을 달 수 없다.
*
* @scenario actor=outsider, target=secret_post
*
* @effects secret_post_comment_blocked_for_outsider
*/
#[Test]
public function outsider_cannot_comment_on_secret_post(): void
{
$postId = $this->createSecretPost();
$before = $this->commentCount();
$response = $this->asUser($this->outsider)->postJson($this->commentsUrl($postId), [
'content' => '무권한 댓글',
]);
$this->assertContains($response->status(), [403, 422], '비밀글 댓글 작성은 차단되어야 한다');
$this->assertSame($before, $this->commentCount(), '차단된 요청은 댓글을 삽입하지 않아야 한다');
}
/**
* C3 — 무권한 사용자는 비밀글의 댓글에 대댓글을 달 수 없다.
*
* @scenario actor=outsider, target=secret_post_comment
*
* @effects secret_post_reply_comment_blocked_for_outsider
*/
#[Test]
public function outsider_cannot_reply_to_comment_on_secret_post(): void
{
$postId = $this->createSecretPost();
$parentCommentId = $this->createTestComment($postId, [
'user_id' => $this->author->id,
'content' => '작성자 댓글',
]);
$before = $this->commentCount();
$response = $this->asUser($this->outsider)->postJson($this->commentsUrl($postId), [
'content' => '무권한 대댓글',
'parent_id' => $parentCommentId,
]);
$this->assertContains($response->status(), [403, 422], '비밀글 대댓글 작성은 차단되어야 한다');
$this->assertSame($before, $this->commentCount());
}
/**
* C4 — 무권한 사용자는 비밀글에 답글(답변 게시글)을 달 수 없다.
*
* @scenario actor=outsider, target=secret_post
*
* @effects secret_post_reply_post_blocked_for_outsider
*/
#[Test]
public function outsider_cannot_write_reply_post_under_secret_post(): void
{
$postId = $this->createSecretPost();
$before = $this->postCount();
$response = $this->asUser($this->outsider)->postJson($this->postsUrl(), [
'title' => '무권한 답글',
'content' => '무권한 답글 내용입니다. 본문 최소 길이 검증을 넘기기 위한 충분한 길이입니다.',
'parent_id' => $postId,
]);
$this->assertContains($response->status(), [403, 422], '비밀글 답글 작성은 차단되어야 한다');
$this->assertSame($before, $this->postCount(), '차단된 요청은 게시글을 삽입하지 않아야 한다');
}
/**
* C5 — 무권한 사용자는 비밀글을 신고할 수 없다.
*
* @scenario actor=outsider, target=secret_post
*
* @effects secret_post_report_blocked_for_outsider
*/
#[Test]
public function outsider_cannot_report_secret_post(): void
{
$postId = $this->createSecretPost();
$before = $this->reportCount();
$response = $this->asUser($this->outsider)->postJson($this->postReportUrl($postId), [
'reason_type' => 'spam',
'reason_detail' => '무권한 신고',
]);
$this->assertContains($response->status(), [403, 422], '비밀글 신고는 차단되어야 한다');
$this->assertSame($before, $this->reportCount(), '차단된 요청은 신고를 남기지 않아야 한다');
}
/**
* C6 — 무권한 사용자는 비밀글에 달린 댓글을 신고할 수 없다.
*
* @scenario actor=outsider, target=secret_post_comment
*
* @effects secret_comment_report_blocked_for_outsider
*/
#[Test]
public function outsider_cannot_report_comment_on_secret_post(): void
{
$postId = $this->createSecretPost();
$commentId = $this->createTestComment($postId, [
'user_id' => $this->author->id,
'content' => '작성자 댓글',
]);
$before = $this->reportCount();
$response = $this->asUser($this->outsider)->postJson($this->commentReportUrl($commentId), [
'reason_type' => 'spam',
'reason_detail' => '무권한 신고',
]);
$this->assertContains($response->status(), [403, 422], '비밀글 댓글 신고는 차단되어야 한다');
$this->assertSame($before, $this->reportCount());
}
/**
* 요청 계층을 우회해도 서비스 최종 관문이 막는다.
*
* HTTP 경로에서는 요청 단계 규칙이 먼저 걸리므로 서비스 게이트는 **한 번도 실행되지
* 않는다**. 그래서 그 게이트가 살아 있는지는 HTTP 테스트로 증명되지 않는다.
* 훅·확장·콘솔처럼 FormRequest 를 지나지 않는 호출자가 실재하므로, 서비스를 직접
* 불러 최종 관문을 따로 고정한다 (이중 방어의 두 층을 각각 잠근다).
*
* @scenario actor=outsider, target=secret_post, layer=service
*
* @effects secret_post_comment_blocked_for_outsider
*/
#[Test]
public function the_service_layer_gate_blocks_even_when_the_request_layer_is_bypassed(): void
{
$postId = $this->createSecretPost();
$before = $this->commentCount();
$this->resetPermissionMiddlewareCache();
$this->actingAs($this->outsider, 'sanctum');
$service = app(CommentService::class);
$threw = false;
try {
$service->createComment($this->board->slug, [
'post_id' => $postId,
'content' => '요청 계층 우회 댓글',
'user_id' => $this->outsider->id,
]);
} catch (PostNotCommentableException $e) {
$threw = true;
$this->assertSame('sirsoft-board::messages.comment.post_secret', $e->getMessageKey());
}
$this->assertTrue($threw, '서비스 최종 관문이 비밀글 하위 쓰기를 막지 않았다');
$this->assertSame($before, $this->commentCount());
}
/**
* 서비스 최종 관문은 작성자에게는 열려 있다 (정상 흐름 불변).
*
* @scenario actor=author, target=secret_post, layer=service
*
* @effects secret_post_comment_allowed_for_author
*/
#[Test]
public function the_service_layer_gate_allows_the_author(): void
{
$postId = $this->createSecretPost();
$before = $this->commentCount();
$this->resetPermissionMiddlewareCache();
$this->actingAs($this->author, 'sanctum');
app(CommentService::class)->createComment($this->board->slug, [
'post_id' => $postId,
'content' => '작성자 댓글 (서비스 직접 호출)',
'user_id' => $this->author->id,
]);
$this->assertSame($before + 1, $this->commentCount());
}
// =========================================================================
// 통과 매트릭스 (정상 흐름 불변)
// =========================================================================
/**
* 작성자 본인은 자기 비밀글에 댓글을 달 수 있다.
*
* @scenario actor=author, target=secret_post
*
* @effects secret_post_comment_allowed_for_author
*/
#[Test]
public function author_can_comment_on_own_secret_post(): void
{
$postId = $this->createSecretPost();
$before = $this->commentCount();
$response = $this->asUser($this->author)->postJson($this->commentsUrl($postId), [
'content' => '작성자 댓글',
]);
$response->assertStatus(201);
$this->assertSame($before + 1, $this->commentCount());
}
/**
* 게시판 관리자는 비밀글에 댓글을 달 수 있다 (답변 시나리오).
*
* @scenario actor=manager, target=secret_post
*
* @effects secret_post_comment_allowed_for_manager
*/
#[Test]
public function manager_can_comment_on_secret_post(): void
{
$postId = $this->createSecretPost();
$before = $this->commentCount();
$response = $this->asUser($this->manager)->postJson($this->commentsUrl($postId), [
'content' => '관리자 답변',
]);
$response->assertStatus(201);
$this->assertSame($before + 1, $this->commentCount());
}
/**
* 비밀글이 아니면 종전과 같이 누구나 댓글을 달 수 있다 (회귀 방지).
*
* @scenario actor=outsider, target=public_post
*
* @effects normal_post_comment_unaffected
*/
#[Test]
public function outsider_can_still_comment_on_public_post(): void
{
$postId = $this->createTestPost([
'user_id' => $this->author->id,
'title' => '공개 게시글',
'content' => '공개 내용',
'is_secret' => false,
'status' => 'published',
]);
$before = $this->commentCount();
$response = $this->asUser($this->outsider)->postJson($this->commentsUrl($postId), [
'content' => '일반 댓글',
]);
$response->assertStatus(201);
$this->assertSame($before + 1, $this->commentCount());
}
/**
* 작성자 본인은 자기 비밀글에 답글을 달 수 있다 (정상 흐름 불변).
*
* @scenario actor=author, target=secret_post
*
* @effects secret_post_comment_allowed_for_author
*/
#[Test]
public function author_can_write_reply_post_under_own_secret_post(): void
{
$postId = $this->createSecretPost();
$before = $this->postCount();
$response = $this->asUser($this->author)->postJson($this->postsUrl(), [
'title' => '작성자 답글',
'content' => '작성자 답글 내용입니다. 본문 최소 길이 검증을 넘기기 위한 충분한 길이입니다.',
'parent_id' => $postId,
]);
$response->assertStatus(201);
$this->assertSame($before + 1, $this->postCount());
}
/**
* 비밀글이 아니면 종전과 같이 답글을 달 수 있다 (회귀 방지).
*
* @scenario actor=outsider, target=public_post
*
* @effects normal_post_comment_unaffected
*/
#[Test]
public function outsider_can_still_write_reply_post_under_public_post(): void
{
$postId = $this->createTestPost([
'user_id' => $this->author->id,
'title' => '공개 게시글',
'content' => '공개 내용',
'is_secret' => false,
'status' => 'published',
]);
$before = $this->postCount();
$response = $this->asUser($this->outsider)->postJson($this->postsUrl(), [
'title' => '일반 답글',
'content' => '일반 답글 내용입니다. 본문 최소 길이 검증을 넘기기 위한 충분한 길이입니다.',
'parent_id' => $postId,
]);
$response->assertStatus(201);
$this->assertSame($before + 1, $this->postCount());
}
/**
* 비밀글이 아니면 종전과 같이 신고할 수 있다 (회귀 방지).
*
* @scenario actor=outsider, target=public_post
*
* @effects normal_post_comment_unaffected
*/
#[Test]
public function outsider_can_still_report_public_post(): void
{
$postId = $this->createTestPost([
'user_id' => $this->author->id,
'title' => '공개 게시글',
'content' => '공개 내용',
'is_secret' => false,
'status' => 'published',
]);
$before = $this->reportCount();
$response = $this->asUser($this->outsider)->postJson($this->postReportUrl($postId), [
'reason_type' => 'spam',
'reason_detail' => '정상 신고',
]);
$response->assertStatus(201);
$this->assertSame($before + 1, $this->reportCount());
}
/**
* 비밀번호 검증 응답은 후속 요청에 쓸 열람 확인 토큰을 함께 돌려준다.
*
* @scenario actor=password_holder, target=secret_post
*
* @effects secret_view_token_issued_on_password_verify
*/
#[Test]
public function password_verification_issues_a_view_token(): void
{
$postId = $this->createSecretPost([
'user_id' => null,
'password' => bcrypt('guestPw123'),
]);
$response = $this->postJson($this->verifyPasswordUrl($postId), ['password' => 'guestPw123']);
$response->assertStatus(200);
$this->assertIsString(
$response->json('secret_view_token'),
'비밀번호 검증 응답에 열람 확인 토큰이 없습니다 — 후속 요청이 열람 사실을 증명할 수단이 사라집니다.'
);
}
/**
* 비밀번호로 원문을 연 사람은 댓글을 달 수 있다.
*
* 비회원이 쓴 비밀글의 작성자 본인은 user_id 가 없어 작성자 판정에 걸리지 않는다.
* 그에게 유일한 신원 증명이 비밀번호이므로, 이 경로가 막히면 자기 글에 댓글을
* 달 수 없다 — 화면은 원문이 열린 뒤 댓글창을 내주므로 사용자에게는 원인이 보이지 않는다.
*
* @scenario actor=password_holder, target=secret_post
*
* @effects secret_post_comment_allowed_for_password_holder
*/
#[Test]
public function password_holder_can_comment_on_secret_post(): void
{
$postId = $this->createSecretPost([
'user_id' => null,
'password' => bcrypt('guestPw123'),
]);
$token = $this->issueViewToken($postId, 'guestPw123');
$before = $this->commentCount();
$response = $this->asUser($this->outsider)
->withHeader(SecretContentGate::VIEW_TOKEN_HEADER, $token)
->postJson($this->commentsUrl($postId), [
'content' => '비밀번호로 열고 남기는 댓글입니다.',
]);
$response->assertStatus(201);
$this->assertSame($before + 1, $this->commentCount());
}
/**
* 비밀번호로 연 사람은 답글도 쓸 수 있다.
*
* @scenario actor=password_holder, target=secret_post
*
* @effects secret_post_reply_allowed_for_password_holder
*/
#[Test]
public function password_holder_can_write_reply_post_under_secret_post(): void
{
$postId = $this->createSecretPost([
'user_id' => null,
'password' => bcrypt('guestPw123'),
]);
$token = $this->issueViewToken($postId, 'guestPw123');
$before = $this->postCount();
$response = $this->asUser($this->outsider)
->withHeader(SecretContentGate::VIEW_TOKEN_HEADER, $token)
->postJson($this->postsUrl(), [
'title' => '답글 제목',
'content' => '비밀번호로 열고 남기는 답글 본문입니다.',
'parent_id' => $postId,
]);
$response->assertStatus(201);
$this->assertSame($before + 1, $this->postCount());
}
/**
* 토큰은 발급받은 게시글에만 통한다 — 다른 비밀글에는 쓰이지 않는다.
*
* 이 결속이 없으면 비밀번호를 아는 글 하나로 같은 게시판의 모든 비밀글이 열려,
* KVE-2026-2044 가 토큰이라는 다른 이름으로 되살아난다.
*
* @scenario actor=password_holder, target=other_secret_post
*
* @effects secret_view_token_is_bound_to_its_post
*/
#[Test]
public function view_token_does_not_work_on_another_post(): void
{
$openedPostId = $this->createSecretPost([
'user_id' => null,
'password' => bcrypt('guestPw123'),
]);
$otherPostId = $this->createSecretPost([
'user_id' => $this->author->id,
'title' => '남의 비밀글',
]);
$token = $this->issueViewToken($openedPostId, 'guestPw123');
$before = $this->commentCount();
$response = $this->asUser($this->outsider)
->withHeader(SecretContentGate::VIEW_TOKEN_HEADER, $token)
->postJson($this->commentsUrl($otherPostId), [
'content' => '남의 비밀글에 다는 댓글입니다.',
]);
$response->assertStatus(422);
$this->assertSame($before, $this->commentCount(), '다른 글의 토큰으로 댓글이 저장되었습니다.');
}
/**
* 위조·만료 토큰은 통하지 않는다.
*
* @scenario actor=outsider, target=secret_post
*
* @effects secret_view_token_rejects_forged_value
*/
#[Test]
public function forged_view_token_is_rejected(): void
{
$postId = $this->createSecretPost([
'user_id' => null,
'password' => bcrypt('guestPw123'),
]);
$before = $this->commentCount();
$response = $this->asUser($this->outsider)
->withHeader(SecretContentGate::VIEW_TOKEN_HEADER, str_repeat('a', 40))
->postJson($this->commentsUrl($postId), [
'content' => '위조 토큰으로 다는 댓글입니다.',
]);
$response->assertStatus(422);
$this->assertSame($before, $this->commentCount());
}
private function verifyPasswordUrl(int $postId): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/verify-password";
}
/**
* 비밀번호를 검증해 열람 확인 토큰을 발급받습니다 (실제 엔드포인트 경유).
*/
private function issueViewToken(int $postId, string $password): string
{
$response = $this->postJson($this->verifyPasswordUrl($postId), ['password' => $password]);
$response->assertStatus(200);
return (string) $response->json('secret_view_token');
}
}