Merge pull request from gnuboard:HeuJung/issue446

fix(installer): Read DB 미사용 시 Write DB만 참조하도록 수정 + GitHub 이슈 템플릿 신설
This commit is contained in:
정정홍
2026-07-06 17:18:04 +09:00
committed by GitHub
14 changed files with 417 additions and 78 deletions
+3 -1
View File
@@ -36,7 +36,9 @@ DB_WRITE_PASSWORD=
# Database Table Prefix (optional)
DB_PREFIX=g7_
# Read Database Connection (Optional - falls back to WRITE if not set)
# Read Database Connection (Optional)
# 비워두면 각 항목이 대응하는 DB_WRITE_* 값으로 대체됩니다 (config/database.php).
# Read 전용 DB(리플리카)를 쓸 때만 값을 채우세요.
DB_READ_HOST=
DB_READ_PORT=
DB_READ_DATABASE=
+1 -1
View File
@@ -34,7 +34,7 @@ DB_WRITE_PASSWORD=
DB_PREFIX=g7_
# Read Database Connection (테스트에서는 WRITE와 동일하게 설정)
# database.php의 read 기본값이 WRITE와 다르므로, 명시 설정 필수
# 비워두면 config/database.php 가 WRITE 값으로 대체하나, 테스트 DB 명시를 위해 값을 채워 둔다.
DB_READ_HOST=127.0.0.1
DB_READ_PORT=3306
DB_READ_DATABASE=g7_testing
+71
View File
@@ -0,0 +1,71 @@
---
name: 🐛 버그 제보
about: 그누보드7(G7) 사용 중 발견한 버그를 제보합니다.
title: "[Bug] "
labels: bug
assignees: ''
---
## 증상 요약
<!-- 어떤 문제가 발생했는지 한두 문장으로 적어 주세요. -->
## 재현 절차
1.
2.
3.
## 기대한 동작
<!-- 원래 어떻게 동작해야 하는지 적어 주세요. -->
## 실제 동작
<!-- 실제로 어떻게 동작했는지 적어 주세요. 오류 메시지가 있다면 그대로 붙여 주세요. -->
## 환경 정보 (필수)
> 아래 정보가 없으면 원인 파악이 어렵습니다. 가능한 한 모두 채워 주세요.
- **코어 버전**: <!-- 예: 7.0.2 / config/app.php 의 APP_VERSION 또는 관리자 > 정보 화면 -->
- **업그레이드 이슈인 경우 — 업그레이드 전 버전 → 후 버전**: <!-- 예: 7.0.0-beta.7 → 7.0.2 -->
- **PHP 버전**: <!-- 예: 8.2.x / php -v -->
- **웹서버·실행 방식**: <!-- 예: Apache + mod_php / Nginx + PHP-FPM / 공유호스팅 -->
- **DB**: <!-- 예: MySQL 8.0 / MariaDB 10.x -->
- **OS**: <!-- 예: Ubuntu 24.04 / Windows 11 -->
## 활성 확장 목록 (해당 시)
<!--
확장(모듈/플러그인/템플릿) 관련 문제이거나 업그레이드 문제라면 아래 명령 결과를 붙여 주세요.
php artisan module:list
php artisan plugin:list
php artisan template:list
-->
```text
(여기에 붙여넣기)
```
## 커스텀 자산 여부 (해당 시)
> 업그레이드·파일 삭제·권한 문제라면 반드시 확인해 주세요.
- [ ] `modules/_bundled/`·`plugins/_bundled/`·`templates/_bundled/` 아래에 직접 추가한 커스텀 확장이 있습니다.
- [ ] `public/.htaccess` 에 직접 추가한 규칙이 있습니다.
- [ ] 그 외 코어 배포본에 없는 커스텀 파일을 직접 추가/수정했습니다.
<!-- 위 항목에 해당하면 어떤 경로에 무엇을 추가했는지 적어 주세요. -->
## 스크린샷·로그
<!--
- 브라우저 콘솔 에러 (F12 > Console)
- storage/logs/ 관련 로그
- 화면 스크린샷
-->
## 추가 정보
<!-- 원인 추정, 임시 우회 방법 등 참고될 내용이 있으면 적어 주세요. -->
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 질문·사용법 문의 (Discussions)
url: https://github.com/gnuboard/g7/discussions
about: 버그가 아닌 사용법 질문·아이디어 논의는 Discussions 를 이용해 주세요.
- name: 보안 취약점 신고
url: https://github.com/gnuboard/g7/security/advisories/new
about: 보안 취약점은 공개 이슈가 아닌 비공개 Security Advisory 로 신고해 주세요.
+26
View File
@@ -0,0 +1,26 @@
---
name: ✨ 기능 제안
about: 새로운 기능이나 개선 아이디어를 제안합니다.
title: "[Feature] "
labels: enhancement
assignees: ''
---
## 해결하려는 문제
<!-- 어떤 불편이나 한계 때문에 이 기능이 필요한지 적어 주세요. -->
## 제안하는 기능
<!-- 원하는 동작을 구체적으로 적어 주세요. -->
## 적용 범위 (해당 시)
- [ ] 코어
- [ ] 모듈 <!-- 어떤 모듈인지: -->
- [ ] 플러그인 <!-- 어떤 플러그인인지: -->
- [ ] 템플릿 <!-- 어떤 템플릿인지: -->
## 대안·참고 사항
<!-- 고려한 다른 방법, 타 CMS 사례, 참고 링크 등이 있으면 적어 주세요. -->
+51
View File
@@ -0,0 +1,51 @@
---
name: ⬆️ 업그레이드 문제
about: core:update / 확장 업데이트 과정에서 발생한 문제를 제보합니다.
title: "[Upgrade] "
labels: "bug, upgrade"
assignees: ''
---
## 업그레이드 경로 (필수)
- **업그레이드 전 버전**: <!-- 예: 7.0.0-beta.7 -->
- **업그레이드 후(목표) 버전**: <!-- 예: 7.0.2 -->
- **실행한 명령**: <!-- 예: php artisan core:update --source=/tmp/g7-7.0.2 -->
- **실행 계정/권한**: <!-- 예: www-data / sudo(root) / 일반 사용자 -->
> 참고: `core:update` 는 **현재 설치된(업그레이드 전) 버전의 코드**로 실행됩니다.
> 따라서 최신 버전에서 수정된 버그라도, 그 수정 이전 버전에서 올라오는 경우에는 재발할 수 있습니다.
> 업그레이드 전 반드시 전체 백업을 권장합니다.
## 증상
<!-- 업데이트 중/후 어떤 문제가 발생했는지 적어 주세요. 오류 메시지는 그대로 붙여 주세요. -->
## 확장·커스텀 자산 상태 (필수)
- [ ] `_bundled/` 아래에 직접 추가한 커스텀 확장이 있습니다. <!-- 경로: -->
- [ ] `public/.htaccess`·`public/storage` 등 커스텀 파일/링크가 있습니다.
- [ ] 업데이트 후 위 자산이 삭제/변경되었습니다.
<!-- 업데이트 전후 확장 목록을 붙여 주시면 도움이 됩니다:
php artisan module:list / plugin:list / template:list
-->
```text
(확장 목록 붙여넣기)
```
## 환경 정보
- **PHP 버전**:
- **웹서버·실행 방식**: <!-- Apache+mod_php / Nginx+PHP-FPM / 공유호스팅 등 -->
- **DB**:
- **OS**:
## 로그·스크린샷
<!--
- 콘솔 전체 출력 (업데이트 실행 로그)
- storage/logs/ 의 관련 로그
- 브라우저 콘솔 에러
-->
+1
View File
@@ -22,6 +22,7 @@
- 코어 업데이트 시 레이아웃 편집기·개발자 진단도구 번들의 빌드 설정 파일이 갱신 대상에서 누락되던 문제를 수정했습니다. 이제 코어를 업데이트하면 이 파일들도 최신 내용으로 반영됩니다.
- 설치 마법사에서 Vendor 설치 방식을 "Composer 실행"으로 선택해 설치할 때, 일부 Windows 환경에서 Composer 설치 로그의 문자 인코딩이 어긋나면 진행 상태 응답이 비어 진행 상황이 표시되지 않고 오류가 반복될 수 있던 문제를 보완했습니다. 어떤 문자가 섞이더라도 진행 로그가 항상 정상적으로 전달되도록 처리했습니다. (#62 @kitrio 님께서 제보해주셨습니다.)
- 설치 마법사의 "설치 환경 확인" 단계에서, storage 폴더 하위에 파일이 많이 쌓인 환경일수록 요구사항 검사가 오래 걸리던 문제를 개선했습니다. 폴더 쓰기 권한 검사가 불필요하게 깊은 하위까지 훑던 것을 필요한 범위로 제한해 이 단계가 즉시 완료되도록 했습니다.
- 설치 시 "읽기 전용 데이터베이스(Read DB)를 사용하지 않음"으로 선택했는데도, 읽기 전용 DB 접속 정보를 확인하려다 설치 마지막 단계에서 실패하던 문제를 수정했습니다. 이제 읽기 전용 DB를 사용하지 않으면 쓰기 DB 정보만으로 설치가 정상 완료됩니다. 아울러 설치 이후에도 읽기 전용 DB 접속 정보를 비워 두면 조회 연결이 자동으로 쓰기 DB로 대체되도록 하여, 별도의 읽기 전용 DB 없이도 안정적으로 동작합니다. (#63 @kitrio 님께서 제보해주셨습니다.)
## [7.0.1] - 2026-07-03
+11 -5
View File
@@ -45,14 +45,20 @@ return [
'mysql' => [
'driver' => 'mysql',
// Read 커넥션 — DB_READ_* 가 비어 있으면(미설정/빈 값/주석) 대응하는
// DB_WRITE_* 로 fallback 한다. Read DB 미사용 환경에서도 SELECT 가 write DB 로
// 정확히 향하도록 보장한다. (이슈 #63)
// 중첩 env(A, env(B)) 대신 Elvis(?:) 를 쓰는 이유: env('DB_READ_HOST') 가
// 빈 문자열('')을 반환하면 두 번째 인자(default)가 적용되지 않아 fallback 이
// 무력화된다. ?: 는 빈 문자열까지 falsy 로 흡수하여 write 로 넘긴다.
'read' => [
'host' => [
env('DB_READ_HOST', '127.0.0.1'),
env('DB_READ_HOST') ?: env('DB_WRITE_HOST', '127.0.0.1'),
],
'port' => env('DB_READ_PORT', '3306'),
'database' => env('DB_READ_DATABASE', 'laravel'),
'username' => env('DB_READ_USERNAME', 'root'),
'password' => env('DB_READ_PASSWORD', ''),
'port' => env('DB_READ_PORT') ?: env('DB_WRITE_PORT', '3306'),
'database' => env('DB_READ_DATABASE') ?: env('DB_WRITE_DATABASE', 'laravel'),
'username' => env('DB_READ_USERNAME') ?: env('DB_WRITE_USERNAME', 'root'),
'password' => env('DB_READ_PASSWORD') ?: env('DB_WRITE_PASSWORD', ''),
],
'write' => [
'host' => [
+8 -6
View File
@@ -1098,12 +1098,14 @@ function generateEnvContent(): ?string
$replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME='.($config['db_read_username'] ?? 'root');
$replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD='.escapeEnvValue($config['db_read_password'] ?? '');
} else {
// Read DB를 사용하지 않는 경우 Write DB 정보와 동일하게 설정
$replacements['DB_READ_HOST='] = 'DB_READ_HOST='.($config['db_write_host'] ?? '127.0.0.1');
$replacements['DB_READ_PORT='] = 'DB_READ_PORT='.($config['db_write_port'] ?? '3306');
$replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE='.($config['db_write_database'] ?? 'g7');
$replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME='.($config['db_write_username'] ?? 'root');
$replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD='.escapeEnvValue($config['db_write_password'] ?? '');
// Read DB를 사용하지 않는 경우 DB_READ_* 를 빈 값으로 둔다.
// config/database.php 의 write fallback(Elvis) 이 SELECT 를 write DB 로 넘기므로
// .env 에 write 값을 중복 기록하지 않는다 (write 변경 시 read stale 방지). (이슈 #63)
$replacements['DB_READ_HOST='] = 'DB_READ_HOST=';
$replacements['DB_READ_PORT='] = 'DB_READ_PORT=';
$replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE=';
$replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME=';
$replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD=';
}
// 앱 설정 치환
+27 -13
View File
@@ -15,13 +15,12 @@
*
* @see https://github.com/gnuboard/g7/issues/23
*/
if (! defined('BASE_PATH')) {
throw new RuntimeException('installer-runtime.php requires BASE_PATH constant.');
}
if (! defined('INSTALLER_RUNTIME_PATH')) {
define('INSTALLER_RUNTIME_PATH', BASE_PATH . '/storage/installer/runtime.php');
define('INSTALLER_RUNTIME_PATH', BASE_PATH.'/storage/installer/runtime.php');
}
if (! function_exists('readInstallerRuntime')) {
@@ -59,9 +58,9 @@ if (! function_exists('writeInstallerRuntime')) {
return false;
}
$php = "<?php\n\nreturn " . var_export($data, true) . ";\n";
$php = "<?php\n\nreturn ".var_export($data, true).";\n";
$tmp = INSTALLER_RUNTIME_PATH . '.tmp';
$tmp = INSTALLER_RUNTIME_PATH.'.tmp';
if (@file_put_contents($tmp, $php, LOCK_EX) === false) {
return false;
}
@@ -107,7 +106,7 @@ if (! function_exists('generateAppKeyInline')) {
*/
function generateAppKeyInline(): string
{
return 'base64:' . base64_encode(random_bytes(32));
return 'base64:'.base64_encode(random_bytes(32));
}
}
@@ -138,13 +137,23 @@ if (! function_exists('mergeRuntimeIntoEnv')) {
$envContent = replaceEnvLine($envContent, 'DB_WRITE_PASSWORD', escapeEnvValue((string) ($write['password'] ?? '')));
}
$read = $runtime['db']['read'] ?? $write; // read 미지정 시 write 와 동기화
// Read DB — runtime 에 read 키가 있을 때(use_read_db=true)만 명시 기록한다.
// 미지정 시 DB_READ_* 를 빈 값으로 남겨 config/database.php 의 write fallback(Elvis)
// 에 위임 → .env 에 write 값이 중복 기록되지 않고, write 변경 시 read stale 위험 제거.
// (이슈 #63)
$read = $runtime['db']['read'] ?? null;
if (is_array($read)) {
$envContent = replaceEnvLine($envContent, 'DB_READ_HOST', (string) ($read['host'] ?? ''));
$envContent = replaceEnvLine($envContent, 'DB_READ_PORT', (string) ($read['port'] ?? ''));
$envContent = replaceEnvLine($envContent, 'DB_READ_DATABASE', (string) ($read['database'] ?? ''));
$envContent = replaceEnvLine($envContent, 'DB_READ_USERNAME', (string) ($read['username'] ?? ''));
$envContent = replaceEnvLine($envContent, 'DB_READ_PASSWORD', escapeEnvValue((string) ($read['password'] ?? '')));
} else {
$envContent = replaceEnvLine($envContent, 'DB_READ_HOST', '');
$envContent = replaceEnvLine($envContent, 'DB_READ_PORT', '');
$envContent = replaceEnvLine($envContent, 'DB_READ_DATABASE', '');
$envContent = replaceEnvLine($envContent, 'DB_READ_USERNAME', '');
$envContent = replaceEnvLine($envContent, 'DB_READ_PASSWORD', '');
}
if (isset($runtime['db']['prefix'])) {
@@ -159,7 +168,7 @@ if (! function_exists('mergeRuntimeIntoEnv')) {
// INSTALLER_COMPLETED 플래그 추가 (CachesModuleStatus 등이 사용)
if (! preg_match('/^INSTALLER_COMPLETED=/m', $envContent)) {
$envContent = rtrim($envContent) . "\n\n# Installation Status\nINSTALLER_COMPLETED=true\n";
$envContent = rtrim($envContent)."\n\n# Installation Status\nINSTALLER_COMPLETED=true\n";
}
return $envContent;
@@ -190,7 +199,7 @@ if (! function_exists('escapeEnvValue')) {
$escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
return '"' . $escaped . '"';
return '"'.$escaped.'"';
}
}
@@ -208,13 +217,13 @@ if (! function_exists('replaceEnvLine')) {
*/
function replaceEnvLine(string $envContent, string $key, string $value): string
{
$line = $key . '=' . $value;
$pattern = '/^' . preg_quote($key, '/') . '=.*$/m';
$line = $key.'='.$value;
$pattern = '/^'.preg_quote($key, '/').'=.*$/m';
$replaced = preg_replace($pattern, $line, $envContent, 1, $count);
if ($count === 0) {
return rtrim($envContent) . "\n" . $line . "\n";
return rtrim($envContent)."\n".$line."\n";
}
return $replaced;
@@ -257,8 +266,13 @@ if (! function_exists('buildInstallerRuntimeFromState')) {
'created_at' => date('c'),
];
// Read 커넥션이 별도로 지정된 경우만 포함 (그렇지 않으면 Laravel 이 write 사용)
if (! empty($stateConfig['db_read_host']) && $stateConfig['db_read_host'] !== ($stateConfig['db_write_host'] ?? null)) {
// Read 커넥션은 use_read_db 플래그가 켜진 경우에만 포함한다 (판정 SSoT).
// 플래그가 꺼져 있으면 db_read_host 에 잔존 값이 있어도 무시 → runtime 에 read 키
// 미생성 → 하류(mergeRuntimeIntoEnv / InstallerRuntimeServiceProvider)가 write 로
// 자동 동기화. (이슈 #63: use_read_db=false 인데 db_read_host 를 참조하던 회귀 차단)
if (! empty($stateConfig['use_read_db'])
&& ! empty($stateConfig['db_read_host'])
&& $stateConfig['db_read_host'] !== ($stateConfig['db_write_host'] ?? null)) {
$runtime['db']['read'] = [
'host' => $stateConfig['db_read_host'],
'port' => $stateConfig['db_read_port'] ?? $runtime['db']['write']['port'],
+40 -30
View File
@@ -4,15 +4,13 @@
* 그누보드7 웹 인스톨러 요청 처리 핸들러
*
* 모든 POST 요청 및 비즈니스 로직을 처리합니다.
*
* @package G7\Installer
*/
/**
* 설치 흐름 검증 및 리다이렉트
*
* @param int $currentStep 현재 단계
* @param array $state 설치 상태
* @param int $currentStep 현재 단계
* @param array $state 설치 상태
*/
function validateInstallationFlow(int $currentStep, array $state): void
{
@@ -42,14 +40,14 @@ function validateInstallationFlow(int $currentStep, array $state): void
/**
* 잘못된 단계 접근 시 알림 표시
*
* @param int $correctStep 올바른 단계 번호
* @param int $correctStep 올바른 단계 번호
*/
function showInvalidStepAccessAlert(int $correctStep): void
{
global $translations;
// 번역이 로드되지 않은 경우 로드
if (!isset($translations)) {
if (! isset($translations)) {
$translations = loadTranslations(getCurrentLanguage());
}
@@ -59,7 +57,7 @@ function showInvalidStepAccessAlert(int $correctStep): void
showAlertAndRedirect(
lang('error_invalid_step_access'),
lang('error_invalid_step_access_message'),
INSTALLER_BASE_URL . '/'
INSTALLER_BASE_URL.'/'
);
}
@@ -73,7 +71,7 @@ function handleStep0Post(): void
}
// 다음 버튼 클릭 시 (언어 선택 변경이 아닌 경우)
if (!isset($_POST['language_change_only'])) {
if (! isset($_POST['language_change_only'])) {
unset($_SESSION['license_agreed']);
updateStepStatus(0, 1);
redirectToStep(1);
@@ -85,14 +83,14 @@ function handleStep0Post(): void
/**
* Step 1 POST 처리 (라이선스 동의)
*
* @param string $currentLang 현재 언어
* @param string|null &$error 에러 메시지 (참조)
* @param string $currentLang 현재 언어
* @param string|null &$error 에러 메시지 (참조)
*/
function handleStep1Post(string $currentLang, ?string &$error = null): void
{
$translations = loadTranslations($currentLang);
if (!isset($_POST['agree']) || $_POST['agree'] !== '1') {
if (! isset($_POST['agree']) || $_POST['agree'] !== '1') {
$error = lang('must_agree');
} else {
$_SESSION['license_agreed'] = true;
@@ -115,9 +113,9 @@ function handleStep2Post(): void
/**
* Step 3 POST 처리 (데이터베이스 및 사이트 설정)
*
* @param string $currentLang 현재 언어
* @param array &$formData 폼 데이터 (참조)
* @param array &$errors 에러 배열 (참조)
* @param string $currentLang 현재 언어
* @param array &$formData 폼 데이터 (참조)
* @param array &$errors 에러 배열 (참조)
*/
function handleStep3Post(string $currentLang, array &$formData, array &$errors): void
{
@@ -135,7 +133,7 @@ function handleStep3Post(string $currentLang, array &$formData, array &$errors):
}
// Read DB 검증 (사용하는 경우)
if (!empty($formData['use_read_db'])) {
if (! empty($formData['use_read_db'])) {
if (empty($formData['db_read_host'])) {
$errors['db_read_host'] = lang('error_db_host_required');
}
@@ -145,10 +143,10 @@ function handleStep3Post(string $currentLang, array &$formData, array &$errors):
}
// 관리자 정보 검증
if (empty($formData['admin_email']) || !filter_var($formData['admin_email'], FILTER_VALIDATE_EMAIL)) {
if (empty($formData['admin_email']) || ! filter_var($formData['admin_email'], FILTER_VALIDATE_EMAIL)) {
$errors['admin_email'] = lang('error_admin_email_invalid');
}
if (!array_key_exists($formData['admin_language'] ?? '', SUPPORTED_LANGUAGES)) {
if (! array_key_exists($formData['admin_language'] ?? '', SUPPORTED_LANGUAGES)) {
$errors['admin_language'] = lang('error_admin_language_invalid');
}
if (empty($formData['admin_password']) || strlen($formData['admin_password']) < 8) {
@@ -176,19 +174,19 @@ function handleStep3Post(string $currentLang, array &$formData, array &$errors):
// 절대 경로 변환
$absolutePath = str_starts_with($corePendingPath, '/')
? $corePendingPath
: BASE_PATH . '/' . $corePendingPath;
: BASE_PATH.'/'.$corePendingPath;
if (file_exists($absolutePath) && !is_dir($absolutePath)) {
if (file_exists($absolutePath) && ! is_dir($absolutePath)) {
$errors['core_update_pending_path'] = lang('error_core_pending_not_directory');
}
}
// DB 테스트 완료 확인
if (empty($errors) && !isset($_SESSION['db_write_tested'])) {
if (empty($errors) && ! isset($_SESSION['db_write_tested'])) {
$errors['db_test'] = lang('error_db_not_tested');
}
if (empty($errors) && !empty($formData['use_read_db']) && !isset($_SESSION['db_read_tested'])) {
if (empty($errors) && ! empty($formData['use_read_db']) && ! isset($_SESSION['db_read_tested'])) {
$errors['db_test'] = lang('error_db_not_tested');
}
@@ -200,9 +198,21 @@ function handleStep3Post(string $currentLang, array &$formData, array &$errors):
$safeFormData = $formData;
unset($safeFormData['db_write_password'], $safeFormData['db_read_password']);
// Read DB 미사용 시 read 필드 잔존값을 제거한다 (이슈 #63 2단계 방어).
// use_read_db=false 인데 db_read_host 등이 남아 있으면 하류(installer-runtime.php)
// 로 유입되어 잘못된 read 커넥션을 만들 수 있으므로 원천 차단한다.
if (empty($safeFormData['use_read_db'])) {
unset(
$safeFormData['db_read_host'],
$safeFormData['db_read_port'],
$safeFormData['db_read_database'],
$safeFormData['db_read_username']
);
}
updateStepStatus(3, 4, [
'config' => $safeFormData,
'installation_status' => 'ready'
'installation_status' => 'ready',
]);
redirectToStep(4);
@@ -212,21 +222,21 @@ function handleStep3Post(string $currentLang, array &$formData, array &$errors):
/**
* POST 요청 라우팅
*
* @param int $currentStep 현재 단계
* @param string $currentLang 현재 언어
* @param array &$formData 폼 데이터 (참조)
* @param array &$errors 에러 배열 (참조)
* @param string|null &$error 에러 메시지 (참조)
* @param int $currentStep 현재 단계
* @param string $currentLang 현재 언어
* @param array &$formData 폼 데이터 (참조)
* @param array &$errors 에러 배열 (참조)
* @param string|null &$error 에러 메시지 (참조)
*/
function handlePostRequest(int $currentStep, string $currentLang, array &$formData = [], array &$errors = [], ?string &$error = null): void
{
// 단계 이동 처리 (go_to_step POST 파라미터)
if (isset($_POST['go_to_step'])) {
$step = (int)$_POST['go_to_step'];
$step = (int) $_POST['go_to_step'];
if ($step >= 0 && $step <= 5) {
$_SESSION['installer_current_step'] = $step;
}
header('Location: ' . INSTALLER_BASE_URL . '/');
header('Location: '.INSTALLER_BASE_URL.'/');
exit;
}
@@ -238,7 +248,7 @@ function handlePostRequest(int $currentStep, string $currentLang, array &$formDa
}
// Step별 POST 처리
match($currentStep) {
match ($currentStep) {
0 => handleStep0Post(),
1 => handleStep1Post($currentLang, $error),
2 => handleStep2Post(),
@@ -40,7 +40,7 @@ class DatabaseConfigDeprecationTest extends TestCase
$this->assertEmpty(
$deprecations,
'config/database.php emitted PHP deprecation: '
. json_encode($deprecations, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)
.json_encode($deprecations, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)
);
}
@@ -57,15 +57,94 @@ class DatabaseConfigDeprecationTest extends TestCase
if ($directReferences > 0 && ! $hasGuard) {
$this->fail(
"config/database.php references PDO::MYSQL_ATTR_SSL_CA without a "
. "PHP_VERSION_ID >= 80500 ternary guard. This causes E_DEPRECATED "
. "on PHP 8.5+. Use \\Pdo\\Mysql::ATTR_SSL_CA when PHP_VERSION_ID >= 80500."
'config/database.php references PDO::MYSQL_ATTR_SSL_CA without a '
.'PHP_VERSION_ID >= 80500 ternary guard. This causes E_DEPRECATED '
.'on PHP 8.5+. Use \\Pdo\\Mysql::ATTR_SSL_CA when PHP_VERSION_ID >= 80500.'
);
}
$this->assertTrue(true);
}
/**
* 이슈 #63 회귀 — Read 커넥션은 DB_READ_* 가 비어 있으면 DB_WRITE_* 로 fallback 한다.
*
* 미설정(null) / 빈 문자열('') / 명시값 세 케이스를 모두 검증한다. 특히 빈 문자열
* 케이스는 config 가 `env('DB_READ_HOST', default)` (중첩 env) 로 되돌아가면
* fallback 이 무력화되므로(env() 가 ''를 반환) 이 테스트가 그 회귀를 차단한다.
*
* @dataProvider readFallbackProvider
*/
public function test_read_connection_falls_back_to_write_when_read_env_empty(
?string $readHost,
string $expectedHost
): void {
$original = [
'DB_WRITE_HOST' => $_ENV['DB_WRITE_HOST'] ?? null,
'DB_READ_HOST' => $_ENV['DB_READ_HOST'] ?? null,
];
$this->setEnvVar('DB_WRITE_HOST', 'write-host.test');
$this->setEnvVar('DB_READ_HOST', $readHost);
try {
// config 파일을 직접 require — 내부 env() 가 현재 $_ENV/putenv 값을 평가한다.
$config = require config_path('database.php');
$resolvedHost = $config['connections']['mysql']['read']['host'][0] ?? null;
$this->assertSame($expectedHost, $resolvedHost);
} finally {
$this->setEnvVar('DB_WRITE_HOST', $original['DB_WRITE_HOST']);
$this->setEnvVar('DB_READ_HOST', $original['DB_READ_HOST']);
}
}
/**
* @return array<string, array{0: ?string, 1: string}>
*/
public static function readFallbackProvider(): array
{
return [
'read 미설정 → write' => [null, 'write-host.test'],
'read 빈 문자열 → write' => ['', 'write-host.test'],
'read 명시 → read replica 존중' => ['read-replica.test', 'read-replica.test'],
];
}
/**
* 정적 검사 — config 가 read fallback 에 Elvis(?:) 패턴을 사용하는지 확인.
* 중첩 env(A, env(B)) 로 회귀하면(빈 문자열 fallback 실패) 이 테스트가 차단한다.
*/
public function test_config_uses_elvis_fallback_for_read_connection(): void
{
$file = file_get_contents(config_path('database.php'));
$this->assertMatchesRegularExpression(
"/env\('DB_READ_HOST'\)\s*\?:\s*env\('DB_WRITE_HOST'/",
$file,
'config/database.php 의 read.host 가 Elvis(?:) write fallback 을 사용해야 한다 '
.'(이슈 #63 — 빈 문자열 DB_READ_HOST 시 write 로 fallback).'
);
}
/**
* process env 를 임시로 설정/해제한다 (putenv + $_ENV + $_SERVER 동기).
*
* @param string $key 환경변수 키
* @param string|null $value 값 (null 이면 제거)
*/
private function setEnvVar(string $key, ?string $value): void
{
if ($value === null) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
} else {
putenv("{$key}={$value}");
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
}
/**
* @param callable $fn 실행 중 발생한 deprecation 을 수집할 콜백
* @return array<int, array{message: string, file: string, line: int}>
@@ -80,6 +159,7 @@ class DatabaseConfigDeprecationTest extends TestCase
$deprecations[] = compact('message', 'file', 'line');
}
}
return true;
}, E_DEPRECATED | E_USER_DEPRECATED);
@@ -16,6 +16,7 @@ use PHPUnit\Framework\TestCase;
class InstallerRuntimeHelperTest extends TestCase
{
private string $tempBase = '';
private string $skipReason = '';
protected function setUp(): void
@@ -23,7 +24,7 @@ class InstallerRuntimeHelperTest extends TestCase
parent::setUp();
// 격리된 BASE_PATH — 운영 환경 storage/installer 와 충돌 방지
$this->tempBase = sys_get_temp_dir() . '/g7-installer-runtime-test-' . bin2hex(random_bytes(4));
$this->tempBase = sys_get_temp_dir().'/g7-installer-runtime-test-'.bin2hex(random_bytes(4));
// 안전 가드: INSTALLER_RUNTIME_PATH(= BASE_PATH/storage/installer/runtime.php) 는
// PHP 상수라 한 프로세스에서 단 한 번만 정의된다. 다른 Installer 테스트가 먼저
@@ -34,9 +35,9 @@ class InstallerRuntimeHelperTest extends TestCase
if (defined('BASE_PATH')) {
$resolved = realpath((string) BASE_PATH) ?: (string) BASE_PATH;
if (strpos($resolved, $tempPrefix) !== 0) {
$this->skipReason = 'BASE_PATH (' . $resolved . ') 가 시스템 temp 하위가 아님 — '
. '다른 Installer 테스트의 BASE_PATH 정의가 선행됨. 실제 storage/installer/runtime.php '
. '파괴 방지를 위해 skip. 격리 실행: php vendor/bin/phpunit --filter=InstallerRuntimeHelperTest';
$this->skipReason = 'BASE_PATH ('.$resolved.') 가 시스템 temp 하위가 아님 — '
.'다른 Installer 테스트의 BASE_PATH 정의가 선행됨. 실제 storage/installer/runtime.php '
.'파괴 방지를 위해 skip. 격리 실행: php vendor/bin/phpunit --filter=InstallerRuntimeHelperTest';
$this->markTestSkipped($this->skipReason);
}
$this->tempBase = (string) BASE_PATH;
@@ -44,15 +45,15 @@ class InstallerRuntimeHelperTest extends TestCase
define('BASE_PATH', $this->tempBase);
}
if (! is_dir($this->tempBase . '/storage/installer')) {
@mkdir($this->tempBase . '/storage/installer', 0755, true);
if (! is_dir($this->tempBase.'/storage/installer')) {
@mkdir($this->tempBase.'/storage/installer', 0755, true);
}
// INSTALLER_RUNTIME_PATH 는 BASE_PATH 기반으로 한 번만 정의되므로,
// 첫 테스트가 끝까지 책임진다 (BASE_PATH 자체가 const 라 재정의 불가).
// installer-runtime.php 자체에 escapeEnvValue polyfill 이 있어 functions.php
// 미로드 환경에서도 mergeRuntimeIntoEnv 가 정상 동작한다.
require_once dirname(__DIR__, 3) . '/public/install/includes/installer-runtime.php';
require_once dirname(__DIR__, 3).'/public/install/includes/installer-runtime.php';
}
protected function tearDown(): void
@@ -151,6 +152,7 @@ class InstallerRuntimeHelperTest extends TestCase
public function test_build_runtime_includes_separate_read_when_specified(): void
{
$built = buildInstallerRuntimeFromState([
'use_read_db' => true, // Read DB 사용 선택 (판정 SSoT)
'db_write_host' => 'write-host',
'db_write_database' => 'g7',
'db_read_host' => 'read-host',
@@ -165,6 +167,7 @@ class InstallerRuntimeHelperTest extends TestCase
public function test_build_runtime_omits_read_when_same_as_write(): void
{
$built = buildInstallerRuntimeFromState([
'use_read_db' => true,
'db_write_host' => 'localhost',
'db_read_host' => 'localhost', // 동일 호스트 → 별도 read 불필요
]);
@@ -172,6 +175,37 @@ class InstallerRuntimeHelperTest extends TestCase
$this->assertArrayNotHasKey('read', $built['db']);
}
/**
* 이슈 #63 회귀 — use_read_db=false 인데 db_read_host 에 잘못된 값이 잔존한 경우
* read 커넥션을 만들지 않아야 한다 (Laravel 이 write 로 자동 fallback).
*/
public function test_build_runtime_omits_read_when_use_read_db_false(): void
{
$built = buildInstallerRuntimeFromState([
'use_read_db' => false,
'db_write_host' => 'mysql',
'db_write_database' => 'g7',
'db_read_host' => 'l3ocalhost', // 잔존 잘못된 값 (이슈 재현값)
'db_read_database' => 'wrong_db',
]);
$this->assertArrayNotHasKey('read', $built['db']);
}
/**
* 이슈 #63 회귀 — use_read_db 키 자체가 없는 레거시 state 도
* read 를 만들지 않아야 한다 (기본 미사용).
*/
public function test_build_runtime_omits_read_when_use_read_db_absent(): void
{
$built = buildInstallerRuntimeFromState([
'db_write_host' => 'mysql',
'db_read_host' => 'read-host', // use_read_db 미지정 → read 무시
]);
$this->assertArrayNotHasKey('read', $built['db']);
}
public function test_build_runtime_falls_back_to_legacy_db_keys(): void
{
// 일부 인스톨러 경로가 db_host/db_database 등 prefix 없는 키를 사용
@@ -273,7 +307,7 @@ ENV;
],
'prefix' => 'real_',
],
'app' => ['key' => 'base64:' . base64_encode(str_repeat('a', 32))],
'app' => ['key' => 'base64:'.base64_encode(str_repeat('a', 32))],
];
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
@@ -287,10 +321,14 @@ ENV;
// Read 라인도 write 와 동기화되어 치환되어야 함
$this->assertStringNotContainsString('DB_WRITE_HOST=127.0.0.1', $merged);
$this->assertStringNotContainsString('DB_WRITE_DATABASE=g7' . PHP_EOL, $merged);
$this->assertStringNotContainsString('DB_WRITE_DATABASE=g7'.PHP_EOL, $merged);
}
public function test_merge_syncs_read_with_write_when_read_not_specified(): void
/**
* 이슈 #63 — read 미지정(use_read_db=false) 시 DB_READ_* 를 빈 값으로 남긴다.
* write 값을 복사하지 않고 config/database.php 의 Elvis fallback 에 위임한다.
*/
public function test_merge_leaves_read_empty_when_read_not_specified(): void
{
$envContent = "DB_READ_HOST=127.0.0.1\nDB_READ_DATABASE=g7\nDB_READ_USERNAME=root\nDB_READ_PASSWORD=\n";
@@ -308,11 +346,14 @@ ENV;
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
// read 미지정 시 write 값으로 동기
$this->assertStringContainsString('DB_READ_HOST=w-host', $merged);
$this->assertStringContainsString('DB_READ_DATABASE=wdb', $merged);
$this->assertStringContainsString('DB_READ_USERNAME=wu', $merged);
$this->assertStringContainsString('DB_READ_PASSWORD="wp"', $merged);
// read 미지정 시 DB_READ_* 는 빈 값 — write 값이 복사되면 안 됨
$this->assertStringContainsString("DB_READ_HOST=\n", $merged);
$this->assertStringContainsString("DB_READ_DATABASE=\n", $merged);
$this->assertStringContainsString("DB_READ_USERNAME=\n", $merged);
$this->assertStringNotContainsString('DB_READ_HOST=w-host', $merged);
$this->assertStringNotContainsString('DB_READ_DATABASE=wdb', $merged);
// write 라인은 정상 기록
$this->assertStringContainsString('DB_WRITE_HOST=w-host', $merged);
}
public function test_merge_uses_separate_read_when_specified(): void
@@ -360,7 +401,7 @@ ENV;
$envContent = "DB_WRITE_HOST=user-input.com\nAPP_KEY=\n";
// runtime 에 db 키 자체가 없는 케이스 (이상 상태) — 기존 .env 값 보존
$runtime = ['app' => ['key' => 'base64:' . base64_encode(str_repeat('x', 32))]];
$runtime = ['app' => ['key' => 'base64:'.base64_encode(str_repeat('x', 32))]];
$merged = mergeRuntimeIntoEnv($envContent, $runtime);
@@ -193,7 +193,7 @@ class InstallerRuntimeServiceProviderTest extends TestCase
public function test_provider_injects_app_key(): void
{
$this->withProductionEnv();
$key = 'base64:' . base64_encode(random_bytes(32));
$key = 'base64:'.base64_encode(random_bytes(32));
$this->writeRuntime([
'app' => ['key' => $key],
@@ -284,6 +284,7 @@ class InstallerRuntimeServiceProviderTest extends TestCase
public function test_recovers_stale_process_env_when_disk_env_is_healthy(): void
{
$this->skipUnlessBeta7CoreVersion();
$this->withProductionEnv();
// 디스크 .env 는 정합 자격증명 + INSTALLER_COMPLETED (finalize 완료 신호)
@@ -423,6 +424,32 @@ class InstallerRuntimeServiceProviderTest extends TestCase
$this->assertSame('root', getenv('DB_WRITE_USERNAME'), 'runtime.php 잔존 시 본 보정 미발동');
}
/**
* beta.7 stale-ENV 복구는 config/app.php version === '7.0.0-beta.7' 일 때만 발동한다
* (InstallerRuntimeServiceProvider::recoverStaleProcessEnvFromDiskEnv 의 한시 가드).
* 코어가 beta.7 을 지난 버전이면 복구가 의도적으로 비활성이므로, 복구 발동을 기대하는
* 테스트는 skip 한다. no-recovery 테스트들은 비활성 상태에서도 결과가 동일하여 영향 없음.
*/
private function skipUnlessBeta7CoreVersion(): void
{
$configPath = base_path('config/app.php');
$content = is_file($configPath) ? (string) @file_get_contents($configPath) : '';
$version = null;
if (preg_match("/'version'\s*=>\s*env\(\s*'APP_VERSION'\s*,\s*'([^']+)'\s*\)/", $content, $m)) {
$version = $m[1];
} elseif (preg_match("/'version'\s*=>\s*'([^']+)'/", $content, $m)) {
$version = $m[1];
}
if ($version !== '7.0.0-beta.7') {
$this->markTestSkipped(
'beta.7 stale-ENV 복구는 코어 version === 7.0.0-beta.7 일 때만 발동. '
.'현재 version='.($version ?? 'unknown').' → 한시 보정 비활성 (정상).'
);
}
}
/**
* @param array<string, mixed> $data
*/
@@ -433,7 +460,7 @@ class InstallerRuntimeServiceProviderTest extends TestCase
mkdir($dir, 0755, true);
}
$php = "<?php\n\nreturn " . var_export($data, true) . ";\n";
$php = "<?php\n\nreturn ".var_export($data, true).";\n";
file_put_contents($this->runtimePath, $php);
}
}