v7.0.0-beta.1 release
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user