feat(core): DB 최고권한 계정 차단 + OPcache 상태 안내

설치 단계와 코어 부팅의 정책이 엇갈려 있었다. 인스톨러는 root 를 자유롭게
허용하면서, 설치 후 부팅은 같은 값을 보고 확장 로딩 전체를 조용히 스킵했다.
설치는 성공하지만 이후 깨진 화면만 뜨고 원인은 로그 한 줄에만 남았다.

판정을 프레임워크 의존성 0 인 공용 SSoT 로 모아 인스톨러와 코어가 같은 답을
내게 하고, 설치 단계에서 서버 2경로로 차단하며, 설치 후 변경은 전용 에러
페이지로 드러낸다. 가드를 boot 가 아닌 미들웨어에 두어 artisan 이 죽지 않게
해 복구 수단을 남겼다. admin 은 일반 권한 운영 계정 오차단을 막기 위해 차단
목록에서 제외했다.

OPcache 는 상태 조회 경로 자체가 없어 성능 저하를 인지할 방법이 없었다.
설치 단계 경고(비차단)와 관리자 정보 탭 표시를 같은 판정 SSoT 로 추가했다.
확장 로드 여부만으로는 부족해 opcache.enable 지시자까지 확인하며, ini_get 이
차단된 환경은 "확인 불가"로 폴백해 경고도 차단도 하지 않는다.
This commit is contained in:
HeuJung
2026-07-20 14:17:49 +09:00
parent 91ec5f2d1f
commit 062a88ca04
38 changed files with 2402 additions and 56 deletions
+4
View File
@@ -17,8 +17,12 @@
- 사이트맵의 갱신 빈도(changefreq) 값을 검색엔진 표준 어휘(always/hourly/daily/weekly/monthly/yearly/never)로 맞춰, 규격에 없는 값은 사이트맵에 출력되지 않도록 했습니다.
- 관리자가 사이트맵 전체 재생성을 실행하면, 완료되거나 실패했을 때 실행한 관리자에게 알림을 보냅니다. 재생성이 오래 걸려 화면을 떠나 있어도 결과를 놓치지 않습니다. (기본은 앱 내 알림이며 관리자 알림 설정에서 이메일도 켤 수 있습니다. 매일 자동 생성이나 글·상품 변경에 따른 재생성은 알림을 보내지 않습니다.)
- 설치할 때 `root` 같은 데이터베이스 최고 권한 계정을 입력하면 설치가 진행되지 않고, 왜 사용할 수 없는지와 어떻게 해야 하는지를 함께 안내합니다. 최고 권한 계정 정보가 유출되면 데이터베이스 전체가 위험해지기 때문이며, 사이트 전용 데이터베이스 계정을 새로 만들어 필요한 권한만 부여해 입력하시면 됩니다. 읽기 전용 데이터베이스를 따로 쓰는 경우에도 동일하게 적용됩니다.
- 서버에 OPcache가 켜져 있는지를 설치 화면과 관리자 환경설정 > 정보에서 확인할 수 있습니다. OPcache는 PHP 코드 해석 결과를 재사용해 사이트 응답 속도를 크게 높여 주는 기능으로, 꺼져 있으면 성능이 떨어진다는 안내와 함께 설정 방법을 알려 드립니다. 꺼져 있어도 설치는 그대로 진행되므로 설치가 막히지 않습니다.
### Fixed
- 데이터베이스 계정 설정이 잘못되었을 때 아무 설명 없이 빈 화면만 뜨던 문제를 수정했습니다. 이전에는 최고 권한 계정을 쓰거나 사용자명이 비어 있으면 사이트 기능이 통째로 동작을 멈추면서도 화면에는 아무 안내가 없어 원인을 알 수 없었습니다. 이제 무엇이 잘못되었고 어떻게 고쳐야 하는지 설명하는 전용 안내 화면이 나타납니다. 서버 관리 명령은 평소대로 실행되므로 이 상태에서도 설정을 고칠 수 있습니다.
- SEO 설정의 캐시 항목(SEO 캐시 사용·유지 시간, Sitemap 캐시 유지 시간)이 저장해도 적용되지 않던 문제를 수정했습니다. 이제 고급 설정의 캐시 값이 기준이 되고, SEO 설정에서 값을 지정하면 그 값이 우선하며, 비워두면 고급 설정을 따릅니다. 업그레이드 시 기존에 기본값과 다르게 지정해 두셨던 값은 그대로 살아나 이제부터 실제로 적용되므로 SEO 캐시 동작이 달라질 수 있습니다. 기본값 그대로였던 항목은 "지정 안 함"으로 정리되어 동작이 바뀌지 않습니다.
## [7.0.5] - 2026-07-16
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Middleware\Concerns;
use Illuminate\Http\Request;
/**
* SetLocale 미들웨어보다 앞서 실행되는 미들웨어를 위한 로케일 자체 감지.
*
* 메인터넌스 페이지·DB 자격증명 가드처럼 인증·세션·확장 로딩 이전에 응답을
* 만들어야 하는 미들웨어는 SetLocale 이 아직 실행되지 않은 상태에서 동작한다.
* 이 트레이트가 SetLocale 의 판정 로직을 준용해 Accept-Language 를 해석한다.
*/
trait DetectsRequestLocale
{
/**
* 요청 헤더에서 로케일을 감지합니다.
*
* @param Request $request HTTP 요청
* @return string 감지된 로케일 (지원 목록에 없으면 앱 기본 로케일)
*/
protected function detectLocale(Request $request): string
{
$supportedLocales = config('app.supported_locales', ['ko', 'en']);
$acceptLanguage = $request->header('Accept-Language');
if ($acceptLanguage) {
$languages = explode(',', $acceptLanguage);
foreach ($languages as $language) {
$lang = trim(explode(';', $language)[0]);
if (strpos($lang, '-') !== false) {
$lang = explode('-', $lang)[0];
}
if (in_array($lang, $supportedLocales)) {
return $lang;
}
}
}
return config('app.locale', 'ko');
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Http\Middleware;
use App\Http\Middleware\Concerns\DetectsRequestLocale;
use App\Support\PrivilegedDatabaseAccounts;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* DB 사용자명이 사용 불가일 때 전용 에러 페이지를 렌더링합니다.
*
* 배경: DB 사용자명이 최고권한 계정이거나 비어 있으면 CoreServiceProvider 가
* 확장(모듈·플러그인·템플릿) 로딩 전체를 스킵한다. 그 결과 템플릿 없는 깨진
* 화면만 뜨고 원인은 로그 한 줄에만 남아 운영자가 알 방법이 없었다.
* 이 미들웨어가 그 "조용한 실패" 를 드러낸다.
*
* ServiceProvider::boot() 가 아니라 미들웨어에 두는 이유: boot() 에서 abort 하면
* `php artisan` 자체가 죽어 복구 수단을 잃는다. 미들웨어는 웹 요청만 타므로
* 콘솔이 자연히 통과하고 운영자가 config:clear 등으로 복구할 수 있다.
*/
class DatabaseCredentialGuard
{
use DetectsRequestLocale;
/**
* DB 자격증명을 검사해 사용 불가 시 503 응답을 반환합니다.
*
* @param Request $request HTTP 요청
* @param Closure $next 다음 미들웨어
* @return Response HTTP 응답
*/
public function handle(Request $request, Closure $next): Response
{
// 미설치 상태는 통과 — DB 설정이 비어 있는 것이 정상이며,
// public/index.php 가 이미 /install 로 유도한다.
if (! config('app.installer_completed')) {
return $next($request);
}
$username = $this->resolveUsername();
if (PrivilegedDatabaseAccounts::isUsable($username)) {
return $next($request);
}
// SetLocale 미실행 상태이므로 로케일을 자체 감지한다.
// API/HTML 분기보다 먼저 적용해야 JSON 메시지도 활성 로케일을 반영한다.
app()->setLocale($this->detectLocale($request));
if ($request->expectsJson() || $request->is('api/*')) {
return response()->json([
'success' => false,
'message' => __('database_credential.message'),
], 503);
}
// 확장·템플릿이 로딩되지 않은 상태이므로 템플릿 뷰나 errors/layout 을
// 쓸 수 없다. DB/JS 무의존 자체 완결 정적 페이지를 반환한다.
return response()->view('database-credential-error', [
// 실제 계정명은 전달하지 않는다 — 미인증 공개 페이지이므로 정보 유출.
'reason' => PrivilegedDatabaseAccounts::isBlocked($username) ? 'blocked' : 'empty',
], 503);
}
/**
* 현재 DB 커넥션 설정에서 사용자명을 해석합니다.
*
* DB::connection() 을 거치지 않고 config 배열만 읽습니다. 이 가드는 DB 설정이
* 잘못된 상황에서 동작해야 하므로, 판정을 위해 커넥션을 해석하는 것 자체가
* 새로운 실패 지점이 됩니다. read/write 우선순위 규칙은
* PrivilegedDatabaseAccounts 가 CoreServiceProvider 와 공유합니다.
*
* @return string|null 해석된 사용자명 (없으면 null)
*/
protected function resolveUsername(): ?string
{
$connection = config('database.default');
return PrivilegedDatabaseAccounts::resolveUsername(
config("database.connections.{$connection}", [])
);
}
}
+3 -27
View File
@@ -2,6 +2,7 @@
namespace App\Http\Middleware;
use App\Http\Middleware\Concerns\DetectsRequestLocale;
use Closure;
use Illuminate\Foundation\Http\MaintenanceModeBypassCookie;
use Illuminate\Http\Request;
@@ -9,6 +10,8 @@ use Symfony\Component\HttpFoundation\Response;
class MaintenanceModePage
{
use DetectsRequestLocale;
/**
* Maintenance 모드일 때 전용 정적 페이지를 렌더링합니다.
*
@@ -76,31 +79,4 @@ class MaintenanceModePage
return MaintenanceModeBypassCookie::isValid($cookie, $data['secret']);
}
/**
* 로케일을 감지합니다. SetLocale 미들웨어의 로직을 준용합니다.
*
* @param Request $request HTTP 요청
* @return string 감지된 로케일
*/
protected function detectLocale(Request $request): string
{
$supportedLocales = config('app.supported_locales', ['ko', 'en']);
$acceptLanguage = $request->header('Accept-Language');
if ($acceptLanguage) {
$languages = explode(',', $acceptLanguage);
foreach ($languages as $language) {
$lang = trim(explode(';', $language)[0]);
if (strpos($lang, '-') !== false) {
$lang = explode('-', $lang)[0];
}
if (in_array($lang, $supportedLocales)) {
return $lang;
}
}
}
return config('app.locale', 'ko');
}
}
+9 -18
View File
@@ -92,6 +92,7 @@ use App\Services\DriverRegistryService;
use App\Services\LayoutExtensionService;
use App\Services\TemplateLayoutAttachmentService;
use App\Services\UniqueIdService;
use App\Support\PrivilegedDatabaseAccounts;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
@@ -338,9 +339,9 @@ class CoreServiceProvider extends ServiceProvider
return;
}
// DB 연결 유효성 검증 (root 사용자 접속 방지)
// DB 연결 유효성 검증 (최고권한 계정 접속 방지)
if (! $this->isDatabaseConnectionValid()) {
Log::error('Database connection invalid: using root user or missing credentials. Skipping extension loading.');
Log::error('Database connection invalid: using a privileged database account or missing credentials. Skipping extension loading.');
return;
}
@@ -444,30 +445,20 @@ class CoreServiceProvider extends ServiceProvider
/**
* 데이터베이스 연결이 유효한지 검증합니다.
*
* root 사용자로 접속하거나 비밀번호가 없는 경우를 감지하여
* DB 최고권한 계정으로 접속하거나 사용자명이 비어 있는 경우를 감지하여
* 잘못된 설정으로 인한 접속 오류를 방지합니다.
* 계정 판정은 `PrivilegedDatabaseAccounts` 가 SSoT 입니다.
*
* @return bool 연결이 유효하면 true
*/
protected function isDatabaseConnectionValid(): bool
{
try {
$config = DB::connection()->getConfig();
// read/write 분리 설정의 우선순위 규칙까지 PrivilegedDatabaseAccounts 가 소유한다.
// 호출처마다 규칙이 달라지면 같은 설정에 서로 다른 판정이 나온다.
$username = PrivilegedDatabaseAccounts::resolveUsername(DB::connection()->getConfig());
// read/write 분리 설정인 경우 read 설정 확인
$username = $config['username'] ?? null;
// read 설정이 배열로 있는 경우
if (isset($config['read']['username'])) {
$username = $config['read']['username'];
}
// root 사용자 또는 빈 username인 경우 무효
if (empty($username) || $username === 'root') {
return false;
}
return true;
return PrivilegedDatabaseAccounts::isUsable($username);
} catch (\Throwable $e) {
Log::error('Database configuration check failed: '.$e->getMessage());
+17
View File
@@ -8,6 +8,7 @@ use App\Contracts\Repositories\ConfigRepositoryInterface;
use App\Extension\HookManager;
use App\Http\Resources\AttachmentResource;
use App\Support\ConfigCacheHelper;
use App\Support\OpcacheStatus;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
@@ -844,6 +845,9 @@ class SettingsService
'php_memory_limit' => $this->safeSystemProbe('php_memory_limit', fn () => ini_get('memory_limit'), __('common.unknown')),
'max_execution_time' => $this->safeSystemProbe('max_execution_time', fn () => ini_get('max_execution_time').__('settings.seconds'), __('common.unknown')),
'upload_max_filesize' => $this->safeSystemProbe('upload_max_filesize', fn () => ini_get('upload_max_filesize'), __('common.unknown')),
// 판정은 App\Support\OpcacheStatus 가 SSoT — 인스톨러 요구사항 화면과 같은 답을 낸다.
// enabled 가 null 이면 "확인 불가"(ini_get 차단 환경).
'opcache' => $this->safeSystemProbe('opcache', fn () => $this->getOpcacheStatus(), ['loaded' => false, 'enabled' => null]),
'install_path' => base_path(),
'config_path' => storage_path('app/settings'),
'log_path' => storage_path('logs'),
@@ -854,6 +858,19 @@ class SettingsService
];
}
/**
* OPcache 활성화 상태를 조회합니다.
*
* 판정은 App\Support\OpcacheStatus 가 SSoT 이며, 인스톨러 요구사항 화면과
* 같은 답을 냅니다. `enabled` 가 null 이면 ini_get 이 차단된 "확인 불가" 상태입니다.
*
* @return array{loaded: bool, enabled: bool|null} OPcache 상태
*/
protected function getOpcacheStatus(): array
{
return OpcacheStatus::probe();
}
/**
* 시스템 정보 probe 를 안전하게 실행합니다.
*
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Support;
/**
* OPcache 활성화 상태 판정 (인스톨러·코어 공용 SSoT).
*
* 인스톨러(`public/install/`)는 Laravel 오토로드 없이 동작하는 순수 PHP 이므로
* 이 클래스는 파사드·헬퍼 등 프레임워크 의존성을 일절 참조하지 않는다.
* 인스톨러는 `require_once` 로 이 파일을 직접 로드해 사용한다.
*
* 확장 로드 여부만으로는 부족하다 — `Zend OPcache` 가 로드돼 있어도
* `opcache.enable=0` 이면 실제로는 동작하지 않는다.
*/
final class OpcacheStatus
{
/** OPcache PHP 확장명 */
public const EXTENSION_NAME = 'Zend OPcache';
/** OPcache 활성화 여부를 담은 php.ini 지시자 */
public const ENABLE_DIRECTIVE = 'opcache.enable';
/**
* OPcache 상태를 조회합니다.
*
* `enabled` 가 null 이면 "확인 불가" 를 뜻합니다 — `ini_get` 이
* `disable_functions` 로 차단된 환경에서 발생하며, 경고도 차단도 하지 않습니다.
*
* 두 콜백 파라미터는 테스트에서 환경을 주입하기 위한 것으로,
* 운영 코드는 인수 없이 호출합니다.
*
* @param callable|null $extensionLoaded 확장 로드 여부 판정 콜백 (기본: extension_loaded)
* @param callable|null $iniGet php.ini 지시자 조회 콜백 (기본: ini_get)
* @return array{loaded: bool, enabled: bool|null} 확장 로드 여부와 활성화 여부
*/
public static function probe(?callable $extensionLoaded = null, ?callable $iniGet = null): array
{
$extensionLoaded ??= static fn (string $name): bool => extension_loaded($name);
$iniGet ??= static fn (string $key) => ini_get($key);
if (! $extensionLoaded(self::EXTENSION_NAME)) {
return ['loaded' => false, 'enabled' => false];
}
try {
$raw = $iniGet(self::ENABLE_DIRECTIVE);
} catch (\Throwable) {
// ini_get 이 disable_functions 로 차단된 환경 — 확인 불가
return ['loaded' => true, 'enabled' => null];
}
// ini_get 은 지시자를 못 읽으면 false 를 반환한다 (값 '0' 과 구분해야 함)
if ($raw === false || $raw === null || $raw === '') {
return ['loaded' => true, 'enabled' => null];
}
return ['loaded' => true, 'enabled' => filter_var($raw, FILTER_VALIDATE_BOOLEAN)];
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Support;
/**
* DB 최고권한 계정 판정 (인스톨러·코어 공용 SSoT).
*
* 인스톨러(`public/install/`)는 Laravel 오토로드 없이 동작하는 순수 PHP 이므로
* 이 클래스는 파사드·헬퍼 등 프레임워크 의존성을 일절 참조하지 않는다.
* 인스톨러는 `require_once` 로 이 파일을 직접 로드해 사용한다.
*/
final class PrivilegedDatabaseAccounts
{
/**
* 차단 대상 DB 최고권한 계정명 (소문자 기준).
*
* `admin` 은 의도적으로 제외한다 — 일반 권한으로 운영 중인 `admin` 계정
* 사용자가 업그레이드 후 오차단되는 것을 막기 위함이다.
*/
public const BLOCKED = [
'root', // MySQL / MariaDB
'postgres', // PostgreSQL
'sa', // SQL Server
'sys', // Oracle
'system', // Oracle
];
/**
* 최고권한 계정명인지 판정합니다.
*
* 대소문자와 앞뒤 공백을 무시해 `ROOT`, ` root ` 같은 우회를 차단합니다.
*
* @param string|null $username 검사할 DB 사용자명
* @return bool 최고권한 계정이면 true
*/
public static function isBlocked(?string $username): bool
{
if ($username === null) {
return false;
}
return in_array(self::normalize($username), self::BLOCKED, true);
}
/**
* G7 이 사용할 수 있는 DB 사용자명인지 판정합니다.
*
* 빈 값(설정 누락)과 최고권한 계정을 모두 사용 불가로 봅니다.
*
* @param string|null $username 검사할 DB 사용자명
* @return bool 사용 가능하면 true
*/
public static function isUsable(?string $username): bool
{
if ($username === null || self::normalize($username) === '') {
return false;
}
return ! self::isBlocked($username);
}
/**
* DB 커넥션 설정에서 실제 사용되는 사용자명을 해석합니다.
*
* read/write 분리 설정인 경우 read 사용자명을 우선합니다. 이 우선순위 규칙이
* 호출처마다 달라지면 같은 설정을 두고 서로 다른 판정이 나오므로 여기서만 정의합니다.
*
* @param array $config DB 커넥션 설정 배열
* @return string|null 해석된 사용자명 (없으면 null)
*/
public static function resolveUsername(array $config): ?string
{
if (isset($config['read']['username'])) {
return $config['read']['username'];
}
return $config['username'] ?? null;
}
/**
* 비교용으로 사용자명을 정규화합니다.
*
* @param string $username 원본 사용자명
* @return string 소문자·트림된 사용자명
*/
private static function normalize(string $username): string
{
return strtolower(trim($username));
}
}
+9
View File
@@ -6,6 +6,7 @@ use App\Helpers\ResponseHelper;
use App\Http\Middleware\AdminMiddleware;
use App\Http\Middleware\CheckTemplateDependencies;
use App\Http\Middleware\CheckUserStatus;
use App\Http\Middleware\DatabaseCredentialGuard;
use App\Http\Middleware\EnforceIdentityPolicy;
use App\Http\Middleware\ExtensionMiddlewareGate;
use App\Http\Middleware\GzipEncodeResponse;
@@ -77,6 +78,14 @@ $app = Application::configure(basePath: dirname(__DIR__))
// Laravel 기본 메인터넌스 미들웨어 제거 (커스텀 MaintenanceModePage로 대체)
$middleware->remove(PreventRequestsDuringMaintenance::class);
// DB 자격증명 가드 (인증 불필요) — 최고권한 계정/빈 사용자명으로 인한
// 확장 로딩 스킵을 전용 에러 페이지로 드러낸다.
//
// prepend() 는 새 항목을 기존 prepends 앞에 넣으므로, 아래 MaintenanceModePage
// prepend 가 뒤따르면 최종 순서는 [MaintenanceModePage, DatabaseCredentialGuard] 가
// 된다. 점검 중에는 점검 페이지가 우선해야 하므로 이 호출 순서를 유지한다.
$middleware->prepend(DatabaseCredentialGuard::class);
// Maintenance 모드 전용 페이지 미들웨어 (인증 불필요, 최우선 실행)
$middleware->prepend(MaintenanceModePage::class);
@@ -8,6 +8,7 @@
### Added
- 데이터베이스 계정 설정 오류 안내 화면의 문구 일본어 번역 추가 (`database_credential.*`) — 최고 권한 계정을 사용하거나 사용자명이 비어 있을 때 표시되는 안내와 복구 방법이 일본어 로케일에서 자연스럽게 표시됩니다.
- 사이트맵 생성이 실패했을 때 표시되는 안내 문구 일본어 번역 추가 (`exceptions.seo.*`) — 사이트맵 파일을 저장하지 못하거나 압축에 실패한 경우의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 사이트맵 재생성 예약·준비 중·생성 실패 안내 문구 일본어 번역 추가 (`seo.sitemap_*`) — 재생성을 예약했을 때, 아직 준비 중일 때, 생성에 실패했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- SEO 설정의 사이트맵 분할 기준·압축·수집기 상한과 SEO/사이트맵 캐시 유지 시간 항목의 검증 안내 문구 일본어 번역 추가 (`validation.settings.*`) — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
@@ -0,0 +1,10 @@
<?php
return [
'title' => 'データベースアカウント設定エラー',
'message' => 'データベースアカウント設定に問題があり、サイトを表示できません。',
'description' => 'この状態ではモジュール·プラグイン·テンプレートがロードされません。以下のガイドに従って設定を修正してください。',
'blocked_reason' => 'root のようなデータベース最高権限アカウントはセキュリティ上使用できません。該当するアカウントが流出した場合、データベース全体が危険にさらされます。',
'empty_reason' => 'データベースユーザー名が空です。設定が欠落しているか、破損している可能性があります。',
'recovery_guide' => '.env ファイルの DB_WRITE_USERNAME 値をこのサイト専用のデータベースアカウントに変更してから、php artisan config:clear コマンドを実行してください。',
];
@@ -8,6 +8,7 @@
### Added
- 환경설정 > 정보 화면에 추가된 OPcache 상태 항목과 비활성 안내 문구 일본어 번역 추가 — 상태 표시와 성능 저하 안내가 일본어 로케일에서도 올바르게 표시됩니다.
- 환경설정 > SEO 화면에 추가된 Sitemap 분할 기준(파일당 URL 수)·Sitemap 압축 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다.
- 환경설정 > SEO 화면에 추가된 Sitemap 다국어 대체 링크(hreflang) 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다.
- 고급 설정에 추가된 Sitemap 캐시 유지 시간 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다.
@@ -1909,7 +1909,12 @@
"update_guide_cmd": "$ sudo php artisan core:update",
"manual_update_cmd": "$ sudo php artisan core:update --source=/path/to/extracted",
"upgrade_guide_link_desc": "運用環境別の権限/ユーザーグループ別の詳細な手順はアップグレードガイドを参照してください。",
"upgrade_guide_link_label": "全体アップグレードガイドを表示"
"upgrade_guide_link_label": "全体アップグレードガイドを表示",
"opcache": "OPcache",
"opcache_enabled": "有効化",
"opcache_disabled": "無効化",
"opcache_unknown": "確認不可",
"opcache_disabled_warning": "OPcacheが無効化されています。毎回のリクエストごとにPHPコードが再解析されるため、サイトの応答速度が著しく低下します。php.iniでopcache.enable=1の設定をお勧めします。設定変更後、この画面に反映されるまで最大1時間かかる可能性があります。キャッシュクリアを実行すると即座に反映されます。"
},
"modals": {
"password_confirm_title": "パスワード確認",
+10
View File
@@ -0,0 +1,10 @@
<?php
return [
'title' => 'Database Account Configuration Error',
'message' => 'The site cannot be displayed because of a problem with the database account configuration.',
'description' => 'While this problem persists, modules, plugins and templates are not loaded. Please correct the configuration using the guidance below.',
'blocked_reason' => 'Database superuser accounts such as root cannot be used, for security reasons. If such an account is compromised, the entire database is at risk.',
'empty_reason' => 'The database username is empty. The configuration may be missing or corrupted.',
'recovery_guide' => 'Change DB_WRITE_USERNAME in the .env file to a database account dedicated to this site, then run: php artisan config:clear',
];
+10
View File
@@ -0,0 +1,10 @@
<?php
return [
'title' => '데이터베이스 계정 설정 오류',
'message' => '데이터베이스 계정 설정에 문제가 있어 사이트를 표시할 수 없습니다.',
'description' => '이 상태에서는 모듈·플러그인·템플릿이 로딩되지 않습니다. 아래 안내에 따라 설정을 수정해 주세요.',
'blocked_reason' => 'root 같은 데이터베이스 최고 권한 계정은 보안상 사용할 수 없습니다. 해당 계정이 유출되면 데이터베이스 전체가 위험해집니다.',
'empty_reason' => '데이터베이스 사용자명이 비어 있습니다. 설정이 누락되었거나 손상되었을 수 있습니다.',
'recovery_guide' => '.env 파일의 DB_WRITE_USERNAME 값을 이 사이트 전용 데이터베이스 계정으로 변경한 뒤, php artisan config:clear 명령을 실행해 주세요.',
];
+40 -1
View File
@@ -69,6 +69,7 @@ class ValidationApi
'directories' => $this->checkDirectoryPermissions(),
'required_files' => $this->checkRequiredFiles(),
'https' => $this->checkHttps(),
'opcache' => $this->checkOpcache(),
];
// 모든 필수 요구사항 통과 여부
@@ -137,6 +138,15 @@ class ValidationApi
throw new Exception(lang('error_db_credentials_required'));
}
// DB 최고권한 계정 차단 (Write/Read 공통).
// 최고권한 계정 자격증명이 유출되면 데이터베이스 전체가 위험해지므로
// 연결 시도 전에 막는다. 판정은 App\Support\PrivilegedDatabaseAccounts 가 SSoT.
if (\App\Support\PrivilegedDatabaseAccounts::isBlocked($username)) {
throw new Exception(
lang('error_db_username_privileged', ['username' => $username])
);
}
// DB 테이블 접두사 길이 검증 (Write DB 입력에만 적용)
// 접두사가 길면 자동 생성 인덱스명이 MySQL identifier 한도(64자)를 초과해
// 일부 확장 설치 시 마이그레이션이 실패하므로 연결 시도 전에 차단한다.
@@ -505,6 +515,35 @@ class ValidationApi
];
}
/**
* OPcache 활성화 여부 검증 (권장 사항 — 설치를 차단하지 않음)
*
* 확장 로드 여부만으로는 부족하다. `Zend OPcache` 가 로드돼 있어도
* `opcache.enable=0` 이면 실제로는 동작하지 않으므로 지시자까지 확인한다.
* 판정은 App\Support\OpcacheStatus 가 SSoT 이며, 코어 관리자 화면과 같은 답을 낸다.
*
* `enabled` 가 null 이면 "확인 불가"(ini_get 차단 환경)로, 경고도 차단도 하지 않는다.
*/
private function checkOpcache(): array
{
$status = \App\Support\OpcacheStatus::probe();
if ($status['enabled'] === null) {
$message = lang('opcache_unknown');
} else {
$message = $status['enabled']
? lang('opcache_enabled')
: lang('opcache_disabled_warning');
}
return [
'required' => false, // OPcache는 선택 사항 (성능 권장)
'loaded' => $status['loaded'],
'enabled' => $status['enabled'],
'message' => $message,
];
}
/**
* 필수 PHP 함수(exec, proc_open) 비활성화 여부 검증
*/
@@ -1026,7 +1065,7 @@ class ValidationApi
return false;
}
// HTTPS는 선택 사항이므로 검증하지 않음
// HTTPS / OPcache 는 선택 사항이므로 검증하지 않음 (경고만 표시하고 설치는 진행)
return true;
}
+76
View File
@@ -443,6 +443,31 @@
);
}
// 9. OPcache 카드 (성능 권장, 필수 아님)
// failedRequirements.push() 를 하지 않는다 — 비활성이어도 설치는 진행된다.
if (data.opcache) {
let opcacheStatusClass, opcacheStatusText;
if (data.opcache.enabled === null) {
// ini_get 이 차단된 환경 — 확인 불가 (경고도 차단도 아님)
opcacheStatusClass = 'status-warning';
opcacheStatusText = lang('opcache_unknown');
} else if (data.opcache.enabled) {
opcacheStatusClass = 'status-pass';
opcacheStatusText = lang('enabled');
} else {
// 단순히 '비활성화됨' 만 보이면 경고의 실질(성능 저하)이 전달되지 않는다.
opcacheStatusClass = 'status-warning';
opcacheStatusText = lang('opcache_disabled_short');
}
html += renderSingleItemCard(
lang('opcache'),
opcacheStatusClass,
opcacheStatusText,
'',
false
);
}
html += '</div>';
// 필수 조건 미충족 시 에러 박스 표시
@@ -1387,6 +1412,39 @@
FormValidator.setFieldValid(field.id, true);
}
/**
* DB 최고권한 계정명 여부 판정
*
* 목록은 서버(App\Support\PrivilegedDatabaseAccounts::BLOCKED)에서 내려온 값을
* 그대로 쓴다. JS 에 목록을 중복 정의하면 서버와 어긋날 수 있다.
* 비교는 서버와 동일하게 소문자·트림 기준.
*
* @param {string} username 검사할 DB 사용자명
* @returns {boolean} 최고권한 계정이면 true
*/
function isPrivilegedDbAccount(username) {
var blocked = window.INSTALLER_BLOCKED_DB_ACCOUNTS;
// 서버 목록이 없으면 클라이언트 판정을 포기한다 (서버 검증이 최종 방어선).
if (!Array.isArray(blocked)) {
return false;
}
return blocked.indexOf(String(username || '').trim().toLowerCase()) !== -1;
}
/**
* 최고권한 계정 차단 메시지를 생성한다.
*
* JS 의 lang() 은 서버 lang() 과 달리 치환을 지원하지 않으므로 호출부에서 수동 치환한다.
*
* @param {string} username 입력된 DB 사용자명
* @returns {string} 치환된 메시지
*/
function privilegedDbAccountMessage(username) {
return getLangMessage('error_db_username_privileged').replace(':username', username);
}
/**
* 필드 검증
*/
@@ -1438,6 +1496,14 @@
return false;
}
// DB 사용자명 검증 — 최고권한 계정은 보안상 사용할 수 없다.
if ((fieldName === 'db_write_username' || fieldName === 'db_read_username') && value) {
if (isPrivilegedDbAccount(value)) {
showFieldError(field, privilegedDbAccountMessage(value));
return false;
}
}
// DB Prefix 검증 (선택 사항이지만, 입력 시 형식 검증)
if (fieldName === 'db_prefix' && value) {
// 영문 소문자로 시작해야 함
@@ -1639,6 +1705,11 @@
}
if (!dbWriteUsername || !dbWriteUsername.value.trim()) {
errors.push({ field: dbWriteUsername, message: lang('error_db_username_required') });
} else if (isPrivilegedDbAccount(dbWriteUsername.value)) {
errors.push({
field: dbWriteUsername,
message: privilegedDbAccountMessage(dbWriteUsername.value.trim()),
});
}
// Read DB 사용 시 Read DB 필드 확인
@@ -1656,6 +1727,11 @@
}
if (!dbReadUsername || !dbReadUsername.value.trim()) {
errors.push({ field: dbReadUsername, message: lang('error_db_username_required') });
} else if (isPrivilegedDbAccount(dbReadUsername.value)) {
errors.push({
field: dbReadUsername,
message: privilegedDbAccountMessage(dbReadUsername.value.trim()),
});
}
}
+13
View File
@@ -210,4 +210,17 @@ if (!defined('SUPPORTED_LANGUAGES')) {
// 설치 완료 후 인스톨러 파일 삭제 여부
if (!defined('DELETE_INSTALLER_AFTER_COMPLETE')) {
define('DELETE_INSTALLER_AFTER_COMPLETE', true);
}
// 코어와 공유하는 판정 SSoT 클래스 로드.
//
// 인스톨러는 Laravel 오토로드 없이 도는 순수 PHP 이므로 파일을 직접 require 한다.
// 두 클래스는 프레임워크 의존성이 0 이라 이 방식이 성립하며, 인스톨러와 코어가
// 서로 다른 판정을 내리는 것을 구조적으로 차단한다.
// class_exists 가드: Laravel 부트가 선행된 테스트 환경에서 중복 로드 방지.
if (!class_exists('App\\Support\\PrivilegedDatabaseAccounts', false)) {
require_once BASE_PATH . '/app/Support/PrivilegedDatabaseAccounts.php';
}
if (!class_exists('App\\Support\\OpcacheStatus', false)) {
require_once BASE_PATH . '/app/Support/OpcacheStatus.php';
}
@@ -119,6 +119,32 @@ function handleStep2Post(): void
}
}
/**
* DB 사용자명을 검증해 에러 배열에 결과를 기록합니다.
*
* 빈 값(설정 누락)과 DB 최고권한 계정을 모두 거부합니다. 최고권한 계정 자격증명이
* 유출되면 데이터베이스 전체가 위험해지므로 설치 단계에서 차단합니다.
* 판정은 App\Support\PrivilegedDatabaseAccounts 가 SSoT 입니다.
*
* @param string $username 입력된 DB 사용자명
* @param string $field 에러 배열에 사용할 필드명 (db_write_username 등)
* @param array &$errors 에러 배열 (참조)
*/
function validateDbUsername(string $username, string $field, array &$errors): void
{
$username = trim($username);
if ($username === '') {
$errors[$field] = lang('error_db_username_required');
return;
}
if (\App\Support\PrivilegedDatabaseAccounts::isBlocked($username)) {
$errors[$field] = lang('error_db_username_privileged', ['username' => $username]);
}
}
/**
* Step 3 POST 처리 (데이터베이스 및 사이트 설정)
*
@@ -140,6 +166,7 @@ function handleStep3Post(string $currentLang, array &$formData, array &$errors):
if (empty($formData['db_write_database'])) {
$errors['db_write_database'] = lang('error_db_name_required');
}
validateDbUsername($formData['db_write_username'] ?? '', 'db_write_username', $errors);
// Read DB 검증 (사용하는 경우)
if (! empty($formData['use_read_db'])) {
@@ -149,6 +176,7 @@ function handleStep3Post(string $currentLang, array &$formData, array &$errors):
if (empty($formData['db_read_database'])) {
$errors['db_read_database'] = lang('error_db_name_required');
}
validateDbUsername($formData['db_read_username'] ?? '', 'db_read_username', $errors);
}
// 관리자 정보 검증
+3
View File
@@ -171,6 +171,9 @@ $stepFile = __DIR__.'/views/'.$currentStep.'-'.(STEP_FILE_MAP[$currentStep] ?? '
window.CURRENT_STEP = <?= $currentStep ?>;
window.INSTALLER_LANG = <?= json_encode($translations ?? [], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
window.INSTALLER_STATE_LOCALE = <?= json_encode($state['g7_locale'] ?? null) ?>;
// DB 최고권한 계정 목록 — 서버 상수를 그대로 내려보내 JS 에 목록을 중복 정의하지 않는다.
// 클라이언트 검증은 즉시 피드백용이며, 실제 차단은 서버 3개 경로가 담당한다.
window.INSTALLER_BLOCKED_DB_ACCOUNTS = <?= json_encode(\App\Support\PrivilegedDatabaseAccounts::BLOCKED) ?>;
</script>
<script src="<?= INSTALLER_BASE_URL ?>/assets/js/installation-monitor.js?v=<?= time() ?>"></script>
<script src="<?= INSTALLER_BASE_URL ?>/assets/js/installer.js?v=<?= time() ?>"></script>
+8
View File
@@ -52,6 +52,7 @@ return [
'directory_permissions' => 'Directory Permissions',
'disk_space' => 'Disk Space',
'https' => 'HTTPS',
'opcache' => 'OPcache',
'required' => 'Required',
'enabled' => 'Enabled',
'not_enabled' => 'Not Enabled',
@@ -171,6 +172,7 @@ return [
'error_db_name_required' => 'Database name is required.',
'error_db_username_required' => 'Database username is required.',
'error_db_credentials_required' => 'Database name and username are required.',
'error_db_username_privileged' => 'For security reasons, G7 cannot be installed with the ":username" account. If a superuser account is compromised, the entire database is at risk. Please create a dedicated database user for G7, grant it only the privileges it needs, and enter that account instead.',
'error_db_prefix_too_long' => 'Table prefix may be up to :max characters. (entered: :current) A long prefix makes some table index names exceed the database identifier limit.',
'error_db_privileges_insufficient' => 'Insufficient database privileges.',
'error_db_not_tested' => 'Please test the database connection first.',
@@ -233,6 +235,12 @@ return [
'https_enabled' => 'HTTPS is enabled (recommended)',
'https_disabled' => 'HTTPS is disabled. We recommend using HTTPS for security.',
// OPcache Messages
'opcache_enabled' => 'OPcache is enabled (recommended)',
'opcache_disabled_short' => 'Disabled — slower responses',
'opcache_disabled_warning' => 'OPcache is disabled. PHP code is re-parsed on every request, which noticeably slows down site response times. We recommend setting opcache.enable=1 in php.ini.',
'opcache_unknown' => 'OPcache status could not be determined. Reading PHP settings is restricted on this server.',
// API Response Messages
'api_method_not_allowed' => 'Only POST requests are allowed.',
'api_invalid_request' => 'Invalid request data.',
+8
View File
@@ -52,6 +52,7 @@ return [
'directory_permissions' => '디렉토리 권한',
'disk_space' => '디스크 공간',
'https' => 'HTTPS',
'opcache' => 'OPcache',
'required' => '필수',
'enabled' => '활성화됨',
'not_enabled' => '비활성화됨',
@@ -171,6 +172,7 @@ return [
'error_db_name_required' => '데이터베이스 이름은 필수입니다.',
'error_db_username_required' => '데이터베이스 사용자명을 입력해주세요.',
'error_db_credentials_required' => '데이터베이스명과 사용자명은 필수입니다.',
'error_db_username_privileged' => '보안상 :username 계정으로는 설치할 수 없습니다. 최고 권한 계정이 유출되면 데이터베이스 전체가 위험해집니다. G7 전용 데이터베이스 사용자를 새로 만들어 필요한 권한만 부여한 뒤 입력해주세요.',
'error_db_prefix_too_long' => '테이블 접두사는 최대 :max자까지 가능합니다. (입력: :current자) 접두사가 길면 일부 테이블의 인덱스명이 데이터베이스 한도를 초과합니다.',
'error_db_privileges_insufficient' => '데이터베이스에 필요한 권한이 부족합니다.',
'error_db_not_tested' => '데이터베이스 연결 테스트를 먼저 수행해주세요.',
@@ -233,6 +235,12 @@ return [
'https_enabled' => 'HTTPS가 활성화되어 있습니다. (권장)',
'https_disabled' => 'HTTPS가 비활성화되어 있습니다. 보안을 위해 HTTPS 사용을 권장합니다.',
// OPcache 메시지
'opcache_enabled' => 'OPcache가 활성화되어 있습니다. (권장)',
'opcache_disabled_short' => '비활성화됨 — 응답 속도 저하',
'opcache_disabled_warning' => 'OPcache가 비활성화되어 있습니다. 매 요청마다 PHP 코드를 다시 해석하므로 사이트 응답 속도가 눈에 띄게 느려집니다. php.ini에서 opcache.enable=1 설정을 권장합니다.',
'opcache_unknown' => 'OPcache 상태를 확인할 수 없습니다. 서버에서 설정 조회가 제한되어 있습니다.',
// API 응답 메시지
'api_method_not_allowed' => 'POST 요청만 허용됩니다.',
'api_invalid_request' => '잘못된 요청 데이터입니다.',
+12 -4
View File
@@ -84,8 +84,12 @@ $dbReadHash = getDatabaseFieldHash($formData, 'db_read');
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('db_username')) ?> *</label>
<input type="text" name="db_write_username" value="<?= htmlspecialchars($formData['db_write_username']) ?>"
class="form-input" required>
<input type="text" name="db_write_username" id="db_write_username"
value="<?= htmlspecialchars($formData['db_write_username']) ?>"
class="form-input" required
aria-invalid="false"
aria-describedby="db_write_username-error">
<div id="db_write_username-error" class="field-error" role="alert"></div>
</div>
<div class="form-group">
@@ -137,8 +141,12 @@ $dbReadHash = getDatabaseFieldHash($formData, 'db_read');
<div class="form-group">
<label class="form-label"><?= htmlspecialchars(lang('db_username')) ?> *</label>
<input type="text" name="db_read_username" value="<?= htmlspecialchars($formData['db_read_username']) ?>"
class="form-input">
<input type="text" name="db_read_username" id="db_read_username"
value="<?= htmlspecialchars($formData['db_read_username']) ?>"
class="form-input"
aria-invalid="false"
aria-describedby="db_read_username-error">
<div id="db_read_username-error" class="field-error" role="alert"></div>
</div>
<div class="form-group">
@@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ config('app.name', '그누보드7') }} - {{ __('database_credential.title') }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.credential-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background-color: #f9fafb;
padding: 1rem;
}
.credential-card {
max-width: 36rem;
width: 100%;
background-color: #ffffff;
border-radius: 0.75rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
padding: 3rem 2rem;
text-align: center;
}
.credential-icon {
margin-bottom: 1.5rem;
}
.credential-icon svg {
width: 4.5rem;
height: 4.5rem;
color: #f59e0b;
}
.credential-title {
font-size: 1.75rem;
font-weight: 700;
color: #111827;
margin-bottom: 0.75rem;
}
.credential-message {
font-size: 1.05rem;
color: #4b5563;
margin-bottom: 0.5rem;
line-height: 1.625;
}
.credential-description {
font-size: 0.875rem;
color: #6b7280;
line-height: 1.625;
margin-bottom: 1.5rem;
}
.credential-reason {
text-align: left;
background-color: #fffbeb;
border: 1px solid #fde68a;
border-radius: 0.5rem;
padding: 1rem 1.25rem;
font-size: 0.9rem;
color: #92400e;
line-height: 1.625;
margin-bottom: 1rem;
}
.credential-recovery {
text-align: left;
background-color: #f3f4f6;
border-radius: 0.5rem;
padding: 1rem 1.25rem;
font-size: 0.875rem;
color: #374151;
line-height: 1.625;
}
@media (prefers-color-scheme: dark) {
.credential-container {
background-color: #111827;
}
.credential-card {
background-color: #1f2937;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2);
}
.credential-icon svg {
color: #fbbf24;
}
.credential-title {
color: #ffffff;
}
.credential-message {
color: #9ca3af;
}
.credential-description {
color: #6b7280;
}
.credential-reason {
background-color: #78350f;
border-color: #92400e;
color: #fde68a;
}
.credential-recovery {
background-color: #374151;
color: #d1d5db;
}
}
</style>
</head>
<body>
<div class="credential-container">
<div class="credential-card">
{{-- Warning triangle icon (inline SVG - no external dependency) --}}
<div class="credential-icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<h1 class="credential-title">{{ __('database_credential.title') }}</h1>
<p class="credential-message">{{ __('database_credential.message') }}</p>
<p class="credential-description">{{ __('database_credential.description') }}</p>
{{-- 원인 구분 안내. 현재 설정된 계정명 자체는 출력하지 않는다 --}}
{{-- (미인증 공개 페이지이므로 DB 계정명 노출은 정보 유출). --}}
<p class="credential-reason">
{{ __('database_credential.'.($reason ?? 'blocked').'_reason') }}
</p>
<p class="credential-recovery">{{ __('database_credential.recovery_guide') }}</p>
</div>
</div>
</body>
</html>
@@ -11,6 +11,7 @@
- SEO 설정에 Sitemap 분할 기준(파일당 URL 수)과 Sitemap 압축 항목을 추가했습니다. URL 이 분할 기준을 넘으면 sitemap 이 여러 파일로 나뉘어 생성됩니다.
- SEO 설정에 다국어 대체 링크(hreflang) 항목을 추가했습니다. 다국어 사이트에서 sitemap 에 각 언어 버전을 가리키는 링크를 넣을지 켜고 끌 수 있으며, 끄면 sitemap 크기를 줄일 수 있습니다.
- 고급 설정의 캐시 항목에 Sitemap 캐시 유지 시간을 추가했습니다. SEO 설정의 Sitemap 캐시 항목을 비워두면 이 값을 따릅니다.
- 환경설정 > 정보의 시스템 사양에 OPcache 상태를 추가했습니다. 꺼져 있으면 사이트 응답 속도가 느려진다는 안내와 설정 방법을 함께 표시합니다.
### Added
@@ -0,0 +1,314 @@
/**
* @file admin-settings-info-opcache.test.tsx
* @description 환경설정 > 정보 탭 OPcache 상태 행/경고 블록 렌더링 테스트
*
* 테스트 대상 (_tab_info.json 의 OPcache 블록):
* - opcache.enabled: true → 상태 "활성화됨", 경고 블록 미표시
* - opcache.enabled: false → 상태 "비활성화됨", 경고 블록 표시 (성능 저하 안내)
* - opcache.enabled: null → 상태 "확인 불가", 경고 블록 미표시 (확인 불가는 경고 아님)
* - opcache 키 자체 부재 → 상태 "확인 불가", 경고 블록 미표시 (구버전 응답 방어)
*
* 경고 블록의 조건은 `=== false` 엄격 비교여야 한다. `!enabled` 로 쓰면
* null(확인 불가)에서도 "비활성화됨" 경고가 잘못 뜬다.
*/
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';
const TestDiv: React.FC<{
className?: string;
children?: React.ReactNode;
'data-testid'?: string;
}> = ({ className, children, 'data-testid': testId }) => (
<div className={className} data-testid={testId}>{children}</div>
);
const TestSpan: React.FC<{
className?: string;
children?: React.ReactNode;
text?: string;
'data-testid'?: string;
}> = ({ className, children, text, 'data-testid': testId }) => (
<span className={className} data-testid={testId}>{children || text}</span>
);
const TestP: React.FC<{
className?: string;
children?: React.ReactNode;
text?: string;
'data-testid'?: string;
}> = ({ className, children, text, 'data-testid': testId }) => (
<p className={className} data-testid={testId}>{children || text}</p>
);
const TestIcon: React.FC<{
name?: string;
className?: string;
}> = ({ name, className }) => (
<i className={className} data-icon={name} data-testid={`icon-${name}`} />
);
// 렌더러가 컴포넌트 목록을 Fragment 로 감싸므로 반드시 등록해야 한다.
// 미등록 시 트리 전체가 렌더되지 않아 "없어야 한다" 계열 단언이 거짓 통과한다.
const TestFragment: React.FC<{ children?: React.ReactNode }> = ({ children }) => <>{children}</>;
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' } },
Icon: { component: TestIcon, metadata: { name: 'Icon', type: 'basic' } },
Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
};
return registry;
}
/**
* system-info API 응답 생성.
*
* @param opcache opcache 페이로드 (undefined 면 키 자체를 생략)
*/
function createSystemInfoResponse(opcache?: { loaded: boolean; enabled: boolean | null }) {
return {
success: true,
data: {
php_memory_limit: '512M',
max_execution_time: '30초',
...(opcache === undefined ? {} : { opcache }),
},
};
}
// _tab_info.json 의 OPcache 블록과 동일한 표현식.
// 프로덕션에서는 $t:key 로 라벨을 해석하나, 테스트 레지스트리에는 i18n 이 없으므로
// 판정 결과 자체를 문자열 리터럴로 확인한다.
const STATUS_EXPR =
"{{systemInfo?.data?.opcache?.enabled === true ? 'enabled' " +
": (systemInfo?.data?.opcache?.enabled === false ? 'disabled' : 'unknown')}}";
const WARNING_IF = '{{systemInfo?.data?.opcache?.enabled === false}}';
const infoLayout = {
version: '1.0.0',
layout_name: 'test_admin_settings_info',
data_sources: [
{
id: 'systemInfo',
type: 'api',
endpoint: '/api/admin/settings/system-info',
method: 'GET',
auto_fetch: true,
auth_required: true,
fallback: { data: {} },
},
],
components: [
{
id: 'card_system_specs',
type: 'basic',
name: 'Div',
children: [
{
type: 'basic',
name: 'Div',
props: { className: 'flex-between' },
children: [
{
type: 'basic',
name: 'Span',
props: { className: 'text-label', 'data-testid': 'opcache-label' },
text: 'OPcache',
},
{
type: 'basic',
name: 'Span',
props: { className: 'text-heading', 'data-testid': 'opcache-value' },
text: STATUS_EXPR,
},
],
},
{
if: WARNING_IF,
type: 'basic',
name: 'Div',
props: { className: 'alert-warning', 'data-testid': 'opcache-warning' },
children: [
{
type: 'basic',
name: 'Div',
props: { className: 'flex-center gap-3' },
children: [
{
type: 'basic',
name: 'Icon',
props: {
name: 'triangle-exclamation',
className: 'text-xl text-amber-600 dark:text-amber-400 flex-shrink-0',
},
},
{
type: 'basic',
name: 'P',
props: { className: 'text-warning-soft' },
text: 'OPcache가 비활성화되어 있습니다.',
},
],
},
],
},
],
},
],
};
describe('admin-settings 정보 탭 — OPcache 상태', () => {
let registry: ComponentRegistry;
beforeEach(() => {
registry = setupTestRegistry();
});
afterEach(() => {
(registry as any).registry = {};
});
describe('OPcache 활성 (enabled: true)', () => {
// @scenario opcache_state=enabled, surface=admin_info_row
// @effects admin_info_row_shows_enabled_disabled_unknown
it('상태가 활성화됨으로 표시되어야 한다', async () => {
const testUtils = createLayoutTest(infoLayout);
testUtils.mockApi('systemInfo', {
response: createSystemInfoResponse({ loaded: true, enabled: true }),
});
await testUtils.render();
expect(screen.getByTestId('opcache-value').textContent).toBe('enabled');
testUtils.cleanup();
});
it('경고 블록이 표시되지 않아야 한다', async () => {
const testUtils = createLayoutTest(infoLayout);
testUtils.mockApi('systemInfo', {
response: createSystemInfoResponse({ loaded: true, enabled: true }),
});
await testUtils.render();
// 긍정 앵커 — 트리가 실제로 렌더됐음을 먼저 확인해야 아래 부정 단언이 의미를 갖는다.
expect(screen.getByTestId('opcache-label')).toBeTruthy();
expect(screen.queryByTestId('opcache-warning')).toBeNull();
testUtils.cleanup();
});
});
describe('OPcache 비활성 (enabled: false)', () => {
// @scenario opcache_state=loaded_but_disabled, surface=admin_info_row
// @effects admin_info_row_shows_enabled_disabled_unknown
it('상태가 비활성화됨으로 표시되어야 한다', async () => {
const testUtils = createLayoutTest(infoLayout);
testUtils.mockApi('systemInfo', {
response: createSystemInfoResponse({ loaded: true, enabled: false }),
});
await testUtils.render();
expect(screen.getByTestId('opcache-value').textContent).toBe('disabled');
testUtils.cleanup();
});
// @scenario opcache_state=loaded_but_disabled, surface=admin_info_warning_block
// @effects admin_info_warning_shown_only_when_disabled
it('성능 저하 경고 블록이 표시되어야 한다', async () => {
const testUtils = createLayoutTest(infoLayout);
testUtils.mockApi('systemInfo', {
response: createSystemInfoResponse({ loaded: true, enabled: false }),
});
await testUtils.render();
const warning = screen.getByTestId('opcache-warning');
expect(warning).toBeTruthy();
expect(warning.className).toContain('alert-warning');
expect(screen.getByTestId('icon-triangle-exclamation')).toBeTruthy();
testUtils.cleanup();
});
it('확장이 로드돼 있어도 enabled=false 면 경고해야 한다', async () => {
// 확장 로드 여부만으로 판정하는 회귀 차단 (opcache.enable=0 케이스)
const testUtils = createLayoutTest(infoLayout);
testUtils.mockApi('systemInfo', {
response: createSystemInfoResponse({ loaded: true, enabled: false }),
});
await testUtils.render();
expect(screen.getByTestId('opcache-warning')).toBeTruthy();
testUtils.cleanup();
});
});
describe('OPcache 확인 불가 (enabled: null)', () => {
// @scenario opcache_state=ini_get_blocked, surface=admin_info_row
// @effects admin_info_row_shows_enabled_disabled_unknown
it('상태가 확인 불가로 표시되어야 한다', async () => {
const testUtils = createLayoutTest(infoLayout);
testUtils.mockApi('systemInfo', {
response: createSystemInfoResponse({ loaded: true, enabled: null }),
});
await testUtils.render();
expect(screen.getByTestId('opcache-value').textContent).toBe('unknown');
testUtils.cleanup();
});
// @scenario opcache_state=ini_get_blocked, surface=admin_info_warning_block
// @effects admin_info_warning_hidden_when_unknown
it('경고 블록이 표시되지 않아야 한다 (확인 불가는 경고가 아니다)', async () => {
const testUtils = createLayoutTest(infoLayout);
testUtils.mockApi('systemInfo', {
response: createSystemInfoResponse({ loaded: true, enabled: null }),
});
await testUtils.render();
// 긍정 앵커 — 미렌더로 인한 거짓 통과 차단
expect(screen.getByTestId('opcache-label')).toBeTruthy();
expect(screen.queryByTestId('opcache-warning')).toBeNull();
testUtils.cleanup();
});
});
describe('opcache 키 부재 (구버전 응답 방어)', () => {
/**
* opcache 키가 아예 없는 구버전/폴백 응답에서도 화면이 깨지지 않아야 한다.
*
* @effects admin_info_defensive_against_missing_opcache_key
*/
it('상태가 확인 불가로 표시되고 경고 블록이 뜨지 않아야 한다', async () => {
const testUtils = createLayoutTest(infoLayout);
testUtils.mockApi('systemInfo', {
response: createSystemInfoResponse(undefined),
});
await testUtils.render();
expect(screen.getByTestId('opcache-value').textContent).toBe('unknown');
expect(screen.queryByTestId('opcache-warning')).toBeNull();
testUtils.cleanup();
});
});
});
@@ -1857,6 +1857,11 @@
"memory": "Memory",
"disk": "Disk",
"php_memory": "PHP Memory Limit",
"opcache": "OPcache",
"opcache_enabled": "Enabled",
"opcache_disabled": "Disabled",
"opcache_unknown": "Unavailable",
"opcache_disabled_warning": "OPcache is disabled. PHP code is re-parsed on every request, which noticeably slows down site response times. We recommend setting opcache.enable=1 in php.ini. After changing the setting, this screen may take up to an hour to reflect it; clearing the cache applies it immediately.",
"max_execution_time": "Max Execution Time",
"upload_size": "Upload Size Limit",
"install_paths": "Installation Paths",
@@ -1861,6 +1861,11 @@
"memory": "메모리",
"disk": "디스크",
"php_memory": "PHP 메모리 제한",
"opcache": "OPcache",
"opcache_enabled": "활성화됨",
"opcache_disabled": "비활성화됨",
"opcache_unknown": "확인 불가",
"opcache_disabled_warning": "OPcache가 비활성화되어 있습니다. 매 요청마다 PHP 코드를 다시 해석하므로 사이트 응답 속도가 눈에 띄게 느려집니다. php.ini에서 opcache.enable=1 설정을 권장합니다. 설정을 변경한 뒤에도 이 화면에 반영되기까지 최대 1시간이 걸릴 수 있으며, 캐시 비우기를 실행하면 즉시 반영됩니다.",
"max_execution_time": "최대 실행 시간",
"upload_size": "업로드 크기 제한",
"install_paths": "설치 경로",
@@ -254,7 +254,7 @@
"if": "{{_local.checkingCoreUpdate}}",
"props": {
"name": "spinner",
"className": "w-3 h-3 animate-spin"
"className": "text-xs animate-spin"
}
},
{
@@ -333,7 +333,7 @@
"if": "{{_local.loadingChangelog}}",
"props": {
"name": "spinner",
"className": "w-3 h-3 animate-spin"
"className": "text-xs animate-spin"
}
},
{
@@ -508,6 +508,32 @@
}
]
},
{
"id": "opcache_row",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-label"
},
"text": "$t:admin.settings.info.opcache"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-heading"
},
"text": "{{systemInfo?.data?.opcache?.enabled === true ? $t('admin.settings.info.opcache_enabled') : (systemInfo?.data?.opcache?.enabled === false ? $t('admin.settings.info.opcache_disabled') : $t('admin.settings.info.opcache_unknown'))}}"
}
]
},
{
"type": "basic",
"name": "Div",
@@ -561,6 +587,42 @@
]
}
]
},
{
"id": "opcache_warning",
"if": "{{systemInfo?.data?.opcache?.enabled === false}}",
"type": "basic",
"name": "Div",
"props": {
"className": "alert-warning"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "triangle-exclamation",
"className": "text-xl text-amber-600 dark:text-amber-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-warning-soft"
},
"text": "$t:admin.settings.info.opcache_disabled_warning"
}
]
}
]
}
]
},
@@ -1157,7 +1219,7 @@
"name": "Icon",
"props": {
"name": "{{ext[1] ? 'check' : 'x'}}",
"className": "{{ext[1] ? 'w-4 h-4 text-green-500 flex-shrink-0' : 'w-4 h-4 text-red-500 flex-shrink-0'}}"
"className": "{{ext[1] ? 'text-base text-green-500 flex-shrink-0' : 'text-base text-red-500 flex-shrink-0'}}"
}
},
{
@@ -1209,7 +1271,7 @@
"name": "Icon",
"props": {
"name": "{{ext[1] ? 'check' : 'x'}}",
"className": "{{ext[1] ? 'w-4 h-4 text-green-500 flex-shrink-0' : 'w-4 h-4 text-red-500 flex-shrink-0'}}"
"className": "{{ext[1] ? 'text-base text-green-500 flex-shrink-0' : 'text-base text-red-500 flex-shrink-0'}}"
}
},
{
@@ -14,6 +14,7 @@ use App\Services\SettingsService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
/**
@@ -1490,6 +1491,150 @@ class SettingsControllerTest extends TestCase
]);
}
/**
* OPcache 상태가 시스템 정보 페이로드에 포함된다.
*
* 관리자 환경설정 > 정보 탭이 이 값으로 상태 행과 성능 저하 경고 블록을 렌더한다.
* enabled 는 true/false 외에 null("확인 불가" — ini_get 차단 환경)을 가질 수 있다.
*
* @scenario probe_item=opcache,probe_outcome=success,auth_state=authenticated_with_permission
*
* @effects opcache_status_exposed_in_system_info
*/
public function test_system_info_includes_opcache_status(): void
{
$cache = $this->app->make(CacheInterface::class);
$cache->forget('settings.system_info.'.app()->getLocale());
$response = $this->authRequest()->getJson('/api/admin/settings/system-info');
$response->assertStatus(200)
->assertJsonStructure([
'data' => [
'opcache' => ['loaded', 'enabled'],
],
]);
$opcache = $response->json('data.opcache');
$this->assertIsBool($opcache['loaded']);
$this->assertTrue(
$opcache['enabled'] === null || is_bool($opcache['enabled']),
'opcache.enabled 는 true/false/null 중 하나여야 합니다.'
);
}
// 아래 test_system_info_reports_each_opcache_state 의 DataProvider 가 함께 검증하는
// 나머지 3개 조합 (docblock 은 @scenario 를 1건만 인식하므로 라인 주석으로 병기).
// @scenario opcache_state=loaded_but_disabled, surface=system_info_api
// @effects opcache_status_exposed_in_system_info
// @scenario opcache_state=not_loaded, surface=system_info_api
// @effects opcache_status_exposed_in_system_info
// @scenario opcache_state=ini_get_blocked, surface=system_info_api
// @effects opcache_status_exposed_in_system_info
/**
* OPcache 판정 결과가 API 페이로드에 그대로 실려 나온다.
*
* 관리자 정보 탭이 이 값으로 상태 행과 경고 블록을 렌더하므로, 네 가지 상태가
* 각각 구별되어 전달되어야 한다. 특히 loaded_but_disabled(확장은 로드됐으나
* opcache.enable=0)와 ini_get_blocked(null)이 뭉개지면 화면 판단이 무너진다.
*
* @scenario opcache_state=enabled, surface=system_info_api
*
* @effects opcache_status_exposed_in_system_info
*/
#[DataProvider('opcacheStateProvider')]
public function test_system_info_reports_each_opcache_state(array $probe): void
{
$cache = $this->app->make(CacheInterface::class);
$cache->forget('settings.system_info.'.app()->getLocale());
$service = new class($this->app, $probe) extends SettingsService
{
private array $stub;
public function __construct($app, array $stub)
{
parent::__construct(
$app->make(ConfigRepositoryInterface::class),
$app->make(AttachmentRepositoryInterface::class),
$app->make(CacheInterface::class),
);
$this->stub = $stub;
}
protected function getOpcacheStatus(): array
{
return $this->stub;
}
};
$this->app->instance(SettingsService::class, $service);
$response = $this->authRequest()->getJson('/api/admin/settings/system-info');
$response->assertStatus(200);
$this->assertSame($probe, $response->json('data.opcache'));
}
/**
* @return array<string, array{0: array{loaded: bool, enabled: bool|null}}>
*/
public static function opcacheStateProvider(): array
{
return [
'enabled' => [['loaded' => true, 'enabled' => true]],
'loaded_but_disabled' => [['loaded' => true, 'enabled' => false]],
'not_loaded' => [['loaded' => false, 'enabled' => false]],
'ini_get_blocked' => [['loaded' => true, 'enabled' => null]],
];
}
/**
* OPcache probe 가 실패해도 격리되어 전체 200 이 유지된다.
*
* ini_get 이 disable_functions 로 차단된 호스팅에서 probe 하나가 죽어
* system-info API 전체가 500 이 나는 것을 방지한다.
*
* @scenario probe_item=opcache,probe_outcome=throws_error,auth_state=authenticated_with_permission
*
* @effects failing_probe_isolated_and_api_returns_200
* @effects failed_item_filled_with_unknown_fallback
*/
public function test_system_info_isolates_failing_opcache_probe(): void
{
$cache = $this->app->make(CacheInterface::class);
$cache->forget('settings.system_info.'.app()->getLocale());
$service = new class($this->app) extends SettingsService
{
public function __construct($app)
{
parent::__construct(
$app->make(ConfigRepositoryInterface::class),
$app->make(AttachmentRepositoryInterface::class),
$app->make(CacheInterface::class),
);
}
protected function getOpcacheStatus(): array
{
throw new \Error('Call to undefined function ini_get() (disable_functions)');
}
};
$this->app->instance(SettingsService::class, $service);
$response = $this->authRequest()->getJson('/api/admin/settings/system-info');
$response->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'opcache' => ['loaded' => false, 'enabled' => null],
],
]);
}
/**
* 사용량 조회 실패 시 폴백 배열 구조 검증: unknownUsage 는 total/used/free 를
* 'unknown' 으로, percentage 를 0 으로 채워 memory_usage·disk_usage 형식을 유지한다.
@@ -0,0 +1,305 @@
<?php
namespace Tests\Feature\Middleware;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
/**
* DatabaseCredentialGuard 미들웨어 회귀 테스트
*
* 배경: 설치 후 `.env` 의 DB 사용자명을 최고권한 계정으로 바꾸거나 비우면
* CoreServiceProvider 가 확장 로딩 전체를 스킵한다. 그 결과 템플릿이 없는 깨진
* 화면만 뜨고 원인은 storage/logs 의 ERROR 한 줄에만 남아, 운영자가 무엇이
* 잘못됐는지 알 방법이 없었다("조용한 실패"). 본 미들웨어가 그 상황을 전용
* 에러 페이지로 드러내는지 보장한다.
*
* 검증 축 (계정종류 × 요청유형 × 설치상태):
* - 계정: 정상 / 최고권한(root·postgres·대문자·공백) / 빈 값
* - 요청: 웹 / API(JSON) — 콘솔은 미들웨어를 타지 않으므로 구조로 보장
* - 설치상태: 완료 / 미완료(설치 전에는 통과해야 함)
* - 정보 유출: 에러 페이지에 실제 계정명이 노출되지 않을 것
*/
class DatabaseCredentialGuardTest extends TestCase
{
/**
* DB 커넥션 설정의 사용자명을 교체합니다.
*
* 미들웨어는 DB 에 접속하지 않고 config 배열만 읽으므로, 실제 커넥션을
* 훼손하지 않고(=RefreshDatabase 트랜잭션 유지) 판정만 바꿀 수 있습니다.
*
* @param string|null $username 주입할 사용자명
*/
private function setDbUsername(?string $username): void
{
$connection = config('database.default');
config([
"database.connections.{$connection}.username" => $username,
"database.connections.{$connection}.read.username" => $username,
"database.connections.{$connection}.write.username" => $username,
]);
}
/**
* 설치 완료 상태에서 정상 계정이면 웹 요청을 통과시켜야 한다.
*
* @scenario account_kind=normal, enforcement_point=runtime_web, install_state=completed
*
* @effects runtime_passes_through_with_normal_account
*/
public function test_passes_through_with_normal_account(): void
{
config(['app.installer_completed' => true]);
$this->setDbUsername('g7user');
$response = $this->get('/');
$this->assertNotEquals(503, $response->getStatusCode());
}
/**
* 정상 계정이면 API 요청도 통과시켜야 한다 (과잉 차단 방지).
*
* @scenario account_kind=normal, enforcement_point=runtime_api, install_state=completed
*
* @effects runtime_passes_through_with_normal_account
*/
public function test_passes_through_with_normal_account_for_api(): void
{
config(['app.installer_completed' => true]);
$this->setDbUsername('g7user');
$response = $this->getJson('/api/test');
$this->assertNotEquals(503, $response->getStatusCode());
}
/**
* 최고권한 계정이면 웹 요청에 503 전용 에러 페이지를 반환해야 한다.
*
* @scenario account_kind=privileged, enforcement_point=runtime_web, install_state=completed
*
* @effects runtime_returns_503_error_page
*/
#[DataProvider('privilegedUsernameProvider')]
public function test_returns_error_page_for_privileged_account(string $username): void
{
config(['app.installer_completed' => true]);
$this->setDbUsername($username);
$response = $this->get('/');
$response->assertStatus(503);
$response->assertViewIs('database-credential-error');
}
/**
* 빈 사용자명(설정 누락)도 동일하게 전용 에러 페이지를 반환해야 한다.
*
* @scenario account_kind=empty, enforcement_point=runtime_web, install_state=completed
*
* @effects runtime_returns_503_error_page
*/
#[DataProvider('emptyUsernameProvider')]
public function test_returns_error_page_for_empty_account(?string $username): void
{
config(['app.installer_completed' => true]);
$this->setDbUsername($username);
$response = $this->get('/');
$response->assertStatus(503);
$response->assertViewIs('database-credential-error');
}
/**
* @return array<string, array{0: string}>
*/
public static function privilegedUsernameProvider(): array
{
return [
'root' => ['root'],
'postgres' => ['postgres'],
'대문자 ROOT (우회 차단)' => ['ROOT'],
'앞뒤 공백 " root " (우회 차단)' => [' root '],
];
}
/**
* @return array<string, array{0: string|null}>
*/
public static function emptyUsernameProvider(): array
{
return [
'빈 문자열 (설정 누락)' => [''],
'null (설정 누락)' => [null],
];
}
/**
* 최고권한 계정일 때 API 요청에는 HTML 이 아니라 JSON 503 을 반환해야 한다.
*
* @scenario account_kind=privileged, enforcement_point=runtime_api, install_state=completed
*
* @effects runtime_returns_503_json_for_api
*/
public function test_returns_json_for_api_requests(): void
{
config(['app.installer_completed' => true]);
$this->setDbUsername('root');
$response = $this->getJson('/api/test');
$response->assertStatus(503);
$response->assertJson(['success' => false]);
}
/**
* 빈 사용자명일 때도 api/* 경로는 Accept 헤더와 무관하게 JSON 이어야 한다.
*
* @scenario account_kind=empty, enforcement_point=runtime_api, install_state=completed
*
* @effects runtime_returns_503_json_for_api
*/
public function test_returns_json_for_api_path_requests(): void
{
config(['app.installer_completed' => true]);
$this->setDbUsername('');
$response = $this->get('/api/settings');
$response->assertStatus(503);
$response->assertHeader('content-type', 'application/json');
}
/**
* 설치 전(installer_completed=false)에는 최고권한 계정이어도 통과해야 한다.
*
* 설치 전에는 DB 설정이 비어 있는 것이 정상이며, public/index.php 가
* 이미 /install 로 유도한다. 여기서 막으면 설치 자체가 불가능해진다.
*
* @scenario account_kind=privileged, enforcement_point=runtime_web, install_state=not_completed
*
* @effects runtime_passes_through_when_not_installed
*/
public function test_passes_through_when_not_installed(): void
{
config(['app.installer_completed' => false]);
$this->setDbUsername('root');
$response = $this->get('/');
$this->assertNotEquals(503, $response->getStatusCode());
}
/**
* 설치 전에는 사용자명이 비어 있어도 통과해야 한다 (설치 전 정상 상태).
*
* @scenario account_kind=empty, enforcement_point=runtime_web, install_state=not_completed
*
* @effects runtime_passes_through_when_not_installed
*/
public function test_passes_through_when_not_installed_with_empty_username(): void
{
config(['app.installer_completed' => false]);
$this->setDbUsername('');
$response = $this->get('/');
$this->assertNotEquals(503, $response->getStatusCode());
}
/**
* 에러 페이지는 미인증 공개 페이지이므로 실제 DB 계정명을 노출하면 안 된다.
*
* @effects error_page_does_not_leak_account_name
*/
public function test_error_page_does_not_leak_account_name(): void
{
config(['app.installer_completed' => true]);
$this->setDbUsername('postgres');
$response = $this->get('/');
$content = $response->getContent();
$response->assertStatus(503);
$this->assertStringNotContainsString('postgres', $content);
}
/**
* 확장·템플릿이 로딩되지 않은 상태에서 뜨는 페이지이므로
* 외부 에셋(JS/CSS)에 의존하지 않고 자체 완결이어야 한다.
*
* @effects error_page_is_self_contained_without_external_assets
*/
public function test_error_page_has_no_external_dependencies(): void
{
config(['app.installer_completed' => true]);
$this->setDbUsername('root');
$response = $this->get('/');
$content = $response->getContent();
$response->assertStatus(503);
$this->assertStringNotContainsString('<script src=', $content);
$this->assertStringNotContainsString('<link rel="stylesheet" href=', $content);
$this->assertStringContainsString('<style>', $content);
}
/**
* SetLocale 미들웨어가 실행되지 않는 지점이므로 자체 로케일 감지가 동작해야 한다.
*
* @effects error_page_detects_locale_without_setlocale_middleware
*/
#[DataProvider('localeProvider')]
public function test_error_page_is_localized(string $acceptLanguage, string $locale): void
{
config(['app.installer_completed' => true]);
$this->setDbUsername('root');
$response = $this->withHeaders(['Accept-Language' => $acceptLanguage])->get('/');
$response->assertStatus(503);
$response->assertSee(__('database_credential.title', [], $locale));
}
/**
* @return array<string, array{0: string, 1: string}>
*/
public static function localeProvider(): array
{
return [
'한국어' => ['ko,en;q=0.9', 'ko'],
'영어' => ['en,ko;q=0.9', 'en'],
];
}
/**
* 메인터넌스 모드가 우선해야 한다 — 점검 중에는 점검 페이지가 맞다.
*
* @effects maintenance_mode_takes_precedence_over_credential_guard
*/
public function test_maintenance_mode_takes_precedence(): void
{
config(['app.installer_completed' => true]);
$this->setDbUsername('root');
$downFile = storage_path('framework/down');
file_put_contents($downFile, json_encode([
'except' => [], 'redirect' => null, 'retry' => null,
'refresh' => null, 'status' => 503, 'template' => null,
]));
try {
$response = $this->get('/');
$response->assertStatus(503);
$response->assertViewIs('maintenance');
} finally {
if (file_exists($downFile)) {
unlink($downFile);
}
}
}
}
@@ -0,0 +1,112 @@
/**
* E2E: 환경설정 > 정보 — OPcache 상태 행 및 성능 저하 경고
*
* @scenario admin_settings_info_opcache
* @effects opcache_row_mounted, opcache_warning_conditional
*
* 배경: OPcache 는 코어 어디에서도 상태를 조회하지 않아, 비활성 환경에서 성능이
* 떨어져도 운영자가 인지할 경로가 없었다. 정보 탭에 상태를 노출하고, 비활성일 때만
* 성능 저하 경고를 띄운다.
*
* 검증:
* 1. 정보 탭에 OPcache 행이 마운트되고 값이 raw 키가 아닌 번역문으로 표시된다
* 2. 경고 블록은 enabled=false 일 때만 뜬다 (활성/확인불가에서는 뜨지 않는다)
* — API 응답을 가로채 세 상태를 모두 주입해 조건부 렌더를 검증한다
*/
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
const OPCACHE_ROW = '#opcache_row';
const OPCACHE_WARNING = '#opcache_warning';
const SYSTEM_INFO_API = '**/api/admin/settings/system-info*';
/** 관리자 환경설정 정보 탭 진입 */
async function gotoInfoTab(page: import('@playwright/test').Page): Promise<void> {
await page.goto('/admin/settings?tab=info');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await expect(page.locator(OPCACHE_ROW)).toBeAttached({ timeout: 20_000 });
}
/**
* system-info 응답의 opcache 필드만 교체해 라우팅한다.
* 나머지 필드는 실제 서버 응답을 그대로 쓴다 (정보 탭 전체가 깨지지 않도록).
*/
async function stubOpcache(
page: import('@playwright/test').Page,
opcache: { loaded: boolean; enabled: boolean | null },
): Promise<void> {
await page.route(SYSTEM_INFO_API, async (route) => {
const response = await route.fetch();
const body = await response.json();
if (body?.data) {
body.data.opcache = opcache;
}
await route.fulfill({ response, json: body });
});
}
// @scenario tab=info, permitted=yes
// @effects opcache_row_mounted
test('@smoke - 정보 탭에 OPcache 상태 행이 번역문과 함께 마운트된다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await gotoInfoTab(page);
expect(page.url()).not.toMatch(/\/admin\/login/);
const row = page.locator(OPCACHE_ROW);
await expect(row).toBeAttached();
// 다국어 키가 해석되지 않으면 "$t:admin.settings.info.opcache" 원문이 그대로 노출된다
const text = (await row.innerText()).trim();
expect(text).not.toContain('$t:');
expect(text).toContain('OPcache');
// 값이 비어 있으면 상태 표현식이 해석되지 않은 것이다 (바인딩 회귀 가드)
expect(text.replace('OPcache', '').trim().length).toBeGreaterThan(0);
});
// @scenario tab=info, opcache_enabled=false
// @effects opcache_warning_conditional
test('- OPcache 비활성이면 성능 저하 경고 블록이 표시된다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await stubOpcache(page, { loaded: true, enabled: false });
await gotoInfoTab(page);
await expect(page.locator(OPCACHE_WARNING)).toBeVisible({ timeout: 20_000 });
const warningText = (await page.locator(OPCACHE_WARNING).innerText()).trim();
expect(warningText).not.toContain('$t:');
expect(warningText.length).toBeGreaterThan(0);
});
// @scenario tab=info, opcache_enabled=true
// @effects opcache_warning_conditional
test('- OPcache 활성이면 경고 블록이 표시되지 않는다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await stubOpcache(page, { loaded: true, enabled: true });
await gotoInfoTab(page);
// 행이 떠 있음을 먼저 확인해야(긍정 앵커) 아래 부정 단언이 의미를 갖는다
await expect(page.locator(OPCACHE_ROW)).toBeVisible();
await expect(page.locator(OPCACHE_WARNING)).toHaveCount(0);
});
// @scenario tab=info, opcache_enabled=null
// @effects opcache_warning_conditional
test('- OPcache 확인 불가(null)면 경고 블록이 표시되지 않는다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
// `!enabled` 로 조건을 쓰면 null 에서도 경고가 잘못 뜬다 — 엄격 비교 회귀 가드
await stubOpcache(page, { loaded: true, enabled: null });
await gotoInfoTab(page);
await expect(page.locator(OPCACHE_ROW)).toBeVisible();
await expect(page.locator(OPCACHE_WARNING)).toHaveCount(0);
});
@@ -0,0 +1,286 @@
<?php
namespace Tests\Unit\Installer;
use App\Support\PrivilegedDatabaseAccounts;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* 인스톨러 DB 최고권한 계정 차단 회귀 테스트
*
* 배경: 인스톨러에는 사용자명 검증이 전무했고, `.env.example` fallback 기본값이
* `DB_WRITE_USERNAME=root` 였다. 그 결과 root 로 설치하면 설치는 성공하지만
* 이후 코어 부팅이 확장 로딩 전체를 스킵해 깨진 화면만 남았다.
* 설치 단계에서 미리 차단하는지 보장한다.
*
* 검증 축:
* - Step 3 폼 POST 검증: Write DB / Read DB 양쪽, 최고권한 / 빈 값 / 정상
* - 인스톨러가 코어와 같은 판정 SSoT 를 공유하는지 (목록 이원화 차단)
* - 다국어 키 존재 및 :username 치환 동작 (ko/en)
*/
class PrivilegedDbAccountGuardTest extends TestCase
{
public static function setUpBeforeClass(): void
{
if (! defined('BASE_PATH')) {
define('BASE_PATH', dirname(__DIR__, 3));
}
require_once dirname(__DIR__, 3).'/public/install/includes/config.php';
require_once dirname(__DIR__, 3).'/public/install/includes/functions.php';
require_once dirname(__DIR__, 3).'/public/install/includes/request-handler.php';
}
protected function setUp(): void
{
parent::setUp();
// lang() 은 전역 $translations 를 읽는다 (functions.php:316).
$GLOBALS['translations'] = require dirname(__DIR__, 3).'/public/install/lang/ko.php';
}
/**
* config.php 가 코어의 판정 SSoT 클래스를 로드해야 한다.
* 인스톨러가 자체 목록을 갖게 되면 코어와 판정이 어긋난다.
*
* @effects installer_and_core_share_the_same_judgement_class
*/
public function test_installer_loads_shared_judgement_class(): void
{
$this->assertTrue(
class_exists(PrivilegedDatabaseAccounts::class),
'config.php 가 PrivilegedDatabaseAccounts 를 로드하지 않았습니다.'
);
}
/**
* Step 3 폼 POST 에서 Write DB 최고권한 계정이 거부되어야 한다.
*
* @scenario account_kind=privileged, enforcement_point=installer_step3_write, install_state=not_completed
*
* @effects installer_step3_post_rejects_privileged_username
*/
#[DataProvider('privilegedUsernameProvider')]
public function test_step3_rejects_privileged_write_username(string $username): void
{
$errors = [];
validateDbUsername($username, 'db_write_username', $errors);
$this->assertArrayHasKey(
'db_write_username',
$errors,
sprintf('사용자명 "%s" 이 거부되지 않았습니다.', $username)
);
}
/**
* Read DB 도 동일하게 차단되어야 한다 (Write 만 막으면 Read 로 우회 가능).
*
* @scenario account_kind=privileged, enforcement_point=installer_step3_read, install_state=not_completed
*
* @effects installer_step3_post_rejects_privileged_username
*/
#[DataProvider('privilegedUsernameProvider')]
public function test_step3_rejects_privileged_read_username(string $username): void
{
$errors = [];
validateDbUsername($username, 'db_read_username', $errors);
$this->assertArrayHasKey('db_read_username', $errors);
}
/**
* 빈 사용자명(설정 누락)도 Write DB 에서 거부되어야 한다.
*
* @scenario account_kind=empty, enforcement_point=installer_step3_write, install_state=not_completed
*
* @effects installer_step3_post_rejects_empty_username
*/
#[DataProvider('emptyUsernameProvider')]
public function test_step3_rejects_empty_write_username(string $username): void
{
$errors = [];
validateDbUsername($username, 'db_write_username', $errors);
$this->assertArrayHasKey('db_write_username', $errors);
}
/**
* 빈 사용자명은 Read DB 에서도 거부되어야 한다.
*
* @scenario account_kind=empty, enforcement_point=installer_step3_read, install_state=not_completed
*
* @effects installer_step3_post_rejects_empty_username
*/
#[DataProvider('emptyUsernameProvider')]
public function test_step3_rejects_empty_read_username(string $username): void
{
$errors = [];
validateDbUsername($username, 'db_read_username', $errors);
$this->assertArrayHasKey('db_read_username', $errors);
}
/**
* @return array<string, array{0: string}>
*/
public static function privilegedUsernameProvider(): array
{
return [
'root' => ['root'],
'postgres' => ['postgres'],
'sa' => ['sa'],
'sys' => ['sys'],
'system' => ['system'],
'대문자 ROOT (우회 차단)' => ['ROOT'],
'앞뒤 공백 " root " (우회 차단)' => [' root '],
];
}
/**
* @return array<string, array{0: string}>
*/
public static function emptyUsernameProvider(): array
{
return [
'빈 문자열' => [''],
'공백만' => [' '],
];
}
/**
* 정상 계정은 Write DB 에서 통과해야 한다 — 특히 `admin` 오차단 방지.
*
* @scenario account_kind=normal, enforcement_point=installer_step3_write, install_state=not_completed
*
* @effects installer_step3_post_accepts_normal_username
*/
#[DataProvider('acceptedUsernameProvider')]
public function test_step3_accepts_normal_write_username(string $username): void
{
$errors = [];
validateDbUsername($username, 'db_write_username', $errors);
$this->assertSame(
[],
$errors,
sprintf('정상 사용자명 "%s" 이 잘못 거부됐습니다.', $username)
);
}
/**
* 정상 계정은 Read DB 에서도 통과해야 한다 (과잉 차단 방지).
*
* @scenario account_kind=normal, enforcement_point=installer_step3_read, install_state=not_completed
*
* @effects installer_step3_post_accepts_normal_username
*/
#[DataProvider('acceptedUsernameProvider')]
public function test_step3_accepts_normal_read_username(string $username): void
{
$errors = [];
validateDbUsername($username, 'db_read_username', $errors);
$this->assertSame([], $errors);
}
/**
* @return array<string, array{0: string}>
*/
public static function acceptedUsernameProvider(): array
{
return [
'g7user' => ['g7user'],
'admin (일반 권한 계정 오차단 방지)' => ['admin'],
'rootuser' => ['rootuser'],
'g7_root' => ['g7_root'],
'my_site_db' => ['my_site_db'],
];
}
/**
* 빈 값과 최고권한 계정은 서로 다른 메시지여야 한다 — 운영자가 원인을 구분해야 한다.
*
* @effects empty_and_privileged_produce_distinct_messages
*/
public function test_empty_and_privileged_produce_different_messages(): void
{
$emptyErrors = [];
validateDbUsername('', 'db_write_username', $emptyErrors);
$privilegedErrors = [];
validateDbUsername('root', 'db_write_username', $privilegedErrors);
$this->assertNotSame(
$emptyErrors['db_write_username'],
$privilegedErrors['db_write_username']
);
}
/**
* 차단 메시지는 입력한 계정명을 치환해 보여줘야 한다 (:username).
*
* @effects installer_message_substitutes_username_and_offers_alternative
*/
public function test_privileged_message_substitutes_username(): void
{
$errors = [];
validateDbUsername('postgres', 'db_write_username', $errors);
$message = $errors['db_write_username'];
$this->assertStringContainsString('postgres', $message);
$this->assertStringNotContainsString(':username', $message, '치환되지 않은 플레이스홀더가 남아 있습니다.');
}
/**
* 차단만 하고 해결책이 없으면 사용자가 진행 불가 상태에 갇힌다.
* ko/en 양쪽 문구가 대안(전용 계정 생성)을 제시하는지 확인한다.
*/
#[DataProvider('localeProvider')]
public function test_message_offers_an_alternative(string $locale, string $needle): void
{
$translations = require dirname(__DIR__, 3)."/public/install/lang/{$locale}.php";
$this->assertArrayHasKey('error_db_username_privileged', $translations);
$this->assertStringContainsString($needle, $translations['error_db_username_privileged']);
}
/**
* @return array<string, array{0: string, 1: string}>
*/
public static function localeProvider(): array
{
return [
'ko' => ['ko', '전용 데이터베이스 사용자'],
'en' => ['en', 'dedicated database user'],
];
}
/**
* OPcache 관련 다국어 키가 ko/en 양쪽에 존재해야 한다 (요구사항 카드 렌더용).
*
* @effects installer_opcache_lang_keys_exist_in_ko_and_en
*/
#[DataProvider('opcacheLocaleProvider')]
public function test_opcache_lang_keys_exist(string $locale): void
{
$translations = require dirname(__DIR__, 3)."/public/install/lang/{$locale}.php";
foreach (['opcache', 'opcache_enabled', 'opcache_disabled_short', 'opcache_disabled_warning', 'opcache_unknown'] as $key) {
$this->assertArrayHasKey($key, $translations, sprintf('%s 로케일에 %s 키가 없습니다.', $locale, $key));
}
}
/**
* @return array<string, array{0: string}>
*/
public static function opcacheLocaleProvider(): array
{
return [
'ko' => ['ko'],
'en' => ['en'],
];
}
}
+161
View File
@@ -0,0 +1,161 @@
<?php
namespace Tests\Unit\Support;
use App\Support\OpcacheStatus;
use PHPUnit\Framework\TestCase;
/**
* OPcache 상태 판정 SSoT 단위 테스트
*
* 인스톨러(요구사항 화면)와 코어(관리자 정보 탭)가 같은 답을 내야 하므로,
* 이 클래스는 Laravel 부트 없이 동작해야 한다. 본 테스트가
* `PHPUnit\Framework\TestCase` 를 직접 상속하는 것은 그 "의존성 0" 속성을
* 검증하기 위함이다.
*
* 검증 축:
* - 확장 미로드 → enabled=false (확인 불가 아님)
* - 확장 로드 + opcache.enable=1 → enabled=true
* - 확장 로드 + opcache.enable=0 → enabled=false (로드만으로 판정하면 안 되는 케이스)
* - 확장 로드 + ini_get 차단(disable_functions) → enabled=null (확인 불가)
*/
class OpcacheStatusTest extends TestCase
{
/**
* @scenario opcache_state=not_loaded, surface=judgement_ssot
*
* @effects not_loaded_reports_disabled_not_unknown
*/
public function test_extension_not_loaded_reports_disabled(): void
{
$result = OpcacheStatus::probe(
extensionLoaded: fn () => false,
iniGet: fn () => $this->fail('확장 미로드 시 ini_get 을 호출하면 안 됩니다.'),
);
$this->assertSame(['loaded' => false, 'enabled' => false], $result);
}
/**
* @scenario opcache_state=enabled, surface=judgement_ssot
*
* @effects on_off_directive_values_parsed
*/
public function test_loaded_and_enabled_reports_enabled(): void
{
$result = OpcacheStatus::probe(
extensionLoaded: fn (string $name) => $name === OpcacheStatus::EXTENSION_NAME,
iniGet: fn () => '1',
);
$this->assertSame(['loaded' => true, 'enabled' => true], $result);
}
/**
* 확장이 로드돼 있어도 opcache.enable=0 이면 실제로는 동작하지 않는다.
* 확장 로드 여부만으로 판정하는 회귀를 차단한다.
*
* @scenario opcache_state=loaded_but_disabled, surface=judgement_ssot
*
* @effects loaded_but_disabled_directive_reports_disabled
*/
public function test_loaded_but_disabled_directive_reports_disabled(): void
{
$result = OpcacheStatus::probe(
extensionLoaded: fn () => true,
iniGet: fn () => '0',
);
$this->assertSame(['loaded' => true, 'enabled' => false], $result);
}
/**
* ini_get 이 disable_functions 로 차단돼 Error 를 던지는 환경.
* 경고도 차단도 아닌 "확인 불가"(null) 로 폴백해야 한다.
*
* @scenario opcache_state=ini_get_blocked, surface=judgement_ssot
*
* @effects ini_get_blocked_reports_unknown_null
*/
public function test_ini_get_blocked_reports_unknown(): void
{
$result = OpcacheStatus::probe(
extensionLoaded: fn () => true,
iniGet: fn () => throw new \Error('Call to undefined function ini_get()'),
);
$this->assertSame(['loaded' => true, 'enabled' => null], $result);
}
/**
* ini_get 은 지시자를 못 읽으면 false 를 반환한다. 값 '0'(비활성 확정) 과
* 구분해 "확인 불가" 로 처리해야 한다.
*
* @effects ini_get_false_distinguished_from_value_zero
*/
public function test_ini_get_returning_false_reports_unknown(): void
{
$result = OpcacheStatus::probe(
extensionLoaded: fn () => true,
iniGet: fn () => false,
);
$this->assertSame(['loaded' => true, 'enabled' => null], $result);
}
public function test_ini_get_returning_empty_string_reports_unknown(): void
{
$result = OpcacheStatus::probe(
extensionLoaded: fn () => true,
iniGet: fn () => '',
);
$this->assertSame(['loaded' => true, 'enabled' => null], $result);
}
/**
* php.ini 는 On/Off 표기도 허용한다.
*/
public function test_on_off_directive_values_are_parsed(): void
{
$this->assertTrue(
OpcacheStatus::probe(fn () => true, fn () => 'On')['enabled']
);
$this->assertFalse(
OpcacheStatus::probe(fn () => true, fn () => 'Off')['enabled']
);
}
/**
* 인수 없이 호출해도 예외 없이 계약된 구조를 반환해야 한다
* (실행 환경의 실제 OPcache 상태와 무관하게 형태만 검증).
*/
public function test_default_probe_returns_contracted_shape(): void
{
$result = OpcacheStatus::probe();
$this->assertArrayHasKey('loaded', $result);
$this->assertArrayHasKey('enabled', $result);
$this->assertIsBool($result['loaded']);
$this->assertTrue($result['enabled'] === null || is_bool($result['enabled']));
}
/**
* 인스톨러가 Laravel 오토로드 없이 `require_once` 로 로드할 수 있어야 하므로,
* 소스에 프레임워크 심볼 참조가 없어야 한다.
*/
public function test_source_has_no_framework_dependency(): void
{
$source = file_get_contents(dirname(__DIR__, 3).'/app/Support/OpcacheStatus.php');
$this->assertIsString($source);
foreach (['use Illuminate', 'Illuminate\\', 'config(', 'app(', '__(', 'trans('] as $forbidden) {
$this->assertStringNotContainsString(
$forbidden,
$source,
sprintf('인스톨러 공용 클래스에 프레임워크 의존("%s")이 포함됐습니다.', $forbidden)
);
}
}
}
@@ -0,0 +1,161 @@
<?php
namespace Tests\Unit\Support;
use App\Support\PrivilegedDatabaseAccounts;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* DB 최고권한 계정 판정 SSoT 단위 테스트
*
* 인스톨러와 코어가 같은 판정을 내려야 하므로, 이 클래스는 Laravel 부트 없이
* 동작해야 한다. 본 테스트가 `PHPUnit\Framework\TestCase` 를 직접 상속하는 것은
* 그 "의존성 0" 속성 자체를 검증하기 위함이다 (Tests\TestCase 사용 금지).
*
* 검증 축:
* - 계정 종류: 최고권한(root/postgres/sa/sys/system) / 일반 / 빈 값 / null
* - 표기 변형: 대문자 / 앞뒤 공백 (우회 차단)
* - isBlocked 와 isUsable 의 책임 분리 (빈 값은 blocked 아님, 그러나 usable 도 아님)
*/
class PrivilegedDatabaseAccountsTest extends TestCase
{
/**
* 최고권한 계정 판정 — 대문자·앞뒤 공백 변형까지 정규화해 차단한다.
*
* @scenario account_kind=privileged, enforcement_point=judgement_ssot, install_state=completed
*
* @effects privileged_account_detected_case_and_whitespace_insensitive
* @effects admin_account_not_blocked
*/
#[DataProvider('blockedProvider')]
public function test_is_blocked_detects_privileged_accounts(?string $username, bool $expected): void
{
$this->assertSame(
$expected,
PrivilegedDatabaseAccounts::isBlocked($username),
sprintf('isBlocked(%s) 판정이 기대와 다릅니다.', var_export($username, true))
);
}
/**
* @return array<string, array{0: string|null, 1: bool}>
*/
public static function blockedProvider(): array
{
return [
'root 차단' => ['root', true],
'postgres 차단' => ['postgres', true],
'sa 차단' => ['sa', true],
'sys 차단' => ['sys', true],
'system 차단' => ['system', true],
'대문자 ROOT 차단' => ['ROOT', true],
'혼합 대소문자 RoOt 차단' => ['RoOt', true],
'앞뒤 공백 " root " 차단' => [' root ', true],
'탭 문자 포함 차단' => ["\troot\n", true],
'일반 계정 g7user 통과' => ['g7user', false],
'admin 은 차단 대상 아님 (오차단 방지)' => ['admin', false],
'root 를 포함한 다른 이름 통과' => ['rootuser', false],
'g7_root 통과' => ['g7_root', false],
'빈 문자열은 blocked 아님' => ['', false],
'공백만 있는 문자열은 blocked 아님' => [' ', false],
'null 은 blocked 아님' => [null, false],
];
}
/**
* 사용 가능 여부 판정 — 빈 값(설정 누락)과 정상 계정을 구분한다.
*
* @scenario account_kind=empty, enforcement_point=judgement_ssot, install_state=completed
*
* @effects empty_username_rejected_as_unusable
*/
#[DataProvider('usableProvider')]
public function test_is_usable_rejects_privileged_and_empty(?string $username, bool $expected): void
{
$this->assertSame(
$expected,
PrivilegedDatabaseAccounts::isUsable($username),
sprintf('isUsable(%s) 판정이 기대와 다릅니다.', var_export($username, true))
);
}
/**
* @return array<string, array{0: string|null, 1: bool}>
*/
public static function usableProvider(): array
{
return [
'일반 계정 g7user 사용 가능' => ['g7user', true],
'admin 사용 가능 (오차단 방지)' => ['admin', true],
'rootuser 사용 가능' => ['rootuser', true],
'숫자 문자열 0 도 유효한 계정명' => ['0', true],
'root 사용 불가' => ['root', false],
'ROOT 사용 불가' => ['ROOT', false],
' root 사용 불가' => [' root ', false],
'postgres 사용 불가' => ['postgres', false],
'빈 문자열 사용 불가 (설정 누락)' => ['', false],
'공백만 있는 문자열 사용 불가' => [' ', false],
'null 사용 불가 (설정 누락)' => [null, false],
];
}
/**
* 목록이 상수 1곳에서만 관리되는지 — 판정이 BLOCKED 상수와 어긋나면 실패한다.
*
* @scenario account_kind=normal, enforcement_point=judgement_ssot, install_state=completed
*
* @effects normal_username_accepted
*/
public function test_every_blocked_constant_entry_is_actually_blocked(): void
{
$this->assertNotEmpty(PrivilegedDatabaseAccounts::BLOCKED);
foreach (PrivilegedDatabaseAccounts::BLOCKED as $account) {
$this->assertSame(
strtolower($account),
$account,
sprintf('BLOCKED 항목 "%s" 는 소문자로 정의되어야 비교가 성립합니다.', $account)
);
$this->assertTrue(PrivilegedDatabaseAccounts::isBlocked($account));
$this->assertFalse(PrivilegedDatabaseAccounts::isUsable($account));
}
}
/**
* `admin` 은 차단 대상이 아니다.
*
* 목록에 `admin` 을 넣으면 일반 권한으로 운영 중인 `admin` 계정 사용자가
* 업그레이드 직후 에러 페이지에 갇힌다. 이 오차단을 구조적으로 고정한다.
*
* @effects admin_account_not_blocked
*/
public function test_admin_is_not_a_blocked_account(): void
{
$this->assertNotContains('admin', PrivilegedDatabaseAccounts::BLOCKED);
$this->assertFalse(PrivilegedDatabaseAccounts::isBlocked('admin'));
$this->assertTrue(PrivilegedDatabaseAccounts::isUsable('admin'));
$this->assertTrue(PrivilegedDatabaseAccounts::isUsable('ADMIN'));
}
/**
* 인스톨러가 Laravel 오토로드 없이 `require_once` 로 로드할 수 있어야 하므로,
* 소스에 프레임워크 심볼 참조가 없어야 한다.
*/
public function test_source_has_no_framework_dependency(): void
{
$source = file_get_contents(
dirname(__DIR__, 3).'/app/Support/PrivilegedDatabaseAccounts.php'
);
$this->assertIsString($source);
foreach (['use Illuminate', 'Illuminate\\', 'config(', 'app(', '__(', 'trans('] as $forbidden) {
$this->assertStringNotContainsString(
$forbidden,
$source,
sprintf('인스톨러 공용 클래스에 프레임워크 의존("%s")이 포함됐습니다.', $forbidden)
);
}
}
}
@@ -0,0 +1,81 @@
feature: DB 최고권한 계정 차단 및 설치 후 자격증명 오류 안내
description: |
G7 은 DB 사용자명을 두 계층에서 다르게 다뤄 왔다. 인스톨러는 `root` 를 자유롭게
허용했고(오히려 `.env.example` fallback 기본값이 DB_WRITE_USERNAME=root), 설치 후
코어 부팅(CoreServiceProvider::boot)은 같은 값을 보고 모듈·플러그인·템플릿 로딩
전체를 조용히 스킵했다. 결과적으로 설치는 성공하지만 이후 모든 요청에서 템플릿 없는
깨진 화면이 뜨고, 원인은 storage/logs 의 ERROR 한 줄에만 남아 운영자가 알 방법이 없었다.
본 기능은 두 계층의 정책을 하나로 맞춘다.
- 판정 SSoT: App\Support\PrivilegedDatabaseAccounts (프레임워크 의존성 0 —
인스톨러가 require_once 로 직접 로드해 코어와 같은 목록·같은 비교 규칙을 쓴다)
- 설치 단계: 서버 2경로(연결 테스트 API, Step 3 폼 POST) + 클라이언트 1경로로 차단
- 설치 후: DatabaseCredentialGuard 미들웨어가 전용 에러 페이지(503)로 원인을 드러냄
`admin` 은 의도적으로 차단 목록에서 제외한다 — 일반 권한으로 운영 중인 `admin`
계정 사용자가 업그레이드 직후 에러 페이지에 갇히는 오차단을 막기 위함이다.
가드를 ServiceProvider::boot 가 아니라 미들웨어에 두는 이유: boot 에서 abort 하면
`php artisan` 자체가 죽어 복구 수단(config:clear 등)을 잃는다. 미들웨어는 웹 요청만
타므로 콘솔이 자연히 통과한다.
axes:
account_kind: [normal, privileged, empty]
enforcement_point: [judgement_ssot, installer_step3_write, installer_step3_read, runtime_web, runtime_api]
install_state: [completed, not_completed]
cross_product: |
account_kind × enforcement_point 이 핵심이다. 서버 경로 중 하나만 막으면 다른 경로로
우회되고, Write 만 막고 Read 를 열어두면 Read DB 로 최고권한 계정이 들어온다.
대문자(`ROOT`)·앞뒤 공백(` root `) 같은 표기 변형은 별도 axis 로 두지 않는다.
판정 SSoT 한 곳에서 정규화하므로 모든 enforcement_point 가 같은 결과를 공유하며,
변형 자체는 DataProvider 로 전수 검증하고 effect
`privileged_account_detected_case_and_whitespace_insensitive` 로 대표한다.
같은 이유로 `postgres`/`sa`/`sys`/`system` 도 privileged 하나로 묶는다.
exclusions:
- { enforcement_point: judgement_ssot, install_state: not_completed, reason: "순수 판정 클래스 — 설치 상태와 무관 (config 접근 없음)" }
- { enforcement_point: installer_step3_write, install_state: completed, reason: "인스톨러는 정의상 미설치 상태 전용 — 설치 완료 후에는 _guard.php 가 410 으로 차단" }
- { enforcement_point: installer_step3_read, install_state: completed, reason: "동일 — 인스톨러 경로는 미설치 전용" }
- { enforcement_point: runtime_api, install_state: not_completed, reason: "미설치 early-return 은 API/HTML 분기보다 앞에서 일어나 분기에 도달하지 않음 — runtime_web 이 대표" }
- { account_kind: normal, enforcement_point: runtime_web, install_state: not_completed, reason: "정상 계정 + 미설치는 통과 사유가 둘 다 성립 — 어느 가드가 통과시켰는지 구별 불가하여 검증가치 없음. privileged/empty 로 미설치 통과를 검증" }
effects:
# 판정 SSoT
- privileged_account_detected_case_and_whitespace_insensitive
- admin_account_not_blocked
- empty_username_rejected_as_unusable
- normal_username_accepted
- installer_and_core_share_the_same_judgement_class
# 설치 단계
- installer_step3_post_rejects_privileged_username
- installer_step3_post_rejects_empty_username
- installer_step3_post_accepts_normal_username
- installer_message_substitutes_username_and_offers_alternative
- empty_and_privileged_produce_distinct_messages
# 설치 후 런타임
- runtime_returns_503_error_page
- runtime_returns_503_json_for_api
- runtime_passes_through_with_normal_account
- runtime_passes_through_when_not_installed
- error_page_does_not_leak_account_name
- error_page_is_self_contained_without_external_assets
- error_page_detects_locale_without_setlocale_middleware
- maintenance_mode_takes_precedence_over_credential_guard
# E2E axis 없음: 인스톨러는 설치 전 전용 순수 PHP 화면이라 Playwright 인증 픽스처
# (issueToken/authenticatePage) 가 성립하지 않고, 런타임 가드는 .env 를 최고권한
# 계정으로 바꿔야 재현되어 E2E 실행 환경 자체를 파괴한다. 두 경로 모두 Feature/Unit 으로
# 검증하고, 브라우저 실측은 Chrome MCP 수동 매트릭스로 대체한다.
# (레이아웃 JSON / 컴포넌트 TSX / 엔진 TS 변경 없음 — installer.js 는 인스톨러 전용
# 독립 스크립트로 템플릿 엔진 자산이 아니다.)
#
# 클라이언트 JS 검증(installer.js)도 axis 로 두지 않는다: 즉시 피드백 용도이며 차단의
# 실체는 서버 2경로다. 목록도 서버 상수를 그대로 내려받아 쓰므로 독립 판정 지점이 아니다.
test_files:
- tests/Unit/Support/PrivilegedDatabaseAccountsTest.php
- tests/Unit/Installer/PrivilegedDbAccountGuardTest.php
- tests/Feature/Middleware/DatabaseCredentialGuardTest.php
@@ -0,0 +1,72 @@
feature: OPcache 상태 노출 (설치 단계 경고 + 관리자 정보 탭)
description: |
OPcache 는 코어 어디에서도 상태를 조회하지 않았다(opcache_reset 호출부 3곳뿐).
비활성 환경에서는 매 요청마다 PHP 코드를 다시 해석해 응답이 느려지는데, 운영자가
그 사실을 인지할 경로가 전혀 없었다.
본 기능은 두 지점에 상태를 노출한다.
- 설치 단계(Step 2 요구사항): 경고 카드로 표시하되 설치를 차단하지 않는다
- 관리자 환경설정 > 정보: 상태 행 + 비활성일 때만 성능 저하 경고 블록
판정 SSoT 는 App\Support\OpcacheStatus (프레임워크 의존성 0 — 인스톨러가
require_once 로 직접 로드). 확장 로드 여부만으로는 부족하다: `Zend OPcache` 가
로드돼 있어도 `opcache.enable=0` 이면 동작하지 않으므로 지시자까지 확인한다.
`ini_get` 이 disable_functions 로 차단된 환경은 enabled=null("확인 불가")로
폴백하며, 이때는 경고도 차단도 하지 않는다.
axes:
opcache_state: [enabled, loaded_but_disabled, not_loaded, ini_get_blocked]
surface: [judgement_ssot, system_info_api, admin_info_row, admin_info_warning_block]
cross_product: |
opcache_state × surface 가 핵심이다. 특히 loaded_but_disabled 는 "확장 로드 =
활성" 으로 판정하는 회귀를 잡는 케이스이고, ini_get_blocked(null) 는 `!enabled`
같은 느슨한 비교를 쓰면 "비활성" 경고가 잘못 뜨는 케이스다. 두 상태가 서로 다른
결과를 내야 한다.
locale 은 axis 로 두지 않는다. 판정값(bool/null)과 문구 해석은 분리돼 있고,
문구는 각 로케일 파일의 키 존재로 보장하면 충분하다 — 로케일마다 판정이 달라질
경로가 없어 axis 로 두면 곱셈만 늘고 검증 가치가 없다. 키 존재는 effect
`installer_opcache_lang_keys_exist_in_ko_and_en` 과 언어팩 동기화 검사가 담당한다.
installer_requirements_card 는 surface 에서 제외한다. 설치 전 전용 화면이라
자동 검증 경로가 없어(아래 E2E 주석 참조) 정직하게 커버할 수 없으며, 그 대신
서버측 판정(judgement_ssot)과 lang 키 존재를 검증하고 화면은 Chrome MCP 로 실측한다.
exclusions:
- { opcache_state: enabled, surface: admin_info_warning_block, reason: "경고 블록은 enabled=false 일 때만 렌더 — 활성 상태에서는 미표시 검증이 admin_info_row 케이스에 포함됨" }
- { opcache_state: not_loaded, surface: admin_info_warning_block, reason: "미로드는 enabled=false 로 수렴 — loaded_but_disabled 와 완전히 동일한 경고 경로" }
- { opcache_state: not_loaded, surface: admin_info_row, reason: "동일 — 화면은 enabled 값만 보므로 loaded_but_disabled 와 구별되지 않음" }
effects:
# 판정 SSoT
- loaded_but_disabled_directive_reports_disabled
- not_loaded_reports_disabled_not_unknown
- ini_get_blocked_reports_unknown_null
- ini_get_false_distinguished_from_value_zero
- on_off_directive_values_parsed
# 설치 단계 (비차단)
- installer_opcache_lang_keys_exist_in_ko_and_en
# 관리자 정보 탭
- opcache_status_exposed_in_system_info
- admin_info_row_shows_enabled_disabled_unknown
- admin_info_warning_shown_only_when_disabled
- admin_info_warning_hidden_when_unknown
- admin_info_defensive_against_missing_opcache_key
# E2E axis: admin_info_row / admin_info_warning_block 는 레이아웃 JSON 변경을 수반하므로
# Playwright spec 을 동반한다 (frontend-change-requires-e2e). API 응답을 route 로 가로채
# 세 상태(enabled/disabled/null)를 모두 주입해 조건부 렌더를 검증한다.
#
# 인스톨러 요구사항 카드는 설치 전 전용 화면이라 Playwright 인증 픽스처가 성립하지 않고,
# 설치 완료 환경에서는 _guard.php 가 410 으로 차단해 자동 접근 경로가 없다. 서버측
# 판정과 lang 키 존재만 자동 검증하고, "카드가 경고로 뜨되 다음 버튼이 활성 유지되는지" 는
# Chrome MCP 수동 매트릭스로 실측한다 (자동 커버 불가 영역을 명시적으로 남긴다).
test_files:
- tests/Unit/Support/OpcacheStatusTest.php
- tests/Unit/Installer/PrivilegedDbAccountGuardTest.php
- tests/Feature/Api/Admin/SettingsControllerTest.php
- templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-info-opcache.test.tsx
- tests/Playwright/specs/admin/settings-info-opcache.spec.ts
@@ -16,16 +16,22 @@ description: |
DashboardService 에만 적용되고 SettingsService::buildSystemInfo 에는 disk 한 곳만
부분 보강되어, CPU/메모리 등 다른 probe 로 재발한 결함의 재발 차단.
opcache 항목은 문자열 'unknown' 이 아니라 배열 {loaded:false, enabled:null} 로
폴백한다 — 소비처(관리자 정보 탭)가 구조 분해로 읽으므로 형식을 유지해야 한다.
화면 노출/경고 렌더는 별도 매니페스트(opcache-status-visibility)가 담당한다.
axes:
probe_item: [cpu_info, os_info, memory_usage, disk_usage, php_memory_limit, mysql_version]
probe_item: [cpu_info, os_info, memory_usage, disk_usage, php_memory_limit, mysql_version, opcache]
probe_outcome: [success, throws_error, throws_exception]
auth_state: [authenticated_with_permission, authenticated_without_permission, unauthenticated]
exclusions:
- { probe_item: os_info, probe_outcome: throws_exception, reason: "각 probe_item 은 동일 safeSystemProbe 경로 — cpu_info 대표 검증으로 커버, 나머지는 형식적 확장" }
- { probe_item: opcache, probe_outcome: throws_exception, reason: "opcache 는 ini_get 차단 시 Error 를 던지므로 throws_error 로 대표 검증 — Exception 경로도 동일 safeSystemProbe catch(\\Throwable) 로 흡수" }
- { auth_state: unauthenticated, probe_outcome: throws_error, reason: "인증 가드가 probe 실행 전에 401/403 으로 차단 — probe_outcome 과 무관" }
effects:
- opcache_status_exposed_in_system_info
- failing_probe_isolated_and_api_returns_200
- failed_item_filled_with_unknown_fallback
- usage_array_fallback_keeps_total_used_free_unknown_and_percentage_zero