diff --git a/.env.example b/.env.example index 9365347d..2057cd34 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.env.testing.example b/.env.testing.example index 3ada5e9b..509844ba 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -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 diff --git a/AGENTS.md b/AGENTS.md index ded1df82..0583722f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 96b79495..50e0e3a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 시장 점유율 측정 도구가 인식하는 `` 태그를 SEO 봇 페이지·SPA·관리자 셸 모두에 출력. 내용 미입력 시 "GnuBoard7 {버전}" 자동 적용, 운영자가 버전 노출을 원치 않으면 "GnuBoard7" 만 입력 가능 +- 관리자 환경설정 → SEO 탭에 Generator 메타 태그 카드 추가 — 토글로 노출 여부를 제어하고 내용 입력으로 W3Techs 등 CMS 시장 점유율 측정 도구가 인식하는 `` 태그를 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 diff --git a/INSTALL.md b/INSTALL.md index 69a9c9d8..b67b5651 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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 diff --git a/README.md b/README.md index ce6b3fc9..6ff3f43e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

- Version + Version PHP Laravel React diff --git a/app/Console/Commands/Core/CoreUpdateCommand.php b/app/Console/Commands/Core/CoreUpdateCommand.php index b935c416..78d49a44 100644 --- a/app/Console/Commands/Core/CoreUpdateCommand.php +++ b/app/Console/Commands/Core/CoreUpdateCommand.php @@ -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'; diff --git a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php index 2a61896b..f6905b19 100644 --- a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php +++ b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php @@ -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; } } diff --git a/app/Console/Commands/Hotfix/RollbackStaleFilesCommand.php b/app/Console/Commands/Hotfix/RollbackStaleFilesCommand.php new file mode 100644 index 00000000..412451b7 --- /dev/null +++ b/app/Console/Commands/Hotfix/RollbackStaleFilesCommand.php @@ -0,0 +1,246 @@ +.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, dirs:array} + */ + 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"; + } +} diff --git a/app/Extension/Helpers/CoreBackupHelper.php b/app/Extension/Helpers/CoreBackupHelper.php index d6f4cbdc..7998bf35 100644 --- a/app/Extension/Helpers/CoreBackupHelper.php +++ b/app/Extension/Helpers/CoreBackupHelper.php @@ -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 $paths + * @return array + */ + 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; + } } diff --git a/app/Extension/Vendor/EnvironmentDetector.php b/app/Extension/Vendor/EnvironmentDetector.php index 0170f261..574d8abf 100644 --- a/app/Extension/Vendor/EnvironmentDetector.php +++ b/app/Extension/Vendor/EnvironmentDetector.php @@ -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; } } diff --git a/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionCollection.php b/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionCollection.php index 826b47ee..55e43ca4 100644 --- a/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionCollection.php +++ b/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionCollection.php @@ -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', ]; } diff --git a/config/app.php b/config/app.php index e5c9d748..d5dfd02e 100644 --- a/config/app.php +++ b/config/app.php @@ -231,7 +231,7 @@ return [ | */ - 'version' => env('APP_VERSION', '7.0.0-beta.5'), + 'version' => env('APP_VERSION', '7.0.0-beta.6'), /* |-------------------------------------------------------------------------- diff --git a/docs/backend/core-update-system.md b/docs/backend/core-update-system.md index 8692e3d1..7380763e 100644 --- a/docs/backend/core-update-system.md +++ b/docs/backend/core-update-system.md @@ -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 diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index 7c3cd28b..2a2d0fb2 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -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_.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?] # 모듈 업데이트 확인 diff --git a/docs/extension/upgrade-step-guide.md b/docs/extension/upgrade-step-guide.md index 5461484a..37ea7e4a 100644 --- a/docs/extension/upgrade-step-guide.md +++ b/docs/extension/upgrade-step-guide.md @@ -513,6 +513,23 @@ CoreUpdateCommand::handle └─ disableMaintenanceMode 대신 사용자에게는 **스텝 전용** 명령 (`php artisan core:execute-upgrade-steps --from= --to= --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 파일에서 아래 조건이 모두 성립할 때 사용한다: diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 8becf797..ba4c79ef 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -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 diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index a5db3b4a..2c67f808 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -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 diff --git a/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md b/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md index 41162974..71789943 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md @@ -14,7 +14,7 @@ ### Fixed -- CKEditor5 사용 게시판 글쓰기 화면에서 제목과 내용을 함께 입력해 저장할 때 "제목이 비어있다" 오류가 나던 문제 수정 +- CKEditor5 사용 게시판 글쓰기 화면에서 제목과 내용을 함께 입력해 저장할 때 "제목이 비어있다" 오류가 나던 문제 수정 (#17 @laelbe 님께서 제보해주셨습니다.) ## [1.0.0-beta.1] - 2026-04-09 diff --git a/public/install/api/check-configuration.php b/public/install/api/check-configuration.php index cdd715c3..a6a91734 100644 --- a/public/install/api/check-configuration.php +++ b/public/install/api/check-configuration.php @@ -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, diff --git a/public/install/api/session-probe.php b/public/install/api/session-probe.php new file mode 100644 index 00000000..fe017821 --- /dev/null +++ b/public/install/api/session-probe.php @@ -0,0 +1,92 @@ + '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); + } +} diff --git a/public/install/assets/js/installer.js b/public/install/assets/js/installer.js index a1151012..bb7ae55f 100644 --- a/public/install/assets/js/installer.js +++ b/public/install/assets/js/installer.js @@ -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'), '', diff --git a/public/install/includes/session.php b/public/install/includes/session.php index f607eded..b17c432a 100644 --- a/public/install/includes/session.php +++ b/public/install/includes/session.php @@ -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') { diff --git a/public/install/includes/task-runner.php b/public/install/includes/task-runner.php index cc7ff0c1..2bfd3c72 100644 --- a/public/install/includes/task-runner.php +++ b/public/install/includes/task-runner.php @@ -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'; } diff --git a/public/install/lang/en.php b/public/install/lang/en.php index ff21e8dc..2dfd6ae5 100644 --- a/public/install/lang/en.php +++ b/public/install/lang/en.php @@ -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:', diff --git a/public/install/lang/ko.php b/public/install/lang/ko.php index 3db574a3..b7e34025 100644 --- a/public/install/lang/ko.php +++ b/public/install/lang/ko.php @@ -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가 설치되어 있지 않습니다. 아래 방법으로 설치할 수 있습니다:', diff --git a/public/install/views/0-welcome.php b/public/install/views/0-welcome.php index 6320810d..c0f3ddd7 100644 --- a/public/install/views/0-welcome.php +++ b/public/install/views/0-welcome.php @@ -169,6 +169,28 @@ $storageResult = $storageCheck['results']['storage'] ?? null; + +

+
@@ -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 진행 자체에는 영향 없음) + } +}); diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index f0f82370..d29d2d1e 100644 --- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md @@ -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 상세보기 diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-identity-messages-tab.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-identity-messages-tab.test.tsx index 82fe7487..ee76456f 100644 --- a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-identity-messages-tab.test.tsx +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-identity-messages-tab.test.tsx @@ -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'); diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_messages.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_messages.json index 7bae0bce..3101f4be 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_messages.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_messages.json @@ -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": [ diff --git a/templates/_bundled/sirsoft-admin_basic/package-lock.json b/templates/_bundled/sirsoft-admin_basic/package-lock.json index 63fe59b2..b0d8d9e5 100644 --- a/templates/_bundled/sirsoft-admin_basic/package-lock.json +++ b/templates/_bundled/sirsoft-admin_basic/package-lock.json @@ -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", diff --git a/templates/_bundled/sirsoft-admin_basic/package.json b/templates/_bundled/sirsoft-admin_basic/package.json index dbff384e..790e5d9a 100644 --- a/templates/_bundled/sirsoft-admin_basic/package.json +++ b/templates/_bundled/sirsoft-admin_basic/package.json @@ -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", diff --git a/templates/_bundled/sirsoft-admin_basic/template.json b/templates/_bundled/sirsoft-admin_basic/template.json index 8653a6e8..0918eb68 100644 --- a/templates/_bundled/sirsoft-admin_basic/template.json +++ b/templates/_bundled/sirsoft-admin_basic/template.json @@ -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 기본 관리자 템플릿", diff --git a/templates/_bundled/sirsoft-basic/CHANGELOG.md b/templates/_bundled/sirsoft-basic/CHANGELOG.md index 5910c470..1b9d701d 100644 --- a/templates/_bundled/sirsoft-basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-basic/CHANGELOG.md @@ -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 누락으로 요청이 발송되지 않고 성공/실패 토스트도 표시되지 않던 회귀 해소 diff --git a/tests/Feature/Api/Admin/Identity/AdminIdentityMessageDefinitionAdminCrudTest.php b/tests/Feature/Api/Admin/Identity/AdminIdentityMessageDefinitionAdminCrudTest.php index faa764e9..74584369 100644 --- a/tests/Feature/Api/Admin/Identity/AdminIdentityMessageDefinitionAdminCrudTest.php +++ b/tests/Feature/Api/Admin/Identity/AdminIdentityMessageDefinitionAdminCrudTest.php @@ -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']})"); + } + } } diff --git a/tests/Feature/Console/Commands/ExecuteUpgradeStepsCommandHandoffTest.php b/tests/Feature/Console/Commands/ExecuteUpgradeStepsCommandHandoffTest.php index df3d00ad..6aa40a69 100644 --- a/tests/Feature/Console/Commands/ExecuteUpgradeStepsCommandHandoffTest.php +++ b/tests/Feature/Console/Commands/ExecuteUpgradeStepsCommandHandoffTest.php @@ -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(); diff --git a/tests/Feature/Console/Commands/ExecuteUpgradeStepsStandaloneTest.php b/tests/Feature/Console/Commands/ExecuteUpgradeStepsStandaloneTest.php new file mode 100644 index 00000000..1635cf91 --- /dev/null +++ b/tests/Feature/Console/Commands/ExecuteUpgradeStepsStandaloneTest.php @@ -0,0 +1,270 @@ +=" 조건을 통과하도록 한다. + $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 $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 = <<createdPaths[] = $path; + } +} diff --git a/tests/Feature/Console/Hotfix/RollbackStaleFilesCommandTest.php b/tests/Feature/Console/Hotfix/RollbackStaleFilesCommandTest.php new file mode 100644 index 00000000..8649955a --- /dev/null +++ b/tests/Feature/Console/Hotfix/RollbackStaleFilesCommandTest.php @@ -0,0 +1,208 @@ +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, '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, '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, '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); + } + } + } +} diff --git a/tests/Feature/Upgrade/AutoRollbackPruneTest.php b/tests/Feature/Upgrade/AutoRollbackPruneTest.php new file mode 100644 index 00000000..f51be077 --- /dev/null +++ b/tests/Feature/Upgrade/AutoRollbackPruneTest.php @@ -0,0 +1,390 @@ +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', 'backupPath.'/app/Services'); + File::put($this->backupPath.'/app/Services/ExistingService.php', 'sourcePath.'/app/Services/LanguagePack'); + File::put($this->sourcePath.'/app/Services/ExistingService.php', 'sourcePath.'/app/Services/LanguagePack/Module.php', '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', 'activePath.'/app/Services/LanguagePack'); + File::put($this->activePath.'/app/Services/LanguagePack/Module.php', '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', 'activePath.'/database/migrations'); + File::put( + $this->activePath.'/database/migrations/2026_05_13_custom_user_migration.php', + 'backupPath.'/app'); + File::put($this->backupPath.'/app/CustomUserHelper.php', 'backupPath.'/database/migrations'); + File::put( + $this->backupPath.'/database/migrations/2026_05_13_custom_user_migration.php', + 'sourcePath.'/app/Services'); + File::put($this->sourcePath.'/app/Services/NewProvider.php', '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', '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', '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', 'sourcePath.'/app'); + File::put($this->sourcePath.'/app/Old.php', 'sourcePath.'/app/IntroducedInBetaN1.php', 'sourcePath.'/app/IntroducedInBetaN2.php', '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', 'activePath.'/app/IntroducedInBetaN2.php', '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', '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', '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', 'activePath.'/app/MixedDir'); + File::put($this->activePath.'/app/MixedDir/NewFromCore.php', 'activePath.'/app/MixedDir/UserAdded.php', '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']); + } +} diff --git a/tests/Feature/Upgrade/Beta6BackfillManifestTest.php b/tests/Feature/Upgrade/Beta6BackfillManifestTest.php new file mode 100644 index 00000000..48556cca --- /dev/null +++ b/tests/Feature/Upgrade/Beta6BackfillManifestTest.php @@ -0,0 +1,264 @@ +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', ' 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', '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()); + } +} diff --git a/tests/Unit/Extension/CoreBackupHelperPruneTest.php b/tests/Unit/Extension/CoreBackupHelperPruneTest.php new file mode 100644 index 00000000..abd363ae --- /dev/null +++ b/tests/Unit/Extension/CoreBackupHelperPruneTest.php @@ -0,0 +1,391 @@ +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', 'sourcePath.'/app/Services'); + File::put($this->sourcePath.'/app/Services/ExistingService.php', 'sourcePath.'/app/Services/NewServiceProviderClass.php', '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', 'sourcePath.'/app'); + File::put($this->sourcePath.'/app/keep.php', 'sourcePath.'/app/Services/LanguagePack'); + File::put($this->sourcePath.'/app/Services/LanguagePack/Module.php', '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', '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', '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', 'sourcePath.'/app/Alpha.php', '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', 'activePath.'/app/Services/UserKeep.php', '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', 'activePath.'/app/Services/LanguagePack/UserAdded.php', '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', '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', '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', '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, + )); + } +} diff --git a/tests/Unit/Extension/Vendor/EnvironmentDetectorTest.php b/tests/Unit/Extension/Vendor/EnvironmentDetectorTest.php index 369a2c5b..a035da3d 100644 --- a/tests/Unit/Extension/Vendor/EnvironmentDetectorTest.php +++ b/tests/Unit/Extension/Vendor/EnvironmentDetectorTest.php @@ -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(); diff --git a/tests/Unit/Installer/ComposerPathValidationTest.php b/tests/Unit/Installer/ComposerPathValidationTest.php index 73b82db9..b94a882b 100644 --- a/tests/Unit/Installer/ComposerPathValidationTest.php +++ b/tests/Unit/Installer/ComposerPathValidationTest.php @@ -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; } diff --git a/tests/Unit/Installer/InstallerSecurityHardeningTest.php b/tests/Unit/Installer/InstallerSecurityHardeningTest.php index 0873684c..6363b822 100644 --- a/tests/Unit/Installer/InstallerSecurityHardeningTest.php +++ b/tests/Unit/Installer/InstallerSecurityHardeningTest.php @@ -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 차단 // ======================================================================== diff --git a/tests/Unit/Installer/SessionProbeTest.php b/tests/Unit/Installer/SessionProbeTest.php new file mode 100644 index 00000000..ff2ceec6 --- /dev/null +++ b/tests/Unit/Installer/SessionProbeTest.php @@ -0,0 +1,110 @@ +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']); + } +} diff --git a/tests/scenarios/core-execute-upgrade-steps-standalone.yaml b/tests/scenarios/core-execute-upgrade-steps-standalone.yaml new file mode 100644 index 00000000..9803e73d --- /dev/null +++ b/tests/scenarios/core-execute-upgrade-steps-standalone.yaml @@ -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 재실행 : 디스크 코어 파일은 이미 신버전 적용된 상태가 단독 실행의 전제. diff --git a/tests/scenarios/core-update-auto-rollback-prune.yaml b/tests/scenarios/core-update-auto-rollback-prune.yaml new file mode 100644 index 00000000..05d7ce87 --- /dev/null +++ b/tests/scenarios/core-update-auto-rollback-prune.yaml @@ -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 diff --git a/tests/scenarios/installer-session-cookie-probe.yaml b/tests/scenarios/installer-session-cookie-probe.yaml new file mode 100644 index 00000000..a08a6ac9 --- /dev/null +++ b/tests/scenarios/installer-session-cookie-probe.yaml @@ -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 diff --git a/upgrades/Upgrade_7_0_0_beta_6.php b/upgrades/Upgrade_7_0_0_beta_6.php new file mode 100644 index 00000000..a09e0422 --- /dev/null +++ b/upgrades/Upgrade_7_0_0_beta_6.php @@ -0,0 +1,29 @@ +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, protected_paths:array, excludes:array} + */ + 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; + } +} diff --git a/upgrades/data/7.0.0-beta.6/migrations/02_LogStaleServiceProviders.php b/upgrades/data/7.0.0-beta.6/migrations/02_LogStaleServiceProviders.php new file mode 100644 index 00000000..f27352aa --- /dev/null +++ b/upgrades/data/7.0.0-beta.6/migrations/02_LogStaleServiceProviders.php @@ -0,0 +1,182 @@ +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 + */ + 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 + */ + 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; + } +}