v7.0.0-beta.1 release

This commit is contained in:
HeuJung committed 2026-04-01 10:30:52 +09:00
commit 6595fd0eb5
3967 files changed
+1875383

No files matched your search

@@ -0,0 +1,423 @@
<?php
namespace Tests\Feature\Module;
use App\Enums\ExtensionStatus;
use App\Models\Module;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ModuleAssetServingTest extends TestCase
{
use RefreshDatabase;
private Module $activeModule;
private string $testModulePath;
protected function setUp(): void
{
parent::setUp();
// 테스트용 활성화된 모듈 생성
$this->activeModule = Module::factory()->create([
'identifier' => 'test-module',
'status' => ExtensionStatus::Active->value,
]);
// 테스트용 모듈 디렉토리 생성
$this->testModulePath = base_path('modules/test-module');
if (!file_exists($this->testModulePath.'/dist/js')) {
mkdir($this->testModulePath.'/dist/js', 0755, true);
}
if (!file_exists($this->testModulePath.'/dist/css')) {
mkdir($this->testModulePath.'/dist/css', 0755, true);
}
}
protected function tearDown(): void
{
// 테스트용 파일 및 디렉토리 정리
if (file_exists($this->testModulePath)) {
$this->deleteDirectory($this->testModulePath);
}
parent::tearDown();
}
/**
* 디렉토리 재귀 삭제 헬퍼 메서드
*/
private function deleteDirectory(string $dir): bool
{
if (!file_exists($dir)) {
return true;
}
if (!is_dir($dir)) {
return unlink($dir);
}
foreach (scandir($dir) as $item) {
if ($item == '.' || $item == '..') {
continue;
}
if (!$this->deleteDirectory($dir.DIRECTORY_SEPARATOR.$item)) {
return false;
}
}
return rmdir($dir);
}
/**
* 활성화된 모듈의 JS 파일 서빙 성공
*/
public function test_serves_js_file_from_active_module(): void
{
// Arrange
$jsPath = $this->testModulePath.'/dist/js/module.iife.js';
file_put_contents($jsPath, 'console.log("test module");');
// Act
$response = $this->get('/api/modules/assets/test-module/dist/js/module.iife.js');
// Assert
$response->assertStatus(200);
$response->assertHeader('Content-Type', 'application/javascript');
}
/**
* 활성화된 모듈의 CSS 파일 서빙 성공
*/
public function test_serves_css_file_from_active_module(): void
{
// Arrange
$cssPath = $this->testModulePath.'/dist/css/module.css';
file_put_contents($cssPath, '.module { color: blue; }');
// Act
$response = $this->get('/api/modules/assets/test-module/dist/css/module.css');
// Assert
$response->assertStatus(200);
$this->assertTrue(
str_starts_with($response->headers->get('Content-Type'), 'text/css'),
'Content-Type should start with text/css'
);
}
/**
* 비활성화 모듈 접근 시 404 반환
*/
public function test_returns_404_for_inactive_module(): void
{
// Arrange
Module::factory()->create([
'identifier' => 'inactive-module',
'status' => ExtensionStatus::Inactive->value,
]);
// Act
$response = $this->get('/api/modules/assets/inactive-module/dist/js/module.iife.js');
// Assert
// 검증 실패로 인한 리다이렉트 또는 404 허용
$this->assertContains($response->status(), [302, 404]);
}
/**
* 존재하지 않는 파일 접근 시 404 반환
*/
public function test_returns_404_for_nonexistent_file(): void
{
// Act
$response = $this->get('/api/modules/assets/test-module/dist/js/nonexistent.js');
// Assert
// 검증 실패로 인한 리다이렉트 또는 404 허용
$this->assertContains($response->status(), [302, 404]);
}
/**
* Path Traversal 공격 차단 - 기본 패턴 (../)
*/
public function test_blocks_path_traversal_attack(): void
{
// Act
$response = $this->get('/api/modules/assets/test-module/../../.env');
// Assert
$this->assertContains($response->status(), [302, 422]);
}
/**
* Path Traversal 공격 차단 - 백슬래시 패턴 (..\)
*/
public function test_blocks_path_traversal_with_backslash(): void
{
// Act
$response = $this->get('/api/modules/assets/test-module/..%5c..%5cconfig.php');
// Assert
$this->assertContains($response->status(), [302, 422]);
}
/**
* Path Traversal 공격 차단 - URL 인코딩 패턴 (%2e%2e%2f)
*/
public function test_blocks_url_encoded_path_traversal(): void
{
$patterns = [
'%2e%2e%2f',
'%2e%2e/',
'%2e%2e%5c',
'..%2f',
'..%5c',
'.%2e/',
];
foreach ($patterns as $pattern) {
$response = $this->get("/api/modules/assets/test-module/{$pattern}secret.txt");
$this->assertContains($response->status(), [302, 422], "Pattern {$pattern} should be blocked");
}
}
/**
* 절대 경로 차단 - Windows 경로
*/
public function test_blocks_windows_absolute_path(): void
{
// Act
$response = $this->get('/api/modules/assets/test-module/C:%5cWindows%5cSystem32%5cconfig.ini');
// Assert
$this->assertContains($response->status(), [302, 422]);
}
/**
* 절대 경로 차단 - Linux 경로
*/
public function test_blocks_linux_absolute_path(): void
{
// Act
$response = $this->get('/api/modules/assets/test-module//etc/passwd');
// Assert
$this->assertContains($response->status(), [302, 422]);
}
/**
* NULL 바이트 공격 차단
*/
public function test_blocks_null_byte_attack(): void
{
// Act
$response = $this->get('/api/modules/assets/test-module/malicious.php%00.js');
// Assert
$this->assertContains($response->status(), [302, 422]);
}
/**
* 불허 파일 타입 차단 - PHP
*/
public function test_blocks_disallowed_file_types(): void
{
// Arrange
$phpPath = $this->testModulePath.'/malicious.php';
file_put_contents($phpPath, '<?php echo "hack"; ?>');
// Act
$response = $this->get('/api/modules/assets/test-module/malicious.php');
// Assert
$this->assertContains($response->status(), [302, 422]);
}
/**
* 불허 파일 타입 차단 - 실행 파일들
*/
public function test_blocks_various_disallowed_file_types(): void
{
$disallowedExtensions = ['exe', 'sh', 'bat', 'dll', 'so', 'py', 'rb', 'pl', 'asp', 'aspx', 'jsp', 'cgi'];
foreach ($disallowedExtensions as $ext) {
$response = $this->get("/api/modules/assets/test-module/malicious.{$ext}");
$this->assertContains($response->status(), [302, 422], "Extension .{$ext} should be blocked");
}
}
/**
* 허용된 파일 타입 확인
*/
public function test_allows_all_whitelisted_file_types(): void
{
$allowedFiles = [
'app.js' => 'application/javascript',
'style.css' => 'text/css',
'data.json' => 'application/json',
'image.png' => 'image/png',
'photo.jpg' => 'image/jpeg',
'icon.svg' => 'image/svg+xml',
'font.woff' => 'font/woff',
'font.woff2' => 'font/woff2',
];
foreach ($allowedFiles as $filename => $expectedMimeType) {
$filePath = $this->testModulePath.'/dist/'.$filename;
file_put_contents($filePath, 'test content');
$response = $this->get("/api/modules/assets/test-module/dist/{$filename}");
$response->assertStatus(200, "File {$filename} should be allowed");
$actualMimeType = $response->headers->get('Content-Type');
if ($filename === 'style.css') {
$this->assertTrue(
str_starts_with($actualMimeType, $expectedMimeType),
"MIME type for {$filename} should start with {$expectedMimeType}"
);
} else {
$this->assertEquals(
$expectedMimeType,
$actualMimeType,
"MIME type for {$filename} should be {$expectedMimeType}"
);
}
}
}
/**
* 캐싱 헤더 정상 설정
*/
public function test_sets_caching_headers(): void
{
// Arrange
$jsPath = $this->testModulePath.'/dist/js/module.iife.js';
file_put_contents($jsPath, 'console.log("test");');
// Act
$response = $this->get('/api/modules/assets/test-module/dist/js/module.iife.js');
// Assert
$response->assertStatus(200);
$cacheControl = $response->headers->get('Cache-Control');
$this->assertNotNull($cacheControl, 'Cache-Control header should be present');
}
/**
* ETag 헤더 생성 확인
*/
public function test_generates_etag_header(): void
{
// Arrange
$jsPath = $this->testModulePath.'/dist/js/module.iife.js';
file_put_contents($jsPath, 'console.log("test");');
// Act
$response = $this->get('/api/modules/assets/test-module/dist/js/module.iife.js');
// Assert
$response->assertStatus(200);
$etag = $response->headers->get('ETag');
$this->assertNotNull($etag, 'ETag header should be present');
$this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $etag, 'ETag should be a 32-character MD5 hash');
}
/**
* ETag 매칭 시 304 Not Modified 응답
*/
public function test_returns_304_when_etag_matches(): void
{
// Arrange
$jsPath = $this->testModulePath.'/dist/js/module.iife.js';
file_put_contents($jsPath, 'console.log("test");');
$firstResponse = $this->get('/api/modules/assets/test-module/dist/js/module.iife.js');
$etag = $firstResponse->headers->get('ETag');
// Act
$response = $this->withHeaders([
'If-None-Match' => $etag,
])->get('/api/modules/assets/test-module/dist/js/module.iife.js');
// Assert
$response->assertStatus(304);
$this->assertEmpty($response->getContent(), '304 response should have empty body');
}
/**
* 프로덕션 환경에서 immutable 캐싱 정책 적용
*/
public function test_applies_immutable_caching_in_production(): void
{
// Arrange
app()['env'] = 'production';
$jsPath = $this->testModulePath.'/dist/js/module.iife.js';
file_put_contents($jsPath, 'console.log("test");');
// Act
$response = $this->get('/api/modules/assets/test-module/dist/js/module.iife.js');
// Assert
$response->assertStatus(200);
$cacheControl = $response->headers->get('Cache-Control');
$this->assertStringContainsString('immutable', $cacheControl, 'Production should use immutable caching');
$this->assertStringContainsString('public', $cacheControl);
$this->assertStringContainsString('max-age=31536000', $cacheControl);
}
/**
* 개발 환경에서 no-cache 정책 적용
*/
public function test_applies_no_cache_in_development(): void
{
// Arrange
app()['env'] = 'local';
$jsPath = $this->testModulePath.'/dist/js/module.iife.js';
file_put_contents($jsPath, 'console.log("test");');
// Act
$response = $this->get('/api/modules/assets/test-module/dist/js/module.iife.js');
// Assert
$response->assertStatus(200);
$cacheControl = $response->headers->get('Cache-Control');
$this->assertStringContainsString('no-cache', $cacheControl, 'Development should use no-cache');
}
/**
* 중첩 경로 처리 테스트
*/
public function test_handles_nested_path(): void
{
// Arrange
$nestedPath = $this->testModulePath.'/dist/js/components/Button.js';
mkdir(dirname($nestedPath), 0755, true);
file_put_contents($nestedPath, 'export default Button;');
// Act
$response = $this->get('/api/modules/assets/test-module/dist/js/components/Button.js');
// Assert
$response->assertStatus(200);
$response->assertHeader('Content-Type', 'application/javascript');
}
/**
* Source Map 파일 서빙 테스트
*/
public function test_serves_sourcemap_file(): void
{
// Arrange
$mapPath = $this->testModulePath.'/dist/js/module.iife.js.map';
file_put_contents($mapPath, '{"version":3,"sources":[],"mappings":""}');
// Act
$response = $this->get('/api/modules/assets/test-module/dist/js/module.iife.js.map');
// Assert
$response->assertStatus(200);
$response->assertHeader('Content-Type', 'application/json');
}
}
+503
View File
@@ -0,0 +1,503 @@
<?php
namespace Tests\Feature\Module;
use App\Enums\ExtensionOwnerType;
use App\Extension\ModuleManager;
use App\Models\Menu;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use ReflectionMethod;
use Tests\Helpers\ProtectsExtensionDirectories;
use Tests\TestCase;
/**
* 모듈 메뉴 동기화 통합 테스트
*
* ExtensionMenuSyncHelper를 통한 실제 DB 기반 메뉴 동기화를 검증합니다:
* - 설치 시 메뉴 생성 (user_overrides 없음)
* - 업데이트 시 user_overrides 기반 사용자 커스터마이징 보존
* - 동적 메뉴/권한 보존 (#135: cleanup 자동 호출 폐기)
* - 새 메뉴 추가
*/
class ModuleMenuSyncTest extends TestCase
{
use ProtectsExtensionDirectories;
use RefreshDatabase;
private ModuleManager $moduleManager;
/** @var bool 활성 디렉토리가 테스트 전에 이미 존재했는지 (tearDown에서 정리 판단용) */
private bool $boardExistedBefore = false;
protected function setUp(): void
{
parent::setUp();
// 이전 세션에서 남은 좀비 디렉토리 정리
$activePath = base_path('modules/sirsoft-board');
if (File::isDirectory($activePath) && ! File::exists($activePath.'/module.php')) {
File::deleteDirectory($activePath);
}
$this->boardExistedBefore = File::isDirectory($activePath);
// 확장 디렉토리 보호 활성화
$this->setUpExtensionProtection();
$this->moduleManager = app(ModuleManager::class);
$this->moduleManager->loadModules();
// 기본 시스템 역할 생성
Role::create([
'identifier' => 'admin',
'name' => ['ko' => '시스템 관리자', 'en' => 'System Administrator'],
'description' => ['ko' => '모든 권한을 가진 관리자', 'en' => 'Administrator with all permissions'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'is_active' => true,
]);
Role::create([
'identifier' => 'manager',
'name' => ['ko' => '매니저', 'en' => 'Manager'],
'description' => ['ko' => '관리 권한을 가진 매니저', 'en' => 'Manager with management permissions'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'is_active' => true,
]);
// 테스트용 사용자 생성
User::factory()->create(['email' => 'admin@test.com']);
}
protected function tearDown(): void
{
// 확장 디렉토리 보호 해제
$this->tearDownExtensionProtection();
// 테스트에서 생성한 활성 디렉토리만 정리
if (! $this->boardExistedBefore) {
$activePath = base_path('modules/sirsoft-board');
if (File::isDirectory($activePath)) {
File::deleteDirectory($activePath);
}
}
parent::tearDown();
}
/**
* ModuleManager의 protected 메서드를 호출하는 헬퍼
*
* @param string $methodName 메서드명
* @param array $args 인자
*/
private function callProtectedMethod(string $methodName, array $args = []): mixed
{
$method = new ReflectionMethod($this->moduleManager, $methodName);
return $method->invokeArgs($this->moduleManager, $args);
}
/**
* 모듈 설치 시 메뉴가 올바르게 생성되는지 테스트
*/
public function test_module_install_creates_menus(): void
{
$this->moduleManager->installModule('sirsoft-board');
// sirsoft-board: 부모 1 + 자식 3 = 4
$menuCount = Menu::where('extension_type', ExtensionOwnerType::Module)
->where('extension_identifier', 'sirsoft-board')
->count();
$this->assertEquals(4, $menuCount);
// 부모 메뉴 확인
$parentMenu = Menu::where('slug', 'sirsoft-board')
->where('extension_type', ExtensionOwnerType::Module)
->where('extension_identifier', 'sirsoft-board')
->first();
$this->assertNotNull($parentMenu);
$this->assertNotNull($parentMenu->name);
$this->assertEquals('fas fa-clipboard-list', $parentMenu->icon);
$this->assertEquals(30, $parentMenu->order);
// 자식 메뉴 확인
$childMenu = Menu::where('slug', 'sirsoft-board-list')
->where('extension_type', ExtensionOwnerType::Module)
->first();
$this->assertNotNull($childMenu);
$this->assertEquals($childMenu->parent_id, $parentMenu->id);
}
/**
* 최초 설치 시 메뉴에 user_overrides가 비어있는지 테스트
*/
public function test_no_user_overrides_on_fresh_menu_install(): void
{
$this->moduleManager->installModule('sirsoft-board');
$allMenus = Menu::where('extension_type', ExtensionOwnerType::Module)
->where('extension_identifier', 'sirsoft-board')
->get();
foreach ($allMenus as $menu) {
$this->assertTrue(
empty($menu->user_overrides),
"메뉴 '{$menu->slug}'의 user_overrides는 비어있어야 합니다"
);
}
}
/**
* user_overrides에 "name"이 있으면 재설치 시 메뉴명이 보존되는지 테스트
*/
public function test_user_menu_name_override_preserved_on_reinstall(): void
{
$this->moduleManager->installModule('sirsoft-board');
// 사용자가 메뉴명 수정 + user_overrides 기록
$parentMenu = Menu::where('slug', 'sirsoft-board')
->where('extension_type', ExtensionOwnerType::Module)
->first();
$customName = ['ko' => '우리 게시판', 'en' => 'Our Board'];
$parentMenu->update([
'name' => $customName,
'user_overrides' => ['name'],
]);
// 재설치 (업데이트 시뮬레이션)
$module = $this->moduleManager->getModule('sirsoft-board');
$this->callProtectedMethod('createModuleMenus', [$module]);
$parentMenu->refresh();
// 사용자 수정 이름 보존
$this->assertEquals($customName, $parentMenu->name);
}
/**
* user_overrides에 "order"가 있으면 재설치 시 메뉴 순서가 보존되는지 테스트
*/
public function test_user_menu_order_override_preserved_on_reinstall(): void
{
$this->moduleManager->installModule('sirsoft-board');
// 사용자가 순서 변경 + user_overrides 기록
$parentMenu = Menu::where('slug', 'sirsoft-board')
->where('extension_type', ExtensionOwnerType::Module)
->first();
$originalIcon = $parentMenu->icon;
$parentMenu->update([
'order' => 99,
'user_overrides' => ['order'],
]);
// 재설치 (업데이트 시뮬레이션)
$module = $this->moduleManager->getModule('sirsoft-board');
$this->callProtectedMethod('createModuleMenus', [$module]);
$parentMenu->refresh();
// 사용자 수정 순서 보존
$this->assertEquals(99, $parentMenu->order);
// 미수정 아이콘은 확장 정의값 유지
$this->assertEquals($originalIcon, $parentMenu->icon);
}
/**
* user_overrides가 없으면 재설치 시 메뉴가 확장 정의값으로 갱신되는지 테스트
*/
public function test_reinstall_updates_unmodified_menus(): void
{
$this->moduleManager->installModule('sirsoft-board');
$parentMenu = Menu::where('slug', 'sirsoft-board')
->where('extension_type', ExtensionOwnerType::Module)
->first();
$originalName = $parentMenu->name;
$originalIcon = $parentMenu->icon;
$originalOrder = $parentMenu->order;
// 재설치
$module = $this->moduleManager->getModule('sirsoft-board');
$this->callProtectedMethod('createModuleMenus', [$module]);
$parentMenu->refresh();
// user_overrides 없으면 확장 정의값 그대로 유지
$this->assertEquals($originalName, $parentMenu->name);
$this->assertEquals($originalIcon, $parentMenu->icon);
$this->assertEquals($originalOrder, $parentMenu->order);
}
/**
* 제거 후 재설치 시 깨끗한 상태에서 메뉴가 올바르게 생성되는지 테스트
*/
public function test_reinstall_after_uninstall_creates_clean_menus(): void
{
// 설치
$this->moduleManager->installModule('sirsoft-board');
$this->assertTrue(Menu::where('extension_identifier', 'sirsoft-board')->exists());
// 제거 (spy가 활성 디렉토리 삭제 차단)
$this->moduleManager->uninstallModule('sirsoft-board');
$this->assertFalse(Menu::where('extension_identifier', 'sirsoft-board')->exists());
// 재설치 (활성 디렉토리가 보존되어 있으므로 재로드 가능)
$this->moduleManager->loadModules();
$this->moduleManager->installModule('sirsoft-board');
// 메뉴가 새로 생성되고 user_overrides 비어있음
$parentMenu = Menu::where('slug', 'sirsoft-board')
->where('extension_type', ExtensionOwnerType::Module)
->first();
$this->assertNotNull($parentMenu);
$this->assertTrue(
empty($parentMenu->user_overrides),
'재설치 후 user_overrides는 비어있어야 합니다'
);
}
/**
* 복수 필드 user_overrides 보존 테스트 (name + icon 동시 수정)
*/
public function test_multiple_user_overrides_preserved_on_reinstall(): void
{
$this->moduleManager->installModule('sirsoft-board');
$parentMenu = Menu::where('slug', 'sirsoft-board')
->where('extension_type', ExtensionOwnerType::Module)
->first();
$customName = ['ko' => '우리 게시판', 'en' => 'Our Board'];
$customIcon = 'fas fa-star';
$originalOrder = $parentMenu->order;
$originalUrl = $parentMenu->url;
$parentMenu->update([
'name' => $customName,
'icon' => $customIcon,
'user_overrides' => ['name', 'icon'],
]);
// 재설치
$module = $this->moduleManager->getModule('sirsoft-board');
$this->callProtectedMethod('createModuleMenus', [$module]);
$parentMenu->refresh();
// 수정된 필드 보존
$this->assertEquals($customName, $parentMenu->name);
$this->assertEquals($customIcon, $parentMenu->icon);
// 미수정 필드는 확장 정의값 유지
$this->assertEquals($originalOrder, $parentMenu->order);
$this->assertEquals($originalUrl, $parentMenu->url);
}
// ========== 동적 메뉴/권한 보존 테스트 ==========
/**
* 모듈 업데이트 시 동적으로 생성된 메뉴가 보존되는지 테스트
*
* 시나리오: 게시판 모듈 설치 후 사용자가 개별 게시판을 관리자 메뉴에 등록.
* 모듈 업데이트(createModuleMenus 재호출) 시 동적 메뉴가 삭제되지 않아야 함.
*/
public function test_module_update_preserves_dynamic_menus(): void
{
$this->moduleManager->installModule('sirsoft-board');
// 정적 메뉴 수 확인 (부모 1 + 자식 3 = 4)
$staticMenuCount = Menu::where('extension_type', ExtensionOwnerType::Module)
->where('extension_identifier', 'sirsoft-board')
->count();
$this->assertEquals(4, $staticMenuCount);
// 동적 메뉴 생성 시뮬레이션 (BoardService::addToAdminMenu 동작 재현)
Menu::create([
'name' => ['ko' => '공지사항', 'en' => 'Notice'],
'slug' => 'board-notice',
'url' => '/admin/board/notice',
'icon' => 'fas fa-clipboard-list',
'parent_id' => null,
'is_active' => true,
'extension_type' => ExtensionOwnerType::Module,
'extension_identifier' => 'sirsoft-board',
'order' => 50,
]);
Menu::create([
'name' => ['ko' => '자유게시판', 'en' => 'Free Board'],
'slug' => 'board-free',
'url' => '/admin/board/free',
'icon' => 'fas fa-clipboard-list',
'parent_id' => null,
'is_active' => true,
'extension_type' => ExtensionOwnerType::Module,
'extension_identifier' => 'sirsoft-board',
'order' => 51,
]);
// 동적 메뉴 포함 총 6개 확인 (정적 4 + 동적 2)
$totalMenuCount = Menu::where('extension_type', ExtensionOwnerType::Module)
->where('extension_identifier', 'sirsoft-board')
->count();
$this->assertEquals(6, $totalMenuCount);
// 모듈 업데이트 시뮬레이션 (createModuleMenus 재호출)
$module = $this->moduleManager->getModule('sirsoft-board');
$this->callProtectedMethod('createModuleMenus', [$module]);
// 동적 메뉴가 보존되어야 함
$this->assertTrue(
Menu::where('slug', 'board-notice')
->where('extension_identifier', 'sirsoft-board')
->exists(),
'동적 메뉴 board-notice가 모듈 업데이트 후에도 보존되어야 합니다'
);
$this->assertTrue(
Menu::where('slug', 'board-free')
->where('extension_identifier', 'sirsoft-board')
->exists(),
'동적 메뉴 board-free가 모듈 업데이트 후에도 보존되어야 합니다'
);
// 전체 메뉴 수 유지 (정적 4 + 동적 2 = 6)
$afterUpdateCount = Menu::where('extension_type', ExtensionOwnerType::Module)
->where('extension_identifier', 'sirsoft-board')
->count();
$this->assertEquals(6, $afterUpdateCount);
}
/**
* 모듈 업데이트 시 동적으로 생성된 권한이 보존되는지 테스트
*
* 시나리오: 게시판 모듈 설치 후 BoardPermissionService가 동적 권한 생성.
* 모듈 업데이트(createModulePermissions 재호출) 시 동적 권한이 삭제되지 않아야 함.
*/
public function test_module_update_preserves_dynamic_permissions(): void
{
$this->moduleManager->installModule('sirsoft-board');
// 정적 권한 수 확인
$staticPermCount = Permission::where('extension_type', ExtensionOwnerType::Module)
->where('extension_identifier', 'sirsoft-board')
->count();
$this->assertGreaterThan(0, $staticPermCount);
// 동적 권한 생성 시뮬레이션 (BoardPermissionService::ensureBoardPermissions 동작 재현)
$moduleRoot = Permission::where('identifier', 'sirsoft-board')
->where('extension_type', ExtensionOwnerType::Module)
->first();
// 모듈 루트 권한이 없으면 생성
if (! $moduleRoot) {
$moduleRoot = Permission::create([
'identifier' => 'sirsoft-board',
'name' => ['ko' => '게시판', 'en' => 'Board'],
'description' => ['ko' => '게시판 모듈', 'en' => 'Board module'],
'extension_type' => ExtensionOwnerType::Module,
'extension_identifier' => 'sirsoft-board',
'type' => 'admin',
]);
}
// 게시판별 동적 권한 생성
$categoryPerm = Permission::create([
'identifier' => 'sirsoft-board.notice',
'name' => ['ko' => '공지사항 게시판', 'en' => 'Notice Board'],
'description' => ['ko' => '공지사항 게시판 권한', 'en' => 'Notice board permissions'],
'extension_type' => ExtensionOwnerType::Module,
'extension_identifier' => 'sirsoft-board',
'parent_id' => $moduleRoot->id,
'type' => 'admin',
]);
Permission::create([
'identifier' => 'sirsoft-board.notice.posts.list',
'name' => ['ko' => '글 목록', 'en' => 'List Posts'],
'description' => ['ko' => '글 목록 조회', 'en' => 'List posts'],
'extension_type' => ExtensionOwnerType::Module,
'extension_identifier' => 'sirsoft-board',
'parent_id' => $categoryPerm->id,
'type' => 'admin',
]);
$totalPermCountBefore = Permission::where('extension_type', ExtensionOwnerType::Module)
->where('extension_identifier', 'sirsoft-board')
->count();
// 모듈 업데이트 시뮬레이션 (createModulePermissions 재호출)
$module = $this->moduleManager->getModule('sirsoft-board');
$this->callProtectedMethod('createModulePermissions', [$module]);
// 동적 권한이 보존되어야 함
$this->assertTrue(
Permission::where('identifier', 'sirsoft-board.notice')
->where('extension_identifier', 'sirsoft-board')
->exists(),
'동적 권한 sirsoft-board.notice가 모듈 업데이트 후에도 보존되어야 합니다'
);
$this->assertTrue(
Permission::where('identifier', 'sirsoft-board.notice.posts.list')
->where('extension_identifier', 'sirsoft-board')
->exists(),
'동적 권한 sirsoft-board.notice.posts.list가 모듈 업데이트 후에도 보존되어야 합니다'
);
// 전체 권한 수 유지 (정적 권한은 sync로 갱신, 동적 권한은 보존)
$totalPermCountAfter = Permission::where('extension_type', ExtensionOwnerType::Module)
->where('extension_identifier', 'sirsoft-board')
->count();
$this->assertEquals($totalPermCountBefore, $totalPermCountAfter);
}
/**
* 모듈 제거 시 동적 메뉴를 포함한 모든 메뉴가 삭제되는지 테스트
*
* cleanup 자동 호출을 제거했지만, uninstall 시 deleteByExtension()은 정상 동작해야 함.
*/
public function test_module_uninstall_removes_all_menus_including_dynamic(): void
{
$this->moduleManager->installModule('sirsoft-board');
// 동적 메뉴 생성 시뮬레이션
Menu::create([
'name' => ['ko' => '공지사항', 'en' => 'Notice'],
'slug' => 'board-notice',
'url' => '/admin/board/notice',
'icon' => 'fas fa-clipboard-list',
'parent_id' => null,
'is_active' => true,
'extension_type' => ExtensionOwnerType::Module,
'extension_identifier' => 'sirsoft-board',
'order' => 50,
]);
// 정적 + 동적 메뉴 존재 확인 (정적 4 + 동적 1 = 5)
$this->assertEquals(5, Menu::where('extension_identifier', 'sirsoft-board')->count());
// 모듈 제거
$this->moduleManager->uninstallModule('sirsoft-board');
// 모든 메뉴 (정적 + 동적) 삭제 확인
$this->assertFalse(
Menu::where('extension_identifier', 'sirsoft-board')->exists(),
'모듈 제거 시 동적 메뉴를 포함한 모든 메뉴가 삭제되어야 합니다'
);
}
}
@@ -0,0 +1,324 @@
<?php
namespace Tests\Feature\Module;
use App\Enums\ExtensionOwnerType;
use App\Extension\ModuleManager;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use ReflectionMethod;
use Tests\TestCase;
/**
* 모듈 역할/권한 동기화 통합 테스트
*
* ExtensionRoleSyncHelper를 통한 실제 DB 기반 동기화를 검증합니다:
* - Permission은 항상 확장 정의값으로 덮어쓰기
* - user_overrides 배열로 유저 커스터마이징 보존
* - DB 기반 diff로 권한 attach/detach
* - stale 권한 정리
*/
class ModuleRolePermissionSyncTest extends TestCase
{
use RefreshDatabase;
private ModuleManager $moduleManager;
/** @var bool 활성 디렉토리가 테스트 전에 이미 존재했는지 (tearDown에서 정리 판단용) */
private bool $boardExistedBefore = false;
protected function setUp(): void
{
parent::setUp();
// 이전 테스트에서 남은 잔존 디렉토리 정리
$activePath = base_path('modules/sirsoft-board');
if (File::isDirectory($activePath) && ! File::exists($activePath.'/module.php')) {
File::deleteDirectory($activePath);
}
$this->boardExistedBefore = File::isDirectory($activePath);
$this->moduleManager = app(ModuleManager::class);
$this->moduleManager->loadModules();
// 기본 시스템 역할 생성
Role::create([
'identifier' => 'admin',
'name' => ['ko' => '시스템 관리자', 'en' => 'System Administrator'],
'description' => ['ko' => '모든 권한을 가진 관리자', 'en' => 'Administrator with all permissions'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'is_active' => true,
]);
Role::create([
'identifier' => 'manager',
'name' => ['ko' => '매니저', 'en' => 'Manager'],
'description' => ['ko' => '관리 권한을 가진 매니저', 'en' => 'Manager with management permissions'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'is_active' => true,
]);
// 테스트용 사용자 생성
User::factory()->create(['email' => 'admin@test.com']);
}
protected function tearDown(): void
{
// 테스트에서 생성한 활성 디렉토리만 정리 (기존에 있었으면 건드리지 않음)
if (! $this->boardExistedBefore) {
$activePath = base_path('modules/sirsoft-board');
if (File::isDirectory($activePath)) {
File::deleteDirectory($activePath);
}
}
parent::tearDown();
}
/**
* ModuleManager의 protected 메서드를 호출하는 헬퍼
*
* @param string $methodName 메서드명
* @param array $args 인자
*/
private function callProtectedMethod(string $methodName, array $args = []): mixed
{
$method = new ReflectionMethod($this->moduleManager, $methodName);
return $method->invokeArgs($this->moduleManager, $args);
}
/**
* 모듈 설치 시 권한이 생성되고 역할에 할당되는지 테스트
*/
public function test_module_install_creates_permissions_and_assigns_to_roles(): void
{
$this->moduleManager->installModule('sirsoft-board');
// 권한 생성 확인 (모듈 루트 1 + 카테고리 3 + 리프 8 = 12)
$permissions = Permission::where('extension_type', ExtensionOwnerType::Module)
->where('extension_identifier', 'sirsoft-board')->get();
$this->assertCount(12, $permissions);
// admin 역할에 할당된 sirsoft-board 권한 확인 (리프 8개)
$adminRole = Role::where('identifier', 'admin')->first();
$adminPermissions = $adminRole->permissions()->where('extension_identifier', 'sirsoft-board')->get();
$this->assertCount(8, $adminPermissions);
// manager 역할에 할당된 sirsoft-board 권한 확인
$managerRole = Role::where('identifier', 'manager')->first();
$managerPermissions = $managerRole->permissions()->where('extension_identifier', 'sirsoft-board')->get();
$this->assertCount(4, $managerPermissions);
}
/**
* Permission은 항상 확장 정의값으로 덮어쓰기되는지 테스트
*/
public function test_reinstall_always_overwrites_permission(): void
{
$this->moduleManager->installModule('sirsoft-board');
$readPerm = Permission::where('identifier', 'sirsoft-board.boards.read')->first();
$originalName = $readPerm->name;
// 재설치 (업데이트 시뮬레이션)
$module = $this->moduleManager->getModule('sirsoft-board');
$this->callProtectedMethod('createModulePermissions', [$module]);
$readPerm->refresh();
// 항상 확장 정의값으로 덮어씀
$this->assertEquals($originalName, $readPerm->name);
}
/**
* 최초 설치 시 역할에 user_overrides가 비어있는지 테스트
*/
public function test_no_user_overrides_on_fresh_install(): void
{
$this->moduleManager->installModule('sirsoft-board');
$adminRole = Role::where('identifier', 'admin')->first();
$managerRole = Role::where('identifier', 'manager')->first();
// 최초 설치 시 user_overrides는 비어있거나 null
$this->assertTrue(
empty($adminRole->user_overrides),
'admin 역할의 user_overrides는 비어있어야 합니다'
);
$this->assertTrue(
empty($managerRole->user_overrides),
'manager 역할의 user_overrides는 비어있어야 합니다'
);
}
/**
* user_overrides에 개별 권한 식별자가 있으면 해당 권한만 보호되는지 테스트
*
* 시나리오:
* 1. 모듈 설치 → admin에 8개 권한 할당
* 2. 유저가 admin에서 boards.delete 해제 + user_overrides에 "sirsoft-board.boards.delete" 기록
* 3. 재설치 (업데이트 시뮬레이션)
* 4. boards.delete만 보호 (해제 상태 유지), 나머지 권한은 정상 동기화
*/
public function test_user_permission_override_preserved_on_reinstall(): void
{
$this->moduleManager->installModule('sirsoft-board');
// 초기 상태 확인 (admin에 리프 8개 할당)
$adminRole = Role::where('identifier', 'admin')->first();
$this->assertCount(8, $adminRole->permissions()->where('extension_identifier', 'sirsoft-board')->get());
// 유저가 admin에서 boards.delete 권한 해제
$deletePerm = Permission::where('identifier', 'sirsoft-board.boards.delete')->first();
$adminRole->permissions()->detach($deletePerm->id);
$this->assertCount(7, $adminRole->permissions()->where('extension_identifier', 'sirsoft-board')->get());
// user_overrides에 개별 권한 식별자 기록 (실제로는 Listener가 수행)
$adminRole->update(['user_overrides' => ['sirsoft-board.boards.delete']]);
// 재설치 (업데이트 시뮬레이션)
$module = $this->moduleManager->getModule('sirsoft-board');
$this->callProtectedMethod('assignPermissionsToRoles', [$module]);
// sirsoft-board.boards.delete만 보호 → 해제 상태 유지 → 7개 유지
$adminRole->refresh();
$this->assertCount(
7,
$adminRole->permissions()->where('extension_identifier', 'sirsoft-board')->get(),
'user_overrides에 개별 권한 식별자가 있으면 해당 권한만 보호되어야 합니다'
);
// boards.delete는 여전히 해제 상태
$this->assertFalse(
$adminRole->permissions()->where('identifier', 'sirsoft-board.boards.delete')->exists(),
'보호된 권한(boards.delete)은 해제 상태가 유지되어야 합니다'
);
}
/**
* user_overrides에 없는 권한은 재설치 시 정상 동기화되는지 테스트
*
* 시나리오:
* 1. 모듈 설치 → admin에 8개 권한 할당
* 2. 유저가 boards.delete 해제 + user_overrides에 기록
* 3. 유저가 boards.read도 해제 (하지만 user_overrides에 미기록 — 가상 시나리오)
* 4. 재설치 → boards.delete는 보호, boards.read는 비보호이므로 다시 attach
*/
public function test_non_overridden_permissions_synced_normally(): void
{
$this->moduleManager->installModule('sirsoft-board');
$adminRole = Role::where('identifier', 'admin')->first();
// 유저가 boards.delete 해제 + user_overrides 기록
$deletePerm = Permission::where('identifier', 'sirsoft-board.boards.delete')->first();
$adminRole->permissions()->detach($deletePerm->id);
$adminRole->update(['user_overrides' => ['sirsoft-board.boards.delete']]);
// boards.read도 해제 (user_overrides 미기록)
$readPerm = Permission::where('identifier', 'sirsoft-board.boards.read')->first();
$adminRole->permissions()->detach($readPerm->id);
$this->assertCount(6, $adminRole->permissions()->where('extension_identifier', 'sirsoft-board')->get());
// 재설치
$module = $this->moduleManager->getModule('sirsoft-board');
$this->callProtectedMethod('assignPermissionsToRoles', [$module]);
$adminRole->refresh();
// boards.read는 비보호 → 다시 attach → 7개
$this->assertTrue(
$adminRole->permissions()->where('identifier', 'sirsoft-board.boards.read')->exists(),
'비보호 권한(boards.read)은 재설치 시 다시 attach되어야 합니다'
);
// boards.delete는 보호 → 해제 상태 유지
$this->assertFalse(
$adminRole->permissions()->where('identifier', 'sirsoft-board.boards.delete')->exists(),
'보호된 권한(boards.delete)은 해제 상태가 유지되어야 합니다'
);
$this->assertCount(
7,
$adminRole->permissions()->where('extension_identifier', 'sirsoft-board')->get(),
'비보호 권한만 동기화되고 보호 권한은 유지되어야 합니다'
);
}
/**
* user_overrides가 없는 역할은 DB 기반 diff로 새 권한만 attach되는지 테스트
*
* 시나리오:
* 1. 모듈 설치 → manager에 4개 권한 할당
* 2. 유저가 manager에서 boards.read 해제 (user_overrides 미기록 — Listener 미동작 가정)
* 3. 재설치
* 4. DB 기반 diff → boards.read가 이미 없으므로 다시 attach됨
*/
public function test_db_based_diff_reattaches_removed_permission_without_user_overrides(): void
{
$this->moduleManager->installModule('sirsoft-board');
$managerRole = Role::where('identifier', 'manager')->first();
$readPerm = Permission::where('identifier', 'sirsoft-board.boards.read')->first();
// 유저가 해제 (user_overrides 미기록)
$managerRole->permissions()->detach($readPerm->id);
$this->assertFalse($managerRole->permissions()->where('identifier', 'sirsoft-board.boards.read')->exists());
// 재설치 (업데이트 시뮬레이션)
$module = $this->moduleManager->getModule('sirsoft-board');
$this->callProtectedMethod('assignPermissionsToRoles', [$module]);
// user_overrides 없음 → DB 기반 diff → boards.read가 다시 attach됨
$this->assertTrue(
$managerRole->permissions()->where('identifier', 'sirsoft-board.boards.read')->exists(),
'user_overrides가 없으면 DB 기반 diff로 누락된 권한이 다시 attach되어야 합니다'
);
}
/**
* 사용자가 수동으로 추가한 역할 할당이 보존되는지 테스트
*/
public function test_user_added_role_assignments_are_preserved(): void
{
$this->moduleManager->installModule('sirsoft-board');
$customRole = Role::create([
'identifier' => 'custom-editor',
'name' => ['ko' => '커스텀 편집자', 'en' => 'Custom Editor'],
'description' => ['ko' => '사용자 정의 편집자 역할', 'en' => 'Custom editor role'],
'is_active' => true,
]);
$readPerm = Permission::where('identifier', 'sirsoft-board.boards.read')->first();
$customRole->permissions()->attach($readPerm->id, [
'granted_at' => now(),
]);
// 재설치 (업데이트 시뮬레이션)
$module = $this->moduleManager->getModule('sirsoft-board');
$this->callProtectedMethod('createModulePermissions', [$module]);
$this->callProtectedMethod('assignPermissionsToRoles', [$module]);
// 사용자가 수동 추가한 할당이 보존됨
$this->assertTrue(
$customRole->permissions()->where('identifier', 'sirsoft-board.boards.read')->exists(),
'사용자가 수동으로 추가한 역할 할당이 보존되어야 합니다'
);
// 기존 admin/manager 할당도 유지됨
$adminRole = Role::where('identifier', 'admin')->first();
$this->assertTrue($adminRole->permissions()->where('identifier', 'sirsoft-board.boards.read')->exists());
$managerRole = Role::where('identifier', 'manager')->first();
$this->assertTrue($managerRole->permissions()->where('identifier', 'sirsoft-board.boards.read')->exists());
}
}
@@ -0,0 +1,312 @@
<?php
namespace Tests\Feature\Module;
use App\Enums\ExtensionOwnerType;
use App\Enums\PermissionType;
use App\Extension\ModuleManager;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use Tests\Helpers\ProtectsExtensionDirectories;
use Tests\TestCase;
class ModuleRolePermissionTest extends TestCase
{
use ProtectsExtensionDirectories;
use RefreshDatabase;
private ModuleManager $moduleManager;
/** @var bool 테스트 전에 활성 디렉토리가 이미 존재했는지 */
private bool $boardExistedBefore = false;
protected function setUp(): void
{
parent::setUp();
$activePath = base_path('modules/sirsoft-board');
$bundledPath = base_path('modules/_bundled/sirsoft-board');
// 이전 세션에서 남은 좀비 디렉토리 정리 (module.php 없는 잔존 디렉토리)
if (File::isDirectory($activePath) && ! File::exists($activePath.'/module.php')) {
File::deleteDirectory($activePath);
}
// 활성 디렉토리 존재 여부 판정
$this->boardExistedBefore = File::isDirectory($activePath)
&& File::exists($activePath.'/module.php');
// 미설치 시 _bundled에서 복사 (테스트 실행을 위한 준비)
if (! $this->boardExistedBefore && File::isDirectory($bundledPath)) {
File::copyDirectory($bundledPath, $activePath);
}
// 확장 디렉토리 보호 활성화 (이후 모든 deleteDirectory 호출 차단)
$this->setUpExtensionProtection();
$this->moduleManager = app(ModuleManager::class);
$this->moduleManager->loadModules();
// 기본 admin role 생성
Role::create([
'identifier' => 'admin',
'name' => ['ko' => '시스템 관리자', 'en' => 'System Administrator'],
'description' => ['ko' => '모든 권한을 가진 관리자', 'en' => 'Administrator with all permissions'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'is_active' => true,
]);
// manager role 생성 (sirsoft-board 권한에서 참조)
Role::create([
'identifier' => 'manager',
'name' => ['ko' => '매니저', 'en' => 'Manager'],
'description' => ['ko' => '관리 권한을 가진 매니저', 'en' => 'Manager with management permissions'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'is_active' => true,
]);
// 테스트용 사용자 생성
User::factory()->create(['email' => 'admin@test.com']);
}
protected function tearDown(): void
{
// 확장 디렉토리 보호 해제 (원래 File 파사드 복원)
$this->tearDownExtensionProtection();
// 테스트에서 생성한 활성 디렉토리만 정리 (기존 설치분은 건드리지 않음)
if (! $this->boardExistedBefore) {
$activePath = base_path('modules/sirsoft-board');
if (File::isDirectory($activePath)) {
File::deleteDirectory($activePath);
}
}
parent::tearDown();
}
/**
* 모듈 설치 시 권한이 생성되는지 테스트
*
* 계층형 구조로 권한이 생성됨:
* - 1레벨: 모듈 노드 (sirsoft-board)
* - 2레벨: 카테고리 노드 (sirsoft-board.boards, sirsoft-board.reports)
* - 3레벨: 개별 권한 (sirsoft-board.boards.read, create, update, delete, sirsoft-board.reports.view, manage)
*/
public function test_module_installation_creates_permissions(): void
{
$this->moduleManager->installModule('sirsoft-board');
// 권한이 생성되었는지 확인 (계층형 구조: 모듈 1 + 카테고리 2 + 개별 권한 6 = 9개)
$permissions = Permission::where('extension_type', ExtensionOwnerType::Module)
->where('extension_identifier', 'sirsoft-board')->get();
$this->assertCount(9, $permissions);
$identifiers = $permissions->pluck('identifier')->toArray();
// 1레벨: 모듈 노드
$this->assertContains('sirsoft-board', $identifiers);
// 2레벨: 카테고리 노드
$this->assertContains('sirsoft-board.boards', $identifiers);
$this->assertContains('sirsoft-board.reports', $identifiers);
// 3레벨: 개별 권한 - boards
$this->assertContains('sirsoft-board.boards.read', $identifiers);
$this->assertContains('sirsoft-board.boards.create', $identifiers);
$this->assertContains('sirsoft-board.boards.update', $identifiers);
$this->assertContains('sirsoft-board.boards.delete', $identifiers);
// 3레벨: 개별 권한 - reports
$this->assertContains('sirsoft-board.reports.view', $identifiers);
$this->assertContains('sirsoft-board.reports.manage', $identifiers);
}
/**
* 모듈 설치 시 권한이 지정된 role에 할당되는지 테스트
*/
public function test_module_installation_assigns_permissions_to_roles(): void
{
$this->moduleManager->installModule('sirsoft-board');
// admin role에 모든 개별 권한(6개)이 할당되었는지 확인
$adminRole = Role::where('identifier', 'admin')->first();
$adminPermissions = $adminRole->permissions()->where('extension_identifier', 'sirsoft-board')->get();
$this->assertCount(6, $adminPermissions);
// manager role에 할당된 권한 확인 (boards: read, create, update + reports: view = 4개)
$managerRole = Role::where('identifier', 'manager')->first();
$managerPermissions = $managerRole->permissions()->where('extension_identifier', 'sirsoft-board')->get();
$this->assertCount(4, $managerPermissions);
$managerIdentifiers = $managerPermissions->pluck('identifier')->toArray();
$this->assertContains('sirsoft-board.boards.read', $managerIdentifiers);
$this->assertContains('sirsoft-board.boards.create', $managerIdentifiers);
$this->assertContains('sirsoft-board.boards.update', $managerIdentifiers);
$this->assertContains('sirsoft-board.reports.view', $managerIdentifiers);
}
/**
* 기존 role에 새 권한 추가 시 기존 권한이 유지되는지 테스트
*/
public function test_existing_role_permissions_are_preserved(): void
{
// admin role에 기존 권한 추가
$adminRole = Role::where('identifier', 'admin')->first();
$existingPermission = Permission::create([
'identifier' => 'core.test.permission',
'name' => ['ko' => '테스트 권한', 'en' => 'Test Permission'],
'description' => ['ko' => '테스트용 권한', 'en' => 'Test permission'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => PermissionType::Admin,
]);
$adminRole->permissions()->attach($existingPermission->id);
// 모듈 설치
$this->moduleManager->installModule('sirsoft-board');
// 기존 권한이 유지되는지 확인
$adminRole->refresh();
$this->assertTrue($adminRole->permissions()->where('identifier', 'core.test.permission')->exists());
// 새 권한도 추가되었는지 확인
$this->assertTrue($adminRole->permissions()->where('identifier', 'sirsoft-board.boards.read')->exists());
}
/**
* 모듈 제거 시 권한이 role에서 분리되는지 테스트
*/
public function test_module_uninstallation_detaches_permissions_from_roles(): void
{
$this->moduleManager->installModule('sirsoft-board');
// admin role에 권한이 있는지 확인
$adminRole = Role::where('identifier', 'admin')->first();
$this->assertTrue($adminRole->permissions()->where('extension_identifier', 'sirsoft-board')->exists());
// 모듈 제거
$this->moduleManager->uninstallModule('sirsoft-board');
// 권한이 분리되었는지 확인
$adminRole->refresh();
$this->assertFalse($adminRole->permissions()->where('extension_identifier', 'sirsoft-board')->exists());
}
/**
* 모듈 제거 시 권한이 삭제되는지 테스트
*/
public function test_module_uninstallation_deletes_permissions(): void
{
$this->moduleManager->installModule('sirsoft-board');
// 권한이 존재하는지 확인
$this->assertTrue(Permission::where('extension_identifier', 'sirsoft-board')->exists());
// 모듈 제거
$this->moduleManager->uninstallModule('sirsoft-board');
// 권한이 삭제되었는지 확인
$this->assertFalse(Permission::where('extension_identifier', 'sirsoft-board')->exists());
}
/**
* 시스템 role은 모듈 제거 시에도 삭제되지 않는지 테스트
*/
public function test_system_roles_are_not_deleted_on_uninstall(): void
{
$this->moduleManager->installModule('sirsoft-board');
$this->moduleManager->uninstallModule('sirsoft-board');
// 시스템 admin role은 삭제되지 않음
$this->assertTrue(Role::where('identifier', 'admin')->exists());
}
/**
* getPermissions() 메서드가 roles 필드를 포함하는지 테스트
*
* 계층형 구조에서 개별 권한(3레벨)에 roles 필드가 있는지 확인
*/
public function test_get_permissions_includes_roles_field(): void
{
$this->moduleManager->installModule('sirsoft-board');
$module = $this->moduleManager->getModule('sirsoft-board');
$permissionConfig = $module->getPermissions();
$this->assertIsArray($permissionConfig);
$this->assertArrayHasKey('categories', $permissionConfig);
$this->assertNotEmpty($permissionConfig['categories']);
// 계층형 구조에서 개별 권한 검증
foreach ($permissionConfig['categories'] as $category) {
$this->assertArrayHasKey('identifier', $category);
$this->assertArrayHasKey('name', $category);
$this->assertArrayHasKey('description', $category);
$this->assertArrayHasKey('permissions', $category);
foreach ($category['permissions'] as $permission) {
$this->assertArrayHasKey('action', $permission);
$this->assertArrayHasKey('name', $permission);
$this->assertArrayHasKey('description', $permission);
$this->assertArrayHasKey('roles', $permission);
$this->assertIsArray($permission['roles']);
}
}
// boards 카테고리의 권한 확인
$boardsCategory = collect($permissionConfig['categories'])->firstWhere('identifier', 'boards');
$this->assertNotNull($boardsCategory);
// read 권한은 admin과 manager 둘 다에게 할당
$readPermission = collect($boardsCategory['permissions'])->firstWhere('action', 'read');
$this->assertContains('admin', $readPermission['roles']);
$this->assertContains('manager', $readPermission['roles']);
// delete 권한은 admin에게만 할당
$deletePermission = collect($boardsCategory['permissions'])->firstWhere('action', 'delete');
$this->assertContains('admin', $deletePermission['roles']);
$this->assertNotContains('manager', $deletePermission['roles']);
}
/**
* roles 필드가 없는 권한은 role에 할당되지 않는지 테스트
*
* 계층형 구조에서 개별 권한(3레벨)만 role에 할당됨.
* 모듈 노드(1레벨)와 카테고리 노드(2레벨)는 할당되지 않음.
*/
public function test_permissions_without_roles_field_are_not_assigned(): void
{
$this->moduleManager->installModule('sirsoft-board');
// 계층형 구조에서 개별 권한(3레벨)만 role에 할당됨
// 모듈의 getPermissions()에서 정의된 개별 권한 6개만 할당되어야 함
$adminRole = Role::where('identifier', 'admin')->first();
// admin role에 할당된 모듈 권한 개수 확인 (개별 권한 6개)
$assignedPermissions = $adminRole->permissions()->where('extension_identifier', 'sirsoft-board')->count();
$this->assertEquals(6, $assignedPermissions);
// 전체 모듈 권한 개수 확인 (모듈 1 + 카테고리 2 + 개별 6 = 9개)
$totalModulePermissions = Permission::where('extension_identifier', 'sirsoft-board')->count();
$this->assertEquals(9, $totalModulePermissions);
// 개별 권한만 role에 할당되었는지 확인
$assignedIdentifiers = $adminRole->permissions()
->where('extension_identifier', 'sirsoft-board')
->pluck('identifier')
->toArray();
$this->assertContains('sirsoft-board.boards.read', $assignedIdentifiers);
$this->assertContains('sirsoft-board.boards.create', $assignedIdentifiers);
$this->assertContains('sirsoft-board.boards.update', $assignedIdentifiers);
$this->assertContains('sirsoft-board.boards.delete', $assignedIdentifiers);
$this->assertContains('sirsoft-board.reports.view', $assignedIdentifiers);
$this->assertContains('sirsoft-board.reports.manage', $assignedIdentifiers);
// 모듈 노드와 카테고리 노드는 할당되지 않음
$this->assertNotContains('sirsoft-board', $assignedIdentifiers);
$this->assertNotContains('sirsoft-board.boards', $assignedIdentifiers);
$this->assertNotContains('sirsoft-board.reports', $assignedIdentifiers);
}
}
@@ -0,0 +1,237 @@
<?php
namespace Tests\Feature\Module;
use App\Enums\ExtensionStatus;
use App\Http\Requests\Module\UpdateModuleRequest;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
/**
* UpdateModuleRequest 검증 테스트
*
* TranslatableField 규칙과 역호환성 처리를 테스트합니다.
*/
class UpdateModuleRequestTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// 한국어 로케일 설정
App::setLocale('ko');
}
/**
* 다국어 배열로 업데이트 요청 시 성공
*/
public function test_passes_with_translatable_array(): void
{
// Arrange
$data = [
'name' => [
'ko' => '테스트 모듈',
'en' => 'Test Module',
],
'description' => [
'ko' => '테스트 모듈 설명',
'en' => 'Test module description',
],
'status' => ExtensionStatus::Active->value,
];
$request = new UpdateModuleRequest;
// Act
$validator = Validator::make($data, $request->rules());
// Assert
$this->assertFalse($validator->fails());
}
/**
* 문자열로 업데이트 요청 시 배열로 변환되어 통과
*/
public function test_converts_string_to_array(): void
{
// Arrange
$data = [
'name' => '테스트 모듈',
'description' => '테스트 설명',
];
// UpdateModuleRequest 인스턴스 생성 및 데이터 주입
$request = UpdateModuleRequest::create('/test', 'POST', $data);
$request->setContainer(app());
$request->validateResolved();
// Act - prepareForValidation이 실행된 후 데이터 확인
$validatedData = $request->validated();
// Assert
$this->assertIsArray($validatedData['name']);
$this->assertIsArray($validatedData['description']);
$this->assertEquals('테스트 모듈', $validatedData['name']['ko']);
$this->assertEquals('테스트 모듈', $validatedData['name']['en']);
}
/**
* name이 maxLength를 초과하면 실패
*/
public function test_fails_when_name_exceeds_max_length(): void
{
// Arrange
$longString = str_repeat('가', 256); // 255자 초과
$data = [
'name' => [
'ko' => $longString,
'en' => 'Test',
],
];
$request = new UpdateModuleRequest;
// Act
$validator = Validator::make($data, $request->rules());
// Assert
$this->assertTrue($validator->fails());
$this->assertArrayHasKey('name', $validator->errors()->toArray());
}
/**
* description이 maxLength를 초과하면 실패
*/
public function test_fails_when_description_exceeds_max_length(): void
{
// Arrange
$longString = str_repeat('가', 1001); // 1000자 초과
$data = [
'description' => [
'ko' => $longString,
'en' => 'Test',
],
];
$request = new UpdateModuleRequest;
// Act
$validator = Validator::make($data, $request->rules());
// Assert
$this->assertTrue($validator->fails());
$this->assertArrayHasKey('description', $validator->errors()->toArray());
}
/**
* status가 허용되지 않은 값이면 실패
*/
public function test_fails_with_invalid_status(): void
{
// Arrange
$data = [
'status' => 'invalid_status',
];
$request = new UpdateModuleRequest;
// Act
$validator = Validator::make($data, $request->rules());
// Assert
$this->assertTrue($validator->fails());
$this->assertArrayHasKey('status', $validator->errors()->toArray());
}
/**
* status가 ExtensionStatus enum 값이면 성공
*/
public function test_passes_with_valid_status_values(): void
{
// Arrange
$request = new UpdateModuleRequest;
// Act & Assert - 모든 유효한 status 값 테스트
foreach (ExtensionStatus::values() as $statusValue) {
$data = ['status' => $statusValue];
$validator = Validator::make($data, $request->rules());
$this->assertFalse($validator->fails(), "Failed with status: {$statusValue}");
}
}
/**
* 모든 필드가 nullable이므로 빈 데이터도 성공
*/
public function test_passes_with_empty_data(): void
{
// Arrange
$data = [];
$request = new UpdateModuleRequest;
// Act
$validator = Validator::make($data, $request->rules());
// Assert
$this->assertFalse($validator->fails());
}
/**
* name만 포함된 업데이트 요청 성공
*/
public function test_passes_with_only_name(): void
{
// Arrange
$data = [
'name' => [
'ko' => '새 이름',
'en' => 'New Name',
],
];
$request = new UpdateModuleRequest;
// Act
$validator = Validator::make($data, $request->rules());
// Assert
$this->assertFalse($validator->fails());
}
/**
* description만 포함된 업데이트 요청 성공
*/
public function test_passes_with_only_description(): void
{
// Arrange
$data = [
'description' => [
'ko' => '새 설명',
'en' => 'New Description',
],
];
$request = new UpdateModuleRequest;
// Act
$validator = Validator::make($data, $request->rules());
// Assert
$this->assertFalse($validator->fails());
}
/**
* status만 포함된 업데이트 요청 성공
*/
public function test_passes_with_only_status(): void
{
// Arrange
$data = [
'status' => ExtensionStatus::Inactive->value,
];
$request = new UpdateModuleRequest;
// Act
$validator = Validator::make($data, $request->rules());
// Assert
$this->assertFalse($validator->fails());
}
}