## 소스맵 노출 차단 (공개 이슈 )
배포본에 .map 이 포함돼 원본 TS/TSX 전문(sourcesContent, 한글 주석 포함)이
그대로 노출됐다. 생성·서빙·저장소 유입 3계층을 모두 막는다.
- 빌드 config 15곳(코어 3 + _bundled 12): sourcemap 하드코딩 제거.
G7_BUILD_SOURCEMAP 판정식으로 교체 — 미설정(로컬 npm run build)이면 생성해
개발 디버깅 경험을 보존하고, 0 이면 미생성.
- Build{Core,Module,Plugin,Template}Command: 죽어 있던 --production 플래그 복구.
$productionMode 를 콘솔 문자열에만 쓰고 npm 프로세스에 env 를 넘기지 않아
산출물이 개발 빌드와 동일했다. runNpmCommand 에 $env 를 추가해 주입
(--watch 에는 미주입). Symfony Process 는 부모 환경에 병합하므로 PATH 유실 없음.
- Allowed{Module,Plugin}FileType / {Module,Plugin}Service::getMimeType:
허용 확장자 map 제거 — 익명 200 서빙 차단(템플릿은 원래 미허용, 3종 정책 일치).
- ExtensionBundleService: CSS 번들의 /*# sourceMappingURL=... */ strip 추가.
CSS 는 블록 주석이라 JS 의 //# 패턴으로는 검출되지 않던 구멍.
- 추적 .map 13개 삭제 + .gitignore 전역 *.map 규칙(경로 한정 시 새 확장에서 누락).
실측: 산출 JS 의 sourceMappingURL 13 → 0, 추적 .map 13 → 0,
확장 디렉토리 npm run build 는 여전히 map 생성(개발 경험 회귀 없음).
## 관리자 환경설정 탭 지연 로딩
코어/이커머스/게시판 환경설정 화면이 진입 즉시 전 탭의 데이터소스를 한꺼번에
호출했다. 각 데이터소스에 활성 탭 조건(if)을 걸어 해당 탭에서만 fetch 하도록
변경하고, 결제 플러그인 3종의 테스트모드 상태 조회도 주문설정 탭으로 한정.
## 동반 결함 수정 (무관 결함 — 재발 방지 목적으로 같은 세션 처리)
- 나이스페이먼츠: 체크아웃 캐시 프리페치가 라우트를 가리지 않아 플러그인 번들이
로드되는 모든 페이지(홈/게시판/로그인)에서 주문서를 조회 → 비회원 매번 422.
형제 플러그인(kginicis)의 기존 가드 패턴을 적용해 체크아웃 경로로 한정.
- admin smoke 8건: 대부분 구현이 아니라 테스트 결함이었다 — URL 오타(단수 board),
존재하지 않는 권한키, 앱 로케일이 en 인데 한국어 라벨 로케이터, DataGrid 버튼을
먼저 잡던 휴리스틱 로케이터. 레이아웃에는 sticky className / 안정 id 를 부여.
- PHPDoc ↔ Pint 충돌 3건: 설명 없는 @param 은 no_superfluous_phpdoc_tags 가
삭제해 audit phpdoc-public-method 와 무한 충돌. 한국어 설명을 붙여 양쪽 충족
(리스너 3종의 설명 누락 메서드도 전수 보강).
- Log::spy 500 3건: spy 가 Log::channel('activity') 를 null 로 만들고
logActivity 가 null->info 로 \Error → 같은 줄 catch(\Exception) 은 못 잡아
통과. 운영에선 채널이 항상 로거이므로 제품 무결 → 테스트에 activity 채널
보존 헬퍼 추가.
- NhnKcpApiServiceTest 5건: 스텁이 Unix 바이너리만 만들어 Windows 분기
(pp_cli_exe.exe)에서만 실패. #!/bin/sh 는 Windows 실행 불가 → 파일 내 기존
컨벤션대로 skip 가드.
## 테스트
- 소스맵 범위 130 passed (신규: BuildCommandSourcemapEnv, NoSourcemapArtifacts,
ExtensionBundleService CSS strip, Allowed*FileType 반전, 자산 서빙 거부)
- 결제 3플러그인 505 passed / 0 failed (skipped 9 = Windows 전용 CLI, 사유 명시)
- 나이스페이먼츠 프론트 38 passed (가드 제거 시 red 실증 후 복원)
- E2E: production-sourcemap-exposure.spec.ts 6건 + 시나리오 매니페스트
버전: sirsoft-admin_basic 1.0.2 → 1.0.3, sirsoft-board 1.0.1 → 1.0.2
(각 manifest/package/lock/composer 동기화). 코어 7.0.4 는 미출시 누적.
341 lines
13 KiB
PHP
341 lines
13 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Services;
|
|
|
|
use App\Extension\ModuleManager;
|
|
use App\Extension\PluginManager;
|
|
use App\Services\ExtensionBundleService;
|
|
use Illuminate\Support\Facades\File;
|
|
use Mockery;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* ExtensionBundleService 단위 테스트
|
|
*
|
|
* 활성 확장 IIFE/CSS 의 priority 정렬, `\n;\n` 구분자 병합, sourceMappingURL
|
|
* 처리, 확장별 fault tolerance, 캐시 파일 생성/정리를 검증한다.
|
|
*/
|
|
class ExtensionBundleServiceTest extends TestCase
|
|
{
|
|
private string $fixtureDir;
|
|
|
|
private ModuleManager $moduleManager;
|
|
|
|
private PluginManager $pluginManager;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->fixtureDir = storage_path('framework/testing/ext-bundle-fixtures');
|
|
File::ensureDirectoryExists($this->fixtureDir);
|
|
|
|
$this->moduleManager = Mockery::mock(ModuleManager::class);
|
|
$this->pluginManager = Mockery::mock(PluginManager::class);
|
|
|
|
// 번들 캐시 디렉토리 초기화 (테스트 격리)
|
|
$bundleDir = storage_path('app/ext-bundles');
|
|
if (is_dir($bundleDir)) {
|
|
foreach (glob($bundleDir.'/*.{js,css}', GLOB_BRACE) as $f) {
|
|
@unlink($f);
|
|
}
|
|
}
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
if (is_dir($this->fixtureDir)) {
|
|
foreach (glob($this->fixtureDir.'/*') as $f) {
|
|
@unlink($f);
|
|
}
|
|
@rmdir($this->fixtureDir);
|
|
}
|
|
|
|
$bundleDir = storage_path('app/ext-bundles');
|
|
if (is_dir($bundleDir)) {
|
|
foreach (glob($bundleDir.'/*.{js,css}', GLOB_BRACE) as $f) {
|
|
@unlink($f);
|
|
}
|
|
}
|
|
|
|
Mockery::close();
|
|
parent::tearDown();
|
|
}
|
|
|
|
/**
|
|
* 지정 내용으로 fixture 에셋 파일을 만들고 절대 경로를 반환한다.
|
|
*/
|
|
private function writeFixture(string $name, string $content): string
|
|
{
|
|
$path = $this->fixtureDir.'/'.$name;
|
|
File::put($path, $content);
|
|
|
|
return $path;
|
|
}
|
|
|
|
/**
|
|
* hasAssets/getAssetLoadingConfig/getBuiltAssetAbsolutePaths/getIdentifier 를
|
|
* 노출하는 가짜 확장 인스턴스를 만든다.
|
|
*/
|
|
private function fakeExtension(string $identifier, int $priority, ?string $jsPath, ?string $cssPath, string $strategy = 'global'): object
|
|
{
|
|
$ext = Mockery::mock();
|
|
$ext->shouldReceive('hasAssets')->andReturn(true);
|
|
$ext->shouldReceive('getIdentifier')->andReturn($identifier);
|
|
$ext->shouldReceive('getAssetLoadingConfig')->andReturn([
|
|
'strategy' => $strategy,
|
|
'priority' => $priority,
|
|
'dependencies' => [],
|
|
]);
|
|
|
|
$paths = [];
|
|
if ($jsPath !== null) {
|
|
$paths['js'] = $jsPath;
|
|
}
|
|
if ($cssPath !== null) {
|
|
$paths['css'] = $cssPath;
|
|
}
|
|
$ext->shouldReceive('getBuiltAssetAbsolutePaths')->andReturn($paths);
|
|
|
|
return $ext;
|
|
}
|
|
|
|
private function service(): ExtensionBundleService
|
|
{
|
|
return new ExtensionBundleService($this->moduleManager, $this->pluginManager);
|
|
}
|
|
|
|
public function test_orders_global_assets_by_priority_ascending(): void
|
|
{
|
|
$a = $this->writeFixture('a.js', '(function(){})()');
|
|
$b = $this->writeFixture('b.js', '(function(){})()');
|
|
$c = $this->writeFixture('c.js', '(function(){})()');
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-c' => $this->fakeExtension('ext-c', 30, $c, null),
|
|
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
|
|
'ext-b' => $this->fakeExtension('ext-b', 20, $b, null),
|
|
]);
|
|
|
|
$ordered = $this->service()->getOrderedGlobalAssetPaths('module');
|
|
|
|
$this->assertSame(['ext-a', 'ext-b', 'ext-c'], array_keys($ordered));
|
|
}
|
|
|
|
public function test_skips_non_global_strategy(): void
|
|
{
|
|
$g = $this->writeFixture('g.js', '(function(){})()');
|
|
$l = $this->writeFixture('l.js', '(function(){})()');
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-global' => $this->fakeExtension('ext-global', 100, $g, null, 'global'),
|
|
'ext-layout' => $this->fakeExtension('ext-layout', 100, $l, null, 'layout'),
|
|
]);
|
|
|
|
$ordered = $this->service()->getOrderedGlobalAssetPaths('module');
|
|
|
|
$this->assertArrayHasKey('ext-global', $ordered);
|
|
$this->assertArrayNotHasKey('ext-layout', $ordered);
|
|
}
|
|
|
|
public function test_js_bundle_joins_iife_with_semicolon_newline_separator(): void
|
|
{
|
|
// 세미콜론 없이 끝나는 IIFE 2개 (ecommerce 형태)
|
|
$a = $this->writeFixture('a.js', '(function(){window.A=1})()');
|
|
$b = $this->writeFixture('b.js', '(function(){window.B=2})()');
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
|
|
'ext-b' => $this->fakeExtension('ext-b', 20, $b, null),
|
|
]);
|
|
|
|
$js = $this->service()->buildJsBundle('module');
|
|
|
|
// 두 IIFE 가 모두 포함되고 `\n;\n` 구분자로 구분
|
|
$this->assertStringContainsString('window.A=1', $js);
|
|
$this->assertStringContainsString('window.B=2', $js);
|
|
$this->assertStringContainsString("\n;\n", $js);
|
|
// priority 순서 (a 가 b 보다 먼저)
|
|
$this->assertLessThan(strpos($js, 'window.B=2'), strpos($js, 'window.A=1'));
|
|
}
|
|
|
|
public function test_prod_strips_source_mapping_url(): void
|
|
{
|
|
$this->app['env'] = 'production';
|
|
app()->detectEnvironment(fn () => 'production');
|
|
|
|
$a = $this->writeFixture('a.js', "(function(){})()\n//# sourceMappingURL=a.js.map");
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
|
|
]);
|
|
|
|
$js = $this->service()->buildJsBundle('module');
|
|
|
|
$this->assertStringNotContainsString('sourceMappingURL', $js);
|
|
}
|
|
|
|
public function test_dev_rewrites_source_mapping_url_to_asset_serving_path(): void
|
|
{
|
|
// 기본 testing 환경 = 비프로덕션 → dev rewrite 경로
|
|
$a = $this->writeFixture('a.js', "(function(){})()\n//# sourceMappingURL=a.js.map");
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
|
|
]);
|
|
|
|
$js = $this->service()->buildJsBundle('module');
|
|
|
|
$this->assertStringContainsString('sourceMappingURL=/api/modules/assets/ext-a/dist/js/a.js.map', $js);
|
|
}
|
|
|
|
public function test_prod_strips_css_source_mapping_url(): void
|
|
{
|
|
$this->app['env'] = 'production';
|
|
app()->detectEnvironment(fn () => 'production');
|
|
|
|
// CSS 는 JS 와 주석 문법이 달라 블록 주석으로 맵을 참조한다.
|
|
$css = $this->writeFixture('a.css', ".a{color:red}\n/*# sourceMappingURL=a.css.map */");
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-a' => $this->fakeExtension('ext-a', 10, null, $css),
|
|
]);
|
|
|
|
$bundle = $this->service()->buildCssBundle('module');
|
|
|
|
$this->assertStringNotContainsString('sourceMappingURL', $bundle);
|
|
// 스타일 본문은 보존되어야 한다 (주석만 제거)
|
|
$this->assertStringContainsString('.a{color:red}', $bundle);
|
|
}
|
|
|
|
public function test_dev_keeps_css_source_mapping_url(): void
|
|
{
|
|
// 기본 testing 환경 = 비프로덕션 → 개발 디버깅을 위해 원본 유지
|
|
$css = $this->writeFixture('a.css', ".a{color:red}\n/*# sourceMappingURL=a.css.map */");
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-a' => $this->fakeExtension('ext-a', 10, null, $css),
|
|
]);
|
|
|
|
$bundle = $this->service()->buildCssBundle('module');
|
|
|
|
$this->assertStringContainsString('sourceMappingURL', $bundle);
|
|
}
|
|
|
|
public function test_per_extension_fault_tolerance_skips_missing_file(): void
|
|
{
|
|
$good = $this->writeFixture('good.js', '(function(){window.GOOD=1})()');
|
|
$missing = $this->fixtureDir.'/does-not-exist.js';
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-missing' => $this->fakeExtension('ext-missing', 10, $missing, null),
|
|
'ext-good' => $this->fakeExtension('ext-good', 20, $good, null),
|
|
]);
|
|
|
|
$js = $this->service()->buildJsBundle('module');
|
|
|
|
// 없는 파일은 skip, 정상 확장은 여전히 포함 (번들 전체 붕괴 안 함)
|
|
$this->assertStringContainsString('window.GOOD=1', $js);
|
|
}
|
|
|
|
public function test_css_with_relative_url_is_excluded(): void
|
|
{
|
|
$safe = $this->writeFixture('safe.css', '.a{color:red}');
|
|
$relative = $this->writeFixture('rel.css', '.b{background:url(./img/x.png)}');
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-safe' => $this->fakeExtension('ext-safe', 10, null, $safe),
|
|
'ext-rel' => $this->fakeExtension('ext-rel', 20, null, $relative),
|
|
]);
|
|
|
|
$css = $this->service()->buildCssBundle('module');
|
|
|
|
$this->assertStringContainsString('.a{color:red}', $css);
|
|
$this->assertStringNotContainsString('url(./img/x.png)', $css);
|
|
}
|
|
|
|
public function test_empty_bundle_returns_empty_path(): void
|
|
{
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
|
|
|
|
$path = $this->service()->getBundleFilePath('module', 'js', 12345);
|
|
|
|
$this->assertSame('', $path);
|
|
}
|
|
|
|
public function test_prod_writes_versioned_cache_file_and_reuses_it(): void
|
|
{
|
|
$this->app['env'] = 'production';
|
|
app()->detectEnvironment(fn () => 'production');
|
|
|
|
$a = $this->writeFixture('a.js', '(function(){window.A=1})()');
|
|
// getActiveModules 는 두 번 호출될 수 있으므로 안정적으로 반환
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
|
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
|
|
]);
|
|
|
|
$svc = $this->service();
|
|
$path1 = $svc->getBundleFilePath('module', 'js', 999);
|
|
|
|
$this->assertNotSame('', $path1);
|
|
$this->assertFileExists($path1);
|
|
$this->assertStringContainsString('module.999.js', $path1);
|
|
|
|
// 같은 version 재요청 → 동일 파일 (캐시 히트)
|
|
$path2 = $svc->getBundleFilePath('module', 'js', 999);
|
|
$this->assertSame($path1, $path2);
|
|
}
|
|
|
|
public function test_cleanup_removes_stale_version_bundles_only(): void
|
|
{
|
|
$bundleDir = storage_path('app/ext-bundles');
|
|
File::ensureDirectoryExists($bundleDir);
|
|
File::put($bundleDir.'/module.100.js', 'old');
|
|
File::put($bundleDir.'/module.200.js', 'current');
|
|
File::put($bundleDir.'/plugin.100.css', 'old');
|
|
|
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
|
|
|
|
$deleted = $this->service()->cleanupStaleBundles(200);
|
|
|
|
// version 200 외 파일만 삭제 (module.100.js, plugin.100.css = 2건)
|
|
$this->assertSame(2, $deleted);
|
|
$this->assertFileExists($bundleDir.'/module.200.js');
|
|
$this->assertFileDoesNotExist($bundleDir.'/module.100.js');
|
|
$this->assertFileDoesNotExist($bundleDir.'/plugin.100.css');
|
|
}
|
|
|
|
public function test_clear_bundles_by_type(): void
|
|
{
|
|
$bundleDir = storage_path('app/ext-bundles');
|
|
File::ensureDirectoryExists($bundleDir);
|
|
File::put($bundleDir.'/module.100.js', 'm');
|
|
File::put($bundleDir.'/plugin.100.js', 'p');
|
|
|
|
$deleted = $this->service()->clearBundles('module');
|
|
|
|
$this->assertSame(1, $deleted);
|
|
$this->assertFileDoesNotExist($bundleDir.'/module.100.js');
|
|
$this->assertFileExists($bundleDir.'/plugin.100.js');
|
|
}
|
|
|
|
public function test_plugin_bundle_orders_by_priority_gdpr_first_when_lowest(): void
|
|
{
|
|
// gdpr 가 priority 50 으로 최상단(제약 1 회귀 가드) — 이름 하드코딩 아닌 선언 결과
|
|
$gdpr = $this->writeFixture('gdpr.js', '(function(){window.GDPR=1})()');
|
|
$other = $this->writeFixture('other.js', '(function(){window.OTHER=1})()');
|
|
|
|
$this->pluginManager->shouldReceive('getActivePlugins')->andReturn([
|
|
'sirsoft-other' => $this->fakeExtension('sirsoft-other', 100, $other, null),
|
|
'sirsoft-gdpr' => $this->fakeExtension('sirsoft-gdpr', 50, $gdpr, null),
|
|
]);
|
|
|
|
$ordered = $this->service()->getOrderedGlobalAssetPaths('plugin');
|
|
|
|
$this->assertSame('sirsoft-gdpr', array_key_first($ordered));
|
|
|
|
$js = $this->service()->buildJsBundle('plugin');
|
|
$this->assertLessThan(strpos($js, 'window.OTHER=1'), strpos($js, 'window.GDPR=1'));
|
|
}
|
|
}
|