v7.0.0-beta.1 release

This commit is contained in:
HeuJung
2026-04-01 10:30:52 +09:00
commit 6595fd0eb5
3967 changed files with 1875383 additions and 0 deletions
@@ -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');
}
}