관리자 상품목록을 한 페이지 여는 것만으로 그 페이지 모든 상품의 옵션이 응답에 실렸다. 같은 패턴을 저장소 전역에서 찾아 14개 목록 엔드포인트를 함께 정리했다. 근본 원인은 둘이다. Resource 가 whenLoaded 로 방어하는데 Repository 가 목록 쿼리에서 관계를 무조건 로드해 가드가 항상 참이 되는 가짜 가드, 그리고 toListArray 경량 표현을 정의해 두고도 컬렉션이 toArray 를 부르는 목록/상세 미분리다. 둘 다 응답만 보면 정상이라 오류도 경고도 없이 페이로드만 불어난다. 목록은 화면이 실제로 그리는 것만 싣는다. 개수·합계는 PHP 컬렉션 연산이 아니라 DB 집계로, 대표 1건이 필요한 곳은 관계 자체를 oldestOfMany 로 좁힌다. eager load 의 limit(1) 은 부모별이 아니라 배치 쿼리 전체에 걸려 첫 행만 값을 갖게 되므로 쓸 수 없다. 뺀 값에는 대체 경로를 먼저 만들었다. 상품 옵션은 행을 펼칠 때 배치로 불러오고(상품 수와 무관하게 쿼리 상수), 종전 동작이 필요한 호출자를 위해 ?with_options=1 등 opt-in 을 남겼다. 배송정책 국가설정과 리뷰 첨부 이미지는 소비처를 실측한 결과 화면이 실제로 그리고 있어 제거하지 않았다 — 그 소비 사실을 회귀 테스트로 고정했다. 재발 방지로 정적 검사 룰 4종과 규정 문서 항목을 함께 넣었다.
355 lines
16 KiB
PHP
355 lines
16 KiB
PHP
<?php
|
|
|
|
namespace Modules\Sirsoft\Board\Tests\Feature;
|
|
|
|
// ModuleTestCase를 수동으로 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 Illuminate\Support\Facades\Schema;
|
|
use Modules\Sirsoft\Board\Http\Resources\BoardResource;
|
|
use Modules\Sirsoft\Board\Models\Board;
|
|
use Modules\Sirsoft\Board\Models\BoardType;
|
|
use Modules\Sirsoft\Board\Services\BoardService;
|
|
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
|
|
|
|
/**
|
|
* 게시판 복제(copyBoard) 회귀 테스트
|
|
*
|
|
* 이슈 #413-16:
|
|
* - 16-(1) 복제 저장 시 board_manager_ids 누락 → 422
|
|
* - 16-(2) 복제 시 권한 미복사 + 역할 identifier 교차 게시판 누수
|
|
*
|
|
* copyBoard() 가 역할/관계 파생 5필드(board_manager_ids/board_managers/
|
|
* board_step_ids/board_steps/permissions)를 채워 반환하고, permissions 의
|
|
* roles 에서 옛 slug 스코프(manager/step) identifier 를 제거하는지 검증한다.
|
|
*/
|
|
class BoardCloneTest extends ModuleTestCase
|
|
{
|
|
private User $adminUser;
|
|
|
|
private BoardService $boardService;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
// 이전 실행 잔여 정리 (DatabaseTransactions 비활성 환경 호환)
|
|
DB::statement('SET FOREIGN_KEY_CHECKS=0');
|
|
DB::table('permissions')->where('identifier', 'like', 'sirsoft-board.clone-src-%')->delete();
|
|
DB::table('permissions')->where('identifier', 'like', 'sirsoft-board.clone-dst-%')->delete();
|
|
DB::table('roles')->where('identifier', 'like', 'sirsoft-board.clone-src-%')->delete();
|
|
DB::table('roles')->where('identifier', 'like', 'sirsoft-board.clone-dst-%')->delete();
|
|
DB::table('boards')->where('slug', 'like', 'clone-src-%')->delete();
|
|
DB::table('boards')->where('slug', 'like', 'clone-dst-%')->delete();
|
|
DB::statement('SET FOREIGN_KEY_CHECKS=1');
|
|
|
|
// 게시판 유형(basic) 보장 — store API 의 type 검증에 필요
|
|
if (! Schema::hasTable('board_types')) {
|
|
$this->artisan('migrate', [
|
|
'--path' => $this->getModuleBasePath().'/database/migrations',
|
|
'--realpath' => true,
|
|
]);
|
|
}
|
|
BoardType::firstOrCreate(
|
|
['slug' => 'default'],
|
|
['name' => ['ko' => '기본', 'en' => 'Default'], 'is_active' => true, 'is_default' => true]
|
|
);
|
|
|
|
// 게시판 권한을 가진 관리자 (form-data/store API 의 permission 미들웨어 통과)
|
|
$this->adminUser = $this->createAdminUser([
|
|
'sirsoft-board.boards.read',
|
|
'sirsoft-board.boards.create',
|
|
'sirsoft-board.boards.update',
|
|
'sirsoft-board.boards.delete',
|
|
]);
|
|
$this->actingAs($this->adminUser);
|
|
|
|
$this->boardService = app(BoardService::class);
|
|
}
|
|
|
|
/**
|
|
* manager 사용자가 지정된 원본 게시판을 생성합니다.
|
|
*/
|
|
private function createSourceBoardWithManager(string $slug, User $manager, ?User $step = null): Board
|
|
{
|
|
return $this->boardService->createBoard([
|
|
'slug' => $slug,
|
|
'name' => ['ko' => "복제원본 {$slug}", 'en' => "Source {$slug}"],
|
|
'type' => 'default',
|
|
'board_manager_ids' => [$manager->uuid],
|
|
'board_step_ids' => $step ? [$step->uuid] : [],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 16-(1)/(2): copyBoard 반환에 board_manager_ids 와 manager 객체 배열이 채워진다.
|
|
*
|
|
* @scenario original_permissions=default
|
|
*
|
|
* @effects clone_includes_manager_ids, clone_includes_manager_objects_with_labels, board_copy_still_resolves_roles
|
|
*/
|
|
public function test_copy_board_includes_manager_ids_and_objects(): void
|
|
{
|
|
$manager = User::factory()->create(['name' => 'Manager One']);
|
|
$board = $this->createSourceBoardWithManager('clone-src-1', $manager);
|
|
|
|
$copyData = $this->boardService->copyBoard($board->id);
|
|
|
|
// 16-(1): board_manager_ids 가 원본 manager uuid 를 포함 (누락 시 422)
|
|
$this->assertArrayHasKey('board_manager_ids', $copyData);
|
|
$this->assertContains($manager->uuid, $copyData['board_manager_ids']);
|
|
|
|
// 16-(2): board_managers 객체 배열이 라벨 데이터({uuid,name,email})를 포함
|
|
$this->assertArrayHasKey('board_managers', $copyData);
|
|
$managerObj = collect($copyData['board_managers'])->firstWhere('uuid', $manager->uuid);
|
|
$this->assertNotNull($managerObj, 'board_managers 에 원본 manager 객체가 없습니다.');
|
|
$this->assertSame($manager->name, $managerObj['name']);
|
|
$this->assertSame($manager->email, $managerObj['email']);
|
|
|
|
// step 키도 존재 (빈 배열이라도)
|
|
$this->assertArrayHasKey('board_step_ids', $copyData);
|
|
$this->assertArrayHasKey('board_steps', $copyData);
|
|
|
|
// add_to_menu 는 boolean 으로 채워져야 한다 (누락 시 폼 Toggle 이 undefined 로
|
|
// 초기화되어 저장 시 boolean 검증에 걸림 → 422)
|
|
$this->assertArrayHasKey('add_to_menu', $copyData);
|
|
$this->assertIsBool($copyData['add_to_menu']);
|
|
}
|
|
|
|
/**
|
|
* 4-3 형식 일치: copyBoard 의 role 데이터가 BoardResource 산출과 동일 구조여야 한다.
|
|
*
|
|
* @scenario original_permissions=default
|
|
*
|
|
* @effects clone_role_data_matches_resource_output
|
|
*/
|
|
public function test_copy_board_role_data_matches_resource_output(): void
|
|
{
|
|
$manager = User::factory()->create(['name' => 'Manager Match']);
|
|
$step = User::factory()->create(['name' => 'Step Match']);
|
|
$board = $this->createSourceBoardWithManager('clone-src-2', $manager, $step);
|
|
|
|
$copyData = $this->boardService->copyBoard($board->id);
|
|
|
|
$expected = BoardResource::getBoardRoleData(
|
|
"sirsoft-board.{$board->slug}"
|
|
);
|
|
|
|
$this->assertSame($expected['board_manager_ids'], $copyData['board_manager_ids']);
|
|
$this->assertSame($expected['board_managers'], $copyData['board_managers']);
|
|
$this->assertSame($expected['board_step_ids'], $copyData['board_step_ids']);
|
|
$this->assertSame($expected['board_steps'], $copyData['board_steps']);
|
|
}
|
|
|
|
/**
|
|
* 4-4 (C) 누수 차단: copyBoard 반환 permissions 의 roles 에 옛 slug 스코프
|
|
* manager/step identifier 가 없어야 하고, 비-스코프 역할은 보존되어야 한다.
|
|
*
|
|
* @scenario original_permissions=custom
|
|
*
|
|
* @effects clone_strips_old_slug_scope_roles, clone_preserves_non_scope_roles
|
|
*/
|
|
public function test_copy_board_strips_old_slug_scope_roles_but_keeps_others(): void
|
|
{
|
|
$manager = User::factory()->create(['name' => 'Manager Leak']);
|
|
$board = $this->createSourceBoardWithManager('clone-src-3', $manager);
|
|
|
|
// 원본 권한에 비-스코프 역할(member)을 하나 추가해 보존 여부 검증
|
|
$memberRole = Role::firstOrCreate(
|
|
['identifier' => 'member'],
|
|
['name' => ['ko' => '회원', 'en' => 'Member']]
|
|
);
|
|
$permission = Permission::where('identifier', "sirsoft-board.{$board->slug}.posts.read")->first();
|
|
$this->assertNotNull($permission, 'posts.read 권한이 생성되지 않았습니다.');
|
|
$permission->roles()->syncWithoutDetaching([$memberRole->id]);
|
|
|
|
$copyData = $this->boardService->copyBoard($board->id);
|
|
|
|
$this->assertArrayHasKey('permissions', $copyData);
|
|
|
|
$oldManager = "sirsoft-board.{$board->slug}.manager";
|
|
$oldStep = "sirsoft-board.{$board->slug}.step";
|
|
|
|
$foundMember = false;
|
|
foreach ($copyData['permissions'] as $key => $roles) {
|
|
$roles = is_array($roles) ? $roles : (array) $roles;
|
|
$this->assertNotContains($oldManager, $roles, "{$key} 권한에 옛 slug manager 역할이 남아있습니다 (누수).");
|
|
$this->assertNotContains($oldStep, $roles, "{$key} 권한에 옛 slug step 역할이 남아있습니다 (누수).");
|
|
if (in_array('member', $roles, true)) {
|
|
$foundMember = true;
|
|
}
|
|
}
|
|
|
|
$this->assertTrue($foundMember, '비-스코프 역할(member)이 복제 permissions 에서 유실되었습니다.');
|
|
}
|
|
|
|
/**
|
|
* 16-(1) 저장 회귀: form-data(copy_id) 응답을 그대로 새 slug 만 바꿔 저장하면
|
|
* 422 가 아니라 201 로 생성되고, manager 명단이 원본과 동일하게 승계된다.
|
|
*
|
|
* @scenario original_permissions=default
|
|
*
|
|
* @effects clone_saved_201, managers_copied
|
|
*/
|
|
public function test_clone_form_data_saves_201_and_inherits_managers(): void
|
|
{
|
|
$manager = User::factory()->create(['name' => 'Manager Save']);
|
|
$source = $this->createSourceBoardWithManager('clone-src-save', $manager);
|
|
|
|
// 복제 폼 데이터 로드 (실제 컨트롤러 경로: copy_id → copyBoard + formatPermissionsForFrontend)
|
|
$formResponse = $this->actingAs($this->adminUser)
|
|
->getJson('/api/modules/sirsoft-board/admin/boards/form-data?copy_id='.$source->id);
|
|
$formResponse->assertStatus(200);
|
|
$form = $formResponse->json('data');
|
|
|
|
// 폼이 그대로 보내는 payload 를 모사 (새 slug 만 변경)
|
|
$form['slug'] = 'clone-dst-save';
|
|
$payload = $form;
|
|
|
|
$storeResponse = $this->actingAs($this->adminUser)
|
|
->postJson('/api/modules/sirsoft-board/admin/boards', $payload);
|
|
|
|
// 422 아님 → 201
|
|
$storeResponse->assertStatus(201);
|
|
|
|
// manager 명단 승계 확인
|
|
$newManagerRole = Role::where('identifier', 'sirsoft-board.clone-dst-save.manager')->first();
|
|
$this->assertNotNull($newManagerRole);
|
|
$this->assertTrue(
|
|
$newManagerRole->users()->where('users.id', $manager->id)->exists(),
|
|
'복제본 manager 역할에 원본 manager 가 승계되지 않았습니다.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 16-(2) 누수 차단 (Feature): 복제 저장 후 새 게시판 권한에 원본 slug 스코프
|
|
* 역할(sirsoft-board.{src}.manager/step)이 attach 되지 않는다 (교차 게시판 누수 0).
|
|
*
|
|
* @scenario original_permissions=custom
|
|
*
|
|
* @effects permissions_copied, no_cross_board_role_leak
|
|
*/
|
|
public function test_clone_save_does_not_leak_source_scope_roles(): void
|
|
{
|
|
$manager = User::factory()->create(['name' => 'Manager NoLeak']);
|
|
$source = $this->createSourceBoardWithManager('clone-src-leak', $manager);
|
|
|
|
$form = $this->actingAs($this->adminUser)
|
|
->getJson('/api/modules/sirsoft-board/admin/boards/form-data?copy_id='.$source->id)
|
|
->json('data');
|
|
$form['slug'] = 'clone-dst-leak';
|
|
|
|
$this->actingAs($this->adminUser)
|
|
->postJson('/api/modules/sirsoft-board/admin/boards', $form)
|
|
->assertStatus(201);
|
|
|
|
// 새 게시판 권한 전체를 순회하며 원본 스코프 역할 부재 확인
|
|
$newPermissions = Permission::where('identifier', 'like', 'sirsoft-board.clone-dst-leak.%')->get();
|
|
$this->assertNotEmpty($newPermissions, '복제본 권한이 생성되지 않았습니다.');
|
|
|
|
$srcManager = 'sirsoft-board.clone-src-leak.manager';
|
|
$srcStep = 'sirsoft-board.clone-src-leak.step';
|
|
|
|
foreach ($newPermissions as $permission) {
|
|
$assigned = $permission->roles()->pluck('identifier')->toArray();
|
|
$this->assertNotContains($srcManager, $assigned, "{$permission->identifier} 에 원본 manager 역할 누수.");
|
|
$this->assertNotContains($srcStep, $assigned, "{$permission->identifier} 에 원본 step 역할 누수.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 권한 양성 복사: 원본의 커스텀 역할 할당(member)이 복제 저장 후 새 게시판
|
|
* 동일 권한에 그대로 복사된다. (누수 차단과 별개로, 의도 권한이 실제 승계되는지)
|
|
*
|
|
* @scenario original_permissions=custom
|
|
*
|
|
* @effects permissions_copied, custom_role_assignment_preserved
|
|
*/
|
|
public function test_clone_save_copies_custom_role_assignment(): void
|
|
{
|
|
$manager = User::factory()->create(['name' => 'Manager Custom']);
|
|
$source = $this->createSourceBoardWithManager('clone-src-custom', $manager);
|
|
|
|
// 원본 posts.read 권한에 비-스코프 커스텀 역할(member) 할당
|
|
$memberRole = Role::firstOrCreate(
|
|
['identifier' => 'member'],
|
|
['name' => ['ko' => '회원', 'en' => 'Member']]
|
|
);
|
|
$srcPerm = Permission::where('identifier', "sirsoft-board.{$source->slug}.posts.read")->first();
|
|
$this->assertNotNull($srcPerm);
|
|
$srcPerm->roles()->syncWithoutDetaching([$memberRole->id]);
|
|
|
|
// 복제 폼 로드 → 새 slug 저장
|
|
$form = $this->actingAs($this->adminUser)
|
|
->getJson('/api/modules/sirsoft-board/admin/boards/form-data?copy_id='.$source->id)
|
|
->json('data');
|
|
$form['slug'] = 'clone-dst-custom';
|
|
|
|
$this->actingAs($this->adminUser)
|
|
->postJson('/api/modules/sirsoft-board/admin/boards', $form)
|
|
->assertStatus(201);
|
|
|
|
// 새 게시판 posts.read 권한에 member 역할이 복사되어야 한다
|
|
$dstPerm = Permission::where('identifier', 'sirsoft-board.clone-dst-custom.posts.read')->first();
|
|
$this->assertNotNull($dstPerm, '복제본 posts.read 권한이 생성되지 않았습니다.');
|
|
$assigned = $dstPerm->roles()->pluck('identifier')->toArray();
|
|
$this->assertContains('member', $assigned, '원본의 커스텀 역할(member)이 복제본에 복사되지 않았습니다.');
|
|
}
|
|
|
|
/**
|
|
* 추가버그(1): copyBoard 반환에 add_to_menu 가 boolean 으로 채워져,
|
|
* 복제 폼 저장이 boolean 검증(422)에 걸리지 않는다.
|
|
*
|
|
* @scenario original_permissions=default
|
|
*
|
|
* @effects clone_includes_add_to_menu_boolean
|
|
*/
|
|
public function test_copy_board_includes_add_to_menu_as_boolean(): void
|
|
{
|
|
$manager = User::factory()->create(['name' => 'Manager Menu']);
|
|
$source = $this->createSourceBoardWithManager('clone-src-menu', $manager);
|
|
|
|
$copyData = $this->boardService->copyBoard($source->id);
|
|
|
|
$this->assertArrayHasKey('add_to_menu', $copyData);
|
|
$this->assertIsBool($copyData['add_to_menu']);
|
|
}
|
|
|
|
/**
|
|
* 추가버그(2): add_to_menu 검증 실패 메시지가 필드명(add_to_menu)이 아니라
|
|
* 한글 라벨로 치환되어 노출된다.
|
|
*
|
|
* @effects add_to_menu_validation_uses_translated_label
|
|
*/
|
|
public function test_add_to_menu_validation_error_uses_translated_label(): void
|
|
{
|
|
$manager = User::factory()->create(['name' => 'Manager Label']);
|
|
|
|
// 한글 라벨 치환을 결정적으로 검증하기 위해 locale 고정
|
|
app()->setLocale('ko');
|
|
|
|
$payload = [
|
|
'slug' => 'clone-dst-label',
|
|
'name' => ['ko' => '라벨 테스트', 'en' => 'Label Test'],
|
|
'type' => 'default',
|
|
'board_manager_ids' => [$manager->uuid],
|
|
'add_to_menu' => 'not-a-boolean', // boolean 위반 유도
|
|
];
|
|
|
|
$response = $this->actingAs($this->adminUser)
|
|
->postJson('/api/modules/sirsoft-board/admin/boards', $payload, ['Accept-Language' => 'ko']);
|
|
|
|
$response->assertStatus(422);
|
|
$message = $response->json('errors.add_to_menu.0') ?? $response->json('message');
|
|
|
|
// 원시 필드명(add_to_menu / "add to menu")이 그대로 노출되지 않고 한글 라벨로 치환되어야 함
|
|
$this->assertStringNotContainsString('add to menu', (string) $message);
|
|
$this->assertStringNotContainsString('add_to_menu', (string) $message);
|
|
$this->assertStringContainsString('관리자 메뉴에 표시', (string) $message);
|
|
}
|
|
}
|