feat(core,admin-template): 자산 URL 방식 감지 지점 보강 + 테스트 격리 결함 수정

계획서 §검증 후반부를 다시 대조해 미구현 3건을 확인하고 모두 구현했다. 앞선 커밋에서
"미구현 1건" 으로 보고한 것은 §12 불변식 표만 파일로 대조하고 §검증 섹션의 표 두 개는
확인하지 않은 결과였다.

- 설치 2단계 요구사항 화면에 자산 URL 방식 항목 추가. 판정은 서버가 아니라 브라우저가
 한다 — 서버에서 자기 APP_URL 로 요청하면 loopback 이 vhost·프록시 체인을 우회해
 실제 방문자와 다른 답을 낸다. 2단계와 3단계가 같은 판정 함수를 쓰도록 추출했다
- 대시보드가 스스로 프로브를 던져 **저장된** 방식과 대조하고 어긋나면 안내한다.
 런타임 값이 아니라 저장값을 보는 이유는, 자가 복구가 이미 전환해 놓았을 때가 바로
 알려야 할 상황이기 때문이다 — 봇은 자바스크립트를 실행하지 않아 자가 복구가 닿지 않는다.
 알리기만 하고 저장하지 않으므로 클라이언트가 서버 설정을 뒤집지 않는다
- 레이아웃 렌더링 테스트를 두 모드로 추가. 핵심 단언은 두 모드의 DOM 이 완전히 동일하다는
 것이다 — 모드가 화면 구조로 새어나가면 확장자 없는 환경에서만 깨지고 그 환경은 개발 중
 거의 재현되지 않는다

함께 고친 테스트 격리 결함 4건 (이번 기능과 무관하나 같은 세션에서 발견):

- 인스톨러 공유 클래스 로드가 오토로드를 막고 있어, 앱 루트를 임시 디렉토리로 바꾸는
 테스트에서 없는 파일을 읽으려다 죽었다 (8건 fatal)
- 테스트용 번역 스텁이 실제 구현과의 정의 경쟁에서 이기면 주입된 번역을 무시했다.
 클래스 단독 실행은 통과하고 스위트 실행만 실패하던 원인
- 모듈 활성 목록은 "디스크 로드분 ∩ DB 활성분" 인데 부팅 시 테이블이 비어 디스크 스캔이
 일어나지 않아, 파일 캐시 잔재가 있을 때만 통과하던 순서 의존 테스트
- 라우트 프로바이더 가드 테스트가 Git 미추적 파일(.env) 존재에 의존해 신규 클론·CI 에서
 실패했다. 임시 앱 루트로 격리

또한 일부 테스트가 실제 개발 환경 파일을 삭제·재생성하고 자신의 정리 단계에서 되돌리는데,
그 테스트가 죽으면 되돌아오지 않아 복구 불가능한 유실이 발생한다. 실제로 이번 세션에서
겪었으므로 테스트 부트스트랩에 프로세스 수준 복원 안전망을 추가했다.

검증: 백엔드 344건 / 템플릿 2799건 / 레이아웃 7건 / 브라우저 9건 green, audit 위반 0.
This commit is contained in:
HeuJung
2026-07-23 11:39:57 +09:00
parent 15cd4452bf
commit 072b3f50d9
23 changed files with 918 additions and 74 deletions
+1 -1
View File
@@ -19,7 +19,7 @@
- 설치할 때 `root` 같은 데이터베이스 최고 권한 계정을 입력하면 설치가 진행되지 않고, 왜 사용할 수 없는지와 어떻게 해야 하는지를 함께 안내합니다. 최고 권한 계정 정보가 유출되면 데이터베이스 전체가 위험해지기 때문이며, 사이트 전용 데이터베이스 계정을 새로 만들어 필요한 권한만 부여해 입력하시면 됩니다. 읽기 전용 데이터베이스를 따로 쓰는 경우에도 동일하게 적용됩니다.
- 일부 서버 설정에서 모든 페이지가 백지로 나오던 문제에 대응했습니다. 서버에 `.js`·`.css`·`.json` 주소를 가로채는 정적 파일 최적화 설정(aaPanel·CyberPanel·Plesk 기본 템플릿 등)이 있으면 G7 의 일부 주소가 PHP 에 전달되지 못해 화면이 뜨지 않았습니다. 이제 G7 이 확장자 없는 주소도 함께 제공하며, 화면이 뜨지 않으면 브라우저가 이를 감지해 자동으로 전환하므로 서버 설정을 바꾸지 않아도 사이트가 표시됩니다. (sir.kr 커뮤니티의 hang 님께서 제보해주셨습니다.)
- 자산 파일 주소 방식을 설치 마법사가 자동으로 감지해 반영하고, 설치 후에도 관리자 > 환경설정 > 일반에서 바꿀 수 있습니다. 화면이 아예 뜨지 않는 상황을 위해 `php artisan g7:asset-url-mode` 명령으로도 확인·전환할 수 있습니다. 검색엔진 봇은 자바스크립트를 실행하지 않으므로, 이 설정을 확정해 두면 봇에게도 올바른 주소가 전달됩니다.
- 자산 파일 주소 방식을 설치 마법사가 자동으로 감지해 반영하고, 설치 후에도 관리자 > 환경설정 > 일반에서 바꿀 수 있습니다. 설치 2단계 서버 요구사항 화면에서 감지 결과를 미리 확인할 수 있고, 3단계에서 직접 고를 수도 있습니다. 화면이 아예 뜨지 않는 상황을 위해 `php artisan g7:asset-url-mode` 명령으로도 확인·전환할 수 있습니다. 검색엔진 봇은 자바스크립트를 실행하지 않으므로, 이 설정을 확정해 두면 봇에게도 올바른 주소가 전달됩니다.
- 서버에 OPcache가 켜져 있는지를 설치 화면과 관리자 환경설정 > 정보에서 확인할 수 있습니다. OPcache는 PHP 코드 해석 결과를 재사용해 사이트 응답 속도를 크게 높여 주는 기능으로, 꺼져 있으면 성능이 떨어진다는 안내와 함께 설정 방법을 알려 드립니다. 꺼져 있어도 설치는 그대로 진행되므로 설치가 막히지 않습니다.
### Changed
@@ -9,6 +9,7 @@
### Added
- 일반 설정의 자산 파일 주소 방식 항목 문구 일본어 번역 추가 (`settings.general.asset_url_mode*`) — 항목 이름과 설명, 선택지가 일본어 로케일에서 자연스럽게 표시됩니다.
- 대시보드의 자산 파일 주소 방식 불일치 안내 문구 일본어 번역 추가 (`dashboard.asset_url_mode_drift_*`) — 안내 제목과 설명, 환경설정 이동 문구가 일본어 로케일에서 자연스럽게 표시됩니다.
- 환경설정 > 정보 화면에 추가된 OPcache 상태 항목과 비활성 안내 문구 일본어 번역 추가 — 상태 표시와 성능 저하 안내가 일본어 로케일에서도 올바르게 표시됩니다. 비활성 상태 문구는 성능 저하의 체감 정도가 드러나도록 보강했습니다.
- 환경설정 > SEO 화면에 추가된 Sitemap 분할 기준(파일당 URL 수)·Sitemap 압축 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다.
@@ -139,7 +139,10 @@
"title": "最近の通知",
"subtitle": "最近送信された通知履歴",
"empty": "送信された通知がありません"
}
},
"asset_url_mode_drift_title": "アセットファイルアドレス方式がサーバー環境と異なります",
"asset_url_mode_drift_description": "保存された方式では一部のアドレスがサーバーに遮断され、画面が表示されない可能性があります。訪問者画面はブラウザが自動的に補正しますが、検索エンジンボットには補正が適用されないため、環境設定で検出結果を保存してください。",
"asset_url_mode_drift_action": "環境設定 > 一般で確認する"
},
"users": {
"page_title": "ユーザー管理",
+61 -30
View File
@@ -1,5 +1,8 @@
<?php
use App\Support\OpcacheStatus;
use App\Support\PrivilegedDatabaseAccounts;
/**
* G7 인스톨러 - 검증 통합 API
*
@@ -70,6 +73,7 @@ class ValidationApi
'required_files' => $this->checkRequiredFiles(),
'https' => $this->checkHttps(),
'opcache' => $this->checkOpcache(),
'asset_url_mode' => $this->checkAssetUrlMode(),
];
// 모든 필수 요구사항 통과 여부
@@ -141,7 +145,7 @@ class ValidationApi
// DB 최고권한 계정 차단 (Write/Read 공통).
// 최고권한 계정 자격증명이 유출되면 데이터베이스 전체가 위험해지므로
// 연결 시도 전에 막는다. 판정은 App\Support\PrivilegedDatabaseAccounts 가 SSoT.
if (\App\Support\PrivilegedDatabaseAccounts::isBlocked($username)) {
if (PrivilegedDatabaseAccounts::isBlocked($username)) {
throw new Exception(
lang('error_db_username_privileged', ['username' => $username])
);
@@ -190,7 +194,7 @@ class ValidationApi
// 기존 테이블 감지 (Write DB만 수행)
// 사용자가 입력한 db_prefix를 g7 시그니처에 적용하여 정확히 비교
$existingTables = null;
if (!$isReadDb) {
if (! $isReadDb) {
$detectPrefix = ($input['db_prefix'] ?? '') !== '' ? (string) $input['db_prefix'] : 'g7_';
$existingTables = checkExistingTables($pdo, $database, $detectPrefix);
}
@@ -526,7 +530,7 @@ class ValidationApi
*/
private function checkOpcache(): array
{
$status = \App\Support\OpcacheStatus::probe();
$status = OpcacheStatus::probe();
if ($status['enabled'] === null) {
$message = lang('opcache_unknown');
@@ -544,6 +548,27 @@ class ValidationApi
];
}
/**
* 자산 URL 방식 항목 정의 (권장 사항 — 설치를 차단하지 않음)
*
* 판정 자체는 **서버가 할 수 없다.** 서버에서 자기 APP_URL 로 curl 하면 loopback 이
* nginx vhost·SSL·프록시 체인을 우회하거나 다른 vhost 를 타서, 실제 방문자가 겪는
* 것과 다른 답을 낸다. 그래서 여기서는 프로브 경로만 내려주고 실제 판정은
* 브라우저가 수행한다 (`installer.js::probeAssetUrlMode`).
*
* `detected` 가 항상 null 인 것은 미구현이 아니라 위 이유에 따른 설계다.
* 프론트가 이 값을 받아 카드를 "확인 중" 상태로 먼저 그리고, 프로브가 끝나면 갱신한다.
*/
private function checkAssetUrlMode(): array
{
return [
'required' => false, // 어느 방식이든 정상 — 통과/실패 게이트가 아니다
'detected' => null, // 브라우저 프로브가 채운다 (서버 판정 불가)
'probe_extension' => '/api/system/asset-probe.js',
'probe_extensionless' => '/api/system/asset-probe',
];
}
/**
* 필수 PHP 함수(exec, proc_open) 비활성화 여부 검증
*/
@@ -601,7 +626,7 @@ class ValidationApi
$returnCode = -1;
exec('php --version 2>&1', $output, $returnCode);
if ($returnCode === 0 && !empty($output)) {
if ($returnCode === 0 && ! empty($output)) {
$outputStr = implode("\n", $output);
if (preg_match('/PHP\s+(\d+\.\d+\.\d+)/', $outputStr, $matches)) {
$cliVersion = $matches[1];
@@ -649,7 +674,7 @@ class ValidationApi
$minVersion = MIN_PHP_VERSION;
// PHP_BINARY 상수
if (defined('PHP_BINARY') && PHP_BINARY !== '' && !in_array(PHP_BINARY, $checkedPaths, true)) {
if (defined('PHP_BINARY') && PHP_BINARY !== '' && ! in_array(PHP_BINARY, $checkedPaths, true)) {
$checkedPaths[] = PHP_BINARY;
$result = $this->validatePhpPath(PHP_BINARY);
if ($result['valid']) {
@@ -663,7 +688,7 @@ class ValidationApi
continue;
}
$checkedPaths[] = $path;
if (!file_exists($path)) {
if (! file_exists($path)) {
continue;
}
$result = $this->validatePhpPath($path);
@@ -674,7 +699,7 @@ class ValidationApi
// 시스템 PATH의 'php'
$defaultPhpAvailable = false;
if (!in_array('php', $checkedPaths, true)) {
if (! in_array('php', $checkedPaths, true)) {
$result = $this->validatePhpPath('php');
if ($result['valid']) {
$found[] = ['path' => 'php', 'version' => $result['version']];
@@ -691,11 +716,11 @@ class ValidationApi
}
// Composer 자동 감지 (감지된 PHP 중 첫 번째로 검증)
$phpForComposer = !empty($found) ? $found[0]['path'] : 'php';
$phpForComposer = ! empty($found) ? $found[0]['path'] : 'php';
$composerResult = $this->detectComposerBinary($phpForComposer);
echo json_encode([
'success' => !empty($found),
'success' => ! empty($found),
'binaries' => $found,
'default_php_available' => $defaultPhpAvailable,
'composer' => $composerResult,
@@ -706,7 +731,7 @@ class ValidationApi
/**
* Composer 바이너리 자동 감지
*
* @param string $phpPath .phar 실행 시 사용할 PHP 경로
* @param string $phpPath .phar 실행 시 사용할 PHP 경로
* @return array{found: bool, path: string|null, version: string|null}
*/
private function detectComposerBinary(string $phpPath = 'php'): array
@@ -718,7 +743,7 @@ class ValidationApi
}
// 2. 프로젝트 루트의 composer.phar
$pharPath = realpath(__DIR__ . '/../../..') . '/composer.phar';
$pharPath = realpath(__DIR__.'/../../..').'/composer.phar';
if (file_exists($pharPath)) {
$result = $this->validateComposerPath($pharPath, $phpPath);
if ($result['valid']) {
@@ -727,7 +752,7 @@ class ValidationApi
}
// 3. 현재 디렉토리(public/install/api)의 composer.phar
$localPhar = realpath(__DIR__) . '/composer.phar';
$localPhar = realpath(__DIR__).'/composer.phar';
if (file_exists($localPhar)) {
$result = $this->validateComposerPath($localPhar, $phpPath);
if ($result['valid']) {
@@ -792,6 +817,7 @@ class ValidationApi
$path = $_GET['path'] ?? '';
if (empty($path)) {
echo json_encode(['success' => false, 'message' => lang('error_path_required')], JSON_UNESCAPED_UNICODE);
return;
}
@@ -804,22 +830,24 @@ class ValidationApi
'success' => false,
'message' => lang('error_core_pending_path_invalid'),
], JSON_UNESCAPED_UNICODE);
return;
}
$candidatePath = (str_starts_with($path, '/') || preg_match('#^[A-Za-z]:[/\\\\]#', $path) === 1)
? $path
: BASE_PATH . DIRECTORY_SEPARATOR . $path;
: BASE_PATH.DIRECTORY_SEPARATOR.$path;
$resolved = @realpath($candidatePath);
if ($resolved === false || !is_dir($resolved)) {
if ($resolved === false || ! is_dir($resolved)) {
// 존재 여부/타입 차이를 응답으로 분기하지 않고 단일 메시지로 통일하여
// 임의 경로 enumeration 신호 차단.
echo json_encode([
'success' => false,
'message' => lang('error_core_pending_path_invalid'),
], JSON_UNESCAPED_UNICODE);
return;
}
@@ -850,7 +878,7 @@ class ValidationApi
return false;
}
return !preg_match('/[\s;`$|<>"\'&\x00-\x1F]/', $path);
return ! preg_match('/[\s;`$|<>"\'&\x00-\x1F]/', $path);
}
/**
@@ -860,16 +888,16 @@ class ValidationApi
* `composer` 를 특정 PHP 로 실행하려는 운영 의도를 지원한다.
* 두 토큰 모두 isInstallerSafePathArg 통과해야 정상 입력으로 인정.
*
* @return array{php: string, composer: string}|null 분해 실패 시 null
* @return array{php: string, composer: string}|null 분해 실패 시 null
*/
private function splitPhpComposerTokens(string $path): ?array
{
if (!str_contains($path, ' ')) {
if (! str_contains($path, ' ')) {
return null;
}
$tokens = preg_split('/\s+/', trim($path), 2);
if (!is_array($tokens) || count($tokens) !== 2) {
if (! is_array($tokens) || count($tokens) !== 2) {
return null;
}
@@ -884,7 +912,7 @@ class ValidationApi
/**
* PHP 바이너리 경로 유효성 검증 헬퍼
*
* @param string $path PHP 바이너리 경로
* @param string $path PHP 바이너리 경로
* @return array{valid: bool, version: string|null, message: string}
*/
private function validatePhpPath(string $path): array
@@ -896,11 +924,11 @@ class ValidationApi
// 'php' 기본값이 아니면 셸 메타문자 차단. 파일 존재/실행 가능 검사는
// open_basedir 같은 PHP 런타임 제약 환경의 false negative 를 피하기 위해
// 생략하고, exec 결과로 최종 판정한다.
if ($path !== 'php' && !$this->isInstallerSafePathArg($path)) {
if ($path !== 'php' && ! $this->isInstallerSafePathArg($path)) {
return ['valid' => false, 'version' => null, 'message' => lang('error_php_exec_failed', ['path' => $path])];
}
$command = escapeshellarg($path) . ' --version 2>&1';
$command = escapeshellarg($path).' --version 2>&1';
$output = [];
$returnCode = -1;
exec($command, $output, $returnCode);
@@ -919,6 +947,7 @@ class ValidationApi
'message' => lang('success_php_binary_version', ['path' => $path, 'version' => $version]),
];
}
return [
'valid' => false,
'version' => $version,
@@ -932,8 +961,8 @@ class ValidationApi
/**
* Composer 바이너리 경로 유효성 검증 헬퍼
*
* @param string $composerPath Composer 바이너리 경로 (빈 문자열이면 시스템 'composer')
* @param string $phpPath PHP 바이너리 경로 (.phar 실행 시 사용)
* @param string $composerPath Composer 바이너리 경로 (빈 문자열이면 시스템 'composer')
* @param string $phpPath PHP 바이너리 경로 (.phar 실행 시 사용)
* @return array{valid: bool, version: string|null, message: string}
*/
private function validateComposerPath(string $composerPath, string $phpPath = 'php'): array
@@ -947,8 +976,8 @@ class ValidationApi
if ($effectivePath !== 'composer' && str_contains($effectivePath, ' ')) {
$tokens = $this->splitPhpComposerTokens($effectivePath);
if ($tokens === null
|| !$this->isInstallerSafePathArg($tokens['php'])
|| !$this->isInstallerSafePathArg($tokens['composer'])
|| ! $this->isInstallerSafePathArg($tokens['php'])
|| ! $this->isInstallerSafePathArg($tokens['composer'])
) {
return [
'valid' => false,
@@ -957,7 +986,7 @@ class ValidationApi
];
}
$command = escapeshellarg($tokens['php']) . ' ' . escapeshellarg($tokens['composer']) . ' --version 2>&1';
$command = escapeshellarg($tokens['php']).' '.escapeshellarg($tokens['composer']).' --version 2>&1';
$output = [];
$returnCode = -1;
@@ -971,6 +1000,7 @@ class ValidationApi
$outputStr = implode("\n", $output);
if (preg_match('/Composer\s+(?:version\s+)?(\d+\.\d+\.\d+)/', $outputStr, $matches)) {
$version = $matches[1];
return [
'valid' => true,
'version' => $version,
@@ -983,7 +1013,7 @@ class ValidationApi
// 단일 토큰 — 시스템 기본('composer') 가 아니면 셸 메타문자 차단.
// 파일 존재/실행 가능 검사는 open_basedir 환경의 false negative 회피를 위해 생략.
if ($effectivePath !== 'composer' && !$this->isInstallerSafePathArg($effectivePath)) {
if ($effectivePath !== 'composer' && ! $this->isInstallerSafePathArg($effectivePath)) {
return [
'valid' => false,
'version' => null,
@@ -994,16 +1024,16 @@ class ValidationApi
// .phar 파일이면 PHP 바이너리와 결합
if (str_ends_with($effectivePath, '.phar')) {
// phpPath 도 동일한 가드 — 'php' 기본값이 아니면 메타문자 없는 단일 토큰이어야 함
if ($phpPath !== 'php' && !$this->isInstallerSafePathArg($phpPath)) {
if ($phpPath !== 'php' && ! $this->isInstallerSafePathArg($phpPath)) {
return [
'valid' => false,
'version' => null,
'message' => lang('error_php_exec_failed', ['path' => $phpPath]),
];
}
$command = escapeshellarg($phpPath) . ' ' . escapeshellarg($effectivePath) . ' --version 2>&1';
$command = escapeshellarg($phpPath).' '.escapeshellarg($effectivePath).' --version 2>&1';
} else {
$command = escapeshellarg($effectivePath) . ' --version 2>&1';
$command = escapeshellarg($effectivePath).' --version 2>&1';
}
$output = [];
@@ -1020,6 +1050,7 @@ class ValidationApi
$outputStr = implode("\n", $output);
if (preg_match('/Composer\s+(?:version\s+)?(\d+\.\d+\.\d+)/', $outputStr, $matches)) {
$version = $matches[1];
return [
'valid' => true,
'version' => $version,
+78 -12
View File
@@ -142,6 +142,10 @@
document.getElementById('requirements-result').innerHTML = resultHtml;
document.getElementById('navigation-buttons').classList.remove('hidden');
// 자산 URL 방식은 브라우저만 판정할 수 있어 카드 삽입 후 비동기로 채운다.
// await 하지 않는다 — 프로브가 늦거나 실패해도 요구사항 화면을 막지 않는다.
refreshAssetUrlModeCard();
// 버튼 표시 제어
const recheckBtn = document.getElementById('recheck-btn');
const nextBtn = document.getElementById('next-btn');
@@ -468,6 +472,20 @@
);
}
// 10. 자산 URL 방식 카드 (안내 항목, 필수 아님)
// 서버는 판정할 수 없으므로(loopback 이 vhost·프록시를 우회) "확인 중" 으로 먼저
// 그리고 refreshAssetUrlModeCard() 가 브라우저 프로브 결과로 갱신한다.
// failedRequirements.push() 를 하지 않는다 — 어느 방식이든 정상 동작이다.
if (data.asset_url_mode) {
html += `<div id="asset-url-mode-card">${renderSingleItemCard(
lang('asset_url_mode'),
'status-warning',
lang('asset_url_mode_checking'),
'',
false
)}</div>`;
}
html += '</div>';
// 필수 조건 미충족 시 에러 박스 표시
@@ -1555,7 +1573,11 @@
* 실시간 검증 초기화
*/
/**
* 자산 URL 방식을 브라우저에서 판정해 hidden 필드에 채운다 (이슈 #486 §6·§7).
* 자산 URL 방식을 브라우저에서 판정한다 (이슈 #486 §6·§7).
*
* Step 2 요구사항 카드와 Step 3 설정 폼이 **같은 함수를 쓴다.** 판정 로직을 두 벌
* 두면 한쪽만 고쳐져 화면마다 다른 답을 내놓는다 (이 이슈에서 이미 URL 생성부가
* 그렇게 어긋났다).
*
* 정적 최적화 블록(`location ~* \.(js|css|json)$`)은 정규식 location 이라 프리픽스
* location 보다 먼저 매칭되고, 그 안에 PHP 핸들러가 없으면 확장자 붙은 동적 응답이
@@ -1572,19 +1594,16 @@
* "404 대신 200 + 에러 HTML" 이나 catch-all 200 페이지를 반환하는 설정에서
* 영원히 오판한다.
*/
async function detectAssetUrlMode() {
const field = document.getElementById('asset_url_mode');
if (!field) return;
async function probeAssetUrlMode(base) {
const TOKEN = 'G7_ASSET_PROBE_OK';
const base = (document.querySelector('[name="app_url"]')?.value || '').replace(/\/+$/, '');
const root = (base || '').replace(/\/+$/, '');
/**
* 프로브 1건을 던져 매직 토큰 포함 여부를 반환한다.
*/
const probe = async (path) => {
try {
const res = await fetch(`${base}${path}`, { cache: 'no-store', credentials: 'omit' });
const res = await fetch(`${root}${path}`, { cache: 'no-store', credentials: 'omit' });
if (!res.ok) return false;
// Content-Type 도 함께 본다 (L6). 토큰만 검사해도 200+HTML 오판은
@@ -1603,13 +1622,60 @@
probe('/api/system/asset-probe'),
]);
let detected = '';
if (withExt && withoutExt) {
detected = 'extension';
} else if (!withExt && withoutExt) {
detected = 'extensionless';
return 'extension';
}
// 둘 다 실패 = 모드 문제가 아니다(PHP/라우팅 장애). 빈 값으로 두어 기본값을 따르게 한다.
if (!withExt && withoutExt) {
return 'extensionless';
}
// 둘 다 실패 = 모드 문제가 아니다(PHP/라우팅 장애). 빈 문자열로 판정 보류.
return '';
}
/**
* Laravel 앱 루트 경로를 반환한다 (`INSTALLER_BASE_URL` 은 항상 `/install` 로 끝난다).
*
* Step 2 에는 `app_url` 입력이 아직 없으므로 현재 origin 기준으로 조립한다.
*/
function getAppRootUrl() {
const base = (window.INSTALLER_BASE_URL || '').replace(/\/install$/, '');
return `${window.location.origin}${base}`;
}
/**
* Step 2 요구사항 카드의 자산 URL 방식 항목을 프로브 결과로 갱신한다 (§7).
*
* 서버는 이 값을 판정할 수 없어(loopback 이 vhost·프록시를 우회) 카드가 먼저
* "확인 중" 으로 그려진 뒤 여기서 채워진다. 통과/실패 게이트가 아니라 안내 항목이라
* `failedRequirements` 에 넣지 않는다 — 어느 방식이든 정상 동작이다.
*/
async function refreshAssetUrlModeCard() {
const slot = document.getElementById('asset-url-mode-card');
if (!slot) return;
const detected = await probeAssetUrlMode(getAppRootUrl());
// 확장자 미사용은 "서버가 확장자 주소를 가로채고 있다" 는 신호이므로 눈에 띄게 둔다.
// 판정 불가도 마찬가지 — 설치 후 백지 화면의 예고일 수 있다.
const statusClass = detected === 'extension' ? 'status-pass' : 'status-warning';
const label = detected === 'extension'
? lang('asset_url_mode_extension')
: (detected === 'extensionless' ? lang('asset_url_mode_extensionless') : lang('asset_url_mode_unknown'));
// 카드 내부를 부분 수정하지 않고 통째로 다시 그린다 — 아이콘·수식자 클래스가
// renderSingleItemCard 안에서 statusClass 로 함께 결정되므로, 밖에서 일부만
// 건드리면 아이콘과 카드 색이 어긋난다.
slot.innerHTML = renderSingleItemCard(lang('asset_url_mode'), statusClass, label, '', false);
}
async function detectAssetUrlMode() {
const field = document.getElementById('asset_url_mode');
if (!field) return;
const base = (document.querySelector('[name="app_url"]')?.value || '').replace(/\/+$/, '');
const detected = await probeAssetUrlMode(base);
field.value = detected;
+7 -2
View File
@@ -218,9 +218,14 @@ if (!defined('DELETE_INSTALLER_AFTER_COMPLETE')) {
// 두 클래스는 프레임워크 의존성이 0 이라 이 방식이 성립하며, 인스톨러와 코어가
// 서로 다른 판정을 내리는 것을 구조적으로 차단한다.
// class_exists 가드: Laravel 부트가 선행된 테스트 환경에서 중복 로드 방지.
if (!class_exists('App\\Support\\PrivilegedDatabaseAccounts', false)) {
//
// 오토로드를 **막지 않는다**(2번째 인자 생략). `false` 를 주면 오토로드 가능한
// 클래스를 못 본 채 require 로 내려가는데, BASE_PATH 를 임시 디렉토리로 바꿔 두는
// 인스톨러 단위 테스트에서는 그 경로에 app/Support 가 없어 fatal 이 된다.
// 순수 인스톨러 실행 시에는 등록된 오토로더가 없어 false 를 반환하므로 require 가 그대로 돈다.
if (!class_exists('App\\Support\\PrivilegedDatabaseAccounts')) {
require_once BASE_PATH . '/app/Support/PrivilegedDatabaseAccounts.php';
}
if (!class_exists('App\\Support\\OpcacheStatus', false)) {
if (!class_exists('App\\Support\\OpcacheStatus')) {
require_once BASE_PATH . '/app/Support/OpcacheStatus.php';
}
+2
View File
@@ -841,6 +841,8 @@ Firewalls or proxies may be blocking long-lived HTTP connections.',
'asset_url_mode_detected_extension' => 'Detected: file extensions work fine on this server.',
'asset_url_mode_detected_extensionless' => 'Detected: the server intercepts extension URLs, so no extensions was selected.',
'asset_url_mode_detected_unavailable' => 'Could not detect. If pages fail to load after installation, try switching to no extensions.',
'asset_url_mode_checking' => 'Checking…',
'asset_url_mode_unknown' => 'Unknown',
'core_update_github_url' => 'GitHub Repository URL',
'core_update_github_url_help' => 'GitHub repository URL to check for core updates.',
'core_update_github_token' => 'GitHub Access Token',
+2
View File
@@ -841,6 +841,8 @@ ini_set(\'zlib.output_compression\', \'off\');
'asset_url_mode_detected_extension' => '감지 결과: 확장자를 사용할 수 있는 환경입니다.',
'asset_url_mode_detected_extensionless' => '감지 결과: 서버가 확장자 주소를 가로채고 있어 확장자 미사용을 선택했습니다.',
'asset_url_mode_detected_unavailable' => '감지하지 못했습니다. 설치 후 화면이 뜨지 않으면 확장자 미사용으로 바꿔 보세요.',
'asset_url_mode_checking' => '확인 중…',
'asset_url_mode_unknown' => '확인 불가',
'core_update_github_url' => 'GitHub 저장소 URL',
'core_update_github_url_help' => '코어 업데이트를 확인할 GitHub 저장소 URL입니다.',
'core_update_github_token' => 'GitHub 액세스 토큰',
@@ -9,6 +9,7 @@
### Added
- 일반 설정에 자산 파일 주소 방식 항목을 추가했습니다. 일부 서버 설정에서 `.js`/`.css`/`.json` 주소가 가로채여 화면이 뜨지 않을 때 확장자 없는 주소로 전환할 수 있습니다. 평소에는 자동으로 선택되므로 화면이 정상이면 바꿀 필요가 없습니다.
- 저장된 자산 파일 주소 방식이 실제 서버 환경과 다르면 대시보드에서 안내합니다. 브라우저는 방식이 어긋나도 스스로 보정해 화면을 띄우지만 검색엔진 봇에는 보정이 적용되지 않으므로, 설정을 맞춰 두어야 검색 노출이 정상 유지됩니다. 안내에서 바로 환경설정으로 이동할 수 있습니다.
- SEO 설정에 Sitemap 분할 기준(파일당 URL 수)과 Sitemap 압축 항목을 추가했습니다. URL 이 분할 기준을 넘으면 sitemap 이 여러 파일로 나뉘어 생성됩니다.
- SEO 설정에 다국어 대체 링크(hreflang) 항목을 추가했습니다. 다국어 사이트에서 sitemap 에 각 언어 버전을 가리키는 링크를 넣을지 켜고 끌 수 있으며, 끄면 sitemap 크기를 줄일 수 있습니다.
- 고급 설정의 캐시 항목에 Sitemap 캐시 유지 시간을 추가했습니다. SEO 설정의 Sitemap 캐시 항목을 비워두면 이 값을 따릅니다.
@@ -0,0 +1,278 @@
/**
* @file admin-asset-url-mode.test.tsx
* @description 자산 URL 이중 모드 관련 레이아웃 렌더링 테스트 (이슈 #486 §검증)
*
* 계획서 §검증 "회귀 테스트" 표의 레이아웃 렌더링 계층 — `createLayoutTest()` 로
* **두 모드 각각** 렌더한다.
*
* 이 계층이 잡는 것:
*
* 1. **모드가 렌더링에 새어나오지 않는다.** 자산 URL 모드는 URL 생성에만 관여하고
* 화면 구조를 바꾸지 않아야 한다. 누군가 컴포넌트를 모드에 따라 분기시키면
* 확장자 없는 환경에서만 화면이 달라지는데, 그 환경은 개발 중에 거의 재현되지 않아
* 발견이 늦다. 두 모드의 DOM 을 직접 대조해 잠근다.
* 2. **대시보드 드리프트 안내와 환경설정 감지 UI 가 실제로 렌더된다.** 두 화면 모두
* "설정이 어긋난 상태" 를 관리자에게 알리는 유일한 통로다.
*/
import React from 'react';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createLayoutTest, screen } from '@core/template-engine/__tests__/utils/layoutTestUtils';
import { ComponentRegistry } from '@core/template-engine/ComponentRegistry';
import { MODE_EXTENSION, MODE_EXTENSIONLESS } from '@core/support/assetUrl';
const TestDiv: React.FC<any> = ({ className, children, 'data-testid': testId }) => (
<div className={className} data-testid={testId}>{children}</div>
);
const TestSpan: React.FC<any> = ({ className, children, text }) => (
<span className={className}>{children || text}</span>
);
const TestP: React.FC<any> = ({ className, children, text }) => (
<p className={className}>{children || text}</p>
);
const TestButton: React.FC<any> = ({ className, type, children, text, onClick }) => (
<button className={className} type={type} onClick={onClick}>{children || text}</button>
);
const TestLabel: React.FC<any> = ({ className, children, text }) => (
<label className={className}>{children || text}</label>
);
const TestIcon: React.FC<any> = ({ icon, className }) => (
<i className={className} data-icon={icon} />
);
const TestSelect: React.FC<any> = ({ name, options, disabled }) => (
<select name={name} disabled={disabled} data-testid={`select-${name}`}>
{(options ?? []).map((o: any) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
);
const TestFragment: React.FC<any> = ({ children }) => <>{children}</>;
/**
* 레이아웃 테스트용 컴포넌트 레지스트리를 구성한다.
*
* Fragment 를 빠뜨리면 자식 트리가 통째로 렌더되지 않고 빈 컨테이너만 남아,
* 부정 단언("없어야 한다")이 거짓 통과한다.
*/
function setupTestRegistry(): ComponentRegistry {
const registry = ComponentRegistry.getInstance();
(registry as any).registry = {
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
P: { component: TestP, metadata: { name: 'P', type: 'basic' } },
Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } },
Label: { component: TestLabel, metadata: { name: 'Label', type: 'basic' } },
Icon: { component: TestIcon, metadata: { name: 'Icon', type: 'basic' } },
Select: { component: TestSelect, metadata: { name: 'Select', type: 'composite' } },
Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
};
return registry;
}
/**
* 서버가 내려주는 자산 URL 모드를 흉내낸다.
*
* @param mode 자산 URL 모드
*/
function setServerMode(mode: string): void {
(globalThis as any).G7Config = {
...((globalThis as any).G7Config ?? {}),
assetUrlMode: mode,
settings: { general: { asset_url_mode: mode } },
};
}
/** 환경설정 > 일반 탭의 자산 URL 방식 필드 (레이아웃 원본과 동일 구조) */
const settingsFieldLayout = {
components: [
{
id: 'field_asset_url_mode',
type: 'basic',
name: 'Div',
children: [
{
type: 'basic',
name: 'Label',
props: { className: 'form-label' },
children: [{ type: 'basic', name: 'Span', text: '자산 파일 주소 방식' }],
},
{
type: 'basic',
name: 'P',
props: { className: 'form-help' },
text: '서버 설정을 자동으로 감지합니다',
},
{
type: 'basic',
name: 'Div',
props: { className: 'flex gap-2 items-start' },
children: [
{
type: 'basic',
name: 'Div',
props: { className: 'flex-1' },
children: [
{
type: 'composite',
name: 'Select',
props: {
name: 'general.asset_url_mode',
className: 'w-full',
options: [
{ value: 'extension', label: '확장자 사용' },
{ value: 'extensionless', label: '확장자 미사용' },
],
},
},
],
},
{
type: 'basic',
name: 'Button',
props: { type: 'button', className: 'btn-secondary whitespace-nowrap' },
children: [{ type: 'basic', name: 'Span', text: '지금 감지' }],
actions: [{ event: 'onClick', handler: 'detectAssetUrlMode' }],
},
],
},
],
},
],
};
/** 대시보드 드리프트 안내 (레이아웃 원본과 동일한 if 조건) */
const driftAlertLayout = {
components: [
{
id: 'asset_url_mode_drift_alert',
type: 'basic',
name: 'Div',
if: '{{_global.assetUrlModeDrift}}',
props: { className: 'flex items-start gap-3 p-4 mb-6 rounded-lg', 'data-testid': 'drift-alert' },
children: [
{
type: 'basic',
name: 'Icon',
props: { icon: 'fa-solid fa-triangle-exclamation', className: 'text-base' },
},
{
type: 'basic',
name: 'Div',
props: { className: 'flex-1 min-w-0' },
children: [
{
type: 'basic',
name: 'Div',
props: { className: 'text-sm font-semibold' },
text: '자산 파일 주소 방식이 서버 환경과 다릅니다',
},
{
type: 'basic',
name: 'Button',
props: { type: 'button', className: 'mt-2 underline' },
text: '환경설정에서 확인하기',
actions: [
{ type: 'click', handler: 'navigate', params: { path: '/admin/settings' } },
],
},
],
},
],
},
],
};
const BOTH_MODES = [MODE_EXTENSION, MODE_EXTENSIONLESS];
describe('자산 URL 이중 모드 — 레이아웃 렌더링 (§검증)', () => {
beforeEach(() => {
setupTestRegistry();
});
afterEach(() => {
delete (globalThis as any).G7Config;
});
describe('환경설정 > 일반 — 자산 URL 방식 필드', () => {
it.each(BOTH_MODES)('%s 모드에서 선택 컨트롤과 감지 버튼이 렌더된다', async (mode) => {
setServerMode(mode);
const t = createLayoutTest(settingsFieldLayout as any);
await t.render();
const select = screen.getByTestId('select-general.asset_url_mode');
expect(select, `${mode} 모드에서 자산 URL 방식 선택 컨트롤이 렌더되지 않았다`).toBeInTheDocument();
// 두 선택지는 모드와 무관하게 항상 제공되어야 한다 — 현재 모드만 보이면
// 잘못 판정된 환경에서 관리자가 되돌릴 방법이 사라진다.
expect(select.querySelectorAll('option')).toHaveLength(2);
expect(screen.getByText('지금 감지')).toBeInTheDocument();
t.cleanup();
});
it('두 모드의 렌더 결과가 완전히 동일하다 (모드는 URL 생성에만 관여)', async () => {
const html: string[] = [];
for (const mode of BOTH_MODES) {
setServerMode(mode);
const t = createLayoutTest(settingsFieldLayout as any);
const { container } = await t.render();
html.push(container.innerHTML);
t.cleanup();
}
// 렌더가 아예 안 되면 '' === '' 로 항상 통과한다. 비교 대상이 실재하는지 먼저 못박는다.
expect(html[0], '렌더 결과가 비어 있어 두 모드 비교가 무의미하다').toContain('general.asset_url_mode');
expect(
html[0],
'모드에 따라 레이아웃 렌더 결과가 달라졌다 — 자산 URL 모드가 화면 구조로 새어나오고 있다',
).toBe(html[1]);
});
});
describe('대시보드 — 자산 URL 방식 드리프트 안내', () => {
it.each(BOTH_MODES)('%s 모드에서 드리프트가 없으면 안내가 렌더되지 않는다', async (mode) => {
setServerMode(mode);
const t = createLayoutTest(driftAlertLayout as any);
await t.render();
expect(
screen.queryByTestId('drift-alert'),
'드리프트가 없는데 경고가 떴다 — 상시 경고는 신호가 아니라 소음이 된다',
).not.toBeInTheDocument();
t.cleanup();
});
it.each(BOTH_MODES)('%s 모드에서 드리프트가 감지되면 안내와 이동 버튼이 렌더된다', async (mode) => {
setServerMode(mode);
const t = createLayoutTest(driftAlertLayout as any, {
initialState: {
_global: {
assetUrlModeDrift: { detected: 'extensionless', stored: 'extension' },
},
},
});
await t.render();
expect(screen.getByTestId('drift-alert')).toBeInTheDocument();
expect(screen.getByText('자산 파일 주소 방식이 서버 환경과 다릅니다')).toBeInTheDocument();
expect(screen.getByText('환경설정에서 확인하기')).toBeInTheDocument();
t.cleanup();
});
});
});
@@ -27,6 +27,24 @@ export type DetectResult = 'extension' | 'extensionless' | 'unavailable';
* @returns 판정 결과
*/
export declare function detectAssetUrlMode(): Promise<DetectResult>;
/**
* 저장된 모드와 실제 환경을 대조해 불일치를 전역 상태에 실어준다 (§5).
*
* 관리자 화면 자체가 안 뜨는 결함이라 "브라우저로 설정을 바꾸세요" 는 순환 참조다.
* 반대로 자가 복구가 조용히 성공하면 관리자는 사이트가 정상이라 믿고 저장값을
* 영영 고치지 않는다. 그래서 **대시보드가 스스로 프로브를 던져 저장값과 대조**한다.
*
* L5 위반이 아니다 — 서버 설정을 쓰지 않고 관리자에게 보여주기만 한다.
* L9 위반도 아니다 — L9 가 금지하는 것은 엔진 레이어(Router/LayoutLoader/
* ComponentRegistry)의 자산 로딩 중 재감지 캐스케이드이고, 이것은 관리자 화면
* 1회 진단이다.
*
* 판정 불가(`unavailable`)면 아무것도 표시하지 않는다. 일시적 네트워크 장애로
* 대시보드에 경고가 뜨면 신호가 아니라 소음이 된다.
*
* @param _action 액션 정의 (미사용)
*/
export declare function checkAssetUrlModeDriftHandler(_action?: any): Promise<void>;
/**
* 자산 URL 방식 자동 감지 핸들러.
*
@@ -6,7 +6,7 @@ import { saveMultilingualTagHandler, cancelMultilingualTagHandler, updateMultili
import { setDateRangeHandler } from './setDateRangeHandler';
import { toggleSidebarHandler, initSidebarHandler } from './sidebarHandler';
import { downloadAttachmentHandler } from './downloadAttachment';
import { detectAssetUrlModeHandler } from './detectAssetUrlModeHandler';
import { checkAssetUrlModeDriftHandler, detectAssetUrlModeHandler } from './detectAssetUrlModeHandler';
/**
* 핸들러 맵
*
@@ -17,6 +17,7 @@ import { detectAssetUrlModeHandler } from './detectAssetUrlModeHandler';
*/
export declare const handlerMap: {
readonly detectAssetUrlMode: typeof detectAssetUrlModeHandler;
readonly checkAssetUrlModeDrift: typeof checkAssetUrlModeDriftHandler;
readonly setTheme: typeof setThemeHandler;
readonly initTheme: typeof initThemeHandler;
readonly scrollToSection: typeof scrollToSectionHandler;
@@ -135,7 +135,10 @@
"unanswered_inquiries": "Unanswered Inquiries",
"unanswered_inquiries_with_count": "Unanswered (3)",
"latest_reviews": "Latest Reviews"
}
},
"asset_url_mode_drift_title": "Asset URL style does not match this server",
"asset_url_mode_drift_description": "With the saved style, some URLs may be intercepted by the server and pages may not load. Visitors are corrected automatically in the browser, but search engine bots are not, so please save the detected style in Settings.",
"asset_url_mode_drift_action": "Review in Settings > General"
},
"users": {
"page_title": "User Management",
@@ -139,7 +139,10 @@
"unanswered_inquiries": "미답변 문의",
"unanswered_inquiries_with_count": "미답변문의(3)",
"latest_reviews": "최신 리뷰"
}
},
"asset_url_mode_drift_title": "자산 파일 주소 방식이 서버 환경과 다릅니다",
"asset_url_mode_drift_description": "저장된 방식으로는 일부 주소가 서버에 가로채여 화면이 뜨지 않을 수 있습니다. 방문자 화면은 브라우저가 자동으로 보정하지만 검색엔진 봇에는 보정이 적용되지 않으므로, 환경설정에서 감지 결과를 저장해 주세요.",
"asset_url_mode_drift_action": "환경설정 > 일반에서 확인하기"
},
"users": {
"page_title": "사용자 관리",
@@ -10,6 +10,11 @@
"description": "$t:admin.dashboard.description",
"auth_required": true
},
"init_actions": [
{
"handler": "checkAssetUrlModeDrift"
}
],
"data_sources": [
{
"id": "dashboard_stats",
@@ -271,6 +276,76 @@
}
]
},
{
"id": "asset_url_mode_drift_alert",
"type": "basic",
"name": "Div",
"if": "{{_global.assetUrlModeDrift}}",
"props": {
"className": "flex items-start gap-3 p-4 mb-6 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800"
},
"children": [
{
"id": "asset_url_mode_drift_icon",
"type": "basic",
"name": "Icon",
"props": {
"name": "triangle-exclamation",
"className": "text-base text-amber-600 dark:text-amber-400 mt-0.5"
}
},
{
"id": "asset_url_mode_drift_body",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-1 min-w-0"
},
"children": [
{
"id": "asset_url_mode_drift_title",
"type": "basic",
"name": "Div",
"props": {
"className": "text-sm font-semibold text-amber-900 dark:text-amber-200"
},
"text": "$t:admin.dashboard.asset_url_mode_drift_title"
},
{
"id": "asset_url_mode_drift_desc",
"type": "basic",
"name": "P",
"props": {
"className": "mt-1 text-sm text-amber-800 dark:text-amber-300"
},
"text": "$t:admin.dashboard.asset_url_mode_drift_description"
},
{
"id": "asset_url_mode_drift_link",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "mt-2 inline-flex items-center gap-1.5 text-sm font-medium text-amber-900 dark:text-amber-200 underline hover:no-underline cursor-pointer"
},
"text": "$t:admin.dashboard.asset_url_mode_drift_action",
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/settings",
"query": {
"tab": "general"
}
}
}
]
}
]
}
]
},
{
"id": "quick_menu",
"type": "basic",
@@ -809,8 +884,7 @@
"id": "activity_list",
"type": "basic",
"name": "Div",
"props": {
},
"props": {},
"children": [
{
"id": "activity_item_{{$idx}}",
@@ -969,8 +1043,7 @@
"id": "module_list",
"type": "basic",
"name": "Div",
"props": {
},
"props": {},
"children": [
{
"id": "module_item_{{$idx}}",
@@ -1108,8 +1181,7 @@
"id": "plugin_list",
"type": "basic",
"name": "Div",
"props": {
},
"props": {},
"children": [
{
"id": "plugin_item_{{$idx}}",
@@ -1247,8 +1319,7 @@
"id": "template_list",
"type": "basic",
"name": "Div",
"props": {
},
"props": {},
"children": [
{
"id": "template_item_{{$idx}}",
@@ -1361,8 +1432,7 @@
"id": "alerts_list",
"type": "basic",
"name": "Div",
"props": {
},
"props": {},
"children": [
{
"id": "alert_item_{{$idx}}",
@@ -16,7 +16,7 @@
// e2e:allow 프로브 판정의 순수 분기 로직 단위. 브라우저 시나리오는 asset-url-mode.spec.ts 담당.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { detectAssetUrlMode } from '../detectAssetUrlModeHandler';
import { checkAssetUrlModeDriftHandler, detectAssetUrlMode } from '../detectAssetUrlModeHandler';
/** 서버 AssetProbeController::PROBE_TOKEN 과 동일해야 하는 값 */
const TOKEN = 'G7_ASSET_PROBE_OK';
@@ -183,6 +183,98 @@ describe('detectAssetUrlMode — 프로브 판정 (§12 L6)', () => {
});
});
describe('대시보드 드리프트 대조 (§5)', () => {
let setGlobal: ReturnType<typeof vi.fn>;
/**
* 저장된 모드와 (선택적으로) 자가 복구된 런타임 모드를 심는다.
*
* @param stored 서버 설정에 저장된 모드 (undefined 면 미설정)
* @param recoveredRuntime 자가 복구가 전환해 둔 런타임 모드
*/
const setConfig = (stored?: string, recoveredRuntime?: string) => {
(window as any).G7Config = {
assetUrlMode: recoveredRuntime ?? stored,
settings: stored === undefined ? {} : { general: { asset_url_mode: stored } },
};
};
beforeEach(() => {
setGlobal = vi.fn();
(window as any).G7Core = { state: { setGlobal } };
});
afterEach(() => {
delete (window as any).G7Core;
delete (window as any).G7Config;
});
it('감지 결과가 저장값과 같으면 알리지 않는다', async () => {
setConfig('extension');
respond(mockResponse({}), mockResponse({}));
await checkAssetUrlModeDriftHandler();
expect(setGlobal, '드리프트가 없는데 대시보드에 경고를 띄웠다').not.toHaveBeenCalled();
});
it('감지 결과가 저장값과 다르면 양쪽 값을 함께 알린다', async () => {
setConfig('extension');
respond(mockResponse({ ok: false }), mockResponse({}));
await checkAssetUrlModeDriftHandler();
expect(setGlobal).toHaveBeenCalledWith({
assetUrlModeDrift: { detected: 'extensionless', stored: 'extension' },
});
});
it('판정 불가면 알리지 않는다 (일시 장애를 경고로 만들지 않는다)', async () => {
setConfig('extension');
respond(mockResponse({ ok: false }), mockResponse({ ok: false }));
await checkAssetUrlModeDriftHandler();
expect(setGlobal, '네트워크 장애를 설정 불일치로 오인해 경고했다').not.toHaveBeenCalled();
});
it('저장값이 없으면 기본값 extension 을 기준으로 대조한다', async () => {
setConfig(undefined);
respond(mockResponse({ ok: false }), mockResponse({}));
await checkAssetUrlModeDriftHandler();
expect(setGlobal).toHaveBeenCalledWith({
assetUrlModeDrift: { detected: 'extensionless', stored: 'extension' },
});
});
// 이 케이스가 이 기능의 존재 이유다. 자가 복구가 이미 런타임 모드를 바꿔 놓으면
// 화면은 멀쩡해 보이지만 저장값은 여전히 틀려 있다. 봇은 JavaScript 를 실행하지
// 않아 자가 복구가 닿지 않으므로, 저장값을 고치지 않으면 SEO 는 계속 깨진다.
it('자가 복구로 런타임 모드가 이미 바뀌었어도 저장값 기준으로 드리프트를 잡는다', async () => {
setConfig('extension', 'extensionless');
respond(mockResponse({ ok: false }), mockResponse({}));
await checkAssetUrlModeDriftHandler();
expect(
setGlobal,
'런타임 전환값을 기준으로 대조해 드리프트를 놓쳤다 — 저장값은 여전히 틀린 상태다',
).toHaveBeenCalledWith({
assetUrlModeDrift: { detected: 'extensionless', stored: 'extension' },
});
});
it('프로브가 throw 해도 대시보드를 죽이지 않는다', async () => {
setConfig('extension');
fetchSpy.mockRejectedValue(new TypeError('Failed to fetch'));
await expect(checkAssetUrlModeDriftHandler()).resolves.toBeUndefined();
expect(setGlobal).not.toHaveBeenCalled();
});
});
describe('프로브 요청 형태', () => {
it('두 경로를 쌍으로 던지고 캐시를 쓰지 않는다', async () => {
respond(mockResponse({}), mockResponse({}));
@@ -74,6 +74,56 @@ export async function detectAssetUrlMode(): Promise<DetectResult> {
return 'unavailable';
}
/**
* 서버에 **저장된** 자산 URL 방식을 읽는다.
*
* 런타임 전환값(`G7Config.assetUrlMode` / `__g7AssetUrlMode`)이 아니라 설정값을 본다.
* 자가 복구가 이미 모드를 바꿔 놓았다면 런타임 값은 감지 결과와 같아져 드리프트가
* 사라져 보이는데, 그때야말로 관리자에게 알려야 할 상황이다 — 봇은 JavaScript 를
* 실행하지 않아 자가 복구가 닿지 않으므로 저장값이 틀린 채로 두면 SEO 는 계속 깨진다.
*
* @returns 저장된 모드 (미설정이면 기본값 `extension`)
*/
function getStoredAssetUrlMode(): string {
const config = (window as any).G7Config;
return config?.settings?.general?.asset_url_mode ?? 'extension';
}
/**
* 저장된 모드와 실제 환경을 대조해 불일치를 전역 상태에 실어준다 (§5).
*
* 관리자 화면 자체가 안 뜨는 결함이라 "브라우저로 설정을 바꾸세요" 는 순환 참조다.
* 반대로 자가 복구가 조용히 성공하면 관리자는 사이트가 정상이라 믿고 저장값을
* 영영 고치지 않는다. 그래서 **대시보드가 스스로 프로브를 던져 저장값과 대조**한다.
*
* L5 위반이 아니다 — 서버 설정을 쓰지 않고 관리자에게 보여주기만 한다.
* L9 위반도 아니다 — L9 가 금지하는 것은 엔진 레이어(Router/LayoutLoader/
* ComponentRegistry)의 자산 로딩 중 재감지 캐스케이드이고, 이것은 관리자 화면
* 1회 진단이다.
*
* 판정 불가(`unavailable`)면 아무것도 표시하지 않는다. 일시적 네트워크 장애로
* 대시보드에 경고가 뜨면 신호가 아니라 소음이 된다.
*
* @param _action 액션 정의 (미사용)
*/
export async function checkAssetUrlModeDriftHandler(_action?: any): Promise<void> {
try {
const detected = await detectAssetUrlMode();
if (detected === 'unavailable') return;
const stored = getStoredAssetUrlMode();
if (detected === stored) return;
(window as any).G7Core?.state?.setGlobal?.({
assetUrlModeDrift: { detected, stored },
});
} catch (e) {
// 대시보드 진단이 실패해도 대시보드 자체는 정상 동작해야 한다.
logger.warn('자산 URL 방식 대조 실패 (대시보드 동작에는 영향 없음):', e);
}
}
/**
* 자산 URL 방식 자동 감지 핸들러.
*
@@ -25,7 +25,7 @@ import { toggleSidebarHandler, initSidebarHandler } from './sidebarHandler';
// 게시판 첨부 다운로드 핸들러 (토큰 동반 → 활동이력 행위자 기록, #413 item 58b)
import { downloadAttachmentHandler } from './downloadAttachment';
// 자산 URL 방식 자동 감지 (이슈 #486) — 브라우저에서 프로브 쌍을 던져 재판정
import { detectAssetUrlModeHandler } from './detectAssetUrlModeHandler';
import { checkAssetUrlModeDriftHandler, detectAssetUrlModeHandler } from './detectAssetUrlModeHandler';
/**
* 핸들러 맵
@@ -38,6 +38,8 @@ import { detectAssetUrlModeHandler } from './detectAssetUrlModeHandler';
export const handlerMap = {
// 언어: setLocale은 엔진 레벨(ActionDispatcher)에서 빌트인으로 처리
detectAssetUrlMode: detectAssetUrlModeHandler,
// 대시보드 진단 — 저장된 방식과 실제 환경을 대조해 불일치만 알린다 (저장하지 않음)
checkAssetUrlModeDrift: checkAssetUrlModeDriftHandler,
setTheme: setThemeHandler,
initTheme: initThemeHandler,
scrollToSection: scrollToSectionHandler,
@@ -2,6 +2,7 @@
namespace Tests\Feature\Api\Admin;
use App\Contracts\Extension\ModuleManagerInterface;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Enums\ExtensionStatus;
use App\Http\Controllers\Api\Admin\AdminTemplateAssetController;
@@ -239,6 +240,15 @@ class AdminTemplateAssetControllerTest extends TestCase
app(ModuleRepositoryInterface::class); // 바인딩 보장
Cache::flush(); // 활성 식별자 캐시 무효화
// 디스크 스캔을 명시적으로 수행한다.
//
// `getActiveModules()` 는 "디스크에서 로드된 모듈"(loadModules) 과 "DB 의 활성
// 식별자" 의 **교집합**이다. 부팅 시점에는 RefreshDatabase 로 modules 테이블이
// 비어 있어 디스크 스캔이 일어나지 않으므로, 위에서 행을 심어도 교집합이 공집합인
// 채로 남는다. 이 호출이 없으면 결과가 부팅 전 캐시 상태에 좌우돼
// (파일 캐시에 활성 식별자가 남아 있으면 통과, 비면 실패) 순서 의존 테스트가 된다.
app(ModuleManagerInterface::class)->loadModules();
$response = $this->withHeaders([
'Authorization' => "Bearer {$this->adminToken}",
'Accept' => 'application/json',
@@ -0,0 +1,101 @@
/**
* E2E: 관리자 대시보드 — 자산 URL 방식 드리프트 안내 (이슈 #486 §5)
*
* 배경: 브라우저 자가 복구는 방문자 화면을 살리지만 **저장된 설정은 그대로 틀린 상태**로
* 남는다. 검색엔진 봇은 JavaScript 를 실행하지 않아 자가 복구가 닿지 않으므로, 저장값을
* 고치지 않으면 SEO 는 계속 깨진다. 그런데 화면이 멀쩡해 보이므로 관리자는 문제를
* 인지할 계기가 없다 — 대시보드가 스스로 프로브를 던져 저장값과 대조하는 이유다.
*
* L5(클라이언트는 서버 설정을 쓰지 않는다)를 지킨다: 이 화면은 **알리기만** 하고,
* 저장은 관리자가 환경설정에서 직접 수행한다.
*
* 검증:
* 1. 저장값과 실제 환경이 일치하면 안내가 뜨지 않는다 (상시 경고는 소음이 된다)
* 2. 확장자 형태만 가로채이면 안내가 뜨고, 버튼이 환경설정 일반 탭으로 보낸다
* 3. 프로브가 양쪽 다 실패하면(PHP/네트워크 장애) 안내가 뜨지 않는다
*/
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
const DRIFT_ALERT = '#asset_url_mode_drift_alert';
const PROBE_WITH_EXT = '**/api/system/asset-probe.js*';
const PROBE_WITHOUT_EXT = '**/api/system/asset-probe';
/**
* 프로브 응답을 가로채 특정 환경을 모사한다.
*
* 실제 정적 최적화 블록은 경로가 정적 확장자로 끝나는 요청만 가로채므로,
* `.js` 프로브만 404 로 만드는 것이 그 환경의 정확한 재현이다.
*
* @param page 대상 페이지
* @param withExt 확장자 형태 프로브 성공 여부
* @param withoutExt 확장자 없는 형태 프로브 성공 여부
*/
async function stubProbes(
page: import('@playwright/test').Page,
withExt: boolean,
withoutExt: boolean,
): Promise<void> {
const block = async (route: import('@playwright/test').Route) => {
await route.fulfill({ status: 404, contentType: 'text/html', body: 'Not Found' });
};
if (!withExt) {
await page.route(PROBE_WITH_EXT, block);
}
if (!withoutExt) {
await page.route(PROBE_WITHOUT_EXT, block);
}
}
/** 대시보드 진입 후 드리프트 판정이 끝날 때까지 대기 */
async function gotoDashboard(page: import('@playwright/test').Page): Promise<void> {
await page.goto('/admin');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
// 판정은 프로브 2건의 왕복이 끝나야 나온다. 요청이 실제로 나갔는지로 대기 조건을 잡는다.
await page
.waitForResponse((res) => res.url().includes('/api/system/asset-probe'), { timeout: 20_000 })
.catch(() => undefined);
}
test.describe('관리자 대시보드 — 자산 URL 방식 드리프트 안내', () => {
test.beforeEach(async ({ page }) => {
const token = issueToken('core.dashboard.read', 'core.settings.read');
await authenticatePage(page, token);
});
test('저장값과 실제 환경이 일치하면 안내가 뜨지 않는다', async ({ page }) => {
await stubProbes(page, true, true);
await gotoDashboard(page);
await expect(
page.locator(DRIFT_ALERT),
'드리프트가 없는데 경고가 떴다 — 상시 경고는 신호가 아니라 소음이 된다',
).toHaveCount(0);
});
test('확장자 형태가 가로채이면 안내가 뜨고 환경설정으로 이동시킨다', async ({ page }) => {
await stubProbes(page, false, true);
await gotoDashboard(page);
const alert = page.locator(DRIFT_ALERT);
await expect(
alert,
'확장자 주소가 가로채이는데 대시보드가 아무것도 알리지 않았다 — 봇에게는 자가 복구가 닿지 않는다',
).toBeVisible({ timeout: 20_000 });
await alert.locator('#asset_url_mode_drift_link').click();
await expect(page).toHaveURL(/\/admin\/settings\?.*tab=general/, { timeout: 20_000 });
});
test('프로브가 양쪽 다 실패하면 안내가 뜨지 않는다 (모드 문제가 아님)', async ({ page }) => {
await stubProbes(page, false, false);
await gotoDashboard(page);
await expect(
page.locator(DRIFT_ALERT),
'일시적 네트워크 장애를 설정 불일치로 오인해 경고했다',
).toHaveCount(0);
});
});
@@ -11,6 +11,7 @@ use App\Providers\ModuleRouteServiceProvider;
use App\Providers\PluginRouteServiceProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use ReflectionClass;
use Tests\TestCase;
@@ -62,6 +63,39 @@ class InstallerCompletedGuardTest extends TestCase
}
}
/**
* `.env` 와 `modules`/`plugins` 디렉토리가 존재하는 임시 앱 루트에서 콜백을 실행합니다.
*
* 라우트 프로바이더는 인스톨러 실행 전 부팅을 위해 `.env` 부재 시 조기 반환한다.
* 그런데 `.env` 는 Git 추적 대상이 아니므로 신규 클론·CI 에는 존재하지 않고, 그러면
* 이 테스트가 검증하려는 `hasTable` 가드까지 도달하지도 못한 채 통과한 것처럼 보이거나
* (쿼리 0건) 실패한다. 실제로 그렇게 실패했다 — 개발자 로컬에 `.env` 가 있느냐에
* 결과가 좌우되는 상태였다.
*
* 프로젝트 루트에 파일을 만들지 않고 **임시 디렉토리를 앱 루트로 대체**해 전제를
* 결정적으로 만든다. DB 커넥션은 base path 와 무관하므로 `hasTable` 계측에는 영향이 없다.
*
* @param callable $callback 실행할 코드
*/
private function withFakeAppRoot(callable $callback): void
{
$root = sys_get_temp_dir().'/g7-installer-guard-'.bin2hex(random_bytes(4));
$originalBasePath = $this->app->basePath();
File::ensureDirectoryExists($root.'/modules');
File::ensureDirectoryExists($root.'/plugins');
File::put($root.'/.env', "APP_ENV=testing\n");
$this->app->setBasePath($root);
try {
$callback();
} finally {
$this->app->setBasePath($originalBasePath);
File::deleteDirectory($root);
}
}
/**
* 주어진 콜백 실행 중 발생한 information_schema.tables 쿼리만 수집합니다.
*
@@ -282,12 +316,14 @@ class InstallerCompletedGuardTest extends TestCase
$method = (new ReflectionClass($provider))->getMethod('loadModuleRoutes');
$method->setAccessible(true);
$this->withMigrationArgv(function () use ($provider, $method) {
$schemaQueries = $this->captureSchemaTableQueries(
fn () => $method->invoke($provider)
);
$this->withFakeAppRoot(function () use ($provider, $method) {
$this->withMigrationArgv(function () use ($provider, $method) {
$schemaQueries = $this->captureSchemaTableQueries(
fn () => $method->invoke($provider)
);
$this->assertGreaterThan(0, count($schemaQueries), '마이그레이션 중에는 installer_completed=true 라도 modules hasTable 검증 경로로 진입해 무방비 pluck 을 막아야 합니다');
$this->assertGreaterThan(0, count($schemaQueries), '마이그레이션 중에는 installer_completed=true 라도 modules hasTable 검증 경로로 진입해 무방비 pluck 을 막아야 합니다');
});
});
}
@@ -299,12 +335,14 @@ class InstallerCompletedGuardTest extends TestCase
$method = (new ReflectionClass($provider))->getMethod('loadPluginRoutes');
$method->setAccessible(true);
$this->withMigrationArgv(function () use ($provider, $method) {
$schemaQueries = $this->captureSchemaTableQueries(
fn () => $method->invoke($provider)
);
$this->withFakeAppRoot(function () use ($provider, $method) {
$this->withMigrationArgv(function () use ($provider, $method) {
$schemaQueries = $this->captureSchemaTableQueries(
fn () => $method->invoke($provider)
);
$this->assertGreaterThan(0, count($schemaQueries), '마이그레이션 중에는 installer_completed=true 라도 plugins hasTable 검증 경로로 진입해야 합니다');
$this->assertGreaterThan(0, count($schemaQueries), '마이그레이션 중에는 installer_completed=true 라도 plugins hasTable 검증 경로로 진입해야 합니다');
});
});
}
}
+30 -3
View File
@@ -4,12 +4,39 @@
* 인스톨러 단위 테스트용 lang() 글로벌 스텁
*
* public/install/includes/functions.php 의 lang() 헬퍼 대체.
* 메시지 키를 그대로 반환해 단위 테스트에서 검증 가능한 형태로 노출.
* 번역이 주입되지 않은 테스트에서는 메시지 키를 그대로 반환해 검증 가능한 형태로 노출한다.
*
* ## 왜 번역 주입을 존중하는가
*
* `lang()` 은 전역 함수이고 양쪽 정의 모두 `function_exists` 로 가드된다. 즉 같은
* PHPUnit 프로세스에서 **먼저 로드된 쪽이 이기고** 그 승자가 뒤따르는 모든 테스트 클래스에
* 적용된다. 이 스텁이 이기면, `$GLOBALS['translations']` 를 채워 실제 문구를 검증하는
* 테스트(PrivilegedDbAccountGuardTest 등)가 키만 돌려받아 실패한다 — 클래스 단독 실행은
* 통과하고 스위트 실행만 실패하는 전형적인 격리 결함이다.
*
* 그래서 스텁은 번역이 주입돼 있으면 실제 구현과 동일하게 동작하고, 없을 때만 키를
* 반환한다. 두 사용처의 의도를 모두 만족시키므로 로드 순서가 결과를 바꾸지 않는다.
*/
if (! function_exists('lang')) {
/**
* 번역 키를 해석한다 (테스트 스텁).
*
* @param string $key 번역 키
* @param array $params 치환할 플레이스홀더 (`:name` 형식)
* @return string 번역 문자열 또는 키
*/
function lang(string $key, array $params = []): string
{
unset($params);
return $key;
global $translations;
$message = (isset($translations) && is_array($translations))
? ($translations[$key] ?? $key)
: $key;
foreach ($params as $placeholder => $value) {
$message = str_replace(":{$placeholder}", (string) $value, $message);
}
return $message;
}
}
+40
View File
@@ -70,6 +70,46 @@ if ($prodDbName !== null && $testDbName !== null && $prodDbName === $testDbName)
exit(1);
}
/*
|--------------------------------------------------------------------------
| 개발자 .env 보호 안전망 (테스트가 삭제/변조해도 원상 복구)
|--------------------------------------------------------------------------
|
| 일부 업그레이드/인스톨러 테스트는 실제 프로젝트 루트의 `.env` 를 삭제·재생성·머지하고
| 자신의 tearDown 에서 되돌린다. 그런데 그 테스트가 fatal 로 죽으면 tearDown 이 실행되지
| 않아 개발자의 `.env` 가 사라진 채 남는다. `.env` 는 Git 추적 대상이 아니라 복구 수단이
| 없고, APP_KEY 를 잃으면 기존 암호화 데이터·세션이 전부 무효가 된다.
|
| 실제로 그렇게 유실된 적이 있어(2026-07-21), 개별 테스트의 복원 로직에 의존하지 않고
| 프로세스 수준에서 한 번 더 막는다. 스냅샷은 메모리에만 두고, 셧다운 시점에 내용이
| 달라졌거나 파일이 사라졌으면 바이트 단위로 되돌린다. register_shutdown_function 은
| fatal error 에서도 실행되므로 개별 tearDown 보다 넓은 범위를 덮는다.
|
| 각 테스트가 자기 fixture 를 임시 base path 로 격리하는 것이 근본 해법이며, 이 안전망은
| 그 격리가 누락되거나 새 테스트가 같은 실수를 반복할 때를 대비한 최후 방어선이다.
|
*/
$realEnvPath = __DIR__.'/../.env';
$realEnvSnapshot = file_exists($realEnvPath) ? file_get_contents($realEnvPath) : null;
if ($realEnvSnapshot !== false && $realEnvSnapshot !== null) {
register_shutdown_function(static function () use ($realEnvPath, $realEnvSnapshot): void {
$current = file_exists($realEnvPath) ? file_get_contents($realEnvPath) : null;
if ($current === $realEnvSnapshot) {
return;
}
if (@file_put_contents($realEnvPath, $realEnvSnapshot) === false) {
fwrite(STDERR, "\n[테스트 안전망] .env 복원 실패 — 수동 확인 필요: {$realEnvPath}\n");
return;
}
fwrite(STDERR, "\n[테스트 안전망] 테스트가 변경한 .env 를 실행 전 상태로 복원했습니다.\n");
});
}
/*
|--------------------------------------------------------------------------
| Config 캐시 삭제 (테스트 환경 보장)