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, ''); // 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'); } }