Files
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

655 lines
24 KiB
PHP

<?php
namespace Modules\Sirsoft\Board\Tests\Feature\User;
// 테스트 베이스 클래스 수동 require (autoload 전에 로드 필요)
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 비회원 게시글 전체 흐름 테스트
*
* 비회원(Guest)의 게시글 생성, 수정, 삭제 흐름을 종합적으로 테스트합니다.
* - 비회원 글쓰기 권한 검증 (guest_permissions)
* - 비밀번호 해싱 및 검증
* - 비회원 게시글 수정/삭제 시 비밀번호 확인
* - 비회원 파일 업로드 권한 검증
*/
class PostGuestTest extends BoardTestCase
{
private User $boardAdminUser;
/**
* 테스트 게시판 slug
*/
protected function getTestBoardSlug(): string
{
return 'post-guest';
}
/**
* 기본 게시판 속성 (비회원 글쓰기 허용)
*/
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '비회원 테스트 게시판', 'en' => 'Guest Test Board'],
'is_active' => true,
'use_file_upload' => true,
'secret_mode' => 'enabled',
'blocked_keywords' => [],
];
}
protected function setUp(): void
{
parent::setUp();
// 게시판 설정이 올바른지 확인 (firstOrCreate는 기존 Board를 업데이트하지 않음)
$this->board->update([
'use_file_upload' => true,
'secret_mode' => 'enabled',
]);
$this->board->refresh();
// 게시판 관리자 생성
$this->createBoardAdminUser();
}
/**
* 게시판 관리자 사용자 생성
*
* User 페이지 라우트는 permission:user 타입 미들웨어를 사용하므로
* admin 타입 권한만으로는 접근 불가. user 타입 권한도 함께 부여합니다.
*/
private function createBoardAdminUser(): void
{
$this->boardAdminUser = User::factory()->create();
$slug = $this->board->slug;
// 게시판 관리자 역할 생성
$boardAdminRole = Role::firstOrCreate(
['identifier' => "{$slug}-board-admin"],
['name' => ['ko' => '게시판 관리자', 'en' => 'Board Admin']]
);
// user 타입 권한 부여 (라우트 미들웨어 통과용)
$userPermissions = ['posts.read', 'posts.write', 'manager'];
foreach ($userPermissions as $key) {
$perm = Permission::firstOrCreate(
['identifier' => "sirsoft-board.{$slug}.{$key}"],
['name' => ['ko' => $key, 'en' => $key], 'type' => 'user']
);
$boardAdminRole->permissions()->syncWithoutDetaching([$perm->id]);
}
// admin 타입 관리 권한 부여 (관리자 기능용)
$managePermission = Permission::firstOrCreate(
['identifier' => "sirsoft-board.{$slug}.admin.manage"],
['name' => ['ko' => '관리', 'en' => 'Manage'], 'type' => 'admin']
);
$boardAdminRole->permissions()->syncWithoutDetaching([$managePermission->id]);
$this->boardAdminUser->roles()->attach($boardAdminRole->id);
}
// ==========================================
// 비회원 게시글 생성 테스트
// ==========================================
/**
* 비회원이 권한이 있을 때 게시글을 생성할 수 있다
*/
public function test_guest_can_create_post_with_permission(): void
{
// Given: 비회원 글쓰기 권한 있음 (기본 설정)
// When: 비회원이 게시글 생성
$response = $this->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
'title' => '비회원 테스트 게시글',
'content' => '비회원이 작성한 내용입니다.',
'author_name' => '비회원작성자',
'password' => 'test1234',
]);
// Then: 201 성공
$response->assertStatus(201)
->assertJsonPath('success', true)
->assertJsonPath('data.title', '비회원 테스트 게시글')
->assertJsonPath('data.author.name', '비회원작성자');
// 비밀번호가 해시되어 저장되었는지 확인
$post = DB::table('board_posts')->where('board_id', $this->board->id)->orderBy('id', 'desc')->first();
$this->assertTrue(Hash::check('test1234', $post->password));
$this->assertNull($post->user_id);
}
/**
* 비회원 게시글 생성 시 비밀번호 필수
*/
public function test_guest_post_requires_password(): void
{
// When: 비밀번호 없이 게시글 생성 시도
$response = $this->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
'title' => '비회원 게시글',
'content' => '내용입니다. 최소 10자 이상.',
'author_name' => '비회원',
// password 누락
]);
// Then: 422 에러 (비밀번호 필수)
$response->assertStatus(422)
->assertJsonValidationErrors(['password']);
}
/**
* 비회원이 권한이 없을 때 게시글 생성 불가
*/
public function test_guest_cannot_create_post_without_permission(): void
{
// Given: 비회원 글쓰기 권한 제거 (posts.write 제외)
$this->setGuestPermissions(['posts.read']);
// When: 비회원이 게시글 생성 시도
$response = $this->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
'title' => '비회원 게시글',
'content' => '내용입니다. 최소 10자 이상.',
'author_name' => '비회원',
'password' => 'test1234',
]);
// Then: 401 에러 (PermissionMiddleware는 guest에 권한 없을 때 401 반환)
$response->assertStatus(401)
->assertJsonPath('success', false);
}
/**
* 비회원이 비밀글을 작성할 수 있다 (secret_mode=enabled)
*/
public function test_guest_can_create_secret_post(): void
{
// Given: secret_mode=enabled (기본 설정)
// When: 비회원이 비밀글 작성
$response = $this->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
'title' => '비회원 비밀글',
'content' => '비밀 내용입니다. 최소 10자 이상.',
'author_name' => '비회원',
'password' => 'secret123',
'is_secret' => true,
]);
// Then: 201 성공, is_secret=true
$response->assertStatus(201)
->assertJsonPath('success', true)
->assertJsonPath('data.is_secret', true);
}
// ==========================================
// 비회원 게시글 수정 테스트
// ==========================================
/**
* 비회원이 올바른 비밀번호로 본인 게시글을 수정할 수 있다
*/
public function test_guest_can_update_own_post_with_correct_password(): void
{
// Given: 비회원 게시글 생성
$password = 'original123';
$postId = $this->createTestPost([
'title' => '원본 제목',
'content' => '원본 내용입니다. 최소 10자 이상.',
'password' => Hash::make($password),
]);
// When: 올바른 비밀번호로 수정 요청
$response = $this->putJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}", [
'title' => '수정된 제목',
'content' => '수정된 내용입니다. 최소 10자 이상.',
'password' => $password,
]);
// Then: 성공
$response->assertStatus(200)
->assertJsonPath('success', true)
->assertJsonPath('data.title', '수정된 제목');
}
/**
* 비회원이 잘못된 비밀번호로 게시글 수정 불가
*/
public function test_guest_cannot_update_post_with_wrong_password(): void
{
// Given: 비회원 게시글 생성
$postId = $this->createTestPost([
'password' => Hash::make('correct_password'),
]);
// When: 잘못된 비밀번호로 수정 요청
$response = $this->putJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}", [
'title' => '수정된 제목',
'password' => 'wrong_password',
]);
// Then: 403 에러
$response->assertStatus(403)
->assertJsonPath('success', false);
}
/**
* 비회원이 비밀번호 없이 게시글 수정 불가
*/
public function test_guest_cannot_update_post_without_password(): void
{
// Given: 비회원 게시글 생성
$postId = $this->createTestPost([
'password' => Hash::make('secret'),
]);
// When: 비밀번호 없이 수정 요청
$response = $this->putJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}", [
'title' => '수정된 제목',
// password 누락
]);
// Then: 403 에러
$response->assertStatus(403)
->assertJsonPath('success', false);
}
// ==========================================
// 비회원 게시글 삭제 테스트
// ==========================================
/**
* 비회원이 올바른 비밀번호로 본인 게시글을 삭제할 수 있다
*/
public function test_guest_can_delete_own_post_with_correct_password(): void
{
// Given: 비회원 게시글 생성
$password = 'delete123';
$postId = $this->createTestPost([
'title' => '삭제할 게시글',
'password' => Hash::make($password),
]);
// When: 올바른 비밀번호로 삭제 요청
$response = $this->deleteJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}", [
'password' => $password,
]);
// Then: 성공
$response->assertStatus(200)
->assertJsonPath('success', true);
// 소프트 삭제 확인
$post = DB::table('board_posts')->where('board_id', $this->board->id)->where('id', $postId)->first();
$this->assertNotNull($post->deleted_at);
}
/**
* 비회원이 잘못된 비밀번호로 게시글 삭제 불가
*/
public function test_guest_cannot_delete_post_with_wrong_password(): void
{
// Given: 비회원 게시글 생성
$postId = $this->createTestPost([
'password' => Hash::make('correct_password'),
]);
// When: 잘못된 비밀번호로 삭제 요청
$response = $this->deleteJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}", [
'password' => 'wrong_password',
]);
// Then: 403 에러
$response->assertStatus(403)
->assertJsonPath('success', false);
}
/**
* 비회원이 비밀번호 없이 게시글 삭제 불가
*/
public function test_guest_cannot_delete_post_without_password(): void
{
// Given: 비회원 게시글 생성
$postId = $this->createTestPost([
'password' => Hash::make('secret'),
]);
// When: 비밀번호 없이 삭제 요청
$response = $this->deleteJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
// Then: 403 에러
$response->assertStatus(403)
->assertJsonPath('success', false);
}
// ==========================================
// 비회원 게시글 조회 테스트
// ==========================================
/**
* 비회원이 일반 게시글 목록을 조회할 수 있다
*/
public function test_guest_can_view_post_list(): void
{
// Given: 게시글 생성
$this->createTestPost(['title' => '첫 번째 게시글']);
$this->createTestPost(['title' => '두 번째 게시글']);
// When: 비회원이 목록 조회
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts");
// Then: 성공, 게시글 2개 반환
$response->assertStatus(200)
->assertJsonPath('success', true);
// 페이지네이션된 응답 구조인 경우 data.data를 확인
$data = $response->json('data');
if (isset($data['data'])) {
$this->assertCount(2, $data['data']);
} else {
$this->assertCount(2, $data);
}
}
/**
* 비회원이 일반 게시글 상세를 조회할 수 있다
*/
public function test_guest_can_view_normal_post_detail(): void
{
// Given: 일반 게시글 생성
$postId = $this->createTestPost([
'title' => '일반 게시글',
'content' => '일반 내용입니다.',
'is_secret' => false,
]);
// When: 비회원이 상세 조회
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
// Then: 성공, 내용 반환
$response->assertStatus(200)
->assertJsonPath('success', true)
->assertJsonPath('data.title', '일반 게시글')
->assertJsonPath('data.content', '일반 내용입니다.');
}
/**
* 비회원이 비밀글 상세 조회 시 content가 null로 반환 (비밀번호 없이)
*/
public function test_guest_cannot_view_secret_post_without_password(): void
{
// Given: 비밀글 생성
$postId = $this->createTestPost([
'title' => '비밀글',
'content' => '비밀 내용입니다.',
'is_secret' => true,
'password' => Hash::make('secret'),
]);
// When: 비회원이 비밀번호 없이 상세 조회
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
// Then: 200 반환하되 content가 null (PostResource에서 필터링)
$response->assertStatus(200)
->assertJsonPath('success', true)
->assertJsonPath('data.is_secret', true)
->assertJsonPath('data.content', null);
}
/**
* 비회원이 비밀글 상세 조회 시 비밀번호로 검증 가능
*/
public function test_guest_can_view_secret_post_with_password_verification(): void
{
// Given: 비밀글 생성
$password = 'viewsecret';
$postId = $this->createTestPost([
'title' => '비밀글',
'content' => '비밀 내용입니다.',
'is_secret' => true,
'password' => Hash::make($password),
]);
// When: 비밀번호 검증 API 호출
$response = $this->postJson(
"/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/verify-password",
['password' => $password]
);
// Then: 성공, 내용 반환
$response->assertStatus(200)
->assertJsonPath('success', true)
->assertJsonPath('data.content', '비밀 내용입니다.');
}
// ==========================================
// 게시판 관리자 권한 테스트
// ==========================================
/**
* 게시판 관리자가 비회원 게시글을 삭제할 수 있다 (비밀번호 없이)
*/
public function test_board_admin_can_delete_guest_post_without_password(): void
{
// Given: 비회원 게시글 생성
$postId = $this->createTestPost([
'title' => '비회원 게시글',
'password' => Hash::make('guest_password'),
]);
// When: 게시판 관리자가 비밀번호 없이 삭제
$response = $this->actingAs($this->boardAdminUser)
->deleteJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
// Then: 성공
$response->assertStatus(200)
->assertJsonPath('success', true);
}
/**
* 게시판 관리자가 비회원 게시글을 수정할 수 있다 (비밀번호 없이)
*/
public function test_board_admin_can_update_guest_post_without_password(): void
{
// Given: 비회원 게시글 생성
$postId = $this->createTestPost([
'title' => '비회원 게시글',
'password' => Hash::make('guest_password'),
]);
// When: 게시판 관리자가 비밀번호 없이 수정
$response = $this->actingAs($this->boardAdminUser)
->putJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}", [
'title' => '관리자가 수정한 제목',
]);
// Then: 성공
$response->assertStatus(200)
->assertJsonPath('success', true)
->assertJsonPath('data.title', '관리자가 수정한 제목');
}
/**
* 게시판 관리자가 비회원 비밀글 내용을 조회할 수 있다
*/
public function test_board_admin_can_view_guest_secret_post(): void
{
// Given: 비회원 비밀글 생성
$postId = $this->createTestPost([
'title' => '비회원 비밀글',
'content' => '비밀 내용입니다.',
'is_secret' => true,
'password' => Hash::make('secret'),
]);
// When: 게시판 관리자가 조회
$response = $this->actingAs($this->boardAdminUser)
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
// Then: 성공, 내용 반환
$response->assertStatus(200)
->assertJsonPath('success', true)
->assertJsonPath('data.content', '비밀 내용입니다.');
}
// ==========================================
// 비회원 파일 업로드 권한 테스트
// ==========================================
/**
* 비회원이 파일 업로드 권한 없을 때 파일 첨부 불가
*/
public function test_guest_cannot_upload_file_without_permission(): void
{
// Given: 비회원 파일 업로드 권한 없음 (attachments.upload 제외)
$this->updateBoardSettings(['use_file_upload' => true]);
$this->setGuestPermissions(['posts.read', 'posts.write']);
// 테스트용 파일 생성
$file = UploadedFile::fake()->create('test.pdf', 100);
// When: 비회원이 파일과 함께 게시글 생성
$response = $this->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
'title' => '파일 첨부 게시글',
'content' => '내용입니다. 최소 10자 이상.',
'author_name' => '비회원',
'password' => 'test1234',
'files' => [$file],
]);
// Then: 403 에러
$response->assertStatus(403)
->assertJsonPath('success', false);
}
/**
* 비회원이 파일 업로드 권한 있을 때 파일 첨부 가능
*/
public function test_guest_can_upload_file_with_permission(): void
{
// Given: 비회원 파일 업로드 권한 있음 (기본 설정)
// 테스트용 파일 생성
$file = UploadedFile::fake()->create('test.pdf', 100);
// When: 비회원이 파일과 함께 게시글 생성
$response = $this->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
'title' => '파일 첨부 게시글',
'content' => '내용입니다. 최소 10자 이상.',
'author_name' => '비회원',
'password' => 'test1234',
'files' => [$file],
]);
// Then: 성공
$response->assertStatus(201)
->assertJsonPath('success', true);
}
// ==========================================
// 전체 흐름 테스트 (E2E)
// ==========================================
/**
* 비회원 게시글 전체 흐름 테스트 (생성 → 조회 → 수정 → 삭제)
*/
public function test_guest_post_full_lifecycle(): void
{
$password = 'lifecycle123';
// 1. 생성
$createResponse = $this->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
'title' => '비회원 전체 흐름 테스트',
'content' => '초기 내용입니다. 최소 10자 이상.',
'author_name' => '테스트비회원',
'password' => $password,
]);
$createResponse->assertStatus(201);
$postId = $createResponse->json('data.id');
// 2. 조회 (일반 게시글이므로 내용 표시)
$showResponse = $this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
$showResponse->assertStatus(200)
->assertJsonPath('data.title', '비회원 전체 흐름 테스트');
// 3. 수정
$updateResponse = $this->putJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}", [
'title' => '수정된 전체 흐름 테스트',
'content' => '수정된 내용입니다.',
'password' => $password,
]);
$updateResponse->assertStatus(200)
->assertJsonPath('data.title', '수정된 전체 흐름 테스트');
// 4. 삭제
$deleteResponse = $this->deleteJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}", [
'password' => $password,
]);
$deleteResponse->assertStatus(200);
// 5. 삭제 후 조회 시 404 또는 삭제된 게시글 (목록에서 제외 확인)
$listResponse = $this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts");
$listResponse->assertStatus(200);
// 삭제된 게시글이 목록에 없는지 확인 (페이지네이션 응답: data.data)
$posts = $listResponse->json('data.data');
$this->assertIsArray($posts);
$postIds = array_column($posts, 'id');
$this->assertNotContains($postId, $postIds);
}
/**
* 회귀 — 비회원 게시글을 수정해도 저장된 비밀번호가 해시로 유지되어야 한다.
*
* 결함: 수정 요청의 `password` 는 본인 확인용 자격증명인데, 컨트롤러가 그것을 그대로
* 저장 데이터로 넘겨 기존 bcrypt 해시를 **평문으로 덮었다**. 그 결과 ① 비회원 게시글
* 비밀번호가 평문으로 DB 에 남고 ② 이후 비밀번호 검증이 "bcrypt 가 아니다" 예외로
* 끝나 본인이 자기 글을 수정·삭제할 수 없게 됐다.
*
* 같은 자리에서 댓글 수정 경로는 이미 password/verification_token 을 저장 데이터에서
* 제거하고 있었다 — 게시글 경로만 빠져 있던 비대칭이다.
*/
public function test_guest_post_password_stays_hashed_after_update(): void
{
$password = 'staysHashed123';
$createResponse = $this->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
'title' => '비밀번호 보존 확인',
'content' => '초기 내용입니다. 최소 10자 이상.',
'author_name' => '테스트비회원',
'password' => $password,
]);
$createResponse->assertStatus(201);
$postId = $createResponse->json('data.id');
$this->putJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}", [
'title' => '수정 후 비밀번호 보존 확인',
'content' => '수정된 내용입니다.',
'password' => $password,
])->assertStatus(200);
$stored = (string) DB::table('board_posts')->where('id', $postId)->value('password');
$this->assertNotSame($password, $stored, '수정 후 비밀번호가 평문으로 저장되었다');
$this->assertSame(
'bcrypt',
password_get_info($stored)['algoName'] ?? 'unknown',
'수정 후 저장값이 해시 형식이 아니다 — 이후 본인 확인이 예외로 끝난다'
);
$this->assertTrue(Hash::check($password, $stored), '수정 후 원래 비밀번호로 검증되지 않는다');
}
}