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_rejects_sourcemap_file(): void { // Arrange: 맵 파일이 실제로 존재하는 상태를 만든다 $mapPath = $this->testModulePath.'/dist/js/module.iife.js.map'; file_put_contents($mapPath, '{"version":3,"sources":[],"mappings":""}'); $this->assertFileExists($mapPath); // Act: JSON 클라이언트 (에러 본문 확인용) $json = $this->getJson('/api/modules/assets/test-module/dist/js/module.iife.js.map'); // Assert: 허용 확장자 검증에서 거부 — 맵 내용이 절대 응답에 실리지 않는다 $json->assertStatus(422); $json->assertJsonValidationErrors('path'); $this->assertStringNotContainsString('mappings', $json->getContent()); // Act: 브라우저 요청 (Accept 헤더 없음) — 서빙되지 않음을 확인 $browser = $this->get('/api/modules/assets/test-module/dist/js/module.iife.js.map'); // Assert: 200 서빙이 아니어야 한다 (검증 실패 → 리다이렉트) $this->assertNotSame(200, $browser->getStatusCode()); } /** * 운영자가 넣은 `custom/` 자산도 서빙된다. * * 모듈 자산 경로는 확장 루트 기준이라 `custom/` 이 별도 분기 없이 해석된다 * (템플릿만 `dist/` 하드코딩이라 분기가 필요했다). 그 사실을 실제 HTTP 로 고정한다 — * 서비스 반환값만 보면 라우트·FormRequest 게이트를 통과하는지가 남는다. * * @scenario custom_source=convention_scan, custom_asset=css * * @effects custom_asset_loaded_after_extension_bundles */ public function test_serves_operator_custom_css_from_active_module(): void { // Arrange mkdir($this->testModulePath.'/custom', 0755, true); file_put_contents($this->testModulePath.'/custom/custom.css', '.operator { color: red; }'); // Act $response = $this->get('/api/modules/assets/test-module/custom/custom.css'); // Assert $response->assertStatus(200); $this->assertTrue( str_starts_with($response->headers->get('Content-Type'), 'text/css'), 'Content-Type should start with text/css' ); // dist/ 의 동명 파일이 아니라 custom/ 의 파일이어야 한다 — 내용으로 확인한다. // CSS 응답은 상대 참조 치환을 거치므로 본문이 버퍼에 실린다(BinaryFileResponse 아님). // 파일 경로 대신 내용을 보는 편이 "무엇이 나갔는가" 를 직접 재는 것이기도 하다. $this->assertStringContainsString( '.operator { color: red; }', $response->getContent(), 'custom/ 의 파일이 서빙되어야 한다' ); } /** * 비활성 모듈의 `custom/` 은 서빙되지 않는다. * * 비활성 확장의 자산이 계속 응답하면 "껐는데 스타일이 남아 있다" 가 된다. * * @scenario custom_source=convention_scan * * @effects inactive_extension_custom_not_served */ public function test_returns_404_for_custom_asset_of_inactive_module(): void { // Arrange mkdir($this->testModulePath.'/custom', 0755, true); file_put_contents($this->testModulePath.'/custom/custom.css', '.operator { color: red; }'); $this->activeModule->update(['status' => ExtensionStatus::Inactive->value]); // Act $response = $this->get('/api/modules/assets/test-module/custom/custom.css'); // Assert $response->assertStatus(404); } /** * `custom/` 도 확장자 화이트리스트를 그대로 받는다 — 신규 확장자를 열지 않는다. * * @scenario custom_source=convention_scan, custom_asset=static_file * * @effects custom_disallowed_extension_blocked */ public function test_blocks_disallowed_file_type_under_custom(): void { // Arrange mkdir($this->testModulePath.'/custom', 0755, true); file_put_contents($this->testModulePath.'/custom/notes.txt', 'not an asset'); // Act $response = $this->getJson('/api/modules/assets/test-module/custom/notes.txt'); // Assert: FormRequest 의 확장자 게이트가 거부한다 (422) $response->assertStatus(422); $response->assertJsonValidationErrors('path'); $this->assertStringNotContainsString('not an asset', $response->getContent()); } /** * `custom/` 자산도 ETag 로 304 를 돌려준다 — 운영자 파일이라고 캐시 계약이 다르지 않다. * * @scenario custom_source=convention_scan, custom_asset=css * * @effects custom_asset_url_busts_on_file_change */ public function test_returns_304_for_custom_asset_when_etag_matches(): void { // Arrange mkdir($this->testModulePath.'/custom', 0755, true); file_put_contents($this->testModulePath.'/custom/custom.css', '.operator { color: red; }'); $first = $this->get('/api/modules/assets/test-module/custom/custom.css'); $first->assertStatus(200); $etag = $first->headers->get('ETag'); $this->assertNotNull($etag, 'custom 자산에도 ETag 가 있어야 한다'); // Act $second = $this->get( '/api/modules/assets/test-module/custom/custom.css', ['If-None-Match' => $etag] ); // Assert $second->assertStatus(304); } }