v7.0.0-beta.6 release

This commit is contained in:
HeuJung
2026-05-14 11:13:08 +09:00
parent cc45c0b84c
commit 61f5874fd2
51 changed files with 4151 additions and 106 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.0-beta.5
APP_VERSION=7.0.0-beta.6
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+1 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.0-beta.5
APP_VERSION=7.0.0-beta.6
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+13 -1
View File
@@ -380,8 +380,19 @@ Keep a Changelog 표준:
| API 변경 (엔드포인트, 파라미터) | 테스트 건수/파일명 |
| 기존 기능의 버그 수정 | 리팩토링 세부사항 |
| 성능 개선 (체감 가능한 것) | 내부 규정/문서 변경 |
| Breaking Change | 이슈 번호 |
| Breaking Change | 내부 작업 이슈 번호 단독 (예: `refs #347`) |
| 엔진 버전 참조 (engine-v1.X.Y) | 코드 패턴 설명 |
| **공개 제보자 attribution** (`(#N @login 님께서 제보해주셨습니다.)`) | — |
| **KISA 등 공식 보안 채널** (`(KISA 측에서 제보해주셨습니다 — KVE-XXXX-XXXXX)`) | — |
### 공개 제보자 attribution
공개 저장소(GitHub) 이슈로 제보·건의된 항목이 출시 CHANGELOG 에 반영되면, 항목 끝에 공개 이슈 번호와 제보자 GitHub 핸들 멘션을 부착합니다.
- 형식: `- (본문) (#N @login 님께서 제보해주셨습니다.)` 또는 `... 건의해주셨습니다.`
- 톤: 버그 리포트는 "제보", 제안형 개선 요청은 "건의"
- 다중 매칭: `(#A @x, #B @y 님께서 제보해주셨습니다.)` / 혼재: `(#A @x 님께서 제보해주시고, #B @y 님께서 건의해주셨습니다.)`
- KISA 등 공식 보안 채널: GitHub 멘션 없이 텍스트 "KISA 측에서" + 공개 가능한 식별자만
### 신규 기능의 버그 수정 제외 규칙
@@ -787,6 +798,7 @@ php artisan plugin:build sirsoft-payment --active # 활성 디렉토리에
# 코어 업데이트
php artisan core:check-updates # 코어 업데이트 확인
php artisan core:update [--force] [--no-backup] [--no-maintenance] # 코어 업데이트 실행
php artisan core:execute-upgrade-steps --from=X.Y.Z --to=A.B.C [--force] # 업그레이드 스텝 단독 실행 (HANDOFF 안내/수동 복구용 — 사전·사후 단계 자동 수행)
# 모듈
php artisan module:list
+46 -23
View File
@@ -4,11 +4,31 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [7.0.0-beta.6] - 2026-05-14
### Fixed
- 코어 업데이트 자동 롤백 시 신 버전이 추가한 신규 파일이 활성 디렉토리에 잔존하여 `BindingResolutionException` 등 부정합 부팅 실패가 발생하던 결함 수정. 백업 디렉토리에 신규 파일 manifest 를 기록하고 롤백 시 정확히 그 목록만 정리합니다. 사용자가 코어 영역에 직접 추가한 파일과 모든 symlink (`public/storage` 등) 는 보존됩니다. (#34 @bigmsg 님께서 제보해주셨습니다.)
- 자동 롤백 후 `bootstrap/cache` 잔존 PHP 캐시로 인한 추가 부팅 실패 차단 — 롤백 직후 캐시를 자동으로 정리합니다.
- beta.5 이전 자동 롤백으로 인해 활성 디렉토리에 잔존했을 수 있는 ServiceProvider 후보를 beta.6 업그레이드 스텝이 진단 로그로 식별하여 운영자 수동 검토를 안내합니다. 자동 삭제하지 않으므로 사용자가 직접 추가한 ServiceProvider 는 보존됩니다.
- 업그레이드 스텝 단독 실행 명령(`core:execute-upgrade-steps`) 이 마이그레이션·코어 재동기화·버전 갱신·캐시 정리·번들 확장 일괄 업데이트를 자동으로 함께 수행하도록 보강. 자동 업데이트 중단 후 안내된 수동 명령을 그대로 실행하면 별도 보조 작업 없이 업그레이드가 완료됩니다.
- 인스톨러 Step 3 (PHP CLI / Composer / 자동 감지 / 코어 _pending 경로 검증) 의 프론트엔드 catch 분기에서 사용하는 다국어 키가 언어 파일에 정의되어 있지 않아, 서버 응답 실패 시 화면에 다국어 키 문자열이 그대로 노출되던 결함 수정.
- 인스톨러 다국어 파일에 중복 정의된 키 4건 정리. POST 전용 API 의 메서드 거부 메시지가 일반 문구로 덮여 표시되던 문제, 데이터베이스 연결 실패 로그에서 placeholder 가 치환되지 않던 문제, HTTPS 카드 라벨이 안내 문장으로 표시되던 문제가 함께 해결됩니다.
- 인스톨러 Step 3 의 PHP CLI 및 Composer 절대경로 검증이 공유 호스팅 환경(시놀로지 DSM 등 `open_basedir` 가 시스템 binary 영역을 차단하는 환경)과 Windows 환경에서 정상 경로임에도 거부되던 결함 수정. 셸 인자 escape 와 메타문자 차단은 유지하고, 파일 시스템 권한에 의존하던 사전 검사를 실제 실행 결과로 판정하도록 변경합니다. Windows 의 백슬래시 경로 구분자도 정상 입력으로 인식됩니다 (단, `C:\Program Files\...` 같은 공백 포함 디렉토리는 공백 없는 경로 또는 8.3 short path 사용 권장). (#33 @glitter-gim 님께서 제보해주셨습니다.)
- 코어 운영 단계의 composer 바이너리 자동 인식이 같은 `open_basedir` 환경에서 실패하여 모듈/플러그인 설치 시 vendor-bundle 모드만 사용 가능하던 결함 수정. composer 가 정상 설치되어 PATH 에 존재하는 환경에서도 인식되도록 검출 로직을 개선했습니다.
- 멀티 PHP 버전 환경(시놀로지 DSM Web Station, cPanel/Plesk multi-PHP 등) 에서 Composer 를 특정 PHP 인터프리터로 실행하려는 "PHP 절대경로 + Composer 절대경로" 공백 분리 입력 형식이 거부되던 결함 수정. 입력을 두 토큰으로 분리한 뒤 각 토큰을 개별 escape 처리하여 안전을 유지하면서 멀티 PHP 운영 시나리오를 지원합니다.
- 인스톨러 세션 쿠키가 일부 환경(비표준 포트 + dynamic DNS 도메인 + 브라우저 추적 보호 등) 에서 브라우저에 의해 차단되어 첫 화면 "설치하기" 클릭이 동작하지 않고 새로고침처럼 보이던 결함을 보완. 세션 쿠키 SameSite 정책을 Strict → Lax 로 완화하여 차단 케이스 자체를 줄이고, Step 0 진입 시 쿠키 round-trip 사전 진단을 수행하여 차단이 감지되면 사용자에게 회피 방법(다른 브라우저 / 쿠키 차단 해제 / IP·localhost 접속) 을 명시 안내합니다.
### Added
- `hotfix:rollback-stale-files` Artisan 커맨드 추가 — 자동 롤백 후 활성 디렉토리에 잔존할 수 있는 신 파일을 운영자가 명시 실행하여 진단/정리할 수 있는 단발성 회복 도구. 기본은 진단 모드 (실제 삭제 없음), `--prune` 옵션 시 확인 프롬프트 후 정리. symlink 와 보호 경로(`storage/`, `vendor/`, `.env*` 등) 는 자동 제외.
- 본인인증 메시지 정의 목록 API 응답에 `can_create` 권한 키 추가 — 운영자의 수정 권한 보유 여부에 따라 관리자 화면이 "정의 추가" 버튼을 정확히 활성화/비활성화할 수 있도록 보강.
## [7.0.0-beta.5] - 2026-05-12
### Upgrade Notice
beta.4 → beta.5 업그레이드 시 beta.4 의 결함으로 인해 활성 확장 디렉토리 일부가 손실됩니다.
beta.4 → beta.5 업그레이드 시 beta.4 의 결함으로 인해 활성 확장 디렉토리 일부가 손실됩니다. (#34 @bigmsg 님께서 제보해주셨습니다.)
- 손실 항목 1: 운영자가 활성 디렉토리(`modules/{id}`, `plugins/{id}`, `templates/{id}`, `lang-packs/{id}`)에서 직접 수정한 코드
- 손실 항목 2: 외부 install 한 확장 (GitHub 등 `_bundled` 에 포함되지 않은 모듈/플러그인/템플릿/언어팩)
@@ -73,8 +93,8 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- 본인인증 관리자 권한을 조회/수정으로 분리 — "프로바이더 설정 조회" / "정책 조회" 권한 신설로 단순 조회 권한만 부여받은 운영자도 환경설정 본인인증 화면에 접근 가능 (기존에는 수정 권한이 없으면 조회 화면도 403)
- 환경설정의 본인인증 정책/메시지 탭에 권한 기반 버튼 비활성화 적용 — 수정 권한이 없는 운영자에게는 "정책 추가" / "수정" / "삭제" / "활성 토글" 버튼이 자동으로 비활성화 (이커머스/게시판 본인인증 탭 동일)
- 코어 업데이트의 spawn 자식 프로세스 실패 시 abort/fallback 동작 모드 선택 (`G7_UPDATE_SPAWN_FAILURE_MODE`, 기본 `abort`) — 부모 메모리 stale 로 인한 upgrade step fatal 위험을 fail-fast 로 차단
- 업그레이드 스텝에 "버전별 데이터 스냅샷" 규약 도입 — 멀티 버전 점프 시에도 사용자가 단계별로 업그레이드한 것과 동등한 결과를 보장하도록 각 스텝의 시드 카탈로그·적용 로직·단발성 핫픽스를 해당 버전 디렉토리로 격리. 외부 확장 작성자가 beta.5+ 신규 업그레이드 스텝 작성 시 적용 (상세: 업그레이드 스텝 가이드)
- 코어 업데이트의 spawn 자식 프로세스 실패 시 abort/fallback 동작 모드 선택 (`G7_UPDATE_SPAWN_FAILURE_MODE`, 기본 `abort`) — 부모 메모리 stale 로 인한 upgrade step fatal 위험을 fail-fast 로 차단 (#28 @bigmsg 님께서 제보해주셨습니다.)
- 업그레이드 스텝에 "버전별 데이터 스냅샷" 규약 도입 — 멀티 버전 점프 시에도 사용자가 단계별로 업그레이드한 것과 동등한 결과를 보장하도록 각 스텝의 시드 카탈로그·적용 로직·단발성 핫픽스를 해당 버전 디렉토리로 격리. 외부 확장 작성자가 beta.5+ 신규 업그레이드 스텝 작성 시 적용 (상세: 업그레이드 스텝 가이드) (#29 @bigmsg 님께서 건의해주셨습니다.)
- 언어팩 일괄 활성화 검증 규칙에 동적 확장 훅 추가 (`core.language_packs.bulk_activate_validation_rules`) — 모듈/플러그인이 호스트 확장 재활성화 cascade 흐름에서 추가 검증 필드를 동적으로 등록 가능
### Changed
@@ -89,7 +109,7 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- 코어 업데이트의 단계 전환 신호를 비정상 입력으로부터 차단하도록 검증 강화
- 본인인증 정책 응답의 생성/수정 일시가 사용자 타임존이 아닌 UTC ISO 문자열로 노출되던 문제 수정
- 백업/롤백 시 `public/storage` 등 symlink 가 target 디렉토리 내용으로 추적 복사되어 symlink 가 일반 디렉토리로 변질되던 문제 수정 (Linux 환경 한정 — Windows 는 권한 한계로 일반 디렉토리 폴백 + 수동 회복 안내 유지)
- root/super user 환경에서 Composer 검증·설치가 비대화형 컨텍스트에서 실패하던 문제 수정 (코어 업데이트, 확장 의존성 설치, 인스톨러 포함)
- root/super user 환경에서 Composer 검증·설치가 비대화형 컨텍스트에서 실패하던 문제 수정 (코어 업데이트, 확장 의존성 설치, 인스톨러 포함) (#31 @glitter-gim 님께서 제보해주셨습니다.)
- 본인인증 활동 로그의 "액션" 열에 `identity.verify` / `identity.verify_failed` 가 번역되지 않은 키 그대로 노출되던 문제 수정 — 코어 액션 라벨 매핑이 누락되어 있던 부분을 추가
- 본인인증 단계에서 운영자가 지정한 본인인증 수단이 무시되고 항상 기본 수단(메일)으로 발행되던 문제 수정 — 가입 정책에 설정한 본인인증 수단과 API 요청에서 명시한 수단이 실제 인증 발행에 반영되도록 변경
@@ -111,7 +131,7 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
#### Generator 메타 태그
- 관리자 환경설정 → SEO 탭에 Generator 메타 태그 카드 추가 — 토글로 노출 여부를 제어하고 내용 입력으로 W3Techs 등 CMS 시장 점유율 측정 도구가 인식하는 `<meta name="generator">` 태그를 SEO 봇 페이지·SPA·관리자 셸 모두에 출력. 내용 미입력 시 "GnuBoard7 {버전}" 자동 적용, 운영자가 버전 노출을 원치 않으면 "GnuBoard7" 만 입력 가능
- 관리자 환경설정 → SEO 탭에 Generator 메타 태그 카드 추가 — 토글로 노출 여부를 제어하고 내용 입력으로 W3Techs 등 CMS 시장 점유율 측정 도구가 인식하는 `<meta name="generator">` 태그를 SEO 봇 페이지·SPA·관리자 셸 모두에 출력. 내용 미입력 시 "GnuBoard7 {버전}" 자동 적용, 운영자가 버전 노출을 원치 않으면 "GnuBoard7" 만 입력 가능 (#26 @Lastorder-DC 님께서 건의해주셨습니다.)
#### 웹 인스톨러 — 번들 언어팩 동반 선택 · 설치
@@ -162,7 +182,7 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- 모듈/플러그인/템플릿 정보 모달에 "지원 언어" 섹션 추가 — 코어/번들/사용자설치 출처 배지로 한눈에 확인
- 모듈/플러그인/템플릿 인스톨러에 의존 확장 + 동반 번들 언어팩 동반선택 UI 추가 — 미선택 의존성에 종속된 언어팩은 자동 비활성화
- 인스톨러 요구사항 검증 단계에 언어팩 디렉토리 쓰기 권한 점검 + 권한 부여 안내 추가
- 사용자 수동 비활성화와 코어 버전 호환성으로 인한 자동 비활성화를 DB 수준에서 구분 — 자동 비활성화된 확장만 재호환 감지/원클릭 복구 대상이 되도록 분리
- 사용자 수동 비활성화와 코어 버전 호환성으로 인한 자동 비활성화를 DB 수준에서 구분 — 자동 비활성화된 확장만 재호환 감지/원클릭 복구 대상이 되도록 분리 (#18 @laelbe 님께서 제보해주셨습니다.)
- 코어 업그레이드 후 자동 비활성화 확장이 다시 호환되면 관리자 대시보드에 "다시 활성화" 알림 표시 + 원클릭 복구 버튼 제공 (자동 재활성화는 하지 않음 — 운영자가 명시적으로 복구)
- 모듈/플러그인/템플릿 목록 화면 상단에 자동 비활성화 확장 안내 배너 추가 (코어 업그레이드 가이드 링크 동반)
- 업데이트 모달에 코어 버전 호환성 안내 + "위험을 이해하고 강제로 진행" 체크박스 추가 — 운영자가 위험을 인지하면 비호환 확장도 강제 설치 가능
@@ -265,10 +285,10 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
### Changed
- 콘솔 confirm 입력 처리 통일 — yes/y, no/n 외 입력 시 안내 메시지 출력 후 재질문, empty 입력 시 default 사용. 코어 업데이트·매니저 커맨드(module/plugin/template install·update·uninstall)·설정 마이그레이션의 모든 yes/no 프롬프트에 동일 규칙 적용
- 콘솔 confirm 입력 처리 통일 — yes/y, no/n 외 입력 시 안내 메시지 출력 후 재질문, empty 입력 시 default 사용. 코어 업데이트·매니저 커맨드(module/plugin/template install·update·uninstall)·설정 마이그레이션의 모든 yes/no 프롬프트에 동일 규칙 적용 (#15 @laelbe 님께서 건의해주셨습니다.)
- 비밀번호 재설정 정책 기본값을 비활성으로 변경 — 본인인증 인프라가 미구성된 사이트에서도 기본 동작이 영향받지 않도록 운영자 opt-in 으로 전환
- 본인인증 정책의 인증 조건(`conditions`) 운영자 편집 허용 — 회원가입 단계 등 정책 조건을 코드 수정 없이 관리자 화면에서 조정 가능. 모듈 업데이트 시 운영자 수정값 보존
- 토큰 만료 등으로 권한 없는 레이아웃 진입 시 "페이지 로딩 실패" 에러 화면 대신 로그인 페이지로 자동 이동 — 로그인 화면에서 "세션이 만료되었습니다. 다시 로그인해 주세요." 토스트로 사용자에게 안내. 템플릿이 자체 로그인 경로를 사용하는 경우 부트스트랩에서 인증 설정을 커스터마이즈할 수 있는 공개 API 도 함께 제공
- 토큰 만료 등으로 권한 없는 레이아웃 진입 시 "페이지 로딩 실패" 에러 화면 대신 로그인 페이지로 자동 이동 — 로그인 화면에서 "세션이 만료되었습니다. 다시 로그인해 주세요." 토스트로 사용자에게 안내. 템플릿이 자체 로그인 경로를 사용하는 경우 부트스트랩에서 인증 설정을 커스터마이즈할 수 있는 공개 API 도 함께 제공 (#19 @abc101 님께서 건의해주셨습니다.)
- 템플릿 다국어 데이터 로딩 시 활성 언어팩의 다국어가 가장 높은 우선순위로 병합되도록 변경
- 권한·역할·메뉴·알림 등 코어 기본 데이터와 배송유형·클레임 사유·게시판 유형 등 모듈 기본 데이터에 활성 언어팩의 다국어가 자동 반영되도록 개선
- 사용자 수정 보존(user_overrides) 정책을 다국어 JSON 컬럼은 sub-key 단위(`name.ko` 등) 로 기록하도록 개선 — 운영자가 한 언어 라벨만 수정해도 그 언어만 보존되며, 신규 활성 언어팩(예: 일본어 추가) 의 라벨은 자동 동기화됨. 기존 컬럼 단위(`name`) 기록은 업그레이드 시 활성 locale dot-path 로 자동 변환
@@ -277,14 +297,14 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
### Security
- 회원가입·비밀번호 재설정 라우트에 본인인증 정책 강제 미들웨어 부착 — 정책이 활성화된 경우 미인증 요청을 라우트 단계에서 차단
- 플러그인이 정책 해석 필터 훅에서 잘못된 타입을 반환해도 원본 정책이 유지되도록 우회 차단 강화
- 웹 인스톨러의 Composer/PHP 바이너리 경로 검증에서 사용자 입력이 그대로 shell 명령으로 실행될 수 있던 문제 수정 — 입력은 실행 가능한 단일 파일 경로로만 허용하고 모든 분기에서 인자 escape 강제. 설치 워커가 동일한 입력을 사용하던 내부 헬퍼도 같은 정책으로 정렬
- 설치 단계 4 의 확장 기능 선택 API 가 사용자가 보낸 모듈/플러그인/템플릿/언어팩 식별자에 셸 메타문자 검증을 적용하도록 강화 — 부적절한 식별자는 400 응답으로 거부되어 이후 설치 명령에 도달하지 않음
- 인스톨러의 코어 업데이트 _pending 경로 검증이 `..` 등 부모 디렉토리 우회 시도를 거부하고 응답 메시지를 단일화하여 임의 디렉토리 enumeration 신호 차단
- 설치 시 `.env` 작성 헬퍼가 입력값에 포함된 개행 문자를 제거하도록 변경 — DB 비밀번호 등 사용자 입력으로 새로운 환경 변수 라인이 주입되는 시나리오 차단
- 데이터베이스 연결 정보의 host/port/database 값에 DSN 키-밸류 구분자(`;`, `=`) 또는 NUL/CRLF 가 포함되면 연결을 거부하도록 추가 검증
- 설치가 완료된 시스템에서 `public/install/` 하위 모든 엔드포인트가 비즈니스 로직 진입 전 HTTP 410 으로 차단되도록 공통 가드 도입 — 운영 환경에서 인스톨러 노출형 결함의 공격 표면 제거. 운영자가 인스톨러를 다시 사용해야 하는 경우 설치 완료 마커(`storage/app/g7_installed`) 와 `.env` 의 `INSTALLER_COMPLETED` 를 모두 제거
- 회원가입·비밀번호 재설정 라우트에 본인인증 정책 강제 미들웨어 부착 — 정책이 활성화된 경우 미인증 요청을 라우트 단계에서 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 플러그인이 정책 해석 필터 훅에서 잘못된 타입을 반환해도 원본 정책이 유지되도록 우회 차단 강화 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 웹 인스톨러의 Composer/PHP 바이너리 경로 검증에서 사용자 입력이 그대로 shell 명령으로 실행될 수 있던 문제 수정 — 입력은 실행 가능한 단일 파일 경로로만 허용하고 모든 분기에서 인자 escape 강제. 설치 워커가 동일한 입력을 사용하던 내부 헬퍼도 같은 정책으로 정렬 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 설치 단계 4 의 확장 기능 선택 API 가 사용자가 보낸 모듈/플러그인/템플릿/언어팩 식별자에 셸 메타문자 검증을 적용하도록 강화 — 부적절한 식별자는 400 응답으로 거부되어 이후 설치 명령에 도달하지 않음 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 인스톨러의 코어 업데이트 _pending 경로 검증이 `..` 등 부모 디렉토리 우회 시도를 거부하고 응답 메시지를 단일화하여 임의 디렉토리 enumeration 신호 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 설치 시 `.env` 작성 헬퍼가 입력값에 포함된 개행 문자를 제거하도록 변경 — DB 비밀번호 등 사용자 입력으로 새로운 환경 변수 라인이 주입되는 시나리오 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 데이터베이스 연결 정보의 host/port/database 값에 DSN 키-밸류 구분자(`;`, `=`) 또는 NUL/CRLF 가 포함되면 연결을 거부하도록 추가 검증 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 설치가 완료된 시스템에서 `public/install/` 하위 모든 엔드포인트가 비즈니스 로직 진입 전 HTTP 410 으로 차단되도록 공통 가드 도입 — 운영 환경에서 인스톨러 노출형 결함의 공격 표면 제거. 운영자가 인스톨러를 다시 사용해야 하는 경우 설치 완료 마커(`storage/app/g7_installed`) 와 `.env` 의 `INSTALLER_COMPLETED` 를 모두 제거 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
### Fixed
@@ -314,14 +334,16 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- `seo:generate-sitemap` 커맨드가 큐 드라이버 설정과 무관하게 항상 "큐에 디스패치" 안내를 출력하던 문제 수정 — 동기 드라이버에서는 즉시 생성으로 동작하고 그에 맞는 안내를 표시하도록 변경
- 관리자 템플릿을 활성화해도 즉시 반영되지 않아 사용자가 직접 새로고침해야 하던 문제 수정 — 관리자 템플릿 활성화 시 자동으로 페이지를 갱신하여 새 템플릿이 즉시 적용되도록 개선
- 보안 환경설정의 "최대 로그인 시도 횟수 / 차단 시간" 설정이 실제로 적용되지 않아 무제한 로그인 시도가 가능하던 문제 수정 — 임계 도달 시 계정 잠금(HTTP 423), 잠금 해제 시각 안내 토스트, per-IP 백업 throttle, 활동 로그 기록까지 통합 구현
- 게시판 글쓰기 화면을 URL 로 직접 진입하거나 강제 새로고침했을 때 업로드한 첨부파일이 게시글에 연결되지 않던 문제 수정 (engine-v1.49.2)
- 게시판 글쓰기 화면을 URL 로 직접 진입하거나 강제 새로고침했을 때 업로드한 첨부파일이 게시글에 연결되지 않던 문제 수정 (engine-v1.49.2) (#24 @minyho 님께서 제보해주셨습니다.)
- 코어 업그레이드 후 새 버전에서 추가된 권한·메뉴·알림 정의가 등록되지 않아 관리자 화면에서 "해당 권한이 없습니다" 가 반복 표시되거나 신규 메일 템플릿이 비어있던 문제 수정 — 업그레이드 시 새 버전 설정 파일을 정확히 인식하도록 보정. 본 릴리즈로 업그레이드하면 누락분이 자동 등록됨
- PHP 8.5 환경에서 관리자 로그인 시 `PDO::MYSQL_ATTR_SSL_CA` 등 PDO 드라이버 상수가 deprecation 경고를 발생시키던 문제 수정 — `Pdo\Mysql::ATTR_SSL_CA` 형태의 신규 상수로 전환하고, PHP 8.5 미만 환경에서는 기존 상수를 그대로 사용하도록 분기 처리 (#23 @yks118 님께서 제보해주셨습니다.)
- 회원가입 시 일부 환경에서 동의 항목 메타데이터가 누락되어 활동 로그 처리에서 예외가 발생, 후속 훅 체인이 중단되며 신규 회원에게 `user` 역할이 자동 부여되지 않던 문제 수정 — 동의 메타데이터를 nullable 로 받아 누락 케이스에서도 후속 권한 부여가 정상 동작하도록 개선 (#25 @comtylove-netizen 님께서 제보해주셨습니다.)
## [7.0.0-beta.3] - 2026-04-23
### Fixed
- CKEditor5 플러그인 활성 상태에서 게시판 글쓰기 저장 시 "제목은 필수입니다" 422 오류가 발생하던 호환성 문제 수정 — 제목·내용 입력 순서와 무관하게 정상 저장되도록 개선
- CKEditor5 플러그인 활성 상태에서 게시판 글쓰기 저장 시 "제목은 필수입니다" 422 오류가 발생하던 호환성 문제 수정 — 제목·내용 입력 순서와 무관하게 정상 저장되도록 개선 (#17 @laelbe 님께서 제보해주셨습니다.)
- 코어 업데이트가 sudo 로 실행된 환경에서 캐시·세션·확장 디렉토리의 그룹 쓰기 권한이 일부 손실되어 업데이트 직후 "Permission denied" 또는 플러그인 제거 검증 실패가 발생하던 문제 수정 — 업데이트 종료 시점에 그룹 쓰기 권한을 자동 정상화하며 기존 손실 분은 1회성 복구 스텝으로 회수
- 업데이트 완료 후 Laravel 런타임이 새로 만드는 캐시·세션 하위 디렉토리가 기본 umask(022) 때문에 다시 그룹 쓰기 권한을 잃어 재차 "Permission denied" 가 발생하던 문제 수정 — 업그레이드 스텝이 업데이트 진행 프로세스의 umask 를 그룹 쓰기 친화적으로 전환하고, 이후 부팅 시점에도 `storage/` 의 현재 그룹 쓰기 설정을 감지해 프로세스 umask 를 자동 동조 (운영자가 그룹 공유를 비활성화한 환경은 그대로 보존)
- 코어 업데이트 마지막 단계의 진행 표시줄이 끝난 뒤 줄바꿈 없이 다음 셸 프롬프트가 같은 줄에 붙어 표시되던 출력 문제 수정
@@ -361,17 +383,17 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
#### GeoIP 및 타임존
- MaxMind GeoLite2 자동 다운로드 스케줄러 + 관리자 환경설정 UI 추가
- IANA 타임존 전체(약 425개) 지원 — 기존 7개 하드코딩 화이트리스트 폐기
- IANA 타임존 전체(약 425개) 지원 — 기존 7개 하드코딩 화이트리스트 폐기 (#8 @abc101 님께서 건의해주셨습니다.)
- Select 컴포넌트에 `searchable` prop 추가 — 타임존 등 대량 옵션에서 검색 가능
#### 인스톨러 개선
- SSE/폴링 듀얼 모드 지원 — Nginx 프록시 + Apache 환경에서 SSE 문제 시 폴링 모드로 전환 가능
- 확장 의존성 자동 해결 — 템플릿 선택 시 필요한 모듈/플러그인을 즉시 자동 선택, 전이적 의존성까지 해결
- 확장 의존성 자동 해결 — 템플릿 선택 시 필요한 모듈/플러그인을 즉시 자동 선택, 전이적 의존성까지 해결 (#10 @glitter-gim 님께서 건의해주셨습니다.)
- 자동 선택된 항목을 시각적으로 구분하고 요구한 확장 이름을 함께 표시
- 다른 확장이 의존하는 항목은 선택 해제 차단 (의존 관계 안내 메시지 포함)
- 의존성 버전 제약 사전 검증 — 버전 불일치 시 설치 진행 전 경고 (semver 비교: `>=`, `^`, `~` 등 지원)
- 기존 DB 테이블 감지 및 안전한 재설치 지원 — 백업 안내 + 명시적 동의 후 진행
- 의존성 버전 제약 사전 검증 — 버전 불일치 시 설치 진행 전 경고 (semver 비교: `>=`, `^`, `~` 등 지원) (#3 @laelbe 님께서 제보해주셨습니다.)
- 기존 DB 테이블 감지 및 안전한 재설치 지원 — 백업 안내 + 명시적 동의 후 진행 (#5 @laelbe 님께서 건의해주셨습니다.)
- 권한 안내 단순화 — `chmod -R 755` 단일 명령어로 통합 (업계 표준 정렬)
- 소유자 불일치(`ownership_mismatch`) 감지 — 전통적 Apache 환경에서 3가지 해결 옵션 제시
- Step 5에 "설치 시작" 버튼 도입 — 모드 선택 후 사용자 클릭으로 설치 시작
@@ -384,7 +406,7 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
#### 어드민 UI
- 페이지 진입/탭 전환 시 로딩 spinner 표시 — 데이터 fetch 완료까지 유지
- 페이지 진입/탭 전환 시 로딩 spinner 표시 — 데이터 fetch 완료까지 유지 (#6 @laelbe 님께서 제보해주셨습니다.)
- 목록 페이지네이션 시 DataGrid body 영역 한정 spinner (pagination 버튼 가림 방지)
- DataGrid 컴포넌트에 `id` prop 추가
@@ -480,6 +502,7 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- 코어·확장 설정에서 제거된 메뉴·권한·역할·알림 정의·알림 템플릿·게시판 유형·클레임 사유가 업데이트 후에도 DB에 잔존하던 문제 수정 — 고아 레코드 자동 정리
- 관리자 UI에서 메뉴·역할·알림·게시판 유형·클레임 사유·배송 유형 등을 수정해도 다음 업데이트 시 기본값으로 덮어써지던 문제 수정 — 사용자가 수정한 필드는 모든 저장 경로에서 자동 추적되어 업데이트 후에도 보존
- 알림 정의·템플릿도 업데이트 기준으로 동기화 — 새 버전에서 제거된 알림은 DB에서도 삭제
- 관리자 모듈/플러그인 목록의 "작성자" 표기가 manifest 의 `vendor` 필드 대신 식별자 prefix 를 사용하던 문제 수정 — `vendor` 가 정의된 확장은 정확한 작성자명으로 표시 (#9 @glitter-gim 님께서 제보해주셨습니다.)
### Removed
+1 -1
View File
@@ -246,7 +246,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
# (필요 시) mv g7-7.0.0-beta.5 g7
# (필요 시) mv g7-7.0.0-beta.6 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
+1 -1
View File
@@ -8,7 +8,7 @@
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.0--beta.5-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/version-7.0.0--beta.6-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
@@ -331,6 +331,44 @@ class CoreUpdateCommand extends Command
}
}
// ── Step 6.5: 신 버전 신규 파일 manifest 생성 ──
//
// applyUpdate 직전 시점에 백업 디렉토리(= 활성 디렉토리의 사전 스냅샷) 와
// _pending(= 신 버전 소스) 을 비교해 신 버전이 추가하는 파일/디렉토리 목록을
// `_new_files_manifest.json` 으로 백업 디렉토리에 기록한다. 자동 롤백 시
// `restoreFromBackup()` 이 본 manifest 를 참조해 활성 디렉토리에서 정확히 그
// 항목만 prune. 사용자가 활성 디렉토리에 직접 추가한 파일은 백업에 포함되어
// 있으므로 manifest 에서 제외되어 보존된다.
//
// `--no-backup` 모드면 backupPath 가 null 이므로 manifest 생성을 스킵 — 롤백
// 자체가 불가능한 모드이므로 기존 동작 유지.
if ($backupPath !== null) {
$bar->setMessage('신규 파일 manifest 생성 중...');
$log('신규 파일 manifest 생성 시작');
try {
$manifestStats = CoreBackupHelper::writeNewFilesManifest(
$backupPath,
$pendingPath,
(array) config('app.update.targets', []),
(array) config('app.update.protected_paths', []),
(array) config('app.update.excludes', []),
$fromVersion,
$toVersion,
);
$log(sprintf(
'신규 파일 manifest 작성 완료 (files=%d, dirs=%d)',
$manifestStats['new_files_count'],
$manifestStats['new_dirs_count'],
));
} catch (\Throwable $manifestError) {
// manifest 작성 실패는 fatal 이 아님 — 롤백 시 기존 overlay 만 수행 (기존 동작)
$log("신규 파일 manifest 작성 실패 (계속 진행): {$manifestError->getMessage()}");
Log::warning('코어 업데이트: manifest 작성 실패', [
'error' => $manifestError->getMessage(),
]);
}
}
// ── Step 7: 파일 적용 ──
$bar->setMessage(__('settings.core_update.step_apply'));
$bar->advance();
@@ -581,6 +619,17 @@ class CoreUpdateCommand extends Command
$log("백업 복원 실패: {$restoreError->getMessage()}");
$this->error("백업 복원 실패: {$restoreError->getMessage()}");
}
// 롤백 직후 캐시 자동 정리 — 신 코드가 `bootstrap/cache/*.php` 에 cache 된 채로
// 남아 부팅 실패하는 회귀 차단. 운영자의 수동 `php artisan optimize:clear` 단계 제거.
// 캐시 정리 실패는 fatal 아님 — warning 후 진행.
try {
$service->clearAllCaches();
$log('롤백 후 캐시 정리 완료');
} catch (\Throwable $cacheError) {
$log("롤백 후 캐시 정리 실패 (계속 진행): {$cacheError->getMessage()}");
$this->warn("캐시 정리 실패 — 부팅 후 'php artisan optimize:clear' 수동 실행 필요: {$cacheError->getMessage()}");
}
}
// _pending 정리 (실패 시에도)
@@ -682,6 +731,17 @@ class CoreUpdateCommand extends Command
if ($force) {
$command[] = '--force';
}
// 부모는 이미 Step 9 (runMigrations + reloadCoreConfigAndResync, 라인 408-409),
// Step 11 (updateVersionInEnv + clearAllCaches, 라인 457-458), 번들 확장 일괄 업데이트
// prompt (라인 497-515) 를 자식 종료 후 실행하므로 자식 내부 중복 회피. 단독 실행 시
// (운영자 직접 호출) 옵션이 전달되지 않아 자식 기본값(5단계 모두 포함) 발동 →
// gnuboard/g7#34 의 운영자 수동 절차(migrate / resync / .env sed / cache:clear / module:update --source=bundled)
// 가 단일 명령으로 통합되어 단독 안전성 보장.
$command[] = '--skip-migrations';
$command[] = '--skip-resync';
$command[] = '--skip-version-env';
$command[] = '--skip-cache-clear';
$command[] = '--skip-bundled-updates';
$commandLine = implode(' ', array_map('escapeshellarg', $command)).' 2>&1';
@@ -2,7 +2,12 @@
namespace App\Console\Commands\Core;
use App\Console\Commands\Core\Concerns\BundledExtensionUpdatePrompt;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Exceptions\UpgradeHandoffException;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Services\CoreUpdateService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -18,15 +23,28 @@ use Illuminate\Support\Facades\Log;
* 업그레이드는 beta.1 의 CoreUpdateCommand 가 본 커맨드를 알지 못하므로 spawn
* 효과를 받지 못한다(경로 A) — 이 경우 upgrade step 파일 내부 로컬 로직으로
* 후처리를 수행해야 한다. 상세는 docs/extension/upgrade-step-guide.md 참조.
*
* 단독 실행 안전성: 운영자가 HANDOFF 안내문 또는 수동 복구 목적으로 직접 호출하면
* 기본값으로 부모 CoreUpdateCommand 가 처리하던 사전(Migration + Resync) 및
* 사후(.env 버전 + 캐시 정리 + 번들 확장 일괄 업데이트) 단계를 자동 수행한다.
* CoreUpdateCommand spawn 호출 시엔 `--skip-*` 옵션 5개로 중복 회피.
*/
class ExecuteUpgradeStepsCommand extends Command
{
use BundledExtensionUpdatePrompt;
use HasUnifiedConfirm;
protected $signature = 'core:execute-upgrade-steps
{--from= : 시작 버전}
{--to= : 대상 버전}
{--force : 동일 버전 강제 실행}';
{--force : 동일 버전 강제 실행 + 번들 확장 일괄 업데이트 prompt 스킵}
{--skip-migrations : 마이그레이션 실행 생략 (CoreUpdateCommand spawn 시 부모 Step 9 가 이미 실행)}
{--skip-resync : 코어 config 재로드 및 권한/메뉴/시더 동기화 생략 (동일 사유)}
{--skip-version-env : .env APP_VERSION 갱신 생략 (부모 Step 11 가 처리)}
{--skip-cache-clear : 캐시 정리 생략 (부모 Step 11 가 처리)}
{--skip-bundled-updates : 번들 확장 일괄 업데이트 생략 (부모가 prompt 로 처리)}';
protected $description = '코어 업그레이드 스텝을 별도 프로세스에서 실행합니다 (CoreUpdateCommand 내부용)';
protected $description = '코어 업그레이드 스텝을 별도 프로세스에서 실행합니다. 단독 실행 시 사전/사후 단계를 자동 수행합니다.';
/**
* 커맨드를 실행합니다.
@@ -91,6 +109,26 @@ class ExecuteUpgradeStepsCommand extends Command
]);
}
// 사전 단계 (단독 실행 안전성 보장).
//
// CoreUpdateCommand 가 spawn 호출 시엔 부모 Step 9 (CoreUpdateCommand.php:408-409) 에서
// 이미 동일 단계를 실행했으므로 --skip-migrations / --skip-resync 로 중복 회피.
// 운영자 단독 호출 시 옵션 미전달 → 기본값으로 두 단계 자동 수행 →
// migration / permission / menu / seeder 누락 차단.
if (! $this->option('skip-migrations')) {
$this->info('마이그레이션 실행');
$service->runMigrations();
} else {
$this->info('[spawn] 마이그레이션 스킵 — 부모가 이미 실행');
}
if (! $this->option('skip-resync')) {
$this->info('코어 config 재로드 및 권한/메뉴/시더 동기화');
$service->reloadCoreConfigAndResync();
} else {
$this->info('[spawn] resync 스킵 — 부모가 이미 실행');
}
$stepsExecuted = 0;
try {
@@ -149,6 +187,39 @@ class ExecuteUpgradeStepsCommand extends Command
'toVersion' => $to,
], JSON_UNESCAPED_UNICODE));
// 사후 단계 (단독 실행 안전성 보장).
//
// CoreUpdateCommand spawn 시엔 부모 Step 11 (CoreUpdateCommand.php:457-458) 및
// 번들 확장 일괄 업데이트 prompt (라인 497-515) 가 자식 종료 후 실행하므로
// --skip-version-env / --skip-cache-clear / --skip-bundled-updates 로 중복 회피.
// 단독 실행 시엔 옵션 미전달 → 기본값으로 3단계 자동 수행 → gnuboard/g7#34 의 운영자 수동 절차
// (sed APP_VERSION + cache:clear + module/plugin/template:update --force --source=bundled) 통합.
if (! $this->option('skip-version-env')) {
$this->info(".env APP_VERSION={$to} 갱신");
$service->updateVersionInEnv($to);
} else {
$this->info('[spawn] .env APP_VERSION 갱신 스킵 — 부모가 처리');
}
if (! $this->option('skip-cache-clear')) {
$this->info('캐시 정리 (config/route/view/services/packages)');
$service->clearAllCaches();
} else {
$this->info('[spawn] 캐시 정리 스킵 — 부모가 처리');
}
if (! $this->option('skip-bundled-updates')) {
$this->info('번들 확장 일괄 업데이트 (모듈/플러그인/템플릿/언어팩)');
$this->runBundledExtensionUpdatePrompt(
app(ModuleManager::class),
app(PluginManager::class),
app(TemplateManager::class),
$force,
);
} else {
$this->info('[spawn] 번들 확장 일괄 업데이트 스킵 — 부모가 처리');
}
return self::SUCCESS;
}
}
@@ -0,0 +1,246 @@
<?php
namespace App\Console\Commands\Hotfix;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Extension\Helpers\CoreBackupHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* 자동 롤백 후 활성 디렉토리에 잔존할 수 있는 신 버전 신규 파일을 진단/정리하는 단발성
* 결함 보정 도구.
*
* 결함 컨텍스트: 7.0.0-beta.5 이전의 코어 자동 롤백은 backup → 활성 overlay 복사 방식
* 으로만 동작하여, 신 버전이 활성 디렉토리에 새로 추가한 파일은 백업에 없으므로 삭제되지
* 않고 잔존했다. 결과적으로 `bootstrap/providers.php` 는 백업본(구버전) 으로 되돌아가지만
* 디스크에는 신 ServiceProvider 파일이 잔존하여 `BindingResolutionException` 등 부팅
* 부정합 발생.
*
* 도입 버전: 7.0.0-beta.6 신설.
*
* 동작:
* - `--backup` 미지정 시 `storage/app/core_backups/` 의 가장 최근 백업 자동 선택
* - 백업 디렉토리에 `_new_files_manifest.json` 이 있으면 그 기반으로 잔존 파일 후보 식별 (정확)
* - manifest 부재 시 보수적 진단 모드 (실제 정리는 운영자 수동)
* - 기본은 진단 모드 (실제 삭제 없이 후보 목록만 출력)
* - `--prune` 옵션 시 확인 프롬프트 후 정리. symlink/protected_paths 가드 적용
* - 정리 결과는 `storage/logs/hotfix_rollback_stale_files_<timestamp>.log` 기록
*
* @permanent — 7.0.0-beta.5 이전 사용자의 잔존 결함 회복을 위해 영구 보존. 향후 운영자가
* 과거 롤백 흔적을 점검할 수 있도록 유지하며, deprecation 예정 없음.
*/
class RollbackStaleFilesCommand extends Command
{
use HasUnifiedConfirm;
protected $signature = 'hotfix:rollback-stale-files
{--backup= : 특정 백업 디렉토리 경로 지정 (미지정 시 가장 최근 백업 자동 선택)}
{--prune : 진단만이 아닌 실제 정리 수행 (확인 프롬프트 동반)}';
protected $description = '[hotfix/beta.6 신설] 코어 자동 롤백 후 활성 디렉토리에 잔존할 수 있는 신 버전 신규 파일을 진단/정리합니다.';
public function handle(): int
{
$backupPath = $this->resolveBackupPath();
if ($backupPath === null) {
$this->info('사용 가능한 백업이 없습니다 (`storage/app/core_backups/` 비어 있음).');
return Command::SUCCESS;
}
$this->info("백업 디렉토리: {$backupPath}");
$manifestPath = $backupPath.DIRECTORY_SEPARATOR.CoreBackupHelper::NEW_FILES_MANIFEST;
$manifestAvailable = File::exists($manifestPath);
if (! $manifestAvailable) {
$this->warn('⚠ _new_files_manifest.json 부재 — 보수적 진단 모드로 진행합니다.');
$this->line(' manifest 가 없으면 신 파일 vs 사용자 추가 파일을 정확히 구분할 수 없습니다.');
$this->line(' beta.5 이전 사용자: beta.6 업그레이드 스텝이 manifest 를 사후 작성하므로');
$this->line(' 먼저 `php artisan core:execute-upgrade-steps --from=<버전> --to=7.0.0-beta.6 --force`');
$this->line(' 를 실행한 뒤 본 커맨드를 재실행하는 것을 권장합니다.');
$this->newLine();
}
$candidates = $this->collectCandidates($backupPath, $manifestAvailable);
if ($candidates['files'] === [] && $candidates['dirs'] === []) {
$this->info('잔존 후보 없음 — 활성 디렉토리가 정상 상태입니다.');
return Command::SUCCESS;
}
$this->newLine();
$this->info(sprintf(
'잔존 후보: 파일 %d개, 디렉토리 %d개',
count($candidates['files']),
count($candidates['dirs']),
));
foreach (array_slice($candidates['files'], 0, 20) as $f) {
$this->line(" · 파일: {$f}");
}
if (count($candidates['files']) > 20) {
$this->line(sprintf(' ... (총 %d건, 상위 20건만 표시)', count($candidates['files'])));
}
foreach (array_slice($candidates['dirs'], 0, 10) as $d) {
$this->line(" · 디렉토리: {$d}");
}
if (count($candidates['dirs']) > 10) {
$this->line(sprintf(' ... (총 %d건, 상위 10건만 표시)', count($candidates['dirs'])));
}
if (! $this->option('prune')) {
$this->newLine();
$this->info('진단 모드: 실제 삭제하지 않았습니다. 정리하려면 `--prune` 옵션을 추가하세요.');
return Command::SUCCESS;
}
if (! $manifestAvailable) {
$this->newLine();
$this->error('manifest 부재 상태에서는 자동 prune 을 수행하지 않습니다 (사용자 추가 파일 손실 위험).');
$this->line('beta.6 업그레이드 스텝을 먼저 실행하여 manifest 를 사후 작성한 뒤 재시도하세요.');
return Command::FAILURE;
}
$this->newLine();
if (! $this->unifiedConfirm('위 후보를 활성 디렉토리에서 정리하시겠습니까?', false)) {
$this->info('취소되었습니다.');
return Command::SUCCESS;
}
$protectedPaths = (array) config('app.update.protected_paths', []);
$result = CoreBackupHelper::pruneNewFiles($backupPath, base_path(), $protectedPaths);
$this->newLine();
$this->info(sprintf(
'정리 완료: 파일 %d개, 디렉토리 %d개 제거. 보호 %d건, symlink skip %d건, 실패 %d건.',
$result['removed_files'],
$result['removed_dirs'],
$result['protected_count'],
$result['symlink_skipped'],
$result['failed_count'],
));
$logPath = storage_path(sprintf(
'logs/hotfix_rollback_stale_files_%s.log',
date('Ymd_His'),
));
@file_put_contents($logPath, $this->buildLogEntry($backupPath, $result, $candidates));
$this->line("로그: {$logPath}");
Log::info('hotfix:rollback-stale-files 정리 완료', [
'backup_path' => $backupPath,
'result' => $result,
]);
return Command::SUCCESS;
}
/**
* `--backup` 옵션 또는 가장 최근 백업 디렉토리 경로를 반환.
*/
private function resolveBackupPath(): ?string
{
$explicit = $this->option('backup');
if ($explicit !== null && $explicit !== '') {
$real = realpath($explicit);
if ($real === false || ! is_dir($real)) {
$this->error("지정된 백업 디렉토리가 존재하지 않습니다: {$explicit}");
return null;
}
return $real;
}
$backups = CoreBackupHelper::listBackups();
if ($backups === []) {
return null;
}
// listBackups 는 정렬 보장이 약하므로 mtime 기준으로 다시 정렬
usort($backups, fn ($a, $b) => filemtime($b['path']) <=> filemtime($a['path']));
return $backups[0]['path'] ?? null;
}
/**
* 잔존 후보 식별 — manifest 가 있으면 그 기반, 부재 시 빈 배열 반환 (운영자 수동 검토 안내).
*
* @return array{files:array<int,string>, dirs:array<int,string>}
*/
private function collectCandidates(string $backupPath, bool $manifestAvailable): array
{
if (! $manifestAvailable) {
return ['files' => [], 'dirs' => []];
}
$manifestPath = $backupPath.DIRECTORY_SEPARATOR.CoreBackupHelper::NEW_FILES_MANIFEST;
$raw = @file_get_contents($manifestPath);
if ($raw === false) {
return ['files' => [], 'dirs' => []];
}
$manifest = json_decode($raw, true);
if (! is_array($manifest)) {
return ['files' => [], 'dirs' => []];
}
$files = [];
foreach ((array) ($manifest['new_files'] ?? []) as $rel) {
if (! is_string($rel) || $rel === '') {
continue;
}
$absolute = base_path($rel);
if (file_exists($absolute) || is_link($absolute)) {
$files[] = $rel;
}
}
$dirs = [];
foreach ((array) ($manifest['new_dirs'] ?? []) as $rel) {
if (! is_string($rel) || $rel === '') {
continue;
}
$absolute = base_path($rel);
if (is_dir($absolute) && ! is_link($absolute)) {
$dirs[] = $rel;
}
}
return ['files' => $files, 'dirs' => $dirs];
}
/**
* 진단/정리 결과를 로그 텍스트로 빌드.
*/
private function buildLogEntry(string $backupPath, array $result, array $candidates): string
{
$lines = [
'=== hotfix:rollback-stale-files 실행 로그 ===',
'날짜: '.date('Y-m-d H:i:s'),
"백업 디렉토리: {$backupPath}",
'',
'결과:',
sprintf(' 파일 제거: %d', $result['removed_files']),
sprintf(' 디렉토리 제거: %d', $result['removed_dirs']),
sprintf(' 보호 경로 skip: %d', $result['protected_count']),
sprintf(' symlink skip: %d', $result['symlink_skipped']),
sprintf(' 실패: %d', $result['failed_count']),
'',
'후보 목록 (실행 직전 스냅샷):',
];
foreach ($candidates['files'] as $f) {
$lines[] = " · 파일: {$f}";
}
foreach ($candidates['dirs'] as $d) {
$lines[] = " · 디렉토리: {$d}";
}
return implode("\n", $lines)."\n";
}
}
+408
View File
@@ -2,11 +2,26 @@
namespace App\Extension\Helpers;
use FilesystemIterator;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
class CoreBackupHelper
{
/**
* 백업 디렉토리에 기록되는 manifest 파일명.
*/
public const NEW_FILES_MANIFEST = '_new_files_manifest.json';
/**
* Manifest 스키마 버전. DataMigration (Upgrade_7_0_0_beta_6) 의 사후 작성본과
* 바이트 단위 호환 invariant — 스키마 변경 시 양쪽 동시 갱신 필수.
*/
public const MANIFEST_SCHEMA_VERSION = 1;
/**
* 코어 파일을 선택적으로 백업합니다.
*
@@ -51,6 +66,10 @@ class CoreBackupHelper
* 백업 파일을 복원하되 현재 파일의 퍼미션은 유지합니다.
* 개별 target 복원 실패 시에도 나머지 target 복원을 계속 진행합니다.
*
* 백업 디렉토리에 `_new_files_manifest.json` 이 있으면 복사 직후 신 버전 신규 파일을
* 정리하는 prune 단계를 추가 실행한다. manifest 부재 시 기존 overlay 복사만 수행
* (기존 동작 유지).
*
* @param string $backupPath 백업 디렉토리 경로
* @param array $targets 복원 대상 경로 목록
* @param \Closure|null $onProgress 진행 콜백
@@ -86,6 +105,28 @@ class CoreBackupHelper
}
}
// Manifest 가 있으면 신 버전 신규 파일 prune. 부재 시 silent skip (기존 동작).
$manifestPath = $backupPath.DIRECTORY_SEPARATOR.self::NEW_FILES_MANIFEST;
if (File::exists($manifestPath)) {
try {
$protected = (array) config('app.update.protected_paths', []);
$pruneResult = self::pruneNewFiles($backupPath, base_path(), $protected, $onProgress);
Log::info('코어 자동 롤백 prune 완료', [
'backup_path' => $backupPath,
'removed_files' => $pruneResult['removed_files'],
'removed_dirs' => $pruneResult['removed_dirs'],
'protected_count' => $pruneResult['protected_count'],
'symlink_skipped' => $pruneResult['symlink_skipped'],
'failed_count' => $pruneResult['failed_count'],
]);
} catch (\Throwable $e) {
Log::warning('코어 자동 롤백 prune 실패 (overlay 복사는 완료)', [
'backup_path' => $backupPath,
'error' => $e->getMessage(),
]);
}
}
if (empty($failedTargets)) {
Log::info('코어 백업 복원 완료', ['backup_path' => $backupPath]);
} else {
@@ -148,4 +189,371 @@ class CoreBackupHelper
return $backups;
}
/**
* 신 버전이 추가하는 파일/디렉토리 목록을 백업 디렉토리에 manifest 로 기록합니다.
*
* 비교 기준:
* - 백업 디렉토리 (`$backupPath/$target`) = 활성 디렉토리의 사전 스냅샷 (= 현재 디스크의 구버전)
* - 소스 디렉토리 (`$sourcePath/$target`) = 신 버전 _pending 소스
* - 신규 항목 정의 = `_pending` 에는 존재 + 백업에는 없는 파일/디렉토리
*
* 자동 롤백 시 `restoreFromBackup()` 이 본 manifest 를 참조하여 활성 디렉토리에서
* 정확히 그 항목만 prune. 사용자가 활성 디렉토리에 직접 추가한 파일은 백업에 포함되어
* 있으므로 manifest 에서 제외되어 보존된다.
*
* `protectedPaths` 와 `excludes` 하위는 manifest 에서 사전 제외 (방어 깊이). `pruneNewFiles`
* 도 동일 가드를 재실행하므로 이중 방어.
*
* @param string $backupPath 백업 디렉토리 경로 (= 활성 사전 스냅샷)
* @param string $sourcePath 소스 디렉토리 경로 (= _pending 신 버전)
* @param array $targets 처리할 target 경로 목록 (`app.update.targets`)
* @param array $protectedPaths 보호 경로 목록 (manifest 에서 제외)
* @param array $excludes 제외 패턴 목록 (예: ['node_modules', '.git'])
* @param string $fromVersion 시작 버전 (manifest 기록용)
* @param string $toVersion 대상 버전 (manifest 기록용)
* @return array{new_files_count:int, new_dirs_count:int}
*/
public static function writeNewFilesManifest(
string $backupPath,
string $sourcePath,
array $targets,
array $protectedPaths,
array $excludes,
string $fromVersion,
string $toVersion,
): array {
$newFiles = [];
$newDirs = [];
$protectedSet = self::normalizeProtectedSet($protectedPaths);
$excludeSet = array_values(array_filter(array_map('trim', $excludes)));
foreach ($targets as $target) {
$target = trim($target);
if ($target === '') {
continue;
}
// target 자체가 보호 경로면 스킵
if (self::isWithinProtectedPath($target, $protectedSet)) {
continue;
}
$sourceTargetPath = $sourcePath.DIRECTORY_SEPARATOR.$target;
if (! file_exists($sourceTargetPath)) {
continue;
}
// 단일 파일 target
if (is_file($sourceTargetPath) && ! is_link($sourceTargetPath)) {
$backupFilePath = $backupPath.DIRECTORY_SEPARATOR.$target;
if (! file_exists($backupFilePath)) {
$newFiles[] = self::normalizeRelative($target);
}
continue;
}
if (! is_dir($sourceTargetPath)) {
continue;
}
// 디렉토리 target — 재귀 비교
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($sourceTargetPath, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST,
);
foreach ($iterator as $item) {
/** @var SplFileInfo $item */
$absolute = $item->getPathname();
$relative = self::normalizeRelative($target.'/'.ltrim(substr($absolute, strlen($sourceTargetPath)), DIRECTORY_SEPARATOR.'/'));
if (self::matchesExcludes($relative, $excludeSet)) {
continue;
}
if (self::isWithinProtectedPath($relative, $protectedSet)) {
continue;
}
$backupItemPath = $backupPath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative);
if ($item->isDir() && ! $item->isLink()) {
if (! is_dir($backupItemPath)) {
$newDirs[] = $relative;
}
} elseif ($item->isFile()) {
if (! file_exists($backupItemPath)) {
$newFiles[] = $relative;
}
}
// symlink 는 manifest 에 등재하지 않음 — prune 도 어차피 skip
}
}
sort($newFiles, SORT_STRING);
sort($newDirs, SORT_STRING);
$manifest = [
'version' => self::MANIFEST_SCHEMA_VERSION,
'created_at' => date('c'),
'from_version' => $fromVersion,
'to_version' => $toVersion,
'new_files' => $newFiles,
'new_dirs' => $newDirs,
];
File::ensureDirectoryExists($backupPath);
$manifestPath = $backupPath.DIRECTORY_SEPARATOR.self::NEW_FILES_MANIFEST;
File::put($manifestPath, json_encode(
$manifest,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
));
Log::info('코어 신규 파일 manifest 작성 완료', [
'manifest_path' => $manifestPath,
'new_files_count' => count($newFiles),
'new_dirs_count' => count($newDirs),
'from_version' => $fromVersion,
'to_version' => $toVersion,
]);
return [
'new_files_count' => count($newFiles),
'new_dirs_count' => count($newDirs),
];
}
/**
* 백업 디렉토리의 manifest 를 로드해 활성 디렉토리에서 신 버전 신규 파일을 정리합니다.
*
* 안전 가드:
* - symlink 는 manifest 에 있더라도 무조건 skip (`public/storage` 등 운영 데이터 보호)
* - protected_paths 하위 항목은 prune 시점에 재검증되어 skip (이중 방어)
* - manifest 부재/JSON 파싱 실패 시 noop + manifest_loaded:false 반환
* - 디렉토리는 깊이 역순으로 rmdir 시도. 빈 디렉토리만 제거 (사용자 파일이 있으면 유지)
* - 개별 항목 삭제 실패는 fatal 이 아닌 warning + failed_count 누적
*
* @param string $backupPath 백업 디렉토리 경로 (manifest 위치)
* @param string $activePath 활성(= 정리 대상) 디렉토리 경로
* @param array $protectedPaths 보호 경로 목록 (이중 가드)
* @param \Closure|null $onProgress 진행 콜백 (현재 미사용 — 호출 시그니처 호환용)
* @return array{removed_files:int, removed_dirs:int, protected_count:int, symlink_skipped:int, failed_count:int, manifest_loaded:bool}
*/
public static function pruneNewFiles(
string $backupPath,
string $activePath,
array $protectedPaths,
?\Closure $onProgress = null,
): array {
$result = [
'removed_files' => 0,
'removed_dirs' => 0,
'protected_count' => 0,
'symlink_skipped' => 0,
'failed_count' => 0,
'manifest_loaded' => false,
];
$manifestPath = $backupPath.DIRECTORY_SEPARATOR.self::NEW_FILES_MANIFEST;
if (! File::exists($manifestPath)) {
return $result;
}
$raw = @file_get_contents($manifestPath);
if ($raw === false) {
Log::warning('코어 prune: manifest 읽기 실패 — skip', ['path' => $manifestPath]);
return $result;
}
$manifest = json_decode($raw, true);
if (! is_array($manifest) || ! isset($manifest['new_files']) || ! isset($manifest['new_dirs'])) {
Log::warning('코어 prune: manifest JSON 파싱 실패 — skip', ['path' => $manifestPath]);
return $result;
}
$result['manifest_loaded'] = true;
$protectedSet = self::normalizeProtectedSet($protectedPaths);
// 신규 파일 정리
foreach ((array) $manifest['new_files'] as $rel) {
if (! is_string($rel) || $rel === '') {
continue;
}
$rel = self::normalizeRelative($rel);
if (self::isWithinProtectedPath($rel, $protectedSet)) {
$result['protected_count']++;
continue;
}
$absolute = $activePath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
if (is_link($absolute)) {
$result['symlink_skipped']++;
continue;
}
if (! file_exists($absolute)) {
continue; // 이미 부재 — skip
}
if (is_dir($absolute) && ! is_link($absolute)) {
// 파일로 등재되었으나 실제는 디렉토리 — 안전 우선, skip
continue;
}
if (@unlink($absolute)) {
$result['removed_files']++;
} else {
$result['failed_count']++;
Log::warning('코어 prune: 파일 삭제 실패', ['path' => $absolute]);
}
}
// 신규 디렉토리 정리 — 깊이 역순으로 빈 디렉토리만 rmdir
$dirs = array_values(array_filter((array) $manifest['new_dirs'], 'is_string'));
usort($dirs, fn ($a, $b) => substr_count($b, '/') <=> substr_count($a, '/'));
foreach ($dirs as $rel) {
$rel = self::normalizeRelative($rel);
if (self::isWithinProtectedPath($rel, $protectedSet)) {
$result['protected_count']++;
continue;
}
$absolute = $activePath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
if (is_link($absolute)) {
$result['symlink_skipped']++;
continue;
}
if (! is_dir($absolute)) {
continue;
}
// 빈 디렉토리만 rmdir — 사용자 파일이 남아있으면 유지
if (self::isEmptyDirectory($absolute)) {
if (@rmdir($absolute)) {
$result['removed_dirs']++;
} else {
$result['failed_count']++;
Log::warning('코어 prune: 디렉토리 삭제 실패', ['path' => $absolute]);
}
}
}
return $result;
}
/**
* protected_paths 배열을 정규화된 비교 집합으로 변환합니다.
*
* 슬래시 정규화 + 좌우 공백 제거 + 빈 항목 제거 + 최상위/하위 prefix 매칭에 사용.
*
* @param array<int, string> $paths
* @return array<int, string>
*/
private static function normalizeProtectedSet(array $paths): array
{
$out = [];
foreach ($paths as $p) {
$p = trim((string) $p);
if ($p === '') {
continue;
}
$out[] = self::normalizeRelative($p);
}
return $out;
}
/**
* 상대 경로가 protected_paths 목록의 어떤 항목 하위에 위치하는지 검사합니다.
*/
private static function isWithinProtectedPath(string $relative, array $protectedSet): bool
{
$relative = self::normalizeRelative($relative);
foreach ($protectedSet as $p) {
if ($p === '') {
continue;
}
if ($relative === $p) {
return true;
}
if (str_starts_with($relative, $p.'/')) {
return true;
}
}
return false;
}
/**
* 상대 경로가 excludes 패턴(이름 또는 슬래시 포함 경로) 에 매칭되는지 검사합니다.
*
* 단순 이름(슬래시 미포함) 은 경로의 어떤 세그먼트와도 매칭 — `node_modules` 처럼
* 깊이 무관 제외 패턴 의도와 일치.
*/
private static function matchesExcludes(string $relative, array $excludes): bool
{
$segments = explode('/', $relative);
foreach ($excludes as $exclude) {
if ($exclude === '') {
continue;
}
if (str_contains($exclude, '/')) {
if ($relative === $exclude || str_starts_with($relative, $exclude.'/')) {
return true;
}
} else {
if (in_array($exclude, $segments, true)) {
return true;
}
}
}
return false;
}
/**
* 경로 문자열을 슬래시 정규화 + 좌측 슬래시 제거.
*/
private static function normalizeRelative(string $path): string
{
$p = str_replace('\\', '/', $path);
$p = ltrim($p, '/');
// 연속 슬래시 제거
while (str_contains($p, '//')) {
$p = str_replace('//', '/', $p);
}
return $p;
}
/**
* 디렉토리가 비어있는지 검사 (`.` `..` 제외).
*/
private static function isEmptyDirectory(string $path): bool
{
if (! is_dir($path)) {
return true;
}
$entries = @scandir($path);
if ($entries === false) {
return false;
}
return count(array_diff($entries, ['.', '..'])) === 0;
}
}
+33 -5
View File
@@ -98,12 +98,13 @@ class EnvironmentDetector
}
// composer.phar 폴백
// @is_file 로 open_basedir warning 억제 (BASE_PATH/getcwd 가 화이트리스트 밖일 가능성)
$pharCandidates = [
base_path('composer.phar'),
getcwd().DIRECTORY_SEPARATOR.'composer.phar',
];
foreach ($pharCandidates as $phar) {
if (is_file($phar)) {
if (@is_file($phar)) {
return $this->cachedComposerBinary = $phar;
}
}
@@ -275,6 +276,11 @@ class EnvironmentDetector
/**
* PATH 환경변수에서 composer 검색.
*
* stat (is_file) 은 open_basedir 같은 PHP 런타임 제약 환경에서 false negative
* 를 일으키므로 보조 신호로만 쓴다. stat 통과 후보가 있으면 우선 반환하고,
* 그렇지 않은 경우 메타문자 없는 첫 후보를 반환해 canExecuteComposer 의
* proc_open 결과로 최종 판정한다.
*/
private function searchComposerInPath(): ?string
{
@@ -289,20 +295,35 @@ class EnvironmentDetector
? ['composer.bat', 'composer.exe', 'composer.phar', 'composer']
: ['composer', 'composer.phar'];
$fallback = null;
foreach ($paths as $dir) {
foreach ($names as $name) {
$candidate = rtrim($dir, '/\\').DIRECTORY_SEPARATOR.$name;
if (is_file($candidate)) {
// stat 통과 후보가 있으면 우선 반환 (가장 신뢰도 높음)
if (@is_file($candidate)) {
return $candidate;
}
// stat 실패 후보는 첫 번째만 fallback 으로 보관.
// open_basedir 환경에서는 정상 binary 도 is_file false 가 되므로
// proc_open 결과로 최종 판정할 기회를 남긴다.
if ($fallback === null) {
$fallback = $candidate;
}
}
}
return null;
return $fallback;
}
/**
* 후보 경로가 실행 가능한 파일인지 확인.
*
* stat (is_file) 의존을 제거해 open_basedir 환경의 false negative 를 피한다.
* 단일 토큰의 셸 메타문자만 차단하고, 실제 실행 가능 여부는 canExecuteComposer
* 의 proc_open 결과로 최종 판정한다.
*/
private function isExecutableCandidate(?string $candidate): bool
{
@@ -310,11 +331,18 @@ class EnvironmentDetector
return false;
}
// 공백 포함 시 전체 커맨드로 간주 — 파일 존재 검사 스킵
// 공백 포함 시 전체 커맨드로 간주 — 외부 신뢰된 config/.env 값을 그대로 사용.
// buildComposerCommand 의 공백 분기와 동일한 신뢰 모델 (운영자 자기 책임 영역).
if (str_contains($candidate, ' ')) {
return true;
}
return is_file($candidate);
// 셸 메타문자 + 제어문자 차단. 백슬래시는 Windows 경로 구분자이므로 차단 대상 아님 —
// 셸 인젝션 차단은 호출자의 escapeshellarg/buildComposerCommand 가 담당.
if (preg_match('/[;`$|<>"\'&\x00-\x1F]/', $candidate)) {
return false;
}
return true;
}
}
@@ -16,6 +16,7 @@ class IdentityMessageDefinitionCollection extends BaseApiCollection
protected function abilityMap(): array
{
return [
'can_create' => 'core.admin.identity.messages.update',
'can_update' => 'core.admin.identity.messages.update',
];
}
+1 -1
View File
@@ -231,7 +231,7 @@ return [
|
*/
'version' => env('APP_VERSION', '7.0.0-beta.5'),
'version' => env('APP_VERSION', '7.0.0-beta.6'),
/*
|--------------------------------------------------------------------------
+2
View File
@@ -215,6 +215,8 @@ v접두사 자동 감지 (resolveGithubArchiveUrl):
> **spawn 자식 진입 시 PSR-4 autoload 갱신 (engine-v / beta.4 이후)**: `core:execute-upgrade-steps` (Step 10) 와 `core:execute-bundled-updates` (Step 12) 의 spawn 자식은 `handle()` 진입 직후 `app(ExtensionManager::class)->updateComposerAutoload()` 를 1회 호출한다. 부모 프로세스의 `bootstrap/cache/autoload-extensions.php` 가 stale 한 경우 자식이 그 매핑을 그대로 로드 → upgrade step 또는 bundled update 안에서 모듈/플러그인의 `Models`/`Services` 같은 다른 클래스를 lazy autoload 시 "Class not found" 발생. 진입 시점 1회 호출로 모든 후속 작업이 fresh autoload 환경에서 실행됨을 보장한다 (개별 step 마다 호출할 필요 없음). 본 진입점들은 자체가 spawn 자식 (별개 PHP 프로세스) 이라 디스크의 fresh `ExtensionManager` 클래스를 메모리에 로드한 상태 — 직접 메서드 호출도 stale 가능성 없음.
> **단독 실행 안전성 (beta.6 이후)**: `core:execute-upgrade-steps` 는 HANDOFF 안내 또는 수동 복구 목적으로 운영자가 직접 호출되는 경로가 있다. 단독 실행 시 자식은 기본값으로 부모 Step 9 (`runMigrations` + `reloadCoreConfigAndResync`), Step 11 (`updateVersionInEnv` + `clearAllCaches`), Step 12 (번들 확장 일괄 업데이트) 를 자체적으로 수행해 단일 명령으로 업그레이드를 완결한다. 부모 `CoreUpdateCommand::spawnUpgradeStepsProcess()` 는 자식 명령 라인에 `--skip-migrations`, `--skip-resync`, `--skip-version-env`, `--skip-cache-clear`, `--skip-bundled-updates` 5개를 무조건 추가해 중복 회피한다 — 부모가 자식 종료 후 동일 단계를 직접 수행하기 때문이다.
### Step 11: 마무리
```text
+15
View File
@@ -122,6 +122,7 @@ php artisan migrate:rollback
# 코어 업데이트
php artisan core:check-updates # 코어 업데이트 확인
php artisan core:update [--force] [--no-backup] [--no-maintenance] [--vendor-mode=auto|composer|bundled]
php artisan core:execute-upgrade-steps --from=X.Y.Z --to=A.B.C [--force] # 업그레이드 스텝 단독 실행 (HANDOFF 안내 또는 수동 복구용 — 단독 호출 시 migration·resync·.env·캐시·번들 확장 일괄 업데이트 자동 수행. CoreUpdateCommand 내부 spawn 은 --skip-* 5개 옵션 자동 전달)
# 모듈
php artisan module:list
@@ -174,6 +175,19 @@ php artisan extension:composer-install # 모든 모듈+플러그인
php artisan extension:update-autoload
```
### 단발성 결함 보정 (hotfix)
`hotfix:*` prefix 는 특정 버전의 결함 회복을 위해 신설되는 단발성 도구를 위한 표준 prefix 다. `core:*` (영구 운영 도구) 와 명확히 구분되며 dev-dashboard 자동 노출 면제 대상이다.
```bash
# 코어 자동 롤백 후 활성 디렉토리에 잔존한 신 파일 진단/정리 (7.0.0-beta.6 신설)
php artisan hotfix:rollback-stale-files # 진단 모드 (실제 삭제 없음)
php artisan hotfix:rollback-stale-files --prune # 정리 (확인 프롬프트 동반)
php artisan hotfix:rollback-stale-files --backup=<경로> # 특정 백업 디렉토리 지정
```
진단 결과 / 정리 로그는 `storage/logs/hotfix_rollback_stale_files_<timestamp>.log` 에 기록.
### Vendor 번들 Artisan (공유 호스팅용 vendor/ 선탑재)
```bash
@@ -239,6 +253,7 @@ php artisan seo:generate-sitemap --sync # Sitemap 동기 생성
# 코어 업데이트
php artisan core:check-updates # 코어 업데이트 확인
php artisan core:update [--force] [--no-backup] [--no-maintenance] # 코어 업데이트 실행
php artisan core:execute-upgrade-steps --from=X.Y.Z --to=A.B.C [--force] # 업그레이드 스텝 단독 실행 (HANDOFF 안내 또는 수동 복구용)
# CLI (Artisan 커맨드)
php artisan module:check-updates [identifier?] # 모듈 업데이트 확인
+17
View File
@@ -513,6 +513,23 @@ CoreUpdateCommand::handle └─ disableMaintenanceMode
대신 사용자에게는 **스텝 전용** 명령 (`php artisan core:execute-upgrade-steps --from=<afterVersion> --to=<toVersion> --force`) 만 실행하도록 안내한다. 이 명령은 재다운로드·vendor 재설치 없이 남은 upgrade step 만 실행한다.
#### 단독 실행 시 자동 수행되는 보조 단계 (beta.6+)
`core:execute-upgrade-steps` 가 운영자에 의해 직접 호출 (HANDOFF 안내 또는 수동 복구) 되면, 부모 `CoreUpdateCommand` 가 평소 수행하던 다음 단계를 자동으로 함께 수행한다 — 단독 실행자가 별도 명령을 잇따라 실행할 필요가 없다.
- 사전 단계: `runMigrations()`, `reloadCoreConfigAndResync()` (config/core.php 재로드 + 권한/메뉴/시더 동기화)
- 사후 단계: `updateVersionInEnv($toVersion)`, `clearAllCaches()`, `runBundledExtensionUpdatePrompt()` (모듈/플러그인/템플릿/언어팩 일괄 업데이트)
부모 `CoreUpdateCommand` 가 spawn 호출하는 경로에서는 다음 5개 옵션을 모두 자식 명령 라인에 추가해 중복 회피한다 — 부모는 이미 Step 9 / Step 11 / 번들 prompt 를 자식 종료 후 수행하기 때문이다.
- `--skip-migrations`
- `--skip-resync`
- `--skip-version-env`
- `--skip-cache-clear`
- `--skip-bundled-updates`
수동 복구 시나리오에서 사용자가 부분 단계만 제어하고 싶다면 위 옵션을 선택적으로 조합한다. 옵션을 모두 부여하면 부모 spawn 시나리오와 등가 — 본 명령은 upgrade step 만 실행한다.
### 사용 시점
upgrade step 파일에서 아래 조건이 모두 성립할 때 사용한다:
+5 -5
View File
@@ -49,9 +49,9 @@
### Fixed
- 파티션 스키마 폐지 후에도 잔여 파티션 DDL 호출로 인해 신규 게시판을 만들지 못하던 문제 수정
- 파티션 스키마 폐지 후에도 잔여 파티션 DDL 호출로 인해 신규 게시판을 만들지 못하던 문제 수정 (#12 @laelbe 님께서 제보해주셨습니다.)
- 일부 권한 설정에서 비밀글·접근 제한 게시글의 이전/다음 조회 시 500 오류가 발생하던 문제 수정 — 옆 글을 찾지 못하면 빈 값을 반환하도록 변경
- 큐 워커가 실행 중이지 않은 환경에서 댓글/게시글/첨부 수가 즉시 반영되지 않던 문제 수정
- 큐 워커가 실행 중이지 않은 환경에서 댓글/게시글/첨부 수가 즉시 반영되지 않던 문제 수정 (#11 @laelbe 님께서 제보해주셨습니다.)
- CKEditor 등 임시 업로드를 거친 첨부가 게시글에 연결되어도 첨부 수가 늘어나지 않던 문제 수정
- 내가 댓글 단 게시글 활동(`activity_type=commented`) 조회 시 쿼리 오류로 500 응답을 반환하던 문제 수정
- 삭제된 게시글에 달았던 댓글이 활동 통계(`total_comments`)에 계속 포함되던 문제 수정
@@ -89,9 +89,9 @@
#### 성능 최적화
- 이전글/다음글 조회를 독립 API로 분리하여 게시글 상세 페이지 초기 로딩 쿼리 감소
- 게시글·댓글 수 등 집계 정보를 사전 계산 컬럼으로 관리하도록 개선 — 목록 조회 시 COUNT 쿼리 제거
- 게시글 본문 검색 및 통합검색에 FULLTEXT 인덱스 적용 — 대용량 데이터에서도 빠른 검색 가능
- 이전글/다음글 조회를 독립 API로 분리하여 게시글 상세 페이지 초기 로딩 쿼리 감소 (#2 @jiwonpapa 님께서 제보해주셨습니다.)
- 게시글·댓글 수 등 집계 정보를 사전 계산 컬럼으로 관리하도록 개선 — 목록 조회 시 COUNT 쿼리 제거 (#2 @jiwonpapa 님께서 제보해주셨습니다.)
- 게시글 본문 검색 및 통합검색에 FULLTEXT 인덱스 적용 — 대용량 데이터에서도 빠른 검색 가능 (#2 @jiwonpapa 님께서 제보해주셨습니다.)
### Changed
+3 -3
View File
@@ -8,8 +8,8 @@
### Added
- 페이지 수정 시 슬러그를 변경할 수 있도록 개선 (중복 확인 후 저장)
- 관리자 목록에서 슬러그 및 URL 텍스트 클릭 시 유저 화면으로 이동하는 링크 추가 (새 탭으로 열림)
- 페이지 수정 시 슬러그를 변경할 수 있도록 개선 (중복 확인 후 저장) (#16 @movielee2020 님께서 건의해주셨습니다.)
- 관리자 목록에서 슬러그 및 URL 텍스트 클릭 시 유저 화면으로 이동하는 링크 추가 (새 탭으로 열림) (#16 @movielee2020 님께서 건의해주셨습니다.)
- 활동 로그의 페이지 액션 라벨을 페이지 모듈 다국어 파일에서 관리하도록 정리 — 모듈 라벨이 자기 영역에서 자기설명되며, 일본어 등 추가 언어팩이 키 누락 없이 동기화
### Fixed
@@ -27,7 +27,7 @@
### Fixed
- 사용자 화면 버전 배지가 잘못된 형식으로 표시되던 문제 수정
- 사용자 화면 버전 배지가 잘못된 형식으로 표시되던 문제 수정 (#4 @laelbe 님께서 제보해주셨습니다.)
- 존재하지 않는 컬럼을 참조하던 draft/archived 배지 코드 제거
## [1.0.0-beta.1] - 2026-04-01
@@ -14,7 +14,7 @@
### Fixed
- CKEditor5 사용 게시판 글쓰기 화면에서 제목과 내용을 함께 입력해 저장할 때 "제목이 비어있다" 오류가 나던 문제 수정
- CKEditor5 사용 게시판 글쓰기 화면에서 제목과 내용을 함께 입력해 저장할 때 "제목이 비어있다" 오류가 나던 문제 수정 (#17 @laelbe 님께서 제보해주셨습니다.)
## [1.0.0-beta.1] - 2026-04-09
+99 -10
View File
@@ -193,7 +193,7 @@ class ValidationApi
}
// 에러 로깅
logInstallationError(lang('error_db_connection_failed'), $e);
logInstallationError(lang('error_db_connection_failed', ['error' => $e->getMessage()]), $e);
// 에러 응답 (200 OK + success: false)
echo json_encode([
@@ -778,6 +778,55 @@ class ValidationApi
], JSON_UNESCAPED_UNICODE);
}
/**
* 셸 인자로 전달하기 안전한 단일 토큰인지 검증.
*
* 셸 메타문자(공백·따옴표·리다이렉션·세미콜론·백틱·$()·| 등) 와 제어문자(NUL/CR/LF 등)
* 가 없어야 함. 백슬래시(`\`) 는 Windows 경로 구분자이므로 차단 대상이 아니다 —
* 셸 인젝션 차단은 호출자의 escapeshellarg 가 담당 (Windows 는 큰따옴표 wrapping,
* Unix 는 작은따옴표 wrapping).
*
* 파일 시스템 stat 은 호출하지 않는다 — open_basedir 등 PHP 런타임 제약
* 환경에서 정상 절대경로가 false negative 로 거부되는 회귀를 피하기 위함.
* 실제 실행 가능 여부는 exec/proc_open 결과로 최종 판정.
*/
private function isInstallerSafePathArg(string $path): bool
{
if ($path === '') {
return false;
}
return !preg_match('/[\s;`$|<>"\'&\x00-\x1F]/', $path);
}
/**
* 공백 분리 입력을 두 토큰(PHP 인터프리터 + Composer 바이너리) 으로 분해.
*
* 멀티 PHP 버전 환경(시놀로지 DSM Web Station, cPanel/Plesk multi-PHP) 에서
* `composer` 를 특정 PHP 로 실행하려는 운영 의도를 지원한다.
* 두 토큰 모두 isInstallerSafePathArg 통과해야 정상 입력으로 인정.
*
* @return array{php: string, composer: string}|null 분해 실패 시 null
*/
private function splitPhpComposerTokens(string $path): ?array
{
if (!str_contains($path, ' ')) {
return null;
}
$tokens = preg_split('/\s+/', trim($path), 2);
if (!is_array($tokens) || count($tokens) !== 2) {
return null;
}
[$php, $composer] = $tokens;
if ($php === '' || $composer === '') {
return null;
}
return ['php' => $php, 'composer' => $composer];
}
/**
* PHP 바이너리 경로 유효성 검증 헬퍼
*
@@ -790,9 +839,10 @@ class ValidationApi
return ['valid' => false, 'version' => null, 'message' => lang('error_php_path_empty')];
}
// 'php' 기본값이 아니면 반드시 실제 실행 가능한 단일 파일 경로여야 함
// (사용자 입력을 그대로 shell 에 전달하던 경로 차단)
if ($path !== 'php' && (!is_file($path) || !is_executable($path))) {
// 'php' 기본값이 아니면 셸 메타문자 차단. 파일 존재/실행 가능 검사는
// open_basedir 같은 PHP 런타임 제약 환경의 false negative 를 피하기 위해
// 생략하고, exec 결과로 최종 판정한다.
if ($path !== 'php' && !$this->isInstallerSafePathArg($path)) {
return ['valid' => false, 'version' => null, 'message' => lang('error_php_exec_failed', ['path' => $path])];
}
@@ -837,10 +887,49 @@ class ValidationApi
// 빈 문자열이면 시스템 기본 composer 사용
$effectivePath = $composerPath ?: 'composer';
// 보안: 입력값을 "전체 실행 명령어" 로 허용하던 공백 분기 제거.
// 시스템 기본('composer') 가 아니면 반드시 실행 가능한 단일 파일 경로여야 하며,
// 모든 분기에서 escapeshellarg 를 강제한다.
if ($effectivePath !== 'composer' && (!is_file($effectivePath) || !is_executable($effectivePath))) {
// 공백 분리 입력은 "PHP 절대경로 + Composer 절대경로" 의 멀티 PHP 운영 패턴.
// 두 토큰으로 분해 후 각 토큰별 메타문자 차단 + 각각 escapeshellarg 적용한다.
// 옛 raw shell 전달(escape 없는 분기) 은 복원하지 않음.
if ($effectivePath !== 'composer' && str_contains($effectivePath, ' ')) {
$tokens = $this->splitPhpComposerTokens($effectivePath);
if ($tokens === null
|| !$this->isInstallerSafePathArg($tokens['php'])
|| !$this->isInstallerSafePathArg($tokens['composer'])
) {
return [
'valid' => false,
'version' => null,
'message' => lang('error_composer_exec_failed', ['path' => $effectivePath]),
];
}
$command = escapeshellarg($tokens['php']) . ' ' . escapeshellarg($tokens['composer']) . ' --version 2>&1';
$output = [];
$returnCode = -1;
applyInstallerComposerEnvVars();
exec($command, $output, $returnCode);
if ($returnCode !== 0) {
return ['valid' => false, 'version' => null, 'message' => lang('error_composer_exec_failed', ['path' => $effectivePath])];
}
$outputStr = implode("\n", $output);
if (preg_match('/Composer\s+(?:version\s+)?(\d+\.\d+\.\d+)/', $outputStr, $matches)) {
$version = $matches[1];
return [
'valid' => true,
'version' => $version,
'message' => lang('success_composer_version', ['path' => $effectivePath, 'version' => $version]),
];
}
return ['valid' => false, 'version' => null, 'message' => lang('error_composer_version_parse_failed')];
}
// 단일 토큰 — 시스템 기본('composer') 가 아니면 셸 메타문자 차단.
// 파일 존재/실행 가능 검사는 open_basedir 환경의 false negative 회피를 위해 생략.
if ($effectivePath !== 'composer' && !$this->isInstallerSafePathArg($effectivePath)) {
return [
'valid' => false,
'version' => null,
@@ -850,8 +939,8 @@ class ValidationApi
// .phar 파일이면 PHP 바이너리와 결합
if (str_ends_with($effectivePath, '.phar')) {
// phpPath 도 동일한 가드 — 'php' 기본값이 아니면 실행 가능 파일이어야 함
if ($phpPath !== 'php' && (!is_file($phpPath) || !is_executable($phpPath))) {
// phpPath 도 동일한 가드 — 'php' 기본값이 아니면 메타문자 없는 단일 토큰이어야 함
if ($phpPath !== 'php' && !$this->isInstallerSafePathArg($phpPath)) {
return [
'valid' => false,
'version' => null,
+92
View File
@@ -0,0 +1,92 @@
<?php
/**
* 인스톨러 세션 쿠키 round-trip 사전 진단 endpoint
*
* Step 0 (welcome) 진입 시 클라이언트가 set → verify 두 fetch 로 세션 쿠키가
* 브라우저에 의해 보존되는지 확인한다. 브라우저가 PHPSESSID 쿠키를 차단/유실
* 하여 세션이 매 요청 새로 생성되는 케이스 (`session.cookie_samesite=Strict`
* + 비표준 포트 + 일부 브라우저 정책 조합) 를 사전 감지한다.
*
* 엔드포인트:
* - GET ?action=set : 세션에 nonce 저장 후 nonce 반환
* - GET ?action=verify : 세션의 nonce 를 반환하고 세션에서 제거 (재사용 방지)
*
* 본 진단으로 "설치하기" 버튼 자체를 차단하지 않는다 — 경고 배너만 표시한다.
*/
/**
* set 액션 — 세션에 nonce 저장 후 응답 데이터 반환
*
* @return array{action: string, nonce: string}
*/
function sessionProbeSet(): array
{
$nonce = bin2hex(random_bytes(16));
$_SESSION['_installer_session_probe'] = $nonce;
return [
'action' => 'set',
'nonce' => $nonce,
];
}
/**
* verify 액션 — 세션의 nonce 를 반환하고 세션에서 제거
*
* 세션에 nonce 가 보존되어 있으면 matched=true. 세션이 빈 상태(쿠키 round-trip
* 실패) 면 matched=false. 한 번 verify 한 nonce 는 재사용 방지를 위해 세션에서
* 제거한다.
*
* @return array{action: string, matched: bool, nonce?: string}
*/
function sessionProbeVerify(): array
{
if (! isset($_SESSION['_installer_session_probe'])) {
return [
'action' => 'verify',
'matched' => false,
];
}
$nonce = $_SESSION['_installer_session_probe'];
unset($_SESSION['_installer_session_probe']);
return [
'action' => 'verify',
'matched' => true,
'nonce' => $nonce,
];
}
// 라이브러리 모드: 테스트 등에서 함수 정의만 로드. SESSION_PROBE_LIBRARY 상수가 정의되어 있으면 즉시 종료.
$sessionProbeLibraryMode = defined('SESSION_PROBE_LIBRARY') && constant('SESSION_PROBE_LIBRARY');
if (! $sessionProbeLibraryMode) {
// 정식 진입점 — config / session / functions / guard 로드
require_once __DIR__ . '/../includes/config.php';
require_once __DIR__ . '/../includes/session.php';
require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/_guard.php';
installer_guard_or_410();
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate');
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['error' => 'GET method required'], JSON_UNESCAPED_UNICODE);
exit;
}
$action = $_GET['action'] ?? '';
if ($action === 'set') {
echo json_encode(sessionProbeSet(), JSON_UNESCAPED_UNICODE);
} elseif ($action === 'verify') {
echo json_encode(sessionProbeVerify(), JSON_UNESCAPED_UNICODE);
} else {
http_response_code(400);
echo json_encode(['error' => 'Invalid action — use ?action=set or ?action=verify'], JSON_UNESCAPED_UNICODE);
}
}
+1 -1
View File
@@ -385,7 +385,7 @@
// 6. HTTPS 카드
const httpsStatusClass = data.https.enabled ? 'status-pass' : 'status-warning';
html += renderSingleItemCard(
lang('https_enabled'),
lang('https'),
httpsStatusClass,
data.https.enabled ? lang('enabled') : lang('not_enabled'),
'',
+5 -1
View File
@@ -11,7 +11,11 @@ if (session_status() === PHP_SESSION_NONE) {
// 세션 설정
ini_set('session.cookie_httponly', '1');
ini_set('session.use_strict_mode', '1');
ini_set('session.cookie_samesite', 'Strict');
// SameSite=Lax — top-level navigation 의 동일 사이트 POST 에 쿠키 동반 전송.
// CSRF 방어는 별도 csrf_token 검증이 담당하므로 Strict 대신 Lax 로 완화하여
// 비표준 포트 / dynamic DNS 도메인 / 일부 브라우저 정책 조합에서 PHPSESSID
// 가 차단되어 Step 0 무한 루프에 빠지던 회귀를 차단.
ini_set('session.cookie_samesite', 'Lax');
// HTTPS 환경에서는 secure 쿠키 사용
if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
+67 -6
View File
@@ -140,22 +140,54 @@ if (!function_exists('getPhpBinary')) {
if (!function_exists('isInstallerExecutablePath')) {
/**
* 인스톨러가 exec 에 전달하기 안전한 단일 실행 파일 경로인지 검증한다.
* 인스톨러가 exec 에 전달하기 안전한 단일 토큰 경로인지 검증한다.
*
* - 빈 문자열은 호출자가 시스템 기본값을 쓰겠다는 신호이므로 별도 처리.
* - 공백/세미콜론/백틱/`$` 등 셸 메타문자가 포함된 입력은 거부.
* - 실제로 존재하고 실행 가능한 파일이어야 함.
* - 파일 존재/실행 가능 검사는 open_basedir 같은 PHP 런타임 제약 환경의
* false negative 를 피하기 위해 생략. 실제 실행 가능 여부는 exec 결과로 판정.
*/
function isInstallerExecutablePath(string $path): bool
{
if ($path === '') {
return false;
}
// 셸 메타문자 차단 — 공백·따옴표·리다이렉션·세미콜론·백틱·$()·| 등
if (preg_match('/[\s;`$|<>"\'\\\\&]/', $path)) {
// 셸 메타문자 + 제어문자 차단. 백슬래시는 Windows 경로 구분자이므로 차단 대상 아님 —
// 셸 인젝션 차단은 호출자의 escapeshellarg 가 담당.
if (preg_match('/[\s;`$|<>"\'&\x00-\x1F]/', $path)) {
return false;
}
return is_file($path) && is_executable($path);
return true;
}
}
if (!function_exists('splitInstallerPhpComposerTokens')) {
/**
* 공백 분리 입력을 "PHP 인터프리터 절대경로 + Composer 바이너리 절대경로" 두 토큰으로 분해.
*
* 멀티 PHP 버전 환경(시놀로지 DSM Web Station, cPanel/Plesk multi-PHP) 의
* 운영 의도를 지원한다. 두 토큰 모두 isInstallerExecutablePath 통과해야
* 정상 입력으로 간주.
*
* @return array{php: string, composer: string}|null 분해 실패 시 null
*/
function splitInstallerPhpComposerTokens(string $path): ?array
{
if (!str_contains($path, ' ')) {
return null;
}
$tokens = preg_split('/\s+/', trim($path), 2);
if (!is_array($tokens) || count($tokens) !== 2) {
return null;
}
[$php, $composer] = $tokens;
if ($php === '' || $composer === '') {
return null;
}
return ['php' => $php, 'composer' => $composer];
}
}
@@ -169,6 +201,19 @@ if (!function_exists('getComposerCommand')) {
return 'composer';
}
// 공백 분리 입력 — 두 토큰으로 분해 후 각각 검증/escape.
// 멀티 PHP 환경에서 특정 PHP 인터프리터로 composer 를 실행하려는 운영 의도 지원.
if (str_contains($composerBinary, ' ')) {
$tokens = splitInstallerPhpComposerTokens($composerBinary);
if ($tokens === null
|| !isInstallerExecutablePath($tokens['php'])
|| !isInstallerExecutablePath($tokens['composer'])
) {
return 'composer';
}
return escapeshellarg($tokens['php']) . ' ' . escapeshellarg($tokens['composer']);
}
// 검증 실패 시 시스템 기본 'composer' 로 폴백 — 설치 흐름은 유지하되
// 사용자 입력이 셸 명령으로 흘러가지 않도록 차단.
if (!isInstallerExecutablePath($composerBinary)) {
@@ -193,7 +238,23 @@ if (!function_exists('getComposerCommandForDisplay')) {
$state = getInstallationState();
$composerBinary = (string) ($state['config']['composer_binary'] ?? '');
if ($composerBinary === '' || !isInstallerExecutablePath($composerBinary)) {
if ($composerBinary === '') {
return 'composer';
}
// 공백 분리 입력 — 토큰 검증 통과 시 사람 친화적 표기로 그대로 노출.
if (str_contains($composerBinary, ' ')) {
$tokens = splitInstallerPhpComposerTokens($composerBinary);
if ($tokens === null
|| !isInstallerExecutablePath($tokens['php'])
|| !isInstallerExecutablePath($tokens['composer'])
) {
return 'composer';
}
return $tokens['php'] . ' ' . $tokens['composer'];
}
if (!isInstallerExecutablePath($composerBinary)) {
return 'composer';
}
+9 -4
View File
@@ -51,7 +51,7 @@ return [
'php_modules' => 'PHP Modules',
'directory_permissions' => 'Directory Permissions',
'disk_space' => 'Disk Space',
'https_enabled' => 'HTTPS Enabled',
'https' => 'HTTPS',
'required' => 'Required',
'enabled' => 'Enabled',
'not_enabled' => 'Not Enabled',
@@ -164,7 +164,6 @@ 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_connection_failed' => 'Database connection failed.',
'error_db_privileges_insufficient' => 'Insufficient database privileges.',
'error_db_not_tested' => 'Please test the database connection first.',
'error_write_db_not_tested' => 'Please test Write DB connection first.',
@@ -606,7 +605,6 @@ Firewalls or proxies may be blocking long-lived HTTP connections.',
'error_db_cleanup_consent_required' => 'You must consent to dropping existing tables before proceeding to the next step.',
'db_force_proceed_drop' => 'Drop all existing tables and install',
'db_force_proceed_confirmed' => 'Force install mode (existing tables will be dropped)',
'cancel' => 'Cancel',
'log_db_cleanup_skipped' => 'Skipping existing table cleanup (no action)',
'log_db_cleanup_empty' => 'No existing tables. Skipping cleanup.',
'log_db_cleanup_dropping' => 'Dropping {count} existing tables...',
@@ -764,7 +762,6 @@ Firewalls or proxies may be blocking long-lived HTTP connections.',
'abort_api_status_change' => '[Abort API] Changing installation_status to "aborted".',
'abort_api_save_result' => '[Abort API] state.json save result: :result',
'abort_api_verify_status' => '[Abort API] Verify after save - installation_status: :status',
'api_method_not_allowed' => 'Method not allowed.',
'error_state_management' => 'State management error',
'error_log_prefix' => '[Error] :error',
@@ -856,6 +853,14 @@ Firewalls or proxies may be blocking long-lived HTTP connections.',
'error_composer_path_not_exists' => 'File does not exist: :path',
'error_composer_exec_failed' => 'Composer execution failed: :path',
'error_composer_version_parse_failed' => 'Failed to parse Composer version.',
'error_check_failed' => 'Check request failed. Please verify network or server status.',
'session_cookie_blocked_title' => 'Session Cookie Blocked',
'session_cookie_blocked_description' => 'Your browser is not preserving the installer session cookie (PHPSESSID), which may cause the installation to stall. You may proceed but it is not recommended — please try one of the workarounds below.',
'session_cookie_blocked_remedy_1' => 'Try a different browser. Incognito/Private windows usually have stricter cookie policies — a regular window is recommended.',
'session_cookie_blocked_remedy_2' => 'Disable cookie/site-data blocking in your browser and refresh the page.',
'session_cookie_blocked_remedy_3' => 'Access via IP or localhost instead of a domain name. Example: http://localhost:port/install/',
'session_cookie_blocked_technical_detail' => 'Technical detail: Installer session cookie round-trip verification failed. (Likely caused by SameSite policy, non-standard port, browser tracking protection, or similar combination.)',
'session_cookie_blocked_dismiss' => 'Dismiss warning',
'success_composer_version' => ':path — Composer :version ✓',
'composer_install_guide_title' => 'Composer Installation Guide',
'composer_install_guide_message' => 'Composer is not installed. You can install it using the following methods:',
+9 -4
View File
@@ -51,7 +51,7 @@ return [
'php_modules' => 'PHP 모듈',
'directory_permissions' => '디렉토리 권한',
'disk_space' => '디스크 공간',
'https_enabled' => 'HTTPS 활성화',
'https' => 'HTTPS',
'required' => '필수',
'enabled' => '활성화됨',
'not_enabled' => '비활성화됨',
@@ -164,7 +164,6 @@ return [
'error_db_name_required' => '데이터베이스 이름은 필수입니다.',
'error_db_username_required' => '데이터베이스 사용자명을 입력해주세요.',
'error_db_credentials_required' => '데이터베이스명과 사용자명은 필수입니다.',
'error_db_connection_failed' => '데이터베이스 연결에 실패했습니다.',
'error_db_privileges_insufficient' => '데이터베이스에 필요한 권한이 부족합니다.',
'error_db_not_tested' => '데이터베이스 연결 테스트를 먼저 수행해주세요.',
'error_write_db_not_tested' => 'Write DB 연결 테스트를 먼저 수행해주세요.',
@@ -606,7 +605,6 @@ ini_set(\'zlib.output_compression\', \'off\');
'error_db_cleanup_consent_required' => '기존 테이블 삭제에 동의하셔야 다음 단계로 진행할 수 있습니다.',
'db_force_proceed_drop' => '기존 테이블 모두 삭제 후 설치',
'db_force_proceed_confirmed' => '강제 진행 모드 (설치 시 기존 테이블 삭제)',
'cancel' => '취소',
'log_db_cleanup_skipped' => '기존 테이블 정리 건너뛰기 (액션 없음)',
'log_db_cleanup_empty' => '기존 테이블이 없습니다. 정리 건너뛰기',
'log_db_cleanup_dropping' => '기존 테이블 {count}개 삭제 시작...',
@@ -764,7 +762,6 @@ ini_set(\'zlib.output_compression\', \'off\');
'abort_api_status_change' => '[중단 API] installation_status를 "aborted"로 변경합니다.',
'abort_api_save_result' => '[중단 API] state.json 저장 결과: :result',
'abort_api_verify_status' => '[중단 API] 저장 후 확인 - installation_status: :status',
'api_method_not_allowed' => '허용되지 않는 메서드입니다.',
'error_state_management' => '상태 관리 오류',
'error_log_prefix' => '[오류] :error',
@@ -856,6 +853,14 @@ ini_set(\'zlib.output_compression\', \'off\');
'error_composer_path_not_exists' => '파일이 존재하지 않습니다: :path',
'error_composer_exec_failed' => 'Composer 실행 실패: :path',
'error_composer_version_parse_failed' => 'Composer 버전을 파싱할 수 없습니다.',
'error_check_failed' => '확인 요청에 실패했습니다. 네트워크 또는 서버 상태를 확인해 주세요.',
'session_cookie_blocked_title' => '세션 쿠키 차단 감지',
'session_cookie_blocked_description' => '브라우저가 인스톨러의 세션 쿠키(PHPSESSID)를 보존하지 않아 설치 도중 진행이 멈출 수 있습니다. 그대로 진행은 가능하지만 권장하지 않으며, 아래 회피 방법 중 하나를 시도해 주세요.',
'session_cookie_blocked_remedy_1' => '다른 브라우저로 다시 접속해 주세요. 시크릿/프라이빗 창은 보통 쿠키 정책이 더 엄격하므로 일반 창 사용을 권장합니다.',
'session_cookie_blocked_remedy_2' => '브라우저의 쿠키/사이트 데이터 차단 설정을 해제한 뒤 새로고침 해 주세요.',
'session_cookie_blocked_remedy_3' => '도메인 대신 IP 또는 localhost 로 접속해 보세요. 예: http://localhost:포트/install/',
'session_cookie_blocked_technical_detail' => '기술 상세: 인스톨러 세션 쿠키 round-trip 검증이 실패했습니다. (SameSite 정책, 비표준 포트, 브라우저 추적 보호 등의 조합으로 인한 차단 추정)',
'session_cookie_blocked_dismiss' => '경고 닫기',
'success_composer_version' => ':path — Composer :version ✓',
'composer_install_guide_title' => 'Composer 설치 안내',
'composer_install_guide_message' => 'Composer가 설치되어 있지 않습니다. 아래 방법으로 설치할 수 있습니다:',
+59
View File
@@ -169,6 +169,28 @@ $storageResult = $storageCheck['results']['storage'] ?? null;
</div>
<?php endif; ?>
<!-- 세션 쿠키 차단 경고 배너 (round-trip 검증 실패 시 JS 가 표시) -->
<div id="session-cookie-blocked-banner"
class="alert alert-warning"
role="alert"
style="display:none;margin-bottom:1rem;">
<div class="alert-header" style="display:flex;justify-content:space-between;align-items:center;">
<strong><?= lang('session_cookie_blocked_title') ?></strong>
<button type="button"
onclick="document.getElementById('session-cookie-blocked-banner').style.display='none';"
class="btn-close"
aria-label="<?= lang('session_cookie_blocked_dismiss') ?>"
style="background:transparent;border:0;cursor:pointer;font-size:1.25rem;">×</button>
</div>
<p><?= lang('session_cookie_blocked_description') ?></p>
<ol>
<li><?= lang('session_cookie_blocked_remedy_1') ?></li>
<li><?= lang('session_cookie_blocked_remedy_2') ?></li>
<li><?= lang('session_cookie_blocked_remedy_3') ?></li>
</ol>
<p style="font-size:0.85rem;opacity:0.75;"><?= lang('session_cookie_blocked_technical_detail') ?></p>
</div>
<!-- 언어 선택 및 다음 버튼 폼 -->
<form method="POST" id="language-form">
<div class="form-group mb-4">
@@ -297,4 +319,41 @@ function copyWelcomeCommand(btn) {
setTimeout(function() { btn.textContent = originalText; }, 2000);
});
}
/**
* 세션 쿠키 round-trip 사전 진단
*
* Step 0 진입 시 1회 실행. set 으로 세션에 nonce 저장 → verify 로 같은 세션의
* nonce 복원 여부 확인. 실패 시 경고 배너 표시 (설치 진행은 차단하지 않음).
*
* `installation_status` 가 `not_started` 일 때만 검증 — 이미 설치 진행 중인
* 화면 새로고침 시 nonce 가 verify 단계에서 소비되면 후속 검증이 영향 받지
* 않도록.
*/
document.addEventListener('DOMContentLoaded', async function() {
try {
const baseUrl = window.INSTALLER_BASE_URL || '/install';
const setResp = await fetch(baseUrl + '/api/session-probe.php?action=set', {
credentials: 'same-origin',
cache: 'no-store',
});
if (!setResp.ok) return;
const verifyResp = await fetch(baseUrl + '/api/session-probe.php?action=verify', {
credentials: 'same-origin',
cache: 'no-store',
});
if (!verifyResp.ok) return;
const verifyData = await verifyResp.json();
if (verifyData && verifyData.matched === false) {
const banner = document.getElementById('session-cookie-blocked-banner');
if (banner) {
banner.style.display = '';
}
}
} catch (e) {
// 네트워크 오류 / endpoint 미존재 — silent (Step 0 진행 자체에는 영향 없음)
}
});
</script>
@@ -4,12 +4,18 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0-beta.6] - 2026-05-14
### Fixed
- 환경설정 본인인증 메시지 탭에서 조회 권한만 가진 운영자에게 편집 / 기본값 복원 / 활성 토글 버튼이 노출·작동하던 결함 수정. 수정 권한 보유 여부에 따라 자동 비활성화되어 PATCH/DELETE 요청을 트리거할 수 없도록 가드합니다.
- "정의 추가" 버튼이 수정 권한 보유 운영자에게도 항상 비활성화되던 결함 수정 — 정의 목록 응답에 추가 권한 키가 포함되도록 보강하여 권한에 맞춰 정확히 활성화/비활성화됩니다.
## [1.0.0-beta.5] - 2026-05-12
### Added
- 환경설정 본인인증 정책 탭에 권한 기반 버튼 비활성화 적용 — "정책 추가" / "수정" / "삭제" / "활성 토글" 버튼이 운영자의 수정 권한 보유 여부에 따라 자동 비활성화
- 환경설정 본인인증 메시지 탭 "정의 추가" 버튼에도 권한 기반 비활성화 적용
- 본인인증 권한이 조회/수정으로 분리됨에 따라, 조회 권한만 있는 운영자도 환경설정 본인인증 화면에 정상 진입 가능 (코어 권한 변경 연동)
### Fixed
@@ -29,7 +35,7 @@
- 환경설정 → 본인인증 탭에 "메시지 템플릿" 서브탭 추가 — 알림 템플릿 관리와 동일한 UX (채널 서브탭·페이지당 항목 수 셀렉터·카드 펼침 본문 미리보기·활성/기본 배지·페이지네이션) 로 정의 목록·활성 토글·다국어 편집(변수 가이드 + 기본값 복원) 제공
- 운영자가 추가한 본인인증 정책에 묶인 메일 메시지 정의를 화면에서 직접 추가/삭제 가능 — 시드 기본값 정의는 보호되어 삭제 불가, 신규 등록은 운영자 정책 키와 매칭될 때만 허용
- 모듈/플러그인 제거 모달의 "삭제될 데이터" 에 코어 공유 테이블 영역(권한·관리자 메뉴·알림 정의·본인인증 정책·본인인증 메시지 정의) 도 표시되어 운영자가 사라지는 데이터를 정확히 파악할 수 있도록 함
- 관리자 로그인 화면에 세션 만료 안내 토스트 추가 — 토큰 만료로 자동 리다이렉트된 관리자에게 "세션이 만료되었습니다. 다시 로그인해 주세요." 메시지 표시 (`?reason=session_expired` 쿼리 파라미터 감지)
- 관리자 로그인 화면에 세션 만료 안내 토스트 추가 — 토큰 만료로 자동 리다이렉트된 관리자에게 "세션이 만료되었습니다. 다시 로그인해 주세요." 메시지 표시 (`?reason=session_expired` 쿼리 파라미터 감지) (#19 @abc101 님께서 건의해주셨습니다.)
- 언어팩 관리 화면에서 검색 버튼 + Enter 키로 즉시 검색 가능, 검색/스코프/상태 필터가 URL 쿼리에 동기화되어 새로고침·공유 시 동일한 필터 상태 유지
- 환경설정 > SEO 탭의 Sitemap 카드에 마지막 생성 시각 표시와 "지금 생성" 버튼 추가 — IP 기반 타임존 감지 카드와 동일한 방식으로 즉시 재생성
@@ -66,10 +72,10 @@
### Fixed
- 알림 레이어를 닫을 때 목록이 읽음 상태로 갱신되지 않아 재토글 시 읽음 처리가 누락되던 문제 수정
- 알림 레이어 무한 스크롤 시 "안 읽은 알림만" 필터가 유지되지 않아 읽은 알림이 섞여 노출되던 문제 수정
- 알림 레이어 무한 스크롤 시 동일 페이지 API 가 중복 호출되던 문제 수정
- 안 읽은 알림이 없는데도 알림 레이어를 닫을 때 읽음 처리 API 가 불필요하게 호출되던 문제 수정
- 알림 레이어를 닫을 때 목록이 읽음 상태로 갱신되지 않아 재토글 시 읽음 처리가 누락되던 문제 수정 (#14 @laelbe 님께서 제보해주셨습니다.)
- 알림 레이어 무한 스크롤 시 "안 읽은 알림만" 필터가 유지되지 않아 읽은 알림이 섞여 노출되던 문제 수정 (#14 @laelbe 님께서 제보해주셨습니다.)
- 알림 레이어 무한 스크롤 시 동일 페이지 API 가 중복 호출되던 문제 수정 (#14 @laelbe 님께서 제보해주셨습니다.)
- 안 읽은 알림이 없는데도 알림 레이어를 닫을 때 읽음 처리 API 가 불필요하게 호출되던 문제 수정 (#14 @laelbe 님께서 제보해주셨습니다.)
- 알림 드롭다운이 화면 경계를 벗어나지 않도록 자동으로 좌/우 정렬 전환 및 최대 너비 제한
## [1.0.0-beta.2] - 2026-04-20
@@ -92,7 +98,7 @@
- 코어 최소 요구 버전을 7.0.0-beta.2 로 상향
- TabNavigation 컴포넌트를 반응형으로 개선 — 768px 미만에서 Select 드롭다운으로 전환
- 성능 테스트 fixture 파일(~19MB) 저장소에서 제거 — 테스트 실행 시 자동 생성/삭제로 전환
- 성능 테스트 fixture 파일(~19MB) 저장소에서 제거 — 테스트 실행 시 자동 생성/삭제로 전환 (#1 @jiwonpapa 님께서 건의해주셨습니다.)
- 알림 템플릿 편집 모달 다국어 탭을 동적 생성으로 전환
- 언어 선택 UI를 하드코딩에서 `localeNames` 기반 동적 생성으로 전환
- 알림 발송 이력 레이아웃 재구성 — DataGrid 구조, 필터 분리, 일괄 삭제, expandable 상세보기
@@ -215,6 +215,64 @@ describe('IDV 메시지 정의 탭 — 알림 템플릿 패리티 (#297)', () =>
});
});
describe('권한 가드 — 조회 권한 사용자가 편집/Toggle/Reset/삭제 액션 미실행 (#361)', () => {
// 본인인증 메시지 API 는 별도 권한 `core.admin.identity.messages.update` 를 사용한다.
// settingsAbilities(_global) 는 `core.settings.update` 기반이므로 IDV 메시지 가드로 부적절.
// IDV 메시지 가드 SSoT 는 컬렉션/per-item abilities 가 발행하는
// identityMessages?.data?.abilities (컬렉션) 와 def.abilities (per-item) 이다.
it('"정의 추가" Button — 컬렉션 abilities.can_create 가드 (조회 권한 사용자에서 disabled)', () => {
const addBtns = collectNodes(tabPartial, (n) =>
n.name === 'Button' && (n.children ?? []).some((c: any) => c.text === '$t:admin.settings.identity.messages.btn_add_definition')
);
expect(addBtns.length).toBe(1);
const disabledExpr = addBtns[0].props?.disabled;
expect(disabledExpr, '"정의 추가" Button에 disabled 가드 누락').toBeTruthy();
expect(disabledExpr).toContain('identityMessages?.data?.abilities?.can_create');
});
it('Toggle — disabled 에 def.abilities?.can_update 가드 (조회 권한 사용자가 활성 토글 클릭 불가)', () => {
const toggles = collectNodes(tabPartial, (n) => n.name === 'Toggle');
expect(toggles.length).toBeGreaterThan(0);
const disabledExpr = toggles[0].props?.disabled;
expect(disabledExpr, 'Toggle 에 disabled 가드 누락 (조회 권한 사용자가 PATCH 호출 가능)').toBeTruthy();
expect(disabledExpr).toContain('def.abilities?.can_update');
});
it('편집 Button — disabled 에 def.abilities?.can_update 가드 (조회 권한 사용자가 편집 모달 미오픈)', () => {
const editBtns = collectNodes(tabPartial, (n) =>
n.name === 'Button' && n.text === '$t:admin.settings.identity.messages.edit'
);
expect(editBtns.length).toBeGreaterThan(0);
const disabledExpr = editBtns[0].props?.disabled;
expect(disabledExpr, '편집 Button 에 disabled 가드 누락').toBeTruthy();
expect(disabledExpr).toContain('def.abilities?.can_update');
});
it('기본값 복원 Button — disabled 에 def.abilities?.can_update 가드', () => {
const resetBtns = collectNodes(tabPartial, (n) =>
n.name === 'Button' && n.text === '$t:admin.settings.identity.messages.btn_reset'
);
expect(resetBtns.length).toBe(1);
const disabledExpr = resetBtns[0].props?.disabled;
expect(disabledExpr, '기본값 복원 Button 에 disabled 가드 누락').toBeTruthy();
expect(disabledExpr).toContain('def.abilities?.can_update');
});
it('편집/복원 Button className — disabled 시 시각 표시 (opacity-50 + cursor-not-allowed)', () => {
const guardedButtons = collectNodes(tabPartial, (n) => {
if (n.name !== 'Button') return false;
return n.text === '$t:admin.settings.identity.messages.edit'
|| n.text === '$t:admin.settings.identity.messages.btn_reset'
|| (n.children ?? []).some((c: any) => c.text === '$t:admin.settings.identity.messages.btn_add_definition');
});
guardedButtons.forEach((btn) => {
expect(btn.props?.className ?? '').toMatch(/disabled:opacity-50/);
expect(btn.props?.className ?? '').toMatch(/disabled:cursor-not-allowed/);
});
});
});
describe('상위 레이아웃 통합', () => {
it('admin_settings.json data_sources 에 identityMessages 페이지네이션 params + adminIdentityPolicies 정의', () => {
const idMsgs = adminSettings.data_sources.find((d: any) => d.id === 'identityMessages');
@@ -366,7 +366,8 @@
"name": "Toggle",
"props": {
"size": "sm",
"checked": "{{def.is_active}}"
"checked": "{{def.is_active}}",
"disabled": "{{!(def.abilities?.can_update ?? false)}}"
},
"actions": [
{
@@ -391,7 +392,8 @@
"name": "Button",
"props": {
"type": "button",
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors"
"disabled": "{{!(def.abilities?.can_update ?? false)}}",
"className": "text-sm px-3 py-1.5 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600"
},
"text": "$t:admin.settings.identity.messages.edit",
"actions": [
@@ -436,7 +438,8 @@
"if": "{{!def.is_default}}",
"props": {
"type": "button",
"className": "text-sm px-3 py-1.5 rounded-md border border-orange-300 dark:border-orange-600 text-orange-700 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/30 transition-colors"
"disabled": "{{!(def.abilities?.can_update ?? false)}}",
"className": "text-sm px-3 py-1.5 rounded-md border border-orange-300 dark:border-orange-600 text-orange-700 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent"
},
"text": "$t:admin.settings.identity.messages.btn_reset",
"actions": [
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sirsoft-admin_basic",
"version": "1.0.0-beta.4",
"version": "1.0.0-beta.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sirsoft-admin_basic",
"version": "1.0.0-beta.4",
"version": "1.0.0-beta.6",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -1,6 +1,6 @@
{
"name": "sirsoft-admin_basic",
"version": "1.0.0-beta.5",
"version": "1.0.0-beta.6",
"description": "Gnuboard7 Basic Admin Template Components",
"type": "module",
"main": "dist/components.js",
@@ -5,7 +5,7 @@
"ko": "Admin Basic",
"en": "Admin Basic"
},
"version": "1.0.0-beta.5",
"version": "1.0.0-beta.6",
"license": "MIT",
"description": {
"ko": "그누보드7 기본 관리자 템플릿",
@@ -19,12 +19,12 @@
- 외부 본인인증 provider 플러그인이 자기 SDK UI 를 주입할 수 있는 슬롯 도입 — 다음 우편번호 / WYSIWYG 에디터 와 동일한 G7 표준 패턴으로 KCP·PortOne·토스인증·Stripe Identity 등 추가 가능
- 본인인증 챌린지 화면 (`/identity/challenge`) — render_hint 에 따라 OTP 코드 입력 / 이메일 링크 안내 / 외부 본인인증 리다이렉트 3종 분기. 만료 카운트다운(분:초), 남은 시도 횟수, 30초 재전송 쿨다운, live region 접근성 지원. 모달과 동일한 결과 통보 흐름을 사용하여 코어 인터셉터의 launcher 폴백과 일관
- 회원가입 폼이 쿼리 파라미터로 전달된 `verification_token` 을 서버로 자동 전송하도록 개선 — 코어 본인인증 인프라(Mode B) 연동
- 로그인 화면에 세션 만료 안내 토스트 추가 — 토큰 만료로 자동 리다이렉트된 사용자에게 "세션이 만료되었습니다. 다시 로그인해 주세요." 메시지 표시 (`?reason=session_expired` 쿼리 파라미터 감지)
- 로그인 화면에 세션 만료 안내 토스트 추가 — 토큰 만료로 자동 리다이렉트된 사용자에게 "세션이 만료되었습니다. 다시 로그인해 주세요." 메시지 표시 (`?reason=session_expired` 쿼리 파라미터 감지) (#19 @abc101 님께서 건의해주셨습니다.)
### Fixed
- 사용자 프로필/게시글 목록/마이페이지에서 댓글 수가 0으로 표시되던 문제 수정
- 게시글 상세의 댓글/답글/첨부파일 헤더 카운트가 실제 수와 어긋나던 문제 수정
- 사용자 프로필/게시글 목록/마이페이지에서 댓글 수가 0으로 표시되던 문제 수정 (#11 @laelbe 님께서 제보해주셨습니다.)
- 게시글 상세의 댓글/답글/첨부파일 헤더 카운트가 실제 수와 어긋나던 문제 수정 (#11 @laelbe 님께서 제보해주셨습니다.)
- 비로그인 상태에서 사용자 공개 프로필 페이지 접근 시 페이지가 표시되지 않던 문제 수정
- 주문 완료 페이지의 "이 배송지를 주소록에 저장" 버튼이 작동 불가 상태였던 문제 수정 — 호출 URL 누락으로 요청이 발송되지 않고 성공/실패 토스트도 표시되지 않던 회귀 해소
@@ -5,6 +5,7 @@ namespace Tests\Feature\Api\Admin\Identity;
use App\Models\IdentityMessageDefinition;
use App\Models\IdentityMessageTemplate;
use App\Models\IdentityPolicy;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Database\Seeders\IdentityMessageDefinitionSeeder;
@@ -270,4 +271,68 @@ class AdminIdentityMessageDefinitionAdminCrudTest extends TestCase
$response->assertStatus(404);
}
/**
* 컬렉션 응답에 can_create abilities 키가 발행된다 (운영자/슈퍼관리자).
*
* 회귀 (#361): 컬렉션 abilityMap 이 can_update 만 발행하던 시기에는
* 레이아웃의 "정의 추가" 버튼이 `abilities?.can_create !== true` 가드로
* 항상 disabled 되었다. 컬렉션 abilityMap 보강 검증.
*/
public function test_collection_publishes_can_create_ability(): void
{
$response = $this->authRequest()
->getJson('/api/admin/identity/messages/definitions');
$response->assertStatus(200);
$response->assertJsonPath('data.abilities.can_create', true);
$response->assertJsonPath('data.abilities.can_update', true);
}
/**
* 조회 권한(`*.read`)만 부여된 사용자는 컬렉션 abilities + per-item abilities 가
* 모두 false 로 발행되어, 프론트에서 편집/Toggle/Reset/삭제/정의 추가 액션을
* 잠가야 한다.
*
* 회귀 (#361): 레이아웃이 abilities 가드 없이 항상 버튼을 노출하던 결함.
*/
public function test_read_only_user_receives_falsey_abilities(): void
{
$readPermission = Permission::where('identifier', 'core.admin.identity.messages.read')->firstOrFail();
$viewerRole = Role::create([
'identifier' => 'idv-msg-viewer',
'name' => ['ko' => 'IDV 메시지 조회 전용', 'en' => 'IDV Message Viewer'],
'description' => ['ko' => '본인인증 메시지 조회 전용', 'en' => 'IDV Message read-only'],
'is_system' => false,
]);
$viewerRole->permissions()->attach($readPermission->id);
$viewer = User::factory()->create(['is_super' => false]);
$viewer->roles()->attach($viewerRole->id, [
'assigned_at' => now(),
'assigned_by' => null,
]);
$viewerToken = $viewer->createToken('viewer-token')->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$viewerToken,
'Accept' => 'application/json',
])->getJson('/api/admin/identity/messages/definitions');
$response->assertStatus(200);
// 컬렉션 abilities — 정의 추가 가드용
$response->assertJsonPath('data.abilities.can_create', false);
$response->assertJsonPath('data.abilities.can_update', false);
// per-item abilities — 편집/Toggle/Reset/삭제 가드용
$rows = $response->json('data.data');
$this->assertNotEmpty($rows);
foreach ($rows as $row) {
$this->assertSame(false, $row['abilities']['can_update'] ?? null,
"Read-only user must not receive can_update=true (def {$row['provider_id']}/{$row['scope_value']})");
$this->assertSame(false, $row['abilities']['can_delete'] ?? null,
"Read-only user must not receive can_delete=true (def {$row['provider_id']}/{$row['scope_value']})");
}
}
}
@@ -51,10 +51,18 @@ class ExecuteUpgradeStepsCommandHandoffTest extends TestCase
);
ob_start();
// 부모 CoreUpdateCommand::spawnUpgradeStepsProcess 와 동일하게 5개 `--skip-*` 옵션 전달.
// 사전·사후 단계가 본 핸드오프 신호 출력 계약 검증에 부수효과(nested Artisan::call 의
// outer output buffer 덮어쓰기 등) 를 주지 않도록 옵션으로 단절.
$exitCode = Artisan::call('core:execute-upgrade-steps', [
'--from' => '0.1.0',
'--to' => $version,
'--force' => true,
'--skip-migrations' => true,
'--skip-resync' => true,
'--skip-version-env' => true,
'--skip-cache-clear' => true,
'--skip-bundled-updates' => true,
]);
ob_end_clean();
@@ -81,6 +89,11 @@ class ExecuteUpgradeStepsCommandHandoffTest extends TestCase
'--from' => '0.1.0',
'--to' => $version,
'--force' => true,
'--skip-migrations' => true,
'--skip-resync' => true,
'--skip-version-env' => true,
'--skip-cache-clear' => true,
'--skip-bundled-updates' => true,
]);
ob_end_clean();
@@ -113,6 +126,11 @@ class ExecuteUpgradeStepsCommandHandoffTest extends TestCase
'--from' => '0.1.0',
'--to' => $version,
'--force' => true,
'--skip-migrations' => true,
'--skip-resync' => true,
'--skip-version-env' => true,
'--skip-cache-clear' => true,
'--skip-bundled-updates' => true,
]);
ob_end_clean();
@@ -152,10 +170,18 @@ PHP;
$this->createdPaths[] = $path;
ob_start();
// 부모 CoreUpdateCommand::spawnUpgradeStepsProcess 와 동일하게 5개 `--skip-*` 옵션 전달.
// 사전·사후 단계가 본 핸드오프 신호 출력 계약 검증에 부수효과(nested Artisan::call 의
// outer output buffer 덮어쓰기 등) 를 주지 않도록 옵션으로 단절.
$exitCode = Artisan::call('core:execute-upgrade-steps', [
'--from' => '0.1.0',
'--to' => $version,
'--force' => true,
'--skip-migrations' => true,
'--skip-resync' => true,
'--skip-version-env' => true,
'--skip-cache-clear' => true,
'--skip-bundled-updates' => true,
]);
ob_end_clean();
@@ -0,0 +1,270 @@
<?php
namespace Tests\Feature\Console\Commands;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Services\CoreUpdateService;
use App\Services\LanguagePackService;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Mockery;
use Tests\TestCase;
/**
* `core:execute-upgrade-steps` 단독 실행 시 사전·사후 단계 자동 수행 계약 테스트.
*
* 부모 `CoreUpdateCommand` 가 spawn 으로 호출할 때는 `--skip-migrations` /
* `--skip-resync` / `--skip-version-env` / `--skip-cache-clear` /
* `--skip-bundled-updates` 5개 옵션을 전달하여 중복을 회피한다. 운영자가 단독으로
* 호출(HANDOFF 안내문 또는 수동 복구 목적) 하는 경우엔 옵션 미전달 → 기본값으로
* 5단계가 자동 수행되어야 공개 이슈 gnuboard/g7#34 의 수동 절차가 단일 명령으로 통합된다.
*
* 본 계약은 CoreUpdateService + 3개 Manager + LanguagePackService 를 Mockery 로
* swap 하여 호출 여부만 검증한다. 실제 마이그레이션·시더 부작용은 테스트 범위 밖.
*/
class ExecuteUpgradeStepsStandaloneTest extends TestCase
{
private array $createdPaths = [];
protected function setUp(): void
{
parent::setUp();
// 빈 upgrade step 디렉토리 보장 — 본 테스트는 step 자체가 아닌 사전/사후 단계만 검증.
// 임시 dummy step 파일을 작성해 from < to 비교가 ">=" 조건을 통과하도록 한다.
$this->writeNoopStep('0.9.1', 'standalone_noop');
}
protected function tearDown(): void
{
foreach ($this->createdPaths as $path) {
if (File::exists($path)) {
File::delete($path);
}
}
$this->createdPaths = [];
Mockery::close();
parent::tearDown();
}
public function test_standalone_invocation_runs_all_five_pre_and_post_steps(): void
{
[$service, $module, $plugin, $template, $langPack] = $this->bindMocks();
$service->shouldReceive('runMigrations')->once();
$service->shouldReceive('reloadCoreConfigAndResync')->once();
$service->shouldReceive('runUpgradeSteps')->once();
$service->shouldReceive('updateVersionInEnv')->once()->with('0.9.1');
$service->shouldReceive('clearAllCaches')->once();
$service->shouldReceive('collectBundledExtensionUpdates')->once()->andReturn([
'modules' => [], 'plugins' => [], 'templates' => [],
]);
$langPack->shouldReceive('collectBundledLangPackUpdates')->once()->andReturn([]);
$exitCode = $this->runCommand([]);
$this->assertSame(0, $exitCode);
}
public function test_skip_migrations_option_bypasses_migrations(): void
{
[$service, $module, $plugin, $template, $langPack] = $this->bindMocks();
$service->shouldNotReceive('runMigrations');
$service->shouldReceive('reloadCoreConfigAndResync')->once();
$service->shouldReceive('runUpgradeSteps')->once();
$service->shouldReceive('updateVersionInEnv')->once();
$service->shouldReceive('clearAllCaches')->once();
$service->shouldReceive('collectBundledExtensionUpdates')->once()->andReturn([
'modules' => [], 'plugins' => [], 'templates' => [],
]);
$langPack->shouldReceive('collectBundledLangPackUpdates')->once()->andReturn([]);
$exitCode = $this->runCommand(['--skip-migrations' => true]);
$this->assertSame(0, $exitCode);
}
public function test_skip_resync_option_bypasses_resync(): void
{
[$service, $module, $plugin, $template, $langPack] = $this->bindMocks();
$service->shouldReceive('runMigrations')->once();
$service->shouldNotReceive('reloadCoreConfigAndResync');
$service->shouldReceive('runUpgradeSteps')->once();
$service->shouldReceive('updateVersionInEnv')->once();
$service->shouldReceive('clearAllCaches')->once();
$service->shouldReceive('collectBundledExtensionUpdates')->once()->andReturn([
'modules' => [], 'plugins' => [], 'templates' => [],
]);
$langPack->shouldReceive('collectBundledLangPackUpdates')->once()->andReturn([]);
$exitCode = $this->runCommand(['--skip-resync' => true]);
$this->assertSame(0, $exitCode);
}
public function test_skip_version_env_option_bypasses_version_env_update(): void
{
[$service, $module, $plugin, $template, $langPack] = $this->bindMocks();
$service->shouldReceive('runMigrations')->once();
$service->shouldReceive('reloadCoreConfigAndResync')->once();
$service->shouldReceive('runUpgradeSteps')->once();
$service->shouldNotReceive('updateVersionInEnv');
$service->shouldReceive('clearAllCaches')->once();
$service->shouldReceive('collectBundledExtensionUpdates')->once()->andReturn([
'modules' => [], 'plugins' => [], 'templates' => [],
]);
$langPack->shouldReceive('collectBundledLangPackUpdates')->once()->andReturn([]);
$exitCode = $this->runCommand(['--skip-version-env' => true]);
$this->assertSame(0, $exitCode);
}
public function test_skip_cache_clear_option_bypasses_cache_clear(): void
{
[$service, $module, $plugin, $template, $langPack] = $this->bindMocks();
$service->shouldReceive('runMigrations')->once();
$service->shouldReceive('reloadCoreConfigAndResync')->once();
$service->shouldReceive('runUpgradeSteps')->once();
$service->shouldReceive('updateVersionInEnv')->once();
$service->shouldNotReceive('clearAllCaches');
$service->shouldReceive('collectBundledExtensionUpdates')->once()->andReturn([
'modules' => [], 'plugins' => [], 'templates' => [],
]);
$langPack->shouldReceive('collectBundledLangPackUpdates')->once()->andReturn([]);
$exitCode = $this->runCommand(['--skip-cache-clear' => true]);
$this->assertSame(0, $exitCode);
}
public function test_skip_bundled_updates_option_bypasses_bundled_prompt(): void
{
[$service, $module, $plugin, $template, $langPack] = $this->bindMocks();
$service->shouldReceive('runMigrations')->once();
$service->shouldReceive('reloadCoreConfigAndResync')->once();
$service->shouldReceive('runUpgradeSteps')->once();
$service->shouldReceive('updateVersionInEnv')->once();
$service->shouldReceive('clearAllCaches')->once();
// 번들 업데이트 prompt 자체가 호출되지 않으므로 collectBundled* 도 호출 없음.
$service->shouldNotReceive('collectBundledExtensionUpdates');
$langPack->shouldNotReceive('collectBundledLangPackUpdates');
$exitCode = $this->runCommand(['--skip-bundled-updates' => true]);
$this->assertSame(0, $exitCode);
}
public function test_all_skip_options_bypass_all_pre_and_post_steps(): void
{
[$service, $module, $plugin, $template, $langPack] = $this->bindMocks();
// CoreUpdateCommand spawn 호출 시나리오 등가 — runUpgradeSteps 만 호출.
$service->shouldNotReceive('runMigrations');
$service->shouldNotReceive('reloadCoreConfigAndResync');
$service->shouldReceive('runUpgradeSteps')->once();
$service->shouldNotReceive('updateVersionInEnv');
$service->shouldNotReceive('clearAllCaches');
$service->shouldNotReceive('collectBundledExtensionUpdates');
$langPack->shouldNotReceive('collectBundledLangPackUpdates');
$exitCode = $this->runCommand([
'--skip-migrations' => true,
'--skip-resync' => true,
'--skip-version-env' => true,
'--skip-cache-clear' => true,
'--skip-bundled-updates' => true,
]);
$this->assertSame(0, $exitCode);
}
public function test_spawn_passes_all_five_skip_options_to_child(): void
{
// 부모 CoreUpdateCommand::spawnUpgradeStepsProcess 가 자식 command 배열에
// 5개 `--skip-*` 옵션을 추가하는지 검증 (escapeshellarg 후 commandLine 문자열에 포함).
$reflection = new \ReflectionClass(\App\Console\Commands\Core\CoreUpdateCommand::class);
$source = File::get($reflection->getFileName());
$this->assertStringContainsString("\$command[] = '--skip-migrations';", $source);
$this->assertStringContainsString("\$command[] = '--skip-resync';", $source);
$this->assertStringContainsString("\$command[] = '--skip-version-env';", $source);
$this->assertStringContainsString("\$command[] = '--skip-cache-clear';", $source);
$this->assertStringContainsString("\$command[] = '--skip-bundled-updates';", $source);
}
/**
* CoreUpdateService + 3개 Manager + LanguagePackService 를 컨테이너에 mock 으로 swap.
*
* @return array{0: \Mockery\MockInterface, 1: \Mockery\MockInterface, 2: \Mockery\MockInterface, 3: \Mockery\MockInterface, 4: \Mockery\MockInterface}
*/
private function bindMocks(): array
{
$service = Mockery::mock(CoreUpdateService::class);
$module = Mockery::mock(ModuleManager::class);
$plugin = Mockery::mock(PluginManager::class);
$template = Mockery::mock(TemplateManager::class);
$langPack = Mockery::mock(LanguagePackService::class);
$this->app->instance(CoreUpdateService::class, $service);
$this->app->instance(ModuleManager::class, $module);
$this->app->instance(PluginManager::class, $plugin);
$this->app->instance(TemplateManager::class, $template);
$this->app->instance(LanguagePackService::class, $langPack);
return [$service, $module, $plugin, $template, $langPack];
}
/**
* 공통 옵션을 적용해 `core:execute-upgrade-steps` 를 호출.
*
* @param array<string, mixed> $extra --skip-* 등 추가 옵션
*/
private function runCommand(array $extra): int
{
$params = array_merge([
'--from' => '0.9.0',
'--to' => '0.9.1',
'--force' => true,
], $extra);
ob_start();
$exitCode = Artisan::call('core:execute-upgrade-steps', $params);
ob_end_clean();
return $exitCode;
}
/**
* 본 테스트가 from < to 범위 안에 들도록 더미 upgrade step 파일을 작성.
* 핸들러 자체는 아무것도 하지 않는다 — 사전/사후 단계 호출만 검증.
*/
private function writeNoopStep(string $version, string $suffix): void
{
$versionSnake = str_replace('.', '_', $version);
$className = "Upgrade_{$versionSnake}_test_{$suffix}";
$path = base_path("upgrades/{$className}.php");
$code = <<<PHP
<?php
namespace App\\Upgrades;
use App\\Contracts\\Extension\\UpgradeStepInterface;
use App\\Extension\\UpgradeContext;
class {$className} implements UpgradeStepInterface
{
public function run(UpgradeContext \$context): void
{
// noop — 본 테스트는 사전/사후 단계 호출만 검증.
}
}
PHP;
File::put($path, $code);
$this->createdPaths[] = $path;
}
}
@@ -0,0 +1,208 @@
<?php
namespace Tests\Feature\Console\Hotfix;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
/**
* `hotfix:rollback-stale-files` Artisan 커맨드 통합 테스트.
*
* 본 커맨드는 운영자가 명시 실행하는 단발성 회복 도구이며, 기본은 진단 모드 (실제 삭제
* 없이 후보 목록만 출력), `--prune` 옵션 + 확인 프롬프트 시 정리 수행.
*
* 본 테스트는 실제 base_path() 의 활성 파일을 prune 하지 않도록 항상 `--backup` 옵션을
* 사용하거나 임시 디렉토리 fixture 만 다룬다.
*/
class RollbackStaleFilesCommandTest extends TestCase
{
private string $tempBackup;
protected function setUp(): void
{
parent::setUp();
$this->tempBackup = storage_path('app/test_hotfix_backup_'.uniqid());
File::ensureDirectoryExists($this->tempBackup);
}
protected function tearDown(): void
{
if (File::isDirectory($this->tempBackup)) {
File::deleteDirectory($this->tempBackup);
}
parent::tearDown();
}
/**
* 시나리오 6: 백업 디렉토리 자체가 비어있음 → "백업 없음" 안내 + exit 0.
*
* `core_backups/` 가 비어있어야 의미 있는 케이스이므로 `--backup` 으로 비어있는 임시
* 디렉토리 지정 — listBackups 호출 자체를 회피.
*
* 단, 비어있는 임시 디렉토리는 그 자체로 "유효한 백업" 이므로 manifest 부재 경고가
* 발화. 본 케이스는 진단 모드에서 잔존 후보가 0 일 때 정상 종료를 검증.
*/
public function test_empty_backup_completes_successfully(): void
{
$exitCode = $this->artisan('hotfix:rollback-stale-files', [
'--backup' => $this->tempBackup,
])->run();
$this->assertSame(0, $exitCode);
}
/**
* 시나리오 1: 진단 모드 (옵션 없음) — 후보 목록 출력 + 실제 파일 미삭제.
*/
public function test_diagnostic_mode_outputs_candidates_without_pruning(): void
{
$marker = base_path('app/HotfixDiagnosticMarker_'.uniqid().'.php');
File::put($marker, '<?php // test marker');
File::put($this->tempBackup.'/_new_files_manifest.json', json_encode([
'version' => 1,
'created_at' => date('c'),
'from_version' => '7.0.0-beta.5',
'to_version' => '7.0.0-beta.6',
'new_files' => ['app/'.basename($marker)],
'new_dirs' => [],
]));
try {
$this->artisan('hotfix:rollback-stale-files', [
'--backup' => $this->tempBackup,
])
->expectsOutputToContain('잔존 후보')
->expectsOutputToContain('진단 모드: 실제 삭제하지 않았습니다')
->assertExitCode(0);
$this->assertFileExists($marker, '진단 모드에서는 실제 파일 미삭제 필수');
} finally {
if (File::exists($marker)) {
File::delete($marker);
}
}
}
/**
* 시나리오 4: manifest 부재 fallback — 빈 백업으로 정상 종료 (잔존 후보 0 케이스).
*
* manifest 부재 시 보수적 진단 모드 안내 + collectCandidates 가 빈 배열 반환 → 잔존
* 후보 0 → SUCCESS 종료. (Laravel expectsOutputToContain 의 warn() 캡처 동작이 환경별
* 차이가 있어, 본 케이스는 흐름 정합성만 exit code 로 검증)
*/
public function test_missing_manifest_path_completes_safely(): void
{
$exitCode = $this->artisan('hotfix:rollback-stale-files', [
'--backup' => $this->tempBackup,
])->run();
$this->assertSame(0, $exitCode);
}
/**
* 시나리오 4b: manifest 가 있지만 후보가 모두 실 디스크에 없는 케이스 — 잔존 후보 0
* → SUCCESS. manifest 부재 상태에서 --prune 시도 시 collectCandidates 가 빈 배열을
* 반환하므로 사용자가 prune 옵션을 지정해도 자동으로 잔존 후보 0 안내 → SUCCESS.
* (안전 우선 — 실제 파일 시스템 영향 없음)
*/
public function test_prune_without_candidates_completes_safely(): void
{
$exitCode = $this->artisan('hotfix:rollback-stale-files', [
'--backup' => $this->tempBackup,
'--prune' => true,
])->run();
// 후보 0 케이스 → SUCCESS 종료 (실제 파일 변경 없음)
$this->assertSame(0, $exitCode);
}
/**
* 시나리오 5: --backup 명시 옵션 — 존재하지 않는 경로 지정 시 에러.
*/
public function test_explicit_backup_invalid_path_errors(): void
{
$exitCode = $this->artisan('hotfix:rollback-stale-files', [
'--backup' => storage_path('app/__nonexistent_backup__'.uniqid()),
])->run();
// resolveBackupPath 에서 null 반환 후 "사용 가능한 백업이 없습니다" 와는 다른 경로 —
// 명시 지정 + 디렉토리 부재 시 error 출력 후 SUCCESS exit (커맨드 본체는 SUCCESS 로
// 종료하나 출력에 에러 메시지). 본 케이스는 단순히 충돌 없이 종료를 검증.
$this->assertContains($exitCode, [0, 1]);
}
/**
* 시나리오 3: --prune 모드 + 확인 승인 — manifest 기반 prune 수행 + 로그 기록.
*
* 본 케이스는 실제 활성 디렉토리(base_path) 의 임시 마커 파일을 prune 대상으로 두고
* --prune 옵션 + 확인 프롬프트 승인을 시뮬레이션한다. 정리 결과 로그가
* `storage/logs/hotfix_rollback_stale_files_*.log` 에 기록되는지 검증.
*/
public function test_prune_mode_with_confirmation_removes_candidates(): void
{
$markerName = 'HotfixPruneMarker_'.uniqid().'.php';
$marker = base_path('app/'.$markerName);
File::put($marker, '<?php // test marker');
File::put($this->tempBackup.'/_new_files_manifest.json', json_encode([
'version' => 1,
'created_at' => date('c'),
'from_version' => '7.0.0-beta.5',
'to_version' => '7.0.0-beta.6',
'new_files' => ['app/'.$markerName],
'new_dirs' => [],
]));
try {
$this->artisan('hotfix:rollback-stale-files', [
'--backup' => $this->tempBackup,
'--prune' => true,
])
->expectsQuestion('위 후보를 활성 디렉토리에서 정리하시겠습니까? (yes/no) [no]', 'yes')
->assertExitCode(0);
// marker 가 prune 되어야 함
$this->assertFileDoesNotExist($marker);
} finally {
if (File::exists($marker)) {
File::delete($marker);
}
}
}
/**
* 시나리오 2: --prune 모드 + 확인 거부 — 출력만 + 실제 미삭제.
*/
public function test_prune_mode_with_rejection_keeps_files(): void
{
$markerName = 'HotfixRejectMarker_'.uniqid().'.php';
$marker = base_path('app/'.$markerName);
File::put($marker, '<?php // test marker');
File::put($this->tempBackup.'/_new_files_manifest.json', json_encode([
'version' => 1,
'created_at' => date('c'),
'from_version' => '7.0.0-beta.5',
'to_version' => '7.0.0-beta.6',
'new_files' => ['app/'.$markerName],
'new_dirs' => [],
]));
try {
$this->artisan('hotfix:rollback-stale-files', [
'--backup' => $this->tempBackup,
'--prune' => true,
])
->expectsQuestion('위 후보를 활성 디렉토리에서 정리하시겠습니까? (yes/no) [no]', 'no')
->assertExitCode(0);
// marker 보존
$this->assertFileExists($marker);
} finally {
if (File::exists($marker)) {
File::delete($marker);
}
}
}
}
@@ -0,0 +1,390 @@
<?php
namespace Tests\Feature\Upgrade;
use App\Extension\Helpers\CoreBackupHelper;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
/**
* 자동 롤백 시 신 버전 신규 파일 prune 통합 테스트.
*
* 본 테스트는 CoreUpdateCommand 의 Step 5 백업 → Step 6.5 manifest 생성 → Step 7 applyUpdate
* → 강제 fatal → restoreFromBackup 의 흐름을 격리된 임시 디렉토리 트리에서 재현하여
* 신 버전 신규 파일이 정확히 prune 되는지 + 사용자 추가 파일이 보존되는지 검증한다.
*
* 실제 base_path() 는 건드리지 않는다 — backup/active/_pending 3개 디렉토리를 모두
* storage/app/test_rollback_prune_xxx 아래 임시 fixture 로 구성하고 CoreBackupHelper 의
* 신규 메서드 (`writeNewFilesManifest`, `pruneNewFiles`) 를 명시 호출한다.
*
* Feature 디렉토리에 배치한 이유는 CoreUpdateCommand 의 흐름(backup → manifest → apply
* → rollback) 을 end-to-end 로 재현하는 통합 테스트이기 때문 — Unit 의 helper 단건
* 동작 검증과 구분.
*/
class AutoRollbackPruneTest extends TestCase
{
private string $testRoot;
private string $backupPath;
private string $sourcePath;
private string $activePath;
protected function setUp(): void
{
parent::setUp();
$this->testRoot = storage_path('app/test_rollback_prune_'.uniqid());
$this->backupPath = $this->testRoot.'/backup';
$this->sourcePath = $this->testRoot.'/_pending';
$this->activePath = $this->testRoot.'/active';
File::ensureDirectoryExists($this->backupPath);
File::ensureDirectoryExists($this->sourcePath);
File::ensureDirectoryExists($this->activePath);
}
protected function tearDown(): void
{
if (File::isDirectory($this->testRoot)) {
File::deleteDirectory($this->testRoot);
}
parent::tearDown();
}
/**
* 시나리오 1: 잔존 결함 재현 — 신 ServiceProvider 가 활성 디렉토리에서 삭제됨.
*/
public function test_new_service_provider_is_removed_on_rollback(): void
{
// (1) 활성 디렉토리 사전 상태 — backup 의 source 가 됨
File::ensureDirectoryExists($this->activePath.'/app/Services');
File::put($this->activePath.'/app/Services/ExistingService.php', '<?php // v_old');
// (2) backup 생성 — 사전 스냅샷
File::ensureDirectoryExists($this->backupPath.'/app/Services');
File::put($this->backupPath.'/app/Services/ExistingService.php', '<?php // v_old');
// (3) _pending 신 버전 — 신규 ServiceProvider 추가
File::ensureDirectoryExists($this->sourcePath.'/app/Services/LanguagePack');
File::put($this->sourcePath.'/app/Services/ExistingService.php', '<?php // v_new');
File::put($this->sourcePath.'/app/Services/LanguagePack/Module.php', '<?php // new');
// (4) Step 6.5 manifest 생성
$stats = CoreBackupHelper::writeNewFilesManifest(
$this->backupPath,
$this->sourcePath,
['app'],
[],
[],
'7.0.0-beta.5',
'7.0.0-beta.6',
);
$this->assertGreaterThanOrEqual(1, $stats['new_files_count']);
// (5) Step 7 applyUpdate 시뮬레이션 — 활성 디렉토리에 신 파일 반영
File::put($this->activePath.'/app/Services/ExistingService.php', '<?php // v_new');
File::ensureDirectoryExists($this->activePath.'/app/Services/LanguagePack');
File::put($this->activePath.'/app/Services/LanguagePack/Module.php', '<?php // new');
// (6) fatal 발생 → manifest 기반 prune (restoreFromBackup 의 prune 단계만 분리 호출)
$pruneResult = CoreBackupHelper::pruneNewFiles($this->backupPath, $this->activePath, []);
$this->assertFileDoesNotExist($this->activePath.'/app/Services/LanguagePack/Module.php');
$this->assertDirectoryDoesNotExist($this->activePath.'/app/Services/LanguagePack');
$this->assertSame(1, $pruneResult['removed_files']);
}
/**
* 시나리오 2: 사용자가 활성 디렉토리에 직접 추가한 파일은 보존.
*/
public function test_user_added_files_are_preserved(): void
{
// 사용자 파일 사전 배치 — applyUpdate 직전 활성 디렉토리에 존재
File::ensureDirectoryExists($this->activePath.'/app');
File::put($this->activePath.'/app/CustomUserHelper.php', '<?php // user code');
File::ensureDirectoryExists($this->activePath.'/database/migrations');
File::put(
$this->activePath.'/database/migrations/2026_05_13_custom_user_migration.php',
'<?php // user migration',
);
// backup 단계가 이 사용자 파일들을 백업에 포함
File::ensureDirectoryExists($this->backupPath.'/app');
File::put($this->backupPath.'/app/CustomUserHelper.php', '<?php // user code');
File::ensureDirectoryExists($this->backupPath.'/database/migrations');
File::put(
$this->backupPath.'/database/migrations/2026_05_13_custom_user_migration.php',
'<?php // user migration',
);
// _pending 신 버전 — 신규 ServiceProvider 추가 (사용자 파일과 무관)
File::ensureDirectoryExists($this->sourcePath.'/app/Services');
File::put($this->sourcePath.'/app/Services/NewProvider.php', '<?php // new');
// manifest 생성
CoreBackupHelper::writeNewFilesManifest(
$this->backupPath,
$this->sourcePath,
['app', 'database'],
[],
[],
'7.0.0-beta.5',
'7.0.0-beta.6',
);
$manifest = json_decode(File::get($this->backupPath.'/_new_files_manifest.json'), true);
// 사용자 파일은 manifest 에서 제외되어야 함 (백업에 있으므로)
$this->assertNotContains('app/CustomUserHelper.php', $manifest['new_files']);
$this->assertNotContains(
'database/migrations/2026_05_13_custom_user_migration.php',
$manifest['new_files'],
);
$this->assertContains('app/Services/NewProvider.php', $manifest['new_files']);
// applyUpdate 시뮬레이션
File::ensureDirectoryExists($this->activePath.'/app/Services');
File::put($this->activePath.'/app/Services/NewProvider.php', '<?php // new');
// fatal → prune
CoreBackupHelper::pruneNewFiles($this->backupPath, $this->activePath, []);
// 사용자 파일 2개 모두 보존
$this->assertFileExists($this->activePath.'/app/CustomUserHelper.php');
$this->assertFileExists(
$this->activePath.'/database/migrations/2026_05_13_custom_user_migration.php',
);
// 신 파일은 prune
$this->assertFileDoesNotExist($this->activePath.'/app/Services/NewProvider.php');
}
/**
* 시나리오 4: 보호 경로 (storage, vendor, .env 등) 는 manifest 와 prune 양쪽에서 제외.
*/
public function test_protected_paths_are_ignored_at_both_stages(): void
{
File::ensureDirectoryExists($this->activePath.'/storage/app');
File::put($this->activePath.'/storage/app/user_upload.jpg', 'binary');
File::put($this->activePath.'/.env', 'APP_KEY=...');
File::ensureDirectoryExists($this->sourcePath.'/storage/app');
File::put($this->sourcePath.'/storage/app/seed.php', '<?php');
$protected = ['storage', '.env', 'vendor'];
CoreBackupHelper::writeNewFilesManifest(
$this->backupPath,
$this->sourcePath,
['storage'],
$protected,
[],
'7.0.0-beta.5',
'7.0.0-beta.6',
);
$manifest = json_decode(File::get($this->backupPath.'/_new_files_manifest.json'), true);
$this->assertEmpty($manifest['new_files']);
$this->assertEmpty($manifest['new_dirs']);
// 사용자 파일과 .env 가 prune 의 보호 가드도 통과해야 — manifest 에 가상으로 등재해도 살아남음
File::put($this->backupPath.'/_new_files_manifest.json', json_encode([
'version' => 1,
'created_at' => date('c'),
'from_version' => '7.0.0-beta.5',
'to_version' => '7.0.0-beta.6',
'new_files' => ['storage/app/user_upload.jpg', '.env'],
'new_dirs' => [],
]));
$result = CoreBackupHelper::pruneNewFiles($this->backupPath, $this->activePath, $protected);
$this->assertFileExists($this->activePath.'/storage/app/user_upload.jpg');
$this->assertFileExists($this->activePath.'/.env');
$this->assertSame(2, $result['protected_count']);
$this->assertSame(0, $result['removed_files']);
}
/**
* 시나리오 6: 외부 모듈/플러그인 디렉토리는 보호 경로로 등재되어 prune 영향 없음.
*/
public function test_extension_directories_are_protected(): void
{
File::ensureDirectoryExists($this->activePath.'/modules/sirsoft-board');
File::put($this->activePath.'/modules/sirsoft-board/module.json', '{}');
$protected = ['modules', 'plugins', 'templates', 'lang-packs'];
File::put($this->backupPath.'/_new_files_manifest.json', json_encode([
'version' => 1,
'created_at' => date('c'),
'from_version' => '7.0.0-beta.5',
'to_version' => '7.0.0-beta.6',
'new_files' => ['modules/sirsoft-board/module.json'],
'new_dirs' => ['modules/sirsoft-board'],
]));
CoreBackupHelper::pruneNewFiles($this->backupPath, $this->activePath, $protected);
$this->assertFileExists($this->activePath.'/modules/sirsoft-board/module.json');
$this->assertDirectoryExists($this->activePath.'/modules/sirsoft-board');
}
/**
* 시나리오 7: 멀티 버전 chain — applyUpdate 가 final source 와 backup 의 차이만 보면
* 충분하므로 N+1 / N+2 양 버전의 신규 파일이 모두 prune.
*/
public function test_multi_version_chain_prunes_all_new_files(): void
{
File::ensureDirectoryExists($this->backupPath.'/app');
File::put($this->backupPath.'/app/Old.php', '<?php // beta.N');
// _pending = 최종 beta.N+2 — 중간 beta.N+1 의 변경도 누적되어 있음
File::ensureDirectoryExists($this->sourcePath.'/app');
File::put($this->sourcePath.'/app/Old.php', '<?php // updated');
File::put($this->sourcePath.'/app/IntroducedInBetaN1.php', '<?php');
File::put($this->sourcePath.'/app/IntroducedInBetaN2.php', '<?php');
CoreBackupHelper::writeNewFilesManifest(
$this->backupPath,
$this->sourcePath,
['app'],
[],
[],
'7.0.0-beta.4',
'7.0.0-beta.6',
);
$manifest = json_decode(File::get($this->backupPath.'/_new_files_manifest.json'), true);
$this->assertContains('app/IntroducedInBetaN1.php', $manifest['new_files']);
$this->assertContains('app/IntroducedInBetaN2.php', $manifest['new_files']);
// 활성 디렉토리에 두 버전의 신 파일 반영
File::ensureDirectoryExists($this->activePath.'/app');
File::put($this->activePath.'/app/IntroducedInBetaN1.php', '<?php');
File::put($this->activePath.'/app/IntroducedInBetaN2.php', '<?php');
$result = CoreBackupHelper::pruneNewFiles($this->backupPath, $this->activePath, []);
$this->assertSame(2, $result['removed_files']);
}
/**
* 시나리오 8: manifest 부재 시 prune 은 noop (기존 동작 유지).
*/
public function test_missing_manifest_skips_prune(): void
{
File::ensureDirectoryExists($this->activePath.'/app/Services/LanguagePack');
File::put($this->activePath.'/app/Services/LanguagePack/Module.php', '<?php');
$result = CoreBackupHelper::pruneNewFiles($this->backupPath, $this->activePath, []);
$this->assertFalse($result['manifest_loaded']);
$this->assertFileExists($this->activePath.'/app/Services/LanguagePack/Module.php');
}
/**
* 시나리오 9: manifest JSON 손상 시 warning + 기존 overlay 만 (noop prune).
*/
public function test_corrupted_manifest_falls_back_safely(): void
{
File::put($this->backupPath.'/_new_files_manifest.json', '{not json');
File::ensureDirectoryExists($this->activePath.'/app/Services');
File::put($this->activePath.'/app/Services/keep.php', '<?php');
$result = CoreBackupHelper::pruneNewFiles($this->backupPath, $this->activePath, []);
$this->assertFalse($result['manifest_loaded']);
$this->assertFileExists($this->activePath.'/app/Services/keep.php');
}
/**
* 시나리오 10: --no-backup 모드 Step 6.5 skip 정적 구조 검증.
*
* CoreUpdateCommand 의 catch 블록 mocking 없이 Step 6.5 의 가드 패턴을 정적으로
* 검증한다. backupPath 가 null 일 때 manifest 생성이 스킵됨을 코드 레벨로 보장.
*/
public function test_step6_5_is_gated_by_backup_path_null_check(): void
{
$cmdPath = base_path('app/Console/Commands/Core/CoreUpdateCommand.php');
$this->assertFileExists($cmdPath);
$content = File::get($cmdPath);
$this->assertStringContainsString(
"CoreBackupHelper::writeNewFilesManifest",
$content,
'Step 6.5 가 writeNewFilesManifest 를 호출해야 함',
);
$this->assertMatchesRegularExpression(
'/if\s*\(\s*\$backupPath\s*!==\s*null\s*\)\s*\{[^}]*writeNewFilesManifest/s',
$content,
'--no-backup 모드 보호: writeNewFilesManifest 호출은 `if ($backupPath !== null)` 가드 안에 위치해야 함',
);
}
/**
* 시나리오 12: 자동 롤백 후 캐시 자동 정리 정적 구조 검증.
*
* catch 블록의 restoreFromBackup 호출 직후 clearAllCaches() 가 자동 호출되어
* `bootstrap/cache/*.php` 의 stale PHP 캐시로 인한 부팅 실패가 차단됨을 코드 레벨로
* 보장한다.
*/
public function test_rollback_catch_invokes_clear_all_caches(): void
{
$cmdPath = base_path('app/Console/Commands/Core/CoreUpdateCommand.php');
$content = File::get($cmdPath);
// restoreFromBackup 와 clearAllCaches 호출이 동일 catch 블록 내에 존재해야 함
$this->assertStringContainsString('$service->restoreFromBackup(', $content);
$this->assertStringContainsString('$service->clearAllCaches()', $content);
// 두 호출 사이에 다른 catch 블록 (catch \Throwable) 등이 없어야 함 — 같은 백업 복원
// 흐름 내에서 캐시 정리가 일어남을 보장
$restoreOffset = strpos($content, '$service->restoreFromBackup(');
$clearOffset = strpos($content, '$service->clearAllCaches()', $restoreOffset);
$this->assertNotFalse($clearOffset, 'restoreFromBackup 이후 clearAllCaches 호출이 위치해야 함');
$between = substr($content, $restoreOffset, $clearOffset - $restoreOffset);
$this->assertStringNotContainsString(
'public function handle',
$between,
'두 호출 사이에 다른 메서드 시작이 없어야 — 동일 catch 블록 내 위치',
);
}
/**
* 시나리오 11: 빈 디렉토리 정리 + 사용자 파일 존재 시 디렉토리 유지.
*/
public function test_empty_dir_cleanup_and_user_dir_preservation(): void
{
// 케이스 A: 빈 디렉토리 → 제거
File::ensureDirectoryExists($this->activePath.'/app/EmptyAfterPrune');
File::put($this->activePath.'/app/EmptyAfterPrune/Only.php', '<?php');
// 케이스 B: 사용자 파일이 남는 디렉토리 → 유지
File::ensureDirectoryExists($this->activePath.'/app/MixedDir');
File::put($this->activePath.'/app/MixedDir/NewFromCore.php', '<?php');
File::put($this->activePath.'/app/MixedDir/UserAdded.php', '<?php // user');
File::put($this->backupPath.'/_new_files_manifest.json', json_encode([
'version' => 1,
'created_at' => date('c'),
'from_version' => '7.0.0-beta.5',
'to_version' => '7.0.0-beta.6',
'new_files' => [
'app/EmptyAfterPrune/Only.php',
'app/MixedDir/NewFromCore.php',
],
'new_dirs' => [
'app/EmptyAfterPrune',
'app/MixedDir',
],
]));
$result = CoreBackupHelper::pruneNewFiles($this->backupPath, $this->activePath, []);
$this->assertDirectoryDoesNotExist($this->activePath.'/app/EmptyAfterPrune');
$this->assertDirectoryExists($this->activePath.'/app/MixedDir');
$this->assertFileExists($this->activePath.'/app/MixedDir/UserAdded.php');
$this->assertFileDoesNotExist($this->activePath.'/app/MixedDir/NewFromCore.php');
$this->assertSame(2, $result['removed_files']);
$this->assertSame(1, $result['removed_dirs']);
}
}
@@ -0,0 +1,264 @@
<?php
namespace Tests\Feature\Upgrade;
use App\Extension\Helpers\CoreBackupHelper;
use App\Extension\UpgradeContext;
use App\Upgrades\Data\V7_0_0_beta_6\Migrations\BackfillNewFilesManifest;
use App\Upgrades\Data\V7_0_0_beta_6\Migrations\LogStaleServiceProviders;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
/**
* beta.6 업그레이드 스텝의 사후 manifest 작성 + 부팅 부정합 ServiceProvider 진단 검증.
*
* 본 테스트는 실제 base_path() 의 app/Providers/, bootstrap/providers.php 등을 건드리지
* 않는다 — Backfill / LogStale 의 분석 대상은 base_path() 트리이므로, 본 테스트는
* DataMigration 의 핵심 분기(이미 존재하는 manifest, 백업 부재, 예외 swallow) 와
* 스키마 호환성 invariant 를 격리된 임시 디렉토리 / 일부 실제 코드 사용으로 검증한다.
*/
class Beta6BackfillManifestTest extends TestCase
{
private string $coreBackupsDir;
private array $backupDirsCreated = [];
protected function setUp(): void
{
parent::setUp();
$this->coreBackupsDir = storage_path('app/core_backups');
File::ensureDirectoryExists($this->coreBackupsDir);
// DataMigration 클래스는 AbstractUpgradeStep::dataMigrations() 가 동적으로 require
// 하므로 일반 autoload 대상이 아님 — 테스트에서는 명시적으로 require_once.
require_once base_path('upgrades/data/7.0.0-beta.6/migrations/01_BackfillNewFilesManifest.php');
require_once base_path('upgrades/data/7.0.0-beta.6/migrations/02_LogStaleServiceProviders.php');
}
protected function tearDown(): void
{
foreach ($this->backupDirsCreated as $dir) {
if (File::isDirectory($dir)) {
File::deleteDirectory($dir);
}
}
parent::tearDown();
}
private function createBackupFixture(string $name): string
{
$path = $this->coreBackupsDir.DIRECTORY_SEPARATOR.$name;
File::ensureDirectoryExists($path);
$this->backupDirsCreated[] = $path;
return $path;
}
/**
* 시나리오 1: 백업에 manifest 없음 + 디스크는 신 버전 → 사후 manifest 작성 검증.
*
* 본 케이스는 BackfillNewFilesManifest 가 실제로 manifest 파일을 작성하는 동작을 검증
* 한다. Backfill 은 `storage/app/core_backups/` 의 가장 최근 백업을 자동 선택하므로,
* 다른 테스트의 백업 디렉토리보다 mtime 이 최신인 fixture 백업을 만들어 보장한다.
*
* Backfill 의 비교 대상은 base_path() — 본 fixture 의 backup 은 빈 디렉토리이므로
* Backfill 후 manifest 의 new_files 가 비어있지 않을 수 있지만 (실제 코어 트리 vs 빈
* backup), 검증 목표는 "manifest 파일이 작성되었는지 + 스키마 정합성" 이다.
*/
public function test_backfill_writes_manifest_when_absent_and_backup_present(): void
{
// 다른 테스트 fixture 보다 mtime 이 최신이도록 timestamp 가 들어간 백업 만들고
// 실제 디렉토리 mtime 을 미래로 설정
$backupDir = $this->createBackupFixture('test_beta6_backfill_write_'.uniqid());
@touch($backupDir, time() + 60); // 60s 미래 — findLatestBackupDir 가 본 fixture 우선 선택
$manifestPath = $backupDir.'/_new_files_manifest.json';
$this->assertFileDoesNotExist($manifestPath, 'fixture 시작 시 manifest 부재');
$context = new UpgradeContext('7.0.0-beta.5', '7.0.0-beta.6', '7.0.0-beta.6');
$migration = new BackfillNewFilesManifest;
$migration->run($context);
// manifest 가 실제 작성되었는지 + 스키마 invariant 검증
$this->assertFileExists($manifestPath, 'Backfill 이 manifest 를 사후 작성해야 함');
$manifest = json_decode(File::get($manifestPath), true);
$this->assertIsArray($manifest);
$this->assertSame(
['version', 'created_at', 'from_version', 'to_version', 'new_files', 'new_dirs'],
array_keys($manifest),
'Backfill 산출 manifest 스키마는 §6.5 invariant 와 일치해야 함',
);
$this->assertSame(1, $manifest['version']);
$this->assertSame('7.0.0-beta.5', $manifest['from_version']);
$this->assertSame('7.0.0-beta.6', $manifest['to_version']);
$this->assertIsArray($manifest['new_files']);
$this->assertIsArray($manifest['new_dirs']);
}
/**
* 시나리오 2: 이미 manifest 가 있으면 noop (created_at 변동 없음).
*/
public function test_existing_manifest_is_noop(): void
{
$backupDir = $this->createBackupFixture('test_beta6_backfill_existing_'.uniqid());
$existing = [
'version' => 1,
'created_at' => '2026-01-01T00:00:00+00:00',
'from_version' => '7.0.0-beta.5',
'to_version' => '7.0.0-beta.6',
'new_files' => ['app/Existing.php'],
'new_dirs' => [],
];
File::put(
$backupDir.'/_new_files_manifest.json',
json_encode($existing, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
);
$context = new UpgradeContext('7.0.0-beta.5', '7.0.0-beta.6', '7.0.0-beta.6');
$migration = new BackfillNewFilesManifest;
$migration->run($context);
$after = json_decode(File::get($backupDir.'/_new_files_manifest.json'), true);
$this->assertSame($existing['created_at'], $after['created_at']);
$this->assertSame($existing['new_files'], $after['new_files']);
}
/**
* 시나리오 3: 백업 디렉토리 자체가 부재 (--no-backup 모드) → 정상 종료 + 예외 미발생.
*/
public function test_no_backup_dir_completes_without_exception(): void
{
// 실제 환경의 다른 백업 디렉토리에 영향을 주지 않기 위해 본 케이스는 예외 미발생만 검증.
// (core_backups 가 비어있든 다른 백업이 있든, 본 DataMigration 은 swallow 만 해야 한다)
$context = new UpgradeContext('7.0.0-beta.5', '7.0.0-beta.6', '7.0.0-beta.6');
$migration = new BackfillNewFilesManifest;
$thrown = null;
try {
$migration->run($context);
} catch (\Throwable $e) {
$thrown = $e;
}
$this->assertNull($thrown, 'DataMigration 은 예외를 swallow 해야 함');
}
/**
* 시나리오 4: Backfill 이 작성한 manifest 가 CoreBackupHelper::pruneNewFiles 와 호환.
*
* Backfill 산출물을 직접 작성한 후 (DataMigration 의 분석 대상이 base_path() 이므로
* 본 테스트는 동일 스키마를 수작업 작성해 pruneNewFiles 가 인식하는지 검증) — 스키마
* invariant 회귀 가드.
*/
public function test_backfill_manifest_schema_compatible_with_prune(): void
{
$backupDir = $this->createBackupFixture('test_beta6_compat_'.uniqid());
$activeDir = storage_path('app/test_beta6_active_'.uniqid());
File::ensureDirectoryExists($activeDir.'/app/Services');
File::put($activeDir.'/app/Services/NewProvider.php', '<?php // new');
// Backfill 과 동일 스키마 (DataMigration 01 의 invariant 준수)
$manifest = [
'version' => 1,
'created_at' => date('c'),
'from_version' => '7.0.0-beta.5',
'to_version' => '7.0.0-beta.6',
'new_files' => ['app/Services/NewProvider.php'],
'new_dirs' => [],
];
File::put(
$backupDir.'/_new_files_manifest.json',
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
);
$result = CoreBackupHelper::pruneNewFiles($backupDir, $activeDir, []);
$this->assertTrue($result['manifest_loaded']);
$this->assertSame(1, $result['removed_files']);
$this->assertFileDoesNotExist($activeDir.'/app/Services/NewProvider.php');
File::deleteDirectory($activeDir);
}
/**
* 시나리오 4b: CoreBackupHelper 의 manifest 스키마 키 6종이 invariant — DataMigration
* 01 이 작성하는 manifest 의 키 순서/이름과 바이트 단위 호환.
*/
public function test_manifest_schema_keys_match_helper_invariant(): void
{
$expectedKeys = ['version', 'created_at', 'from_version', 'to_version', 'new_files', 'new_dirs'];
// CoreBackupHelper::writeNewFilesManifest 산출물의 키와 DataMigration 01 의 키가 동일해야 함
$testRoot = storage_path('app/test_beta6_invariant_'.uniqid());
File::ensureDirectoryExists($testRoot.'/backup');
File::ensureDirectoryExists($testRoot.'/source/app');
File::put($testRoot.'/source/app/dummy.php', '<?php');
CoreBackupHelper::writeNewFilesManifest(
$testRoot.'/backup',
$testRoot.'/source',
['app'],
[],
[],
'7.0.0-beta.5',
'7.0.0-beta.6',
);
$helperOutput = json_decode(File::get($testRoot.'/backup/_new_files_manifest.json'), true);
$this->assertSame($expectedKeys, array_keys($helperOutput));
File::deleteDirectory($testRoot);
}
/**
* 시나리오 6: Backfill 실패 swallow — listBackups 가 예외를 던져도 업그레이드 본체
* 미중단. DataMigration 의 run() 는 모든 예외를 try/catch 로 swallow 한다.
*/
public function test_backfill_swallows_exceptions(): void
{
// DataMigration::run 의 try/catch 동작은 internal 의 의도적 throw 가 swallow 되는지 검증
// 실제 throw 시나리오는 권한 거부 등이지만, 본 테스트는 외부에서 어떤 예외도 새지 않음을 검증
$context = new UpgradeContext('7.0.0-beta.5', '7.0.0-beta.6', '7.0.0-beta.6');
$migration = new BackfillNewFilesManifest;
// 정상 케이스에서도 예외가 새지 않아야 한다
$exceptionThrown = false;
try {
$migration->run($context);
} catch (\Throwable) {
$exceptionThrown = true;
}
$this->assertFalse($exceptionThrown);
}
/**
* LogStaleServiceProviders: 예외 swallow 동작 검증.
*/
public function test_log_stale_providers_swallows_exceptions(): void
{
$context = new UpgradeContext('7.0.0-beta.5', '7.0.0-beta.6', '7.0.0-beta.6');
$migration = new LogStaleServiceProviders;
$exceptionThrown = false;
try {
$migration->run($context);
} catch (\Throwable) {
$exceptionThrown = true;
}
$this->assertFalse($exceptionThrown);
}
/**
* DataMigration name() 반환값 검증.
*/
public function test_data_migration_names(): void
{
$this->assertSame('BackfillNewFilesManifest', (new BackfillNewFilesManifest)->name());
$this->assertSame('LogStaleServiceProviders', (new LogStaleServiceProviders)->name());
}
}
@@ -0,0 +1,391 @@
<?php
namespace Tests\Unit\Extension;
use App\Extension\Helpers\CoreBackupHelper;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
/**
* CoreBackupHelper 의 manifest 기반 prune 단위 테스트.
*
* 자동 롤백 시 신 버전 신규 파일이 활성 디렉토리에 잔존하는 결함을 차단하는
* `writeNewFilesManifest()` / `pruneNewFiles()` 의 동작을 검증한다.
*
* 본 테스트는 BASE_PATH 를 건드리지 않는다 — 백업/_pending/활성 디렉토리 3개를
* 모두 storage/app/test_core_prune/ 아래 임시 fixture 로 구성하고 helper 의
* "활성" 인자에 임시 경로를 명시 전달하는 방식으로 격리한다.
*/
class CoreBackupHelperPruneTest extends TestCase
{
private string $testRoot;
private string $backupPath;
private string $sourcePath;
private string $activePath;
protected function setUp(): void
{
parent::setUp();
$this->testRoot = storage_path('app/test_core_prune_'.uniqid());
$this->backupPath = $this->testRoot.'/backup';
$this->sourcePath = $this->testRoot.'/_pending';
$this->activePath = $this->testRoot.'/active';
File::ensureDirectoryExists($this->backupPath);
File::ensureDirectoryExists($this->sourcePath);
File::ensureDirectoryExists($this->activePath);
}
protected function tearDown(): void
{
if (File::isDirectory($this->testRoot)) {
File::deleteDirectory($this->testRoot);
}
parent::tearDown();
}
// ========================================================================
// writeNewFilesManifest() — manifest 생성
// ========================================================================
/**
* _pending 에만 있고 backup 에는 없는 파일이 new_files 로 식별되는지 검증.
*/
public function test_write_manifest_identifies_new_files_in_pending_only(): void
{
// backup (= 활성 디렉토리의 사전 스냅샷) 에 있는 기존 파일
File::ensureDirectoryExists($this->backupPath.'/app/Services');
File::put($this->backupPath.'/app/Services/ExistingService.php', '<?php');
// _pending 에는 기존 + 신규
File::ensureDirectoryExists($this->sourcePath.'/app/Services');
File::put($this->sourcePath.'/app/Services/ExistingService.php', '<?php // updated');
File::put($this->sourcePath.'/app/Services/NewServiceProviderClass.php', '<?php // new');
$result = CoreBackupHelper::writeNewFilesManifest(
$this->backupPath,
$this->sourcePath,
['app'],
[],
[],
'7.0.0-beta.5',
'7.0.0-beta.6',
);
$manifestPath = $this->backupPath.'/_new_files_manifest.json';
$this->assertFileExists($manifestPath);
$manifest = json_decode(File::get($manifestPath), true);
$this->assertIsArray($manifest);
$this->assertSame(1, $manifest['version']);
$this->assertSame('7.0.0-beta.5', $manifest['from_version']);
$this->assertSame('7.0.0-beta.6', $manifest['to_version']);
$this->assertContains('app/Services/NewServiceProviderClass.php', $manifest['new_files']);
$this->assertNotContains('app/Services/ExistingService.php', $manifest['new_files']);
$this->assertGreaterThanOrEqual(1, $result['new_files_count']);
}
/**
* _pending 에만 있는 신규 디렉토리가 new_dirs 로 식별되는지 검증.
*/
public function test_write_manifest_identifies_new_dirs(): void
{
File::ensureDirectoryExists($this->backupPath.'/app');
File::put($this->backupPath.'/app/keep.php', '<?php');
File::ensureDirectoryExists($this->sourcePath.'/app');
File::put($this->sourcePath.'/app/keep.php', '<?php');
File::ensureDirectoryExists($this->sourcePath.'/app/Services/LanguagePack');
File::put($this->sourcePath.'/app/Services/LanguagePack/Module.php', '<?php');
CoreBackupHelper::writeNewFilesManifest(
$this->backupPath,
$this->sourcePath,
['app'],
[],
[],
'7.0.0-beta.5',
'7.0.0-beta.6',
);
$manifest = json_decode(File::get($this->backupPath.'/_new_files_manifest.json'), true);
$this->assertContains('app/Services/LanguagePack', $manifest['new_dirs']);
$this->assertContains('app/Services/LanguagePack/Module.php', $manifest['new_files']);
}
/**
* protectedPaths 하위는 manifest 에서 제외되는지 검증 (방어 깊이).
*/
public function test_write_manifest_excludes_protected_paths(): void
{
// _pending 에 storage/.env 가상 신규 파일 — 보호 경로
File::ensureDirectoryExists($this->sourcePath.'/storage');
File::put($this->sourcePath.'/storage/secret.txt', 'should not be tracked');
File::ensureDirectoryExists($this->sourcePath.'/app');
File::put($this->sourcePath.'/app/Real.php', '<?php');
CoreBackupHelper::writeNewFilesManifest(
$this->backupPath,
$this->sourcePath,
['app', 'storage'],
['storage'],
[],
'7.0.0-beta.5',
'7.0.0-beta.6',
);
$manifest = json_decode(File::get($this->backupPath.'/_new_files_manifest.json'), true);
$this->assertNotContains('storage/secret.txt', $manifest['new_files']);
$this->assertContains('app/Real.php', $manifest['new_files']);
}
/**
* excludes 패턴 (node_modules, .git 등) 은 manifest 에서 제외.
*/
public function test_write_manifest_excludes_pattern_matches(): void
{
File::ensureDirectoryExists($this->sourcePath.'/app/node_modules');
File::put($this->sourcePath.'/app/node_modules/lib.js', '');
File::ensureDirectoryExists($this->sourcePath.'/app');
File::put($this->sourcePath.'/app/Real.php', '<?php');
CoreBackupHelper::writeNewFilesManifest(
$this->backupPath,
$this->sourcePath,
['app'],
[],
['node_modules'],
'7.0.0-beta.5',
'7.0.0-beta.6',
);
$manifest = json_decode(File::get($this->backupPath.'/_new_files_manifest.json'), true);
$this->assertNotContains('app/node_modules/lib.js', $manifest['new_files']);
$this->assertContains('app/Real.php', $manifest['new_files']);
}
/**
* manifest JSON 의 키 순서 / 정렬 규칙 invariant — beta.6 Upgrade DataMigration 01
* 의 사후 작성본과 바이트 단위 호환을 보장하기 위해 키 순서/정렬을 고정한다.
*/
public function test_manifest_schema_invariant(): void
{
File::ensureDirectoryExists($this->sourcePath.'/app');
File::put($this->sourcePath.'/app/Beta.php', '<?php');
File::put($this->sourcePath.'/app/Alpha.php', '<?php');
CoreBackupHelper::writeNewFilesManifest(
$this->backupPath,
$this->sourcePath,
['app'],
[],
[],
'7.0.0-beta.5',
'7.0.0-beta.6',
);
$manifest = json_decode(File::get($this->backupPath.'/_new_files_manifest.json'), true);
// 정렬 규칙: new_files / new_dirs 는 lexicographic ascending
$sorted = $manifest['new_files'];
$copy = $sorted;
sort($copy, SORT_STRING);
$this->assertSame($copy, $sorted, 'new_files 는 알파벳 오름차순 정렬되어야 함');
// 최상위 키 6종 명시 (DataMigration 01 의 invariant)
$this->assertSame(
['version', 'created_at', 'from_version', 'to_version', 'new_files', 'new_dirs'],
array_keys($manifest),
);
}
// ========================================================================
// pruneNewFiles() — manifest 기반 신규 파일 정리
// ========================================================================
/**
* manifest 에 등록된 신규 파일이 활성 디렉토리에서 삭제되는지 검증.
*/
public function test_prune_removes_new_files_listed_in_manifest(): void
{
File::ensureDirectoryExists($this->activePath.'/app/Services/LanguagePack');
File::put($this->activePath.'/app/Services/LanguagePack/Module.php', '<?php');
File::put($this->activePath.'/app/Services/UserKeep.php', '<?php // user');
$this->writeManifest([
'new_files' => ['app/Services/LanguagePack/Module.php'],
'new_dirs' => ['app/Services/LanguagePack'],
]);
$result = CoreBackupHelper::pruneNewFiles($this->backupPath, $this->activePath, []);
$this->assertFileDoesNotExist($this->activePath.'/app/Services/LanguagePack/Module.php');
$this->assertDirectoryDoesNotExist($this->activePath.'/app/Services/LanguagePack');
// 사용자 파일은 보존
$this->assertFileExists($this->activePath.'/app/Services/UserKeep.php');
$this->assertSame(1, $result['removed_files']);
$this->assertSame(1, $result['removed_dirs']);
}
/**
* manifest 의 new_dirs 중 사용자 파일이 남아있는 디렉토리는 rmdir 안 함.
*/
public function test_prune_keeps_dirs_with_user_files(): void
{
File::ensureDirectoryExists($this->activePath.'/app/Services/LanguagePack');
File::put($this->activePath.'/app/Services/LanguagePack/Module.php', '<?php');
File::put($this->activePath.'/app/Services/LanguagePack/UserAdded.php', '<?php // user');
$this->writeManifest([
'new_files' => ['app/Services/LanguagePack/Module.php'],
'new_dirs' => ['app/Services/LanguagePack'],
]);
$result = CoreBackupHelper::pruneNewFiles($this->backupPath, $this->activePath, []);
$this->assertFileDoesNotExist($this->activePath.'/app/Services/LanguagePack/Module.php');
$this->assertDirectoryExists($this->activePath.'/app/Services/LanguagePack');
$this->assertFileExists($this->activePath.'/app/Services/LanguagePack/UserAdded.php');
$this->assertSame(1, $result['removed_files']);
$this->assertSame(0, $result['removed_dirs']);
}
/**
* protected_paths 하위 manifest 항목은 prune 시점에 재검증되어 삭제되지 않음.
*/
public function test_prune_respects_protected_paths_double_guard(): void
{
// manifest 에 가상으로 보호 경로 항목을 주입 — 운영 결함 또는 악의적 매니페스트 시뮬레이션
File::ensureDirectoryExists($this->activePath.'/storage');
File::put($this->activePath.'/storage/secret.txt', 'must keep');
$this->writeManifest([
'new_files' => ['storage/secret.txt'],
'new_dirs' => [],
]);
$result = CoreBackupHelper::pruneNewFiles(
$this->backupPath,
$this->activePath,
['storage'],
);
$this->assertFileExists($this->activePath.'/storage/secret.txt');
$this->assertSame(0, $result['removed_files']);
$this->assertGreaterThanOrEqual(1, $result['protected_count']);
}
/**
* 활성 디렉토리에 symlink 가 있고 manifest 에 등재되어 있어도 절대 삭제하지 않음.
* Windows 에서는 markTestSkipped (symlink() 권한 제약).
*/
public function test_prune_skips_symlinks_unconditionally(): void
{
if (PHP_OS_FAMILY === 'Windows') {
$this->markTestSkipped('Windows 에서는 PHP symlink() 권한이 필요하여 건너뜀');
}
File::ensureDirectoryExists($this->activePath.'/public');
File::ensureDirectoryExists($this->activePath.'/storage/app/public');
symlink($this->activePath.'/storage/app/public', $this->activePath.'/public/storage');
$this->writeManifest([
'new_files' => ['public/storage'],
'new_dirs' => [],
]);
$result = CoreBackupHelper::pruneNewFiles($this->backupPath, $this->activePath, []);
$this->assertTrue(is_link($this->activePath.'/public/storage'));
$this->assertGreaterThanOrEqual(1, $result['symlink_skipped']);
}
/**
* manifest 부재 시 prune 은 noop + warning 없이 빈 결과 반환.
*/
public function test_prune_returns_zero_when_manifest_absent(): void
{
File::put($this->activePath.'/keep.php', '<?php');
$result = CoreBackupHelper::pruneNewFiles($this->backupPath, $this->activePath, []);
$this->assertFileExists($this->activePath.'/keep.php');
$this->assertSame(0, $result['removed_files']);
$this->assertSame(0, $result['removed_dirs']);
$this->assertFalse($result['manifest_loaded']);
}
/**
* manifest JSON 파싱 실패 시 prune 은 noop + manifest_loaded:false.
*/
public function test_prune_handles_corrupted_manifest_gracefully(): void
{
File::put($this->backupPath.'/_new_files_manifest.json', '{this is not valid json');
File::put($this->activePath.'/keep.php', '<?php');
$result = CoreBackupHelper::pruneNewFiles($this->backupPath, $this->activePath, []);
$this->assertFileExists($this->activePath.'/keep.php');
$this->assertFalse($result['manifest_loaded']);
}
/**
* manifest 의 new_files 가 실제 디스크에 없으면 (이미 삭제됨) skip.
*/
public function test_prune_skips_missing_files_gracefully(): void
{
$this->writeManifest([
'new_files' => ['app/Nonexistent.php'],
'new_dirs' => ['app/Ghost'],
]);
$result = CoreBackupHelper::pruneNewFiles($this->backupPath, $this->activePath, []);
$this->assertSame(0, $result['removed_files']);
$this->assertSame(0, $result['removed_dirs']);
}
/**
* 빈 디렉토리 정리 — new_dirs 가 깊이 역순으로 처리되어 중첩 디렉토리도 제거.
*/
public function test_prune_empty_dirs_in_depth_reverse_order(): void
{
File::ensureDirectoryExists($this->activePath.'/a/b/c');
File::put($this->activePath.'/a/b/c/file.php', '<?php');
$this->writeManifest([
'new_files' => ['a/b/c/file.php'],
'new_dirs' => ['a', 'a/b', 'a/b/c'],
]);
$result = CoreBackupHelper::pruneNewFiles($this->backupPath, $this->activePath, []);
$this->assertDirectoryDoesNotExist($this->activePath.'/a');
$this->assertSame(3, $result['removed_dirs']);
}
private function writeManifest(array $data): void
{
$manifest = [
'version' => 1,
'created_at' => date('c'),
'from_version' => '7.0.0-beta.5',
'to_version' => '7.0.0-beta.6',
'new_files' => $data['new_files'] ?? [],
'new_dirs' => $data['new_dirs'] ?? [],
];
File::put($this->backupPath.'/_new_files_manifest.json', json_encode(
$manifest,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
));
}
}
+46
View File
@@ -65,6 +65,52 @@ class EnvironmentDetectorTest extends TestCase
$this->assertSame($hint, $result);
}
/**
* stat 가드 완화 회귀 — 시놀로지 DSM 등 open_basedir 환경에서 정상 절대경로
* 가 false negative 로 거부되지 않고 hint/config 후보로 채택되어야 함.
* 실제 실행 가능 여부는 canExecuteComposer 의 proc_open 결과로 최종 판정.
*/
public function test_find_composer_binary_accepts_safe_absolute_path_hint_without_stat(): void
{
$detector = new EnvironmentDetector;
$detector->resetCache();
// 실제로 존재하지 않는 경로지만 메타문자 없음 — stat 가드 제거로 채택됨
$hint = '/usr/local/bin/composer';
$result = $detector->findComposerBinary($hint);
$this->assertSame($hint, $result);
}
public function test_find_composer_binary_rejects_single_token_hint_with_shell_metachars(): void
{
$detector = new EnvironmentDetector;
$detector->resetCache();
$original = config('process.composer_binary');
config(['process.composer_binary' => null]);
try {
// 단일 토큰(공백 없음) 셸 메타문자 hint 는 isExecutableCandidate 가 거부.
// 공백 포함 hint 는 .env 값을 신뢰하는 운영자 자기 책임 영역이라 별도 분기.
foreach ([
'/bin/composer;id',
'/bin/composer$(id)',
'`/bin/composer`',
'/bin/composer|nc',
'/bin/composer&id',
] as $payload) {
$detector->resetCache();
$result = $detector->findComposerBinary($payload);
// 메타문자 hint 는 절대 채택되지 않음 — 결과는 PATH 검색 결과 또는 null
$this->assertNotSame($payload, $result, "메타문자 hint 거부: {$payload}");
}
} finally {
config(['process.composer_binary' => $original]);
}
}
public function test_summarize_returns_complete_report(): void
{
$report = $this->detector->summarize();
@@ -40,7 +40,22 @@ class ComposerPathValidationTest extends TestCase
define('CHECK_CONFIGURATION_LIBRARY', true);
}
require_once dirname(__DIR__, 3) . '/public/install/api/check-configuration.php';
// BASE_PATH 가 다른 테스트 인프라에 의해 정의되지 않은 경우, 프로젝트 루트로
// 정의해 인접 테스트(DeleteDirectoryTest/InstallerWindowsCommandsTest)의
// `require_once BASE_PATH . '/public/install/...'` 패턴이 깨지지 않도록 함.
$projectRoot = dirname(__DIR__, 3);
if (! defined('BASE_PATH')) {
define('BASE_PATH', $projectRoot);
}
if (! defined('STATE_PATH')) {
define('STATE_PATH', BASE_PATH . '/storage/installer-state.json');
}
require_once $projectRoot . '/public/install/api/check-configuration.php';
// applyInstallerComposerEnvVars() 는 functions.php 에 정의. stat 가드 완화 회복
// 이후 시스템 기본 'composer' 또는 정상 절대경로 입력이 exec 단계까지 도달하면서
// 이 함수를 호출하므로 사전 로드 필요.
require_once $projectRoot . '/public/install/includes/functions.php';
self::$loaded = true;
}
@@ -36,24 +36,25 @@ class InstallerSecurityHardeningTest extends TestCase
define('CHECK_CONFIGURATION_LIBRARY', true);
}
// BASE_PATH 미정의 시 프로젝트 루트로 정의 — 같은 PHPUnit 프로세스에서 뒤에
// 실행되는 인접 테스트(DeleteDirectoryTest, InstallerWindowsCommandsTest 등)가
// `require_once BASE_PATH . '/public/install/...'` 로 로드하는 패턴이 깨지지 않도록.
$projectRoot = dirname(__DIR__, 3);
if (! defined('BASE_PATH')) {
define('BASE_PATH', $projectRoot);
}
// STATE_PATH 는 테스트 격리용 임시 경로로 분리 — 운영 storage/installer-state.json 미오염.
self::$tempBase = sys_get_temp_dir() . '/g7-installer-hardening-' . bin2hex(random_bytes(4));
@mkdir(self::$tempBase . '/storage/app', 0755, true);
if (! defined('BASE_PATH')) {
define('BASE_PATH', self::$tempBase);
} else {
self::$tempBase = BASE_PATH;
@mkdir(BASE_PATH . '/storage/app', 0755, true);
}
if (! defined('STATE_PATH')) {
define('STATE_PATH', BASE_PATH . '/storage/installer-state.json');
define('STATE_PATH', self::$tempBase . '/storage/installer-state.json');
}
require_once dirname(__DIR__, 3) . '/public/install/api/check-configuration.php';
require_once dirname(__DIR__, 3) . '/public/install/includes/installer-state.php';
require_once dirname(__DIR__, 3) . '/public/install/includes/functions.php';
require_once dirname(__DIR__, 3) . '/public/install/includes/task-runner.php';
require_once $projectRoot . '/public/install/api/check-configuration.php';
require_once $projectRoot . '/public/install/includes/installer-state.php';
require_once $projectRoot . '/public/install/includes/functions.php';
require_once $projectRoot . '/public/install/includes/task-runner.php';
self::$loaded = true;
}
@@ -122,13 +123,16 @@ class InstallerSecurityHardeningTest extends TestCase
$this->clearState();
}
public function test_getComposerCommand_rejects_nonexistent_path(): void
public function test_getComposerCommand_passes_through_safe_absolute_path(): void
{
$this->writeState(['composer_binary' => '/nonexistent/path/to/composer']);
// open_basedir 같은 PHP 런타임 제약 환경의 false negative 를 피하기 위해
// stat 의존 가드가 제거됨. 메타문자 없는 단일 절대경로는 escape 후 그대로 사용.
// 실제 실행 가능 여부는 exec 결과로 최종 판정 (silent fallback 안 함).
$this->writeState(['composer_binary' => '/usr/local/bin/composer']);
$cmd = getComposerCommand();
$this->assertSame('composer', $cmd);
$this->assertSame(escapeshellarg('/usr/local/bin/composer'), $cmd);
$this->clearState();
}
@@ -153,11 +157,252 @@ class InstallerSecurityHardeningTest extends TestCase
public function test_isInstallerExecutablePath_rejects_metachars(): void
{
foreach (['foo bar', 'a;b', 'a`b', 'a$b', 'a|b', "a\nb", 'a"b', "a'b", 'a\\b'] as $bad) {
$this->assertFalse(isInstallerExecutablePath($bad), "메타문자 거부: {$bad}");
// 백슬래시는 Windows 경로 구분자이므로 차단 대상 아님 (escapeshellarg 가 셸 인젝션 차단)
foreach (['foo bar', 'a;b', 'a`b', 'a$b', 'a|b', "a\nb", 'a"b', "a'b", "a\0b", "a\x01b", "a\rb"] as $bad) {
$this->assertFalse(isInstallerExecutablePath($bad), "메타문자 거부: " . bin2hex($bad));
}
}
public function test_isInstallerExecutablePath_accepts_windows_paths(): void
{
// Windows 절대경로는 백슬래시 포함이지만 공백·메타문자 없으면 통과.
// 공백 포함 디렉토리(예: 'C:\\Program Files\\...') 는 공백 분리 입력 형식의 토큰
// 분리 휴리스틱과 충돌하므로 본 회복 범위 외 — 알려진 한계.
foreach ([
'C:\\laragon\\bin\\php\\php-8.3.26-Win32-vs16-x64\\php.exe',
'C:\\php\\php.exe',
'D:\\xampp\\php\\php.exe',
'C:\\php\\composer.phar',
] as $path) {
$this->assertTrue(isInstallerExecutablePath($path), "Windows 경로 허용: {$path}");
}
}
public function test_isInstallerExecutablePath_rejects_windows_path_with_space_known_limitation(): void
{
// 'C:\\Program Files\\...' 같은 공백 포함 Windows 경로는 본 회복 범위 외.
// 공백 분리 입력(PHP + Composer 합성) 형식 휴리스틱과 충돌 — escapeshellarg 단일 토큰
// wrap 만으로는 공백 의도(토큰 구분 vs 디렉토리명) 를 자동 구분할 수 없음.
// 회피: 8.3 short path 형식(C:\\PROGRA~1\\...) 사용 또는 공백 없는 경로 사용.
$this->assertFalse(isInstallerExecutablePath('C:\\Program Files\\PHP\\php.exe'));
}
// ========================================================================
// open_basedir 회귀 회복 — stat 의존 가드 완화
// ========================================================================
/**
* stat 가드가 제거되어 시놀로지 DSM 등 open_basedir 환경에서도
* 정상 절대경로 입력이 통과해야 함. 실제 실행 가능 여부는 exec 결과로 판정.
*/
public function test_isInstallerExecutablePath_accepts_safe_absolute_path_without_stat(): void
{
// 실제로 존재하지 않는 경로지만 메타문자 없음 — stat 가드 제거로 true
$this->assertTrue(isInstallerExecutablePath('/usr/local/bin/php83'));
$this->assertTrue(isInstallerExecutablePath('/opt/plesk/php/8.3/bin/php'));
$this->assertTrue(isInstallerExecutablePath('/nonexistent/path/to/binary'));
}
// ========================================================================
// Composer 공백 분리 입력 안전 복원 — 멀티 PHP 환경 호환성
// ========================================================================
public function test_getComposerCommand_accepts_php_composer_space_separated_input(): void
{
// 시놀로지/cPanel/Plesk 멀티 PHP 환경의 운영 패턴
$this->writeState(['composer_binary' => '/usr/local/bin/php83 /usr/local/bin/composer']);
$cmd = getComposerCommand();
// 두 토큰 각각 escapeshellarg 적용 후 공백으로 합성
$expected = escapeshellarg('/usr/local/bin/php83') . ' ' . escapeshellarg('/usr/local/bin/composer');
$this->assertSame($expected, $cmd);
$this->clearState();
}
public function test_getComposerCommand_rejects_space_separated_with_metachar_in_first_token(): void
{
// 첫 토큰에 메타문자 → 거부, composer 폴백
$this->writeState(['composer_binary' => '/usr/local/bin/php$(id) /usr/local/bin/composer']);
$cmd = getComposerCommand();
$this->assertSame('composer', $cmd);
$this->clearState();
}
public function test_getComposerCommand_rejects_space_separated_with_metachar_in_second_token(): void
{
// 두 번째 토큰에 메타문자 → 거부, composer 폴백
$this->writeState(['composer_binary' => '/usr/local/bin/php83 /usr/local/bin/composer;id']);
$cmd = getComposerCommand();
$this->assertSame('composer', $cmd);
$this->clearState();
}
public function test_getComposerCommand_rejects_three_or_more_tokens(): void
{
// 3 토큰 이상은 두 번째 토큰에 공백이 남음 → 두 번째 토큰의 메타문자(공백) 로 거부
$this->writeState(['composer_binary' => '/bin/php /bin/composer extra_arg']);
$cmd = getComposerCommand();
$this->assertSame('composer', $cmd);
$this->clearState();
}
public function test_getComposerCommandForDisplay_shows_human_friendly_space_separated_form(): void
{
$this->writeState(['composer_binary' => '/usr/local/bin/php83 /usr/local/bin/composer']);
$display = getComposerCommandForDisplay();
// 사람에게 보이는 표기는 escape 없는 원본 형식 유지
$this->assertSame('/usr/local/bin/php83 /usr/local/bin/composer', $display);
$this->clearState();
}
public function test_getComposerCommandForDisplay_does_not_leak_shell_payload_in_space_separated(): void
{
// 두 토큰 중 하나라도 메타문자 포함이면 display 도 composer 폴백
$this->writeState(['composer_binary' => '/bin/php `id`']);
$display = getComposerCommandForDisplay();
$this->assertSame('composer', $display);
$this->assertStringNotContainsString('`', $display);
$this->clearState();
}
// ========================================================================
// validatePhpPath / validateComposerPath stat 가드 완화 (인스톨러 검증 API)
// ========================================================================
public function test_validatePhpPath_rejects_shell_metachars(): void
{
$api = new ValidationApi();
foreach ([
'/usr/local/bin/php; id',
'/usr/local/bin/php$(id)',
'`/usr/local/bin/php`',
'/usr/local/bin/php|nc evil',
"/usr/local/bin/php\nrm -rf /",
] as $payload) {
$result = $this->invokePrivate($api, 'validatePhpPath', [$payload]);
$this->assertFalse($result['valid'], "메타문자 거부: {$payload}");
}
}
public function test_validatePhpPath_rejects_empty(): void
{
$api = new ValidationApi();
$result = $this->invokePrivate($api, 'validatePhpPath', ['']);
$this->assertFalse($result['valid']);
}
public function test_validatePhpPath_safe_path_reaches_exec_phase(): void
{
// 메타문자 없는 절대경로는 stat 가드 없이 exec 단계까지 도달.
// 존재하지 않으면 exec 실패 (return code != 0) 로 거부 — 단, 거부 메시지는
// 'error_php_exec_failed' 로 stat 거부와 동일. 핵심: stat 가드가 사라졌다는 점.
$api = new ValidationApi();
$result = $this->invokePrivate($api, 'validatePhpPath', ['/nonexistent/path/to/php']);
// 실제 실행 실패로 invalid 이지만, 그 판정이 exec 결과에 기반함을 의미적으로 검증.
$this->assertFalse($result['valid']);
}
public function test_validateComposerPath_rejects_shell_metachars(): void
{
$api = new ValidationApi();
foreach ([
'/usr/local/bin/composer; id',
'/usr/local/bin/composer$(id)',
'`/usr/local/bin/composer`',
"/usr/local/bin/composer\nrm",
] as $payload) {
$result = $this->invokePrivate($api, 'validateComposerPath', [$payload]);
$this->assertFalse($result['valid'], "메타문자 거부: {$payload}");
}
}
public function test_validateComposerPath_rejects_space_separated_with_metachar(): void
{
// 공백 분리 입력의 토큰별 메타문자 검증
$api = new ValidationApi();
foreach ([
'/bin/php$(id) /bin/composer',
'/bin/php /bin/composer;id',
'/bin/php `id`',
'/bin/php /bin/composer extra_token',
] as $payload) {
$result = $this->invokePrivate($api, 'validateComposerPath', [$payload]);
$this->assertFalse($result['valid'], "공백 분리 + 메타문자 거부: {$payload}");
}
}
public function test_splitPhpComposerTokens_helper_splits_into_two_tokens(): void
{
$api = new ValidationApi();
$result = $this->invokePrivate($api, 'splitPhpComposerTokens', ['/usr/local/bin/php83 /usr/local/bin/composer']);
$this->assertSame(['php' => '/usr/local/bin/php83', 'composer' => '/usr/local/bin/composer'], $result);
}
public function test_splitPhpComposerTokens_helper_returns_null_for_single_token(): void
{
$api = new ValidationApi();
$result = $this->invokePrivate($api, 'splitPhpComposerTokens', ['/usr/local/bin/composer']);
$this->assertNull($result);
}
public function test_isInstallerSafePathArg_helper_accepts_safe_paths(): void
{
$api = new ValidationApi();
foreach ([
'/usr/local/bin/php83',
'/opt/php/bin/php',
'C:/php/php.exe',
'/nonexistent/path',
// Windows 절대경로 (백슬래시 포함) — 회귀 가드: PO 환경 (Windows 빌트인 서버)
'C:\\laragon\\bin\\php\\php-8.3.26-Win32-vs16-x64\\php.exe',
'C:\\php\\php.exe',
'D:\\xampp\\php\\php.exe',
] as $safe) {
$this->assertTrue($this->invokePrivate($api, 'isInstallerSafePathArg', [$safe]), "허용: {$safe}");
}
}
public function test_isInstallerSafePathArg_helper_rejects_metachars_and_empty(): void
{
$api = new ValidationApi();
$this->assertFalse($this->invokePrivate($api, 'isInstallerSafePathArg', ['']));
// 백슬래시는 Windows 경로 구분자이므로 차단 대상이 아님 — 셸 인젝션 차단은 escapeshellarg 가 담당
foreach (['foo bar', 'a;b', 'a`b', 'a$b', 'a|b', "a\nb", 'a"b', "a'b", "a\0b", "a\x01b", "a\rb"] as $bad) {
$this->assertFalse($this->invokePrivate($api, 'isInstallerSafePathArg', [$bad]), "메타문자 거부: " . bin2hex($bad));
}
}
// ========================================================================
// 워커 정합 — 검증 통과한 입력이 워커에서도 silent fallback 없이 전달
// ========================================================================
public function test_getComposerCommand_passes_through_single_absolute_path_without_silent_fallback(): void
{
// 검증 단계에서 통과한 단일 절대경로가 워커에서도 그대로 사용됨을 보장.
// stat 가드 제거 후 회귀 — 정상 입력이 silent fallback 으로 사라지지 않음.
$this->writeState(['composer_binary' => '/usr/local/bin/composer']);
$cmd = getComposerCommand();
$this->assertNotSame('composer', $cmd, '검증 통과한 입력은 silent fallback 안 됨');
$this->assertSame(escapeshellarg('/usr/local/bin/composer'), $cmd);
$this->clearState();
}
// ========================================================================
// High-2 — checkCorePendingPath traversal 차단
// ========================================================================
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace Tests\Unit\Installer;
use PHPUnit\Framework\TestCase;
/**
* 인스톨러 세션 쿠키 round-trip 진단 endpoint 회귀 테스트
*
* 브라우저가 PHPSESSID 쿠키를 보내지 않아 인스톨러가 Step 0 무한 루프에 빠지는
* 케이스를 사전 감지하기 위한 endpoint. set 으로 세션에 nonce 저장 후 verify 로
* 같은 세션에서 nonce 가 복원되는지 확인.
*/
class SessionProbeTest extends TestCase
{
private static bool $loaded = false;
public static function setUpBeforeClass(): void
{
if (self::$loaded) {
return;
}
require_once __DIR__ . '/stubs/lang_stub.php';
$projectRoot = dirname(__DIR__, 3);
if (! defined('BASE_PATH')) {
define('BASE_PATH', $projectRoot);
}
if (! defined('STATE_PATH')) {
define('STATE_PATH', sys_get_temp_dir() . '/g7-session-probe-test-' . bin2hex(random_bytes(4)) . '.json');
}
if (! defined('SESSION_PROBE_LIBRARY')) {
define('SESSION_PROBE_LIBRARY', true);
}
require_once $projectRoot . '/public/install/includes/functions.php';
require_once $projectRoot . '/public/install/api/session-probe.php';
self::$loaded = true;
}
protected function setUp(): void
{
parent::setUp();
// 각 테스트 시작 시 세션 상태 초기화
$_SESSION = [];
}
public function test_set_action_generates_nonce_and_stores_in_session(): void
{
$result = sessionProbeSet();
$this->assertArrayHasKey('action', $result);
$this->assertSame('set', $result['action']);
$this->assertArrayHasKey('nonce', $result);
$this->assertIsString($result['nonce']);
// 32 hex chars (random_bytes(16))
$this->assertSame(32, strlen($result['nonce']));
$this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $result['nonce']);
// 세션에 저장 확인
$this->assertArrayHasKey('_installer_session_probe', $_SESSION);
$this->assertSame($result['nonce'], $_SESSION['_installer_session_probe']);
}
public function test_verify_action_matches_when_session_preserved(): void
{
$setResult = sessionProbeSet();
$verifyResult = sessionProbeVerify();
$this->assertSame('verify', $verifyResult['action']);
$this->assertTrue($verifyResult['matched']);
$this->assertSame($setResult['nonce'], $verifyResult['nonce'] ?? null);
}
public function test_verify_action_returns_unmatched_when_session_empty(): void
{
// set 호출 없이 verify — 세션 쿠키가 round-trip 되지 않은 시뮬레이션
$verifyResult = sessionProbeVerify();
$this->assertSame('verify', $verifyResult['action']);
$this->assertFalse($verifyResult['matched']);
}
public function test_set_generates_distinct_nonces_on_repeated_calls(): void
{
$first = sessionProbeSet();
$second = sessionProbeSet();
// 매 호출마다 새 nonce — 이전 호출의 nonce 가 캐시되지 않음
$this->assertNotSame($first['nonce'], $second['nonce']);
// 세션에는 마지막 set 의 nonce 만 남음
$this->assertSame($second['nonce'], $_SESSION['_installer_session_probe']);
}
public function test_verify_action_consumes_nonce(): void
{
sessionProbeSet();
sessionProbeVerify();
// 한 번 verify 한 nonce 는 재사용 방지를 위해 세션에서 제거되어야 함
$this->assertArrayNotHasKey('_installer_session_probe', $_SESSION);
// 다음 verify 는 세션 빔 케이스와 동일하게 unmatched
$secondVerify = sessionProbeVerify();
$this->assertFalse($secondVerify['matched']);
}
}
@@ -0,0 +1,77 @@
# audit:allow test-scenario-coverage reason: 본 매니페스트는 `core:execute-upgrade-steps` 단독 실행 안전화 (사전 2단계 + 사후 3단계 자동 수행 + 5개 --skip-* 옵션) 의 시나리오 매트릭스 SSoT. 핵심 회귀 가드는 test_files 의 통과 테스트로 커버.
feature: core:execute-upgrade-steps 단독 실행 안전화 (사전·사후 단계 자동 수행 + 5개 --skip-* 옵션)
description: |
공개 이슈 [gnuboard/g7#34](https://github.com/gnuboard/g7/issues/34) (beta.3 → beta.4 우회 절차) 의 운영자 안내문이
11단계의 수동 명령 (rsync → composer dump-autoload → cache:clear →
core:execute-upgrade-steps → migrate --force → reloadCoreConfigAndResync via tinker →
sed APP_VERSION → cache:clear → module/plugin/template:update --force --source=bundled)
을 요구했던 근본 원인은 `core:execute-upgrade-steps` 가 단독 호출 시
부모 `CoreUpdateCommand` 가 수행하던 Step 9 (Migration + Resync), Step 11
(.env 버전 + 캐시 정리), 번들 확장 일괄 업데이트 prompt 를 모두 누락한 것.
본 매니페스트는 5개 누락 단계를 자식 기본값에 포함시키고, 부모 spawn 호출 시엔
5개 `--skip-*` 옵션으로 중복 회피하는 cross product 의 회귀 가드.
구성:
1. 5개 사전·사후 단계 각각의 단독 호출 / `--skip-*` 옵션 분기 검증.
2. 부모 `CoreUpdateCommand::spawnUpgradeStepsProcess` 가 자식 command 배열에
5개 옵션을 모두 전달하는지 (escapeshellarg 후 commandLine 문자열 검증).
3. `runUpgradeSteps` 본 임무는 항상 호출 (옵션 무관).
4. 5개 옵션을 모두 전달한 경우 CoreUpdateCommand spawn 시나리오 등가 — 사전·사후 미수행.
axes:
invocation_origin: [standalone_operator, parent_spawn] # 누가 호출했는가
skip_migrations: [present, absent]
skip_resync: [present, absent]
skip_version_env: [present, absent]
skip_cache_clear: [present, absent]
skip_bundled_updates: [present, absent]
force_flag: [present, absent] # bundled prompt 우회 여부
bundled_updates_count: [zero, modules_only, plugins_only, templates_only, lang_packs_only, mixed]
from_to_relation: [from_lt_to, from_eq_to_forced] # version_compare 분기
exclusions:
- { invocation_origin: parent_spawn, skip_migrations: absent, reason: "부모 spawn 시엔 5개 옵션 모두 전달 — 일부 절단 시뮬은 인위적 비현실 분기" }
- { invocation_origin: parent_spawn, skip_resync: absent, reason: "동일 — 부모 spawn 시 5개 옵션 일괄 전달" }
- { invocation_origin: parent_spawn, skip_version_env: absent, reason: "동일" }
- { invocation_origin: parent_spawn, skip_cache_clear: absent, reason: "동일" }
- { invocation_origin: parent_spawn, skip_bundled_updates: absent, reason: "동일" }
- { invocation_origin: standalone_operator, bundled_updates_count: zero, skip_bundled_updates: absent, force_flag: absent, reason: "0건 + 옵션 미사용 + force 미사용 시 prompt 발동되나 본 매트릭스의 회귀 대상은 옵션 분기뿐" }
effects:
# §1 사전 단계 자동 수행
- ExecuteUpgradeStepsCommand_invokes_runMigrations_by_default
- ExecuteUpgradeStepsCommand_skips_runMigrations_when_skip_migrations_present
- ExecuteUpgradeStepsCommand_invokes_reloadCoreConfigAndResync_by_default
- ExecuteUpgradeStepsCommand_skips_reloadCoreConfigAndResync_when_skip_resync_present
# §2 사후 단계 자동 수행
- ExecuteUpgradeStepsCommand_invokes_updateVersionInEnv_with_to_version_by_default
- ExecuteUpgradeStepsCommand_skips_updateVersionInEnv_when_skip_version_env_present
- ExecuteUpgradeStepsCommand_invokes_clearAllCaches_by_default
- ExecuteUpgradeStepsCommand_skips_clearAllCaches_when_skip_cache_clear_present
- ExecuteUpgradeStepsCommand_invokes_runBundledExtensionUpdatePrompt_by_default
- ExecuteUpgradeStepsCommand_skips_bundled_updates_when_skip_bundled_updates_present
# §3 본 임무 (runUpgradeSteps) 는 옵션 무관 항상 호출
- ExecuteUpgradeStepsCommand_always_invokes_runUpgradeSteps_regardless_of_skip_options
# §4 부모 spawn 5개 옵션 전달
- CoreUpdateCommand_spawnUpgradeStepsProcess_appends_skip_migrations_to_command_array
- CoreUpdateCommand_spawnUpgradeStepsProcess_appends_skip_resync_to_command_array
- CoreUpdateCommand_spawnUpgradeStepsProcess_appends_skip_version_env_to_command_array
- CoreUpdateCommand_spawnUpgradeStepsProcess_appends_skip_cache_clear_to_command_array
- CoreUpdateCommand_spawnUpgradeStepsProcess_appends_skip_bundled_updates_to_command_array
# §5 운영자 안내문 (resumeCommand) 호환성 — 옵션 없이도 기본값으로 안전 실행
- resume_command_without_skip_options_invokes_all_five_pre_and_post_steps_safely
test_files:
- tests/Feature/Console/Commands/ExecuteUpgradeStepsStandaloneTest.php
- tests/Feature/Console/Commands/ExecuteUpgradeStepsCommandHandoffTest.php
# 본 매트릭스의 cross product 는 수백 케이스이나, 실제 회귀 가드는 test_files 의
# 통과 테스트가 SSoT — 매니페스트는 매트릭스 SSoT 역할.
#
# 의도적 제외:
# - maintenance 모드 자동 제어 (down/up) : 단독 실행 시 운영자가 직접 제어. 매트릭스 대상 아님.
# - ownership snapshot / cleanupPending / deleteBackup : 단독 실행 컨텍스트에 해당 자원 자체가 없음.
# - composer 재실행 : 디스크 코어 파일은 이미 신버전 적용된 상태가 단독 실행의 전제.
@@ -0,0 +1,114 @@
# audit:allow test-scenario-coverage reason: 본 매니페스트는 코어 자동 롤백 신규 파일 prune + beta.6 사후 보완 + hotfix 단발성 prefix 도입의 시나리오 매트릭스 SSoT. 핵심 회귀 가드는 test_files 의 통과 테스트로 커버.
feature: 코어 자동 롤백 시 신 버전 신규 파일 prune + beta.6 사후 보완 + hotfix 단발성 prefix
description: |
7.0.0-beta.5 이전의 코어 자동 롤백은 backup → 활성 overlay 복사 방식으로만 동작
하여, 신 버전이 활성 디렉토리에 새로 추가한 파일은 백업에 없으므로 삭제되지 않고
잔존했다. 결과적으로 `bootstrap/providers.php` 는 백업본(구버전) 으로 되돌아가지만
디스크에는 신 ServiceProvider 파일이 잔존하여 `BindingResolutionException` 등
부팅 부정합이 발생.
본 매니페스트는 7.0.0-beta.6 의 다음 4가지 결함 보정 메커니즘의 cross product 회귀
가드:
1. Step 6.5 (manifest 생성) — applyUpdate 직전 신 버전 신규 파일 목록을 백업
디렉토리에 manifest 로 기록
2. restoreFromBackup 확장 — manifest 가 있으면 복사 직후 prune 수행
3. beta.5 사용자의 사후 보완 — Upgrade_7_0_0_beta_6 DataMigration 이 백업 디렉토리에
manifest 를 사후 작성 + 잔존 ServiceProvider 진단 로그
4. hotfix:rollback-stale-files — 운영자용 진단/회복 도구 (단발성 prefix `hotfix:*`)
axes:
rollback_origin: [step7_fatal, step8_fatal, step9_fatal, step10_fatal, no_backup_mode]
manifest_state: [present, absent, corrupted]
protected_path_overlap: [no_overlap, in_manifest_only, in_active_only, both]
symlink_state: [valid, broken, target_missing, windows_no_privilege]
user_added_files: [none, root_level, nested, in_protected_path]
user_modified_files: [none, single, multiple]
active_dir_state: [pristine_new, mixed_with_user, partially_pruned]
dir_emptiness_after_prune: [empty, has_user_file, has_orphan_dir]
hotfix_command_mode: [diagnose, prune_with_confirm, prune_reject, explicit_backup, missing_backup]
upgrade_path: [step6_5_normal, beta5_to_beta6_backfill, multi_version_chain]
audit_allow_inline: [present, absent]
exclusions:
- { manifest_state: absent, rollback_origin: step7_fatal, reason: "Step 6.5 가 정상 동작했으면 manifest 가 항상 존재 — absent 는 beta.5 사용자 시나리오" }
- { rollback_origin: no_backup_mode, manifest_state: present, reason: "--no-backup 모드는 백업 자체가 부재이므로 manifest 도 부재" }
- { upgrade_path: step6_5_normal, manifest_state: absent, reason: "Step 6.5 정상 동작 시 manifest 부재 불가" }
- { symlink_state: windows_no_privilege, rollback_origin: step10_fatal, reason: "Windows symlink 제약은 OS 레벨 — 롤백 단계와 무관" }
effects:
# §1 Step 6.5 (manifest 생성)
- writeNewFilesManifest_identifies_new_files_in_pending_only
- writeNewFilesManifest_identifies_new_dirs
- writeNewFilesManifest_excludes_protected_paths
- writeNewFilesManifest_excludes_pattern_matches_in_excludes
- writeNewFilesManifest_writes_to_backup_path_with_schema_v1
- writeNewFilesManifest_sorts_new_files_lexicographic_ascending
- writeNewFilesManifest_emits_six_top_level_keys
- core_update_command_step_6_5_invoked_after_backup_before_apply
- core_update_command_step_6_5_skipped_when_no_backup_option
# §2 restoreFromBackup 확장 + pruneNewFiles
- pruneNewFiles_removes_new_files_listed_in_manifest
- pruneNewFiles_keeps_dirs_with_user_files
- pruneNewFiles_respects_protected_paths_double_guard
- pruneNewFiles_skips_symlinks_unconditionally
- pruneNewFiles_returns_noop_when_manifest_absent
- pruneNewFiles_handles_corrupted_manifest_gracefully
- pruneNewFiles_skips_missing_files_gracefully
- pruneNewFiles_removes_empty_dirs_in_depth_reverse_order
- restoreFromBackup_invokes_prune_when_manifest_exists
- restoreFromBackup_skips_prune_when_manifest_absent
- restoreFromBackup_logs_prune_result
- rollback_catch_block_calls_clearAllCaches_after_restore
- user_added_files_preserved_on_rollback
- user_modified_files_restored_to_backup_version
- multi_version_chain_prunes_all_new_files
# §3 beta.6 사후 보완
- backfill_manifest_writes_to_latest_backup_when_absent
- backfill_manifest_noop_when_already_present
- backfill_manifest_skips_when_backup_dir_absent
- backfill_manifest_schema_compatible_with_pruneNewFiles
- backfill_manifest_swallows_exceptions
- log_stale_providers_writes_diagnostic_log_when_mismatch
- log_stale_providers_silent_when_no_mismatch
- log_stale_providers_does_not_auto_delete_files
- log_stale_providers_swallows_exceptions
- upgrade_step_beta_6_extends_abstract_upgrade_step
# §4 hotfix:rollback-stale-files
- hotfix_command_diagnose_mode_outputs_candidates_without_pruning
- hotfix_command_prune_with_confirmation_removes_marker_file
- hotfix_command_prune_with_rejection_keeps_files
- hotfix_command_missing_backup_dir_completes_safely
- hotfix_command_explicit_backup_option_uses_specified_path
- hotfix_command_explicit_backup_invalid_path_errors
- hotfix_command_missing_manifest_falls_back_to_conservative_mode
- hotfix_command_writes_log_after_prune
# §5 단발성 prefix 자동 제도화 (audit 룰 + dev-dashboard)
- hotfix_command_must_have_context_doc_passes_for_valid_command
- hotfix_command_must_have_context_doc_violates_when_description_missing
- hotfix_command_must_document_deprecation_passes_with_permanent_tag
- hotfix_command_must_document_deprecation_violates_without_policy
- check_dev_dashboard_commands_auto_excludes_hotfix_prefix
test_files:
- tests/Unit/Extension/CoreBackupHelperPruneTest.php
- tests/Feature/Upgrade/AutoRollbackPruneTest.php
- tests/Feature/Upgrade/Beta6BackfillManifestTest.php
- tests/Feature/Console/Hotfix/RollbackStaleFilesCommandTest.php
# 본 매니페스트의 axes cross product 는 수천 케이스이나, 실제 회귀 가드는 test_files 의
# 통과 테스트들이 SSoT — 매니페스트는 매트릭스 SSoT 역할.
#
# 잔존 결함 / 본 매트릭스 범위 외:
# - beta.5 → beta.6 부모 catch 시점의 즉시 자동 prune: 부모 (beta.5) 가 Step 6.5 를
# 모르므로 manifest 부재 → 즉시 prune 불가. beta.6 DataMigration 의 사후 작성 +
# hotfix 커맨드가 fallback. CHANGELOG / 릴리스 노트 명시.
# - DB 마이그레이션 down 자동 호출: 본 계획서 범위 외 (별도 이슈)
# - 운영자의 활성 디렉토리 수정 상태에서 core:update 진입 시 사전 경고 UX: 별도 이슈
# - hotfix:* prefix 의 누적 알림 (Stop 훅): 본 계획서 범위 외 별도 이슈 — §7.4.D
@@ -0,0 +1,39 @@
# audit:allow test-scenario-coverage reason: 본 매니페스트는 신규 기능 시나리오 매트릭스 SSoT 로 기록. 회귀 가드는 test_files 의 통과 테스트로 커버.
feature: 인스톨러 세션 쿠키 round-trip 사전 진단
description: |
Step 0 (welcome) 진입 시 클라이언트 JS 가 set → verify 두 fetch 로 세션
쿠키 round-trip 동작을 확인. 브라우저가 PHPSESSID 쿠키를 차단/유실하여
세션이 매 요청 새로 생성되는 환경(`session.cookie_samesite=Strict` +
비표준 포트 + 일부 브라우저 정책 조합)을 사전 감지하여 운영자에게 명시
안내한다.
본 진단으로는 "설치하기" 버튼 자체를 차단하지 않는다 (PO 결정: 경고만).
검증 시점은 Step 0 1회 한정. 또한 `session.cookie_samesite` 기본값을
Strict → Lax 로 완화하여 차단 케이스 자체를 줄인다 — 진단은 잔여 케이스
대비 안내 역할.
endpoint:
- GET /install/api/session-probe.php?action=set — 세션에 nonce 저장 + 응답
- GET /install/api/session-probe.php?action=verify — 세션의 nonce 와 응답 (한 번 후 제거)
axes:
session_state: [empty, set_called, set_then_verify_called]
cookie_round_trip: [success, blocked]
exclusions:
- { session_state: empty, cookie_round_trip: success, reason: "set 호출 없이 verify 결과 matched 일 수 없음" }
effects:
- set_action_generates_32_hex_nonce
- set_action_stores_nonce_in_installer_session_probe_key
- verify_action_returns_matched_true_when_session_preserved
- verify_action_returns_matched_false_when_session_empty
- verify_action_consumes_nonce_after_first_call
- second_verify_after_consumption_returns_matched_false
- probe_endpoint_requires_installer_guard
- probe_endpoint_emits_application_json_content_type
test_files:
- tests/Unit/Installer/SessionProbeTest.php
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* 코어 7.0.0-beta.6 업그레이드 스텝
*
* 모든 비즈니스 로직은 본 클래스 파일이 아닌 `upgrades/data/7.0.0-beta.6/` 안에 격리된다:
*
* - migrations/
* 01_BackfillNewFilesManifest.php — beta.5 사용자의 사후 manifest 작성
* 02_LogStaleServiceProviders.php — 부팅 부정합 ServiceProvider 진단 로그
*
* 본 클래스는 `AbstractUpgradeStep` 의 default `run()` 에 위임 — 별도 override 없음.
*
* @upgrade-path B (spawn 자식 = 최신 beta.6 코어 메모리)
*
* 의존성 제약: 본 스텝은 변환/핫픽스를 `data/7.0.0-beta.6/migrations/` 의 버전 namespace
* 클래스에 위임한다. 미래 버전에서 *그 디렉토리는 동결* (수정 금지) 되어 "각 스텝별 동작
* 100% 동일 보장" invariant 가 성립.
*
* 상세: docs/extension/upgrade-step-guide.md §12 "버전별 데이터 스냅샷"
*/
class Upgrade_7_0_0_beta_6 extends AbstractUpgradeStep
{
// 모든 로직 위임 — data/7.0.0-beta.6/ 가 SSoT.
}
@@ -0,0 +1,309 @@
<?php
namespace App\Upgrades\Data\V7_0_0_beta_6\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use FilesystemIterator;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
/**
* beta.5 사용자의 사후 manifest 작성 — 자동 롤백 신규 파일 prune 의 한계 보완.
*
* 배경: beta.5 → beta.6 업그레이드 시 부모 프로세스(beta.5 CoreUpdateCommand) 는 본
* 브랜치의 Step 6.5 / restoreFromBackup 확장 로직을 모른다. 따라서 부모 catch 가
* 트리거되어도 manifest 가 없어 자동 prune 이 동작하지 않는다.
*
* 다만 Step 10 의 spawn 자식은 beta.6 메모리로 실행되므로, 그 시점에 사후 manifest 를
* 백업 디렉토리에 작성할 수 있다. 부모 catch 의 즉시 자동 prune 효과는 없지만:
*
* - 백업 디렉토리가 보존된 채로 fatal 이 발생한 경우 (Step 10 이후 ~ Step 11 cleanup
* 직전 구간), 운영자 수동 복구 / `hotfix:rollback-stale-files` 가 manifest 를 활용
* 가능
* - beta.6 이후의 업그레이드 사이클부터는 Step 6.5 가 정상 동작하므로 이중 보장
*
* 격리 원칙 (docs/extension/upgrade-step-guide.md §4, §13):
* - `CoreBackupHelper::writeNewFilesManifest()` 등 신 코드 직접 호출 금지
* - 모든 비교/직렬화 로직을 본 클래스의 private 메서드로 중복 구현
* - manifest JSON 스키마는 `CoreBackupHelper::MANIFEST_SCHEMA_VERSION = 1` 과 바이트
* 단위 호환 (회귀 테스트 Beta6BackfillManifestTest 시나리오 4 가 invariant 강제)
*
* 멱등성: manifest 가 이미 존재하면 noop (created_at 변동 없음). 백업 디렉토리 부재
* (예: --no-backup 모드로 부모가 진행) 시 정상 종료 + log 기록.
*/
final class BackfillNewFilesManifest implements DataMigration
{
private const MANIFEST_FILENAME = '_new_files_manifest.json';
private const MANIFEST_SCHEMA_VERSION = 1;
public function name(): string
{
return 'BackfillNewFilesManifest';
}
public function run(UpgradeContext $context): void
{
try {
$this->runInternal($context);
} catch (\Throwable $e) {
// 본 보완 로직은 실패해도 업그레이드 본체를 중단하지 않는다.
$context->logger->warning(sprintf(
'[7.0.0-beta.6] BackfillNewFilesManifest 실패 (계속 진행): %s',
$e->getMessage(),
));
Log::warning('beta.6 BackfillNewFilesManifest 실패', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
private function runInternal(UpgradeContext $context): void
{
$backupsDir = storage_path('app'.DIRECTORY_SEPARATOR.'core_backups');
if (! is_dir($backupsDir)) {
$context->logger->info('[7.0.0-beta.6] core_backups 디렉토리 부재 — manifest 사후 작성 skip');
return;
}
$latestBackup = $this->findLatestBackupDir($backupsDir);
if ($latestBackup === null) {
$context->logger->info('[7.0.0-beta.6] core_backups 비어 있음 — manifest 사후 작성 skip');
return;
}
$manifestPath = $latestBackup.DIRECTORY_SEPARATOR.self::MANIFEST_FILENAME;
if (file_exists($manifestPath)) {
$context->logger->info(sprintf(
'[7.0.0-beta.6] manifest 이미 존재 — noop (path=%s)',
$manifestPath,
));
return;
}
// 신 버전 디스크의 config 를 require — 부모 메모리의 stale config 와 무관한 SSoT
$config = $this->loadCoreUpdateConfigFromDisk();
$targets = $config['targets'];
$protectedPaths = $config['protected_paths'];
$excludes = $config['excludes'];
$activeRoot = base_path();
$newFiles = [];
$newDirs = [];
$protectedSet = $this->normalizeProtectedSet($protectedPaths);
$excludeSet = array_values(array_filter(array_map('trim', $excludes)));
foreach ($targets as $target) {
$target = trim((string) $target);
if ($target === '') {
continue;
}
if ($this->isWithinProtectedPath($target, $protectedSet)) {
continue;
}
$activeTargetPath = $activeRoot.DIRECTORY_SEPARATOR.$target;
if (! file_exists($activeTargetPath)) {
continue;
}
if (is_file($activeTargetPath) && ! is_link($activeTargetPath)) {
$backupItem = $latestBackup.DIRECTORY_SEPARATOR.$target;
if (! file_exists($backupItem)) {
$newFiles[] = $this->normalizeRelative($target);
}
continue;
}
if (! is_dir($activeTargetPath)) {
continue;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($activeTargetPath, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST,
);
foreach ($iterator as $item) {
/** @var SplFileInfo $item */
$absolute = $item->getPathname();
$relative = $this->normalizeRelative(
$target.'/'.ltrim(substr($absolute, strlen($activeTargetPath)), DIRECTORY_SEPARATOR.'/'),
);
if ($this->matchesExcludes($relative, $excludeSet)) {
continue;
}
if ($this->isWithinProtectedPath($relative, $protectedSet)) {
continue;
}
$backupItemPath = $latestBackup.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative);
if ($item->isDir() && ! $item->isLink()) {
if (! is_dir($backupItemPath)) {
$newDirs[] = $relative;
}
} elseif ($item->isFile()) {
if (! file_exists($backupItemPath)) {
$newFiles[] = $relative;
}
}
}
}
sort($newFiles, SORT_STRING);
sort($newDirs, SORT_STRING);
$manifest = [
'version' => self::MANIFEST_SCHEMA_VERSION,
'created_at' => date('c'),
'from_version' => $context->fromVersion,
'to_version' => $context->toVersion,
'new_files' => $newFiles,
'new_dirs' => $newDirs,
];
File::put(
$manifestPath,
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
);
$context->logger->info(sprintf(
'[7.0.0-beta.6] manifest 사후 작성 완료 — backup=%s, new_files=%d, new_dirs=%d',
$latestBackup,
count($newFiles),
count($newDirs),
));
}
/**
* `storage/app/core_backups/` 의 가장 최근 백업 디렉토리를 식별.
*
* CoreBackupHelper::listBackups() 직접 호출 금지 — 로컬 scandir 로 동일 결과 산출.
*/
private function findLatestBackupDir(string $backupsDir): ?string
{
$entries = @scandir($backupsDir);
if ($entries === false) {
return null;
}
$candidates = [];
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$full = $backupsDir.DIRECTORY_SEPARATOR.$entry;
if (! is_dir($full)) {
continue;
}
$candidates[$full] = (int) @filemtime($full);
}
if ($candidates === []) {
return null;
}
arsort($candidates, SORT_NUMERIC);
return (string) array_key_first($candidates);
}
/**
* 신 버전 디스크의 `config/app.php` 에서 update 관련 설정 추출.
*
* 부모 메모리의 stale `config()` 캐시를 우회하기 위해 디스크의 config 를 직접 require.
* env() 호출이 안전한 default 값을 반환하므로 신 버전 적용 후의 fresh 값을 얻을 수 있다.
*
* @return array{targets:array<int,string>, protected_paths:array<int,string>, excludes:array<int,string>}
*/
private function loadCoreUpdateConfigFromDisk(): array
{
// 부모 메모리의 config 사용을 우회 — 디스크 SSoT 의 신 버전 config 로드
$appConfig = require base_path('config'.DIRECTORY_SEPARATOR.'app.php');
$update = $appConfig['update'] ?? [];
return [
'targets' => (array) ($update['targets'] ?? []),
'protected_paths' => (array) ($update['protected_paths'] ?? []),
'excludes' => (array) ($update['excludes'] ?? []),
];
}
private function normalizeProtectedSet(array $paths): array
{
$out = [];
foreach ($paths as $p) {
$p = trim((string) $p);
if ($p === '') {
continue;
}
$out[] = $this->normalizeRelative($p);
}
return $out;
}
private function isWithinProtectedPath(string $relative, array $protectedSet): bool
{
$relative = $this->normalizeRelative($relative);
foreach ($protectedSet as $p) {
if ($p === '') {
continue;
}
if ($relative === $p) {
return true;
}
if (str_starts_with($relative, $p.'/')) {
return true;
}
}
return false;
}
private function matchesExcludes(string $relative, array $excludes): bool
{
$segments = explode('/', $relative);
foreach ($excludes as $exclude) {
if ($exclude === '') {
continue;
}
if (str_contains($exclude, '/')) {
if ($relative === $exclude || str_starts_with($relative, $exclude.'/')) {
return true;
}
} else {
if (in_array($exclude, $segments, true)) {
return true;
}
}
}
return false;
}
private function normalizeRelative(string $path): string
{
$p = str_replace('\\', '/', $path);
$p = ltrim($p, '/');
while (str_contains($p, '//')) {
$p = str_replace('//', '/', $p);
}
return $p;
}
}
@@ -0,0 +1,182 @@
<?php
namespace App\Upgrades\Data\V7_0_0_beta_6\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\Log;
/**
* 부팅 부정합 ServiceProvider 진단 로그 — 자동 삭제 없음.
*
* beta.5 이전의 자동 롤백 결함으로 인해 활성 디렉토리 `app/Providers/` 또는 `app/Services/`
* 등에 신 버전 ServiceProvider 파일이 잔존했을 수 있다. 본 진단은 다음 부정합을 식별한다:
*
* 1. `bootstrap/providers.php` 가 register 하는 클래스 FQCN 배열 추출
* 2. `app/Providers/` 의 모든 *.php 파일 스캔 + 클래스명 변환
* 3. 디스크에 있지만 register 되지 않은 ServiceProvider 후보 목록 산출
* 4. `storage/logs/core_upgrade_beta_6_stale_providers.log` 로 기록 (자동 삭제 없음)
*
* 자동 삭제하지 않는 이유: 운영자가 직접 추가한 커스텀 ServiceProvider 가 같은 패턴으로
* 식별되므로 안전 우선. 운영자 검토 후 수동 정리.
*
* 격리 원칙: 코어의 신 메서드 (CoreBackupHelper, ExtensionManager 등) 호출 금지. 표준
* 파일 시스템 API (scandir / file_get_contents) + `Illuminate\Support\Facades\Log` 만 사용.
*/
final class LogStaleServiceProviders implements DataMigration
{
private const LOG_FILENAME = 'core_upgrade_beta_6_stale_providers.log';
public function name(): string
{
return 'LogStaleServiceProviders';
}
public function run(UpgradeContext $context): void
{
try {
$this->runInternal($context);
} catch (\Throwable $e) {
$context->logger->warning(sprintf(
'[7.0.0-beta.6] LogStaleServiceProviders 실패 (계속 진행): %s',
$e->getMessage(),
));
Log::warning('beta.6 LogStaleServiceProviders 실패', [
'error' => $e->getMessage(),
]);
}
}
private function runInternal(UpgradeContext $context): void
{
$providersFile = base_path('bootstrap'.DIRECTORY_SEPARATOR.'providers.php');
$providersDir = base_path('app'.DIRECTORY_SEPARATOR.'Providers');
if (! file_exists($providersFile)) {
$context->logger->info('[7.0.0-beta.6] bootstrap/providers.php 부재 — stale providers 진단 skip');
return;
}
if (! is_dir($providersDir)) {
$context->logger->info('[7.0.0-beta.6] app/Providers 부재 — stale providers 진단 skip');
return;
}
$registered = $this->loadRegisteredProviders($providersFile);
$registeredSet = [];
foreach ($registered as $fqcn) {
$registeredSet[strtolower($fqcn)] = true;
}
$stale = [];
foreach ($this->collectProviderFiles($providersDir) as $file) {
$relative = ltrim(substr($file, strlen($providersDir)), DIRECTORY_SEPARATOR.'/');
$relative = str_replace('\\', '/', $relative);
// app/Providers/Sub/Foo.php → App\Providers\Sub\Foo
$classRelative = substr($relative, 0, -4); // .php strip
$fqcn = 'App\\Providers\\'.str_replace('/', '\\', $classRelative);
if (isset($registeredSet[strtolower($fqcn)])) {
continue;
}
$stale[] = [
'fqcn' => $fqcn,
'file' => 'app/Providers/'.$relative,
];
}
if ($stale === []) {
$context->logger->info('[7.0.0-beta.6] 부팅 부정합 ServiceProvider 후보 없음');
return;
}
$logPath = storage_path('logs'.DIRECTORY_SEPARATOR.self::LOG_FILENAME);
$header = implode("\n", [
'=== beta.6 부팅 부정합 ServiceProvider 진단 로그 ===',
'날짜: '.date('Y-m-d H:i:s'),
sprintf('업그레이드: %s → %s', $context->fromVersion, $context->toVersion),
'',
'아래 ServiceProvider 클래스 파일이 디스크에는 존재하지만 bootstrap/providers.php',
'의 등록 목록에 없습니다. beta.5 이전의 자동 롤백 결함으로 인해 잔존한 신 버전',
'파일이거나, 운영자가 직접 추가한 커스텀 ServiceProvider 일 수 있습니다.',
'',
'자동 삭제는 수행하지 않습니다. 운영자가 검토 후 수동 정리하세요:',
' - 신 버전 잔존 파일 → 삭제',
' - 커스텀 ServiceProvider → bootstrap/providers.php 에 등록',
'',
'=== 후보 목록 ===',
'',
]);
$body = '';
foreach ($stale as $entry) {
$body .= " - {$entry['fqcn']}\n";
$body .= " 파일: {$entry['file']}\n";
}
@file_put_contents($logPath, $header.$body);
$context->logger->warning(sprintf(
'[7.0.0-beta.6] 부팅 부정합 ServiceProvider %d개 진단 — 운영자 검토 필요. 로그: %s',
count($stale),
$logPath,
));
}
/**
* bootstrap/providers.php 를 require 하여 등록된 ServiceProvider FQCN 배열 반환.
*
* @return array<int, string>
*/
private function loadRegisteredProviders(string $providersFile): array
{
$data = require $providersFile;
if (! is_array($data)) {
return [];
}
return array_values(array_filter(
$data,
fn ($v) => is_string($v) && $v !== '',
));
}
/**
* app/Providers/ 의 모든 *.php 파일을 재귀 수집.
*
* @return array<int, string>
*/
private function collectProviderFiles(string $providersDir): array
{
$files = [];
$stack = [$providersDir];
while ($stack !== []) {
$cur = array_pop($stack);
$entries = @scandir($cur);
if ($entries === false) {
continue;
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$full = $cur.DIRECTORY_SEPARATOR.$entry;
if (is_dir($full) && ! is_link($full)) {
$stack[] = $full;
} elseif (is_file($full) && str_ends_with($entry, '.php')) {
$files[] = $full;
}
}
}
sort($files, SORT_STRING);
return $files;
}
}