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:
HeuJung
2026-07-23 13:04:59 +09:00
parent 3044119b37
commit d6e1f98a4f
17 changed files with 562 additions and 66 deletions
+2
View File
@@ -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 매핑으로 훅 발화 누락/과잉 위험).
+73 -1
View File
@@ -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;
}
/**
* 오토로드 파일 내용을 생성합니다.
*
+14 -7
View File
@@ -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;
}
}
+14 -7
View File
@@ -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;
}
}
+15 -6
View File
@@ -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;
}
}
+2
View File
@@ -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)
+17 -3
View File
@@ -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` 등 |
| ❌ 화이트리스트 외 모든 확장자 차단 | 기본 거부 정책 |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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;
@@ -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.': 환경에 따라 달라지는 확장자는 소스맵 하나뿐이어야 합니다.'
);
}
}