From cbc91117d7c7a82af0799ac1f524c29ea6887b11 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Mon, 6 Jul 2026 16:30:29 +0900 Subject: [PATCH] =?UTF-8?q?fix(installer):=20Read=20DB=20=EB=AF=B8?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EC=8B=9C=20Write=20DB=EB=A7=8C=20?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20+=20GitHub=20=EC=9D=B4=EC=8A=88=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=20=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설치 시 use_read_db=false 인데도 db_read_host 잔존값으로 read 커넥션을 조립해 5단계 마이그레이션이 실패하던 문제를 수정한다. "read 사용 여부" 판정이 세 지점에서 달랐던 것이 근본 원인으로, use_read_db 플래그를 단일 SSoT 로 통일한다. - installer-runtime.php: read 블록을 use_read_db 게이트로. 미사용 시 runtime 에 read 키 미생성 → 하류가 write 로 자동 fallback. mergeRuntimeIntoEnv 도 read 부재 시 DB_READ_* 를 빈 값으로 기록(write 복사 제거). - config/database.php: read env 를 Elvis(?:) fallback 으로 복원. 중첩 env(A, env(B)) 는 DB_READ_* 가 빈 문자열일 때 fallback 이 무력화되므로, 빈 값까지 흡수하는 ?: 를 사용(tinker 실측 확인). 설치 이후 운영 중 DB_READ_* 공란 시에도 SELECT 가 write DB 로 정확히 향하게 한다. - functions.php / request-handler.php: read 미사용 시 .env DB_READ_* 빈 값 기록 + state.json 잔존 read 필드 제거(2단계 방어). 기존 설치 환경은 .env 에 DB_READ_* 가 이미 채워져 Elvis fallback 미발동 → 동작 불변이라 업그레이드 스텝 불필요(실측 확인). 무관 사전 실패 테스트(beta.7 stale-ENV 복구)는 코어 version 이 beta.7 이 아니면 skip 하도록 가드 헬퍼 추가(프로덕션 한시 가드 전제를 테스트에 반영). 자유형식 제보에서 진단 축(버전/활성 확장/커스텀 자산/환경)이 누락되기 쉬워 G7 전용 이슈 템플릿을 추가한다. bug_report / feature_request / upgrade_issue + config.yml(blank 비활성, Discussions·보안신고 링크). --- .env.example | 4 +- .env.testing.example | 2 +- .github/ISSUE_TEMPLATE/bug_report.md | 71 +++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 8 ++ .github/ISSUE_TEMPLATE/feature_request.md | 26 ++++++ .github/ISSUE_TEMPLATE/upgrade_issue.md | 51 +++++++++++ CHANGELOG.md | 1 + config/database.php | 16 ++-- public/install/includes/functions.php | 14 +-- public/install/includes/installer-runtime.php | 40 ++++++--- public/install/includes/request-handler.php | 70 ++++++++------- .../Config/DatabaseConfigDeprecationTest.php | 88 ++++++++++++++++++- .../Installer/InstallerRuntimeHelperTest.php | 73 +++++++++++---- .../InstallerRuntimeServiceProviderTest.php | 31 ++++++- 14 files changed, 417 insertions(+), 78 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/upgrade_issue.md diff --git a/.env.example b/.env.example index d0848527..8fcf8120 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.env.testing.example b/.env.testing.example index 4df97e96..96277f5a 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -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 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..6371abda --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,71 @@ +--- +name: 🐛 버그 제보 +about: 그누보드7(G7) 사용 중 발견한 버그를 제보합니다. +title: "[Bug] " +labels: bug +assignees: '' +--- + +## 증상 요약 + + + +## 재현 절차 + +1. +2. +3. + +## 기대한 동작 + + + +## 실제 동작 + + + +## 환경 정보 (필수) + +> 아래 정보가 없으면 원인 파악이 어렵습니다. 가능한 한 모두 채워 주세요. + +- **코어 버전**: +- **업그레이드 이슈인 경우 — 업그레이드 전 버전 → 후 버전**: +- **PHP 버전**: +- **웹서버·실행 방식**: +- **DB**: +- **OS**: + +## 활성 확장 목록 (해당 시) + + + +```text +(여기에 붙여넣기) +``` + +## 커스텀 자산 여부 (해당 시) + +> 업그레이드·파일 삭제·권한 문제라면 반드시 확인해 주세요. + +- [ ] `modules/_bundled/`·`plugins/_bundled/`·`templates/_bundled/` 아래에 직접 추가한 커스텀 확장이 있습니다. +- [ ] `public/.htaccess` 에 직접 추가한 규칙이 있습니다. +- [ ] 그 외 코어 배포본에 없는 커스텀 파일을 직접 추가/수정했습니다. + + + +## 스크린샷·로그 + + + +## 추가 정보 + + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..94e0db64 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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 로 신고해 주세요. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..0e9e294b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,26 @@ +--- +name: ✨ 기능 제안 +about: 새로운 기능이나 개선 아이디어를 제안합니다. +title: "[Feature] " +labels: enhancement +assignees: '' +--- + +## 해결하려는 문제 + + + +## 제안하는 기능 + + + +## 적용 범위 (해당 시) + +- [ ] 코어 +- [ ] 모듈 +- [ ] 플러그인 +- [ ] 템플릿 + +## 대안·참고 사항 + + diff --git a/.github/ISSUE_TEMPLATE/upgrade_issue.md b/.github/ISSUE_TEMPLATE/upgrade_issue.md new file mode 100644 index 00000000..b09d50d0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/upgrade_issue.md @@ -0,0 +1,51 @@ +--- +name: ⬆️ 업그레이드 문제 +about: core:update / 확장 업데이트 과정에서 발생한 문제를 제보합니다. +title: "[Upgrade] " +labels: "bug, upgrade" +assignees: '' +--- + +## 업그레이드 경로 (필수) + +- **업그레이드 전 버전**: +- **업그레이드 후(목표) 버전**: +- **실행한 명령**: +- **실행 계정/권한**: + +> 참고: `core:update` 는 **현재 설치된(업그레이드 전) 버전의 코드**로 실행됩니다. +> 따라서 최신 버전에서 수정된 버그라도, 그 수정 이전 버전에서 올라오는 경우에는 재발할 수 있습니다. +> 업그레이드 전 반드시 전체 백업을 권장합니다. + +## 증상 + + + +## 확장·커스텀 자산 상태 (필수) + +- [ ] `_bundled/` 아래에 직접 추가한 커스텀 확장이 있습니다. +- [ ] `public/.htaccess`·`public/storage` 등 커스텀 파일/링크가 있습니다. +- [ ] 업데이트 후 위 자산이 삭제/변경되었습니다. + + + +```text +(확장 목록 붙여넣기) +``` + +## 환경 정보 + +- **PHP 버전**: +- **웹서버·실행 방식**: +- **DB**: +- **OS**: + +## 로그·스크린샷 + + diff --git a/CHANGELOG.md b/CHANGELOG.md index dede7c91..7e0e398b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/config/database.php b/config/database.php index da01b564..05fcc509 100644 --- a/config/database.php +++ b/config/database.php @@ -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' => [ diff --git a/public/install/includes/functions.php b/public/install/includes/functions.php index 07c11d08..71c3a45c 100644 --- a/public/install/includes/functions.php +++ b/public/install/includes/functions.php @@ -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='; } // 앱 설정 치환 diff --git a/public/install/includes/installer-runtime.php b/public/install/includes/installer-runtime.php index 02134496..b2f80317 100644 --- a/public/install/includes/installer-runtime.php +++ b/public/install/includes/installer-runtime.php @@ -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 = " 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'], diff --git a/public/install/includes/request-handler.php b/public/install/includes/request-handler.php index c2395a10..2e7394d0 100644 --- a/public/install/includes/request-handler.php +++ b/public/install/includes/request-handler.php @@ -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(), diff --git a/tests/Unit/Config/DatabaseConfigDeprecationTest.php b/tests/Unit/Config/DatabaseConfigDeprecationTest.php index 478f2c55..8d76ea0a 100644 --- a/tests/Unit/Config/DatabaseConfigDeprecationTest.php +++ b/tests/Unit/Config/DatabaseConfigDeprecationTest.php @@ -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 + */ + 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 @@ -80,6 +159,7 @@ class DatabaseConfigDeprecationTest extends TestCase $deprecations[] = compact('message', 'file', 'line'); } } + return true; }, E_DEPRECATED | E_USER_DEPRECATED); diff --git a/tests/Unit/Installer/InstallerRuntimeHelperTest.php b/tests/Unit/Installer/InstallerRuntimeHelperTest.php index b7046c3b..24319159 100644 --- a/tests/Unit/Installer/InstallerRuntimeHelperTest.php +++ b/tests/Unit/Installer/InstallerRuntimeHelperTest.php @@ -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); diff --git a/tests/Unit/Providers/InstallerRuntimeServiceProviderTest.php b/tests/Unit/Providers/InstallerRuntimeServiceProviderTest.php index bfc52770..2a615db3 100644 --- a/tests/Unit/Providers/InstallerRuntimeServiceProviderTest.php +++ b/tests/Unit/Providers/InstallerRuntimeServiceProviderTest.php @@ -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 $data */ @@ -433,7 +460,7 @@ class InstallerRuntimeServiceProviderTest extends TestCase mkdir($dir, 0755, true); } - $php = "runtimePath, $php); } }