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 committed 2026-07-23 13:04:59 +09:00
1 parent 3044119b37
commit d6e1f98a4f
17 files changed
+562 -66

No files matched your search

+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` 등 |
| ❌ 화이트리스트 외 모든 확장자 차단 | 기본 거부 정책 |
-1
View File
@@ -1558,4 +1558,3 @@ details[open] > .g7dt-summary::before {
}
`,ve=()=>{const d="g7-devtools-styles";if(document.getElementById(d))return;const t=document.createElement("style");t.id=d,t.textContent=be,document.head.appendChild(t)},le={state:"상태",actions:"액션",cache:"캐시",diagnose:"진단",performance:"성능",lifecycle:"라이프사이클",network:"네트워크",form:"Form",conditional:"조건부",expression:"표현식",handlers:"핸들러",events:"이벤트",datasources:"데이터소스",renders:"렌더링",styles:"스타일",auth:"인증",logs:"로그",layout:"레이아웃",changedetection:"변경감지",staleclosure:"Stale Closure",nestedcontext:"Context",modalscope:"모달스코프",namedactions:"Named Actions"},Ne=700,ue=500,re=400,ce=300,ne="g7-devtools-panel",te=d=>d===null||d===void 0?"g7dt-text-gray":typeof d=="string"?"g7dt-text-yellow":typeof d=="number"?"g7dt-text-cyan":typeof d=="boolean"?"g7dt-text-pink":Array.isArray(d)?"g7dt-text-orange":typeof d=="object"?"g7dt-text-blue":"g7dt-text-white",ye=d=>d===null?"null":d===void 0?"undefined":Array.isArray(d)?`Array(${d.length})`:typeof d=="object"?`Object(${Object.keys(d).length})`:typeof d,we=d=>d===null?"null":d===void 0?"undefined":typeof d=="string"?d.length>50?`"${d.substring(0,50)}..."`:`"${d}"`:typeof d=="boolean"?d?"true":"false":typeof d=="number"?String(d):"",oe=()=>{try{const d=localStorage.getItem(ne);if(d)return JSON.parse(d)}catch(d){console.warn("[G7DevTools] 설정 로드 실패:",d)}return{}},ie=d=>{try{const s={...oe(),...d};localStorage.setItem(ne,JSON.stringify(s))}catch(t){console.warn("[G7DevTools] 설정 저장 실패:",t)}},W=({keyName:d,value:t,path:s,expandedPaths:a,onToggle:g,searchTerm:r,depth:o=0})=>{const p=t!==null&&typeof t=="object",v=a.has(s),l=Array.isArray(t),f=r&&(d.toLowerCase().includes(r.toLowerCase())||typeof t=="string"&&t.toLowerCase().includes(r.toLowerCase())||typeof t=="number"&&String(t).includes(r)),c=y=>{y.stopPropagation(),p&&g(s)},h=p?Object.entries(t):[];return e.jsxs("div",{style:{marginLeft:o>0?"16px":"0"},children:[e.jsxs("div",{className:`g7dt-tree-node g7dt-flex g7dt-items-center g7dt-gap-1 ${f?"g7dt-ring-highlight":""}`,onClick:c,children:[p?e.jsx("span",{className:"g7dt-text-gray g7dt-w-4 g7dt-text-xs g7dt-select-none",style:{textAlign:"center"},children:v?"▼":"▶"}):e.jsx("span",{className:"g7dt-w-4"}),e.jsx("span",{className:"g7dt-text-purple g7dt-text-xs",children:d}),e.jsx("span",{className:"g7dt-text-gray-dark g7dt-text-xs",children:":"}),p?e.jsxs("span",{className:`g7dt-text-xs ${te(t)}`,children:[l?"[":"{",!v&&e.jsx("span",{className:"g7dt-text-gray",style:{margin:"0 4px"},children:ye(t)}),!v&&(l?"]":"}")]}):e.jsx("span",{className:`g7dt-text-xs ${te(t)}`,children:we(t)}),!p&&e.jsxs("span",{className:"g7dt-text-gray-dark g7dt-text-xs g7dt-ml-1",children:["(",typeof t,")"]})]}),p&&v&&e.jsxs("div",{className:"g7dt-border-l",style:{marginLeft:"8px"},children:[h.map(([y,n])=>e.jsx(W,{keyName:l?`[${y}]`:y,value:n,path:`${s}.${y}`,expandedPaths:a,onToggle:g,searchTerm:r,depth:o+1},`${s}.${y}`)),e.jsx("div",{className:"g7dt-ml-4 g7dt-text-xs",children:e.jsx("span",{className:te(t),children:l?"]":"}"})})]})]})},ke=({state:d,devTools:t})=>{const[s,a]=x.useState(new Set(["_global","_local","_computed","_isolated","$parent"])),[g,r]=x.useState(""),[o,p]=x.useState(!1),[v,l]=x.useState(!1),[f,c]=x.useState(null),h=x.useCallback(()=>{t&&c(t.getComputedDependencyTrackingInfo())},[t]);x.useEffect(()=>{if(v&&t){h();const w=setInterval(h,2e3);return()=>clearInterval(w)}},[v,t,h]);const y=w=>{switch(w){case"state-change":return"📊";case"prop-change":return"📥";case"dependency-change":return"🔗";case"manual":return"👆";case"initial":return"🚀";default:return"❓"}},n=x.useCallback(w=>{a(S=>{const z=new Set(S);return z.has(w)?z.delete(w):z.add(w),z})},[]),i=x.useCallback((w,S)=>{const z=[S];if(w!==null&&typeof w=="object")for(const[O,K]of Object.entries(w))z.push(...i(K,`${S}.${O}`));return z},[]),u=x.useCallback(()=>{if(o)a(new Set(["_global","_local","_computed","_isolated","$parent"]));else{const w=new Set;i(d._global,"_global").forEach(S=>w.add(S)),i(d._local,"_local").forEach(S=>w.add(S)),i(d._computed,"_computed").forEach(S=>w.add(S)),i(d._isolated,"_isolated").forEach(S=>w.add(S)),d.$parent&&i(d.$parent,"$parent").forEach(S=>w.add(S)),a(w)}p(!o)},[o,d,i]),m=Object.keys(d._global||{}).length,k=Object.keys(d._local||{}).length,b=Object.keys(d._computed||{}).length,N=Object.keys(d._isolated||{}).length,C=Object.keys(d.$parent||{}).length;return e.jsxs("div",{className:"g7dt-space-y-4",children:[e.jsxs("div",{className:"g7dt-flex g7dt-gap-2 g7dt-items-center g7dt-flex-shrink-0",children:[e.jsx("input",{type:"text",placeholder:"키 또는 값 검색...",value:g,onChange:w=>r(w.target.value),className:"g7dt-input g7dt-flex-1"}),e.jsx("button",{onClick:u,className:"g7dt-btn g7dt-bg-gray-700",children:o?"모두 접기":"모두 펼치기"})]}),e.jsxs("div",{className:"g7dt-flex g7dt-gap-3 g7dt-text-xs g7dt-text-gray g7dt-flex-wrap g7dt-flex- Line truncated
... (truncated)`:v}catch{return"(직렬화 불가)"}};return e.jsxs("div",{className:"g7dt-space-y-4",children:[t.activeRequests.length>0&&e.jsxs("div",{children:[e.jsxs("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-text-yellow g7dt-mb-2",children:["진행 중 (",t.activeRequests.length,")"]}),e.jsx("div",{className:"g7dt-space-y-1",children:t.activeRequests.map(o=>e.jsxs("details",{className:"g7dt-details",children:[e.jsxs("summary",{className:"g7dt-summary g7dt-pending-request",children:[e.jsx("span",{className:"g7dt-font-bold",children:o.method}),e.jsx("span",{className:"g7dt-ml-2 g7dt-text-lightgray g7dt-truncate",children:o.fullUrl||o.url})]}),e.jsxs("div",{className:"g7dt-card g7dt-mt-1 g7dt-text-xs",children:[a(o)&&e.jsxs("div",{className:"g7dt-mb-2",children:[e.jsx("div",{className:"g7dt-text-blue g7dt-font-bold g7dt-mb-1",children:"쿼리 파라미터"}),e.jsx("div",{className:"g7dt-pl-2 g7dt-border-l-2 g7dt-border-blue",children:g(o.queryParams)})]}),o.requestBody&&e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-orange g7dt-font-bold g7dt-mb-1",children:"요청 본문"}),e.jsx("pre",{className:"g7dt-pre g7dt-text-xs",children:r(o.requestBody,2e3)})]})]})]},o.id))})]}),e.jsxs("div",{children:[e.jsx("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-mb-2",children:"요청 이력"}),e.jsx("div",{className:"g7dt-space-y-1 g7dt-scroll-list-lg",children:t.requestHistory.length===0?e.jsx("div",{className:"g7dt-empty-message",children:"요청 이력 없음"}):t.requestHistory.slice(-20).reverse().map(o=>e.jsxs("details",{className:"g7dt-details",children:[e.jsxs("summary",{className:"g7dt-summary g7dt-card g7dt-text-xs",children:[e.jsxs("div",{className:"g7dt-flex g7dt-justify-between g7dt-items-center g7dt-w-full",children:[e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-font-bold",children:o.method}),e.jsx("span",{className:`g7dt-ml-2 ${s(o.status)}`,children:o.statusCode||o.status})]}),e.jsxs("span",{className:"g7dt-text-gray",children:[o.duration,"ms"]})]}),e.jsx("div",{className:"g7dt-text-gray g7dt-truncate g7dt-mt-1",children:o.fullUrl||o.url})]}),e.jsxs("div",{className:"g7dt-card g7dt-mt-1 g7dt-text-xs g7dt-ml-2 g7dt-border-l-2 g7dt-border-gray",children:[e.jsx("div",{className:"g7dt-mb-2",children:e.jsxs("div",{className:"g7dt-text-xs g7dt-space-y-1",children:[e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"URL:"})," ",e.jsx("span",{className:"g7dt-text-lightgray",children:o.fullUrl||o.url})]}),e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"상태:"})," ",e.jsx("span",{className:s(o.status),children:o.statusCode||o.status})]}),e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"소요시간:"})," ",e.jsxs("span",{className:"g7dt-text-lightgray",children:[o.duration,"ms"]})]}),o.dataSourceId&&e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"데이터소스:"})," ",e.jsx("span",{className:"g7dt-text-blue",children:o.dataSourceId})]}),o.error&&e.jsxs("div",{children:[e.jsx("span",{className:"g7dt-text-gray",children:"에러:"})," ",e.jsx("span",{className:"g7dt-text-red",children:o.error})]})]})}),a(o)&&e.jsxs("div",{className:"g7dt-mb-2",children:[e.jsx("div",{className:"g7dt-text-blue g7dt-font-bold g7dt-mb-1",children:"쿼리 파라미터"}),e.jsx("div",{className:"g7dt-pl-2 g7dt-border-l-2 g7dt-border-blue",children:g(o.queryParams)})]}),o.requestBody&&e.jsxs("div",{className:"g7dt-mb-2",children:[e.jsx("div",{className:"g7dt-text-orange g7dt-font-bold g7dt-mb-1",children:"요청 본문"}),e.jsx("pre",{className:"g7dt-pre g7dt-text-xs g7dt-max-h-40 g7dt-overflow-auto",children:r(o.requestBody,2e3)})]}),o.response!==void 0&&e.jsxs("div",{children:[e.jsx("div",{className:"g7dt-text-green g7dt-font-bold g7dt-mb-1",children:"응답 내용"}),e.jsx("pre",{className:"g7dt-pre g7dt-text-xs g7dt-max-h-60 g7dt-overflow-auto",children:r(o.response,5e3)})]})]})]},o.id))})]}),t.pendingDataSources.length>0&&e.jsxs("div",{children:[e.jsxs("h3",{className:"g7dt-text-sm g7dt-font-bold g7dt-text-blue g7dt-mb-2",children:["대기 중인 데이터소스 (",t.pendingDataSources.length,")"]}),e.jsx("div",{className:"g7dt-flex g7dt-flex-wrap g7dt-gap-2",children:t.pendingDataSources.map(o=>e.jsx("span",{className:"g7dt-tag-blue",children:o},o))})]})]})},Ee=({devTools:d})=>{const t=d.getFormInfo(),[s,a]=x.useState(!1),[g,r]=x.useState(null),o=x.useCallback(()=>{r(d.getFormBindingValidationTrackingInfo())},[d]);x.useEffect(()=>{if(s){o();const l=setInterval(o,2e3);return()=>clearInterval(l)}},[s,o]);const p=l=>{switch(l){case"missing-datakey":return"🔑";case"missing-input-name":return"📝";case"context-not-propagated":return"🔗";case"sortable-context-break":return"📊";case"modal-context-isolation":return"🪟";case"duplicate-input-name":return"👯";case"value-type-mismatch":return"⚠️";case"binding-path-invalid":return"❌";default:return"❓"}},v=l=>{switch(l){case"error":return"g7dt- Line truncated
//# sourceMappingURL=devtools.min.js.map
-1
View File
@@ -11,4 +11,3 @@ var iM=Object.defineProperty;var sM=(dn,i,u)=>i in dn?iM(dn,i,{enumerable:!0,con
Press space again to drop the item in its new position, or press escape to cancel.
`},z7={onDragStart(e){let{active:t}=e;return"Picked up draggable item "+t.id+"."},onDragOver(e){let{active:t,over:n}=e;return n?"Draggable item "+t.id+" was moved over droppable area "+n.id+".":"Draggable item "+t.id+" is no longer over a droppable area."},onDragEnd(e){let{active:t,over:n}=e;return n?"Draggable item "+t.id+" was dropped over droppable area "+n.id:"Draggable item "+t.id+" was dropped."},onDragCancel(e){let{active:t}=e;return"Dragging was cancelled. Draggable item "+t.id+" was dropped."}};function M7(e){let{announcements:t=z7,container:n,hiddenTextDescribedById:o,screenReaderInstructions:r=N7}=e;const{announce:s,announcement:a}=A7(),l=ei("DndLiveRegion"),[d,c]=u.useState(!1);if(u.useEffect(()=>{c(!0)},[]),T7(u.useMemo(()=>({onDragStart(f){let{active:y}=f;s(t.onDragStart({active:y}))},onDragMove(f){let{active:y,over:g}=f;t.onDragMove&&s(t.onDragMove({active:y,over:g}))},onDragOver(f){let{active:y,over:g}=f;s(t.onDragOver({active:y,over:g}))},onDragEnd(f){let{active:y,over:g}=f;s(t.onDragEnd({active:y,over:g}))},onDragCancel(f){let{active:y,over:g}=f;s(t.onDragCancel({active:y,over:g}))}}),[s,t])),!d)return null;const p=u.createElement(u.Fragment,null,u.createElement(E7,{id:o,value:r.draggable}),u.createElement(I7,{id:l,announcement:a}));return n?Cn.createPortal(p,n):p}var it;(function(e){e.DragStart="dragStart",e.DragMove="dragMove",e.DragEnd="dragEnd",e.DragCancel="dragCancel",e.DragOver="dragOver",e.RegisterDroppable="registerDroppable",e.SetDroppableDisabled="setDroppableDisabled",e.UnregisterDroppable="unregisterDroppable"})(it||(it={}));function oi(){}function L7(e,t){return u.useMemo(()=>({sensor:e,options:t??{}}),[e,t])}function P7(){for(var e=arguments.length,t=new Array(e),n=0;n<e;n++)t[n]=arguments[n];return u.useMemo(()=>[...t].filter(o=>o!=null),[...t])}const Ht=Object.freeze({x:0,y:0});function O7(e,t){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function B7(e,t){const n=ni(e);if(!n)return"0 0";const o={x:(n.x-t.left)/t.width*100,y:(n.y-t.top)/t.height*100};return o.x+"% "+o.y+"%"}function W7(e,t){let{data:{value:n}}=e,{data:{value:o}}=t;return n-o}function j7(e,t){let{data:{value:n}}=e,{data:{value:o}}=t;return o-n}function H7(e){let{left:t,top:n,height:o,width:r}=e;return[{x:t,y:n},{x:t+r,y:n},{x:t,y:n+o},{x:t+r,y:n+o}]}function F7(e,t){if(!e||e.length===0)return null;const[n]=e;return n[t]}function K7(e,t){const n=Math.max(t.top,e.top),o=Math.max(t.left,e.left),r=Math.min(t.left+t.width,e.left+e.width),s=Math.min(t.top+t.height,e.top+e.height),a=r-o,l=s-n;if(o<r&&n<s){const d=t.width*t.height,c=e.width*e.height,p=a*l,f=p/(d+c-p);return Number(f.toFixed(4))}return 0}const V7=e=>{let{collisionRect:t,droppableRects:n,droppableContainers:o}=e;const r=[];for(const s of o){const{id:a}=s,l=n.get(a);if(l){const d=K7(l,t);d>0&&r.push({id:a,data:{droppableContainer:s,value:d}})}}return r.sort(j7)};function U7(e,t){const{top:n,left:o,bottom:r,right:s}=t;return n<=e.y&&e.y<=r&&o<=e.x&&e.x<=s}const q7=e=>{let{droppableContainers:t,droppableRects:n,pointerCoordinates:o}=e;if(!o)return[];const r=[];for(const s of t){const{id:a}=s,l=n.get(a);if(l&&U7(o,l)){const c=H7(l).reduce((f,y)=>f+O7(o,y),0),p=Number((c/4).toFixed(4));r.push({id:a,data:{droppableContainer:s,value:p}})}}return r.sort(W7)};function G7(e,t,n){return{...e,scaleX:t&&n?t.width/n.width:1,scaleY:t&&n?t.height/n.height:1}}function jp(e,t){return e&&t?{x:e.left-t.left,y:e.top-t.top}:Ht}function Y7(e){return function(n){for(var o=arguments.length,r=new Array(o>1?o-1:0),s=1;s<o;s++)r[s-1]=arguments[s];return r.reduce((a,l)=>({...a,top:a.top+e*l.y,bottom:a.bottom+e*l.y,left:a.left+e*l.x,right:a.right+e*l.x}),{...n})}}const X7=Y7(1);function Hp(e){if(e.startsWith("matrix3d(")){const t=e.slice(9,-1).split(/, /);return{x:+t[12],y:+t[13],scaleX:+t[0],scaleY:+t[5]}}else if(e.startsWith("matrix(")){const t=e.slice(7,-1).split(/, /);return{x:+t[4],y:+t[5],scaleX:+t[0],scaleY:+t[3]}}return null}function J7(e,t,n){const o=Hp(t);if(!o)return e;const{scaleX:r,scaleY:s,x:a,y:l}=o,d=e.left-a-(1-r)*parseFloat(n),c=e.top-l-(1-s)*parseFloat(n.slice(n.indexOf(" ")+1)),p=r?e.width/r:e.width,f=s?e.height/s:e.height;return{width:p,height:f,top:c,right:d+p,bottom:c+f,left:d}}const Z7={ignoreTransform:!1};function jo(e,t){t===void 0&&(t=Z7);let n=e.getBoundingClientRect();if(t.ignoreTransform){const{transform:c,transformOrigin:p}=_t(e).getComputedStyle(e);c&&(n=J7(n,c,p))}const{top:o,left:r,width:s,height:a,bottom:l,right:d}=n;return{top:o,left:r,width:s,height:a,bottom:l,right:d}}function Fp(e){return jo(e,{ignoreTransform:!0})}function Q7(e){const t=e.innerWidth,n=e.innerHeight;return{top:0,left:0,right:t,bottom:n,width:t,height:n}}function R7(e,t){return t===void 0&&(t=_t(e).getComputedStyle(e)),t.position==="fixed"}function e2(e,t){t===void 0&&(t=_t(e).getComputedStyle(e));const n=/(auto|scroll|overlay)/;return["overflow","overflowX","overflowY"].some(r=>{const s=t[r];return typeof s=="string"?n.test(s):!1})}function da(e,t){const n=[]; Line truncated
`:"",e.message]})]}),i.jsxs("div",{style:{display:"flex",justifyContent:"center",gap:8,flexWrap:"wrap"},children:[e.kind==="unauthorized"&&i.jsx("button",{type:"button","data-testid":"g7le-access-error-action-signin",onClick:a,style:Ka,children:t("layout_editor.access_error.action.sign_in")}),e.kind==="forbidden"&&i.jsx("button",{type:"button","data-testid":"g7le-access-error-action-home",onClick:r,style:Ka,children:t("layout_editor.access_error.action.go_home")}),(e.kind==="not_found"||e.kind==="server_error"||e.kind==="network"||e.kind==="unknown")&&i.jsx("button",{type:"button","data-testid":"g7le-access-error-action-retry",onClick:s,style:Ka,children:t("layout_editor.access_error.action.retry")}),e.kind!=="unauthorized"&&i.jsx("button",{type:"button","data-testid":"g7le-access-error-action-home-secondary",onClick:r,style:_4,children:t("layout_editor.access_error.action.go_admin_home")})]})]})]})}const Ka={padding:"8px 16px",background:"#2563eb",color:"#fff",border:"1px solid #1d4ed8",borderRadius:6,fontSize:13,fontWeight:600,cursor:"pointer"},_4={padding:"8px 16px",background:"#fff",color:"#475569",border:"1px solid #cbd5e1",borderRadius:6,fontSize:13,fontWeight:500,cursor:"pointer"};function x4(e){const t=window;if(typeof t>"u")return{getSnapshot:()=>({globalState:{},localState:{}}),restore:()=>{}};const n=t.G7Core||{},o={templateApp:t.__templateApp,g7CoreState:n.state,g7CoreDispatch:n.dispatch,g7CoreData:n.data,g7CoreModal:n.modal,g7CoreToast:n.toast};let r={...e.initialGlobalState??{}};r._local||(r._local={});const s=[],a=()=>{const c={globalState:{...r},localState:{...r._local??{}}};for(const p of s)try{p(r)}catch{}e.onChange?.(c)},l={},d={getGlobalState:()=>r,setGlobalState:(c,p)=>{typeof c=="function"?r=c(r):r={...r,...c},a()},onGlobalStateChange:c=>(s.push(c),()=>{const p=s.indexOf(c);p>=0&&s.splice(p,1)}),get globalState(){return r},getActionDispatcher:()=>e.actionDispatcher,getDataSource:c=>l[c]??null,setDataSource:(c,p)=>{l[c]=p},refetchDataSource:async c=>null,updateDataSourceItem:(c,p,f)=>l[c]??null,getLocale:()=>e.locale,changeLocale:c=>{},getRouter:()=>({navigate:()=>{},replace:()=>{},push:()=>{}}),updateQueryParams:()=>{}};return t.__templateApp=d,n&&typeof n.dispatch=="function"&&(n.dispatch=(c,p)=>{try{return e.actionDispatcher.executeAction(c,p??{})}catch{return}}),n&&typeof n.modal=="object"&&n.modal!==null&&(n.modal={...n.modal,open:()=>{},close:()=>{},closeAll:()=>{}}),n&&typeof n.toast=="object"&&n.toast!==null&&(n.toast={...n.toast,push:()=>{},dismiss:()=>{}}),{getSnapshot:()=>({globalState:{...r},localState:{...r._local??{}}}),restore:()=>{t.__templateApp===d&&(t.__templateApp=o.templateApp),t.G7Core&&(o.g7CoreState!==void 0&&(t.G7Core.state=o.g7CoreState),o.g7CoreDispatch!==void 0&&(t.G7Core.dispatch=o.g7CoreDispatch),o.g7CoreData!==void 0&&(t.G7Core.data=o.g7CoreData),o.g7CoreModal!==void 0&&(t.G7Core.modal=o.g7CoreModal),o.g7CoreToast!==void 0&&(t.G7Core.toast=o.g7CoreToast)),s.length=0}}}const w4=new Set(["id","userId","postId","orderId","productId","categoryId","commentId","noticeId","inquiryId","addressId","wishlistId","notificationId","roleId","menuId"]);function k4(e){if(!e)return{};const t={},n=/:([a-zA-Z_][a-zA-Z0-9_]*)/g;let o;for(;(o=n.exec(e))!==null;){const r=o[1];t[r]=S4(r)}return t}function S4(e){return w4.has(e)||/Id$|_id$/.test(e)?"1":"sample"}const C4=dr("LayoutEditor");function Xo(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function bi(e){if(Array.isArray(e))return e.map(t=>bi(t));if(Xo(e)){const t={};for(const[n,o]of Object.entries(e))t[n]=bi(o);return t}return e}function hg(e,t="",n=new Set){for(const[o,r]of Object.entries(e)){const s=t?`${t}.${o}`:o;Xo(r)?hg(r,s,n):n.add(s)}return n}function yg(e,t,n,o,r,s=""){for(const[a,l]of Object.entries(t)){const d=s?`${s}.${a}`:a;if(n.has(d)){r(`[LayoutEditor] sampleGlobal key conflict: extension '${o}' override of core key '${d}' ignored`);continue}const c=e[a];Xo(l)&&Xo(c)?yg(c,l,n,o,r,d):e[a]=bi(l)}}function Va(e){const t=e.warn??(r=>C4.warn(r)),n=bi(e.coreSeed??{});e.isGuestOnly&&"currentUser"in n&&delete n.currentUser;const o=hg(n);for(const r of e.sources)Xo(r.sampleGlobal)&&yg(n,r.sampleGlobal,o,r.id,t);return e.isGuestOnly&&"currentUser"in n&&delete n.currentUser,n}function $4(e){const t={};if(!e||typeof e!="object"||Array.isArray(e))return t;for(const[n,o]of Object.entries(e))n.startsWith("$")&&(t[n]=o);return t}function E4(e){const t={};if(!e||typeof e!="object"||Array.isArray(e))return t;for(const[n,o]of Object.entries(e))n.startsWith("$")||(t[n]=o);return t}function I4(e){if(typeof e!="string")return null;const t=/^\s*\{\{\s*([\s\S]*?)\s*\}\}\s*$/.exec(e);if(!t)return null;let n=t[1];const o=n.indexOf("??");return o>=0&&(n=n.slice(0,o)),n=n.replace(/\?\./g,".").replace(/\s+/g,""),n===""||!/^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(n)?null:n.split(".")}function A4(e,t){const n=I4(t);if(!n||n.length===0)return e;let o=e;for(let a=0;a<n.length;a++){if(o==null||typeof o!="obj Line truncated
//# sourceMappingURL=layout-editor.min.js.map
-1
View File
@@ -241,4 +241,3 @@ Error generating stack: `+c.message+`
}
}
`)(a)}catch(r){B.warn(`Expression evaluation failed: ${e}`,r);return}}async executeInitActions(e,a={}){const{getActionDispatcher:r}=await Promise.resolve().then(()=>za),i=r();if(!i){B.warn("ActionDispatcher not available for init_actions");return}B.log("Executing init_actions:",e.map(d=>d.handler)),B.log("Init actions dataContext:",a),B.log("Init actions dataContext._global:",a._global);const l=e.map(d=>d.handler).filter(d=>d.includes("."));l.length>0&&await this.waitForHandlers(i,l);let u={...a};for(const d of e)try{const f={type:"click",handler:d.handler,target:d.target,params:d.params,resultTo:d.resultTo,onSuccess:d.onSuccess,onError:d.onError,if:d.if,conditions:d.conditions,auth_mode:d.auth_mode,auth_required:d.auth_required},g=i.createHandler(f,u),m=new Event("init");await g(m),B.log(`Init action executed: ${d.handler}`);const b=this.globalState;u={...u,_global:{...b},_local:b._local||u._local||{}},B.log(`Data context refreshed after ${d.handler}:`,{cartKey:u._global?.cartKey})}catch(f){B.error(`Failed to execute init action: ${d.handler}`,f)}}async waitForHandlers(e,a,r=5e3){const i=Date.now(),l=50,u=()=>a.every(f=>e.customHandlers?.has(f));if(u()){B.log("All module handlers already registered");return}const d=uf().getFailedJsAssets();if(d.length>0){const f=d.some(b=>b==="module"||b==="plugin"),g=a.filter(b=>!e.customHandlers?.has(b)),m=g.length>0&&g.every(b=>d.some(v=>b.startsWith(`${v}.`)));if(f||m){B.warn("Extension asset load failed — not waiting for handlers that will never register:",{pending:g,failedAssets:d});return}}return B.log("Waiting for module handlers:",a),new Promise(f=>{const g=()=>{if(u()){B.log("All module handlers now registered"),f();return}if(Date.now()-i>=r){const m=a.filter(b=>!e.customHandlers?.has(b));B.warn("Timeout waiting for handlers:",m),f();return}setTimeout(g,l)};g()})}};$(Ca,"LOCALE_STORAGE_KEY","g7_locale"),$(Ca,"CACHE_VERSION_STORAGE_KEY","g7_cache_version");let vo=Ca;function ff(o){const e=new vo(o);return o.websocket&&(fc.configure(o.websocket),B.log("WebSocket 설정 완료")),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>{e.init(),window.__templateApp=e}):(e.init(),window.__templateApp=e),e}typeof window<"u"&&(window.G7Core=window.G7Core||{},window.G7Core.initTemplateApp=ff,window.G7Core.dataSource={refetch:async(o,e)=>{const a=window.__templateApp;if(!a){B.warn("TemplateApp not initialized (G7Core.dataSource.refetch)");return}return a.refetchDataSource(o,e)},get:o=>{const e=window.__templateApp;if(!e){B.warn("TemplateApp not initialized (G7Core.dataSource.get)");return}return e.getDataSource(o)}});function K_(o={}){const{checked:e,value:a,name:r,type:i="checkbox"}=o,u=a!==void 0?a:i==="checkbox"||i==="radio"?e??!1:String(e??""),d={checked:e??!1,value:u,name:r??"",type:i};return{target:d,currentTarget:d,preventDefault:()=>{},stopPropagation:()=>{},nativeEvent:new Event("change"),bubbles:!0,cancelable:!0,defaultPrevented:!1,eventPhase:Event.AT_TARGET,isTrusted:!1,timeStamp:Date.now(),type:"change",isDefaultPrevented:()=>!1,isPropagationStopped:()=>!1,persist:()=>{}}}function W_(o={}){const{button:e=0,clientX:a=0,clientY:r=0}=o;return{button:e,clientX:a,clientY:r,preventDefault:()=>{},stopPropagation:()=>{},nativeEvent:new MouseEvent("click"),bubbles:!0,cancelable:!0,defaultPrevented:!1,eventPhase:Event.AT_TARGET,isTrusted:!1,timeStamp:Date.now(),type:"click",isDefaultPrevented:()=>!1,isPropagationStopped:()=>!1,persist:()=>{},target:document.createElement("div"),currentTarget:document.createElement("div")}}function Y_(){return{preventDefault:()=>{},stopPropagation:()=>{},nativeEvent:new Event("submit"),bubbles:!0,cancelable:!0,defaultPrevented:!1,eventPhase:Event.AT_TARGET,isTrusted:!1,timeStamp:Date.now(),type:"submit",isDefaultPrevented:()=>!1,isPropagationStopped:()=>!1,persist:()=>{},target:document.createElement("form"),currentTarget:document.createElement("form")}}function X_(o,e="keydown"){return{key:o,code:o.length===1?`Key${o.toUpperCase()}`:o,preventDefault:()=>{},stopPropagation:()=>{},nativeEvent:new KeyboardEvent(e,{key:o}),bubbles:!0,cancelable:!0,defaultPrevented:!1,eventPhase:Event.AT_TARGET,isTrusted:!1,timeStamp:Date.now(),type:e,isDefaultPrevented:()=>!1,isPropagationStopped:()=>!1,persist:()=>{},target:document.createElement("div"),currentTarget:document.createElement("div"),altKey:!1,ctrlKey:!1,metaKey:!1,shiftKey:!1,repeat:!1}}const J_={justify:/^justify-(start|end|center|between|around|evenly)$/,items:/^items-(start|end|center|baseline|stretch)$/,content:/^content-(start|end|center|between|around|evenly|stretch)$/,self:/^self-(auto|start|end|center|stretch|baseline)$/,flex:/^flex-(row|row-reverse|col|col-reverse|wrap|wrap-reverse|nowrap|1|auto|initial|none)$/,grow:/^(grow|grow-0)$/,shrink:/^(shrink|shrink-0)$/,basis:/^basis-/,order:/^(order-|-)order-/,gap:/^gap(-x|-y)?-/,gridCols:/^grid-cols-/,gridRows:/^grid-rows-/,colSpan:/^col-(span-|start-|end-)/,rowSpan:/^row-(span-|start-|end-)/,displa Line truncated
//# sourceMappingURL=template-engine.min.js.map
@@ -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.': 환경에 따라 달라지는 확장자는 소스맵 하나뿐이어야 합니다.'
);
}
}