fix(core,extensions): 설치 완료 스크롤 + 미인증 화면 테마 버튼 무반응 수정

Modern PHP User Group 2026-09 정기모임 설치 리뷰에서 접수된 제보 2건.

설치 완료·실패·중단·필수파일 안내는 페이지 최상단에 뜨는데, 진행 로그를 보느라
화면이 아래에 머물러 있으면 안내가 눈에 들어오지 않았다. 네 경로 모두 DOM 변경이
끝난 뒤 결과 섹션으로 부드럽게 이동시킨다(모션 최소화 설정 시 즉시 이동).

미인증 3화면의 테마 버튼은 setTheme 을 params.theme 으로 불렀는데 핸들러는
action.target 만 읽는다. 엔진에 상호 폴백이 없어 클릭이 콘솔 경고 한 줄만 남기고
아무 일도 하지 않았다. 로그인 이후 화면은 핸들러를 거치지 않는 ThemeToggle 을
쓰기 때문에 정상이었고, 그래서 이 세 화면에서만 나타났다.

같은 계약 불일치가 편집기 액션 레시피(admin 9·basic 3)와 두 템플릿 문서에도 있어
함께 고쳤다. 편집기로 만든 액션은 생성 즉시 no-op 이 되는데 오류가 남지 않는다.
정적 검사 레지스트리가 오히려 틀린 계약(params.theme 필수)을 강제하고 있어 올바른
형태를 막고 있었으므로 두 미러를 함께 정정했다.

테마를 고치는 과정에서 별개 결함이 드러났다. GDPR 스토리지 인터셉터가 기능 쿠키
미동의 상태에서 필수 목록 밖 저장을 부팅마다 파기하는데, 화면 테마가 그 목록에서
빠져 있었다. 저장소의 setItem 호출을 기계 도출해 대조한 결과 같은 이유로 사라지던
항목이 13건 더 있었다 — 비회원 주문 조회, 결제창 복귀 기록, 본인인증 복귀 기록,
관리자 화면 상태, 자산 주소 형식 캐시. 전량 필수로 분류하고, 동의 안내 문구가
"다크모드는 기능 쿠키" 라고 말하던 부분을 사실에 맞게 정정했다(기설치본의 저장된
문구는 업그레이드 스텝이 정정하며, 운영자가 고친 문구는 건드리지 않는다).

재발 방지는 허용목록을 직접 import 하고 모집단을 디렉토리 순회로 도출하는 커버리지
테스트가 맡는다 — 손으로 열거하지 않으므로 새 저장 키가 등재를 빠뜨리면 붉어진다.
This commit is contained in:
HeuJung
2026-09-03 18:42:30 +09:00
parent 92923a5011
commit 8cf829f953
47 changed files with 1424 additions and 282 deletions
+1
View File
@@ -75,6 +75,7 @@
- 설치 과정의 모든 응답이 계정 이름·경로·외부 명령 출력의 문자 인코딩과 무관하게 전달되도록 범위를 넓혔습니다. 7.0.2 에서는 설치 진행 로그 한 곳만 보완했는데, 같은 원인이 다른 단계의 응답에도 남아 있었습니다. 아울러 진행 로그에 한글이 물음표로 깨져 남던 것도 이제 원래 글자로 기록됩니다. (#62 @kitrio 님께서 제보해주셨습니다.)
- 서버가 빈 응답이나 알 수 없는 형식의 응답을 보냈을 때, 설치 마법사가 원인도 조치도 알 수 없는 오류 문구 대신 무엇을 확인하면 되는지 안내합니다. 설치 진행 화면도 응답을 계속 읽지 못하면 화면에 아무 표시 없이 기다리기만 하지 않고 사용자에게 알립니다. (#62 @kitrio 님께서 제보해주셨습니다.)
- 한국어 Windows 에서 관리자 환경설정의 시스템 정보가 서버 오류(500)가 될 수 있던 문제를 수정했습니다. CPU 정보를 조회하는 명령의 한글 출력이 원인이었습니다.
- 설치 완료·실패·중단 안내와 필수 파일 생성 안내가 나타날 때 화면이 그 위치로 부드럽게 이동합니다. 종전에는 설치 진행 로그를 보느라 화면이 아래쪽에 머물러 있으면 안내가 표시되어도 눈에 들어오지 않았습니다. 화면 움직임을 최소화하도록 설정한 사용자에게는 즉시 이동합니다. (Modern PHP User Group 박민권 님께서 제보해주셨습니다.)
## [7.0.9] - 2026-08-24
+62 -62
View File
@@ -77650,8 +77650,8 @@ HTTP/1.1 200
],
"build": {
"handler": "setState",
"target": "local",
"params": {
"target": "local",
"{{key}}": "{{value}}"
}
}
@@ -77723,9 +77723,7 @@ HTTP/1.1 200
],
"build": {
"handler": "setLocale",
"params": {
"target": "{{target}}"
}
"target": "{{target}}"
}
},
"setTheme": {
@@ -77753,24 +77751,22 @@ HTTP/1.1 200
],
"build": {
"handler": "setTheme",
"params": {
"target": "{{target}}"
}
"target": "{{target}}"
}
},
"scrollToSection": {
"label": "$t:editor.action.scroll_to_section.label",
"params": [
{
"key": "sectionId",
"label": "$t:editor.action.scroll_to_section.param_section_id",
"key": "targetId",
"label": "$t:editor.action.scroll_to_section.param_target_id",
"widget": "text"
}
],
"build": {
"handler": "scrollToSection",
"params": {
"sectionId": "{{sectionId}}"
"targetId": "{{targetId}}"
}
}
},
@@ -77795,8 +77791,8 @@ HTTP/1.1 200
"label": "$t:editor.action.set_date_range.preset_month"
},
{
"value": "year",
"label": "$t:editor.action.set_date_range.preset_year"
"value": "1year",
"label": "$t:editor.action.set_date_range.preset_1year"
}
]
}
@@ -77812,32 +77808,29 @@ HTTP/1.1 200
"label": "$t:editor.action.toggle_filter_visibility.label",
"params": [
{
"key": "filterKey",
"label": "$t:editor.action.toggle_filter_visibility.param_filter_key",
"key": "storageKey",
"label": "$t:editor.action.toggle_filter_visibility.param_storage_key",
"widget": "text"
},
{
"key": "filterId",
"label": "$t:editor.action.toggle_filter_visibility.param_filter_id",
"widget": "text"
}
],
"build": {
"handler": "toggleFilterVisibility",
"params": {
"filterKey": "{{filterKey}}"
"storageKey": "{{storageKey}}",
"filterId": "{{filterId}}"
}
}
},
"saveMultilingualTag": {
"label": "$t:editor.action.save_multilingual_tag.label",
"params": [
{
"key": "tag",
"label": "$t:editor.action.save_multilingual_tag.param_tag",
"widget": "i18n-text"
}
],
"params": [],
"build": {
"handler": "saveMultilingualTag",
"params": {
"tag": "{{tag}}"
}
"handler": "saveMultilingualTag"
}
},
"initTheme": {
@@ -77865,9 +77858,7 @@ HTTP/1.1 200
],
"build": {
"handler": "initTheme",
"params": {
"target": "{{target}}"
}
"target": "{{target}}"
}
},
"initMenuFromUrl": {
@@ -77879,9 +77870,18 @@ HTTP/1.1 200
},
"initFilterVisibility": {
"label": "$t:editor.action.init_filter_visibility.label",
"params": [],
"params": [
{
"key": "storageKey",
"label": "$t:editor.action.init_filter_visibility.param_storage_key",
"widget": "text"
}
],
"build": {
"handler": "initFilterVisibility"
"handler": "initFilterVisibility",
"params": {
"storageKey": "{{storageKey}}"
}
}
}
},
@@ -173497,8 +173497,8 @@ HTTP/1.1 200
],
"build": {
"handler": "setState",
"target": "local",
"params": {
"target": "local",
"{{key}}": "{{value}}"
}
}
@@ -173570,9 +173570,7 @@ HTTP/1.1 200
],
"build": {
"handler": "setLocale",
"params": {
"target": "{{target}}"
}
"target": "{{target}}"
}
},
"setTheme": {
@@ -173600,24 +173598,22 @@ HTTP/1.1 200
],
"build": {
"handler": "setTheme",
"params": {
"target": "{{target}}"
}
"target": "{{target}}"
}
},
"scrollToSection": {
"label": "$t:editor.action.scroll_to_section.label",
"params": [
{
"key": "sectionId",
"label": "$t:editor.action.scroll_to_section.param_section_id",
"key": "targetId",
"label": "$t:editor.action.scroll_to_section.param_target_id",
"widget": "text"
}
],
"build": {
"handler": "scrollToSection",
"params": {
"sectionId": "{{sectionId}}"
"targetId": "{{targetId}}"
}
}
},
@@ -173642,8 +173638,8 @@ HTTP/1.1 200
"label": "$t:editor.action.set_date_range.preset_month"
},
{
"value": "year",
"label": "$t:editor.action.set_date_range.preset_year"
"value": "1year",
"label": "$t:editor.action.set_date_range.preset_1year"
}
]
}
@@ -173659,32 +173655,29 @@ HTTP/1.1 200
"label": "$t:editor.action.toggle_filter_visibility.label",
"params": [
{
"key": "filterKey",
"label": "$t:editor.action.toggle_filter_visibility.param_filter_key",
"key": "storageKey",
"label": "$t:editor.action.toggle_filter_visibility.param_storage_key",
"widget": "text"
},
{
"key": "filterId",
"label": "$t:editor.action.toggle_filter_visibility.param_filter_id",
"widget": "text"
}
],
"build": {
"handler": "toggleFilterVisibility",
"params": {
"filterKey": "{{filterKey}}"
"storageKey": "{{storageKey}}",
"filterId": "{{filterId}}"
}
}
},
"saveMultilingualTag": {
"label": "$t:editor.action.save_multilingual_tag.label",
"params": [
{
"key": "tag",
"label": "$t:editor.action.save_multilingual_tag.param_tag",
"widget": "i18n-text"
}
],
"params": [],
"build": {
"handler": "saveMultilingualTag",
"params": {
"tag": "{{tag}}"
}
"handler": "saveMultilingualTag"
}
},
"initTheme": {
@@ -173712,9 +173705,7 @@ HTTP/1.1 200
],
"build": {
"handler": "initTheme",
"params": {
"target": "{{target}}"
}
"target": "{{target}}"
}
},
"initMenuFromUrl": {
@@ -173726,9 +173717,18 @@ HTTP/1.1 200
},
"initFilterVisibility": {
"label": "$t:editor.action.init_filter_visibility.label",
"params": [],
"params": [
{
"key": "storageKey",
"label": "$t:editor.action.init_filter_visibility.param_storage_key",
"widget": "text"
}
],
"build": {
"handler": "initFilterVisibility"
"handler": "initFilterVisibility",
"params": {
"storageKey": "{{storageKey}}"
}
}
}
},
@@ -10,6 +10,10 @@
- 동의 이력의 출처 "会員退会"(회원탈퇴) 일본어 라벨 추가
### Changed
- 자동 차단 정책 안내의 기능 카테고리 범위 문구에서 다크모드를 뺐습니다 — 화면 테마가 필수 항목으로 재분류되어 동의 여부와 무관하게 저장됩니다.
## [1.0.1] - 2026-08-10
### Added
@@ -124,11 +124,11 @@
"scope_label": "自動ブロック対象",
"tools_label": "代表的なツール例",
"necessary": {
"scope": "自動ブロックしません。セッション·ログイントークン、カート識別子、ユーザーが登録時に選択した言語設定、クッキー同意記録など、サイト動作に不可欠な項目は常に許可されます。",
"tools": "セッションID、認証トークン、CSRFトークン、カート識別子、多言語設定など(別途設定不要)"
"scope": "自動ブロックしません。セッション·ログイントークン、カート識別子、ユーザーが自ら選んだ言語設定と画面テーマ、クッキー同意記録など、サイト動作に不可欠な項目は常に許可されます。",
"tools": "セッションID、認証トークン、CSRFトークン、カート識別子、多言語設定、画面テーマなど(別途設定不要)"
},
"functional": {
"scope": "「自動ブロックポリシー」タブの機能カテゴリドメインリストに登録された外部リソースが、同意前までブロックされます。また、ダークモード·通貨選好などのユーザー利便設定も、同意後のみ保存されます。",
"scope": "「自動ブロックポリシー」タブの機能カテゴリドメインリストに登録された外部リソースが、同意前までブロックされます。また、通貨選好などのユーザー利便設定も、同意後のみ保存されます。",
"tools": "顧客サポートチャットボット(Crisp、Intercom、Tawk.to)、多言語自動翻訳ウィジェット、ユーザー設定同期サービスなど"
},
"analytics": {
@@ -11,6 +11,12 @@
- 확장 제거 시 표시되는 「운영자 파일 사본 보관」 안내 제목·설명의 일본어 번역을 추가했습니다 — 모듈·플러그인·템플릿 제거 결과 화면에서 보관 경로 안내가 일본어 로케일로 표시됩니다.
- 확장 제거가 끝난 뒤 결과 화면 제목(「모듈 제거 완료」·「플러그인 제거 완료」·「템플릿 제거 완료」)의 일본어 번역을 추가했습니다 — 종전에는 결과 화면인데 제목이 「제거 확인」으로 남아 있었습니다.
- 환경설정 > 고급의 리버스 프록시 진단 항목(라벨·상태·안내 문구)의 일본어 번역을 추가했습니다.
- 레이아웃 편집기 「필터 보이기/숨기기」·「필터 표시 초기화」 동작의 저장 키·필터 ID 입력 항목 이름을 일본어로 추가했습니다.
- 사이드바 하위 메뉴 펼치기/접기 버튼의 화면 낭독기 라벨을 일본어로 추가했습니다.
### Changed
- 레이아웃 편집기 「기간 빠르게 선택」의 마지막 선택지 표기를 「今年」에서 「直近1年」으로 바꿨습니다 — 실제 계산 범위가 올해가 아니라 최근 1년이었습니다.
## [1.0.7] - 2026-08-24
@@ -49,6 +49,8 @@
"logout": "ログアウト",
"expand_sidebar": "サイドバーを展開",
"collapse_sidebar": "サイドバーを折りたたむ",
"expand": "展開",
"collapse": "折りたたむ",
"module": "モジュール",
"plugin": "プラグイン",
"status_active": "有効化",
@@ -1261,7 +1261,7 @@
},
"scroll_to_section": {
"label": "特定の領域にスクロール",
"param_section_id": "領域ID"
"param_target_id": "領域ID"
},
"set_date_range": {
"label": "期間を素早く選択",
@@ -1269,15 +1269,15 @@
"preset_today": "今日",
"preset_week": "今週",
"preset_month": "今月",
"preset_year": "今年"
"preset_1year": "直近1年"
},
"toggle_filter_visibility": {
"label": "フィルターの表示·非表示",
"param_filter_key": "フィルターキー"
"param_storage_key": "保存キー",
"param_filter_id": "フィルターID"
},
"save_multilingual_tag": {
"label": "多言語タグを保存",
"param_tag": "タグ"
"label": "多言語タグを保存"
},
"init_theme": {
"label": "画面のテーマをリセット",
@@ -1290,7 +1290,8 @@
"label": "アドレスからメニューを初期化"
},
"init_filter_visibility": {
"label": "フィルター表示をリセット"
"label": "フィルター表示をリセット",
"param_storage_key": "保存キー"
}
},
"condition": {
+10 -1
View File
@@ -33,6 +33,12 @@ immutable append-only)으로 이중 기록합니다 — 지금 상태 조회와
necessary 4종(`XSRF-TOKEN`/세션/`laravel_maintenance`/`gdpr_session`)을 제외한 **모든** 쿠키를
차단합니다. EDPB Guidelines 2/2023 §16 원칙이 "비필수는 동의 전 전면 차단"이지 "등록된 것만
차단"이 아니기 때문입니다.
**허용목록은 둘입니다** — 쿠키(`cookieInterceptor.ts` · `CookieConsentMiddleware`)와 저장소
(`storageInterceptor.ts` 의 `DEFAULT_NECESSARY_ALLOWLIST`)는 서로 다른 목록이고 항목 수도
다릅니다. 위 "4종" 은 쿠키 쪽 이야기이며, 저장소 쪽은 코어가 동작에 필요로 하는 키와 WP29
Opinion 04/2012 §3.6 의 user-initiated preference 예외 항목(`g7_locale` 언어 설정,
`g7_color_scheme` 화면 테마)을 담습니다. 둘을 같은 목록으로 착각하면 한쪽만 고치게 됩니다.
<!-- @intent END -->
## 2. 디렉토리 지도
@@ -129,6 +135,8 @@ necessary 4종(`XSRF-TOKEN`/세션/`laravel_maintenance`/`gdpr_session`)을 제
- [ ] `gdpr_user_consent_histories` 는 append-only — UPDATE/DELETE 로 기존 행을 고치지 않는다 (완전삭제 시 익명화 UPDATE 예외는 `GdprUserDeleteListener` 단일 지점에서만 수행)
- [ ] 새 자동 차단 카테고리(기능/분석/마케팅 외)를 추가하면 배너 UI·`blocked_domains` 스키마·차단 스크립트 3곳 동기화
- [ ] `CookieConsentMiddleware` 의 strictly-necessary allowlist(4종)를 확장할 때는 ePrivacy Art.5(3) 면제 항목인지 먼저 검토 — 임의로 늘리면 동의 전 차단 원칙이 무력화된다
- [ ] `storageInterceptor.ts` 의 `DEFAULT_NECESSARY_ALLOWLIST`(저장소 쪽, 쿠키 목록과 별개)를 고치면 **동의 안내 문구도 함께** 고친다 — 항목이 어느 카테고리에 속하는지 사용자에게 말하는 자리가 `plugin.php`(설치 시드) · `src/Services/CookieCategoryService.php`(런타임 폴백) · `resources/lang/{ko,en}.json`(관리자 안내) · `editor-spec.json`(편집기 샘플) 넷이다. 한 곳만 고치면 동의 고지가 실제 동작과 어긋난 채 남는다
- [ ] 그 문구를 고쳤으면 기설치본의 **저장된** 안내도 정정하는 업그레이드 스텝을 동반한다 — 카테고리 정의는 설치 시점에 시드되고 이후 갱신되지 않는다 (선례: `upgrades/data/1.0.4/migrations/01_RetagThemeAsStrictlyNecessary.php`)
- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec.json` 을 함께 갱신 — 샘플이 없는 `data_source` 는 편집기 캔버스에서만 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다. 반영은 `php artisan plugin:update sirsoft-gdpr --force`
## 6. 금지 패턴
@@ -140,6 +148,7 @@ necessary 4종(`XSRF-TOKEN`/세션/`laravel_maintenance`/`gdpr_session`)을 제
| 회원탈퇴(`after_withdraw`)에서 신원 정보(user_id 등)를 제거 | 활성 동의만 철회 처리, 신원은 완전삭제(`before_delete`) 시점에만 익명화 | 두 이벤트를 섞으면 탈퇴 회원의 재가입·이력 조회가 깨진다 |
| 운영자가 등록하지 않은 functional 쿠키를 화이트리스트에 추가 | strictly necessary 4종 고정 목록만 예외 | GDPR 은 "동의 전 전면 차단"이 원칙이지 "등록된 것만 차단"이 아니다 |
| 정책 버전 발행을 코드/배치로 자동화 | 운영자가 매번 명시적으로 "+ 새 버전 발행" 클릭 | 자동화하면 사소한 문구 수정에도 전 회원이 재동의 화면을 보게 된다 |
| 저장소 허용목록만 고치고 동의 안내 문구는 그대로 두기 | 목록·문구 4곳·업그레이드 스텝을 한 작업 단위로 | 안내가 "이 항목은 기능 쿠키이고 거부하면 저장되지 않는다" 라고 말하는데 실제로는 항상 저장되면, 고지 자체가 사실과 달라진다 |
<!-- @intent END -->
## 7. 테스트 실행
@@ -148,7 +157,7 @@ necessary 4종(`XSRF-TOKEN`/세션/`laravel_maintenance`/`gdpr_session`)을 제
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 24개 | `plugins/_bundled/sirsoft-gdpr/tests` |
| Vitest | 12개 | `vitest.config.ts` |
| Vitest | 13개 | `vitest.config.ts` |
| Playwright | 3개 | `tests/Playwright` |
| 시나리오 매니페스트 | 5개 | `tests/scenarios` |
@@ -16,6 +16,9 @@
- 회원탈퇴로 자동 철회된 동의 이력이 관리자 「GDPR 동의 이력」 화면의 출처 필터로 걸러지지 않던 문제를 수정했습니다.
- 쿠키 동의 저장 요청이 서버만 기록해야 하는 출처(회원가입 시 동의)를 직접 지정할 수 있던 문제를 수정했습니다 — 동의 이력의 출처가 실제 동의 경로와 일치합니다.
- 화면 테마(밝게/어둡게/시스템 설정) 선택이 저장되지 않던 문제를 수정했습니다. 기능 쿠키에 동의하기 전에는 테마를 바꿔도 새로고침하면 원래대로 돌아갔고, 관리자·사용자 화면 모두 같았습니다. 테마는 사용자가 화면에서 직접 고른 표시 환경이므로 언어 설정과 같은 필수 항목으로 분류해 동의 여부와 무관하게 저장합니다.
- 이에 맞춰 쿠키 동의 안내 문구를 정정했습니다 — 필수 항목 설명에 화면 테마가 포함되고, 기능 쿠키 설명에서는 빠집니다. 이미 설치된 사이트의 저장된 안내 문구도 업데이트 시 함께 정정됩니다(운영자가 직접 고친 문구는 그대로 둡니다).
- 화면 테마와 같은 이유로 사라지던 나머지 항목도 함께 필수로 분류했습니다. 비회원이 주문 후 주문내역을 조회할 때 쓰는 정보, 결제창에서 돌아왔을 때 결제 중단 사유를 서버에 알리는 기록, 본인인증을 마치고 원래 화면·입력 내용으로 복귀하는 데 쓰는 기록, 관리자 화면의 메뉴 접기·목록 필터 표시·닫은 경고·레이아웃 편집기 작업 상태, 그리고 사이트가 자산 주소 형식을 기억해 두는 내부 캐시가 해당합니다. 기능 쿠키 동의 여부와 무관하게 유지됩니다.
## [1.0.3] - 2026-08-19
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-gdpr",
"version": "1.0.2",
"version": "1.0.4",
"components": {
"basic": [],
"composite": [],
File diff suppressed because one or more lines are too long
@@ -305,7 +305,7 @@ HTTP/1.1 200
"id": null,
"consent_key": "cookie_necessary",
"consent_label": "필수 쿠키",
"consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"consent_category": "necessary",
"is_required": true,
"is_consented": false,
@@ -322,7 +322,7 @@ HTTP/1.1 200
"id": null,
"consent_key": "cookie_functional",
"consent_label": "기능 쿠키",
"consent_description": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"consent_description": "사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"consent_category": "functional",
"is_required": false,
"is_consented": false,
@@ -82,8 +82,8 @@ HTTP/1.1 200
"en": "Strictly Necessary"
},
"description": {
"ko": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"en": "Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference at registration, cookie consent record. Cannot be disabled."
"ko": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"en": "Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference and display theme, cookie consent record. Cannot be disabled."
}
},
{
@@ -94,8 +94,8 @@ HTTP/1.1 200
"en": "Functional"
},
"description": {
"ko": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"en": "Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit."
"ko": "사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"en": "Cookies that remember user preferences such as display currency. If declined, defaults are used on every visit."
}
},
{
@@ -325,8 +325,8 @@ HTTP/1.1 200
"en": "Strictly Necessary"
},
"description": {
"ko": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"en": "Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference at registration, cookie consent record. Cannot be disabled."
"ko": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"en": "Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference and display theme, cookie consent record. Cannot be disabled."
}
},
{
@@ -337,8 +337,8 @@ HTTP/1.1 200
"en": "Functional"
},
"description": {
"ko": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"en": "Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit."
"ko": "사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"en": "Cookies that remember user preferences such as display currency. If declined, defaults are used on every visit."
}
},
{
@@ -222,8 +222,8 @@
"en": "Strictly Necessary"
},
"description": {
"ko": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"en": "Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference at registration, cookie consent record. Cannot be disabled."
"ko": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"en": "Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference and display theme, cookie consent record. Cannot be disabled."
}
},
{
@@ -234,8 +234,8 @@
"en": "Functional"
},
"description": {
"ko": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"en": "Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit."
"ko": "사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"en": "Cookies that remember user preferences such as display currency. If declined, defaults are used on every visit."
}
},
{
@@ -397,7 +397,7 @@
"id": 1,
"consent_key": "cookie_necessary",
"consent_label": "필수 쿠키",
"consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"consent_category": "cookie",
"is_required": true,
"is_consented": true,
@@ -416,7 +416,7 @@
"id": 2,
"consent_key": "cookie_functional",
"consent_label": "기능 쿠키",
"consent_description": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"consent_description": "사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"consent_category": "cookie",
"is_required": false,
"is_consented": true,
@@ -478,7 +478,7 @@
"id": 1,
"consent_key": "cookie_necessary",
"consent_label": "필수 쿠키",
"consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"consent_description": "세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.",
"consent_category": "cookie",
"is_required": true,
"is_consented": true,
@@ -497,7 +497,7 @@
"id": 2,
"consent_key": "cookie_functional",
"consent_label": "기능 쿠키",
"consent_description": "사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"consent_description": "사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.",
"consent_category": "cookie",
"is_required": false,
"is_consented": true,
+7 -7
View File
@@ -408,22 +408,22 @@ class Plugin extends AbstractPlugin
'required' => true,
'label' => ['ko' => '필수 쿠키', 'en' => 'Strictly Necessary'],
'description' => [
// g7_locale 은 ePrivacy Art.5(3) + WP29 Opinion 04/2012 §3.6 의 user-initiated preference
// 예외 (사용자 가입 시 명시 선택) 로 strictly necessary 분류. 사용자 안내에 명시.
'ko' => '세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.',
'en' => 'Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference at registration, cookie consent record. Cannot be disabled.',
// g7_locale·g7_color_scheme 은 ePrivacy Art.5(3) + WP29 Opinion 04/2012 §3.6 의 user-initiated preference
// 예외 (사용자가 화면에서 직접 고른 표시 환경) 로 strictly necessary 분류. 사용자 안내에 명시.
'ko' => '세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.',
'en' => 'Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference and display theme, cookie consent record. Cannot be disabled.',
],
],
[
// Phase 1: functional 카테고리 신설 — ICO/CNIL 4분류 체계 부합.
// 자체 functional 키 (다크모드/통화) + 외부 functional 도구 (Crisp, Intercom 등) 분류 영역.
// 자체 functional 키 (표시 통화 등) + 외부 functional 도구 (Crisp, Intercom 등) 분류 영역.
// Phase 2 에서 실제 게이팅 (Storage.prototype 가로채기 + cookie 가로채기 + Set-Cookie 미들웨어) 구현 예정.
'key' => 'functional',
'required' => false,
'label' => ['ko' => '기능 쿠키', 'en' => 'Functional'],
'description' => [
'ko' => '사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.',
'en' => 'Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit.',
'ko' => '사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.',
'en' => 'Cookies that remember user preferences such as display currency. If declined, defaults are used on every visit.',
],
],
[
@@ -67,6 +67,17 @@ describe('functionalCleaner', () => {
expect(window.localStorage.getItem('app_pref')).toBeNull();
});
// dev-g7#640: 동의 철회 정리에서도 테마는 언어 설정과 같이 남아야 한다.
it('strictly necessary 키 (g7_color_scheme) 는 보존', () => {
window.localStorage.setItem('g7_color_scheme', 'dark');
window.localStorage.setItem('app_pref', 'value');
cleanupFunctionalArtifacts();
expect(window.localStorage.getItem('g7_color_scheme')).toBe('dark');
expect(window.localStorage.getItem('app_pref')).toBeNull();
});
it('prefix 매칭 키 (g7_devtools_*) 는 보존', () => {
window.localStorage.setItem('g7_devtools_filter', 'enabled');
window.localStorage.setItem('app_pref', 'value');
@@ -0,0 +1,403 @@
/**
* strictly necessary allowlist 모집단 커버리지 테스트
*
* functionalCleaner 는 functional 미동의(기본 상태)에서 **부팅마다** allowlist 밖의
* localStorage / sessionStorage 를 전량 파기한다. 그래서 코어·확장이 새로 저장 키를
* 도입하면서 allowlist 등재를 잊으면, 그 설정은 "저장은 되는데 새로고침하면 사라지는"
* 상태가 된다 — 예외도 콘솔 오류도 남지 않아 증상만으로는 원인을 특정할 수 없다.
*
* 이 테스트는 allowlist 를 **손으로 열거하지 않는다.** 저장소의 소스를 훑어
* `.setItem()` 이 쓰는 키를 기계 도출하고, 그 전량이 allowlist 에 등재되었거나
* 의도적 비필수(INTENTIONALLY_NON_NECESSARY)로 선언되었는지 검사한다.
* 새 저장 키가 추가되면 그 시점에 붉어진다.
*
* @module sirsoft-gdpr/__tests__/necessaryAllowlistCoverage
*/
import { describe, it, expect } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { DEFAULT_NECESSARY_ALLOWLIST } from '../storageInterceptor';
/**
* 저장소 루트를 탐색합니다. (artisan + composer.json 동시 보유 디렉토리)
*
* @return 저장소 루트 절대경로
*/
function findRepoRoot(): string {
let dir = __dirname;
for (let i = 0; i < 12; i += 1) {
if (
fs.existsSync(path.join(dir, 'artisan'))
&& fs.existsSync(path.join(dir, 'composer.json'))
) {
return dir;
}
dir = path.dirname(dir);
}
throw new Error('저장소 루트를 찾지 못했습니다.');
}
const REPO_ROOT = findRepoRoot();
/**
* 스캔 대상 디렉토리 (glob 없이 실제 디렉토리 열거로 확장 전량 포함).
*
* @return 존재하는 스캔 대상 절대경로 배열
*/
function scanRoots(): string[] {
const roots: string[] = [path.join(REPO_ROOT, 'resources', 'js', 'core')];
const extensionGroups: Array<[string, string[]]> = [
['templates', ['src']],
['modules', ['resources', 'js']],
['plugins', ['resources', 'js']],
];
for (const [group, tail] of extensionGroups) {
const bundled = path.join(REPO_ROOT, group, '_bundled');
if (!fs.existsSync(bundled)) {
continue;
}
for (const entry of fs.readdirSync(bundled)) {
const candidate = path.join(bundled, entry, ...tail);
if (fs.existsSync(candidate)) {
roots.push(candidate);
}
}
}
return roots.filter((dir) => fs.existsSync(dir));
}
/**
* 디렉토리를 재귀 순회하며 .ts/.tsx 파일을 수집합니다. (테스트 디렉토리 제외)
*
* @param dir 순회 시작 디렉토리
* @param out 누적 배열
* @return void
*/
function collectSourceFiles(dir: string, out: string[]): void {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === '__tests__' || entry.name === 'node_modules' || entry.name === 'dist') {
continue;
}
collectSourceFiles(full, out);
continue;
}
if (/\.(ts|tsx)$/.test(entry.name) && !/\.d\.ts$/.test(entry.name)) {
out.push(full);
}
}
}
/**
* 파일 안에서 식별자에 대입된 문자열 리터럴을 찾습니다.
*
* @param source 파일 원문
* @param ident 식별자
* @return 리터럴 값 또는 null
*/
function resolveIdentifierLiteral(source: string, ident: string): string | null {
const re = new RegExp(
`(?:const|let|var|readonly)\\s+${ident}\\s*(?::[^=]+)?=\\s*(['"])([^'"]*)\\1`,
);
const m = re.exec(source);
return m ? m[2] : null;
}
/**
* 주석을 제거합니다.
*
* 주석 안의 `.setItem()` 서술이 호출로 오인되면 쓰레기 키가 모집단에 섞이고,
* 그 노이즈가 실제 미해석 호출을 가린다.
*
* @param source 파일 원문
* @return 주석이 공백으로 치환된 원문 (오프셋 보존)
*/
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
.replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + ' '.repeat(m.length - p1.length));
}
/**
* `import { X } from './y'` 를 따라가 다른 파일의 상수를 해석합니다.
*
* 코어의 저장 키 상수는 대부분 별도 모듈(`constants.ts` · `types.ts`)에 모여 있어,
* 같은 파일만 보면 그 키가 통째로 모집단에서 빠진다.
*
* @param file 호출이 있는 파일 절대경로
* @param source 그 파일 원문
* @param ident 식별자
* @return 리터럴 값 또는 null
*/
function resolveImportedLiteral(file: string, source: string, ident: string): string | null {
const importRe = new RegExp(
`import\\s*\\{[^}]*\\b${ident}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`,
);
const m = importRe.exec(source);
if (m === null || !m[1].startsWith('.')) {
return null;
}
const base = path.resolve(path.dirname(file), m[1]);
const candidates = [
`${base}.ts`, `${base}.tsx`,
path.join(base, 'index.ts'), path.join(base, 'index.tsx'),
];
for (const candidate of candidates) {
if (!fs.existsSync(candidate)) {
continue;
}
const target = stripComments(fs.readFileSync(candidate, 'utf-8'));
const value = resolveIdentifierLiteral(target, ident);
if (value !== null) {
return value;
}
}
return null;
}
/**
* 식별자에 대입된 **템플릿 리터럴** 원문을 찾습니다.
*
* `const fullKey = ` + 백틱 표현식처럼 키가 한 단계 변수를 거쳐 조립되는 형태를
* 놓치면 그 키가 모집단에서 통째로 빠진다 (접두사 키가 전부 이 형태다).
*
* @param source 파일 원문
* @param ident 식별자
* @return 백틱을 포함한 템플릿 원문 또는 null
*/
function resolveIdentifierTemplate(source: string, ident: string): string | null {
// 초기화식이 삼항/`useMemo(() => ...)` 로 감싸인 경우까지 닿도록, 선언 뒤
// 가까운 범위에서 첫 백틱 템플릿을 찾는다 (저장 키 조립의 실제 형태들).
const declRe = new RegExp(`(?:const|let|var|readonly)\\s+${ident}\\s*(?::[^=]+)?=`);
const decl = declRe.exec(source);
if (decl === null) {
return null;
}
const window = source.slice(decl.index, decl.index + 400);
const tpl = /`[^`]*`/.exec(window);
return tpl ? tpl[0] : null;
}
interface DiscoveredKey {
key: string;
matchType: 'exact' | 'prefix';
origin: string;
}
/**
* `.setItem(...)` 호출의 키 표현식을 정적으로 해석합니다.
*
* 해석 가능한 형태: 문자열 리터럴 · 식별자(같은 파일의 상수) · 템플릿 리터럴(정적 접두사).
*
* @param keyExpr 키 표현식 원문
* @param source 파일 원문 (식별자 해석용)
* @return 해석 결과 또는 null (해석 불가)
*/
function resolveKeyExpression(
keyExpr: string,
source: string,
file: string,
): { key: string; matchType: 'exact' | 'prefix' } | null {
const expr = keyExpr.trim();
const literal = /^(['"])(.*)\1$/.exec(expr);
if (literal) {
return { key: literal[2], matchType: 'exact' };
}
// `this.TOKEN_KEY` · `TemplateApp.LOCALE_STORAGE_KEY` 같은 멤버 표현식은
// 마지막 조각이 곧 상수명이다. 여기서 끊으면 코어 키 다수가 통째로 빠진다.
const member = /^(?:[A-Za-z_$][\w$]*\.)+([A-Za-z_$][\w$]*)$/.exec(expr);
const ident = member ? member[1] : expr;
if (/^[A-Za-z_$][\w$]*$/.test(ident)) {
const value = resolveIdentifierLiteral(source, ident);
if (value !== null) {
return { key: value, matchType: 'exact' };
}
// 변수를 한 단계 거쳐 조립되는 템플릿 키 (접두사 키의 대표 형태).
const template = resolveIdentifierTemplate(source, ident);
if (template !== null) {
return resolveKeyExpression(template, source, file);
}
// 다른 모듈에 모여 있는 상수.
const imported = resolveImportedLiteral(file, source, ident);
return imported === null ? null : { key: imported, matchType: 'exact' };
}
// `storageKey()` 처럼 키를 조립해 돌려주는 무인자 함수. 본문의 return 템플릿을
// 따라간다 — 여기서 끊으면 그 키의 허용목록 등재가 아무 검사도 받지 않는다
// (등재를 지워도 초록인 죽은 축이 된다).
const call = /^([A-Za-z_$][\w$]*)\(\s*\)$/.exec(expr);
if (call !== null) {
const fnRe = new RegExp(`function\\s+${call[1]}\\s*\\([^)]*\\)[^{]*\\{`);
const fn = fnRe.exec(source);
if (fn !== null) {
const body = source.slice(fn.index, fn.index + 600);
const ret = /return\s+(`[^`]*`|['"][^'"]*['"])/.exec(body);
if (ret !== null) {
return resolveKeyExpression(ret[1], source, file);
}
}
return null;
}
if (expr.startsWith('`')) {
const body = expr.slice(1, -1);
// 선두가 `${IDENT}` 이면 그 상수를 펼쳐 정적 접두사를 만든다.
const leading = /^\$\{\s*([A-Za-z_$][\w$]*)\s*\}/.exec(body);
let prefix = '';
let rest = body;
if (leading) {
const value = resolveIdentifierLiteral(source, leading[1]);
if (value === null) {
return null;
}
prefix = value;
rest = body.slice(leading[0].length);
}
const staticHead = rest.split('${')[0];
prefix += staticHead;
return prefix === '' ? null : { key: prefix, matchType: 'prefix' };
}
return null;
}
/**
* 저장소 전체에서 storage 저장 키를 도출합니다.
*
* @return 도출된 키 목록
*/
function discoverStorageKeys(): { keys: DiscoveredKey[]; unresolved: string[] } {
const files: string[] = [];
for (const root of scanRoots()) {
collectSourceFiles(root, files);
}
const found = new Map<string, DiscoveredKey>();
const unresolved: string[] = [];
// 수신자 표현식을 좁히지 않는다 — `safeSessionStorage()?.setItem(...)` 처럼
// 호출 결과에 바로 붙는 형태를 놓치면 그 키가 모집단에서 통째로 빠진다.
const callRe = /\.setItem\(\s*([^,]+?)\s*,/g;
for (const file of files) {
const source = stripComments(fs.readFileSync(file, 'utf-8'));
callRe.lastIndex = 0;
let m: RegExpExecArray | null = callRe.exec(source);
while (m !== null) {
const origin = path.relative(REPO_ROOT, file).replace(/\\/g, '/');
const resolved = resolveKeyExpression(m[1], source, file);
if (resolved === null) {
// 조용히 버리지 않는다 — 버리면 모집단이 부분적으로 비어도 초록이 된다.
unresolved.push(`${m[1].replace(/\s+/g, ' ')} ← ${origin}`);
} else {
const id = `${resolved.matchType}:${resolved.key}`;
if (!found.has(id)) {
found.set(id, { ...resolved, origin });
}
}
m = callRe.exec(source);
}
}
return {
keys: [...found.values()].sort((a, b) => a.key.localeCompare(b.key)),
unresolved: [...new Set(unresolved)].sort(),
};
}
/**
* 필수(strictly necessary) 가 **아니라고 의도적으로 판단한** 키.
*
* 여기 적힌 키는 functional 동의가 없으면 파기되는 것이 설계 의도다.
* 새 키를 여기 넣을 때는 사용자에게 그 상실이 수용 가능한지 근거를 함께 남긴다.
*/
/**
* 키가 실행 시점에 정해져 **정적으로 해석할 수 없는** 쓰기 지점.
*
* 이 목록은 사각을 지우는 것이 아니라 드러내 고정한다. 정렬된 문자열로 비교하므로
* 새 동적 쓰기 지점이 생기면 그 즉시 붉어진다.
*/
const DECLARED_DYNAMIC_CALL_SITES: ReadonlyArray<{ site: string; reason: string }> = [
{
site: 'key ← resources/js/core/template-engine/ActionDispatcher.ts',
reason: '`saveToLocalStorage` 핸들러 — 키를 레이아웃 액션이 넘긴다. 레이아웃이 임의로 정하는 값이라 목록으로 열거할 수 없다.',
},
{
site: 'key ← templates/_bundled/sirsoft-basic/src/handlers/storageHandlers.ts',
reason: '`setLocalStorage` 핸들러 — 위와 같은 이유 (레이아웃이 키를 정한다).',
},
];
const INTENTIONALLY_NON_NECESSARY: ReadonlyArray<{ key: string; reason: string }> = [
{
key: 'g7_preferred_currency',
reason: '표시 통화 — functional 카테고리 설명에 명시된 선호도. 미동의 시 기본 통화로 표시되는 것이 의도된 동작이다.',
},
];
/**
* 도출된 키가 allowlist 에 등재되어 있는지 검사합니다.
*
* 저장소 종류(localStorage/sessionStorage)는 개별 테스트가 잠그며, 여기서는
* "필수 목록이 이 키를 알고 있는가" 만 본다.
*
* @param discovered 도출된 키
* @return 등재 여부
*/
function isDeclared(discovered: DiscoveredKey): boolean {
return DEFAULT_NECESSARY_ALLOWLIST.some((entry) => {
const entryMatch = entry.matchType ?? 'exact';
if (entryMatch === 'exact') {
return entry.key === discovered.key;
}
// prefix 항목은 그 접두사로 시작하는 키(정확형·접두형 모두)를 덮는다.
return discovered.key.startsWith(entry.key);
});
}
describe('strictly necessary allowlist 모집단 커버리지', () => {
const { keys: discovered, unresolved } = discoverStorageKeys();
it('저장 키 도출이 비어 있지 않다 (모집단 하한 — 스캐너가 죽으면 붉어진다)', () => {
expect(discovered.length).toBeGreaterThanOrEqual(10);
});
it('정적 해석 불가 지점이 선언된 목록과 정확히 일치한다 (부분 누락 방지)', () => {
// 해석 못 한 표현식을 버리면 그 키는 검사 대상에서 사라지는데, 결과는
// "이상 0건" 과 구분되지 않는다. 그래서 버리지 않고 **선언**한다 —
// 새 동적 쓰기 지점이 생기면 여기서 붉어지고, 그때 그 키가 파기돼도
// 되는지 판단해 선언에 추가하거나 정적 상수로 바꾼다.
expect(unresolved).toEqual(DECLARED_DYNAMIC_CALL_SITES.map((s) => s.site));
});
it('도출된 모든 저장 키가 allowlist 등재 또는 의도적 비필수 선언 중 하나에 해당한다', () => {
const exempt = new Set(INTENTIONALLY_NON_NECESSARY.map((e) => e.key));
const uncovered = discovered
.filter((d) => !exempt.has(d.key))
.filter((d) => !isDeclared(d))
.map((d) => `${d.key} (${d.matchType}) ← ${d.origin}`);
expect(uncovered).toEqual([]);
});
it('의도적 비필수 선언은 실제로 존재하는 키만 담는다 (사문화 방지)', () => {
const discoveredKeys = new Set(discovered.map((d) => d.key));
const stale = INTENTIONALLY_NON_NECESSARY
.filter((e) => !discoveredKeys.has(e.key))
.map((e) => e.key);
expect(stale).toEqual([]);
});
});
@@ -43,6 +43,18 @@ describe('storageInterceptor', () => {
expect(window.localStorage.getItem('g7_locale')).toBe('ko');
});
// dev-g7#640: 화면 테마가 목록에서 빠져 있어, 동의 전에는 테마를 바꿔도 저장이
// 조용히 버려졌다 (새로고침하면 원래대로). 미인증 화면뿐 아니라 관리자 화면 전체가 같았다.
it('strictly necessary 키 (g7_color_scheme) → 항상 통과 (미동의여도)', () => {
installStorageInterceptor({
functionalConsented: false,
necessaryAllowlist: DEFAULT_NECESSARY_ALLOWLIST,
});
window.localStorage.setItem('g7_color_scheme', 'dark');
expect(window.localStorage.getItem('g7_color_scheme')).toBe('dark');
});
it('prefix 매칭 (g7_devtools_*) → 통과', () => {
installStorageInterceptor({
functionalConsented: false,
@@ -139,11 +151,13 @@ describe('storageInterceptor', () => {
__setLastInteractionForTest(Date.now());
expect(isStorageAllowed('app_pref', 'localStorage')).toBe(true); // user-initiated 면제
expect(isStorageAllowed('g7_locale', 'localStorage')).toBe(true); // necessary
expect(isStorageAllowed('g7_color_scheme', 'localStorage')).toBe(true); // necessary
// user-initiated 가 만료된 시점엔 차단
__setLastInteractionForTest(0);
expect(isStorageAllowed('app_pref', 'localStorage')).toBe(false);
expect(isStorageAllowed('g7_locale', 'localStorage')).toBe(true); // necessary 는 항상 통과
expect(isStorageAllowed('g7_color_scheme', 'localStorage')).toBe(true); // necessary 는 항상 통과
// 함수 호출만으로 storage 변경 X
expect(window.localStorage.getItem('app_pref')).toBeNull();
@@ -7,8 +7,8 @@
*
* 게이팅 규칙 (Phase 2 단순화 — 4단계):
* 1. strictly necessary allowlist 매칭 → 항상 허용
* (XSRF-TOKEN, g7_locale, auth_token, g7_cart_key, g7_cache_version,
* g7-devtools-panel, g7_devtools_*, g7_filters_*, g7_columns_*, g7_order_*)
* (등재 항목은 `DEFAULT_NECESSARY_ALLOWLIST` 가 SSoT — 여기에 목록을 복제하지
* 않는다. 사본을 두면 항목이 늘 때 한쪽만 고쳐져 조용히 어긋난다.)
* 2. functional 동의 → 허용
* 3. user-initiated 면제 (WP29 §3.6, 항상 활성) → 사용자 인터랙션 직후 허용
* 4. 그 외 → 차단
@@ -59,25 +59,62 @@ export interface StorageInterceptorConfig {
* 본 목록은 G7 코어가 정상 동작하는 데 반드시 필요한 키만 포함. 운영자가 추가 등록 불가
* (코드 상수). 운영자가 추가하려면 본 파일 수정 + PR 필요.
*
* prefix 매칭: g7_devtools_*, g7_filters_*, g7_columns_*, g7_order_*
* exact 매칭: g7_locale, auth_token, g7_cache_version, g7_cart_key, g7-devtools-panel
* 계열: 인증·세션 / 사용자 명시 선택(언어·테마) / 구매 동선(장바구니·비회원 주문) /
* 관리자 화면 상태 / 레이아웃 편집기 작업 상태 / 결제 복귀 기록 / 본인인증 복귀.
*
* 이 배열이 유일한 정본이다. 코어·확장이 새 저장 키를 도입하면서 여기 등재를 잊으면
* functionalCleaner 가 부팅마다 그 키를 파기해 "저장은 되는데 새로고침하면 사라지는"
* 상태가 되고, 예외도 로그도 남지 않는다. 그 누락은 저장소의 `.setItem()` 을 기계
* 도출해 대조하는 `__tests__/necessaryAllowlistCoverage.test.ts` 가 잡는다.
*/
export const DEFAULT_NECESSARY_ALLOWLIST: readonly NecessaryAllowlistEntry[] = [
// 사용자 명시 선택 (WP29 §3.6) — 다국어 설정
{ key: 'g7_locale', storage: 'localStorage', matchType: 'exact' },
// 사용자 명시 선택 (WP29 §3.6) — 화면 테마(밝게/어둡게/시스템 설정).
// 언어 설정과 같은 범주다: 사용자가 화면에서 직접 고른 표시 환경이며 추적에 쓰이지
// 않는다. 목록에서 빠져 있는 동안에는 테마를 바꿔도 새로고침하면 되돌아갔고,
// 그 증상이 미인증 화면뿐 아니라 관리자 화면 전체에 나타났다 (dev-g7#640).
{ key: 'g7_color_scheme', storage: 'localStorage', matchType: 'exact' },
// 인증 토큰 — 로그인 유지 필수 (strictly necessary, Art.6(1)(b))
{ key: 'auth_token', storage: 'localStorage', matchType: 'exact' },
// 코어 캐시 버전 — 운영자가 의도적 갱신 시 사용
{ key: 'g7_cache_version', storage: 'localStorage', matchType: 'exact' },
// 자산 URL 형식 판정 캐시 — 사용자 정보가 아니라 서버 능력 판정 결과다.
// 파기되면 재방문마다 기본 형식으로 첫 자산 요청을 보내 404 를 겪은 뒤에야
// 대체 형식으로 넘어간다. 키에 캐시 버전이 붙으므로 접두사로 등재한다.
{ key: 'g7_asset_url_mode', storage: 'localStorage', matchType: 'prefix' },
// 장바구니 게스트 키 — 익명 카트 식별 (구매 동선 필수)
{ key: 'g7_cart_key', storage: 'localStorage', matchType: 'exact' },
// devtools UI 상태 — 개발자 환경 (strictly necessary 개발자 도구)
{ key: 'g7-devtools-panel', storage: 'localStorage', matchType: 'exact' },
// 비회원 주문 조회 — 주문 이행에 필요 (Art.6(1)(b)). 게스트 장바구니 키와 같은 범주로,
// 파기되면 방금 주문한 비회원이 자기 주문내역에 도달할 수 없다.
{ key: 'g7_guest_order_token', storage: 'localStorage', matchType: 'exact' },
{ key: 'g7_guest_order_number', storage: 'localStorage', matchType: 'exact' },
{ key: 'g7_guest_order_expires_at', storage: 'localStorage', matchType: 'exact' },
// 관리자 페이지 상태 (필터/정렬/컬럼/devtools) — 사용자 의사로 조작
{ key: 'g7_devtools_', matchType: 'prefix' },
{ key: 'g7_filters_', matchType: 'prefix' },
{ key: 'g7_columns_', matchType: 'prefix' },
{ key: 'g7_order_', matchType: 'prefix' },
// 관리자 화면 표시 설정 — 테마와 같은 범주 (사용자가 화면에서 직접 고른 표시 환경).
// `g7_filters_` 는 목록 필터 '값' 이고 `g7_filter_visibility_` 는 필터 '표시 여부' 라
// 접두사가 서로 덮지 않는다 — 목록에 따로 서야 한다 (dev-g7#640).
{ key: 'g7_admin_sidebar_collapsed', storage: 'localStorage', matchType: 'exact' },
{ key: 'g7_filter_visibility_', matchType: 'prefix' },
{ key: 'g7_dismissed_warnings', storage: 'localStorage', matchType: 'exact' },
// 레이아웃 편집기 작업 상태 (라우트 트리 접힘·클립보드) — 운영자 편집 동선 유지
{ key: 'g7le.', matchType: 'prefix' },
// 결제 진행 기록 — 결제창 복귀 시 종료·실패 사유 보고에 필요 (Art.6(1)(b)).
// 결제창은 전체 페이지 이동으로 열리고 돌아오므로 sessionStorage 에 맡겨 두는데,
// 부팅 시 파기되면 그 보고가 통째로 누락된다.
{ key: 'g7:sirsoft-pay_kginicis:pendingClose', storage: 'sessionStorage', matchType: 'exact' },
{ key: 'g7:sirsoft-pay_nhnkcp:pendingClose', storage: 'sessionStorage', matchType: 'exact' },
{ key: 'g7:sirsoft-tosspayments:pendingClose', storage: 'sessionStorage', matchType: 'exact' },
{ key: '__sirsoftKginicisMobilePaymentReturnPending', matchType: 'exact' },
// 본인인증 복귀 — 인증창에서 돌아왔을 때 원래 화면·입력 내용 복원에 필요
{ key: 'g7.identity.redirectStash', matchType: 'exact' },
{ key: 'sirsoft-verification_nhnkcp.formStash', matchType: 'exact' },
];
let installed = false;
@@ -6,7 +6,7 @@
*
* WP29 Opinion 04/2012 §3.6 + EDPB Guidelines 2/2023 §16 의 "user-initiated
* preference" 면제 적용 — functional 카테고리 미동의 상태에서도 사용자가 직접
* 트리거한 설정 저장 (다크모드 토글, 통화 변경 등) 은 허용해야 함. 그렇지 않으면
* 트리거한 설정 저장 (통화 변경 등) 은 허용해야 함. 그렇지 않으면
* 메뉴 클릭 시점에 setItem 차단되어 UX 가 무너짐.
*
* 판정 기준:
@@ -124,11 +124,11 @@
"scope_label": "Auto-blocking scope",
"tools_label": "Common tools",
"necessary": {
"scope": "Not auto-blocked. Session/auth tokens, shopping basket identifier, user-selected language preference at registration, and cookie consent records are essential to site operation and always allowed.",
"tools": "Session IDs, authentication tokens, CSRF tokens, shopping basket identifier, language preference, etc. (no configuration required)"
"scope": "Not auto-blocked. Session/auth tokens, shopping basket identifier, user-selected language preference and display theme, and cookie consent records are essential to site operation and always allowed.",
"tools": "Session IDs, authentication tokens, CSRF tokens, shopping basket identifier, language preference, display theme, etc. (no configuration required)"
},
"functional": {
"scope": "External resources matching the functional blocked-domain list (in the Auto-blocking Policy tab) are auto-blocked until the user consents. User preferences such as dark mode and currency selection are also persisted only after consent.",
"scope": "External resources matching the functional blocked-domain list (in the Auto-blocking Policy tab) are auto-blocked until the user consents. User preferences such as currency selection are also persisted only after consent.",
"tools": "Customer support chatbots (Crisp, Intercom, Tawk.to), translation widgets, user-preference synchronization services, etc."
},
"analytics": {
@@ -124,11 +124,11 @@
"scope_label": "자동 차단 대상",
"tools_label": "대표 도구 예시",
"necessary": {
"scope": "자동 차단하지 않습니다. 세션·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 동작에 꼭 필요한 항목은 항상 허용됩니다.",
"tools": "세션 ID, 인증 토큰, CSRF 토큰, 장바구니 식별자, 다국어 설정 등 (별도 설정 불필요)"
"scope": "자동 차단하지 않습니다. 세션·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 동작에 꼭 필요한 항목은 항상 허용됩니다.",
"tools": "세션 ID, 인증 토큰, CSRF 토큰, 장바구니 식별자, 다국어 설정, 화면 테마 등 (별도 설정 불필요)"
},
"functional": {
"scope": "「자동 차단 정책」 탭의 기능 카테고리 도메인 목록에 등록된 외부 리소스가 동의 전까지 자동 차단됩니다. 또한 다크모드·통화 선호 같은 사용자 편의 저장도 동의 후에만 보관됩니다.",
"scope": "「자동 차단 정책」 탭의 기능 카테고리 도메인 목록에 등록된 외부 리소스가 동의 전까지 자동 차단됩니다. 또한 통화 선호 같은 사용자 편의 저장도 동의 후에만 보관됩니다.",
"tools": "고객지원 챗봇 (Crisp, Intercom, Tawk.to), 다국어 자동 번역 위젯, 사용자 설정 동기화 서비스 등"
},
"analytics": {
@@ -202,21 +202,21 @@ class CookieCategoryService
'required' => true,
'label' => ['ko' => '필수 쿠키', 'en' => 'Strictly Necessary'],
'description' => [
// g7_locale 은 ePrivacy Art.5(3) + WP29 Opinion 04/2012 §3.6 의 user-initiated preference
// 예외 (사용자 가입 시 명시 선택) 로 strictly necessary 분류. 사용자 안내에 명시.
'ko' => '세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.',
'en' => 'Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference at registration, cookie consent record. Cannot be disabled.',
// g7_locale·g7_color_scheme 은 ePrivacy Art.5(3) + WP29 Opinion 04/2012 §3.6 의 user-initiated preference
// 예외 (사용자가 화면에서 직접 고른 표시 환경) 로 strictly necessary 분류. 사용자 안내에 명시.
'ko' => '세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.',
'en' => 'Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference and display theme, cookie consent record. Cannot be disabled.',
],
],
[
// Phase 1: functional 카테고리 신설 — ICO/CNIL 4분류 체계 부합.
// 자체 functional 키 (다크모드/통화) + 외부 functional 도구 (Crisp, Intercom 등) 분류 영역.
// 자체 functional 키 (표시 통화 등) + 외부 functional 도구 (Crisp, Intercom 등) 분류 영역.
'key' => 'functional',
'required' => false,
'label' => ['ko' => '기능 쿠키', 'en' => 'Functional'],
'description' => [
'ko' => '사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.',
'en' => 'Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit.',
'ko' => '사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.',
'en' => 'Cookies that remember user preferences such as display currency. If declined, defaults are used on every visit.',
],
],
[
@@ -0,0 +1,17 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* sirsoft-gdpr 플러그인 1.0.4 업그레이드 스텝
*
* 저장된 쿠키 카테고리 설명에서 화면 테마를 「기능 쿠키」로 안내하던 문구를 정정한다.
* 화면 테마(`g7_color_scheme`)가 언어 설정과 같은 strictly necessary 항목으로 재분류되어,
* 동의 여부와 무관하게 저장되기 때문이다. 소스 기본값은 교정했으나 기설치본은 설치 시점에
* 시드된 설정 파일을 그대로 쓰므로 안내 문구만 사실과 어긋난 채 남는다.
*
* 모든 비즈니스 로직은 data/1.0.4/migrations/ 로 격리(AbstractUpgradeStep 규약).
*/
class Upgrade_1_0_4 extends AbstractUpgradeStep {}
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Upgrades\Data\Ext\Plugins\SirsoftGdpr\V1_0_4\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use App\Support\ExtensionStoragePath;
use Illuminate\Support\Facades\File;
/**
* 저장된 쿠키 카테고리 안내에서 화면 테마의 분류를 정정합니다.
*
* 배경:
* 화면 테마(`g7_color_scheme`)는 사용자가 화면에서 직접 고른 표시 환경이므로 언어 설정과
* 같은 strictly necessary 항목입니다. 그런데 저장 게이트의 필수 목록에서 빠져 있어, 기능
* 쿠키에 동의하기 전에는 테마를 바꿔도 저장이 조용히 버려졌습니다(새로고침하면 원래대로).
* 재분류로 그 동작은 고쳤지만, 동의 안내는 여전히 "다크모드는 기능 쿠키이고 거부하면 매
* 방문마다 기본값" 이라고 말합니다. 안내가 실제 동작과 어긋난 채 남으면 동의 고지 자체가
* 사실과 다른 상태가 되므로 저장된 문구도 함께 정정합니다.
*
* 멱등: 이미 정정된 문구는 그대로 둡니다. 재실행해도 결과가 같습니다.
*
* 안전:
* - **알려진 구 문구와 정확히 일치할 때만** 교체합니다 — 운영자나 다른 경로가 바꾼 문구를
* 덮어쓰지 않습니다.
* - 설명이 아예 없는 항목에는 **넣지 않습니다** — 배너는 설명이 있을 때만 문단을 그리므로,
* 없던 설명을 주입하면 화면에 없던 문단이 새로 생깁니다(이 스텝의 목적 밖).
* - 카테고리 구성(키·필수 여부·라벨)은 건드리지 않습니다.
*
* V-1 안전(docs/extension/upgrade-step-guide.md §13): 파일 시스템과 코어 경로 해석기만
* 사용하고 Service / Manager / Repository 를 해석하지 않습니다.
*/
final class RetagThemeAsStrictlyNecessary implements DataMigration
{
/**
* 정정 대상 문구 (1.0.4 시점 동결) — 카테고리 키 => [로케일 => [구 문구, 새 문구]].
*
* @var array<string, array<string, array{0: string, 1: string}>>
*/
private const REPLACEMENTS = [
'necessary' => [
'ko' => [
'세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 가입 시 선택한 언어 설정, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.',
'세션·CSRF·로그인 토큰, 장바구니 식별자, 사용자가 직접 고른 언어 설정과 화면 테마, 쿠키 동의 기록 등 사이트 운영에 반드시 필요한 항목입니다. 비활성화할 수 없습니다.',
],
'en' => [
'Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference at registration, cookie consent record. Cannot be disabled.',
'Strictly necessary for site operation: session/CSRF/auth tokens, shopping basket identifier, user-selected language preference and display theme, cookie consent record. Cannot be disabled.',
],
],
'functional' => [
'ko' => [
'사용자 선호도(다크모드, 표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.',
'사용자 선호도(표시 통화 등)를 기억하는 쿠키입니다. 거부 시 매 방문마다 기본값으로 표시됩니다.',
],
'en' => [
'Cookies that remember user preferences such as dark mode and display currency. If declined, defaults are used on every visit.',
'Cookies that remember user preferences such as display currency. If declined, defaults are used on every visit.',
],
],
];
/**
* 마이그레이션 식별자 (로그용).
*
* @return string 사람이 읽을 수 있는 짧은 식별자
*/
public function name(): string
{
return 'RetagThemeAsStrictlyNecessary';
}
/**
* 저장된 쿠키 카테고리 설명 문구를 정정합니다. idempotent.
*
* @param UpgradeContext $context 업그레이드 컨텍스트 (로거 등)
*/
public function run(UpgradeContext $context): void
{
// 절대 경로는 코어 해석기가 디스크 root 를 기준으로 조립한다 — 확장마다 직접 조립하면
// 테스트 환경에서 운영 설정 파일을 그대로 건드리게 된다.
$path = ExtensionStoragePath::plugin('sirsoft-gdpr', 'settings').'/setting.json';
if (! File::exists($path)) {
$context->logger->info('[sirsoft-gdpr] 설정 파일 없음 — 설치 시 기본값이 시드하므로 skip');
return;
}
$settings = json_decode(File::get($path), true);
if (! is_array($settings) || ! isset($settings['cookie_categories'])) {
$context->logger->info('[sirsoft-gdpr] 저장된 쿠키 카테고리 없음 — 문구 정정 skip');
return;
}
// cookie_categories 는 settings 컬럼이 string 이라 json_encode 된 형태로 저장된다.
$raw = $settings['cookie_categories'];
$wasEncoded = is_string($raw);
$categories = $wasEncoded ? json_decode($raw, true) : $raw;
if (! is_array($categories)) {
$context->logger->warning('[sirsoft-gdpr] 쿠키 카테고리 JSON 형식 비정상 — 문구 정정 skip');
return;
}
$changed = [];
foreach ($categories as $index => $category) {
if (! is_array($category) || ! isset($category['key'])) {
continue;
}
$rules = self::REPLACEMENTS[$category['key']] ?? null;
// 설명이 없는 항목에는 넣지 않는다 — 없던 문단을 새로 만들지 않기 위함.
if ($rules === null || ! isset($category['description']) || ! is_array($category['description'])) {
continue;
}
foreach ($rules as $locale => [$before, $after]) {
if (($category['description'][$locale] ?? null) === $before) {
$categories[$index]['description'][$locale] = $after;
$changed[] = "{$category['key']}.{$locale}";
}
}
}
if ($changed === []) {
$context->logger->info('[sirsoft-gdpr] 정정 대상 문구 없음 — 이미 최신이거나 운영자가 수정함');
return;
}
$settings['cookie_categories'] = $wasEncoded
? json_encode($categories, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
: $categories;
File::put($path, json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$context->logger->info('[sirsoft-gdpr] 쿠키 카테고리 안내 문구 정정: '.implode(', ', $changed));
}
}
+11
View File
@@ -2323,6 +2323,17 @@ hr {
box-shadow: var(--shadow-md);
}
/*
* 결과/안내 섹션으로 스크롤할 때 sticky 헤더에 가려지지 않도록 여백을 확보한다.
* 헤더 실높이는 데스크톱 70px / 모바일 60px (+ 하단 테두리 1px) 이므로 여유를 포함해 90px.
* 전역 `html { scroll-behavior: smooth }` 는 두지 않는다 — 로그 영역 등 내부 스크롤에
* 부작용을 준다. 스크롤 동작은 JS(scrollResultIntoView)가 개별 호출로 지정한다.
*/
.result-section,
#env-setup-section {
scroll-margin-top: 90px;
}
/* 성공 배경 */
.result-section:has(.result-icon-success) {
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
+37
View File
@@ -3842,6 +3842,31 @@
}
/**
* 결과/안내 섹션을 뷰포트로 부드럽게 스크롤합니다.
*
* 완료/실패/중단 안내는 페이지 최상단에 있는데, 설치 진행 중에는 사용자가 로그를 보느라
* 화면이 하단에 머물러 있는 경우가 많다. 그대로 두면 안내가 표시되어도 눈에 들어오지
* 않는다. 이미 최상단이면 스크롤은 no-op 이라 새로고침 복원 경로에서도 무해하다.
*
* 헤더 바가 sticky 이므로 섹션 상단이 가려지지 않도록 CSS 의 scroll-margin-top 이
* 여백을 확보한다 (installer.css 의 .result-section, #env-setup-section).
*
* @param {HTMLElement|null} sectionEl 스크롤 대상 섹션
*/
function scrollResultIntoView(sectionEl) {
if (!sectionEl) return;
// 모션 최소화를 선호하는 사용자는 즉시 이동
const reduceMotion = window.matchMedia
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
sectionEl.scrollIntoView({
behavior: reduceMotion ? 'auto' : 'smooth',
block: 'start',
});
}
/**
* 완료 섹션 표시
*/
@@ -3924,6 +3949,9 @@
} catch (_) {
// fetch 자체가 실패해도 무시 — runtime.php 가 보존되어 다음 부팅 시 앱 정상 동작
}
// 완료 안내로 스크롤 (타이틀 숨김·카드 접기로 문서 높이가 바뀐 뒤에 호출)
scrollResultIntoView(completionSection);
}
/**
@@ -4066,6 +4094,9 @@
failureSection.style.opacity = '1';
}, 100);
}
// 실패 안내로 스크롤 (카드 body 를 펼친 뒤에 호출)
scrollResultIntoView(failureSection);
}
/**
@@ -4399,6 +4430,9 @@
setTaskStatus(nextTask.id, 'aborted', nextTask.target || null);
}
}
// 중단 안내로 스크롤 (로그 렌더링으로 문서 높이가 늘어난 뒤에 호출)
scrollResultIntoView(abortedSection);
}
/**
@@ -5213,6 +5247,9 @@
if (statusEl) {
statusEl.classList.add('hidden');
}
// 안내 섹션으로 스크롤 (목록·명령어를 채운 뒤에 호출)
scrollResultIntoView(section);
}
/**
@@ -121,8 +121,8 @@ admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 0개 | — |
| Vitest | 203개 | `vitest.config.ts` |
| Playwright | 8개 | `tests/Playwright` |
| Vitest | 205개 | `vitest.config.ts` |
| Playwright | 9개 | `tests/Playwright` |
| 시나리오 매니페스트 | 2개 | `tests/scenarios` |
```bash
@@ -28,6 +28,10 @@
- 대시보드 알림 중 일부 경고가 일반 안내와 같은 회색으로 표시되어 경고로 보이지 않던 문제를 수정했습니다. 이제 알림의 심각도에 따라 색이 정해집니다.
- 경고 등급 알림을 대시보드 맨 아래 「시스템 알림」 칸이 아니라 **화면 상단**에 표시합니다. 종전에는 스크롤을 끝까지 내려야 보여, 화면이 정상으로 보이는 상황에서는 조치가 필요하다는 사실을 지나치기 쉬웠습니다. 경고가 아닌 안내는 종전대로 하단에 남으며, 같은 알림이 두 곳에 중복으로 뜨지 않습니다.
- 알림이 여러 건일 때 서로 맞붙어 표시되던 문제를 수정했습니다. 이제 간격을 두고 차례로 쌓입니다.
- 로그인·비밀번호 찾기·비밀번호 재설정 화면의 테마(밝게/어둡게/시스템 설정) 버튼이 눌러도 아무 반응이 없던 문제를 수정했습니다. 로그인 이후 화면은 정상이었기 때문에 이 세 화면에서만 나타났습니다. (Modern PHP User Group 박민권 님께서 제보해주셨습니다.)
- 레이아웃 편집기의 「액션 추가」로 만든 일부 동작이 만들자마자 아무 일도 하지 않던 문제를 수정했습니다. 화면 테마 바꾸기·화면 언어 바꾸기·화면 테마 초기화·특정 영역으로 스크롤·기간 빠르게 선택·필터 보이기/숨기기·필터 표시 초기화·다국어 태그 저장·화면 상태 바꾸기 아홉 가지가 대상이며, 편집기가 만들어 주는 값의 형태가 실제 동작이 읽는 형태와 달라 오류 표시도 없이 무시되고 있었습니다.
- 필터 보이기/숨기기와 필터 표시 초기화 동작에 저장 키를 입력할 자리가 없어, 편집기로 만들면 필터 상태가 저장도 복원도 되지 않던 문제를 수정했습니다.
- 사이드바 하위 메뉴의 펼치기/접기 버튼에 번역되지 않은 원문(`common.expand`)이 그대로 붙어 있던 문제를 수정했습니다. 화면에는 보이지 않지만 화면 낭독기 사용자에게 그대로 읽혔습니다.
## [1.0.7] - 2026-08-24
@@ -0,0 +1,94 @@
/**
* @file action-recipes-contract.test.ts
* @description 레이아웃 편집기 액션 레시피(actionRecipes.json) ↔ 실제 핸들러 계약 일치 회귀 테스트
*
* 배경: 편집기의 「액션 추가」 팔레트는 이 레시피의 `build` 를 그대로 레이아웃 JSON 으로 굽는다.
* 그래서 레시피가 핸들러 계약과 어긋나 있으면, 운영자가 편집기로 만든 액션이 **생성 즉시 no-op** 이
* 된다. 예외도 오류도 남지 않고 버튼만 반응하지 않으므로 만든 사람이 알아챌 방법이 없다.
*
* 아래 8종은 실제로 어긋나 있던 항목이다 (dev-g7#640 부수의무 전수 조사).
*
* @vitest-environment jsdom
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const recipes = JSON.parse(
fs.readFileSync(
path.resolve(__dirname, '../../editor-spec/actionRecipes.json'),
'utf8',
),
);
/** 레시피의 입력 필드 선언에서 key 목록을 뽑는다. */
function paramKeys(id: string): string[] {
return (recipes[id]?.params ?? []).map((p: any) => p.key);
}
describe('actionRecipes.json — 핸들러 계약 일치', () => {
describe('target 형 액션 (params 가 아니라 top-level target)', () => {
// setLocale 은 엔진 빌트인(ActionDispatcher), setTheme/initTheme 은 템플릿 핸들러.
// 셋 다 action.target 만 읽고 params 는 보지 않는다.
it.each(['setLocale', 'setTheme', 'initTheme'])('%s 는 top-level target 으로 굽는다', (id) => {
const build = recipes[id]?.build;
expect(build).toBeDefined();
expect(build.target).toBe('{{target}}');
expect(build.params).toBeUndefined();
expect(paramKeys(id)).toContain('target');
});
});
it('scrollToSection 은 params.targetId 로 굽는다 (sectionId 아님)', () => {
const build = recipes.scrollToSection?.build;
expect(build.params).toEqual({ targetId: '{{targetId}}' });
expect(build.params.sectionId).toBeUndefined();
expect(paramKeys('scrollToSection')).toEqual(['targetId']);
});
it('setDateRange 의 preset 선택지는 핸들러 유효값 안에 있다', () => {
// setDateRangeHandler 의 DatePreset 타입
const validPresets = ['today', 'week', 'month', '3months', '6months', '1year'];
const options = recipes.setDateRange?.params?.[0]?.options ?? [];
const values = options.map((o: any) => o.value);
expect(values.length).toBeGreaterThan(0);
for (const v of values) expect(validPresets).toContain(v);
// 'year' 는 핸들러가 모르는 값 — switch 어느 분기에도 걸리지 않는다
expect(values).not.toContain('year');
expect(values).toContain('1year');
});
it('toggleFilterVisibility 는 storageKey + filterId 를 모두 넘긴다', () => {
const build = recipes.toggleFilterVisibility?.build;
expect(build.params).toEqual({
storageKey: '{{storageKey}}',
filterId: '{{filterId}}',
});
expect(build.params.filterKey).toBeUndefined();
expect(paramKeys('toggleFilterVisibility').sort()).toEqual(['filterId', 'storageKey']);
});
it('initFilterVisibility 는 storageKey 를 넘긴다 (없으면 핸들러가 조기 반환)', () => {
const build = recipes.initFilterVisibility?.build;
expect(build.params).toEqual({ storageKey: '{{storageKey}}' });
expect(paramKeys('initFilterVisibility')).toEqual(['storageKey']);
});
it('saveMultilingualTag 은 인자를 받지 않는다 (핸들러가 전역 편집 상태만 읽음)', () => {
const recipe = recipes.saveMultilingualTag;
expect(recipe.build.params).toBeUndefined();
expect(recipe.params).toEqual([]);
});
it('changeState 는 setState 의 상태 범위를 params.target 으로 넘긴다', () => {
// handleSetState 는 resolvedParams 에서 target 을 읽는다 (루트 action.target 은 무시).
// 루트에 두면 기본값 'component' 로 떨어져, 나중에 global 을 고를 수 있게 되는 순간
// 전역 대신 _local 에 조용히 기록된다.
const build = recipes.changeState?.build;
expect(build.handler).toBe('setState');
expect(build.target).toBeUndefined();
expect(build.params?.target).toBe('local');
});
});
@@ -0,0 +1,83 @@
/**
* @file admin-auth-settheme-target.test.tsx
* @description 비인증 화면(로그인·비밀번호찾기·비밀번호재설정) 테마 버튼의 setTheme 액션 형태 회귀 테스트
*
* 배경: 세 화면의 테마 버튼(밝게/어둡게/자동)이 `"params": { "theme": "dark" }` 형태로
* `setTheme` 을 호출했으나, 템플릿 핸들러(`src/handlers/setThemeHandler.ts`)는 **`action.target`
* 만** 읽는다. 엔진(ActionDispatcher)에도 `target` ↔ `params` 상호 폴백이 없으므로 클릭 시
* `[Handler:SetTheme] Invalid theme: undefined` 경고만 남기고 아무 일도 일어나지 않았다.
* 로그인 이후 화면은 핸들러를 경유하지 않는 `ThemeToggle` 컴포지트를 쓰므로 정상이었고,
* 그래서 이 결함은 미인증 3화면에서만 나타났다.
*
* 조치: 같은 디렉토리의 `admin-auth-setlocale-target.test.tsx` 가 잠근 setLocale 선례와 동형으로,
* 세 화면의 액션을 `params.theme` → top-level `target` 으로 옮겼다.
*
* 이 테스트를 `params.theme` 로 되돌리면 세 화면의 테마 전환이 조용히 죽는다.
*
* @vitest-environment jsdom
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const baseDir = path.resolve(__dirname, '../..');
function loadJson(relPath: string): any {
return JSON.parse(fs.readFileSync(path.resolve(baseDir, relPath), 'utf8'));
}
/** 레이아웃 전체에서 handler === 'setTheme' 인 액션을 모두 수집한다. */
function collectSetThemeActions(node: any, acc: any[] = []): any[] {
if (!node || typeof node !== 'object') return acc;
if (Array.isArray(node)) {
for (const n of node) collectSetThemeActions(n, acc);
return acc;
}
for (const action of node.actions ?? []) {
if (action?.handler === 'setTheme') acc.push(action);
}
for (const k of ['children', 'components']) {
if (node[k]) collectSetThemeActions(node[k], acc);
}
return acc;
}
const VALID_THEMES = ['light', 'dark', 'auto'];
const layouts: Array<[string, string]> = [
['admin_login', 'layouts/admin_login.json'],
['admin_forgot_password', 'layouts/admin_forgot_password.json'],
['admin_reset_password', 'layouts/admin_reset_password.json'],
];
describe('비인증 화면 테마 버튼 — setThemeHandler 규약', () => {
it.each(layouts)('%s 의 setTheme 3건은 target 으로 테마를 넘긴다', (_name, relPath) => {
const layout = loadJson(relPath);
const actions = collectSetThemeActions(layout.components ?? layout);
// 밝게 / 어둡게 / 자동 세 버튼
expect(actions.length).toBe(3);
for (const action of actions) {
expect(action.type).toBe('click');
expect(VALID_THEMES).toContain(action.target);
// 핸들러는 params 를 읽지 않는다. 남아 있으면 무시되어 테마 전환이 죽는다.
expect(action.params).toBeUndefined();
}
// 세 버튼이 서로 다른 테마를 지정한다
expect([...actions.map((a) => a.target)].sort()).toEqual(['auto', 'dark', 'light']);
});
it('세 화면 합계 9건이 모두 target 형식이다', () => {
const all = layouts.flatMap(([, relPath]) => {
const layout = loadJson(relPath);
return collectSetThemeActions(layout.components ?? layout);
});
expect(all.length).toBe(9);
expect(all.every((a) => VALID_THEMES.includes(a.target))).toBe(true);
expect(all.every((a) => a.params === undefined)).toBe(true);
});
});
@@ -43,46 +43,63 @@
다크/라이트/자동(`auto`, 시스템 설정 따름) 테마를 전환·복원합니다. `setTheme` 은 localStorage
저장 + `document.documentElement` 클래스 적용(Tailwind `dark:` variant 활성화)을,
`initTheme` 은 params 없이 `init_actions` 에서 호출해 저장된 테마를 앱 시작 시 복원합니다.
`initTheme` 은 `init_actions` 에서 호출해 저장된 테마를 앱 시작 시 복원합니다.
두 핸들러 모두 테마 값을 액션 **top-level `target`** 으로 받습니다. `params.theme` 으로 넘기면
엔진에 상호 폴백이 없어 조용히 no-op 이 됩니다 — 콘솔 경고 한 줄 외에는 아무 흔적이 없습니다.
```json
{ "type": "click", "handler": "setTheme", "params": { "theme": "{{_global.theme === 'dark' ? 'light' : 'dark'}}" } }
{ "type": "click", "handler": "setTheme", "target": "{{_global.theme === 'dark' ? 'light' : 'dark'}}" }
```
`initTheme` 의 `target` 은 선택입니다 — 유효한 테마 값이면 그것을 적용하고, 없거나 유효하지
않으면 localStorage 저장값(없으면 `auto`)으로 복원합니다.
### scrollToSection
`params.selector`(CSS 선택자, 필수) 로 지정한 요소로 부드럽게 스크롤합니다. `params.offset`
(기본 `0`, 음수면 위로)은 고정 헤더 높이를 보상할 때 씁니다.
`params.targetId`(엘리먼트 **ID**, 필수) 로 지정한 요소로 부드럽게 스크롤합니다 — CSS 선택자가
아니라 `getElementById` 대상이므로 `#` 이나 클래스 선택자를 넣지 않습니다. `params.offset`
(기본 `120`)은 고정 헤더 높이를 보상하는 여백이고, `params.delay`(기본 `100`)는 조건부 렌더링
요소를 기다리는 재시도 간격, `params.scrollContainerId` 는 스크롤 컨테이너를 명시할 때 씁니다.
```json
{ "type": "click", "handler": "scrollToSection", "params": { "selector": "#features", "offset": -80 } }
{ "type": "click", "handler": "scrollToSection", "params": { "targetId": "features", "offset": 80 } }
```
### initMenuFromUrl
현재 URL 경로를 사이드바 메뉴 항목과 매칭해 활성 메뉴(및 부모 메뉴의 펼침 상태)를 자동
설정합니다. params 없이 `_admin_base.json` 의 `init_actions` 에서 호출합니다.
URL **쿼리스트링**(`?menu=<slug>&mode=<모드>`)을 읽어 메뉴 관리 화면의 선택 메뉴와 편집 모드를
초기화합니다. `window.location.pathname` 을 사이드바 메뉴와 매칭하는 핸들러가 아닙니다 —
메뉴 관리 화면에 URL 로 직접 들어왔을 때 해당 메뉴를 선택 상태로 여는 용도입니다.
params 없이 그 화면의 `init_actions` 에서 호출합니다.
### 필터 가시성 핸들러 4종
목록 화면 필터 패널의 표시/숨김을 localStorage 에 저장해 새로고침 후에도 유지합니다.
`storageKey` 는 네 핸들러 모두 **필수**입니다 — 빠지면 경고 한 줄을 남기고 조기 반환하므로
필터 상태가 복원도 저장도 되지 않습니다. localStorage 키는 `g7_filter_visibility_{storageKey}`
이고, 복원 대상 로컬 상태 경로는 `params.stateKey`(기본 `visibleFilters`)입니다.
| 핸들러 | params | 설명 |
|---|---|---|
| `initFilterVisibility` | 없음 | localStorage → `_local` 복원 (`init_actions`에서 호출) |
| `saveFilterVisibility` | `{ filters }` | `_local` → localStorage 저장 |
| `toggleFilterVisibility` | `{ key }` | 특정 필터 키 가시성 토글 |
| `resetFilterVisibility` | 없음 | 전체 초기화 |
| `initFilterVisibility` | `{ storageKey, defaultFilters?, stateKey? }` | localStorage → `_local` 복원 (`init_actions`에서 호출) |
| `saveFilterVisibility` | `{ storageKey, filters }` | `_local` → localStorage 저장 |
| `toggleFilterVisibility` | `{ storageKey, filterId, stateKey? }` | 특정 필터 가시성 토글 + 즉시 저장 |
| `resetFilterVisibility` | `{ storageKey, defaultFilters?, stateKey? }` | 기본값으로 초기화 |
### 다국어 태그 핸들러 3종
`MultilingualInput` 컴포넌트가 쓰는 태그 편집 핸들러입니다.
편집 중인 값은 전역 상태 `_global.multilingualTagEdit` 에 있습니다 — 저장·취소 핸들러는 그
상태만 읽으므로 액션 인자를 받지 않습니다.
| 핸들러 | params | 설명 |
|---|---|---|
| `saveMultilingualTag` | `{ field, locale }` | 태그 저장 |
| `saveMultilingualTag` | 없음 | `_global.multilingualTagEdit` 을 부모 태그 배열에 반영 |
| `cancelMultilingualTag` | 없음 | 편집 취소 |
| `updateMultilingualTagValue` | `{ field, locale, value }` | 값 업데이트 |
| `updateMultilingualTagValue` | `{ locale }` | 그 로케일 값 갱신 (값은 `context.event` 에서 읽음) |
### setDateRange
@@ -105,7 +122,7 @@ JSON 이 `sequence` + `setState` 로 반환값(`$prev.startDate` 등)을 원하
| 핸들러 | params | 설명 |
|---|---|---|
| `initSidebar` | 없음 | 저장된 접힘 상태 복원 (`init_actions`에서 호출) |
| `initSidebar` | 없음 | 저장된 접힘 상태 복원 (레이아웃 `init_actions` 가 아니라 `src/index.ts` 부트스트랩이 1회 호출) |
| `toggleSidebar` | 없음 | 접힘 상태 반전 + 저장 |
### downloadAttachment
@@ -168,28 +185,29 @@ ApiClient 경유로 토큰을 자동 첨부해야 다운로드 행위가 관리
### setLocale
> **정정(#601)**: `setLocale` 은 더 이상 이 템플릿이 등록하는 핸들러가 아닙니다 — 엔진(ActionDispatcher)
> 빌트인으로 승격되어 모든 템플릿에서 동작합니다. 아래 서술은 이관 시점 기록이며, 동작·파라미터는
> 같지만 **소유 주체가 템플릿이 아니라 엔진**입니다.
> 빌트인으로 승격되어 모든 템플릿에서 동작합니다. 아래 서술은 이관 시점 기록이며, **소유 주체가
> 템플릿이 아니라 엔진**입니다.
>
> **정정(#640)**: 엔진 빌트인은 로케일을 액션 **top-level `target`** 으로만 읽습니다.
> `params.locale` 로 넘기면 무시되어 언어 전환이 조용히 죽습니다.
앱 언어를 변경합니다. 번역 파일을 다시 로드하고 UI를 갱신합니다.
**소스**: `src/handlers/setLocaleHandler.ts`
**소스**: 엔진 빌트인 (`resources/js/core/template-engine/ActionDispatcher.ts`) — 이 템플릿에는 소스 파일이 없습니다.
```json
{
"type": "click",
"handler": "setLocale",
"params": {
"locale": "en"
}
"target": "en"
}
```
#### params
#### 파라미터
| 필드 | 타입 | 필수 | 설명 |
| 위치 | 타입 | 필수 | 설명 |
|------|------|------|------|
| `locale` | string | ✅ | 변경할 로케일 코드 (예: `"ko"`, `"en"`, `"ja"`) |
| `target` | string | ✅ | 변경할 로케일 코드 (예: `"ko"`, `"en"`, `"ja"`) |
#### 동작
@@ -213,9 +231,7 @@ ApiClient 경유로 토큰을 자동 첨부해야 다운로드 행위가 관리
{
"type": "click",
"handler": "setLocale",
"params": {
"locale": "en"
}
"target": "en"
}
]
}
@@ -235,17 +251,18 @@ ApiClient 경유로 토큰을 자동 첨부해야 다운로드 행위가 관리
{
"type": "click",
"handler": "setTheme",
"params": {
"theme": "dark"
}
"target": "dark"
}
```
#### setTheme params
#### setTheme 파라미터
| 필드 | 타입 | 필수 | 설명 |
| 위치 | 타입 | 필수 | 설명 |
|------|------|------|------|
| `theme` | string | ✅ | `"light"`, `"dark"`, `"auto"` (시스템 설정 따름) |
| `target` | string | ✅ | `"light"`, `"dark"`, `"auto"` (시스템 설정 따름) |
핸들러는 `action.target` 만 읽습니다. `params.theme` 으로 넘기면 콘솔에 `Invalid theme:
undefined` 경고만 남기고 아무 것도 하지 않습니다 (dev-g7#640).
#### 동작
@@ -269,7 +286,16 @@ ApiClient 경유로 토큰을 자동 첨부해야 다운로드 행위가 관리
}
```
params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원합니다.
`target` 은 선택입니다. 유효한 테마 값(`light`/`dark`/`auto`)이면 그 값을 적용하고, 없거나
유효하지 않으면 localStorage 저장값(없으면 `auto`)으로 복원합니다.
```json
{
"init_actions": [
{ "handler": "initTheme", "target": "{{query.theme}}" }
]
}
```
#### 사용 예시
@@ -282,9 +308,7 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
{
"type": "click",
"handler": "setTheme",
"params": {
"theme": "{{_global.theme === 'dark' ? 'light' : 'dark'}}"
}
"target": "{{_global.theme === 'dark' ? 'light' : 'dark'}}"
}
]
}
@@ -294,7 +318,7 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
### scrollToSection
특정 섹션으로 스크롤합니다. 오프셋 지원에 특화되어 있습니다.
특정 섹션으로 스크롤합니다. 고정 헤더 보상 오프셋과 조건부 렌더링 대기에 특화되어 있습니다.
**소스**: `src/handlers/scrollToSectionHandler.ts`
@@ -303,8 +327,8 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
"type": "click",
"handler": "scrollToSection",
"params": {
"selector": "#features",
"offset": -80
"targetId": "features",
"offset": 80
}
}
```
@@ -313,15 +337,18 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|------|------|------|--------|------|
| `selector` | string | ✅ | - | CSS 선택자 (예: `"#section-id"`, `".class-name"`) |
| `offset` | number | ❌ | `0` | 스크롤 오프셋 (음수: 위로, 양수: 아래로). 고정 헤더 높이 보상에 사용 |
| `targetId` | string | ✅ | - | 대상 엘리먼트의 **ID** (`getElementById` 대상 — `#` 없이, CSS 선택자 아님) |
| `offset` | number | ❌ | `120` | 고정 헤더 높이 보상 여백 |
| `delay` | number | ❌ | `100` | 요소가 아직 렌더되지 않았을 때의 재시도 간격(ms) |
| `scrollContainerId` | string | ❌ | - | 스크롤 컨테이너를 명시할 때 (미지정 시 자동 탐색 → window) |
#### 동작
```text
1. document.querySelector(selector)로 대상 요소 검색
2. 요소의 위치 계산 + offset 적용
3. window.scrollTo({ top, behavior: 'smooth' })로 부드러운 스크롤
1. document.getElementById(targetId)로 대상 요소 검색 (미발견 시 delay 간격으로 재시도)
2. 스크롤 컨테이너 결정 (scrollContainerId → 자동 탐색 → window)
3. 요소의 위치 계산 + offset 보상
4. 부드러운 스크롤 실행
```
#### 사용 예시
@@ -340,8 +367,8 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
"type": "click",
"handler": "scrollToSection",
"params": {
"selector": "#features",
"offset": -80
"targetId": "features",
"offset": 80
}
}
]
@@ -352,7 +379,7 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
### initMenuFromUrl
현재 URL을 기반으로 사이드바/네비게이션 메뉴의 활성 상태를 초기화합니다. 주로 관리자 템플릿의 `init_actions`에서 사용합니다.
URL 쿼리스트링(`?menu=<slug>&mode=<모드>`)을 읽어 메뉴 관리 화면의 선택 메뉴와 편집 모드를 초기화합니다. 그 화면의 `init_actions`에서 사용합니다.
**소스**: `src/handlers/initMenuFromUrlHandler.ts`
@@ -368,23 +395,23 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
#### params
없음. 현재 URL 경로를 메뉴 항목과 매칭하여 활성 메뉴를 자동 설정합니다.
없음. 읽는 값은 액션 인자가 아니라 URL 쿼리 파라미터입니다.
#### 동작
```text
1. 현재 URL 경로 (window.location.pathname) 추출
2. 사이드바 메뉴 데이터에서 URL 매칭
3. 매칭된 메뉴 항목의 is_active 상태 설정
4. 부모 메뉴도 자동으로 펼침 상태 설정
1. URLSearchParams 로 ?menu= (메뉴 slug) 와 ?mode= 추출
2. 메뉴 데이터 소스에서 slug 로 해당 메뉴 검색 (자식 메뉴까지 재귀)
3. 찾은 메뉴를 선택 상태로, mode 를 편집 모드로 설정
```
#### 사용 예시 (_admin_base.json)
`window.location.pathname` 을 사이드바 메뉴와 매칭하는 핸들러가 아닙니다.
#### 사용 예시 (메뉴 관리 화면)
```json
{
"init_actions": [
{ "handler": "initTheme" },
{ "handler": "initMenuFromUrl" }
]
}
@@ -400,13 +427,18 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
#### initFilterVisibility
저장된 필터 가시성 상태를 `_local`에 복원합니다.
저장된 필터 가시성 상태를 `_local`에 복원합니다. `storageKey` 가 없으면 경고 후 조기 반환합니다.
```json
{
"init_actions": [
{
"handler": "initFilterVisibility"
"handler": "initFilterVisibility",
"params": {
"storageKey": "product_index_filters",
"defaultFilters": ["category", "date"],
"stateKey": "visibleFilters"
}
}
]
}
@@ -420,51 +452,65 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
{
"handler": "saveFilterVisibility",
"params": {
"filters": "{{_local.filterVisibility}}"
"storageKey": "product_index_filters",
"filters": "{{_local.visibleFilters}}"
}
}
```
#### toggleFilterVisibility
특정 필터 키의 가시성을 토글합니다.
특정 필터의 가시성을 토글하고 즉시 localStorage 에 저장합니다. `storageKey` 와 `filterId` 가
모두 있어야 하며, 하나라도 없으면 경고 후 조기 반환합니다.
```json
{
"type": "click",
"handler": "toggleFilterVisibility",
"params": {
"key": "advancedFilters"
"storageKey": "product_index_filters",
"filterId": "category"
}
}
```
#### resetFilterVisibility
모든 필터 가시성을 초기 상태로 리셋합니다.
모든 필터 가시성을 `defaultFilters` 로 되돌립니다.
```json
{
"type": "click",
"handler": "resetFilterVisibility"
"handler": "resetFilterVisibility",
"params": {
"storageKey": "product_index_filters",
"defaultFilters": ["category", "date"]
}
}
```
#### 핸들러 params 요약
`storageKey` 는 네 핸들러 모두 필수입니다. 실제 localStorage 키는
`g7_filter_visibility_{storageKey}` 이고, 복원 대상 로컬 상태 경로는 `stateKey`(기본
`visibleFilters`)입니다.
| 핸들러 | params | 설명 |
|--------|--------|------|
| `initFilterVisibility` | 없음 | localStorage → `_local` 복원 |
| `saveFilterVisibility` | `{ filters }` | `_local` → localStorage 저장 |
| `toggleFilterVisibility` | `{ key }` | 특정 키 토글 |
| `resetFilterVisibility` | 없음 | 전체 초기화 |
| `initFilterVisibility` | `{ storageKey, defaultFilters?, stateKey? }` | localStorage → `_local` 복원 |
| `saveFilterVisibility` | `{ storageKey, filters }` | `_local` → localStorage 저장 |
| `toggleFilterVisibility` | `{ storageKey, filterId, stateKey? }` | 특정 필터 토글 + 즉시 저장 |
| `resetFilterVisibility` | `{ storageKey, defaultFilters?, stateKey? }` | 기본값으로 초기화 |
#### 사용 예시 (목록 페이지)
```json
{
"init_actions": [
{ "handler": "initFilterVisibility" }
{
"handler": "initFilterVisibility",
"params": { "storageKey": "product_index_filters", "defaultFilters": ["advancedFilters"] }
}
],
"components": [
{
@@ -478,7 +524,7 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
{
"type": "click",
"handler": "toggleFilterVisibility",
"params": { "key": "advancedFilters" }
"params": { "storageKey": "product_index_filters", "filterId": "advancedFilters" }
}
]
},
@@ -486,7 +532,7 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
"id": "filter_section",
"type": "basic",
"name": "Div",
"if": "{{_local.filterVisibility?.advancedFilters}}",
"if": "{{_local.visibleFilters?.includes('advancedFilters')}}",
"children": [
{ "comment": "필터 컴포넌트들" }
]
@@ -505,15 +551,13 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
#### saveMultilingualTag
다국어 태그를 저장합니다.
편집 중인 다국어 태그를 부모 태그 배열에 반영하고 모달을 닫습니다. 액션 인자를 받지 않으며,
읽는 값은 전역 상태 `_global.multilingualTagEdit`(필드명·편집 인덱스·로케일별 값·상태 경로)
뿐입니다.
```json
{
"handler": "saveMultilingualTag",
"params": {
"field": "tags",
"locale": "{{_global.locale}}"
}
"handler": "saveMultilingualTag"
}
```
@@ -529,15 +573,15 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
#### updateMultilingualTagValue
다국어 태그 값을 업데이트합니다.
편집 중인 다국어 태그의 특정 로케일 값을 갱신합니다. 값은 액션 인자가 아니라
`context.event`(입력 이벤트)에서 읽으므로 `params` 에는 `locale` 만 넘깁니다.
```json
{
"type": "change",
"handler": "updateMultilingualTagValue",
"params": {
"field": "tags",
"locale": "ko",
"value": "{{$event.target.value}}"
"locale": "ko"
}
}
```
@@ -546,9 +590,9 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
| 핸들러 | params | 설명 |
|--------|--------|------|
| `saveMultilingualTag` | `{ field, locale }` | 태그 저장 |
| `saveMultilingualTag` | 없음 | `_global.multilingualTagEdit` 을 부모 태그 배열에 반영 |
| `cancelMultilingualTag` | 없음 | 편집 취소 |
| `updateMultilingualTagValue` | `{ field, locale, value }` | 값 업데이트 |
| `updateMultilingualTagValue` | `{ locale }` | 그 로케일 값 갱신 (값은 `context.event` 에서 읽음) |
---
@@ -556,7 +600,7 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
| 핸들러명 | 소스 파일 | 등록 함수 |
|---------|----------|----------|
| `setLocale` | `src/handlers/setLocaleHandler.ts` | `setLocaleHandler` |
| `setLocale` | 엔진 빌트인 (이 템플릿에 소스 없음) | — |
| `setTheme`, `initTheme` | `src/handlers/setThemeHandler.ts` | `initTheme` |
| `scrollToSection` | `src/handlers/scrollToSectionHandler.ts` | `scrollToSectionHandler` |
| `initMenuFromUrl` | `src/handlers/initMenuFromUrlHandler.ts` | `initMenuFromUrlHandler` |
@@ -68,8 +68,8 @@
],
"build": {
"handler": "setState",
"target": "local",
"params": {
"target": "local",
"{{key}}": "{{value}}"
}
}
@@ -132,9 +132,7 @@
],
"build": {
"handler": "setLocale",
"params": {
"target": "{{target}}"
}
"target": "{{target}}"
}
},
"setTheme": {
@@ -153,24 +151,22 @@
],
"build": {
"handler": "setTheme",
"params": {
"target": "{{target}}"
}
"target": "{{target}}"
}
},
"scrollToSection": {
"label": "$t:editor.action.scroll_to_section.label",
"params": [
{
"key": "sectionId",
"label": "$t:editor.action.scroll_to_section.param_section_id",
"key": "targetId",
"label": "$t:editor.action.scroll_to_section.param_target_id",
"widget": "text"
}
],
"build": {
"handler": "scrollToSection",
"params": {
"sectionId": "{{sectionId}}"
"targetId": "{{targetId}}"
}
}
},
@@ -185,7 +181,7 @@
{ "value": "today", "label": "$t:editor.action.set_date_range.preset_today" },
{ "value": "week", "label": "$t:editor.action.set_date_range.preset_week" },
{ "value": "month", "label": "$t:editor.action.set_date_range.preset_month" },
{ "value": "year", "label": "$t:editor.action.set_date_range.preset_year" }
{ "value": "1year", "label": "$t:editor.action.set_date_range.preset_1year" }
]
}
],
@@ -200,32 +196,29 @@
"label": "$t:editor.action.toggle_filter_visibility.label",
"params": [
{
"key": "filterKey",
"label": "$t:editor.action.toggle_filter_visibility.param_filter_key",
"key": "storageKey",
"label": "$t:editor.action.toggle_filter_visibility.param_storage_key",
"widget": "text"
},
{
"key": "filterId",
"label": "$t:editor.action.toggle_filter_visibility.param_filter_id",
"widget": "text"
}
],
"build": {
"handler": "toggleFilterVisibility",
"params": {
"filterKey": "{{filterKey}}"
"storageKey": "{{storageKey}}",
"filterId": "{{filterId}}"
}
}
},
"saveMultilingualTag": {
"label": "$t:editor.action.save_multilingual_tag.label",
"params": [
{
"key": "tag",
"label": "$t:editor.action.save_multilingual_tag.param_tag",
"widget": "i18n-text"
}
],
"params": [],
"build": {
"handler": "saveMultilingualTag",
"params": {
"tag": "{{tag}}"
}
"handler": "saveMultilingualTag"
}
},
"initTheme": {
@@ -244,9 +237,7 @@
],
"build": {
"handler": "initTheme",
"params": {
"target": "{{target}}"
}
"target": "{{target}}"
}
},
"initMenuFromUrl": {
@@ -258,9 +249,18 @@
},
"initFilterVisibility": {
"label": "$t:editor.action.init_filter_visibility.label",
"params": [],
"params": [
{
"key": "storageKey",
"label": "$t:editor.action.init_filter_visibility.param_storage_key",
"widget": "text"
}
],
"build": {
"handler": "initFilterVisibility"
"handler": "initFilterVisibility",
"params": {
"storageKey": "{{storageKey}}"
}
}
}
}
@@ -49,6 +49,8 @@
"logout": "Logout",
"expand_sidebar": "Expand sidebar",
"collapse_sidebar": "Collapse sidebar",
"expand": "Expand",
"collapse": "Collapse",
"module": "Module",
"plugin": "Plugin",
"status_active": "Active",
@@ -1261,7 +1261,7 @@
},
"scroll_to_section": {
"label": "Scroll to a section",
"param_section_id": "Section ID"
"param_target_id": "Section ID"
},
"set_date_range": {
"label": "Quick date range",
@@ -1269,15 +1269,15 @@
"preset_today": "Today",
"preset_week": "This week",
"preset_month": "This month",
"preset_year": "This year"
"preset_1year": "Last 1 year"
},
"toggle_filter_visibility": {
"label": "Show/hide filter",
"param_filter_key": "Filter key"
"param_storage_key": "Storage key",
"param_filter_id": "Filter ID"
},
"save_multilingual_tag": {
"label": "Save multilingual tag",
"param_tag": "Tag"
"label": "Save multilingual tag"
},
"init_theme": {
"label": "Initialize screen theme",
@@ -1290,7 +1290,8 @@
"label": "Initialize menu from URL"
},
"init_filter_visibility": {
"label": "Initialize filter visibility"
"label": "Initialize filter visibility",
"param_storage_key": "Storage key"
}
},
"computed": {
@@ -49,6 +49,8 @@
"logout": "로그아웃",
"expand_sidebar": "사이드바 펼치기",
"collapse_sidebar": "사이드바 접기",
"expand": "펼치기",
"collapse": "접기",
"module": "모듈",
"plugin": "플러그인",
"status_active": "활성화",
@@ -1261,7 +1261,7 @@
},
"scroll_to_section": {
"label": "특정 영역으로 스크롤",
"param_section_id": "영역 ID"
"param_target_id": "영역 ID"
},
"set_date_range": {
"label": "기간 빠르게 선택",
@@ -1269,15 +1269,15 @@
"preset_today": "오늘",
"preset_week": "이번 주",
"preset_month": "이번 달",
"preset_year": "올해"
"preset_1year": "최근 1년"
},
"toggle_filter_visibility": {
"label": "필터 보이기/숨기기",
"param_filter_key": "필터 키"
"param_storage_key": "저장 키",
"param_filter_id": "필터 ID"
},
"save_multilingual_tag": {
"label": "다국어 태그 저장",
"param_tag": "태그"
"label": "다국어 태그 저장"
},
"init_theme": {
"label": "화면 테마 초기화",
@@ -1290,7 +1290,8 @@
"label": "주소로 메뉴 초기화"
},
"init_filter_visibility": {
"label": "필터 표시 초기화"
"label": "필터 표시 초기화",
"param_storage_key": "저장 키"
}
},
"computed": {
@@ -330,7 +330,7 @@
"props": {
"className": "block w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm",
"value": "{{$locale}}",
"options": "{{$locales}}"
"options": "{{$locales ?? []}}"
},
"actions": [
{
@@ -372,9 +372,7 @@
{
"type": "click",
"handler": "setTheme",
"params": {
"theme": "light"
}
"target": "light"
}
],
"children": [
@@ -402,9 +400,7 @@
{
"type": "click",
"handler": "setTheme",
"params": {
"theme": "dark"
}
"target": "dark"
}
],
"children": [
@@ -432,9 +428,7 @@
{
"type": "click",
"handler": "setTheme",
"params": {
"theme": "auto"
}
"target": "auto"
}
],
"children": [
@@ -374,7 +374,7 @@
"props": {
"className": "block w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm",
"value": "{{$locale}}",
"options": "{{$locales}}"
"options": "{{$locales ?? []}}"
},
"actions": [
{
@@ -415,9 +415,7 @@
{
"type": "click",
"handler": "setTheme",
"params": {
"theme": "light"
}
"target": "light"
}
],
"children": [
@@ -445,9 +443,7 @@
{
"type": "click",
"handler": "setTheme",
"params": {
"theme": "dark"
}
"target": "dark"
}
],
"children": [
@@ -475,9 +471,7 @@
{
"type": "click",
"handler": "setTheme",
"params": {
"theme": "auto"
}
"target": "auto"
}
],
"children": [
@@ -472,7 +472,7 @@
"props": {
"className": "block w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm",
"value": "{{$locale}}",
"options": "{{$locales}}"
"options": "{{$locales ?? []}}"
},
"actions": [
{
@@ -514,9 +514,7 @@
{
"type": "click",
"handler": "setTheme",
"params": {
"theme": "light"
}
"target": "light"
}
],
"children": [
@@ -544,9 +542,7 @@
{
"type": "click",
"handler": "setTheme",
"params": {
"theme": "dark"
}
"target": "dark"
}
],
"children": [
@@ -574,9 +570,7 @@
{
"type": "click",
"handler": "setTheme",
"params": {
"theme": "auto"
}
"target": "auto"
}
],
"children": [
@@ -198,6 +198,25 @@ describe('setThemeHandler', () => {
expect(setAttributeSpy).not.toHaveBeenCalled();
});
// 계약 명문화: 이 핸들러가 읽는 것은 action.target 뿐이다.
// 레이아웃이 params.theme 로 넘기면(과거 admin_login/forgot/reset 3화면이 그랬다)
// 엔진에 상호 폴백이 없어 조용히 no-op 이 된다. 되살아나면 여기가 red 가 된다.
it('params.theme 로만 넘기면 경고 후 아무 것도 하지 않아야 함 (target 단일 계약)', async () => {
const action = {
type: 'click',
handler: 'setTheme',
params: { theme: 'dark' },
};
await setThemeHandler(action);
expect(console.warn).toHaveBeenCalledWith('[Handler:SetTheme]', 'Invalid theme:', undefined);
expect(setItemSpy).not.toHaveBeenCalled();
expect(setAttributeSpy).not.toHaveBeenCalled();
expect(classListAddSpy).not.toHaveBeenCalled();
expect(classListRemoveSpy).not.toHaveBeenCalled();
});
it('localStorage 저장 실패 시 오류를 출력하고 테마를 적용하지 않아야 함', async () => {
// localStorage.setItem이 실패하도록 모의
const setItemError = new Error('Storage quota exceeded');
@@ -0,0 +1,162 @@
/**
* E2E: 미인증 관리자 화면(로그인·비밀번호 찾기·비밀번호 재설정)의 테마 버튼 (dev-g7#640)
*
* @scenario screen=admin_login|admin_forgot_password|admin_reset_password, theme=light|dark|auto
*
* 배경: 세 화면의 테마 버튼이 `setTheme` 을 `params.theme` 로 호출했으나 템플릿 핸들러는
* `action.target` 만 읽는다. 엔진에 상호 폴백이 없어 클릭이 콘솔 경고 한 줄만 남기고
* 아무 것도 하지 않았다 — 예외도 실패한 요청도 없어 화면상 원인이 보이지 않는다.
* 로그인 이후 화면은 핸들러를 경유하지 않는 ThemeToggle 컴포지트를 쓰므로 정상이었다.
*
* 이 spec 은 클릭 한 번으로 `data-theme` 속성 · `dark` 클래스 · `localStorage` 세 값이
* 함께 바뀌는지를 세 화면 × 세 버튼(9변종) 전부에 대해 잰다.
*
* ## 저장 축이 여기 함께 있는 이유 (실측 2026-09-03)
*
* 이 결함을 고치는 과정에서 **두 번째 독립 결함**이 드러났다. sirsoft-gdpr 플러그인의
* 스토리지 인터셉터가 `Storage.prototype.setItem` 을 감싸고 functional 동의 전에는
* strictly-necessary 허용목록 밖 키의 쓰기를 조용히 버리는데, `g7_color_scheme` 이 그
* 목록에서 빠져 있었다. 그래서 테마를 바꿔도 새로고침하면 되돌아갔고, 증상이 미인증
* 화면뿐 아니라 관리자 화면 전체에 나타났다.
*
* 그 키를 언어 설정(`g7_locale`)과 같은 필수 항목으로 재분류해 함께 고쳤으므로, 저장과
* 새로고침 영속까지 이 spec 이 잰다. 두 결함 중 하나만 되돌아가도 여기가 붉어진다.
*
* 규율: `check()` 를 쓰지 않고 `click()` + 단언을 분리한다. `_global.theme` 은 `page.goto`
* 로 초기화되므로 E2E 단언에 넣지 않는다.
*/
import { test, expect } from '@playwright/test';
import type { Page } from '@playwright/test';
/** 테마 버튼의 `title` 은 `$t:admin.theme.*` — config 가 로케일을 ko 로 고정한다. */
const THEME_BUTTON_TITLE = {
light: '라이트 모드',
dark: '다크 모드',
auto: '시스템 설정',
} as const;
type ThemeMode = keyof typeof THEME_BUTTON_TITLE;
const SCREENS: Array<[string, string]> = [
['로그인', '/admin/login'],
['비밀번호 찾기', '/admin/forgot-password'],
// 토큰 없이도 레이아웃은 렌더된다 (init_actions 에 토큰 가드 없음).
['비밀번호 재설정', '/admin/reset-password'],
];
/** 화면 진입 — 테마 세그먼트 컨트롤이 그려질 때까지 기다린다. */
async function gotoScreen(page: Page, path: string): Promise<void> {
await page.goto(path);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await expect(page.locator(`button[title="${THEME_BUTTON_TITLE.dark}"]`)).toBeVisible({
timeout: 20_000,
});
}
/** DOM 표현 2종 + 저장값을 한 번에 채집한다. */
async function readThemeState(page: Page) {
return await page.evaluate(() => ({
dataTheme: document.documentElement.getAttribute('data-theme'),
darkClass: document.documentElement.classList.contains('dark'),
stored: window.localStorage.getItem('g7_color_scheme'),
}));
}
async function clickTheme(page: Page, mode: ThemeMode): Promise<void> {
await page.locator(`button[title="${THEME_BUTTON_TITLE[mode]}"]`).click();
}
test.describe('미인증 관리자 화면 테마 버튼', () => {
for (const [label, path] of SCREENS) {
test(`${label} 화면 — 다크/라이트 전환이 화면과 저장값에 즉시 반영된다`, async ({ page }) => {
await gotoScreen(page, path);
await clickTheme(page, 'dark');
await expect
.poll(async () => (await readThemeState(page)).dataTheme, { timeout: 10_000 })
.toBe('dark');
const afterDark = await readThemeState(page);
expect(afterDark.darkClass).toBe(true);
expect(afterDark.stored).toBe('dark');
await clickTheme(page, 'light');
await expect
.poll(async () => (await readThemeState(page)).dataTheme, { timeout: 10_000 })
.toBe('light');
const afterLight = await readThemeState(page);
expect(afterLight.darkClass).toBe(false);
expect(afterLight.stored).toBe('light');
});
test(`${label} 화면 — 자동(시스템 설정) 버튼이 시스템 모드로 해석된다`, async ({ page }) => {
await gotoScreen(page, path);
// 먼저 dark 를 걸어 두고 auto 로 되돌아오는지 본다 (초기값과 구분).
await clickTheme(page, 'dark');
await expect
.poll(async () => (await readThemeState(page)).dataTheme, { timeout: 10_000 })
.toBe('dark');
await clickTheme(page, 'auto');
// auto 는 prefers-color-scheme 으로 해석된다 — 이 실행 환경은 light 다.
await expect
.poll(async () => (await readThemeState(page)).stored, { timeout: 10_000 })
.toBe('auto');
const state = await readThemeState(page);
expect(state.dataTheme).toBe('light');
expect(state.darkClass).toBe(false);
});
}
// 두 결함(핸들러 계약 · GDPR 허용목록)이 모두 고쳐져야만 통과한다.
// 계약이 되돌아가면 클릭이 no-op 이 되고, 허용목록이 되돌아가면 저장이 버려진다.
test('로그인 화면 — 다크 설정이 새로고침 뒤에도 유지된다 (동의 없는 첫 방문)', async ({ page }) => {
test.setTimeout(90_000);
await gotoScreen(page, '/admin/login');
await clickTheme(page, 'dark');
await expect
.poll(async () => (await readThemeState(page)).stored, { timeout: 10_000 })
.toBe('dark');
await page.reload();
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await expect
.poll(async () => (await readThemeState(page)).dataTheme, { timeout: 20_000 })
.toBe('dark');
const restored = await readThemeState(page);
expect(restored.darkClass).toBe(true);
expect(restored.stored).toBe('dark');
});
// 대조군 — 허용목록이 통째로 열린 것이 아님을 확인한다.
// 이 단언이 없으면 "테마가 저장된다" 가 게이트 무력화로도 성립해 버린다.
test('로그인 화면 — 허용목록 밖 키는 여전히 동의 전 차단된다', async ({ page }) => {
await gotoScreen(page, '/admin/login');
const accepted = await page.evaluate(() => {
window.localStorage.setItem('e2e_non_allowlisted_probe', 'x');
return window.localStorage.getItem('e2e_non_allowlisted_probe') !== null;
});
expect(accepted).toBe(false);
});
test('로그인 화면 — 테마 클릭이 Invalid theme 경고를 남기지 않는다', async ({ page }) => {
const noisy: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'warning' || msg.type() === 'error') noisy.push(msg.text());
});
await gotoScreen(page, '/admin/login');
// 세 버튼 전부 — 어느 하나라도 계약이 어긋나면 그 클릭에서 경고가 난다.
for (const mode of ['dark', 'light', 'auto'] as ThemeMode[]) {
await clickTheme(page, mode);
await page.waitForTimeout(200);
}
expect(noisy.filter((w) => /Invalid theme|Unsupported theme/i.test(w))).toEqual([]);
});
});
+1 -1
View File
@@ -158,7 +158,7 @@ API 까지만 소유하고, 그 API 를 소비해 실제로 그리는 것은 이
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 0개 | — |
| Vitest | 141개 | `vitest.config.ts` |
| Vitest | 142개 | `vitest.config.ts` |
| Playwright | 8개 | `tests/Playwright` |
| 시나리오 매니페스트 | 3개 | `tests/scenarios` |
@@ -20,6 +20,7 @@
- 게시글·페이지 본문을 표시할 때 쓰는 HTML 정화 라이브러리가 구버전에 머물러 있던 문제를 고쳤습니다. 관리자 템플릿과 동일한 최신 버전으로 맞췄습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
- 통화 표시·선호 통화 저장 관련 화면 동작 함수 4종이 실제 호출 규약과 다른 형태로 작성돼 있어, 호출되면 값이 전달되지 않고 상태가 잘못 기록되던 문제를 고쳤습니다.
- 주소 검색을 불러오지 못한 상태에서 주문서의 우편번호·주소를 직접 입력해도 값이 주문에 반영되지 않아 결제 버튼이 계속 눌리지 않던 문제를 고쳤습니다. 이제 직접 입력한 주소로 주문을 끝까지 진행할 수 있습니다.
- 레이아웃 편집기의 「액션 추가」로 만든 「테마 바꾸기」·「테마 초기화」·「화면 상태 바꾸기」 동작이 만들자마자 아무 일도 하지 않던 문제를 고쳤습니다. 편집기가 만들어 주는 값의 형태가 실제 동작이 읽는 형태와 달라 오류 표시도 없이 무시되고 있었습니다.
## [1.1.2] - 2026-08-24
@@ -0,0 +1,42 @@
/**
* @file action-recipes-contract.test.ts
* @description 레이아웃 편집기 액션 레시피(actionRecipes.json) ↔ 실제 핸들러 계약 일치 회귀 테스트
*
* 배경: 편집기의 「액션 추가」 팔레트는 이 레시피의 `build` 를 그대로 레이아웃 JSON 으로 굽는다.
* `setTheme` 레시피가 `params.target` 으로 굽고 있었으나 테마 핸들러는 `action.target` 만 읽으므로,
* 편집기로 만든 테마 버튼은 생성 즉시 no-op 이었다 (오류·경고 없음).
*
* @vitest-environment jsdom
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const recipes = JSON.parse(
fs.readFileSync(
path.resolve(__dirname, '../../editor-spec/actionRecipes.json'),
'utf8',
),
);
describe('actionRecipes.json — 핸들러 계약 일치', () => {
// 두 핸들러 모두 action.target 만 읽는다 (src/handlers/setThemeHandler.ts).
it.each(['setTheme', 'initTheme'])('%s 는 top-level target 으로 굽는다', (id) => {
const build = recipes[id]?.build;
expect(build).toBeDefined();
expect(build.target).toBe('{{target}}');
expect(build.params).toBeUndefined();
expect((recipes[id].params ?? []).map((p: any) => p.key)).toContain('target');
});
it('changeState 는 setState 의 상태 범위를 params.target 으로 넘긴다', () => {
// handleSetState 는 resolvedParams 에서 target 을 읽는다 (루트 action.target 은 무시).
// 루트에 두면 기본값 'component' 로 떨어져, 나중에 global 을 고를 수 있게 되는 순간
// 전역 대신 _local 에 조용히 기록된다.
const build = recipes.changeState?.build;
expect(build.handler).toBe('setState');
expect(build.target).toBeUndefined();
expect(build.params?.target).toBe('local');
});
});
@@ -169,23 +169,25 @@ sirsoft-admin_basic과 동일한 localStorage 키(`g7_color_scheme`)를 사용
#### setTheme
테마 값은 액션 **top-level `target`** 으로 넘깁니다. 핸들러는 `params` 를 읽지 않으므로
`params.theme` 으로 넘기면 콘솔 경고 한 줄만 남기고 아무 것도 하지 않습니다 (dev-g7#640).
```json
{
"type": "click",
"handler": "setTheme",
"params": {
"theme": "dark"
}
"target": "dark"
}
```
| 필드 | 타입 | 필수 | 설명 |
| 위치 | 타입 | 필수 | 설명 |
|------|------|------|------|
| `theme` | string | ✅ | `"light"`, `"dark"`, `"auto"` (시스템 설정 따름) |
| `target` | string | ✅ | `"light"`, `"dark"`, `"auto"` (시스템 설정 따름) |
#### initTheme
앱 시작 시 `init_actions`에서 호출. params 없음.
앱 시작 시 `init_actions`에서 호출합니다. `target` 은 선택이며, 유효한 테마 값이면 그 값을,
없거나 유효하지 않으면 localStorage 저장값(없으면 `auto`)을 적용합니다.
```json
{
@@ -68,8 +68,8 @@
],
"build": {
"handler": "setState",
"target": "local",
"params": {
"target": "local",
"{{key}}": "{{value}}"
}
}
@@ -132,7 +132,7 @@
],
"build": {
"handler": "setTheme",
"params": { "target": "{{target}}" }
"target": "{{target}}"
}
},
"savePreferredCurrency": {
@@ -240,7 +240,7 @@
],
"build": {
"handler": "initTheme",
"params": { "target": "{{target}}" }
"target": "{{target}}"
}
},
"initCartKey": {