Files
HeuJung 7f8fb6870f fix(core,board,ecommerce): 조건부 검증 결함 패밀리 일괄 수정 및 실패 안내 노출
여러 화면에서 조건부(FormRequest) 검증이 우회되거나 과도하게 제한되던 결함을 한 계열로 묶어 수정한다.

- 검증 규칙 확장 필터 이름 10개를 나머지 92개와 동일 규칙으로 통일
- 설명 길이를 바이트가 아닌 글자 수로 계산(한글 오검출 해소)
- 비활성 언어팩 번역 보존한 채 수정 허용
- 알림 템플릿 제목 nullable 존중, 미지원 발송 수단 저장 차단
- 일정/실행 이력 목록 조회 검증 정합
- 게시판 첨부 미사용 시 빈 확장자 통과, new_display_hours/최소 길이 0 허용, 일괄 적용 override_values 범위 검증(SSoT)
- 이커머스 클레임 사유 조회 검증, 용어 정정
- 원문 i18n 키 노출 다수 해소, 저장/삭제 실패 시 사유 화면 노출
- 아이콘 크기/다크모드 대비 회귀 수정

번들 확장(모듈3·플러그인6·템플릿2·언어팩5) 버전 new-bump 및 CHANGELOG 반영, ja 언어팩 동기.
2026-07-23 10:54:17 +09:00

647 lines
23 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 Modules\Sirsoft\Board\Tests\BoardTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* PostResource 권한 API 응답 구조 테스트
*
* Admin/User 페이지별 권한 분기 및 소유권 플래그 테스트
*/
class PostResourcePermissionTest extends BoardTestCase
{
private User $adminUser;
private User $regularUser;
private User $postAuthor;
/**
* 테스트 게시판 slug
*/
protected function getTestBoardSlug(): string
{
return 'permission-test';
}
/**
* 기본 게시판 속성
*/
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '권한 테스트 게시판', 'en' => 'Permission Test Board'],
'is_active' => true,
'secret_mode' => 'disabled',
'blocked_keywords' => [],
];
}
protected function setUp(): void
{
parent::setUp();
// 테스트 사용자 생성
$this->createTestUsers();
// 권한 설정
$this->setupPermissions();
}
/**
* 테스트 사용자 생성
*/
private function createTestUsers(): void
{
$this->adminUser = User::factory()->create();
$this->regularUser = User::factory()->create();
$this->postAuthor = User::factory()->create();
}
/**
* 권한 설정
*/
private function setupPermissions(): void
{
$slug = $this->board->slug;
// Admin 권한 생성 및 할당 (라우트 미들웨어와 일치하는 식별자 사용)
$adminPermissions = [];
$adminPermissionDefs = [
'admin.posts.read' => ['ko' => '관리자 게시글 조회', 'en' => 'Admin Posts Read'],
'admin.posts.write' => ['ko' => '관리자 게시글 작성', 'en' => 'Admin Posts Write'],
'admin.manage' => ['ko' => '관리자 관리', 'en' => 'Admin Manage'],
];
foreach ($adminPermissionDefs as $action => $name) {
$permission = Permission::firstOrCreate(
['identifier' => "sirsoft-board.{$slug}.{$action}"],
[
'name' => $name,
'slug' => "sirsoft-board.{$slug}.{$action}",
'type' => 'admin',
]
);
$adminPermissions[] = $permission->id;
}
$adminRole = Role::firstOrCreate(
['identifier' => "{$slug}-admin"],
['name' => ['ko' => '게시판 관리자', 'en' => 'Board Admin']]
);
$adminRole->permissions()->syncWithoutDetaching($adminPermissions);
$this->adminUser->roles()->attach($adminRole->id);
// User 권한 생성 및 할당 (라우트 미들웨어와 일치하는 식별자 사용)
$userPermissions = [];
$userPermissionDefs = [
'posts.read' => ['ko' => '게시글 조회', 'en' => 'Read Posts'],
'posts.write' => ['ko' => '게시글 작성', 'en' => 'Write Posts'],
'comments.read' => ['ko' => '댓글 조회', 'en' => 'Read Comments'],
'comments.write' => ['ko' => '댓글 작성', 'en' => 'Write Comments'],
];
foreach ($userPermissionDefs as $action => $name) {
$permission = Permission::firstOrCreate(
['identifier' => "sirsoft-board.{$slug}.{$action}"],
[
'name' => $name,
'slug' => "sirsoft-board.{$slug}.{$action}",
'type' => 'user',
]
);
$userPermissions[] = $permission->id;
}
$userRole = Role::firstOrCreate(
['identifier' => "{$slug}-user"],
['name' => ['ko' => '일반 사용자', 'en' => 'Regular User']]
);
$userRole->permissions()->syncWithoutDetaching($userPermissions);
$this->regularUser->roles()->attach($userRole->id);
$this->postAuthor->roles()->attach($userRole->id);
// 관리자도 유저 라우트를 열려면 user 타입 권한이 필요하다.
// 유저 게시글 상세는 `permission:user,sirsoft-board.{slug}.posts.read` 로 가드되므로
// admin 타입 권한만으로는 403 이다 (실제 운영에서도 관리자는 두 역할을 함께 보유).
// can_access_admin 게이트는 이 라우트를 통과한 뒤 admin.manage 보유 여부로 판정된다.
$this->adminUser->roles()->attach($userRole->id);
}
// ==========================================
// Admin 페이지 권한 테스트
// ==========================================
/**
* Admin 페이지에서는 admin_* 권한만 반환
*/
#[Test]
public function admin_page_returns_admin_permissions_only(): void
{
// Given: 회원 게시글 생성
$postId = $this->createPost([
'user_id' => $this->postAuthor->id,
'title' => 'Test Post',
'content' => 'Test Content',
]);
// When: Admin API 호출
$response = $this->actingAs($this->adminUser, 'sanctum')
->getJson("/api/modules/sirsoft-board/admin/board/{$this->board->slug}/posts/{$postId}");
// Then: admin 컨텍스트의 can_* abilities 포함
$response->assertStatus(200);
$abilities = $response->json('data.abilities');
$this->assertArrayHasKey('can_read', $abilities);
$this->assertArrayHasKey('can_write', $abilities);
$this->assertArrayHasKey('can_manage', $abilities);
// Admin 컨텍스트이므로 admin.* 권한 기반으로 체크됨
// User 전용 키는 통합 can_* 키 사용으로 별도 존재하지 않음
$this->assertTrue($abilities['can_read']);
$this->assertTrue($abilities['can_write']);
$this->assertTrue($abilities['can_manage']);
}
/**
* Admin 페이지에서 User 권한 미반환
*/
#[Test]
public function admin_page_excludes_user_permissions(): void
{
// Given
$postId = $this->createPost([
'user_id' => $this->postAuthor->id,
'title' => 'Test Post',
'content' => 'Test Content',
]);
// When
$response = $this->actingAs($this->adminUser, 'sanctum')
->getJson("/api/modules/sirsoft-board/admin/board/{$this->board->slug}/posts/{$postId}");
// Then: abilities에 통합 can_* 키만 존재 (admin/user 구분 없음)
$response->assertStatus(200);
$abilities = $response->json('data.abilities');
// 통합 키 사용으로 old-style 키는 존재하지 않음
$this->assertArrayNotHasKey('posts_read', $abilities);
$this->assertArrayNotHasKey('posts_write', $abilities);
$this->assertArrayNotHasKey('comments_read', $abilities);
$this->assertArrayNotHasKey('comments_write', $abilities);
}
// ==========================================
// User 페이지 권한 테스트
// ==========================================
/**
* User 페이지에서는 posts_*, comments_* 권한만 반환
*/
#[Test]
public function user_page_returns_user_permissions_only(): void
{
// Given
$postId = $this->createPost([
'user_id' => $this->postAuthor->id,
'title' => 'Test Post',
'content' => 'Test Content',
]);
// When: User API 호출
$response = $this->actingAs($this->regularUser, 'sanctum')
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
// Then: User 컨텍스트의 can_* abilities 포함
$response->assertStatus(200);
$abilities = $response->json('data.abilities');
$this->assertArrayHasKey('can_read', $abilities);
$this->assertArrayHasKey('can_write', $abilities);
$this->assertArrayHasKey('can_read_secret', $abilities);
$this->assertArrayHasKey('can_read_comments', $abilities);
$this->assertArrayHasKey('can_write_comments', $abilities);
// User 컨텍스트이므로 user.* 권한 기반으로 체크됨
$this->assertTrue($abilities['can_read']);
$this->assertTrue($abilities['can_write']);
$this->assertTrue($abilities['can_read_comments']);
$this->assertTrue($abilities['can_write_comments']);
}
/**
* User 페이지에서 Admin 권한 미반환
*/
#[Test]
public function user_page_excludes_admin_permissions(): void
{
// Given
$postId = $this->createPost([
'user_id' => $this->postAuthor->id,
'title' => 'Test Post',
'content' => 'Test Content',
]);
// When
$response = $this->actingAs($this->regularUser, 'sanctum')
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
// Then: abilities에 통합 can_* 키만 존재 (admin/user 구분 없음)
$response->assertStatus(200);
$abilities = $response->json('data.abilities');
// 통합 키 사용으로 old-style admin 키는 존재하지 않음
$this->assertArrayNotHasKey('admin_posts_read', $abilities);
$this->assertArrayNotHasKey('admin_posts_write', $abilities);
$this->assertArrayNotHasKey('admin_manage', $abilities);
}
// ==========================================
// can_access_admin 게이트 테스트 (#450)
// 유저 화면에서 관리자 게시판 화면 진입 크로스링크 노출 여부
// ==========================================
/**
* User 상세: admin.manage(Admin 타입) 권한 보유자는 can_access_admin = true
*
* @scenario link=user_board_to_admin, permitted=true
*
* @effects board_admin_gate_true_shows_link
*/
#[Test]
public function user_detail_returns_can_access_admin_true_for_admin_permission_holder(): void
{
// Given: 게시글 + admin.manage 권한 보유 사용자
$postId = $this->createPost([
'user_id' => $this->postAuthor->id,
'title' => 'Test Post',
'content' => 'Test Content',
]);
// When: 관리 권한 보유자가 유저 라우트로 상세 조회
$response = $this->actingAs($this->adminUser, 'sanctum')
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
// Then: 유저 컨텍스트에도 can_access_admin 노출 + true
$response->assertStatus(200);
$abilities = $response->json('data.abilities');
$this->assertArrayHasKey('can_access_admin', $abilities);
$this->assertTrue($abilities['can_access_admin']);
}
/**
* User 상세: 일반 사용자(admin.manage 미보유)는 can_access_admin = false
*
* @scenario link=user_board_to_admin, permitted=false
*
* @effects board_admin_gate_false_hides_link
*/
#[Test]
public function user_detail_returns_can_access_admin_false_for_regular_user(): void
{
// Given
$postId = $this->createPost([
'user_id' => $this->postAuthor->id,
'title' => 'Test Post',
'content' => 'Test Content',
]);
// When: user 권한만 보유한 사용자가 유저 라우트로 상세 조회
$response = $this->actingAs($this->regularUser, 'sanctum')
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
// Then: can_access_admin 은 노출되나 false
$response->assertStatus(200);
$abilities = $response->json('data.abilities');
$this->assertArrayHasKey('can_access_admin', $abilities);
$this->assertFalse($abilities['can_access_admin']);
}
/**
* User 목록: 컬렉션 abilities 에도 can_access_admin 노출
*/
#[Test]
public function user_list_collection_exposes_can_access_admin(): void
{
// Given
$this->createPost([
'user_id' => $this->postAuthor->id,
'title' => 'List Post',
'content' => 'List Content',
]);
// When: 관리 권한 보유자가 유저 라우트로 목록 조회
$response = $this->actingAs($this->adminUser, 'sanctum')
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts");
// Then: 컬렉션 abilities 에 can_access_admin = true
$response->assertStatus(200);
$abilities = $response->json('data.abilities');
$this->assertArrayHasKey('can_access_admin', $abilities);
$this->assertTrue($abilities['can_access_admin']);
}
/**
* User 목록/상세 응답의 board 정보에 id 가 포함된다 (#450)
*
* "게시판 관리" 크로스링크가 /admin/boards/:id/edit 로 이동하려면 board id 필요.
*/
#[Test]
public function user_response_board_info_includes_id(): void
{
// Given
$postId = $this->createPost([
'user_id' => $this->postAuthor->id,
'title' => 'Board Id Post',
'content' => 'Board Id Content',
]);
// When: 목록
$listResponse = $this->actingAs($this->regularUser, 'sanctum')
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts");
// And: 상세
$detailResponse = $this->actingAs($this->regularUser, 'sanctum')
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
// Then: 목록/상세 board 정보 모두 실제 게시판 id 노출
$listResponse->assertStatus(200);
$this->assertSame($this->board->id, $listResponse->json('data.board.id'));
$detailResponse->assertStatus(200);
$this->assertSame($this->board->id, $detailResponse->json('data.board.id'));
}
// ==========================================
// 소유권 플래그 테스트
// ==========================================
/**
* 본인 글: is_author = true
*/
#[Test]
public function is_author_true_for_own_post(): void
{
// Given: 작성자가 작성한 게시글
$postId = $this->createPost([
'user_id' => $this->postAuthor->id,
'title' => 'My Post',
'content' => 'My Content',
]);
// When: 작성자 본인이 조회
$response = $this->actingAs($this->postAuthor, 'sanctum')
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
// Then
$response->assertStatus(200);
$this->assertTrue($response->json('data.is_author'));
$this->assertFalse($response->json('data.is_guest_post'));
}
/**
* 타인 글: is_author = false
*/
#[Test]
public function is_author_false_for_others_post(): void
{
// Given: 다른 사용자가 작성한 게시글
$postId = $this->createPost([
'user_id' => $this->postAuthor->id,
'title' => 'Others Post',
'content' => 'Others Content',
]);
// When: 다른 사용자가 조회
$response = $this->actingAs($this->regularUser, 'sanctum')
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
// Then
$response->assertStatus(200);
$this->assertFalse($response->json('data.is_author'));
$this->assertFalse($response->json('data.is_guest_post'));
}
/**
* 비회원 글: is_guest_post = true
*/
#[Test]
public function is_guest_post_true_for_guest_post(): void
{
// Given: 비회원이 작성한 게시글
$postId = $this->createPost([
'user_id' => null,
'author_name' => 'Guest Author',
'title' => 'Guest Post',
'content' => 'Guest Content',
]);
// When: 로그인 사용자가 조회
$response = $this->actingAs($this->regularUser, 'sanctum')
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
// Then
$response->assertStatus(200);
$this->assertFalse($response->json('data.is_author'));
$this->assertTrue($response->json('data.is_guest_post'));
}
/**
* 회원 글: is_guest_post = false
*/
#[Test]
public function is_guest_post_false_for_member_post(): void
{
// Given: 회원이 작성한 게시글
$postId = $this->createPost([
'user_id' => $this->postAuthor->id,
'title' => 'Member Post',
'content' => 'Member Content',
]);
// When
$response = $this->actingAs($this->regularUser, 'sanctum')
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
// Then
$response->assertStatus(200);
$this->assertFalse($response->json('data.is_guest_post'));
}
// ==========================================
// 비회원 권한 테스트
// ==========================================
/**
* 비회원: guest_permissions에 따라 권한 체크
*/
#[Test]
public function guest_permissions_respected_for_non_authenticated_user(): void
{
// Given: 비회원 게시글
$postId = $this->createPost([
'user_id' => null,
'author_name' => 'Guest',
'title' => 'Public Post',
'content' => 'Public Content',
]);
// When: 비로그인 상태로 조회
$response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
// Then: grantDefaultGuestPermissions()에서 posts.read, posts.write, attachments.upload 부여
$response->assertStatus(200);
$abilities = $response->json('data.abilities');
$this->assertTrue($abilities['can_read']); // guest에 posts.read 있음
$this->assertTrue($abilities['can_write']); // guest에 posts.write 있음
$this->assertFalse($abilities['can_read_comments']); // guest에 comments.read 없음
$this->assertFalse($abilities['can_write_comments']); // guest에 comments.write 없음
}
/**
* 로그인 사용자: Gate로 권한 체크
*/
#[Test]
public function authenticated_user_permissions_check_via_gate(): void
{
// Given
$postId = $this->createPost([
'user_id' => $this->postAuthor->id,
'title' => 'Test Post',
'content' => 'Test Content',
]);
// When: 로그인 사용자 조회
$response = $this->actingAs($this->regularUser, 'sanctum')
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
// Then: Role-Permission으로 체크
$response->assertStatus(200);
$abilities = $response->json('data.abilities');
// regularUser는 posts.read, posts.write, comments.read, comments.write 권한 보유
$this->assertTrue($abilities['can_read']);
$this->assertTrue($abilities['can_write']);
$this->assertTrue($abilities['can_read_comments']);
$this->assertTrue($abilities['can_write_comments']);
}
// ==========================================
// 답글 섹션 삭제 게시글 필터링 테스트
// ==========================================
/**
* 일반 사용자는 답글 섹션에서 삭제된 답글을 볼 수 없다
*/
#[Test]
public function regular_user_cannot_see_deleted_reply_in_post_detail(): void
{
// Given: 원글 생성
$parentId = $this->createTestPost([
'user_id' => $this->postAuthor->id,
'title' => '원글',
'content' => '원글 내용',
]);
// 정상 답글 생성
$normalReplyId = $this->createTestPost([
'user_id' => $this->postAuthor->id,
'parent_id' => $parentId,
'title' => '정상 답글',
'content' => '정상 답글 내용',
'status' => 'published',
]);
// 삭제된 답글 생성 (status='deleted', deleted_at 설정)
$this->createTestPost([
'user_id' => $this->postAuthor->id,
'parent_id' => $parentId,
'title' => '삭제된 답글',
'content' => '삭제된 답글 내용',
'status' => 'deleted',
'deleted_at' => now(),
]);
// When: 일반 사용자가 원글 상세 조회
$response = $this->actingAs($this->regularUser, 'sanctum')
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$parentId}");
// Then: replies에 삭제된 답글이 포함되지 않음
$response->assertStatus(200);
$replies = $response->json('data.replies');
$this->assertCount(1, $replies);
$this->assertEquals($normalReplyId, $replies[0]['id']);
}
/**
* 관리자는 답글 섹션에서 삭제된 답글을 볼 수 있다
*/
#[Test]
public function admin_user_can_see_deleted_reply_in_post_detail(): void
{
// Given: 원글 생성
$parentId = $this->createTestPost([
'user_id' => $this->postAuthor->id,
'title' => '원글',
'content' => '원글 내용',
]);
// 정상 답글 생성
$this->createTestPost([
'user_id' => $this->postAuthor->id,
'parent_id' => $parentId,
'title' => '정상 답글',
'content' => '정상 답글 내용',
'status' => 'published',
]);
// 삭제된 답글 생성
$this->createTestPost([
'user_id' => $this->postAuthor->id,
'parent_id' => $parentId,
'title' => '삭제된 답글',
'content' => '삭제된 답글 내용',
'status' => 'deleted',
'deleted_at' => now(),
]);
// When: 관리자가 Admin API로 원글 상세 조회
$response = $this->actingAs($this->adminUser, 'sanctum')
->getJson("/api/modules/sirsoft-board/admin/board/{$this->board->slug}/posts/{$parentId}");
// Then: replies에 삭제된 답글이 포함됨
$response->assertStatus(200);
$replies = $response->json('data.replies');
$this->assertCount(2, $replies);
}
// ==========================================
// 헬퍼 메서드
// ==========================================
/**
* 게시글 생성 헬퍼
*/
private function createPost(array $attributes): int
{
$postId = $this->createTestPost($attributes);
return $postId;
}
}