fix(core): 확장 오토로드 캐시 공집합 덮어쓰기로 인한 전역 500 + 소스맵 노출 차단 복원
오토로드 캐시(psr4)가 공집합으로 덮어써져 모든 모듈/플러그인 컨트롤러가 해석 불가능해지고 확장 화면 전체가 500 이 됐다. 이 매핑은 "DB 설치 목록 × 디스크 활성 디렉토리" 교집합이라 DB 쪽만 일시적으로 비어도(테이블 부재· 설치 중간 실패·testing DB) 정상 매핑이 통째로 소실된다. 코어 라우트는 200 인데 확장 라우트만 전부 500 인 비대칭이 이 결함의 진단 단서다. - 디스크에 확장 디렉토리가 남아 있는데 매핑이 0건이면 기록하지 않고 보존. 정상적인 0건(신규 설치·마지막 확장 삭제)은 디스크도 함께 비어 통과한다 - generateAutoloadFile 에 testing 실 캐시 경로 쓰기 차단. updateComposerAutoload 에만 있던 가드를 생성기 자체로 옮겨 어느 경로로 들어와도 막히게 했다 - extension:update-autoload 가 훅 캐시 재생성 전에 런타임 오토로드를 재등록. 미재등록 시 깨진 매핑으로 훅 캐시가 modules/plugins 0건으로 생성되어 확장 훅이 조용히 사라지고, 복구에 커맨드 2회 실행이 필요했다 소스맵 서빙은 에서 차단했으나 이번 브랜치에서 화이트리스트에 재추가되어 운영에서 원본 코드 전문(sourcesContent)이 노출될 수 있었다. 이 화이트리스트가 확장 에셋 서빙의 유일한 방어선이다. dev 빌드의 sourceMappingURL 404 를 피하면서 운영을 막도록 local 환경에서만 허용하도록 분기하고, 산출물을 production 으로 재빌드해 docroot 에 남아 있던 소스맵을 제거했다. --production 빌드는 소스맵을 생성하지 않을 뿐 기존 파일을 지우지 않는다. CoreUpdateCommandSpawnFailureTest 는 in-process fallback 이 실제 업그레이드 스텝을 돌려 코어 역할·권한·메뉴를 커밋하는데 RefreshDatabase 가 없어, 이후 모든 테스트 클래스가 중복 키로 깨졌다(단독 실행 시 미재현).
This commit is contained in:
@@ -28,6 +28,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 데이터베이스 연결이 일시적으로 끊기거나 확장 설치가 중간에 실패한 직후 확장 목록을 다시 만들면, 설치된 모듈·플러그인 정보가 통째로 비워지면서 게시판·쇼핑몰 등 모든 확장 화면이 500 오류를 내던 문제를 수정했습니다. 이제 확장 폴더가 그대로 남아 있는데 목록만 비어 있으면 기존 정보를 지우지 않고 보존하며, 왜 갱신하지 않았는지 로그에 남깁니다.
|
||||
- 확장 목록이 손상된 상태에서 복구 명령을 실행하면 목록은 되살아나지만 확장 기능 연결이 비어 있는 채로 남아, 명령을 두 번 실행해야 완전히 복구되던 문제를 수정했습니다. 이제 한 번 실행으로 모두 복구됩니다.
|
||||
- 데이터베이스 계정 설정이 잘못되었을 때 아무 설명 없이 빈 화면만 뜨던 문제를 수정했습니다. 이전에는 최고 권한 계정을 쓰거나 사용자명이 비어 있으면 사이트 기능이 통째로 동작을 멈추면서도 화면에는 아무 안내가 없어 원인을 알 수 없었습니다. 이제 무엇이 잘못되었고 어떻게 고쳐야 하는지 설명하는 전용 안내 화면이 나타납니다. 서버 관리 명령은 평소대로 실행되므로 이 상태에서도 설정을 고칠 수 있습니다.
|
||||
- SEO 설정의 캐시 항목(SEO 캐시 사용·유지 시간, Sitemap 캐시 유지 시간)이 저장해도 적용되지 않던 문제를 수정했습니다. 이제 고급 설정의 캐시 값이 기준이 되고, SEO 설정에서 값을 지정하면 그 값이 우선하며, 비워두면 고급 설정을 따릅니다. 업그레이드 시 기존에 기본값과 다르게 지정해 두셨던 값은 그대로 살아나 이제부터 실제로 적용되므로 SEO 캐시 동작이 달라질 수 있습니다. 기본값 그대로였던 항목은 "지정 안 함"으로 정리되어 동작이 바뀌지 않습니다.
|
||||
- 한글로 쓴 설명이 글자 수 제한에 걸리지 않는데도 "너무 깁니다" 로 거부되던 문제를 수정했습니다. 글자 수를 바이트 단위로 세는 바람에 한글 한 글자가 세 글자로 계산되어, 500자 제한에서 167자만 넘어도 저장이 막혔습니다. 역할·권한·모듈·플러그인·템플릿 설명에 모두 해당합니다.
|
||||
|
||||
@@ -41,6 +41,12 @@ class UpdateAutoloadCommand extends Command
|
||||
$this->info('오토로드 파일이 성공적으로 생성되었습니다.');
|
||||
$this->line(' → bootstrap/cache/autoload-extensions.php');
|
||||
|
||||
// 방금 생성한 PSR-4 매핑을 현재 프로세스의 ClassLoader 에 즉시 반영.
|
||||
// 이 커맨드는 매핑이 깨진 상태를 복구하는 경로이기도 한데, 재등록 없이 아래
|
||||
// loadModules/loadPlugins 를 호출하면 부팅 시점의 (깨진) 매핑으로 확장 클래스를
|
||||
// 찾지 못해 훅 캐시가 modules/plugins 0건으로 생성된다 — 모든 확장 훅이 조용히 소실.
|
||||
$extensionManager->reregisterRuntimeAutoload();
|
||||
|
||||
// 오토로드 캐시와 동일 생명주기의 정적 훅 매핑 캐시도 함께 재생성.
|
||||
// 코어 업데이트(clearAllCaches → 이 커맨드) 시 코어 리스너 추가/변경/삭제가
|
||||
// 캐시에 반영되도록 보장한다 (미갱신 시 stale 매핑으로 훅 발화 누락/과잉 위험).
|
||||
|
||||
@@ -106,7 +106,7 @@ class ExtensionManager
|
||||
* 매핑을 즉시 유효화한다. 기존 매핑에 경로가 추가되거나 새 네임스페이스가
|
||||
* 등록되며, 동일 매핑은 중복 없이 merge 된다.
|
||||
*/
|
||||
protected function reregisterRuntimeAutoload(): void
|
||||
public function reregisterRuntimeAutoload(): void
|
||||
{
|
||||
if (! class_exists(ClassLoader::class, false)) {
|
||||
return;
|
||||
@@ -135,6 +135,14 @@ class ExtensionManager
|
||||
*/
|
||||
public function generateAutoloadFile(): void
|
||||
{
|
||||
// 테스트 환경에서는 실 캐시 파일을 재생성하지 않는다.
|
||||
// testing DB 의 활성 확장 구성은 개발 DB 와 다르므로, 어떤 경로로든 실 캐시가
|
||||
// 재생성되면 개발 환경 PSR-4 매핑이 testing 기준으로 교체되어 모든 모듈/플러그인
|
||||
// 라우트가 500 이 된다. 생성 로직 자체를 검증하는 테스트는 경로를 치환해 사용한다.
|
||||
if ($this->writesRealCacheFileDuringTests()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 모듈별 오토로드 수집
|
||||
$moduleAutoloads = $this->collectModuleAutoloads();
|
||||
|
||||
@@ -170,6 +178,24 @@ class ExtensionManager
|
||||
// 즉시 경로를 반환하도록 한다(성능). 클래스 로딩은 여전히 lazy — 사용 시점에만 include.
|
||||
$srcClassmap = $this->buildSourceClassmap($psr4);
|
||||
|
||||
// 공집합 산출물로 정상 매핑을 덮어쓰지 않는다.
|
||||
// 이 매핑은 "DB 설치 목록 × 디스크 활성 디렉토리" 교집합이라, DB 쪽만 일시적으로
|
||||
// 비어도(테이블 부재 · 설치 실패로 행 미기록 · 다른 DB 접속) 교집합이 공집합이 된다.
|
||||
// 디스크에 설치 형태의 확장 디렉토리가 남아 있는데 매핑이 0건이면 DB 쪽 조회를
|
||||
// 신뢰할 수 없다는 뜻이므로, 기존 파일을 보존하고 경고만 남긴다.
|
||||
// (정상적인 0건 — 신규 설치 직후 · 마지막 확장 삭제 — 은 디스크도 함께 비어 통과)
|
||||
$extensionDirectories = empty($psr4) ? $this->findInstalledExtensionDirectories() : [];
|
||||
|
||||
if (empty($psr4) && ! empty($extensionDirectories)) {
|
||||
Log::warning('확장 오토로드 매핑이 0건으로 산출되어 기존 캐시를 보존합니다', [
|
||||
'path' => $this->autoloadFilePath,
|
||||
'extension_directories' => $extensionDirectories,
|
||||
'hint' => '확장 테이블(modules/plugins) 조회 결과가 비어 있습니다. DB 연결·마이그레이션 상태를 확인한 뒤 extension:update-autoload 를 다시 실행하세요.',
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 내용 생성
|
||||
$content = $this->buildAutoloadFileContent($psr4, $classmap, $files, $vendorAutoloads, $srcClassmap);
|
||||
|
||||
@@ -192,6 +218,52 @@ class ExtensionManager
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 호출이 테스트 환경에서 실 캐시 파일을 건드리는지 판정합니다.
|
||||
*
|
||||
* 경로가 치환된(임시 경로) 인스턴스는 생성 로직 검증용이므로 통과시킨다.
|
||||
*
|
||||
* @return bool 테스트 환경에서 실 캐시 파일을 쓰려는 경우 true
|
||||
*/
|
||||
protected function writesRealCacheFileDuringTests(): bool
|
||||
{
|
||||
return app()->environment('testing')
|
||||
&& $this->autoloadFilePath === base_path('bootstrap/cache/autoload-extensions.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* 디스크에서 설치 형태를 갖춘 확장 디렉토리를 찾습니다.
|
||||
*
|
||||
* `_bundled` / `_pending` 등 내부 디렉토리는 제외하고, composer.json 을 가진
|
||||
* 활성 디렉토리만 센다 (설치된 확장이 남긴 흔적).
|
||||
*
|
||||
* @return array<int, string> 확장 식별자 목록 (예: `modules/sirsoft-board`)
|
||||
*/
|
||||
protected function findInstalledExtensionDirectories(): array
|
||||
{
|
||||
$found = [];
|
||||
|
||||
foreach (['modules' => $this->modulesPath, 'plugins' => $this->pluginsPath] as $type => $basePath) {
|
||||
if (! File::isDirectory($basePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (File::directories($basePath) as $dir) {
|
||||
$name = basename($dir);
|
||||
|
||||
if (str_starts_with($name, '_')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (File::exists($dir.'/composer.json')) {
|
||||
$found[] = $type.'/'.$name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* 오토로드 파일 내용을 생성합니다.
|
||||
*
|
||||
|
||||
@@ -20,10 +20,6 @@ class AllowedModuleFileType implements ValidationRule
|
||||
// Data
|
||||
'json',
|
||||
|
||||
// Source maps — dev 빌드의 `//# sourceMappingURL` 이 개별 에셋 서빙 URL 을
|
||||
// 가리키므로 허용 필요. prod 는 ExtensionBundleService 가 참조 자체를 strip 한다.
|
||||
'map',
|
||||
|
||||
// Images
|
||||
'png', 'jpg', 'jpeg', 'svg', 'webp', 'gif', 'ico',
|
||||
|
||||
@@ -43,11 +39,12 @@ class AllowedModuleFileType implements ValidationRule
|
||||
}
|
||||
|
||||
$extension = strtolower(pathinfo($value, PATHINFO_EXTENSION));
|
||||
$allowed = self::getAllowedExtensions();
|
||||
|
||||
if (! in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
|
||||
if (! in_array($extension, $allowed, true)) {
|
||||
$fail(__('validation.module_path.file_type_not_allowed', [
|
||||
'extension' => $extension,
|
||||
'allowed' => implode(', ', self::ALLOWED_EXTENSIONS),
|
||||
'allowed' => implode(', ', $allowed),
|
||||
]));
|
||||
|
||||
return;
|
||||
@@ -57,10 +54,20 @@ class AllowedModuleFileType implements ValidationRule
|
||||
/**
|
||||
* 허용된 확장자 목록 반환
|
||||
*
|
||||
* @return array<string>
|
||||
* 로컬 개발 환경에서만 소스맵(`map`)을 덧붙인다. dev 빌드 산출물은
|
||||
* `//# sourceMappingURL` 이 개별 에셋 서빙 URL 을 가리키므로 서빙되어야
|
||||
* 브라우저 콘솔에 404 가 남지 않는다. 반면 소스맵에는 원본 코드 전문
|
||||
* (`sourcesContent`)이 담겨 있고 이 화이트리스트가 에셋 서빙의 유일한
|
||||
* 방어선이므로, 운영 환경에서는 어떤 경우에도 서빙하지 않는다.
|
||||
*
|
||||
* @return array<string> 허용된 확장자 목록
|
||||
*/
|
||||
public static function getAllowedExtensions(): array
|
||||
{
|
||||
if (app()->environment('local')) {
|
||||
return [...self::ALLOWED_EXTENSIONS, 'map'];
|
||||
}
|
||||
|
||||
return self::ALLOWED_EXTENSIONS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,6 @@ class AllowedPluginFileType implements ValidationRule
|
||||
// Data
|
||||
'json',
|
||||
|
||||
// Source maps — dev 빌드의 `//# sourceMappingURL` 이 개별 에셋 서빙 URL 을
|
||||
// 가리키므로 허용 필요. prod 는 ExtensionBundleService 가 참조 자체를 strip 한다.
|
||||
'map',
|
||||
|
||||
// Images
|
||||
'png', 'jpg', 'jpeg', 'svg', 'webp', 'gif', 'ico',
|
||||
|
||||
@@ -43,11 +39,12 @@ class AllowedPluginFileType implements ValidationRule
|
||||
}
|
||||
|
||||
$extension = strtolower(pathinfo($value, PATHINFO_EXTENSION));
|
||||
$allowed = self::getAllowedExtensions();
|
||||
|
||||
if (! in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
|
||||
if (! in_array($extension, $allowed, true)) {
|
||||
$fail(__('validation.plugin_path.file_type_not_allowed', [
|
||||
'extension' => $extension,
|
||||
'allowed' => implode(', ', self::ALLOWED_EXTENSIONS),
|
||||
'allowed' => implode(', ', $allowed),
|
||||
]));
|
||||
|
||||
return;
|
||||
@@ -57,10 +54,20 @@ class AllowedPluginFileType implements ValidationRule
|
||||
/**
|
||||
* 허용된 확장자 목록 반환
|
||||
*
|
||||
* @return array<string>
|
||||
* 로컬 개발 환경에서만 소스맵(`map`)을 덧붙인다. dev 빌드 산출물은
|
||||
* `//# sourceMappingURL` 이 개별 에셋 서빙 URL 을 가리키므로 서빙되어야
|
||||
* 브라우저 콘솔에 404 가 남지 않는다. 반면 소스맵에는 원본 코드 전문
|
||||
* (`sourcesContent`)이 담겨 있고 이 화이트리스트가 에셋 서빙의 유일한
|
||||
* 방어선이므로, 운영 환경에서는 어떤 경우에도 서빙하지 않는다.
|
||||
*
|
||||
* @return array<string> 허용된 확장자 목록
|
||||
*/
|
||||
public static function getAllowedExtensions(): array
|
||||
{
|
||||
if (app()->environment('local')) {
|
||||
return [...self::ALLOWED_EXTENSIONS, 'map'];
|
||||
}
|
||||
|
||||
return self::ALLOWED_EXTENSIONS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,6 @@ class AllowedTemplateFileType implements ValidationRule
|
||||
// Data
|
||||
'json',
|
||||
|
||||
// Source maps — dev 빌드 산출물의 소스맵 참조 대응.
|
||||
// prod 빌드는 소스맵 참조를 포함하지 않는다.
|
||||
'map',
|
||||
|
||||
// Images
|
||||
'png', 'jpg', 'jpeg', 'svg', 'webp', 'gif',
|
||||
|
||||
@@ -43,11 +39,12 @@ class AllowedTemplateFileType implements ValidationRule
|
||||
}
|
||||
|
||||
$extension = strtolower(pathinfo($value, PATHINFO_EXTENSION));
|
||||
$allowed = self::getAllowedExtensions();
|
||||
|
||||
if (! in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
|
||||
if (! in_array($extension, $allowed, true)) {
|
||||
$fail(__('validation.template_path.file_type_not_allowed', [
|
||||
'extension' => $extension,
|
||||
'allowed' => implode(', ', self::ALLOWED_EXTENSIONS),
|
||||
'allowed' => implode(', ', $allowed),
|
||||
]));
|
||||
|
||||
return;
|
||||
@@ -56,9 +53,21 @@ class AllowedTemplateFileType implements ValidationRule
|
||||
|
||||
/**
|
||||
* 허용된 확장자 목록 반환
|
||||
*
|
||||
* 로컬 개발 환경에서만 소스맵(`map`)을 덧붙인다. dev 빌드 산출물은
|
||||
* `//# sourceMappingURL` 이 개별 에셋 서빙 URL 을 가리키므로 서빙되어야
|
||||
* 브라우저 콘솔에 404 가 남지 않는다. 반면 소스맵에는 원본 코드 전문
|
||||
* (`sourcesContent`)이 담겨 있고 이 화이트리스트가 에셋 서빙의 유일한
|
||||
* 방어선이므로, 운영 환경에서는 어떤 경우에도 서빙하지 않는다.
|
||||
*
|
||||
* @return array<string> 허용된 확장자 목록
|
||||
*/
|
||||
public static function getAllowedExtensions(): array
|
||||
{
|
||||
if (app()->environment('local')) {
|
||||
return [...self::ALLOWED_EXTENSIONS, 'map'];
|
||||
}
|
||||
|
||||
return self::ALLOWED_EXTENSIONS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,6 +434,8 @@ php artisan template:cache-clear # 전체 번들 파일 정리 포함
|
||||
프로덕션은 version-in-path 디스크 캐시, 비프로덕션(dev/watch)은 캐시 없이 매 요청 concat(rebuild 즉시 반영). `_bundled` 수정 후에는 `{type}:update {id} --force` 로 활성 반영 후 version bump 로 번들이 재생성된다.
|
||||
|
||||
> 개별 에셋 서빙 라우트(`/api/{type}/assets/...`, `*.map` 포함)는 소스맵·static 참조를 위해 존치한다.
|
||||
> 다만 `*.map` 의 **실제 서빙은 `local` 환경에서만** 허용된다 — 소스맵에는 원본 코드 전문이
|
||||
> 담기므로 운영에서는 확장자 화이트리스트가 차단한다. 상세: [template-security.md](template-security.md) "소스맵 (`map`) — 로컬 개발 환경 전용".
|
||||
|
||||
### 전송 압축 (gzip)
|
||||
|
||||
|
||||
@@ -266,14 +266,28 @@ class TemplateService
|
||||
|--------|------|
|
||||
| `js` | JavaScript |
|
||||
| `mjs` | ES Module JavaScript |
|
||||
| `js.map` | JavaScript 소스맵 |
|
||||
|
||||
#### 스타일 파일
|
||||
|
||||
| 확장자 | 설명 |
|
||||
|--------|------|
|
||||
| `css` | Cascading Style Sheets |
|
||||
| `css.map` | CSS 소스맵 |
|
||||
|
||||
#### 소스맵 (`map`) — 로컬 개발 환경 전용
|
||||
|
||||
소스맵에는 원본 TS/TSX 전문(`sourcesContent`)이 담기고, 이 확장자 화이트리스트가 확장 에셋
|
||||
서빙의 유일한 방어선이다. 따라서 `map` 은 `local` 환경에서만 허용 목록에 덧붙고, 그 외
|
||||
환경에서는 어떤 경우에도 서빙하지 않는다.
|
||||
|
||||
로컬에서 허용하는 이유는 dev 빌드 산출물의 `//# sourceMappingURL` 이 개별 에셋 서빙 URL 을
|
||||
가리키기 때문이다 — 차단하면 브라우저 콘솔에 404 가 쌓인다. `--production` 빌드는 소스맵을
|
||||
생성하지 않으므로(`G7_BUILD_SOURCEMAP=0`) 운영에서는 참조 자체가 없다.
|
||||
|
||||
판정 지점은 `Allowed{Module,Plugin,Template}FileType::getAllowedExtensions()` 한 곳이며,
|
||||
`validate()` 도 이 게터를 참조한다(상수 직접 참조 금지 — 환경 분기가 우회된다).
|
||||
|
||||
`public/build/**` 는 docroot 라 이 화이트리스트를 거치지 않는다. `--production` 재빌드는
|
||||
기존 `.map` 을 지우지 않으므로 직접 삭제해야 한다.
|
||||
|
||||
#### 폰트 파일
|
||||
|
||||
@@ -365,7 +379,7 @@ private function getMimeType(string $extension): string
|
||||
|------|------|
|
||||
| ✅ 화이트리스트에 명시된 확장자만 허용 | 목록 외 차단 |
|
||||
| ✅ 대소문자 구분 없이 검증 | `strtolower()` 사용 |
|
||||
| ✅ 소스맵 파일 특별 처리 | `.js.map`, `.css.map` |
|
||||
| ✅ 소스맵은 `local` 에서만 허용 | 운영에서 서빙 시 원본 코드 전문 노출 |
|
||||
| 실행 파일 확장자 금지 | `.php`, `.sh`, `.exe` 등 |
|
||||
| ❌ 서버 설정 파일 금지 | `.htaccess`, `.env` 등 |
|
||||
| ❌ 화이트리스트 외 모든 확장자 차단 | 기본 거부 정책 |
|
||||
|
||||
Vendored
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
@@ -5,8 +5,12 @@ namespace Tests\Feature\Console;
|
||||
use App\Console\Commands\Core\CoreUpdateCommand;
|
||||
use App\Exceptions\UpgradeHandoffException;
|
||||
use App\Services\CoreUpdateService;
|
||||
use Illuminate\Console\OutputStyle;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\BufferedOutput;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
@@ -22,6 +26,13 @@ use Tests\TestCase;
|
||||
*/
|
||||
class CoreUpdateCommandSpawnFailureTest extends TestCase
|
||||
{
|
||||
// in-process fallback 은 실제 업그레이드 스텝을 실행하고, 그 스텝이 코어
|
||||
// 역할·권한·메뉴를 동기화한다(`fresh config 기반 코어 데이터 재동기화`).
|
||||
// 트랜잭션 격리가 없으면 이 시드 데이터가 testing DB 에 영구 커밋되어,
|
||||
// 이후 모든 테스트 클래스의 `Role::create(identifier: 'admin')` 이
|
||||
// UniqueConstraintViolation 으로 깨진다(단독 실행에서는 재현되지 않음).
|
||||
use RefreshDatabase;
|
||||
|
||||
private string $failingStepPath;
|
||||
|
||||
private string $silentStepPath;
|
||||
@@ -46,7 +57,7 @@ class CoreUpdateCommandSpawnFailureTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function failSpawnWithMode_abort_모드는_UpgradeHandoffException_을_throw_한다(): void
|
||||
public function fail_spawn_with_mode_abort_모드는_upgrade_handoff_exception_을_throw_한다(): void
|
||||
{
|
||||
config(['app.update.spawn_failure_mode' => 'abort']);
|
||||
|
||||
@@ -69,7 +80,7 @@ class CoreUpdateCommandSpawnFailureTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function failSpawnWithMode_fallback_모드는_false_를_반환한다(): void
|
||||
public function fail_spawn_with_mode_fallback_모드는_false_를_반환한다(): void
|
||||
{
|
||||
config(['app.update.spawn_failure_mode' => 'fallback']);
|
||||
|
||||
@@ -152,7 +163,7 @@ class CoreUpdateCommandSpawnFailureTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleSpawnExit_executed_0_discovered_0_이면_from_lt_to_여도_정상_통과한다(): void
|
||||
public function handle_spawn_exit_executed_0_discovered_0_이면_from_lt_to_여도_정상_통과한다(): void
|
||||
{
|
||||
// 케이스 B — 범위 내 스텝 파일 부재. from<to 여도 실패 아님 (스텝 불필요 릴리즈).
|
||||
// proc_open 불필요한 순수 판정 로직 단위 검증.
|
||||
@@ -175,7 +186,7 @@ class CoreUpdateCommandSpawnFailureTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleSpawnExit_구버전_자식_discovered_null_은_레거시_fail_fast_판정을_유지한다(): void
|
||||
public function handle_spawn_exit_구버전_자식_discovered_null_은_레거시_fail_fast_판정을_유지한다(): void
|
||||
{
|
||||
// discovered 필드를 모르는 구버전 자식 — executed=0 + discovered=null + from<to.
|
||||
// 신버전 부모는 이 케이스를 기존(레거시) fail-fast 로 판정해야 한다 (안전 우선).
|
||||
@@ -214,7 +225,7 @@ class CoreUpdateCommandSpawnFailureTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function spawn_자식_정상_종료_시_STEPS_EXECUTED_파싱_후_true_반환한다(): void
|
||||
public function spawn_자식_정상_종료_시_step_s_execute_d_파싱_후_true_반환한다(): void
|
||||
{
|
||||
if (! function_exists('proc_open')) {
|
||||
$this->markTestSkipped('proc_open 미지원 환경');
|
||||
@@ -335,7 +346,7 @@ PHP);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function runUpgradeSteps_stale_메모리_감지_시_abort_throw_한다(): void
|
||||
public function run_upgrade_steps_stale_메모리_감지_시_abort_throw_한다(): void
|
||||
{
|
||||
config(['app.version' => '7.0.0-beta.3']);
|
||||
config(['app.update.spawn_failure_mode' => 'abort']);
|
||||
@@ -354,7 +365,7 @@ PHP);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function runUpgradeSteps_stale_메모리_감지_시_fallback_은_경고_후_진행한다(): void
|
||||
public function run_upgrade_steps_stale_메모리_감지_시_fallback_은_경고_후_진행한다(): void
|
||||
{
|
||||
config(['app.version' => '7.0.0-beta.3']);
|
||||
config(['app.update.spawn_failure_mode' => 'fallback']);
|
||||
@@ -369,7 +380,7 @@ PHP);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function runUpgradeSteps_memory_가_target_과_동일하면_가드_미발동(): void
|
||||
public function run_upgrade_steps_memory_가_target_과_동일하면_가드_미발동(): void
|
||||
{
|
||||
config(['app.version' => '7.0.0-beta.5']);
|
||||
config(['app.update.spawn_failure_mode' => 'abort']);
|
||||
@@ -389,9 +400,9 @@ PHP);
|
||||
{
|
||||
$command = app(CoreUpdateCommand::class);
|
||||
|
||||
$input = new \Symfony\Component\Console\Input\ArrayInput([]);
|
||||
$output = new \Symfony\Component\Console\Output\BufferedOutput;
|
||||
$style = new \Illuminate\Console\OutputStyle($input, $output);
|
||||
$input = new ArrayInput([]);
|
||||
$output = new BufferedOutput;
|
||||
$style = new OutputStyle($input, $output);
|
||||
|
||||
$reflection = new \ReflectionClass($command);
|
||||
$property = $reflection->getProperty('output');
|
||||
|
||||
@@ -100,7 +100,7 @@ class HooksCacheCommandTest extends TestCase
|
||||
* 코어 업데이트(CoreUpdateService::clearAllCaches → extension:update-autoload)의
|
||||
* 훅 캐시 재생성 choke point. 코어 리스너 추가/변경/삭제가 이 경로로 캐시에 반영된다.
|
||||
*
|
||||
* 격리: ExtensionManager::generateAutoloadFile() 은 mock 으로 no-op 처리한다.
|
||||
* 격리: ExtensionManager 의 generateAutoloadFile() / reregisterRuntimeAutoload() 은 mock 으로 no-op 처리한다.
|
||||
* 실제 커맨드를 그대로 실행하면 testing DB(활성 확장 0건) 기준으로 실 경로
|
||||
* `bootstrap/cache/autoload-extensions.php` 를 빈 배열로 덮어써 dev 환경을 오염시킨다
|
||||
* (feedback_test_must_protect_dev_directories). 훅 캐시는 setUp 의 임시 경로 매니저로 격리.
|
||||
@@ -111,9 +111,11 @@ class HooksCacheCommandTest extends TestCase
|
||||
*/
|
||||
public function test_extension_update_autoload_regenerates_hook_cache(): void
|
||||
{
|
||||
// 실 오토로드 캐시 파일을 건드리지 않도록 generateAutoloadFile 을 no-op 으로 대체
|
||||
// 실 오토로드 캐시 파일과 현재 프로세스의 ClassLoader 를 건드리지 않도록
|
||||
// ExtensionManager 의 부수효과 메서드를 no-op 으로 대체 (생성자 미실행 partial mock).
|
||||
$extensionManager = Mockery::mock(ExtensionManager::class)->makePartial();
|
||||
$extensionManager->shouldReceive('generateAutoloadFile')->once();
|
||||
$extensionManager->shouldReceive('reregisterRuntimeAutoload')->once();
|
||||
$this->app->instance(ExtensionManager::class, $extensionManager);
|
||||
|
||||
$this->assertFalse(File::exists($this->tmpCachePath));
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
||||
use App\Contracts\Repositories\PluginRepositoryInterface;
|
||||
use App\Extension\ExtensionManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 확장 오토로드 캐시(bootstrap/cache/autoload-extensions.php) 파괴 방지 가드 테스트.
|
||||
*
|
||||
* 이 캐시는 `Modules\*` / `Plugins\*` PSR-4 매핑의 단일 소스라, 빈 배열로 덮어써지면
|
||||
* 모든 모듈/플러그인 컨트롤러가 "Target class does not exist" 로 사이트 전역 500 이 된다.
|
||||
*
|
||||
* 생성기는 "DB 설치 목록 × 디스크 활성 디렉토리" 교집합이므로, DB 쪽만 일시적으로
|
||||
* 비어도(테이블 부재 · 설치 중간 상태 · testing DB) 교집합이 공집합이 되어 정상 매핑이
|
||||
* 소실된다. 본 테스트는 그 소실을 차단하는 두 가드를 고정한다.
|
||||
*/
|
||||
class ExtensionAutoloadCacheGuardTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 실 오토로드 캐시 파일 경로
|
||||
*/
|
||||
private string $realCachePath;
|
||||
|
||||
/**
|
||||
* 실 캐시 파일의 테스트 시작 시점 내용 (없으면 null)
|
||||
*/
|
||||
private ?string $realCacheBackup = null;
|
||||
|
||||
/**
|
||||
* 임시 오토로드 캐시 파일 경로
|
||||
*/
|
||||
private string $tmpCachePath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->realCachePath = base_path('bootstrap/cache/autoload-extensions.php');
|
||||
$this->tmpCachePath = storage_path('framework/testing/autoload_guard_'.uniqid().'.php');
|
||||
|
||||
File::ensureDirectoryExists(dirname($this->tmpCachePath));
|
||||
|
||||
// 가드가 없는 상태(= 최초 fail 실행)에서도 개발 환경 캐시가 파괴되지 않도록 원본 보존.
|
||||
if (File::exists($this->realCachePath)) {
|
||||
$this->realCacheBackup = File::get($this->realCachePath);
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if ($this->realCacheBackup !== null) {
|
||||
File::put($this->realCachePath, $this->realCacheBackup);
|
||||
}
|
||||
|
||||
if (File::exists($this->tmpCachePath)) {
|
||||
File::delete($this->tmpCachePath);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 디스크에 활성 확장 디렉토리가 있는데 DB 조회 결과가 0건이면 빈 매핑을 쓰지 않아야 한다.
|
||||
*
|
||||
* DB 쪽이 신뢰 불가한 상태(테이블 부재 · 설치 중간 · 다른 DB)에서 산출된 공집합을
|
||||
* 그대로 기록하면 정상 매핑이 소실된다. 이 경우 기존 파일을 보존해야 한다.
|
||||
*/
|
||||
public function test_generate_autoload_file_preserves_existing_map_when_db_reports_none_but_directories_exist(): void
|
||||
{
|
||||
$this->assertNotEmpty(
|
||||
$this->installedLookingExtensionDirectories(),
|
||||
'이 테스트는 디스크에 활성 확장 디렉토리가 존재하는 저장소에서만 유효합니다.'
|
||||
);
|
||||
|
||||
// RefreshDatabase 로 modules/plugins 테이블은 비어 있다 (= DB 설치 목록 0건).
|
||||
$manager = $this->managerWritingTo($this->tmpCachePath);
|
||||
|
||||
// 정상 매핑이 이미 존재하는 상태를 재현
|
||||
File::put($this->tmpCachePath, $this->cacheFileWithPsr4([
|
||||
'Modules\\Sirsoft\\Board\\' => 'modules/sirsoft-board/src/',
|
||||
]));
|
||||
|
||||
$manager->generateAutoloadFile();
|
||||
|
||||
$written = require $this->tmpCachePath;
|
||||
|
||||
$this->assertNotEmpty(
|
||||
$written['psr4'],
|
||||
'DB 조회가 0건이어도 디스크에 활성 확장 디렉토리가 있으면 기존 PSR-4 매핑을 빈 배열로 덮어쓰면 안 됩니다. '
|
||||
.'(덮어쓰면 모든 모듈/플러그인 라우트가 500 — Target class does not exist)'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트 환경에서는 실 bootstrap/cache 캐시 파일을 절대 건드리지 않아야 한다.
|
||||
*
|
||||
* testing DB 는 활성 확장 구성이 개발 DB 와 다르므로, 어떤 경로로든 실 캐시가
|
||||
* 재생성되면 개발 환경 매핑이 testing 기준으로 교체되어 사이트가 깨진다.
|
||||
*/
|
||||
public function test_generate_autoload_file_does_not_touch_real_cache_path_in_testing_env(): void
|
||||
{
|
||||
$this->assertNotNull(
|
||||
$this->realCacheBackup,
|
||||
'이 테스트는 실 오토로드 캐시가 생성된 환경에서만 유효합니다 (extension:update-autoload 선행 필요).'
|
||||
);
|
||||
|
||||
app(ExtensionManager::class)->generateAutoloadFile();
|
||||
|
||||
$this->assertSame(
|
||||
$this->realCacheBackup,
|
||||
File::get($this->realCachePath),
|
||||
'testing 환경에서는 실 bootstrap/cache/autoload-extensions.php 를 재생성하면 안 됩니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* extension:update-autoload 는 훅 캐시 재생성 전에 런타임 오토로드를 재등록해야 한다.
|
||||
*
|
||||
* 미재등록 시 현재 프로세스의 ClassLoader 는 (방금 교체된) 이전 매핑을 들고 있어
|
||||
* ModuleManager/PluginManager 가 확장 클래스를 로드하지 못하고, 훅 캐시가
|
||||
* modules/plugins 0건으로 생성된다 — 모든 확장 훅이 조용히 사라진다.
|
||||
*/
|
||||
public function test_update_autoload_command_reregisters_runtime_autoload_before_hook_cache(): void
|
||||
{
|
||||
$body = File::get(app_path('Console/Commands/Extension/UpdateAutoloadCommand.php'));
|
||||
|
||||
$reregisterPos = strpos($body, 'reregisterRuntimeAutoload(');
|
||||
$hookCachePos = strpos($body, '->generate($moduleManager, $pluginManager)');
|
||||
|
||||
$this->assertNotFalse(
|
||||
$reregisterPos,
|
||||
'extension:update-autoload 는 런타임 오토로드 재등록을 호출해야 합니다.'
|
||||
);
|
||||
$this->assertNotFalse($hookCachePos, '훅 캐시 재생성 호출을 찾을 수 없습니다.');
|
||||
|
||||
$this->assertLessThan(
|
||||
$hookCachePos,
|
||||
$reregisterPos,
|
||||
'런타임 오토로드 재등록은 훅 캐시 재생성보다 먼저 수행되어야 합니다 '
|
||||
.'(순서가 뒤바뀌면 훅 캐시가 modules/plugins 0건으로 생성됨).'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 경로에 기록하는 ExtensionManager 인스턴스를 생성합니다.
|
||||
*
|
||||
* @param string $path 오토로드 캐시 파일 경로
|
||||
* @return ExtensionManager 경로가 치환된 매니저
|
||||
*/
|
||||
private function managerWritingTo(string $path): ExtensionManager
|
||||
{
|
||||
return new class(app(ModuleRepositoryInterface::class), app(PluginRepositoryInterface::class), $path) extends ExtensionManager
|
||||
{
|
||||
public function __construct(
|
||||
ModuleRepositoryInterface $moduleRepository,
|
||||
PluginRepositoryInterface $pluginRepository,
|
||||
string $path
|
||||
) {
|
||||
parent::__construct($moduleRepository, $pluginRepository);
|
||||
$this->autoloadFilePath = $path;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 디스크에 존재하는 "설치된 것으로 보이는" 확장 디렉토리 목록을 반환합니다.
|
||||
*
|
||||
* @return array<int, string> 디렉토리 절대경로 목록
|
||||
*/
|
||||
private function installedLookingExtensionDirectories(): array
|
||||
{
|
||||
$found = [];
|
||||
|
||||
foreach (['modules', 'plugins'] as $type) {
|
||||
$base = base_path($type);
|
||||
if (! File::isDirectory($base)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (File::directories($base) as $dir) {
|
||||
if (str_starts_with(basename($dir), '_')) {
|
||||
continue;
|
||||
}
|
||||
if (File::exists($dir.'/composer.json')) {
|
||||
$found[] = $dir;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주어진 PSR-4 매핑을 담은 캐시 파일 내용을 생성합니다.
|
||||
*
|
||||
* @param array<string, string> $psr4 PSR-4 매핑
|
||||
* @return string 캐시 파일 내용
|
||||
*/
|
||||
private function cacheFileWithPsr4(array $psr4): string
|
||||
{
|
||||
return '<?php return '.var_export([
|
||||
'psr4' => $psr4,
|
||||
'classmap' => [],
|
||||
'src_classmap' => [],
|
||||
'files' => [],
|
||||
'vendor_autoloads' => [],
|
||||
], true).';';
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,39 @@ class NoSourcemapArtifactsTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 docroot(`public/build`)에 소스맵 산출물이 남아 있지 않은지 테스트
|
||||
*
|
||||
* 확장 에셋과 달리 `public/` 은 웹서버가 직접 서빙하는 docroot 라, 확장자
|
||||
* 화이트리스트(AllowedModuleFileType 등)를 전혀 거치지 않는다. 즉 여기 남은
|
||||
* `.map` 은 URL 만 알면 그대로 내려받을 수 있고 `sourcesContent` 에 원본
|
||||
* 전문이 담겨 있다.
|
||||
*
|
||||
* `--production` 빌드는 `.map` 을 생성하지 않지만 **기존 파일을 지우지도 않는다**.
|
||||
* 따라서 한 번이라도 dev 빌드를 돌린 트리에는 stale 한 소스맵이 그대로 남는다
|
||||
* (실측: `GET /build/core/template-engine.min.js.map` → 200 + 원본 노출).
|
||||
*/
|
||||
public function test_public_build_dir_has_no_sourcemap_artifacts(): void
|
||||
{
|
||||
$offenders = [];
|
||||
|
||||
foreach (glob(base_path('public/build/**/*.map')) ?: [] as $path) {
|
||||
$offenders[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $path);
|
||||
}
|
||||
|
||||
foreach (glob(base_path('public/build/*.map')) ?: [] as $path) {
|
||||
$offenders[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $path);
|
||||
}
|
||||
|
||||
$this->assertSame(
|
||||
[],
|
||||
$offenders,
|
||||
"공개 docroot 에 소스맵이 남아 있습니다. URL 로 직접 내려받아 원본 코드가 노출됩니다.\n".
|
||||
"`--production` 재빌드는 기존 .map 을 지우지 않으므로 직접 삭제해야 합니다:\n ".
|
||||
implode("\n ", $offenders)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성(서빙) 확장 디렉토리에 소스맵 산출물이 남아 있지 않은지 테스트
|
||||
*
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Tests\Unit\Extension;
|
||||
use App\Enums\ExtensionStatus;
|
||||
use App\Enums\LayoutSourceType;
|
||||
use App\Extension\TemplateManager;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Models\Template;
|
||||
use App\Models\TemplateLayout;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -46,7 +47,7 @@ class TemplateManagerOverrideTest extends TestCase
|
||||
private function createTestTemplateStructure(): void
|
||||
{
|
||||
// 기본 디렉토리 생성
|
||||
File::makeDirectory($this->testTemplatePath . '/layouts/overrides/sirsoft-sample', 0755, true);
|
||||
File::makeDirectory($this->testTemplatePath.'/layouts/overrides/sirsoft-sample', 0755, true);
|
||||
|
||||
// template.json 생성
|
||||
$templateJson = [
|
||||
@@ -57,7 +58,7 @@ class TemplateManagerOverrideTest extends TestCase
|
||||
'type' => 'admin',
|
||||
'description' => ['ko' => '테스트용 템플릿', 'en' => 'Test template'],
|
||||
];
|
||||
File::put($this->testTemplatePath . '/template.json', json_encode($templateJson, JSON_PRETTY_PRINT));
|
||||
File::put($this->testTemplatePath.'/template.json', json_encode($templateJson, JSON_PRETTY_PRINT));
|
||||
|
||||
// 기본 레이아웃 생성
|
||||
$baseLayout = [
|
||||
@@ -65,7 +66,7 @@ class TemplateManagerOverrideTest extends TestCase
|
||||
'meta' => ['title' => 'Admin Base'],
|
||||
'components' => [['component' => 'Container', 'children' => []]],
|
||||
];
|
||||
File::put($this->testTemplatePath . '/layouts/_admin_base.json', json_encode($baseLayout, JSON_PRETTY_PRINT));
|
||||
File::put($this->testTemplatePath.'/layouts/_admin_base.json', json_encode($baseLayout, JSON_PRETTY_PRINT));
|
||||
|
||||
// 오버라이드 레이아웃 생성
|
||||
$overrideLayout = [
|
||||
@@ -78,7 +79,7 @@ class TemplateManagerOverrideTest extends TestCase
|
||||
],
|
||||
];
|
||||
File::put(
|
||||
$this->testTemplatePath . '/layouts/overrides/sirsoft-sample/admin_index.json',
|
||||
$this->testTemplatePath.'/layouts/overrides/sirsoft-sample/admin_index.json',
|
||||
json_encode($overrideLayout, JSON_PRETTY_PRINT)
|
||||
);
|
||||
}
|
||||
@@ -154,8 +155,8 @@ class TemplateManagerOverrideTest extends TestCase
|
||||
*/
|
||||
public function test_generate_override_layout_name(): void
|
||||
{
|
||||
$basePath = $this->testTemplatePath . '/layouts/overrides/sirsoft-sample';
|
||||
$filePath = $basePath . '/admin_index.json';
|
||||
$basePath = $this->testTemplatePath.'/layouts/overrides/sirsoft-sample';
|
||||
$filePath = $basePath.'/admin_index.json';
|
||||
|
||||
// Windows/Unix 경로 정규화
|
||||
$normalizedBasePath = str_replace('/', DIRECTORY_SEPARATOR, $basePath);
|
||||
@@ -175,10 +176,10 @@ class TemplateManagerOverrideTest extends TestCase
|
||||
*/
|
||||
public function test_generate_override_layout_name_with_nested_path(): void
|
||||
{
|
||||
$basePath = $this->testTemplatePath . '/layouts/overrides/sirsoft-sample';
|
||||
File::makeDirectory($basePath . '/products', 0755, true);
|
||||
$basePath = $this->testTemplatePath.'/layouts/overrides/sirsoft-sample';
|
||||
File::makeDirectory($basePath.'/products', 0755, true);
|
||||
|
||||
$filePath = $basePath . '/products/list.json';
|
||||
$filePath = $basePath.'/products/list.json';
|
||||
|
||||
// Windows/Unix 경로 정규화
|
||||
$normalizedBasePath = str_replace('/', DIRECTORY_SEPARATOR, $basePath);
|
||||
@@ -199,13 +200,13 @@ class TemplateManagerOverrideTest extends TestCase
|
||||
public function test_scan_override_layout_files(): void
|
||||
{
|
||||
// 디렉토리 구조 생성
|
||||
$overridesPath = $this->testTemplatePath . '/layouts/overrides/sirsoft-sample';
|
||||
File::makeDirectory($overridesPath . '/nested', 0755, true);
|
||||
$overridesPath = $this->testTemplatePath.'/layouts/overrides/sirsoft-sample';
|
||||
File::makeDirectory($overridesPath.'/nested', 0755, true);
|
||||
|
||||
$testLayout = ['slots' => []];
|
||||
File::put($overridesPath . '/index.json', json_encode($testLayout));
|
||||
File::put($overridesPath . '/edit.json', json_encode($testLayout));
|
||||
File::put($overridesPath . '/nested/detail.json', json_encode($testLayout));
|
||||
File::put($overridesPath.'/index.json', json_encode($testLayout));
|
||||
File::put($overridesPath.'/edit.json', json_encode($testLayout));
|
||||
File::put($overridesPath.'/nested/detail.json', json_encode($testLayout));
|
||||
|
||||
$reflection = new \ReflectionClass($this->templateManager);
|
||||
$method = $reflection->getMethod('scanLayoutFilesRecursively');
|
||||
@@ -290,8 +291,8 @@ class TemplateManagerOverrideTest extends TestCase
|
||||
public function test_register_overrides_for_multiple_modules(): void
|
||||
{
|
||||
// 두 모듈의 오버라이드 디렉토리 생성
|
||||
$module1Path = $this->testTemplatePath . '/layouts/overrides/sirsoft-module1';
|
||||
$module2Path = $this->testTemplatePath . '/layouts/overrides/sirsoft-module2';
|
||||
$module1Path = $this->testTemplatePath.'/layouts/overrides/sirsoft-module1';
|
||||
$module2Path = $this->testTemplatePath.'/layouts/overrides/sirsoft-module2';
|
||||
File::makeDirectory($module1Path, 0755, true);
|
||||
File::makeDirectory($module2Path, 0755, true);
|
||||
|
||||
@@ -303,13 +304,13 @@ class TemplateManagerOverrideTest extends TestCase
|
||||
'version' => '1.0.0',
|
||||
'type' => 'admin',
|
||||
];
|
||||
File::put($this->testTemplatePath . '/template.json', json_encode($templateJson));
|
||||
File::put($this->testTemplatePath.'/template.json', json_encode($templateJson));
|
||||
|
||||
// 각 모듈의 오버라이드 레이아웃 생성
|
||||
$layout1 = ['layout_name' => 'sirsoft-module1_admin_index', 'slots' => []];
|
||||
$layout2 = ['layout_name' => 'sirsoft-module2_admin_index', 'slots' => []];
|
||||
File::put($module1Path . '/admin_index.json', json_encode($layout1));
|
||||
File::put($module2Path . '/admin_index.json', json_encode($layout2));
|
||||
File::put($module1Path.'/admin_index.json', json_encode($layout1));
|
||||
File::put($module2Path.'/admin_index.json', json_encode($layout2));
|
||||
|
||||
// 템플릿 로드
|
||||
$this->templateManager->loadTemplates();
|
||||
@@ -404,7 +405,7 @@ class TemplateManagerOverrideTest extends TestCase
|
||||
public function test_handles_missing_overrides_directory(): void
|
||||
{
|
||||
// 기본 템플릿 구조만 생성 (overrides 없음)
|
||||
File::makeDirectory($this->testTemplatePath . '/layouts', 0755, true);
|
||||
File::makeDirectory($this->testTemplatePath.'/layouts', 0755, true);
|
||||
|
||||
$templateJson = [
|
||||
'identifier' => 'test-admin',
|
||||
@@ -413,7 +414,7 @@ class TemplateManagerOverrideTest extends TestCase
|
||||
'version' => '1.0.0',
|
||||
'type' => 'admin',
|
||||
];
|
||||
File::put($this->testTemplatePath . '/template.json', json_encode($templateJson));
|
||||
File::put($this->testTemplatePath.'/template.json', json_encode($templateJson));
|
||||
|
||||
$this->templateManager->loadTemplates();
|
||||
|
||||
@@ -472,21 +473,35 @@ class TemplateManagerOverrideTest extends TestCase
|
||||
Cache::put('g7:core:ext.cache_version', $expectedVersion);
|
||||
|
||||
// ClearsTemplateCaches trait의 정적 메서드 호출
|
||||
$version = \App\Extension\Traits\ClearsTemplateCaches::getExtensionCacheVersion();
|
||||
$version = ClearsTemplateCaches::getExtensionCacheVersion();
|
||||
|
||||
$this->assertEquals($expectedVersion, $version);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 버전이 설정되지 않은 경우 0 반환
|
||||
* 캐시 버전 키가 없으면 유효한 새 버전을 생성·저장하는지 테스트
|
||||
*
|
||||
* 0 을 반환하면 버전 기반 캐시 무효화가 통째로 붕괴한다(모든 URL 이 `?v=0`
|
||||
* 으로 고정되어 갱신이 전파되지 않음). 키 부재는 캐시 플러시·TTL 만료로
|
||||
* 언제든 발생하므로, 그 자리에서 새 버전을 만들어 저장한다.
|
||||
*/
|
||||
public function test_get_extension_cache_version_returns_zero_when_not_set(): void
|
||||
public function test_get_extension_cache_version_regenerates_when_not_set(): void
|
||||
{
|
||||
Cache::forget('g7:core:ext.cache_version');
|
||||
|
||||
$version = \App\Extension\Traits\ClearsTemplateCaches::getExtensionCacheVersion();
|
||||
$beforeTime = time();
|
||||
$version = ClearsTemplateCaches::getExtensionCacheVersion();
|
||||
$afterTime = time();
|
||||
|
||||
$this->assertEquals(0, $version);
|
||||
$this->assertGreaterThan(0, $version, '키 부재 시 0 을 반환하면 캐시 무효화가 붕괴합니다.');
|
||||
$this->assertGreaterThanOrEqual($beforeTime, $version);
|
||||
$this->assertLessThanOrEqual($afterTime, $version);
|
||||
|
||||
$this->assertEquals(
|
||||
$version,
|
||||
Cache::get('g7:core:ext.cache_version'),
|
||||
'생성한 버전은 캐시에 저장되어 다음 조회에서 재사용되어야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Rules;
|
||||
|
||||
use App\Rules\AllowedModuleFileType;
|
||||
use App\Rules\AllowedPluginFileType;
|
||||
use App\Rules\AllowedTemplateFileType;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 소스맵 에셋 서빙의 환경별 허용 계약 테스트.
|
||||
*
|
||||
* 소스맵에는 원본 TS/TSX 전문(`sourcesContent`)이 담기고, 확장 에셋 서빙에서
|
||||
* 이 확장자 화이트리스트가 유일한 방어선이다. 따라서 운영 환경에서는 어떤
|
||||
* 경우에도 `.map` 이 서빙되면 안 된다.
|
||||
*
|
||||
* 동시에 로컬 개발 환경의 dev 빌드 산출물은 `//# sourceMappingURL` 이 개별
|
||||
* 에셋 서빙 URL 을 가리키므로, 차단하면 브라우저 콘솔에 404 가 쌓인다.
|
||||
* 두 요구를 환경으로 가른다.
|
||||
*/
|
||||
class SourcemapServingEnvironmentTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 검증 대상 규칙 클래스 목록
|
||||
*
|
||||
* @return array<string, array{class-string, string}>
|
||||
*/
|
||||
public static function ruleProvider(): array
|
||||
{
|
||||
return [
|
||||
'module' => [AllowedModuleFileType::class, 'dist/js/module.iife.js.map'],
|
||||
'plugin' => [AllowedPluginFileType::class, 'dist/js/plugin.iife.js.map'],
|
||||
'template' => [AllowedTemplateFileType::class, 'dist/js/components.iife.js.map'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영 환경에서 소스맵이 차단되는지 테스트
|
||||
*
|
||||
* @param class-string $ruleClass 규칙 클래스
|
||||
* @param string $sourcemapPath 소스맵 경로
|
||||
*/
|
||||
#[DataProvider('ruleProvider')]
|
||||
public function test_sourcemap_is_blocked_in_production(string $ruleClass, string $sourcemapPath): void
|
||||
{
|
||||
$this->app->detectEnvironment(fn () => 'production');
|
||||
|
||||
$this->assertNotContains('map', $ruleClass::getAllowedExtensions());
|
||||
|
||||
$validator = Validator::make(['path' => $sourcemapPath], ['path' => new $ruleClass]);
|
||||
|
||||
$this->assertFalse(
|
||||
$validator->passes(),
|
||||
$ruleClass.': 운영 환경에서 소스맵이 서빙되면 원본 코드 전문이 노출됩니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로컬 개발 환경에서 소스맵이 허용되는지 테스트
|
||||
*
|
||||
* @param class-string $ruleClass 규칙 클래스
|
||||
* @param string $sourcemapPath 소스맵 경로
|
||||
*/
|
||||
#[DataProvider('ruleProvider')]
|
||||
public function test_sourcemap_is_allowed_in_local(string $ruleClass, string $sourcemapPath): void
|
||||
{
|
||||
$this->app->detectEnvironment(fn () => 'local');
|
||||
|
||||
$this->assertContains('map', $ruleClass::getAllowedExtensions());
|
||||
|
||||
$validator = Validator::make(['path' => $sourcemapPath], ['path' => new $ruleClass]);
|
||||
|
||||
$this->assertTrue(
|
||||
$validator->passes(),
|
||||
$ruleClass.': 로컬 개발 환경에서는 dev 빌드의 소스맵 참조가 404 가 되지 않도록 허용해야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스맵 외 확장자는 환경과 무관하게 동일하게 허용되는지 테스트
|
||||
*
|
||||
* 환경 분기가 소스맵에만 적용되고 다른 확장자에는 영향을 주지 않아야 한다.
|
||||
*
|
||||
* @param class-string $ruleClass 규칙 클래스
|
||||
*/
|
||||
#[DataProvider('ruleProvider')]
|
||||
public function test_non_sourcemap_extensions_are_environment_independent(string $ruleClass): void
|
||||
{
|
||||
$this->app->detectEnvironment(fn () => 'production');
|
||||
$production = $ruleClass::getAllowedExtensions();
|
||||
|
||||
$this->app->detectEnvironment(fn () => 'local');
|
||||
$local = $ruleClass::getAllowedExtensions();
|
||||
|
||||
$this->assertSame(
|
||||
['map'],
|
||||
array_values(array_diff($local, $production)),
|
||||
$ruleClass.': 환경에 따라 달라지는 확장자는 소스맵 하나뿐이어야 합니다.'
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user