From d5b6f4b4576429728b89b03bb400d1fc1d58b92e Mon Sep 17 00:00:00 2001 From: chym1217 Date: Tue, 28 Jul 2026 12:14:18 +0900 Subject: [PATCH 1/7] =?UTF-8?q?fix(sirsoft-gdpr):=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=BF=A0=ED=82=A4=20=EB=8F=99?= =?UTF-8?q?=EC=9D=98=20=EC=B2=A0=ED=9A=8C=20=EC=8B=9C=20=EC=B2=A0=ED=9A=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=AF=B8=ED=91=9C=EC=8B=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 철회 이력(status=revoked)이 있는 항목에 한해 status_at_formatted· status_label 을 채워 마이페이지에 철회일이 표시되도록 한다. 거부· 미설정 상태는 기존대로 미표시 유지 ( 확인). 동의/철회 날짜 줄을 초록/빨강으로 구분해 시각적으로 대등하게 강조한다 (배지·관리자 GDPR 동의 이력 화면과 동일 톤). --- plugins/_bundled/sirsoft-gdpr/CHANGELOG.md | 2 +- .../extensions/mypage_privacy_tab.json | 4 +-- .../layouts/mypage_privacy_tab.test.tsx | 26 +++++++++++++++---- .../Resources/GdprUserConsentResource.php | 23 ++++++++++------ .../Resources/GdprUserConsentResourceTest.php | 10 +++---- 5 files changed, 44 insertions(+), 21 deletions(-) diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md index 557662e9..38201236 100644 --- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md @@ -17,7 +17,7 @@ - 쿠키 배너 버튼을 정리했습니다 — 「필수만 사용」을 제거하고, 「환경설정」을 「선택 동의」로, 펼침 패널의 저장 버튼을 「선택 저장」으로 변경했습니다. - 마이페이지 「쿠키 동의 관리」를 상태 배지 + 동의/철회 버튼 방식으로 개선했습니다 — 각 항목이 「동의함/거부함/항상 적용」 상태로 표시되고, 버튼을 누르면 동의·철회 여부를 한 번 더 확인합니다. 항목명도 「기능 쿠키」처럼 알아보기 쉽게 표기합니다. -- 마이페이지 「쿠키 동의 관리」의 글자 크기·여백이 어긋나던 문제를 바로잡고, 동의한 항목에는 동의한 날짜를 함께 표시합니다. +- 마이페이지 「쿠키 동의 관리」의 글자 크기·여백이 어긋나던 문제를 바로잡고, 동의한 항목에는 동의한 날짜를, 철회한 항목에는 철회한 날짜를 함께 표시합니다. - 「동의하지 않고 계속하기」를 눌러도 필수 쿠키가 「동의함」으로 기록되지 않도록 바로잡았습니다 — 필수 쿠키는 서비스 이용에 반드시 필요하여 동의 대상이 아닙니다. - 거부 시 안내 문구를 「동의하지 않고 계속합니다」로 바로잡았습니다 (이전에는 「동의가 저장되었습니다」로 잘못 표시). diff --git a/plugins/_bundled/sirsoft-gdpr/resources/extensions/mypage_privacy_tab.json b/plugins/_bundled/sirsoft-gdpr/resources/extensions/mypage_privacy_tab.json index 8f5ca815..439b0c6e 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/extensions/mypage_privacy_tab.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/extensions/mypage_privacy_tab.json @@ -592,11 +592,11 @@ "text": "{{consent?.consent_description ?? ''}}" }, { - "comment": "이슈 #430 (3안) — 라벨 아래 이력 줄: '날짜 + 동의'. 상태는 이미 라벨 옆 배지가 말하므로 여기선 시점만 강조. 서버가 동의한 항목만 status_label·status_at_formatted 를 채워 내려주므로(거부/미설정은 null) 둘 다 있을 때만 렌더.", + "comment": "이슈 #430 (3안) + #509 16번 — 라벨 아래 이력 줄: '날짜 + 동의/철회'. 동의=초록, 철회=빨강으로 둘 다 강조해 균형을 맞춘다 (배지의 동의함=green-700/green-300, 관리자 「GDPR 동의 이력」의 revoked=red 관례와 동일 톤). 서버가 동의·철회 항목만 status_label·status_at_formatted 를 채워 내려주므로(거부/미설정은 null) 둘 다 있을 때만 렌더.", "type": "basic", "name": "Div", "if": "{{!!consent?.status_label && !!consent?.status_at_formatted}}", - "props": { "className": "mt-1.5 text-xs text-gray-400 dark:text-gray-500" }, + "props": { "className": "mt-1.5 text-xs font-medium {{consent?.status === 'revoked' ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}}" }, "children": [ { "type": "basic", "name": "Span", "text": "{{consent?.status_at_formatted ?? ''}}" }, { "type": "basic", "name": "Span", "props": { "className": "ml-1" }, "text": "{{consent?.status_label ?? ''}}" } diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx index 2d452db5..79e3e349 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/mypage_privacy_tab.test.tsx @@ -311,17 +311,33 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매 * @scenario entry=reject, subject=member, category=optional * @effects mypage_status_date_line_consented_only */ - it('이슈 #430 — 동의한 항목만 날짜 줄 노출 (서버가 동의 상태에만 status_at_formatted 제공)', () => { - // PO 결정(2026-07-20): 날짜는 "동의한 항목만". 거부·철회·미설정은 서버가 status_at_formatted 를 - // null 로 내려주므로 날짜 줄이 렌더되지 않는다. 프론트는 status_label && status_at_formatted 가 둘 다 - // 있을 때만 날짜 줄을 그린다. + it('이슈 #430/#509 — 동의·철회 이력이 있는 항목만 날짜 줄 노출 (서버가 status_at_formatted 제공)', () => { + // PO 결정(2026-07-20) + 이슈 #509 16번 갱신: 날짜는 "동의한 항목" + "철회 이력이 있는 항목". + // 거부·신규 미설정은 서버가 status_at_formatted 를 null 로 내려주므로 날짜 줄이 렌더되지 않는다. + // 프론트는 status_label && status_at_formatted 가 둘 다 있을 때만 날짜 줄을 그리며, 값 자체는 + // 서버가 상태별(동의일/철회일)로 계산해 내려주므로 프론트 조건식은 상태 무관 동일하게 유지된다. const text = serializeForSearch(section); expect(text).toContain('consent?.status_label'); expect(text).toContain('consent?.status_at_formatted'); - // 날짜 줄은 둘 다 있을 때만 (거부/미설정은 null → 미노출) + // 날짜 줄은 둘 다 있을 때만 (거부/미설정은 null → 미노출, 철회는 이제 값이 채워짐) expect(text).toContain('!!consent?.status_label && !!consent?.status_at_formatted'); }); + /** + * @scenario entry=reject, subject=member, category=optional + * @effects mypage_status_date_line_revoked_color_distinction + */ + it('이슈 #509 16번 — 동의·철회 날짜 줄을 서로 다른 색으로 균형있게 강조 (동의=초록/철회=빨강, 배지·관리자 이력 화면과 동일 톤)', () => { + // 철회만 강조되고 동의는 밋밋해 보인다는 PO 피드백 반영 — 동의도 철회와 대등하게 강조한다. + // status === 'revoked' 면 text-red-600/dark:text-red-400, 그 외(동의)는 text-green-600/dark:text-green-400. + const text = serializeForSearch(section); + expect(text).toContain("consent?.status === 'revoked'"); + expect(text).toContain('text-red-600'); + expect(text).toContain('dark:text-red-400'); + expect(text).toContain('text-green-600'); + expect(text).toContain('dark:text-green-400'); + }); + /** * @scenario entry=reject, subject=member, category=required * @effects mypage_required_shows_locked_button diff --git a/plugins/_bundled/sirsoft-gdpr/src/Http/Resources/GdprUserConsentResource.php b/plugins/_bundled/sirsoft-gdpr/src/Http/Resources/GdprUserConsentResource.php index 42c3ee4e..d781aac0 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Http/Resources/GdprUserConsentResource.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Http/Resources/GdprUserConsentResource.php @@ -53,15 +53,22 @@ class GdprUserConsentResource extends BaseApiResource } else { $status = 'none'; } - // 표시 날짜/라벨은 "동의한 항목만" 노출한다 (PO 결정, 2026-07-20). - // 동의 상태(required·consented)만 동의일 + '동의' 라벨을 내려주고, - // 거부·철회·미선택은 status_label·status_at_formatted 를 null 로 두어 프론트가 날짜 줄을 렌더하지 않는다. - // (거부일·철회일은 사용자 화면 확인 가치가 낮고, 감사 로그·관리자 화면에서 확인 가능) + // 표시 날짜/라벨은 "동의한 항목" + "철회 이력이 있는 항목"만 노출한다 (PO 결정, 2026-07-20 / 이슈 #509 16번 갱신). + // 동의 상태(required·consented)는 동의일 + '동의' 라벨, 철회(revoked)는 철회일 + '철회' 라벨을 내려준다. + // 거부·신규 미선택은 status_label·status_at_formatted 를 null 로 두어 프론트가 날짜 줄을 렌더하지 않는다. + // (거부일은 사용자 화면 확인 가치가 낮고, 감사 로그·관리자 화면에서 확인 가능. 철회일은 유저 본인의 + // 철회권 행사 시점 확인 필요성이 있어 노출 — 이슈 #509 16번) $isConsentState = $status === 'required' || $status === 'consented'; - $statusRawDate = $isConsentState ? $this->consented_at : null; - $statusLabel = $isConsentState - ? __('sirsoft-gdpr::messages.mypage.privacy.status.granted') - : null; + $statusRawDate = match (true) { + $isConsentState => $this->consented_at, + $status === 'revoked' => $this->revoked_at, + default => null, + }; + $statusLabel = match (true) { + $isConsentState => __('sirsoft-gdpr::messages.mypage.privacy.status.granted'), + $status === 'revoked' => __('sirsoft-gdpr::messages.mypage.privacy.status.revoked'), + default => null, + }; // 이슈 #430 (버튼 재설계) — 상태 배지 문구. 토글 대신 배지+단일버튼 UI 에서 // 오른쪽 배지에 노출한다. 철회 이력(revoked)과 신규 미선택(none)은 '미설정'으로 통합. $statusBadgeKey = match ($status) { diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Unit/Http/Resources/GdprUserConsentResourceTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Http/Resources/GdprUserConsentResourceTest.php index d642424d..b728a7e3 100644 --- a/plugins/_bundled/sirsoft-gdpr/tests/Unit/Http/Resources/GdprUserConsentResourceTest.php +++ b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Http/Resources/GdprUserConsentResourceTest.php @@ -84,9 +84,10 @@ class GdprUserConsentResourceTest extends PluginTestCase $this->assertSame(__('sirsoft-gdpr::messages.mypage.privacy.badge.rejected'), $result['status_badge_label']); } - public function test_revoked_optional_maps_to_revoked_status_with_no_date_label(): void + public function test_revoked_optional_maps_to_revoked_status_with_revoked_date_label(): void { - // 철회도 거부와 동일 — 동의 상태가 아니므로 날짜/라벨 미노출. + // 이슈 #509 16번 — 철회 이력이 있는 항목은 철회일 + '철회' 라벨을 노출한다 + // (배지는 신규 미선택과 함께 '미설정'(unset) 유지 — 배지 통합과 날짜 줄 노출은 별개). $consent = new GdprUserConsent([ 'consent_key' => 'cookie_marketing', 'is_consented' => false, @@ -98,9 +99,8 @@ class GdprUserConsentResourceTest extends PluginTestCase $result = $this->toArray($consent); $this->assertSame('revoked', $result['status']); - $this->assertNull($result['status_label']); - $this->assertNull($result['status_at_formatted']); - // 철회는 신규 미선택과 함께 '미설정'(unset) 배지로 통합. + $this->assertSame(__('sirsoft-gdpr::messages.mypage.privacy.status.revoked'), $result['status_label']); + $this->assertNotNull($result['status_at_formatted']); $this->assertSame(__('sirsoft-gdpr::messages.mypage.privacy.badge.unset'), $result['status_badge_label']); } From a0cc36a9dbebd48fb30f42d60253f946a1fb5a83 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Tue, 28 Jul 2026 14:53:05 +0900 Subject: [PATCH 2/7] =?UTF-8?q?fix(sirsoft-gdpr):=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EB=B3=B8=EB=AC=B8=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=A7=81=ED=81=AC=20404=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정책 버전 snapshot 모달의 본문 페이지 링크가 /{{slug}} 로 조합되어 페이지 모듈(sirsoft-page) URL 패턴 /page/{slug} 와 불일치해 404가 발생하던 문제를 수정. 쿠키 배너의 동일 링크와 조합 방식을 통일했다. 회귀 검증을 위해 GDPR 플러그인에 Playwright E2E 인프라를 최초 도입. --- plugins/_bundled/sirsoft-gdpr/CHANGELOG.md | 1 + plugins/_bundled/sirsoft-gdpr/package.json | 4 +- .../layouts/admin_plugin_settings.test.tsx | 24 ++++++ .../_policy_version_snapshot_modal.json | 5 +- .../layouts/admin/plugin_settings.json | 1 + .../tests/Playwright/fixtures/gdpr-auth.ts | 24 ++++++ .../tests/Playwright/playwright.config.ts | 84 +++++++++++++++++++ .../policy-version-snapshot-link.spec.ts | 53 ++++++++++++ 8 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 plugins/_bundled/sirsoft-gdpr/tests/Playwright/fixtures/gdpr-auth.ts create mode 100644 plugins/_bundled/sirsoft-gdpr/tests/Playwright/playwright.config.ts create mode 100644 plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/policy-version-snapshot-link.spec.ts diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md index 38201236..22fcfab5 100644 --- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md @@ -27,6 +27,7 @@ - 관리자 환경설정의 카테고리 「필수」 배지가 다크 모드에서 배경·글자색이 적용되지 않던 문제를 바로잡았습니다. - 관리자 환경설정의 「운영 주체 / 개인정보처리방침」 섹션 제목 영역이 다른 카드와 다르게 표시되던 문제를 바로잡았습니다. - 관리자 환경설정의 저장/취소 버튼을 다른 관리자 화면과 동일하게 화면 하단 고정 위치로 옮기고, 상단 탭 영역의 불필요한 구분선과 좌우 여백 어긋남을 바로잡았습니다. +- 관리자 환경설정 「정책 버전 보기」에서 본문 페이지 링크를 클릭하면 페이지를 찾을 수 없던 문제를 바로잡았습니다. ## [1.0.1] - 2026-07-16 diff --git a/plugins/_bundled/sirsoft-gdpr/package.json b/plugins/_bundled/sirsoft-gdpr/package.json index 31c6df25..0ee7e653 100644 --- a/plugins/_bundled/sirsoft-gdpr/package.json +++ b/plugins/_bundled/sirsoft-gdpr/package.json @@ -7,7 +7,9 @@ "build": "vite build", "dev": "vite build --watch", "test": "vitest", - "test:run": "vitest run" + "test:run": "vitest run", + "test:e2e": "playwright test --config=tests/Playwright/playwright.config.ts", + "test:e2e:ui": "playwright test --config=tests/Playwright/playwright.config.ts --ui" }, "devDependencies": { "@testing-library/jest-dom": "^6.5.0", diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx index 0b6b3e4f..b58870b1 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx @@ -14,6 +14,7 @@ import { describe, it, expect } from 'vitest'; import layout from '../../../layouts/admin/plugin_settings.json'; import publishModal from '../../../layouts/admin/partials/plugin_settings/_policy_version_publish_modal.json'; +import snapshotModal from '../../../layouts/admin/partials/_shared/_policy_version_snapshot_modal.json'; import { findById, type AnyNode } from './helpers'; describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / auto_blocking_policy)', () => { @@ -698,3 +699,26 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au }); }); }); + +/** + * 이슈 #509 체크리스트 12번 회귀 테스트 — 정책 버전 snapshot 모달의 본문 페이지 링크가 + * `/{{slug}}` 로 조합되어 페이지 모듈(sirsoft-page) 실제 URL 패턴 `/page/{slug}` 와 + * 불일치, 클릭 시 404 발생하던 결함. 쿠키 배너의 '변경된 정책 본문 보기' 링크와 + * 동일하게 `/page/{{slug}}` 로 조합되어야 한다. + */ +describe('admin/partials/_shared/_policy_version_snapshot_modal.json — 본문 페이지 링크', () => { + const layoutJson = JSON.stringify(snapshotModal); + + it('본문 페이지 링크 href 가 /page/{{slug}} 형식으로 조합된다 (페이지 모듈 URL 패턴 일치)', () => { + expect(layoutJson).toContain('"href":"/page/{{gdprPolicyVersionSnapshot?.data?.data?.snapshot?.privacy_policy_slug ?? \'\'}}"'); + }); + + it('href 에 /page/ 프리픽스 없이 슬러그만 조합하는 결함 패턴이 없다', () => { + expect(layoutJson).not.toContain('"href":"/{{gdprPolicyVersionSnapshot?.data?.data?.snapshot?.privacy_policy_slug}}"'); + }); + + it('본문 페이지 링크는 새 탭(target=_blank) + noopener 로 연다', () => { + expect(layoutJson).toContain('"target":"_blank"'); + expect(layoutJson).toContain('"rel":"noopener"'); + }); +}); diff --git a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/partials/_shared/_policy_version_snapshot_modal.json b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/partials/_shared/_policy_version_snapshot_modal.json index 430a0d2a..be293185 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/partials/_shared/_policy_version_snapshot_modal.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/partials/_shared/_policy_version_snapshot_modal.json @@ -172,11 +172,12 @@ "text": "{{gdprPolicyVersionSnapshot?.data?.data?.snapshot?.privacy_policy_slug ?? '-'}}" }, { - "comment": "본문 페이지 새 탭 열기 — 슬러그를 그대로 path 로 사용 (예: 'privacy-policy' → '/privacy-policy'). 운영자가 검증한 슬러그라 사용자 입력 검증 불요.", + "id": "policy_version_snapshot_open_policy_page_link", + "comment": "본문 페이지 새 탭 열기 — 페이지 모듈(sirsoft-page) URL 패턴 /page/{slug} 로 조합 (예: 'privacy-policy' → '/page/privacy-policy'). 쿠키 배너의 '변경된 정책 본문 보기' 링크(cookie_banner.json)와 동일한 조합 방식. 운영자가 검증한 슬러그라 사용자 입력 검증 불요.", "type": "basic", "name": "A", "props": { - "href": "/{{gdprPolicyVersionSnapshot?.data?.data?.snapshot?.privacy_policy_slug}}", + "href": "/page/{{gdprPolicyVersionSnapshot?.data?.data?.snapshot?.privacy_policy_slug ?? ''}}", "target": "_blank", "rel": "noopener", "className": "text-info-soft inline-flex items-center gap-1 px-3 py-2 font-medium bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors flex-shrink-0" diff --git a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json index 859aedef..5f8a16a8 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json @@ -755,6 +755,7 @@ "text": "v{{gdprPolicyVersionCurrent?.data?.data?.version ?? '?'}}" }, { + "id": "policy_version_current_snapshot_view_button", "type": "basic", "name": "Button", "if": "{{gdprPolicyVersionCurrent?.data?.data?.version}}", diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Playwright/fixtures/gdpr-auth.ts b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/fixtures/gdpr-auth.ts new file mode 100644 index 00000000..27d87758 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/fixtures/gdpr-auth.ts @@ -0,0 +1,24 @@ +/** + * GDPR 플러그인 권한 fixture. + * + * 코어 `tests/Playwright/fixtures/auth.ts` 의 헬퍼를 재사용하되, 플러그인 권한 토큰 fixture 를 + * 자체적으로 정의한다. 권한 식별자는 임의 string 이므로 코어 PlaywrightIssueToken 커맨드가 + * 그대로 동작한다 (Permission::firstOrCreate 가 자동 생성). + */ +import { test as base } from '@playwright/test'; +// 6단계 상위 = 코어 루트의 fixtures/auth.ts +import { issueToken, authenticatePage } from '../../../../../../tests/Playwright/fixtures/auth'; + +type GdprAuthFixtures = { + /** GDPR 개인정보 조회 + 설정 변경 권한 보유 토큰 (환경설정 화면 접근용) */ + privacyManageToken: string; +}; + +export const test = base.extend({ + privacyManageToken: async ({}, use) => { + await use(issueToken('sirsoft-gdpr.privacy.view', 'sirsoft-gdpr.privacy.update')); + }, +}); + +export { authenticatePage }; +export { expect } from '@playwright/test'; diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Playwright/playwright.config.ts b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/playwright.config.ts new file mode 100644 index 00000000..e932044a --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/playwright.config.ts @@ -0,0 +1,84 @@ +/** + * GDPR 플러그인 Playwright E2E 설정. + * + * 코어 `playwright.config.ts` 와 동일한 base URL 해석 우선순위를 따른다 — 플러그인도 활성 호스트가 + * 가변(개발자/CI/PO 환경별로 다른 도메인)이므로 하드코딩 회피. + * + * Base URL 해석: + * 1. PLAYWRIGHT_BASE_URL 환경변수 (CI/명시적 오버라이드) + * 2. .env (코어 루트) 의 APP_URL — 단 localhost 류는 fallback 부적합 + * 3. 그 외 — 명시 에러 + * + * 실행 예시: + * PowerShell — $env:PLAYWRIGHT_BASE_URL='https://g7.dev'; npm run test:e2e + * Bash — PLAYWRIGHT_BASE_URL=https://g7.dev npm run test:e2e + * + * 플러그인은 코어 fixture 의 `issueToken` / `authenticatePage` 헬퍼를 재사용 — 권한 식별자는 + * `sirsoft-gdpr.*` 등 임의 string. + */ +import { defineConfig, devices } from '@playwright/test'; +import { readFileSync, existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// ESM 환경(package.json "type": "module")에서는 __dirname 이 정의되지 않으므로 +// import.meta.url 로 재구성한다. +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * 코어 루트 (artisan / .env / Playwright 산출물의 기준 경로). + * + * 확장 config 는 확장 디렉토리에서 실행되지만, 산출물을 그 안에 쓰면 Windows 에서 + * `plugin:update` 의 디렉토리 이동이 열린 핸들에 걸려 실패한다. + * 산출물은 코어 루트 아래로 모아 update 경로와 분리한다 (.gitignore 가 이미 덮는 위치). + */ +const CORE_ROOT = process.env.G7_ROOT || resolve(__dirname, '../../../../../'); + +/** 확장별 산출물 격리 — 확장끼리 리포트를 덮어쓰지 않도록 slug 로 네임스페이스. */ +const ARTIFACT_SLUG = 'plugins/sirsoft-gdpr'; + +function readEnvFile(filePath: string, key: string): string | null { + if (!existsSync(filePath)) return null; + const content = readFileSync(filePath, { encoding: 'utf-8' }); + const pattern = new RegExp(`^${key}=(.*)$`, 'm'); + const match = content.match(pattern); + if (!match) return null; + let value = match[1].trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + return value || null; +} + +function resolveBaseUrl(): string { + if (process.env.PLAYWRIGHT_BASE_URL) { + return process.env.PLAYWRIGHT_BASE_URL; + } + const appUrl = readEnvFile(resolve(CORE_ROOT, '.env'), 'APP_URL'); + if (appUrl && !/^https?:\/\/localhost(:\d+)?\/?$/i.test(appUrl)) { + return appUrl; + } + throw new Error( + 'GDPR 플러그인 E2E base URL 미설정. PLAYWRIGHT_BASE_URL 환경변수를 지정하거나 코어 .env 의 APP_URL 을 활성 호스트로 설정하세요.' + ); +} + +export default defineConfig({ + testDir: './specs', + outputDir: resolve(CORE_ROOT, 'test-results', ARTIFACT_SLUG), + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [ + ['html', { outputFolder: resolve(CORE_ROOT, 'playwright-report', ARTIFACT_SLUG), open: 'never' }], + ['list'], + ], + use: { + baseURL: resolveBaseUrl(), + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + ignoreHTTPSErrors: true, + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/policy-version-snapshot-link.spec.ts b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/policy-version-snapshot-link.spec.ts new file mode 100644 index 00000000..73927d64 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/policy-version-snapshot-link.spec.ts @@ -0,0 +1,53 @@ +/** + * E2E: 관리자 환경설정 > 쿠키 배너 > 정책 버전 > 보기 — 본문 페이지 링크 (#509 체크리스트 12번) + * + * @scenario admin_gdpr_policy_version_snapshot_open_policy_page_link + * @effects snapshot_modal_opens, policy_page_link_href_matches_page_module_url_pattern + * + * 배경: 정책 버전 snapshot 모달의 본문 페이지 링크가 `/{{slug}}` 로 조합되어 페이지 모듈 + * (sirsoft-page) 의 실제 URL 패턴 `/page/{slug}` 와 불일치, 클릭 시 404 가 발생하던 결함. + * 마이그레이션이 시드하는 v1 initial 정책 버전의 snapshot 에는 항상 기본 슬러그 'privacy' 가 + * 포함되므로 (GdprPolicyVersionMigrationSmokeTest 참조), 별도 설정 저장 없이도 v배지 클릭 → + * 모달의 본문 페이지 링크 href 를 검증할 수 있다. + * + * 검증: + * 1. 환경설정 화면에서 v배지 옆 "본문 보기" 버튼 클릭 시 snapshot 모달이 열린다 + * 2. 모달의 본문 페이지 링크 href 가 `/page/{slug}` 형식으로 조합되어 페이지 모듈 URL 패턴과 일치한다 + * (프리픽스 누락으로 `/{slug}` 형태가 되는 결함 패턴이 재발하지 않았는지 회귀 가드) + */ +import { test, expect, authenticatePage } from '../../fixtures/gdpr-auth'; + +const CARD_COOKIE_BANNER = '#card_cookie_banner'; +const SNAPSHOT_VIEW_BUTTON = '#policy_version_current_snapshot_view_button'; +const OPEN_POLICY_PAGE_LINK = '#policy_version_snapshot_open_policy_page_link'; + +/** 관리자 GDPR 환경설정 진입 후 쿠키 배너 탭(정책 버전 UI 포함)으로 스크롤 이동 */ +async function gotoGdprSettings(page: import('@playwright/test').Page): Promise { + await page.goto('/admin/plugins/sirsoft-gdpr/settings'); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + await expect(page.locator('#settings_tab_navigation')).toBeAttached({ timeout: 20_000 }); + + // TabNavigationScroll 은 URL query 가 아니라 클릭 시 해당 카드로 스크롤하는 방식이다. + await page.locator(CARD_COOKIE_BANNER).scrollIntoViewIfNeeded(); + await expect(page.locator(SNAPSHOT_VIEW_BUTTON)).toBeAttached({ timeout: 20_000 }); +} + +// @scenario tab=card_cookie_banner, permitted=yes +// @effects snapshot_modal_opens, policy_page_link_href_matches_page_module_url_pattern +test('#509 - 정책 버전 snapshot 모달의 본문 페이지 링크가 /page/{slug} 로 조합된다', async ({ page, privacyManageToken }) => { + await authenticatePage(page, privacyManageToken); + + await gotoGdprSettings(page); + expect(page.url()).not.toMatch(/\/admin\/login/); + + await page.locator(SNAPSHOT_VIEW_BUTTON).click(); + + const link = page.locator(OPEN_POLICY_PAGE_LINK); + await expect(link).toBeAttached({ timeout: 10_000 }); + + const href = await link.getAttribute('href'); + expect(href).not.toBeNull(); + // 결함 패턴(/{slug}, /page/ 프리픽스 누락) 재발 방지 — 페이지 모듈 URL 패턴 /page/{slug} 와 일치해야 한다. + expect(href).toMatch(/^\/page\/.+/); + expect(href).not.toMatch(/^\/(?!page\/)/); +}); From 192000cfced7f9d72ae4ca79a569f691b5df704e Mon Sep 17 00:00:00 2001 From: chym1217 Date: Tue, 28 Jul 2026 16:18:54 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix(sirsoft-gdpr):=20=EC=BF=A0=ED=82=A4=20?= =?UTF-8?q?=EB=B0=B0=EB=84=88=20=EB=85=B8=EC=B6=9C=20=ED=86=A0=EA=B8=80=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=EB=AC=B8=EA=B5=AC=20=EB=B0=8F=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=A0=95?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit banner_enabled(쿠키 배너 노출) 토글 안내가 "마이페이지 동의 관리 카드"까지 함께 켜지고 꺼지는 것처럼 서술되어 있었으나, 실제로는 카드가 이 토글과 무관하게 동의/철회 이력이 있는 회원에게 항상 노출된다(GDPR Art.7(3) 철회 대칭성 보장, mypage_privacy_tab.json 회귀 가드로 이미 고정된 설계). 문구만 실제 동작과 어긋나 있어 ko/en 양쪽 hint 및 관련 코드 주석을 바로잡았다. 문구 확인 과정에서 field_legal_entity_name / field_data_storage_location / field_banner_enabled / field_banner_position 4개 필드가 텍스트 스타일 전용 클래스(section-heading-md, grid 미정의)를 컨테이너로 잘못 사용해 안내 문구가 길어지면 입력/토글 컨트롤이 아래 줄로 밀려나던 기존 UI 결함도 함께 발견해 수정했다. 같은 파일의 field_privacy_policy_slug가 이미 쓰던 올바른 grid-3col-responsive 패턴으로 통일. --- plugins/_bundled/sirsoft-gdpr/CHANGELOG.md | 2 + plugins/_bundled/sirsoft-gdpr/README.md | 2 +- .../config/settings/defaults.json | 2 +- .../sirsoft-gdpr/lang/en/messages.php | 2 +- .../sirsoft-gdpr/lang/ko/messages.php | 2 +- plugins/_bundled/sirsoft-gdpr/plugin.php | 10 ++-- .../layouts/admin_plugin_settings.test.tsx | 51 +++++++++++++++++++ .../sirsoft-gdpr/resources/js/layouts.ts | 10 ++-- .../sirsoft-gdpr/resources/lang/en.json | 2 +- .../sirsoft-gdpr/resources/lang/ko.json | 2 +- .../layouts/admin/plugin_settings.json | 10 ++-- 11 files changed, 76 insertions(+), 19 deletions(-) diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md index 22fcfab5..76f88488 100644 --- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md @@ -20,6 +20,7 @@ - 마이페이지 「쿠키 동의 관리」의 글자 크기·여백이 어긋나던 문제를 바로잡고, 동의한 항목에는 동의한 날짜를, 철회한 항목에는 철회한 날짜를 함께 표시합니다. - 「동의하지 않고 계속하기」를 눌러도 필수 쿠키가 「동의함」으로 기록되지 않도록 바로잡았습니다 — 필수 쿠키는 서비스 이용에 반드시 필요하여 동의 대상이 아닙니다. - 거부 시 안내 문구를 「동의하지 않고 계속합니다」로 바로잡았습니다 (이전에는 「동의가 저장되었습니다」로 잘못 표시). +- 관리자 환경설정의 「쿠키 배너 노출」 안내 문구를 실제 동작에 맞게 바로잡았습니다 — 이 토글은 배너와 자동 차단만 제어하며, 마이페이지 「쿠키 동의 관리」 카드는 동의·철회 이력이 있는 회원에게 이 토글과 무관하게 항상 노출됩니다. ### Fixed @@ -28,6 +29,7 @@ - 관리자 환경설정의 「운영 주체 / 개인정보처리방침」 섹션 제목 영역이 다른 카드와 다르게 표시되던 문제를 바로잡았습니다. - 관리자 환경설정의 저장/취소 버튼을 다른 관리자 화면과 동일하게 화면 하단 고정 위치로 옮기고, 상단 탭 영역의 불필요한 구분선과 좌우 여백 어긋남을 바로잡았습니다. - 관리자 환경설정 「정책 버전 보기」에서 본문 페이지 링크를 클릭하면 페이지를 찾을 수 없던 문제를 바로잡았습니다. +- 관리자 환경설정의 「운영 주체」·「쿠키 배너 노출」·「배너 위치」 필드에서 안내 문구가 길어질 때 입력·토글 컨트롤이 옆이 아닌 아래 줄로 밀려나던 문제를 바로잡았습니다. ## [1.0.1] - 2026-07-16 diff --git a/plugins/_bundled/sirsoft-gdpr/README.md b/plugins/_bundled/sirsoft-gdpr/README.md index 78facf0d..729a99b7 100644 --- a/plugins/_bundled/sirsoft-gdpr/README.md +++ b/plugins/_bundled/sirsoft-gdpr/README.md @@ -38,7 +38,7 @@ php artisan plugin:activate sirsoft-gdpr | 항목 | 설명 | |------|------| -| 쿠키 배너 노출 (`banner_enabled`) | 마스터 토글 — ON 시 배너 + 자동 차단 + 마이페이지 동의 관리 카드가 일괄 활성화됩니다. GDPR Art.6 "동의 전 처리 금지" 의 강제 메커니즘인 자동 차단을 단독 OFF 할 수 없도록 단일 토글로 통합되어 있습니다 | +| 쿠키 배너 노출 (`banner_enabled`) | 마스터 토글 — ON 시 배너 + 자동 차단이 함께 활성화됩니다. GDPR Art.6 "동의 전 처리 금지" 의 강제 메커니즘인 자동 차단을 단독 OFF 할 수 없도록 단일 토글로 통합되어 있습니다. 마이페이지 동의 관리 카드는 이 토글과 무관하게 동의/철회 이력이 있는 회원에게 항상 노출됩니다 (Art.7(3) 철회 대칭성 보장) | | 배너 위치 (`banner_position`) | 하단 바 / 좌하단 팝업 / 우하단 팝업 / 중앙 모달 | ### 자동 차단 정책 diff --git a/plugins/_bundled/sirsoft-gdpr/config/settings/defaults.json b/plugins/_bundled/sirsoft-gdpr/config/settings/defaults.json index 777da260..89d7216f 100644 --- a/plugins/_bundled/sirsoft-gdpr/config/settings/defaults.json +++ b/plugins/_bundled/sirsoft-gdpr/config/settings/defaults.json @@ -1,7 +1,7 @@ { "_meta": { "version": "1.0.0", - "description": "GDPR (일반 데이터 보호 규정) 플러그인 환경설정 기본값 및 프론트엔드 스키마. banner_enabled 가 GDPR 컴플라이언스 모드 단일 토글 — ON 시 배너 노출 + 동의 전 외부 추적 자동 차단 + 마이페이지 동의 관리 카드 일괄 활성." + "description": "GDPR (일반 데이터 보호 규정) 플러그인 환경설정 기본값 및 프론트엔드 스키마. banner_enabled 가 GDPR 컴플라이언스 모드 단일 토글 — ON 시 배너 노출 + 동의 전 외부 추적 자동 차단이 함께 시작됨. 마이페이지 동의 관리 카드는 이 토글과 무관하게 동의/철회 이력이 있는 회원에게 항상 노출됨." }, "frontend_schema": { "privacy_policy_slug": { "type": "string", "expose": true }, diff --git a/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php b/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php index 965f5d4a..e9c5bf1b 100644 --- a/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php @@ -78,7 +78,7 @@ return [ ], 'banner_enabled' => [ 'label' => 'Show Cookie Banner', - 'hint' => 'When enabled, the cookie consent banner, pre-consent auto-blocking, and the MyPage consent management card are activated together. (Auto-blocking is the enforcement mechanism for GDPR Art.6 "no processing before consent" and is unified into a single toggle to prevent operators from disabling it independently.)', + 'hint' => 'When enabled, the cookie consent banner and pre-consent auto-blocking are activated together. (Auto-blocking is the enforcement mechanism for GDPR Art.6 "no processing before consent" and is unified into a single toggle to prevent operators from disabling it independently.) The MyPage consent management card is independent of this toggle and always shows for members with consent/withdrawal history.', ], 'banner_position' => [ 'label' => 'Banner Position', diff --git a/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php b/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php index 4b617c90..fb52b1b4 100644 --- a/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php @@ -78,7 +78,7 @@ return [ ], 'banner_enabled' => [ 'label' => '쿠키 배너 노출', - 'hint' => '활성 시 쿠키 동의 배너 노출 + 동의 전 외부 추적 자동 차단 + 마이페이지 동의 관리 카드가 일괄 활성됩니다. (GDPR Art.6 "동의 전 처리 금지" 의 강제 메커니즘인 차단을 분리 토글로 끄지 못하도록 단일 토글로 통합)', + 'hint' => '활성 시 쿠키 동의 배너 노출 + 동의 전 외부 추적 자동 차단이 함께 시작됩니다. (GDPR Art.6 "동의 전 처리 금지" 의 강제 메커니즘인 차단을 분리 토글로 끄지 못하도록 단일 토글로 통합) 마이페이지 동의 관리 카드는 이 토글과 무관하게 동의/철회 이력이 있는 회원에게 항상 노출됩니다.', ], 'banner_position' => [ 'label' => '배너 위치', diff --git a/plugins/_bundled/sirsoft-gdpr/plugin.php b/plugins/_bundled/sirsoft-gdpr/plugin.php index abb2852f..48216cfd 100644 --- a/plugins/_bundled/sirsoft-gdpr/plugin.php +++ b/plugins/_bundled/sirsoft-gdpr/plugin.php @@ -381,7 +381,9 @@ class Plugin extends AbstractPlugin 'data_storage_location' => '', // 쿠키 배너 + 자동 차단 (F-01 / F-02) — banner_enabled 단일 토글로 통합 제어. - // ON 시 배너 노출 + 동의 전 외부 추적 자동 차단 + 마이페이지 동의 관리 카드 일괄 활성. + // ON 시 배너 노출 + 동의 전 외부 추적 자동 차단이 함께 시작됨. 마이페이지 동의 관리 + // 카드는 이 토글과 무관 — GDPR Art.7(3) 철회 대칭성 보장을 위해 동의/철회 이력이 + // 있는 회원에게 배너 노출 여부와 무관하게 항상 노출된다 (resources/extensions/mypage_privacy_tab.json 참조). // 차단을 별도 토글로 제공하지 않는 이유: GDPR Art.6 "동의 전 처리 금지" 의 강제 메커니즘 // 인 차단을 운영자가 단독 OFF 할 수 있으면 위반 조합 (배너 ON + 차단 OFF) 가능 → CNIL // Microsoft €60M / Amazon €35M / Google €100M 처벌 패턴과 동일. 단일 토글로 구조적 차단. @@ -492,15 +494,15 @@ class Plugin extends AbstractPlugin 'required' => false, ], - // 쿠키 배너 + 자동 차단 + 마이페이지 카드 단일 토글 (banner_enabled) + // 쿠키 배너 + 자동 차단 단일 토글 (banner_enabled). 마이페이지 카드는 이 토글과 무관 (별도 절 참조). // 기본값 true — 플러그인 활성화 = 운영자의 GDPR 컴플라이언스 의사 표명. 'banner_enabled' => [ 'type' => 'boolean', 'default' => true, 'label' => ['ko' => '쿠키 배너 노출', 'en' => 'Show Cookie Banner'], 'hint' => [ - 'ko' => 'ON 시 쿠키 동의 배너 노출 + 동의 전 외부 추적 자동 차단 + 마이페이지 동의 관리 카드 일괄 활성. 정책 슬러그·카테고리 설정 후 켜는 것을 권장.', - 'en' => 'When ON, the cookie consent banner, pre-consent auto-blocking, and the MyPage consent management card are activated together.', + 'ko' => 'ON 시 쿠키 동의 배너 노출 + 동의 전 외부 추적 자동 차단이 함께 시작됩니다. 정책 슬러그·카테고리 설정 후 켜는 것을 권장. 마이페이지 동의 관리 카드는 이 토글과 무관하게 동의/철회 이력이 있는 회원에게 항상 노출됩니다.', + 'en' => 'When ON, the cookie consent banner and pre-consent auto-blocking are activated together. The MyPage consent management card is independent of this toggle and always shows for members with consent/withdrawal history.', ], 'required' => false, ], diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx index b58870b1..54c239f4 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx @@ -1,3 +1,4 @@ +// e2e:allow 이슈 #509 4번 — banner_enabled 안내 문구/주석 정정만 (레이아웃 구조·핸들러·바인딩 변경 없음, 브라우저 관찰 가능한 동작 변화 없음). Vitest 회귀(위 hint 문자열 검증)로 충분. /** * GDPR 플러그인 환경설정 레이아웃 구조 검증 * @@ -15,6 +16,8 @@ import { describe, it, expect } from 'vitest'; import layout from '../../../layouts/admin/plugin_settings.json'; import publishModal from '../../../layouts/admin/partials/plugin_settings/_policy_version_publish_modal.json'; import snapshotModal from '../../../layouts/admin/partials/_shared/_policy_version_snapshot_modal.json'; +import koLang from '../../../lang/ko.json'; +import enLang from '../../../lang/en.json'; import { findById, type AnyNode } from './helpers'; describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / auto_blocking_policy)', () => { @@ -121,7 +124,55 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au }); }); + /** + * 이슈 #509 체크리스트 4번 회귀 테스트 — 라벨/hint + 컨트롤(Input/Toggle/Select) 2단 + * 배치 필드가 `section-heading-md`(텍스트 스타일 전용, grid 미정의) 를 컨테이너 + * className 으로 사용해 col-span-* 자식이 무효화되고 컨트롤이 아래 줄로 밀려나던 + * 결함. 코드베이스 표준은 `grid-3col-responsive`(grid grid-cols-1 lg:grid-cols-3 + * gap-4 items-start) — field_privacy_policy_slug 가 원래 이 클래스를 올바르게 + * 사용하고 있었다 (참조 패턴). + */ + describe('필드 2단 배치 컨테이너 — grid-3col-responsive 사용 (section-heading-md 오용 회귀 가드)', () => { + it.each([ + 'field_legal_entity_name', + 'field_data_storage_location', + 'field_banner_enabled', + 'field_banner_position', + ])('%s 컨테이너 className 이 grid-3col-responsive 를 포함한다', (fieldId) => { + const field = findById(root, fieldId); + const className = String((field?.props as { className?: string } | undefined)?.className ?? ''); + expect(className).toContain('grid-3col-responsive'); + }); + + it('section-heading-md 는 더 이상 필드 2단 배치 컨테이너로 오용되지 않는다 (텍스트 스타일 전용 클래스)', () => { + const layoutJson = JSON.stringify(root); + expect(layoutJson).not.toContain('"className":"section-heading-md"'); + }); + }); + describe('card_cookie_banner', () => { + /** + * 이슈 #509 체크리스트 4번 회귀 테스트 — banner_enabled 안내 문구가 "마이페이지 동의 + * 관리 카드"까지 이 토글로 일괄 제어되는 것처럼 서술하던 결함. 실제로는 카드가 + * banner_enabled 와 무관하게 동의/철회 이력 존재 여부로만 판정된다 + * (mypage_privacy_tab.test.tsx "카드 표시 조건" 참조). 안내 문구를 실제 동작과 + * 불일치시키지 않도록 ko/en 양쪽 hint 를 고정한다. + */ + it('banner_enabled hint 는 마이페이지 카드가 이 토글과 무관하게 항상 노출됨을 명시한다 (ko/en)', () => { + const koHint = (koLang as { settings?: { fields?: { banner_enabled?: { hint?: string } } } }).settings + ?.fields?.banner_enabled?.hint ?? ''; + const enHint = (enLang as { settings?: { fields?: { banner_enabled?: { hint?: string } } } }).settings + ?.fields?.banner_enabled?.hint ?? ''; + + expect(koHint).toContain('마이페이지'); + expect(koHint).toContain('무관'); + expect(koHint).not.toMatch(/마이페이지.*(함께|일괄).*(시작|활성)/); + + expect(enHint).toContain('MyPage'); + expect(enHint).toContain('independent'); + expect(enHint).not.toMatch(/MyPage.*activated together/); + }); + it('banner_enabled (쿠키 배너 노출 단일 토글) / banner_position 포함. auto_blocking_enabled 별도 토글 제거됨', () => { const fields = findById(root, 'card_cookie_banner_fields'); const serialized = JSON.stringify(fields); diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/layouts.ts b/plugins/_bundled/sirsoft-gdpr/resources/js/layouts.ts index 42764ffa..d9b7074e 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/layouts.ts +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/layouts.ts @@ -9,10 +9,11 @@ * (회원/게스트 통합 — 인증 불필요. 회원은 sanctum 토큰이 있으면 자동 적용) * * banner_enabled 가 단일 토글 (쿠키 배너 노출) — ON 시 배너 노출 + 동의 전 외부 추적 - * 자동 차단 + 마이페이지 동의 관리 카드 일괄 활성. 차단 별도 토글 없음 (위반 조합 구조적 차단). + * 자동 차단이 함께 시작됨. 차단 별도 토글 없음 (위반 조합 구조적 차단). * - * 마이페이지 동의 카드(F-04)는 GDPR Art.7(3) 대칭성 의무에 따라 회원에게 동의 데이터가 있을 - * 때만 노출 (빈 카드 노출 방지). 가드 조건은 `mypage_privacy_tab.json` 의 `if` 속성에 정의. + * 마이페이지 동의 카드(F-04)는 banner_enabled 와 무관 — GDPR Art.7(3) 대칭성 의무(철회는 + * 동의만큼 쉬워야 함)에 따라 회원에게 동의 데이터가 있으면 배너 비활성 중에도 항상 노출 + * (빈 카드 노출만 방지). 가드 조건은 `mypage_privacy_tab.json` 의 `if` 속성에 정의. * * G7Core.api 가 로드된 환경에서는 G7Core.api.get() 사용 (auth/devtools/locale 헤더 자동), * 미로드 환경(부트 초기 등)에서는 fetch 로 fallback. @@ -23,7 +24,8 @@ export interface GdprPublicSettings { cookie_policy_version: string; /** - * 쿠키 배너 노출 단일 토글. ON 시 배너 + 자동 차단 + 마이페이지 카드 일괄 활성. + * 쿠키 배너 노출 단일 토글. ON 시 배너 + 자동 차단이 함께 활성. + * 마이페이지 동의 관리 카드는 이 값과 무관 (동의/철회 이력 존재 여부로만 판정). */ banner_enabled: boolean; /** diff --git a/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json b/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json index ec4ab41b..f86d3fa9 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json @@ -88,7 +88,7 @@ }, "banner_enabled": { "label": "Show Cookie Banner", - "hint": "When enabled, the cookie banner, pre-consent auto-blocking, and the MyPage consent management card are activated together." + "hint": "When enabled, the cookie banner and pre-consent auto-blocking are activated together. The MyPage consent management card is independent of this toggle and always shows for members with consent/withdrawal history." }, "banner_position": { "label": "Banner Position", diff --git a/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json b/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json index 1c7253f2..28d975f4 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json @@ -88,7 +88,7 @@ }, "banner_enabled": { "label": "쿠키 배너 노출", - "hint": "활성 시 쿠키 배너 노출, 동의 전 외부 추적 자동 차단, 마이페이지 동의 관리 카드가 함께 시작됩니다." + "hint": "활성 시 쿠키 배너 노출과 동의 전 외부 추적 자동 차단이 함께 시작됩니다. 마이페이지 동의 관리 카드는 이 토글과 무관하게 동의/철회 이력이 있는 회원에게 항상 노출됩니다." }, "banner_position": { "label": "배너 위치", diff --git a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json index 5f8a16a8..9aab0645 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json @@ -286,7 +286,7 @@ "type": "basic", "name": "Div", "props": { - "className": "section-heading-md" + "className": "grid-3col-responsive" }, "children": [ { @@ -350,7 +350,7 @@ "type": "basic", "name": "Div", "props": { - "className": "section-heading-md" + "className": "grid-3col-responsive" }, "children": [ { @@ -549,7 +549,7 @@ "name": "Div", "comment": "v1.4.0: banner_display_mode Select → banner_enabled Toggle", "props": { - "className": "section-heading-md" + "className": "grid-3col-responsive" }, "children": [ { @@ -600,7 +600,7 @@ "type": "basic", "name": "Div", "props": { - "className": "section-heading-md" + "className": "grid-3col-responsive" }, "children": [ { @@ -1578,7 +1578,7 @@ "type": "basic", "name": "Div", "if": "{{!_local.form?.banner_enabled}}", - "comment": "쿠키 배너 노출 (banner_enabled) OFF 시 안내 박스. ON 으로 전환하면 배너 + 자동 차단 + 마이페이지 카드가 일괄 활성됨", + "comment": "쿠키 배너 노출 (banner_enabled) OFF 시 안내 박스. ON 으로 전환하면 배너 + 자동 차단이 함께 활성됨 (마이페이지 카드는 이 토글과 무관, 항상 노출)", "props": { "className": "bg-blue-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 border-l-4 border-l-blue-500 dark:border-l-blue-400 rounded-lg p-4 flex-center gap-2" }, From 2a036704f432a43f0d06e49afcaf2dd8913b0caa Mon Sep 17 00:00:00 2001 From: chym1217 Date: Wed, 29 Jul 2026 12:56:40 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix(sirsoft-gdpr):=20=EC=BF=A0=ED=82=A4=20?= =?UTF-8?q?=EB=8F=99=EC=9D=98=20=EB=B3=B4=EC=95=88=20=EA=B2=B0=ED=95=A8=20?= =?UTF-8?q?3=EA=B1=B4=20=EC=88=98=EC=A0=95=20+=20=ED=95=98=EC=9C=84?= =?UTF-8?q?=ED=98=B8=ED=99=98=20=EA=B3=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 체크리스트와는 별개로, 처리 완료 후 지시로 진행한 sirsoft-gdpr 플러그인 전수 비판적 검토에서 발견한 보안·결함 3건을 수정한다. - Admin 설정 저장 API 의 검증 우회 fallback(빈 요청 시 $request->all 사용) 제거. UpdateAdminSettingsRequest 필드가 모두 nullable 이라 검증을 거치지 않은 임의 키가 저장 로직까지 전달될 수 있었다. - 게스트 세션 식별 쿠키(gdpr_session) 에 HMAC-SHA256 서명 적용. api 미들웨어 그룹에는 EncryptCookies 가 없어 쿠키가 평문으로 오가 임의 UUID로 타인의 게스트 동의 상태를 조회/철회할 수 있었다. 결제 플러그인들의 IssuesReceiptCookie 트레이트와 동일 컨벤션 채택. 이 조치로 배포 이전 발급된 비회원 쿠키는 1회 초기화되며 이미 배너를 닫았던 비회원은 다음 방문 시 배너를 다시 보게 된다(회원 동의 이력은 영향 없음) — CHANGELOG 에 고지. - 게스트로 쿠키 배너를 닫은 뒤 동의 이력 없는 다른 계정으로 로그인해도 배너가 계속 숨겨져 있던 결함 수정. _global.gdprBannerDismissedFor 가 "닫힘 여부"(boolean) 대신 "누가 닫았는지"(사용자 식별자)를 저장하도록 변경해, 로그인으로 사용자가 바뀌면 다시 노출되게 한다. 실제 로그인 폼 제출을 거쳐 재현하는 E2E spec 신규 작성(PlaywrightSeedGdprGuestLogin 시드 커맨드 포함, sirsoft-page 의 PlaywrightSeedPage 패턴 재사용). 부수: 자동 차단 정책 안내에 보호 범위 한계 고지 추가, GDPR 플러그인 번들 로딩 우선순위 상향(다른 확장의 스크립트로부터 보호 범위 확대). --- .../g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md | 6 + .../backend/ja/messages.php | 134 +++++------ .../frontend/ja.json | 223 +++++++++--------- .../language-pack.json | 2 +- .../seed/manifest.json | 4 +- .../g7-plugin-sirsoft-gdpr-ja/seed/roles.json | 4 +- plugins/_bundled/sirsoft-gdpr/CHANGELOG.md | 3 + .../_bundled/sirsoft-gdpr/docs/api/consent.md | 2 +- .../sirsoft-gdpr/docs/api/settings.md | 2 + plugins/_bundled/sirsoft-gdpr/plugin.json | 2 +- .../resources/extensions/cookie_banner.json | 12 +- .../__tests__/layouts/cookie_banner.test.tsx | 83 ++++++- .../sirsoft-gdpr/resources/lang/en.json | 1 + .../sirsoft-gdpr/resources/lang/ko.json | 1 + .../layouts/admin/plugin_settings.json | 5 + .../src/Concerns/IssuesGuestSessionCookie.php | 73 ++++++ .../Commands/PlaywrightSeedGdprGuestLogin.php | 106 +++++++++ .../Admin/GdprAdminSettingsController.php | 13 +- .../Public/GdprCookieConsentController.php | 17 +- .../Middleware/CookieConsentMiddleware.php | 12 +- .../src/Providers/GdprServiceProvider.php | 12 + .../GdprCookieConsentControllerTest.php | 25 +- .../fixtures/gdpr-guest-login-seed.ts | 43 ++++ ...cookie-banner-guest-login-reappear.spec.ts | 60 +++++ .../Concerns/IssuesGuestSessionCookieTest.php | 93 ++++++++ 25 files changed, 720 insertions(+), 218 deletions(-) create mode 100644 plugins/_bundled/sirsoft-gdpr/src/Concerns/IssuesGuestSessionCookie.php create mode 100644 plugins/_bundled/sirsoft-gdpr/src/Console/Commands/PlaywrightSeedGdprGuestLogin.php create mode 100644 plugins/_bundled/sirsoft-gdpr/tests/Playwright/fixtures/gdpr-guest-login-seed.ts create mode 100644 plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/public/cookie-banner-guest-login-reappear.spec.ts create mode 100644 plugins/_bundled/sirsoft-gdpr/tests/Unit/Concerns/IssuesGuestSessionCookieTest.php diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md index a82eec79..2d8c92ba 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.2] - 2026-07-29 + +### Added + +- 자동 차단 정책 카드에 보호 범위 한계 안내 문구(운영자가 직접 삽입한 외부 스크립트는 보호 범위 밖) 일본어 번역 추가 + ## [1.0.1] - 2026-07-27 ### Added diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/backend/ja/messages.php index 7aa37c5f..9f840ab4 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/backend/ja/messages.php @@ -4,9 +4,9 @@ return [ 'consent' => [ 'granted' => '同意が保存されました。', 'granted_again' => '同意が更新されました。', - 'rejected_saved' => '同意せずに続行します。選択項目は使用されません。', + 'rejected_saved' => '同意しないで続行します。選択項目は使用されません。', 'revoked' => '同意が取り消されました。', - 'renew_all_success' => '有効な項目 {renewed} 個を新しいポリシーバージョンに更新しました。', + 'renew_all_success' => 'アクティブな項目 {renewed} 個を新しいポリシーバージョンに更新しました。', 'granting' => '同意中...', 'revoking' => '取り消し中...', 'renewing' => '更新中...', @@ -19,35 +19,35 @@ return [ 'category_cookie_marketing' => 'マーケティングクッキー', ], 'blocked_domains' => [ - 'invalid_format' => '正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com ワイルドカード対応)', - 'invalid_format_functional' => '機能カテゴリ — 正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com ワイルドカード対応)', - 'invalid_format_analytics' => '分析カテゴリ — 正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com ワイルドカード対応)', - 'invalid_format_marketing' => 'マーケティングカテゴリ — 正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com ワイルドカード対応)', - 'too_long' => 'ドメインは 253 文字を超えることができません。', - 'too_long_functional' => '機能カテゴリ — ドメインは 253 文字を超えることができません。', - 'too_long_analytics' => '分析カテゴリ — ドメインは 253 文字を超えることができません。', - 'too_long_marketing' => 'マーケティングカテゴリ — ドメインは 253 文字を超えることができません。', - 'must_be_array' => 'カテゴリごとのドメインリストは配列である必要があります。', + 'invalid_format' => '正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com のワイルドカード可能)', + 'invalid_format_functional' => '機能カテゴリー — 正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com のワイルドカード可能)', + 'invalid_format_analytics' => '分析カテゴリー — 正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com のワイルドカード可能)', + 'invalid_format_marketing' => 'マーケティングカテゴリー — 正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com のワイルドカード可能)', + 'too_long' => 'ドメインは 253 文字を超えることはできません。', + 'too_long_functional' => '機能カテゴリー — ドメインは 253 文字を超えることはできません。', + 'too_long_analytics' => '分析カテゴリー — ドメインは 253 文字を超えることはできません。', + 'too_long_marketing' => 'マーケティングカテゴリー — ドメインは 253 文字を超えることはできません。', + 'must_be_array' => 'カテゴリー別ドメイン一覧は配列である必要があります。', ], 'settings' => [ 'saved' => '設定が保存されました。', 'title' => 'GDPR 設定', - 'description' => 'クッキー同意バナーと マイページ同意管理カードの動作を管理者が制御します。', + 'description' => 'クッキー同意バナーとマイページ同意管理カードの動作を運営者が制御します。', 'section' => [ - 'operator' => '運営主体 / プライバシーポリシー', - 'operator_desc' => 'クッキーバナー・ポリシーページに表示される運営主体、データ保存場所、ポリシーページスラッグを登録します。(GDPR Art.13 透明性義務)', + 'operator' => '運営主体 / 個人情報保護方針', + 'operator_desc' => 'クッキーバナー·ポリシーページに表示される運営主体、データ保存位置、ポリシーページスラッグを登録します。(GDPR Art.13 透明性義務)', 'cookie_banner' => 'クッキー同意バナー', - 'cookie_banner_desc' => '訪問者に表示されるクッキー同意バナーの表示/位置/カテゴリ/ポリシーバージョンを一箇所で設定します。', + 'cookie_banner_desc' => '訪問者に表示されるクッキー同意バナーの表示/位置/カテゴリー/ポリシーバージョンを一箇所で設定します。', 'cookie_banner_settings' => 'バナー表示設定', - 'cookie_categories' => 'クッキーカテゴリ', + 'cookie_categories' => 'クッキーカテゴリー', 'cookie_policy_version' => 'クッキーポリシーバージョン', - 'auto_blocking' => '自動ブロック設定', - 'auto_blocking_desc' => 'クッキーバナー表示が ON の場合、下記のカテゴリごとのブロックドメインリストの外部トラッキングリソースがユーザー同意前まで自動ブロックされます。(別途トグルなし — バナー表示と共に自動稼働)', + 'auto_blocking' => '自動ブロックポリシー', + 'auto_blocking_desc' => 'クッキーバナー表示が ON の場合、下記カテゴリー別ブロックドメイン一覧の外部トラッキングリソースがユーザー同意前まで自動ブロックされます。(個別トグルなし — バナー表示と共に自動で動作)', ], 'nav' => [ 'operator' => '運営情報', 'cookie_banner' => 'クッキーバナー', - 'auto_blocking_policy' => '自動ブロック設定', + 'auto_blocking_policy' => '自動ブロックポリシー', ], 'status' => [ 'enabled' => '有効化', @@ -58,29 +58,29 @@ return [ 'fields' => [ 'cookie_policy_version' => [ 'label' => 'ポリシーバージョン', - 'hint' => '管理者が直接入力せず、システムが自動管理します。 -• カテゴリの追加/削除、カテゴリの意味の変更、ポリシーページの変更 → 新バージョン自動公開 + すべての会員に再同意 -• ドメインの追加/削除、ラベル変更など単純な変更 → 公開なし', + 'hint' => '運営者が直接入力せず、システムが自動管理します。 +• カテゴリー追加/削除、カテゴリー意味変更、ポリシーページ変更 → 新バージョン自動公開 + すべての会員再同意 +• ドメイン追加/削除、ラベル変更など単純変更 → 公開なし', ], 'privacy_policy_slug' => [ - 'label' => 'プライバシーポリシーページスラッグ', - 'hint' => 'ページプラグインに登録されたプライバシーポリシーページのスラッグ。空の場合、クッキーバナーのポリシーリンクが表示されません。', + 'label' => '個人情報保護方針ページスラッグ', + 'hint' => 'ページプラグインに登録された処理方針ページのスラッグ。空の場合、クッキーバナーのポリシーリンクが表示されません。', 'placeholder' => '例: privacy-policy', ], 'legal_entity_name' => [ 'label' => '法人名 / 運営主体', - 'hint' => 'クッキーバナーと マイページに表示されるサイト運営主体名です。', + 'hint' => 'クッキーバナーとマイページに表示されるサイト運営主体の名前です。', 'placeholder' => '例: (株)会社名', ], 'data_storage_location' => [ - 'label' => 'データ保存場所', - 'hint' => 'ユーザーに通知するデータ保存国を入力してください。例: "日本"、"アメリカ (AWS)"。IP アドレス、クラウドリージョンコード(例: ap-northeast-2)、データセンターアドレスはセキュリティ上入力しないでください。', - 'placeholder' => '例: 日本 (自社データセンター) / アメリカ (AWS)', - 'sensitive_format' => 'データ保存場所に IP アドレス、CIDR、クラウドリージョンコード(例: ap-northeast-2) は入力できません。国名(例: 日本、アメリカ) で記載してください。', + 'label' => 'データ保存位置', + 'hint' => 'ユーザーに案内するデータ保存国を入力してください。例: 「大韓民国」、「アメリカ (AWS)」。IP アドレス、クラウドリージョンコード(例: ap-northeast-2)、データセンターアドレスはセキュリティ上、入力しないでください。', + 'placeholder' => '例: 大韓民国 (自社データセンター) / アメリカ (AWS)', + 'sensitive_format' => 'データ保存位置に IP アドレス、CIDR、クラウドリージョンコード(例: ap-northeast-2) は入力できません。国名(例: 大韓民国、アメリカ) で表記してください。', ], 'banner_enabled' => [ 'label' => 'クッキーバナー表示', - 'hint' => '有効化すると、クッキー同意バナー表示 + 同意前の外部トラッキング自動ブロック + マイページ同意管理カードが一括有効化されます。(GDPR Art.6 "同意前処理禁止" の強制メカニズムであるブロックを個別トグルで無効化できないよう、単一トグルに統合)', + 'hint' => '有効化時、クッキー同意バナー表示 + 同意前外部トラッキング自動ブロックが一緒に開始されます。(GDPR Art.6 「同意前処理禁止」 の強制メカニズムであるブロックを個別トグルで無効化できないよう単一トグルに統合) マイページ同意管理カードはこのトグルとは無関係に、同意/取り消し履歴のある会員に常に表示されます。', ], 'banner_position' => [ 'label' => 'バナー位置', @@ -91,21 +91,21 @@ return [ 'option_centered_modal' => '中央モーダル', ], 'blocked_domains' => [ - 'warnings_title' => '注意', - 'auto_blocking_off_hint' => '自動ブロックはクッキーバナー表示が ON の場合のみ稼働します。「クッキーバナー」カードでクッキーバナー表示トグルをまずオンにしてください。', - 'warning_visual_break' => '外部フォント・スタイルシートは分類に注意してください。誤ってブロックするとページが静かに破損する可能性があります。', + 'warnings_title' => '案内', + 'auto_blocking_off_hint' => '自動ブロックはクッキーバナー表示が ON のときのみ動作します。「クッキーバナー」カードのクッキーバナー表示トグルをまず有効化してください。', + 'warning_visual_break' => '外部フォント·スタイルシートは分類に注意してください。誤ってブロックするとページが静かに崩れる可能性があります。', 'warning_policy_bump' => 'ブロックドメインを変更した後、クッキーポリシーバージョンを上げてください。既存の同意者が新しいドメインにも同意したものとして処理される問題を防ぎます。', - 'warning_domain_format' => 'ドメイン形式: ドット 1 個以上を含むドメイン。ワイルドカード *.example.com 対応。シングルラベル(localhost) および多言語ドメイン(xn-- 変換) は未対応。', - 'tag_input_placeholder' => 'ドメイン入力後 Enter (例: google-analytics.com、*.hotjar.com)', + 'warning_domain_format' => 'ドメイン形式: ドットが 1 個以上含まれるドメイン。ワイルドカード *.example.com 可能。単一ラベル(localhost) および韓国語ドメイン(xn-- 変換) 非サポート。', + 'tag_input_placeholder' => 'ドメイン入力後 Enter キー (例: google-analytics.com, *.hotjar.com)', 'tag_no_options' => '推奨ドメインはありません。直接入力してください。', 'category' => [ - 'functional' => '機能カテゴリブロックドメイン', - 'analytics' => '分析カテゴリブロックドメイン', - 'marketing' => 'マーケティングカテゴリブロックドメイン', + 'functional' => '機能カテゴリーブロックドメイン', + 'analytics' => '分析カテゴリーブロックドメイン', + 'marketing' => 'マーケティングカテゴリーブロックドメイン', ], ], 'cookie_categories' => [ - 'description' => 'クッキーカテゴリ 4 種類の用途とブロックツール例は、各カードの [情報を展開] で確認できます。', + 'description' => 'クッキーカテゴリー 4 種の用途とブロック作業の例は、各カードの [情報を展開] から確認できます。', 'category_required_badge' => '必須', 'category_optional_badge' => '選択', ], @@ -123,12 +123,12 @@ return [ 'publishing' => '公開中...', 'change_type' => [ 'material' => '再同意トリガー', - 'non_material' => '簡単な変更', + 'non_material' => '単純変更', 'initial' => '初期公開', ], 'history_modal' => [ 'title' => 'ポリシーバージョン公開履歴', - 'empty' => '公開されたポリシーバージョンはありません。', + 'empty' => '公開されたポリシーバージョンがありません。', ], 'history' => [ 'col' => [ @@ -144,21 +144,21 @@ return [ 'material_modal' => [ 'title' => 'ポリシーバージョン公開 + 再同意トリガー', 'body' => '次の変更は、ポリシーバージョンの自動公開とすべての会員の再同意トリガーを発生させます。', - 'effect_bump' => 'ポリシーバージョンの自動公開(単調増加)', - 'effect_reconsent' => 'すべての会員の次回訪問時に再同意画面が表示されます', - 'effect_history' => '履歴モーダルに新しいバージョン行を追加', + 'effect_bump' => 'ポリシーバージョン自動公開(単調増加)', + 'effect_reconsent' => 'すべての会員の次回訪問時に再同意画面を表示', + 'effect_history' => '履歴モーダルに新バージョン行を追加', 'memo_label' => '変更理由(必須)', - 'memo_placeholder' => '例:分析カテゴリ新設(Hotjar導入)', + 'memo_placeholder' => '例:分析カテゴリー新設(Hotjar導入)', 'confirm' => '新バージョン公開後に保存', ], 'snapshot_modal' => [ 'title' => 'ポリシーバージョン v:version 本文', - 'description' => '公開時点での設定のカテゴリリスト・ポリシー本文スラッグ・ブロックドメイン等、全体の本文です。DPO が同意紛争時の立証資料として活用します。', - 'section_categories' => 'クッキーカテゴリ', + 'description' => '公開時点での設定のカテゴリー一覧・ポリシー本文スラッグ・ブロックドメインなど全体の本文です。DPOが同意紛争時の証拠資料として活用します。', + 'section_categories' => 'クッキーカテゴリー', 'section_privacy_policy' => 'ポリシー本文スラッグ', 'section_blocked_domains' => 'ブロックドメインカタログ', 'category_required_badge' => '必須', - 'no_categories' => '登録されたカテゴリがありません。', + 'no_categories' => '登録されたカテゴリーがありません。', 'no_blocked_domains' => '登録されたブロックドメインがありません。', 'view_button' => 'v:version 本文を表示', 'close' => '閉じる', @@ -171,7 +171,7 @@ return [ 'admin' => [ 'consent_log' => [ 'title' => 'GDPR同意履歴', - 'description' => '会員・ゲストの同意・撤回変更履歴を照会します。IP·User-Agent等の監査情報が一緒に表示されます。', + 'description' => '会員・ゲストの同意・撤回変更履歴を照会します。IP・User-Agentなど監査情報が合わせて表示されます。', 'refresh' => '更新', 'empty' => '照会された同意履歴がありません。', 'clear_filters' => 'フィルターをリセット', @@ -211,19 +211,19 @@ return [ 'detail' => [ 'session_id_label' => 'セッションID', 'user_agent_label' => 'User-Agent', - 'categories_label' => 'カテゴリスナップショット', - 'categories_hint' => '同意時点で会員がカテゴリ別に示した意思です。ポリシー本文が更新されても、この記録は不変として保存されます。', + 'categories_label' => 'カテゴリースナップショット', + 'categories_hint' => '同意時点で会員がカテゴリー別に表示した意思です。ポリシー本文が更新されても、この記録は不変として保存されます。', 'snapshot_granted' => '同意', - 'snapshot_revoked' => '非同意', + 'snapshot_revoked' => '未同意', ], ], ], 'mypage' => [ 'privacy' => [ 'title' => 'クッキー同意管理', - 'description' => 'サイトが使用中のクッキー項目に対する同意を変更または撤回できます。', + 'description' => 'サイトが使用中のクッキー項目の同意を変更または撤回できます。', 'section_consents' => '私の同意状況', - 'section_consents_hint' => '同意/撤回時点に従ってすぐに反映されます。必須項目は撤回できません。', + 'section_consents_hint' => '同意・撤回時点に応じて即座に反映されます。必須項目は撤回できません。', 'col_consent_key' => '同意項目', 'col_consented' => 'ステータス', 'col_consented_at' => '同意日', @@ -231,12 +231,12 @@ return [ 'col_policy_version' => 'ポリシーバージョン', 'col_action' => '作業', 'consented' => '同意', - 'revoked' => '取消', + 'revoked' => '撤回', 'rejected_label' => '拒否', 'status' => [ 'granted' => '同意', 'consented' => '同意', - 'revoked' => '取消', + 'revoked' => '撤回', 'rejected' => '拒否', ], 'badge' => [ @@ -245,20 +245,20 @@ return [ 'rejected' => '拒否しました', 'unset' => '未設定', ], - 'revoke' => '取消', + 'revoke' => '撤回', 'grant' => '同意', 'grant_again' => '再度同意', 'required_label' => '必須', - 'required_hint' => 'サービス提供に必須であるため、取消することはできません。', - 'revoke_confirm_title' => '同意取消確認', - 'revoke_confirm_message' => 'このアイテムを取消しますか? 同意を取消すると、該当するカテゴリーの関連機能が制限される可能性があります。', - 'no_consents' => 'アクティブな同意項目がありません。', + 'required_hint' => 'サービス提供に必須のため撤回することはできません。', + 'revoke_confirm_title' => '同意撤回確認', + 'revoke_confirm_message' => 'この項目を撤回してよろしいですか?同意を撤回すると、該当カテゴリ関連の機能が制限される可能性があります。', + 'no_consents' => '有効な同意項目がありません。', 'needs_renewal' => 'クッキーポリシーが変更されました。同意を更新してください。', 'needs_renewal_with_version' => 'クッキーポリシーがバージョン {version} に変更されました。選択項目の同意を再度確認してください。', - 'needs_renewal_hint' => '必須クッキーはサイト運営に必ず必要な項目のため、ポリシー変更の有無に関わらず常に適用されます。', + 'needs_renewal_hint' => '必須クッキーはサイト運営に必要な項目であるため、ポリシー変更に関わらず常に適用されます。', 'btn_renew_all' => '現在の同意更新', 'btn_renew_this_item' => 'ポリシー更新', - 'renew_all_success' => 'アクティブな項目 {renewed} 個を新しいポリシーバージョンに更新しました。', + 'renew_all_success' => '有効な項目 {renewed} 個を新しいポリシーバージョンに更新しました。', ], ], 'common' => [ @@ -269,15 +269,15 @@ return [ 're_appear_reason' => 'クッキーポリシーがバージョン :version に変更されたため、同意を再度確認します。', 'consent_required_hint' => '拒否した項目に関連する機能が制限される可能性があります。', 'btn_renew_consent' => '現在の同意更新', - 'title' => 'クッキー使用のお知らせ', - 'description' => '本サイトはユーザーエクスペリエンスの改善と分析のためクッキーを使用します。項目ごとに同意の有無を選択できます。', + 'title' => 'クッキー使用案内', + 'description' => '本サイトはユーザー体験の向上と分析のためクッキーを使用します。項目ごとに同意の有無を選択できます。', 'accept_all' => 'すべてに同意', - 'reject_continue' => '同意しないで続ける', + 'reject_continue' => '同意せずに続行', 'preferences' => '選択同意', 'save' => '選択を保存', 'policy_link' => 'プライバシーポリシー', 'preferences_title' => 'クッキー環境設定', - 'preferences_description' => 'カテゴリー別のクッキー使用を個別に同意・取消できます。必須カテゴリーはオフにできません。', + 'preferences_description' => 'カテゴリ別クッキー使用について個別に同意・撤回できます。必須カテゴリはオフにできません。', 'category_required_badge' => '必須', 'view_policy_label' => '変更されたポリシー本文を表示', ], diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/frontend/ja.json index 359f2fd7..c3bfeb2c 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/frontend/ja.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/frontend/ja.json @@ -15,14 +15,14 @@ "consent": { "granted": "同意が保存されました。", "granted_again": "同意が更新されました。", - "rejected_saved": "同意しないで続行します。選択項目は使用されません。", - "revoked": "同意が撤回されました。", + "rejected_saved": "同意せずに続行します。選択項目は使用されません。", + "revoked": "同意が取り消されました。", "renew_all_success": "アクティブな項目 {renewed} 個を新しいポリシーバージョンに更新しました。", "granting": "同意中...", - "revoking": "撤回中...", + "revoking": "取り消し中...", "renewing": "更新中...", "already_same_state": "既に同じ状態です。", - "required_cannot_revoke": "必須項目は撤回できません。", + "required_cannot_revoke": "必須項目は取り消せません。", "invalid_key": "無効な同意項目です。", "category_cookie_necessary": "必須クッキー", "category_cookie_functional": "機能クッキー", @@ -30,10 +30,10 @@ "category_cookie_marketing": "マーケティングクッキー" }, "blocked_domains": { - "invalid_format": "正しいドメイン形式ではありません。(FQDNのみ対応、*.example.com ワイルドカード可能)", - "invalid_format_functional": "機能カテゴリ — 正しいドメイン形式ではありません。(FQDNのみ対応、*.example.com ワイルドカード可能)", - "invalid_format_analytics": "分析カテゴリ — 正しいドメイン形式ではありません。(FQDNのみ対応、*.example.com ワイルドカード可能)", - "invalid_format_marketing": "マーケティングカテゴリ — 正しいドメイン形式ではありません。(FQDNのみ対応、*.example.com ワイルドカード可能)", + "invalid_format": "正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com ワイルドカード可能)", + "invalid_format_functional": "機能カテゴリ — 正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com ワイルドカード可能)", + "invalid_format_analytics": "分析カテゴリ — 正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com ワイルドカード可能)", + "invalid_format_marketing": "マーケティングカテゴリ — 正しいドメイン形式ではありません。(FQDN のみサポート、*.example.com ワイルドカード可能)", "too_long": "ドメインは253文字を超えることはできません。", "too_long_functional": "機能カテゴリ — ドメインは253文字を超えることはできません。", "too_long_analytics": "分析カテゴリ — ドメインは253文字を超えることはできません。", @@ -43,17 +43,17 @@ "settings": { "saved": "設定が保存されました。", "title": "GDPR設定", - "description": "クッキー同意バナーと本人ページ同意管理カードの動作を運営者が制御します。", + "description": "クッキー同意バナーとマイページ同意管理カードの動作を運営者が制御します。", "section": { - "operator": "運営主体·プライバシーポリシー", - "operator_desc": "クッキーバナー·ポリシーページに表示される運営主体、データ保管位置、ポリシーページスラッグを登録します。", + "operator": "運営主体/プライバシーポリシー", + "operator_desc": "クッキーバナー・ポリシーページに表示される運営主体、データ保管位置、ポリシーページスラッグを登録します。", "cookie_banner": "クッキー同意バナー", - "cookie_banner_desc": "訪問者に表示されるクッキー同意バナーの表示/位置/カテゴリ/ポリシーバージョンを一か所で設定します。", + "cookie_banner_desc": "訪問者に表示されるクッキー同意バナーの表示/位置/カテゴリ/ポリシーバージョンを一箇所で設定します。", "cookie_banner_settings": "バナー表示設定", "cookie_categories": "クッキーカテゴリ", "cookie_policy_version": "クッキーポリシーバージョン", "auto_blocking": "自動ブロックポリシー", - "auto_blocking_desc": "クッキーバナーが表示されると、カテゴリ別ドメインリストの外部追跡リソースがユーザーの同意前まで自動ブロックされます。" + "auto_blocking_desc": "クッキーバナーが表示されると、カテゴリ別ドメインリストの外部追跡リソースはユーザーの同意前まで自動ブロックされます。" }, "nav": { "operator": "運営情報", @@ -69,16 +69,16 @@ "fields": { "cookie_policy_version": { "label": "ポリシーバージョン", - "hint": "ポリシー本文、カテゴリの意味、委託者·データ保管情報が変わった場合は「+ 新規バージョン公開」をクリックしてください。公開直後にすべての会員が次回訪問時に再同意を進めます。" + "hint": "ポリシー本文、カテゴリの意味、受託者・データ保管情報が変わったら「+ 新しいバージョンを公開」を押してください。公開直後にすべての会員が次回訪問時に再同意を進めます。" }, "privacy_policy_slug": { "label": "プライバシーポリシーページスラッグ", - "hint": "ページプラグインに登録された処理方針ページのスラッグ。空白の場合、クッキーバナーのポリシーリンクが表示されません。", + "hint": "ページプラグインに登録された処理方針ページのスラッグ。空の場合、クッキーバナーのポリシーリンクは表示されません。", "placeholder": "例: privacy-policy" }, "legal_entity_name": { - "label": "法人名·運営主体", - "hint": "クッキーバナーと本人ページに表示されるサイト運営主体の名前です。", + "label": "法人名/運営主体", + "hint": "クッキーバナーとマイページに表示されるサイト運営主体の名前です。", "placeholder": "例: (株)会社名" }, "data_storage_location": { @@ -88,7 +88,7 @@ }, "banner_enabled": { "label": "クッキーバナー表示", - "hint": "有効化時にクッキーバナー表示、同意前外部追跡自動ブロック、本人ページ同意管理カードが一緒に開始されます。" + "hint": "有効化時、クッキーバナーの表示と同意前の外部追跡自動ブロックが一緒に開始されます。マイページ同意管理カードはこのトグルと無関係に、同意/取消履歴がある会員に常に表示されます。" }, "banner_position": { "label": "バナー位置", @@ -99,75 +99,76 @@ "option_centered_modal": "中央モーダル" }, "blocked_domains": { - "warnings_title": "案内", - "auto_blocking_off_hint": "自動ブロックはクッキーバナー表示がONの時のみ動作します。「クッキーバナー」カードでクッキーバナー表示トグルをまず有効化してください。", - "warning_visual_break": "外部フォント·スタイルシートは分類に注意してください。誤ってブロックするとページが静かに壊れる可能性があります。", + "warnings_title": "ご案内", + "auto_blocking_off_hint": "自動ブロックはクッキーバナー表示が ON の場合のみ動作します。「クッキーバナー」カードでクッキーバナー表示トグルを先に有効化してください。", + "warning_visual_break": "外部フォント・スタイルシートは分類に注意してください。誤ってブロックするとページが静かに崩れる可能性があります。", "warning_policy_bump": "ブロックドメインを変更した後、クッキーポリシーバージョンを上げてください。既存の同意者が新しいドメインにも同意したものとして処理される問題を防ぎます。", - "warning_domain_format": "ドメイン形式: example.com、*.example.com (ワイルドカード) 可能。localhostのように点がない名前や韓国語ドメインはサポートされていません。", - "tag_input_placeholder": "ドメイン入力後、Enter キー (例: google-analytics.com、*.hotjar.com)", - "tag_no_options": "推奨ドメインがありません。直接入力してください。", + "warning_domain_format": "ドメイン形式: example.com、*.example.com (ワイルドカード) 可能。localhost のようにドットのない名前やハングルドメインはサポートしていません。", + "warning_scope_limit": "この自動ブロックはプラグイン・モジュールが登録するスクリプトのみが対象です。「テンプレート外部スクリプト」など、運営者が直接 head 領域に挿入した外部スクリプトはこの自動ブロックの保護範囲外なので、別途ロード条件を確認してください。", + "tag_input_placeholder": "ドメイン入力後 Enter (例: google-analytics.com、*.hotjar.com)", + "tag_no_options": "推奨ドメインはありません。直接入力してください。", "category": { "functional": "機能カテゴリブロックドメイン", - "analytics": "分析カテゴリブロックドメイン", - "marketing": "マーケティング カテゴリ 차단 도메인" + "analytics": "分析カテゴリ ブロック対象ドメイン", + "marketing": "マーケティングカテゴリ ブロック対象ドメイン" } }, "cookie_categories": { - "description": "クッキー カテゴリ 4種の用途と차단 도구 例は各カードの【情報を展開】で確認できます。", + "description": "クッキーカテゴリ4種の用途とブロック機能の例は、各カードの【情報を展開】で確認できます。", "category_required_badge": "必須", "category_optional_badge": "選択", "info": { "expand": "情報を展開", "collapse": "情報を閉じる", - "description_label": "ユーザー案内文句", - "scope_label": "自動차단 対象", - "tools_label": "代表 ツール例", + "description_label": "ユーザーへの案内文", + "scope_label": "自動ブロック対象", + "tools_label": "代表的なツール例", "necessary": { - "scope": "自動차단しません。セッション·ログイン トークン、カート識別子、ユーザーが会員登録 時に選択した言語 設定、クッキー 同意 記録など サイト動作に必ず必要な項目は常に許可されます。", - "tools": "セッション ID、認証 トークン、CSRF トークン、カート識別子、多言語 設定など(別途設定 不要)" + "scope": "自動ブロックしません。セッション·ログイントークン、カート識別子、ユーザーが登録時に選択した言語設定、クッキー同意記録など、サイト動作に不可欠な項目は常に許可されます。", + "tools": "セッションID、認証トークン、CSRFトークン、カート識別子、多言語設定など(別途設定不要)" }, "functional": { - "scope": "「自動차단 ポリシー」タブの機能 カテゴリ ドメイン リストに登録された外部リソースが同意前まで自動차断されます。また ダークモード·通貨 選好のような ユーザー 利便性 保存も同意後にのみ保管されます。", - "tools": "顧客サポート チャットボット(Crisp、Intercom、Tawk.to)、多言語 自動翻訳 ウィジェット、ユーザー設定 同期 サービスなど" + "scope": "「自動ブロックポリシー」タブの機能カテゴリドメインリストに登録された外部リソースが、同意前までブロックされます。また、ダークモード·通貨選好などのユーザー利便設定も、同意後のみ保存されます。", + "tools": "顧客サポートチャットボット(Crisp、Intercom、Tawk.to)、多言語自動翻訳ウィジェット、ユーザー設定同期サービスなど" }, "analytics": { - "scope": "「自動차단 ポリシー」タブの分析 カテゴリ ドメイン リストに登録された外部分析 スクリプト·埋め込み·トラッキング ピクセルが同意前まで自動차단されます。同意すると即座に復元されて作動します。", - "tools": "Google Analytics 4(GA4)、Hotjar、Microsoft Clarity、Mixpanel、Matomo、ネイバー プレミアム ログ分析など" + "scope": "「自動ブロックポリシー」タブの分析カテゴリドメインリストに登録された外部分析スクリプト·埋め込み·トラッキングピクセルが、同意前までブロックされます。同意すると即座に復元され、機能します。", + "tools": "Google Analytics 4(GA4)、Hotjar、Microsoft Clarity、Mixpanel、Matomo、Naver プレミアムログ分析など" }, "marketing": { - "scope": "「自動차단 ポリシー」タブのマーケティング カテゴリ ドメイン リストに登録された外部広告·埋め込み·トラッキング ピクセルが同意前まで自動차단されます。同意すると即座に復元されて作動します。", - "tools": "Meta ピクセル(Facebook)、Google 広告 リマーケティング、カカオ ピクセル、YouTube 動画 埋め込みなど" + "scope": "「自動ブロックポリシー」タブのマーケティングカテゴリドメインリストに登録された外部広告·埋め込み·トラッキングピクセルが、同意前までブロックされます。同意すると即座に復元され、機能します。", + "tools": "Meta Pixel(Facebook)、Google 広告リマーケティング、カカオピクセル、YouTube動画埋め込みなど" } } } }, "save_success": "設定が保存されました。", - "save_error": "設定 保存 中 エラーが発生しました。", - "validation_error": "入力値を確認してください。", - "unsaved_changes": "保存されていない変更事項があります。", + "save_error": "設定の保存中にエラーが発生しました。", + "validation_error": "入力値をご確認ください。", + "unsaved_changes": "保存されていない変更があります。", "policy_version": { - "current_label": "現在バージョン", - "published_at_label": "公開 日時", + "current_label": "現在のバージョン", + "published_at_label": "公開日時", "publisher_label": "公開者", "publisher_system": "システム", - "memo_label": "変更 理由", - "publish_button": "新 バージョン 公開", - "history_toggle_show": "履歴を見る", + "memo_label": "変更理由", + "publish_button": "新バージョンを公開", + "history_toggle_show": "履歴を表示", "history_toggle_hide": "履歴を閉じる", - "history_empty": "公開された ポリシー バージョンがありません。", - "publish_success": "新しい ポリシー バージョンが公開されました。すべての会員が次回 訪問時に再同意を進めます。", - "publishing": "公開 中...", - "guide_title": "いつ新しい バージョンを公開しますか?", - "guide_body": "차断 ドメイン 変更、UI 文句 修正のような運用上の変更は新しい バージョン 公開が必要ではありません。", + "history_empty": "公開されたポリシーバージョンがありません。", + "publish_success": "新しいポリシーバージョンが公開されました。すべての会員は次回訪問時に再同意を行います。", + "publishing": "公開中...", + "guide_title": "いつ新バージョンを公開しますか?", + "guide_body": "ブロック対象ドメインの変更、UI文句の修正などの運用上の変更は、新バージョンの公開は不要です。", "change_type": { - "material": "再同意 トリガー", - "non_material": "単純 変更", - "initial": "初期 公開" + "material": "再同意トリガー", + "non_material": "単純な変更", + "initial": "初回公開" }, "history": { "col": { "version": "バージョン", - "created_at": "公開 日時", + "created_at": "公開日時", "publisher": "公開者", "change_type": "種類", "memo": "理由", @@ -176,50 +177,50 @@ }, "snapshot_view_short": "表示", "material_modal": { - "title": "新 ポリシー バージョン 公開 + 再同意 トリガー", - "body": "「新 バージョン 公開」を押すと即座に公開され、すべての ユーザーが次回 訪問時に再同意する必要があります。", - "effect_bump": "ポリシー バージョン 単調 増加(例:v2 → v3)", - "effect_reconsent": "すべての会員が次回 訪問時に再同意 画面を見ることになります", - "effect_history": "履歴 表に新しい バージョン row が追加されます", - "memo_label": "変更 理由(必須)", - "memo_placeholder": "例:規約 ページ 本文 修正/委託 運営者 変更", - "confirm": "新 バージョン 公開" + "title": "新ポリシーバージョンの公開 + 再同意トリガー", + "body": "【新バージョンを公開】をクリックすると、即座に公開され、すべてのユーザーが次回訪問時に再同意する必要があります。", + "effect_bump": "ポリシーバージョンが単調増加(例:v2 → v3)", + "effect_reconsent": "すべての会員が次回訪問時に再同意画面を見ることになります", + "effect_history": "履歴テーブルに新バージョン行が追加されます", + "memo_label": "変更理由(必須)", + "memo_placeholder": "例:規約ページ本文の修正/受託運営者の変更", + "confirm": "新バージョンを公開" }, "snapshot_modal": { - "editor_label": "ポリシー バージョン 本文を見る", - "title": "ポリシー バージョン v{version} 本文", - "description": "公開 時点 settings のカテゴリ リスト·ポリシー 本文 スラッグ·차단 ドメインなど全体 本文です。DPO が同意 紛争時に立証 資料として活用します。", - "section_categories": "クッキー カテゴリ", - "section_privacy_policy": "ポリシー 本文 スラッグ", - "section_blocked_domains": "차단 ドメイン カタログ", + "editor_label": "ポリシーバージョン本文を表示", + "title": "ポリシーバージョンv{version}本文", + "description": "公開時点での settings のカテゴリリスト·ポリシー本文スラッグ·ブロック対象ドメインなど全体の本文です。DPO が同意紛争時に立証資料として活用します。", + "section_categories": "クッキーカテゴリ", + "section_privacy_policy": "ポリシー本文スラッグ", + "section_blocked_domains": "ブロック対象ドメインカタログ", "category_required_badge": "必須", "no_categories": "登録されたカテゴリがありません。", - "no_blocked_domains": "登録された차단 ドメインがありません。", - "view_button": "v{version} 本文を見る", + "no_blocked_domains": "登録されたブロック対象ドメインがありません。", + "view_button": "v{version}本文を表示", "close": "閉じる", - "open_policy_page": "本文 ページを開く", - "no_policy_slug": "登録されたポリシー 本文 ページがありません。" + "open_policy_page": "本文ページを開く", + "no_policy_slug": "登録されたポリシー本文ページがありません。" }, - "not_found": "該当するポリシー バージョンが見つかりません。" + "not_found": "そのポリシーバージョンが見つかりません。" } }, "admin": { "consent_log": { - "title": "GDPR 同意 履歴", - "description": "会員·ゲストの同意/撤回 変更 履歴を照会します。IP·User-Agent などの監査 情報が合わせて露出されます。", + "title": "GDPR 同意履歴", + "description": "会員·ゲストの同意/取消の変更履歴を照会します。IP·User-Agent など監査情報も併せて表示されます。", "refresh": "更新", - "empty": "照会された同意 履歴がありません。", - "clear_filters": "フィルター 初期化", + "empty": "照会された同意履歴がありません。", + "clear_filters": "フィルターを初期化", "guest_label": "ゲスト", - "view_member_tooltip": "会員 詳細 ページへ移動", - "search_placeholder": "メールアドレス または セッション ID で検索", + "view_member_tooltip": "会員詳細ページへ移動", + "search_placeholder": "メールアドレスまたはセッションID で検索", "search_button": "検索", "search_type": { - "email": "メール", - "session_id": "セッション ID" + "email": "メールアドレス", + "session_id": "セッションID" }, "filter": { - "consent_key_label": "同意 項目", + "consent_key_label": "同意項目", "action_label": "アクション", "source_label": "出典", "option_all": "すべて" @@ -244,10 +245,10 @@ "ip": "IP" }, "detail": { - "session_id_label": "セッション ID", + "session_id_label": "セッションID", "user_agent_label": "User-Agent", - "categories_label": "カテゴリースナップショット", - "categories_hint": "同意時点で会員がカテゴリー別に表示した意思です。ポリシー本文が更新されてもこの記録は不変として保存されます。", + "categories_label": "カテゴリスナップショット", + "categories_hint": "同意時点に会員がカテゴリ別に表示した意思です。ポリシー本文が更新されても、この記録は不変として保存されます。", "snapshot_granted": "同意", "snapshot_revoked": "未同意" } @@ -256,9 +257,9 @@ "mypage": { "privacy": { "title": "クッキー同意管理", - "description": "サイトが使用中のクッキー項目について同意を変更または撤回できます。", + "description": "サイトが使用しているクッキー項目に対する同意を変更または撤回できます。", "section_consents": "私の同意状況", - "section_consents_hint": "同意/撤回時点に応じて即座に反映されます。必須項目は撤回できません。", + "section_consents_hint": "同意·撤回時点に従って即座に反映されます。必須項目は撤回できません。", "col_consent_key": "同意項目", "col_consented": "ステータス", "col_consented_at": "同意日", @@ -270,29 +271,29 @@ "rejected_label": "拒否", "revoke": "撤回", "grant": "同意", - "grant_again": "再度同意", - "label_suffix": "{label} クッキー", + "grant_again": "改めて同意", + "label_suffix": "{label}クッキー", "locked_label": "必須", "enable_all": "すべてに同意", - "enable_all_hint": "同意していない選択項目があります。一度にすべてに同意できます。", - "enable_all_success": "選択項目にすべて同意しました。", + "enable_all_hint": "同意していない選択項目があります。一度にすべて同意できます。", + "enable_all_success": "選択項目のすべてに同意しました。", "required_label": "必須", "required_hint": "サービス提供に必須のため撤回できません。", "grant_confirm_title": "クッキー同意確認", - "grant_confirm_message": "このクッキーに同意しますか?同意すると該当カテゴリーのクッキーが使用され、いつでも再度撤回できます。", + "grant_confirm_message": "このクッキーに同意いたしますか?同意すると、該当カテゴリのクッキーが使用され、いつでも再度撤回できます。", "revoke_confirm_title": "同意撤回確認", - "revoke_confirm_message": "この項目を撤回しますか?同意を撤回すると該当カテゴリー関連機能が制限される可能性があります。", - "revoke_target_consented_at": "同意 · {date}", - "no_consents": "有効な同意項目がありません。", + "revoke_confirm_message": "この項目を撤回いたしますか?同意を撤回すると、該当カテゴリ関連機能が制限される可能性があります。", + "revoke_target_consented_at": "同意·{date}", + "no_consents": "アクティブな同意項目がありません。", "needs_renewal": "クッキーポリシーが変更されました。同意を更新してください。", - "needs_renewal_with_version": "クッキーポリシーがバージョン {version} に変更されました。選択項目の同意を再度確認してください。", - "needs_renewal_hint": "必須クッキーはサイト運営に必ず必要な項目のためポリシー変更と無関係に常に適用されます。", + "needs_renewal_with_version": "クッキーポリシーがバージョン{version}に変更されました。選択項目の同意を改めてご確認ください。", + "needs_renewal_hint": "必須クッキーはサイト運営に不可欠な項目のため、ポリシー変更に関わらず常に適用されます。", "btn_renew_all": "現在の同意更新", "btn_renew_this_item": "ポリシー更新", - "renew_all_success": "有効項目 {renewed} 個を新しいポリシーバージョンに更新しました。", - "operator_and_storage_meta": "本サイトは {entity} が運営しており、データは {location} に保存されます", - "operator_only_meta": "本サイトは {entity} が運営しています", - "storage_only_meta": "ユーザーデータは {location} に保存されます" + "renew_all_success": "アクティブな項目{renewed}個を新しいポリシーバージョンに更新しました。", + "operator_and_storage_meta": "本サイトは{entity}が運営しており、データは{location}に保存されます", + "operator_only_meta": "本サイトは{entity}が運営しています", + "storage_only_meta": "ユーザーデータは{location}に保存されます" } }, "common": { @@ -300,22 +301,22 @@ "refresh": "更新" }, "banner": { - "re_appear_reason": "クッキーポリシーがバージョン {version} に変更されたため同意を再度確認します。", + "re_appear_reason": "クッキーポリシーがバージョン{version}に変更されたため、同意を改めてご確認します。", "consent_required_hint": "拒否した項目に関連する機能が制限される可能性があります。", "btn_renew_consent": "現在の同意更新", - "title": "クッキー使用のお知らせ", - "description": "本サイトはユーザー体験の改善と分析のためクッキーを使用しています。項目ごとに同意の有無を選択できます。", + "title": "クッキー使用案内", + "description": "本サイトはユーザーエクスペリエンスの向上と分析のためクッキーを使用します。項目別に同意の有無を選択できます。", "accept_all": "すべてに同意", - "reject_continue": "同意せずに続行", + "reject_continue": "同意せずに続ける", "preferences": "選択同意", - "save": "選択を保存", + "save": "選択保存", "policy_link": "プライバシーポリシー", "preferences_title": "クッキー環境設定", - "preferences_description": "カテゴリー別クッキー使用を個別に同意/撤回できます。必須カテゴリーはオフにできません。", + "preferences_description": "カテゴリ別クッキー使用を個別に同意·撤回できます。必須カテゴリはオフにできません。", "category_required_badge": "必須", "view_policy_label": "変更されたポリシー本文を表示", - "operator_and_storage_line": "本サイトは {entity} が運営しており、データは {location} に保存されます", - "operator_only_line": "本サイトは {entity} が運営しています", - "storage_only_line": "ユーザーデータは {location} に保存されます" + "operator_and_storage_line": "本サイトは{entity}が運営しており、データは{location}に保存されます", + "operator_only_line": "本サイトは{entity}が運営しています", + "storage_only_line": "ユーザーデータは{location}に保存されます" } } diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/language-pack.json index 799fe5b4..393526fc 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/language-pack.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 plugin (sirsoft-gdpr) Japanese language pack (bundled)", "ja": "G7 プラグイン (sirsoft-gdpr) 日本語 言語パック(バンドル)" }, - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "scope": "plugin", "target_identifier": "sirsoft-gdpr", diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/seed/manifest.json b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/seed/manifest.json index ce39d6b7..b5768f0b 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/seed/manifest.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/seed/manifest.json @@ -1,4 +1,4 @@ { - "name": "GDPR (一般データ保護規則)", - "description": "クッキー同意バナー、自動ブロック、同意履歴保存、マイページ同意撤回を提供するGDPR対応プラグインです。" + "name": "GDPR(一般データ保護規則)", + "description": "クッキー同意バナー、自動ブロック、同意履歴の保存、マイページでの同意取消を提供するGDPR対応プラグインです。" } diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/seed/roles.json b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/seed/roles.json index 9b4c5c61..fd997546 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/seed/roles.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/seed/roles.json @@ -1,6 +1,6 @@ { "sirsoft-gdpr.privacy": { - "name": "個人情報管理者", - "description": "GDPR ドメイン運用権限 (設定·同意履歴閲覧)" + "name": "個人情報運営者", + "description": "GDPRドメイン運用権限(設定・同意履歴閲覧)" } } diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md index 76f88488..8908bc1b 100644 --- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md @@ -12,6 +12,7 @@ - 거부한 이력을 동의·철회와 구분해 관리자 「GDPR 동의 이력」에서 별도 배지와 [거부] 필터로 확인할 수 있습니다. - 거부 후 다시 동의하면 상태가 동의로 갱신되며, 거부·동의 이력은 모두 보존됩니다. - 마이페이지 「쿠키 동의 관리」에 「모두 동의」 버튼 추가 — 동의하지 않은 선택 항목을 한 번에 동의할 수 있습니다. +- 자동 차단 정책 안내에 보호 범위 한계를 명시했습니다 — 이 자동 차단은 플러그인·모듈이 등록한 스크립트만 대상이며, 운영자가 템플릿 외부 스크립트 등으로 head 영역에 직접 삽입한 외부 스크립트는 보호 범위 밖임을 안내합니다. ### Changed @@ -30,6 +31,8 @@ - 관리자 환경설정의 저장/취소 버튼을 다른 관리자 화면과 동일하게 화면 하단 고정 위치로 옮기고, 상단 탭 영역의 불필요한 구분선과 좌우 여백 어긋남을 바로잡았습니다. - 관리자 환경설정 「정책 버전 보기」에서 본문 페이지 링크를 클릭하면 페이지를 찾을 수 없던 문제를 바로잡았습니다. - 관리자 환경설정의 「운영 주체」·「쿠키 배너 노출」·「배너 위치」 필드에서 안내 문구가 길어질 때 입력·토글 컨트롤이 옆이 아닌 아래 줄로 밀려나던 문제를 바로잡았습니다. +- 비회원(게스트) 방문자의 쿠키 동의 식별자가 위·변조에 노출되던 문제를 바로잡았습니다 — 다른 방문자의 쿠키 동의 상태를 임의로 조회·변경할 수 없도록 서명을 적용했습니다. 이 조치로 이번 업데이트 이전에 발급된 비회원 쿠키 배너 상태는 1회에 한해 초기화되며, 이미 배너를 닫았던 비회원 방문자는 다음 방문 시 배너를 다시 보게 됩니다(회원 동의 이력은 영향 없음). +- 비회원으로 쿠키 배너를 닫은 뒤 동의 이력이 없는 다른 계정으로 로그인하면 배너가 계속 숨겨져 있던 문제를 바로잡았습니다 — 이제 로그인한 계정 기준으로 다시 판단해 배너가 정상적으로 노출됩니다. ## [1.0.1] - 2026-07-16 diff --git a/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md b/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md index e214db83..2ddb868f 100644 --- a/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md +++ b/plugins/_bundled/sirsoft-gdpr/docs/api/consent.md @@ -65,7 +65,7 @@ _대표 에러 없음 (공개 조회). -**설명** 공개 쿠키 동의 배너에서 방문자가 선택한 카테고리별 동의를 저장합니다. `optional.sanctum` 라우트로 게스트와 회원 모두 호출할 수 있으며, 회원(sanctum 토큰 보유)이면 user_id 기준으로 status를 upsert하고 history를 남기고, 게스트면 session_id 기준으로 history를 기록합니다. 게스트가 처음 호출해 세션 식별자가 없으면 UUID 기반 `gdpr_session` 쿠키(1년, SameSite=Lax)를 응답에 발급해 첨부합니다. 동의 철회 시 실제 쿠키 파기는 이 엔드포인트가 아니라 클라이언트 정리기와 후속 응답의 CookieConsentMiddleware가 담당합니다. +**설명** 공개 쿠키 동의 배너에서 방문자가 선택한 카테고리별 동의를 저장합니다. `optional.sanctum` 라우트로 게스트와 회원 모두 호출할 수 있으며, 회원(sanctum 토큰 보유)이면 user_id 기준으로 status를 upsert하고 history를 남기고, 게스트면 session_id 기준으로 history를 기록합니다. 게스트가 처음 호출해 세션 식별자가 없으면 UUID에 HMAC-SHA256 서명을 붙인(`{uuid}|{서명}`) `gdpr_session` 쿠키(1년, SameSite=Lax)를 응답에 발급해 첨부합니다 — 서명은 위조 방지 목적이며, 요청으로 들어온 값의 서명이 유효하지 않으면 신원 불명(새 게스트)으로 처리합니다. 동의 철회 시 실제 쿠키 파기는 이 엔드포인트가 아니라 클라이언트 정리기와 후속 응답의 CookieConsentMiddleware가 담당합니다. ### GET /api/plugins/sirsoft-gdpr/consent/cookie/status diff --git a/plugins/_bundled/sirsoft-gdpr/docs/api/settings.md b/plugins/_bundled/sirsoft-gdpr/docs/api/settings.md index ac265354..3138dd9b 100644 --- a/plugins/_bundled/sirsoft-gdpr/docs/api/settings.md +++ b/plugins/_bundled/sirsoft-gdpr/docs/api/settings.md @@ -188,6 +188,8 @@ Content-Type: application/json +**참고** 위 표에 정의되지 않은 요청 필드는 검증 대상에서 제외되며(`FormRequest::validated()` 결과만 사용), 저장 로직에 전달되지 않습니다. + **에러 응답** | 상태코드 | 의미 | 발생 조건 | diff --git a/plugins/_bundled/sirsoft-gdpr/plugin.json b/plugins/_bundled/sirsoft-gdpr/plugin.json index 04d9368c..a21d784b 100644 --- a/plugins/_bundled/sirsoft-gdpr/plugin.json +++ b/plugins/_bundled/sirsoft-gdpr/plugin.json @@ -31,6 +31,6 @@ }, "loading": { "strategy": "global", - "priority": 50 + "priority": 0 } } \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-gdpr/resources/extensions/cookie_banner.json b/plugins/_bundled/sirsoft-gdpr/resources/extensions/cookie_banner.json index ad14f89a..30df4f51 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/extensions/cookie_banner.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/extensions/cookie_banner.json @@ -27,8 +27,8 @@ "id": "gdpr_cookie_banner", "type": "basic", "name": "Div", - "comment": "GDPR 쿠키 동의 배너 (W-1) — banner_enabled 토글 기반. 환경설정은 모달 대신 배너 내부 인라인 펼침으로 처리. 동의 완료 여부는 gdprMyConsent (서버) 가 우선이며, 사용자 클릭 직후 즉시 사라지도록 _global.gdprBannerDismissed 도 함께 검사", - "if": "{{gdprPublicSettings?.data?.banner_enabled === true && gdprMyConsent?.data?.has_consented !== true && _global.gdprBannerDismissed !== true}}", + "comment": "GDPR 쿠키 동의 배너 (W-1) — banner_enabled 토글 기반. 환경설정은 모달 대신 배너 내부 인라인 펼침으로 처리. 동의 완료 여부는 gdprMyConsent (서버) 가 우선이며, 사용자 클릭 직후 즉시 사라지도록 _global.gdprBannerDismissedFor 도 함께 검사 — 닫은 시점의 사용자 식별자(uuid 또는 'guest')를 저장해 현재 사용자와 비교. 로그인으로 사용자가 바뀌면(uuid 불일치) 다시 노출되어, 게스트로 닫은 뒤 미동의 계정으로 로그인해도 배너가 계속 숨겨지는 회귀를 방지.", + "if": "{{gdprPublicSettings?.data?.banner_enabled === true && gdprMyConsent?.data?.has_consented !== true && _global.gdprBannerDismissedFor !== (_global.currentUser?.uuid ?? 'guest')}}", "props": { "className": "fixed z-50 {{(gdprPublicSettings?.data?.banner_position === 'bottom_left_popup') ? 'bottom-4 left-4 max-w-md rounded-lg' : ((gdprPublicSettings?.data?.banner_position === 'bottom_right_popup') ? 'bottom-4 right-4 max-w-md rounded-lg' : ((gdprPublicSettings?.data?.banner_position === 'centered_modal') ? 'top-1/2 -translate-y-1/2 inset-x-4 mx-auto max-w-md rounded-lg' : 'bottom-4 left-4 right-4 rounded-lg'))}} bg-white dark:bg-gray-800 shadow-lg border border-gray-200 dark:border-gray-700" }, @@ -178,7 +178,7 @@ "params": { "target": "global", "gdprBannerSubmittingAction": null, - "gdprBannerDismissed": true, + "gdprBannerDismissedFor": "{{_global.currentUser?.uuid ?? 'guest'}}", "gdprPreferencesOpen": false, "gdprConsent": "{{(gdprPublicSettings?.data?.cookie_categories ?? []).reduce((acc, c) => ({ ...acc, [c.key]: c.required === true }), {})}}" } @@ -270,7 +270,7 @@ "target": "/api/plugins/sirsoft-gdpr/consent/renew-all", "params": { "method": "POST" }, "onSuccess": [ - { "handler": "setState", "params": { "target": "global", "gdprBannerSubmittingAction": null, "gdprBannerDismissed": true } }, + { "handler": "setState", "params": { "target": "global", "gdprBannerSubmittingAction": null, "gdprBannerDismissedFor": "{{_global.currentUser?.uuid ?? 'guest'}}" } }, { "handler": "refetchDataSource", "params": { "dataSourceId": "gdprMyConsent" } }, { "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-gdpr.mypage.privacy.renew_all_success|renewed={{response?.data?.renewed ?? 0}}" } } ], @@ -318,7 +318,7 @@ "params": { "target": "global", "gdprBannerSubmittingAction": null, - "gdprBannerDismissed": true, + "gdprBannerDismissedFor": "{{_global.currentUser?.uuid ?? 'guest'}}", "gdprPreferencesOpen": false, "gdprConsent": "{{(gdprPublicSettings?.data?.cookie_categories ?? []).reduce((acc, c) => ({ ...acc, [c.key]: true }), {})}}" } @@ -535,7 +535,7 @@ "params": { "target": "global", "gdprBannerSubmittingAction": null, - "gdprBannerDismissed": true, + "gdprBannerDismissedFor": "{{_global.currentUser?.uuid ?? 'guest'}}", "gdprPreferencesOpen": false } }, diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx index c7b344be..356fd9bb 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx @@ -3,7 +3,7 @@ * * @description * - extension_point: user_global_overlay (sirsoft-basic _user_base 가 사전에 정의) - * - banner_enabled === true && !gdprBannerDismissed 조건 표시 + * - banner_enabled === true && !has_consented && dismissedFor(닫은 주체) !== 현재 사용자 조건 표시 * - 모두 동의 / 필수만 사용 / 환경설정 / 정책 링크 제공 * - 환경설정은 모달이 아니라 배너 내부 인라인 펼침 패널 (gdpr_preferences_panel) * - banner_position 4종 분기 (bottom_bar / bottom_left_popup / bottom_right_popup / centered_modal) @@ -79,17 +79,86 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => { expect(banner).toBeTruthy(); }); - it('banner_enabled + has_consented + dismissed 3중 조건 — 데이터소스 경로는 한 단계 (?.data?.)', () => { + it('banner_enabled + has_consented + dismissedFor 3중 조건 — 데이터소스 경로는 한 단계 (?.data?.)', () => { // 회귀 가드: // 1. ResponseHelper::success($msg, $data) 응답은 데이터소스에서 그대로 보존되므로 단일 객체 응답은 `.data.` 한 단계 경로. // 과거 통합본에서 `?.data?.data?` 두 단계로 작성되어 항상 undefined → if false → 배너 미출력 사고. - // 2. _global.gdprBannerDismissed 만으로는 새로고침 시 휘발 — 서버 조회 (gdprMyConsent.has_consented) 가 우선이며 클릭 직후 즉시 사라지는 효과만 글로벌 상태가 보장. + // 2. _global.gdprBannerDismissedFor 만으로는 새로고침 시 휘발 — 서버 조회 (gdprMyConsent.has_consented) 가 우선이며 클릭 직후 즉시 사라지는 효과만 글로벌 상태가 보장. // 3. needs_renewal=true 일 때도 배너만 노출 (모달 폐기 — 결정 모달/배너 중복 제거). 배너 본문에 사유 안내 + 회원 한정 "현 상태 유지" 액션 통합. + // 4. dismissedFor 는 "누가 닫았는지"(uuid|'guest') 를 저장해 현재 사용자(_global.currentUser?.uuid)와 비교한다. + // boolean(gdprBannerDismissed) 만 저장하던 이전 방식은 게스트로 닫은 뒤 미동의 계정으로 로그인해도 + // 배너가 계속 숨겨지는 회귀가 있었다 (닫은 주체를 구분하지 않았기 때문). expect(banner?.if).toBe( - '{{gdprPublicSettings?.data?.banner_enabled === true && gdprMyConsent?.data?.has_consented !== true && _global.gdprBannerDismissed !== true}}' + "{{gdprPublicSettings?.data?.banner_enabled === true && gdprMyConsent?.data?.has_consented !== true && _global.gdprBannerDismissedFor !== (_global.currentUser?.uuid ?? 'guest')}}" ); }); + /** + * 회귀 가드 — 게스트로 배너를 닫은 뒤 미동의 계정으로 로그인해도 배너가 계속 + * 숨겨지던 결함. banner.if 표현식을 실제 JS 로 평가해 각 시나리오의 최종 + * 노출 여부를 직접 검증한다 (문자열 일치만으로는 실제 평가 결과를 보장하지 못함). + */ + describe('닫은 주체(dismissedFor) vs 현재 사용자 비교 — 실제 평가', () => { + const evaluateBannerIf = ( + gdprMyConsent: { data?: { has_consented?: boolean } } | undefined, + _global: { gdprBannerDismissedFor?: string; currentUser?: { uuid?: string } } + ): boolean => { + const gdprPublicSettings = { data: { banner_enabled: true } }; + const expr = (banner?.if ?? '').replace(/^\{\{/, '').replace(/\}\}$/, ''); + // eslint-disable-next-line no-new-func + const fn = new Function('gdprPublicSettings', 'gdprMyConsent', '_global', `return (${expr});`); + return fn(gdprPublicSettings, gdprMyConsent, _global) as boolean; + }; + + it('게스트로 배너를 닫으면(dismissedFor="guest") 같은 게스트 방문에는 배너가 다시 뜨지 않는다', () => { + const shown = evaluateBannerIf( + { data: { has_consented: false } }, + { gdprBannerDismissedFor: 'guest', currentUser: undefined } + ); + expect(shown).toBe(false); + }); + + it('게스트로 배너를 닫은 뒤 미동의 계정으로 로그인하면 배너가 다시 뜬다 (회귀 재현 케이스)', () => { + const shown = evaluateBannerIf( + { data: { has_consented: false } }, + { gdprBannerDismissedFor: 'guest', currentUser: { uuid: 'user-uuid-1' } } + ); + expect(shown).toBe(true); + }); + + it('회원 A 가 닫은 뒤 같은 세션에서 회원 A 로 계속 조회하면 배너가 다시 뜨지 않는다', () => { + const shown = evaluateBannerIf( + { data: { has_consented: false } }, + { gdprBannerDismissedFor: 'user-uuid-1', currentUser: { uuid: 'user-uuid-1' } } + ); + expect(shown).toBe(false); + }); + + it('회원 A 가 닫은 뒤 (동일 세션에서) 회원 B 로 전환되면 배너가 다시 뜬다', () => { + const shown = evaluateBannerIf( + { data: { has_consented: false } }, + { gdprBannerDismissedFor: 'user-uuid-1', currentUser: { uuid: 'user-uuid-2' } } + ); + expect(shown).toBe(true); + }); + + it('서버가 이미 동의 완료(has_consented=true)로 응답하면 dismissedFor 값과 무관하게 배너를 띄우지 않는다', () => { + const shown = evaluateBannerIf( + { data: { has_consented: true } }, + { gdprBannerDismissedFor: 'guest', currentUser: { uuid: 'user-uuid-1' } } + ); + expect(shown).toBe(false); + }); + + it('dismissedFor 가 아직 설정되지 않은 최초 방문(게스트)에는 배너가 뜬다', () => { + const shown = evaluateBannerIf( + { data: { has_consented: false } }, + { gdprBannerDismissedFor: undefined, currentUser: undefined } + ); + expect(shown).toBe(true); + }); + }); + it('needs_renewal 강제 모달 (gdpr_needs_renewal_modal) 이 더 이상 존재하지 않는다 (결정 — 배너 통합)', () => { // 모달 폐기 회귀 가드: 모달과 배너의 시각적 중복 + UX 혼란 + dark pattern 회피 위해 // 모달 통째 제거하고 배너에 사유 안내 + 회원 한정 "현 상태 유지" 액션 통합. @@ -293,7 +362,7 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => { it('동의 저장 직후 onSuccess 에서 gdprMyConsent 를 refetch (3개 버튼 모두)', () => { // 회귀 가드: 동의 후 새로고침 시 배너 재출력을 막으려면 서버 동의 상태를 다시 조회해야 함. - // setState gdprBannerDismissed=true 만으로는 글로벌 상태가 새로고침 시 휘발됨. + // setState gdprBannerDismissedFor= 만으로는 글로벌 상태가 새로고침 시 휘발됨. const refetchOccurrences = (text.match(/"handler":\s*"refetchDataSource"/g) ?? []).length; expect(refetchOccurrences).toBeGreaterThanOrEqual(3); expect(text).toContain('"dataSourceId": "gdprMyConsent"'); @@ -398,10 +467,10 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => { expect(text).toContain('"disabled": "{{category?.required === true}}"'); }); - it('저장 버튼 클릭 시 POST /consent/cookie + 배너 dismiss + 성공 토스트', () => { + it('저장 버튼 클릭 시 POST /consent/cookie + 배너 dismissedFor 갱신 + 성공 토스트', () => { const text = serializeForSearch(panel); expect(text).toContain('/api/plugins/sirsoft-gdpr/consent/cookie'); - expect(text).toContain('"gdprBannerDismissed": true'); + expect(text).toContain('"gdprBannerDismissedFor": "{{_global.currentUser?.uuid ?? \'guest\'}}"'); expect(text).toContain('"gdprPreferencesOpen": false'); expect(text).toContain('sirsoft-gdpr.consent.granted'); }); diff --git a/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json b/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json index f86d3fa9..2ae96555 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json @@ -104,6 +104,7 @@ "warning_visual_break": "Be careful when classifying external font/stylesheet domains. Misclassification can break the page silently.", "warning_policy_bump": "Bump the cookie policy version after changing the blocked domain list. This prevents existing consenters from being implicitly enrolled into newly added trackers.", "warning_domain_format": "Domain format: example.com, *.example.com (wildcard) are supported. Names without dots (like localhost) and non-ASCII domains are not supported.", + "warning_scope_limit": "Auto-blocking only covers scripts registered by plugins/modules. External scripts inserted directly into the head area by admins (e.g. via Template External Scripts) are outside this protection — verify their load conditions separately.", "tag_input_placeholder": "Type a domain and press Enter (e.g., google-analytics.com, *.hotjar.com)", "tag_no_options": "No suggestions. Type to add.", "category": { diff --git a/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json b/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json index 28d975f4..e286f8fa 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json @@ -104,6 +104,7 @@ "warning_visual_break": "외부 폰트·스타일시트는 분류에 주의하세요. 잘못 차단하면 페이지가 조용히 깨질 수 있습니다.", "warning_policy_bump": "차단 도메인을 변경한 후 쿠키 정책 버전을 올리세요. 기존 동의자가 새 도메인에도 동의한 것으로 처리되는 문제를 막습니다.", "warning_domain_format": "도메인 형식: example.com, *.example.com (와일드카드) 가능. localhost 처럼 점이 없는 이름이나 한글 도메인은 지원하지 않습니다.", + "warning_scope_limit": "이 자동 차단은 플러그인·모듈이 등록하는 스크립트만 대상입니다. 「템플릿 외부 스크립트」 등 운영자가 직접 head 영역에 삽입한 외부 스크립트는 이 자동 차단의 보호 범위 밖이므로, 별도로 로드 조건을 확인하세요.", "tag_input_placeholder": "도메인 입력 후 Enter (예: google-analytics.com, *.hotjar.com)", "tag_no_options": "추천 도메인이 없습니다. 직접 입력하세요.", "category": { diff --git a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json index 9aab0645..4751ca4d 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json @@ -1668,6 +1668,11 @@ "type": "basic", "name": "Li", "text": "$t:sirsoft-gdpr.settings.fields.blocked_domains.warning_domain_format" + }, + { + "type": "basic", + "name": "Li", + "text": "$t:sirsoft-gdpr.settings.fields.blocked_domains.warning_scope_limit" } ] } diff --git a/plugins/_bundled/sirsoft-gdpr/src/Concerns/IssuesGuestSessionCookie.php b/plugins/_bundled/sirsoft-gdpr/src/Concerns/IssuesGuestSessionCookie.php new file mode 100644 index 00000000..9850d90f --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/src/Concerns/IssuesGuestSessionCookie.php @@ -0,0 +1,73 @@ +computeGuestSessionSignature($sessionId); + } + + /** + * 쿠키 값에서 서명을 검증하고 원본 session_id 를 반환합니다. + * + * 서명이 없거나 위조된 값은 신뢰하지 않고 null 을 반환합니다 + * (호출자는 미식별 게스트로 취급해야 함). + * + * @param string|null $cookieValue 요청 쿠키 원본 값 + * @return string|null 검증된 session_id, 위조/형식 오류 시 null + */ + protected function verifyGuestSessionId(?string $cookieValue): ?string + { + if (! is_string($cookieValue) || $cookieValue === '') { + return null; + } + + $parts = explode('|', $cookieValue, 2); + if (count($parts) !== 2) { + return null; + } + + [$sessionId, $signature] = $parts; + + if ($sessionId === '' || ! ctype_xdigit($signature) || strlen($signature) !== 64) { + return null; + } + + if (! hash_equals($this->computeGuestSessionSignature($sessionId), $signature)) { + return null; + } + + return substr($sessionId, 0, 100); + } + + /** + * session_id 에 대한 HMAC-SHA256 서명을 계산합니다. + * + * @param string $sessionId 게스트 세션 식별자 + * @return string 64자 hex 서명 + */ + private function computeGuestSessionSignature(string $sessionId): string + { + return hash_hmac('sha256', $sessionId, (string) config('app.key', '')); + } +} diff --git a/plugins/_bundled/sirsoft-gdpr/src/Console/Commands/PlaywrightSeedGdprGuestLogin.php b/plugins/_bundled/sirsoft-gdpr/src/Console/Commands/PlaywrightSeedGdprGuestLogin.php new file mode 100644 index 00000000..cf72c671 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/src/Console/Commands/PlaywrightSeedGdprGuestLogin.php @@ -0,0 +1,106 @@ +error('CLI 전용 커맨드입니다.'); + + return self::FAILURE; + } + + // ② 명시 옵트인 + if (env('G7_PLAYWRIGHT_BYPASS') !== '1') { + $this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다.'); + + return self::FAILURE; + } + + // ③ APP_DEBUG 강제 + Config::set('app.debug', true); + + // 배너 자체가 꺼져 있으면 재현이 불가능하므로 명시적으로 켠다. + $pluginSettings->save('sirsoft-gdpr', ['banner_enabled' => true]); + + // 게스트 세션: 서명된 쿠키 값을 발급하고, 동일 session_id 로 "모두 동의" 이력을 기록한다. + $guestSessionId = (string) Str::uuid(); + $signedCookieValue = $this->signGuestSessionId($guestSessionId); + $consentService->updateConsents( + null, + $guestSessionId, + ['cookie_necessary' => true, 'cookie_functional' => true, 'cookie_analytics' => true, 'cookie_marketing' => true], + 'banner' + ); + + // 로그인 대상 회원: 동의 이력이 전혀 없는 신규 계정. + $email = 'e2e-gdpr-guest-login-'.uniqid().'@example.test'; + $member = User::factory()->create([ + 'email' => $email, + 'password' => bcrypt(self::MEMBER_PASSWORD), + ]); + + $result = [ + 'guest_session_cookie_value' => $signedCookieValue, + 'member_email' => $member->email, + 'member_password' => self::MEMBER_PASSWORD, + ]; + + if ($this->option('json')) { + $this->line(json_encode($result, JSON_UNESCAPED_UNICODE)); + } else { + $this->info('GDPR 게스트→로그인 시나리오 시드 완료: '.$member->email); + } + + return self::SUCCESS; + } +} \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Admin/GdprAdminSettingsController.php b/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Admin/GdprAdminSettingsController.php index 48bdacaa..dd371c6b 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Admin/GdprAdminSettingsController.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Admin/GdprAdminSettingsController.php @@ -30,8 +30,8 @@ class GdprAdminSettingsController extends AdminBaseController /** * GdprAdminSettingsController 생성자 * - * @param PluginSettingsService $pluginSettings 플러그인 설정 서비스 (코어 — 조회용) - * @param GdprSettingsService $settingsService GDPR 설정 저장 서비스 + * @param PluginSettingsService $pluginSettings 플러그인 설정 서비스 (코어 — 조회용) + * @param GdprSettingsService $settingsService GDPR 설정 저장 서비스 */ public function __construct( private readonly PluginSettingsService $pluginSettings, @@ -62,18 +62,13 @@ class GdprAdminSettingsController extends AdminBaseController * 정책 버전은 자동 발행되지 않습니다. 운영자가 별도로 * 「+ 새 버전 발행」 을 클릭해야 발행됩니다. * - * @param UpdateAdminSettingsRequest $request 검증된 요청 + * @param UpdateAdminSettingsRequest $request 검증된 요청 * @return JsonResponse */ public function update(UpdateAdminSettingsRequest $request): JsonResponse { $validated = $request->validated(); - // 동적 스키마 fallback (validation.md "동적 스키마 기반 FormRequest 패턴") - if (empty($validated)) { - $validated = $request->all(); - } - // 옛 자동 발행 흐름의 change_memo 필드는 더 이상 사용하지 않음 — 정책 버전 발행은 별도 엔드포인트로 일원화 unset($validated['change_memo']); @@ -87,7 +82,7 @@ class GdprAdminSettingsController extends AdminBaseController /** * 응답에서 JSON 필드는 디코드하여 객체/배열로 노출합니다. * - * @param array $settings 설정 배열 + * @param array $settings 설정 배열 * @return array */ private function normalizeJsonFields(array $settings): array diff --git a/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Public/GdprCookieConsentController.php b/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Public/GdprCookieConsentController.php index d73c39be..4a6cb07f 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Public/GdprCookieConsentController.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Http/Controllers/Public/GdprCookieConsentController.php @@ -5,6 +5,7 @@ namespace Plugins\Sirsoft\Gdpr\Http\Controllers\Public; use App\Helpers\ResponseHelper; use App\Http\Controllers\Api\Base\PublicBaseController; use Illuminate\Http\JsonResponse; +use Plugins\Sirsoft\Gdpr\Concerns\IssuesGuestSessionCookie; use Plugins\Sirsoft\Gdpr\Http\Requests\StoreCookieConsentRequest; use Plugins\Sirsoft\Gdpr\Services\GdprConsentService; @@ -18,6 +19,8 @@ use Plugins\Sirsoft\Gdpr\Services\GdprConsentService; */ class GdprCookieConsentController extends PublicBaseController { + use IssuesGuestSessionCookie; + /** * GdprCookieConsentController 생성자 * @@ -120,17 +123,18 @@ class GdprCookieConsentController extends PublicBaseController /** * 게스트 세션 ID를 결정합니다. * - * 클라이언트가 쿠키 또는 헤더로 전달한 session_id가 있으면 사용, - * 없으면 Laravel session ID를 fallback으로 사용합니다. + * 클라이언트가 쿠키로 전달한 session_id는 서명을 검증한 뒤 신뢰합니다 + * (위조된 값은 미식별 게스트로 취급). 쿠키가 없으면 Laravel session ID를 + * fallback으로 사용합니다. * * @param \Illuminate\Http\Request $request 요청 * @return string|null */ private function resolveGuestSessionId($request): ?string { - $cookieValue = $request->cookie('gdpr_session'); - if (is_string($cookieValue) && $cookieValue !== '') { - return substr($cookieValue, 0, 100); + $verified = $this->verifyGuestSessionId($request->cookie('gdpr_session')); + if ($verified !== null) { + return $verified; } try { @@ -156,6 +160,7 @@ class GdprCookieConsentController extends PublicBaseController * 응답에 게스트 세션 쿠키 (gdpr_session) 를 첨부합니다. * * 1년 유효, path=/, SameSite=Lax. HTTPS 환경에서는 Secure 자동 적용. + * 쿠키 값은 HMAC 서명이 붙어 위변조 시 서버가 거부합니다. * * @param JsonResponse $response 응답 * @param string $sessionId 발급된 세션 ID @@ -165,7 +170,7 @@ class GdprCookieConsentController extends PublicBaseController { $response->cookie( 'gdpr_session', - $sessionId, + $this->signGuestSessionId($sessionId), 60 * 24 * 365, '/', null, diff --git a/plugins/_bundled/sirsoft-gdpr/src/Http/Middleware/CookieConsentMiddleware.php b/plugins/_bundled/sirsoft-gdpr/src/Http/Middleware/CookieConsentMiddleware.php index dc617f63..2ee03228 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Http/Middleware/CookieConsentMiddleware.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Http/Middleware/CookieConsentMiddleware.php @@ -5,6 +5,7 @@ namespace Plugins\Sirsoft\Gdpr\Http\Middleware; use Closure; use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Http\Request; +use Plugins\Sirsoft\Gdpr\Concerns\IssuesGuestSessionCookie; use Plugins\Sirsoft\Gdpr\Services\GdprConsentService; use Symfony\Component\HttpFoundation\Response; @@ -25,6 +26,8 @@ use Symfony\Component\HttpFoundation\Response; */ class CookieConsentMiddleware { + use IssuesGuestSessionCookie; + /** * CookieConsentMiddleware 생성자 * @@ -106,6 +109,9 @@ class CookieConsentMiddleware /** * 게스트 세션 식별자를 추출합니다 (gdpr_session cookie 또는 Laravel session ID). * + * 쿠키 값은 서명을 검증한 뒤 신뢰합니다 — 위조된 값은 미식별 게스트로 + * 취급하고 Laravel session ID로 폴백합니다. + * * @param Request $request HTTP 요청 * @return string|null 세션 식별자 (회원이거나 식별 불가 시 null) */ @@ -115,9 +121,9 @@ class CookieConsentMiddleware return null; } - $cookieValue = $request->cookie('gdpr_session'); - if (is_string($cookieValue) && $cookieValue !== '') { - return substr($cookieValue, 0, 100); + $verified = $this->verifyGuestSessionId($request->cookie('gdpr_session')); + if ($verified !== null) { + return $verified; } try { diff --git a/plugins/_bundled/sirsoft-gdpr/src/Providers/GdprServiceProvider.php b/plugins/_bundled/sirsoft-gdpr/src/Providers/GdprServiceProvider.php index b0342c58..fff101c9 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Providers/GdprServiceProvider.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Providers/GdprServiceProvider.php @@ -3,6 +3,7 @@ namespace Plugins\Sirsoft\Gdpr\Providers; use App\Extension\BasePluginServiceProvider; +use Plugins\Sirsoft\Gdpr\Console\Commands\PlaywrightSeedGdprGuestLogin; use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprPolicyVersionRepositoryInterface; use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentHistoryRepositoryInterface; use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentRepositoryInterface; @@ -27,4 +28,15 @@ class GdprServiceProvider extends BasePluginServiceProvider GdprUserConsentHistoryRepositoryInterface::class => GdprUserConsentHistoryRepository::class, GdprPolicyVersionRepositoryInterface::class => GdprPolicyVersionRepository::class, ]; + + public function boot(): void + { + parent::boot(); + + if ($this->app->runningInConsole()) { + $this->commands([ + PlaywrightSeedGdprGuestLogin::class, + ]); + } + } } diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Public/GdprCookieConsentControllerTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Public/GdprCookieConsentControllerTest.php index 6d973e3a..4a579200 100644 --- a/plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Public/GdprCookieConsentControllerTest.php +++ b/plugins/_bundled/sirsoft-gdpr/tests/Feature/Api/Public/GdprCookieConsentControllerTest.php @@ -153,11 +153,32 @@ class GdprCookieConsentControllerTest extends PluginTestCase '신규 게스트 session_id 는 UUID v4 형식이어야 함' ); - // 응답에 gdpr_session 쿠키가 첨부되어야 함 + // 응답에 gdpr_session 쿠키가 첨부되어야 함 — 값은 위조 방지를 위해 HMAC 서명이 붙어 + // "{session_id}|{signature}" 형식이므로 session_id 는 접두 일치로 확인한다. $cookies = collect($response->headers->getCookies()) ->firstWhere(fn ($c) => $c->getName() === 'gdpr_session'); $this->assertNotNull($cookies, '게스트 신규 동의 시 gdpr_session 쿠키가 발급되어야 함'); - $this->assertSame($sessionId, $cookies->getValue()); + $this->assertStringStartsWith($sessionId.'|', (string) $cookies->getValue()); + } + + /** + * 회귀 가드: gdpr_session 쿠키 값을 임의로 조작해 보내면 서버가 그 값을 신뢰하지 않고 + * 새로운(다른) session_id 로 취급해야 한다 — 서명 없는 값을 그대로 신뢰하던 결함 회귀 방지. + */ + public function test_tampered_gdpr_session_cookie_is_not_trusted(): void + { + $this->mockSettings(['cookie_policy_version' => '1.0']); + + $forgedSessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + + $response = $this->withCookie('gdpr_session', $forgedSessionId) + ->postJson('/api/plugins/sirsoft-gdpr/consent/cookie', [ + 'consents' => ['cookie_necessary' => true, 'cookie_analytics' => true], + 'source' => 'banner', + ]); + + $response->assertOk(); + $this->assertNotSame($forgedSessionId, $response->json('data.session_id'), '서명 없는 위조 session_id 는 신뢰되면 안 됨'); } public function test_guest_consent_persists_via_service_with_session_id(): void diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Playwright/fixtures/gdpr-guest-login-seed.ts b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/fixtures/gdpr-guest-login-seed.ts new file mode 100644 index 00000000..57914c05 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/fixtures/gdpr-guest-login-seed.ts @@ -0,0 +1,43 @@ +/** + * GDPR "게스트로 배너 닫음 → 다른 계정 로그인" 시나리오 도메인 시드 fixture. + * + * `playwright:seed-gdpr-guest-login` artisan 커맨드를 호출하여 다음을 발급받는다: + * - 서명된 게스트 세션 쿠키 값 (이미 "모두 동의" 이력이 기록된 상태) + * - 동의 이력이 전혀 없는 신규 회원 계정 (email/password 고정, 로그인 폼에 실제 입력) + */ +import { test as base } from '@playwright/test'; +import { execSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// ESM 환경(package.json "type": "module")에서는 __dirname 미정의 → import.meta.url 로 재구성. +const __dirname = dirname(fileURLToPath(import.meta.url)); + +type GdprGuestLoginSeedFixtures = { + /** 게스트→로그인 배너 재노출 시나리오용 시드 데이터 */ + gdprGuestLoginSeed: { + guest_session_cookie_value: string; + member_email: string; + member_password: string; + }; +}; + +export const test = base.extend({ + gdprGuestLoginSeed: async ({}, use) => { + // 6단계 상위 = 코어 루트 (artisan 실행 cwd) + const coreRoot = process.env.G7_ROOT || resolve(__dirname, '../../../../../../'); + const out = execSync('php artisan playwright:seed-gdpr-guest-login --json', { + cwd: coreRoot, + encoding: 'utf-8', + env: { + ...process.env, + G7_PLAYWRIGHT_BYPASS: '1', + }, + }); + const jsonLine = out.trim().split(/\r?\n/).filter((l) => l.trim().startsWith('{')).pop() ?? '{}'; + const seed = JSON.parse(jsonLine); + await use(seed); + }, +}); + +export { expect } from '@playwright/test'; \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/public/cookie-banner-guest-login-reappear.spec.ts b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/public/cookie-banner-guest-login-reappear.spec.ts new file mode 100644 index 00000000..ba1aa209 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/public/cookie-banner-guest-login-reappear.spec.ts @@ -0,0 +1,60 @@ +/** + * E2E: 게스트로 쿠키 배너를 닫은 뒤 동의 이력 없는 계정으로 로그인 시 배너 재노출 회귀 가드 + * + * @scenario cookie_banner_guest_dismiss_then_login_reappear + * @effects banner_hidden_for_consented_guest, banner_reappears_after_login_with_no_consent_history + * + * 배경: `_global.gdprBannerDismissedFor` 가 "닫힘 여부"(boolean)가 아니라 "누가 닫았는지" + * (사용자 식별자)를 저장하도록 수정되기 전에는, 게스트로 배너를 닫은 뒤 동의 이력이 없는 + * 다른 계정으로 로그인해도 배너가 계속 숨겨져 있는 결함이 있었다 (PO 가 스크린샷으로 재현·지적). + * 현재는 cookie_banner.json 의 노출 조건이 `_global.gdprBannerDismissedFor !== (사용자 식별자)` + * 로 비교하므로, 로그인으로 사용자가 바뀌면(게스트 uuid → 회원 uuid) 다시 노출되어야 한다. + * + * 검증: + * 1. 서명된 "모두 동의" 게스트 세션 쿠키를 심은 상태로 홈에 진입하면 배너가 노출되지 않는다 + * (게스트 자신은 이미 동의했으므로 정상) + * 2. 로그인 폼으로 동의 이력이 전혀 없는 신규 계정에 실제로 로그인하면 배너가 다시 노출된다 + * (회귀 재현 케이스 — 수정 전에는 여기서 실패) + */ +import { test, expect } from '../../fixtures/gdpr-guest-login-seed'; + +const BANNER = '#gdpr_cookie_banner'; + +test('#509 보안검토 - 게스트로 동의 완료 후 미동의 계정으로 로그인하면 배너가 다시 노출된다', async ({ page, gdprGuestLoginSeed }) => { + // 먼저 baseURL 로 진입해 현재 오리진을 확보한 뒤(하드코딩 회피), 서명된 게스트 세션 + // 쿠키를 심는다 (이미 "모두 동의" 이력이 기록된 상태). + await page.goto('/'); + await page.context().addCookies([ + { + name: 'gdpr_session', + value: gdprGuestLoginSeed.guest_session_cookie_value, + url: page.url(), + path: '/', + }, + ]); + + await page.reload(); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + // 이미 동의한 게스트이므로 배너가 노출되지 않아야 한다. + await expect(page.locator(BANNER)).not.toBeVisible({ timeout: 15_000 }); + + // 2. 로그인 폼으로 동의 이력이 전혀 없는 신규 계정에 실제로 로그인한다. + await page.goto('/login'); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + const emailInput = page.locator('input[name="email"]').first(); + await expect(emailInput).toBeVisible({ timeout: 15_000 }); + await emailInput.fill(gdprGuestLoginSeed.member_email); + + const passwordInput = page.locator('input[name="password"]').first(); + await passwordInput.fill(gdprGuestLoginSeed.member_password); + + await page.locator('button[type="submit"]').first().click(); + + // 로그인 성공 후 리다이렉트 대기 (로그인 페이지를 벗어남). + await expect(page).not.toHaveURL(/\/login/, { timeout: 20_000 }); + + // 회귀 가드: 동의 이력 없는 계정으로 전환됐으므로 배너가 다시 노출되어야 한다. + await expect(page.locator(BANNER)).toBeVisible({ timeout: 15_000 }); +}); \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Unit/Concerns/IssuesGuestSessionCookieTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Concerns/IssuesGuestSessionCookieTest.php new file mode 100644 index 00000000..827492fd --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Concerns/IssuesGuestSessionCookieTest.php @@ -0,0 +1,93 @@ +signGuestSessionId($sessionId); + } + + public function verify(?string $cookieValue): ?string + { + return $this->verifyGuestSessionId($cookieValue); + } + }; + } + + public function test_signed_cookie_value_round_trips_to_original_session_id(): void + { + $subject = $this->subject(); + $sessionId = '11111111-2222-3333-4444-555555555555'; + + $signed = $subject->sign($sessionId); + $verified = $subject->verify($signed); + + $this->assertSame($sessionId, $verified); + } + + public function test_tampered_session_id_with_stale_signature_is_rejected(): void + { + $subject = $this->subject(); + $signed = $subject->sign('11111111-2222-3333-4444-555555555555'); + + [, $signature] = explode('|', $signed, 2); + $tampered = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee|'.$signature; + + $this->assertNull($subject->verify($tampered)); + } + + public function test_arbitrary_unsigned_value_is_rejected(): void + { + $subject = $this->subject(); + + $this->assertNull($subject->verify('just-a-random-string')); + } + + public function test_empty_or_null_value_is_rejected(): void + { + $subject = $this->subject(); + + $this->assertNull($subject->verify(null)); + $this->assertNull($subject->verify('')); + } + + public function test_malformed_signature_format_is_rejected(): void + { + $subject = $this->subject(); + + // 서명 길이가 64자 hex 가 아님 + $this->assertNull($subject->verify('11111111-2222-3333-4444-555555555555|not-a-valid-signature')); + } + + public function test_signature_computed_with_different_app_key_is_rejected(): void + { + $subject = $this->subject(); + $sessionId = '11111111-2222-3333-4444-555555555555'; + + config(['app.key' => 'base64:'.base64_encode(random_bytes(32))]); + $signed = $subject->sign($sessionId); + + config(['app.key' => 'base64:'.base64_encode(random_bytes(32))]); + + $this->assertNull($subject->verify($signed)); + } +} From 11d3cc64453b7419d8888c36f1f92924ab85926b Mon Sep 17 00:00:00 2001 From: chym1217 Date: Wed, 29 Jul 2026 16:52:55 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix(sirsoft-gdpr):=20E2E=20=EC=8B=9C?= =?UTF-8?q?=EB=93=9C=20=EC=BB=A4=EB=A7=A8=EB=93=9C=20=EB=B3=B4=EC=95=88?= =?UTF-8?q?=C2=B7=EB=A9=B1=EB=93=B1=EC=84=B1=20=EA=B2=B0=ED=95=A8=202?= =?UTF-8?q?=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 생성 직전 마지막 점검으로 변경분 전체를 라인 단위 재검토한 결과, 직전 커밋에서 신규 작성한 게스트→로그인 배너 재노출 E2E 시드 커맨드 (PlaywrightSeedGdprGuestLogin)에서 2건을 발견해 수정한다. - 로그인 테스트 계정 비밀번호가 코드에 고정 문자열로 박혀 있어, 실수로 프로덕션 DB에서 실행되면 알려진 비밀번호로 로그인 가능한 계정이 남는 위험이 있었다. 실행마다 Str::random(32)로 무작위 생성하도록 변경. - 이메일/게스트 세션 ID를 매번 새로 발급해 재실행할 때마다 계정과 동의 이력이 계속 누적됐다. 고정값으로 바꾸고 재실행 시 기존 데이터를 먼저 삭제하도록 변경(sirsoft-page 모듈의 선삭제 패턴과 동일). 2회 연속 실행 후 DB 조회로 누적되지 않음을 확인. 부수: cookie_banner.json 의 배너 재노출 조건에 "회원 로그인 후 로그아웃" 케이스가 회귀 테스트로 고정되어 있지 않아 추가 (로직 자체는 기존에도 정상 동작 확인됨 — 커버리지 보강). --- .../__tests__/layouts/cookie_banner.test.tsx | 10 +++++++ .../Commands/PlaywrightSeedGdprGuestLogin.php | 27 ++++++++++--------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx index 356fd9bb..a6069948 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/cookie_banner.test.tsx @@ -142,6 +142,16 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => { expect(shown).toBe(true); }); + it('회원이 닫은 뒤 로그아웃하면(게스트로 전환) 배너가 다시 뜬다', () => { + // currentUser 가 undefined 로 바뀌므로 식별자는 fallback 'guest' 로 평가된다. + // dismissedFor 는 이전 회원 uuid 로 남아있어 'guest' 와 불일치 → 재노출(안전측 동작). + const shown = evaluateBannerIf( + { data: { has_consented: false } }, + { gdprBannerDismissedFor: 'user-uuid-1', currentUser: undefined } + ); + expect(shown).toBe(true); + }); + it('서버가 이미 동의 완료(has_consented=true)로 응답하면 dismissedFor 값과 무관하게 배너를 띄우지 않는다', () => { const shown = evaluateBannerIf( { data: { has_consented: true } }, diff --git a/plugins/_bundled/sirsoft-gdpr/src/Console/Commands/PlaywrightSeedGdprGuestLogin.php b/plugins/_bundled/sirsoft-gdpr/src/Console/Commands/PlaywrightSeedGdprGuestLogin.php index cf72c671..e1027525 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Console/Commands/PlaywrightSeedGdprGuestLogin.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Console/Commands/PlaywrightSeedGdprGuestLogin.php @@ -8,6 +8,7 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\Config; use Illuminate\Support\Str; use Plugins\Sirsoft\Gdpr\Concerns\IssuesGuestSessionCookie; +use Plugins\Sirsoft\Gdpr\Models\GdprUserConsentHistory; use Plugins\Sirsoft\Gdpr\Services\GdprConsentService; /** @@ -33,11 +34,6 @@ class PlaywrightSeedGdprGuestLogin extends Command { use IssuesGuestSessionCookie; - /** - * 시나리오에서 로그인 폼에 실제로 입력할 고정 비밀번호. - */ - private const MEMBER_PASSWORD = 'e2e-test-password-1234'; - protected $signature = 'playwright:seed-gdpr-guest-login {--json : 결과를 JSON 으로 출력}'; @@ -72,8 +68,10 @@ class PlaywrightSeedGdprGuestLogin extends Command // 배너 자체가 꺼져 있으면 재현이 불가능하므로 명시적으로 켠다. $pluginSettings->save('sirsoft-gdpr', ['banner_enabled' => true]); - // 게스트 세션: 서명된 쿠키 값을 발급하고, 동일 session_id 로 "모두 동의" 이력을 기록한다. - $guestSessionId = (string) Str::uuid(); + // 게스트 세션: 고정 session_id 사용 — 재실행 시 기존 이력을 먼저 삭제해 + // gdpr_user_consent_histories 가 무한히 누적되지 않도록 한다(멱등성 보장). + $guestSessionId = 'e2e-gdpr-guest-login-fixed-session'; + GdprUserConsentHistory::where('session_id', $guestSessionId)->delete(); $signedCookieValue = $this->signGuestSessionId($guestSessionId); $consentService->updateConsents( null, @@ -82,17 +80,22 @@ class PlaywrightSeedGdprGuestLogin extends Command 'banner' ); - // 로그인 대상 회원: 동의 이력이 전혀 없는 신규 계정. - $email = 'e2e-gdpr-guest-login-'.uniqid().'@example.test'; + // 로그인 대상 회원: 동의 이력이 전혀 없는 신규 계정. 고정 이메일 사용 — 재실행 시 + // 기존 계정을 먼저 삭제해 계정이 무한히 누적되지 않도록 한다(멱등성 보장). + // 비밀번호는 실행마다 무작위 생성 — 고정값이 코드에 남아있으면 실수로 프로덕션 DB + // 에서 실행됐을 때 알려진 비밀번호로 로그인 가능한 계정이 남는다. + $email = 'e2e-gdpr-guest-login@example.test'; + User::where('email', $email)->delete(); + $plainPassword = Str::random(32); $member = User::factory()->create([ 'email' => $email, - 'password' => bcrypt(self::MEMBER_PASSWORD), + 'password' => bcrypt($plainPassword), ]); $result = [ 'guest_session_cookie_value' => $signedCookieValue, 'member_email' => $member->email, - 'member_password' => self::MEMBER_PASSWORD, + 'member_password' => $plainPassword, ]; if ($this->option('json')) { @@ -103,4 +106,4 @@ class PlaywrightSeedGdprGuestLogin extends Command return self::SUCCESS; } -} \ No newline at end of file +} From 4d50fa2cdec9e138f0438ed1a787e3223c7f67dd Mon Sep 17 00:00:00 2001 From: chym1217 Date: Wed, 29 Jul 2026 18:08:09 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix(sirsoft-gdpr):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=84=A4=EC=A0=95=20=EC=B9=B4=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20=EA=B2=BD=EA=B3=A0=20=EC=83=89=EC=83=81=20=EC=98=A4?= =?UTF-8?q?=EC=9A=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 가 GDPR 관리자 설정 화면 스크린샷에서 카드 제목만 다른 확장과 달리 빨간색으로 표시되는 것을 발견해 지시했다. 「운영 주체」·「쿠키 동의 배너」·「자동 차단 정책」 카드 제목과 차단 도메인 안내 소제목이 에러/경고 전용 시맨틱(text-error-strong, red 계열)을 잘못 사용하고 있었다. 일반 카드 제목이므로 다른 정상 카드와 동일한 중립 톤(section-heading-md)으로 교체. 원래 develop 에 이미 있던 기존 결함이며 다른 확장(marketing, pay_kginicis 등)에도 동일 패턴이 있으나, 이번엔 GDPR 플러그인 범위로 한정해 수정한다. 기존 Vitest 회귀 가드가 이 색상 버그를 "의도된 설계"로 잘못 확정해두고 있어 함께 뒤집었고, 실제 브라우저에서 색상을 검증하는 E2E 테스트도 추가했다(computed style RGB 값 검사). --- plugins/_bundled/sirsoft-gdpr/CHANGELOG.md | 1 + .../layouts/admin_plugin_settings.test.tsx | 40 ++++++++++++++++--- .../layouts/admin/plugin_settings.json | 8 ++-- .../policy-version-snapshot-link.spec.ts | 27 ++++++++++++- 4 files changed, 66 insertions(+), 10 deletions(-) diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md index 8908bc1b..11705fd0 100644 --- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md @@ -33,6 +33,7 @@ - 관리자 환경설정의 「운영 주체」·「쿠키 배너 노출」·「배너 위치」 필드에서 안내 문구가 길어질 때 입력·토글 컨트롤이 옆이 아닌 아래 줄로 밀려나던 문제를 바로잡았습니다. - 비회원(게스트) 방문자의 쿠키 동의 식별자가 위·변조에 노출되던 문제를 바로잡았습니다 — 다른 방문자의 쿠키 동의 상태를 임의로 조회·변경할 수 없도록 서명을 적용했습니다. 이 조치로 이번 업데이트 이전에 발급된 비회원 쿠키 배너 상태는 1회에 한해 초기화되며, 이미 배너를 닫았던 비회원 방문자는 다음 방문 시 배너를 다시 보게 됩니다(회원 동의 이력은 영향 없음). - 비회원으로 쿠키 배너를 닫은 뒤 동의 이력이 없는 다른 계정으로 로그인하면 배너가 계속 숨겨져 있던 문제를 바로잡았습니다 — 이제 로그인한 계정 기준으로 다시 판단해 배너가 정상적으로 노출됩니다. +- 관리자 환경설정의 「운영 주체」·「쿠키 동의 배너」·「자동 차단 정책」 카드 제목과 차단 도메인 안내 문구가 빨간색(경고 색상)으로 표시되던 문제를 바로잡았습니다 — 일반 안내에도 경고 색상이 쓰여 어색하게 강조되던 부분을 다른 관리자 화면과 동일한 색상으로 정리했습니다. ## [1.0.1] - 2026-07-16 diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx index 54c239f4..e5fb9dc0 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx @@ -144,9 +144,37 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au expect(className).toContain('grid-3col-responsive'); }); - it('section-heading-md 는 더 이상 필드 2단 배치 컨테이너로 오용되지 않는다 (텍스트 스타일 전용 클래스)', () => { - const layoutJson = JSON.stringify(root); - expect(layoutJson).not.toContain('"className":"section-heading-md"'); + it('section-heading-md 는 필드 2단 배치 컨테이너로 오용되지 않는다 (해당 4개 필드 컨테이너 한정 검증)', () => { + // 카드 제목(H3)은 section-heading-md 를 정당하게 사용하므로(카드 제목 색상 + // 수정 회귀 가드 참조), 전체 레이아웃 문자열이 아니라 필드 컨테이너 4개만 검증한다. + for (const fieldId of ['field_legal_entity_name', 'field_data_storage_location', 'field_banner_enabled', 'field_banner_position']) { + const field = findById(root, fieldId); + const className = String((field?.props as { className?: string } | undefined)?.className ?? ''); + expect(className).not.toBe('section-heading-md'); + } + }); + }); + + /** + * 카드 제목(H3) 색상 회귀 가드 — text-error-strong(에러/경고 전용 시맨틱, red 계열)이 + * 일반 카드 제목에 오용되어 "운영 주체", "쿠키 동의 배너", "자동 차단 정책" 제목이 빨간색으로 + * 표시되던 결함. 정상 카드 제목 색상은 section-heading-md(중립 톤). + */ + describe('카드 제목 색상 — text-error-strong 오용 회귀 가드', () => { + it.each(['card_operator_header', 'card_cookie_banner_header', 'card_auto_blocking_policy_header'])( + '%s 의 H3 제목이 section-heading-md 를 사용한다 (에러 색상 아님)', + (headerId) => { + const header = findById(root, headerId); + const json = JSON.stringify(header); + expect(json).toContain('"className":"section-heading-md"'); + expect(json).not.toContain('text-error-strong'); + } + ); + + it('차단 도메인 안내 박스(정보 톤)의 소제목도 에러 색상을 사용하지 않는다', () => { + const box = findById(root, 'blocked_domains_warnings_box'); + const json = JSON.stringify(box); + expect(json).not.toContain('text-error-strong'); }); }); @@ -350,8 +378,10 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au expect(serialized).not.toContain('blocked_domains.preblocker_active'); expect(serialized).not.toContain('blocked_domains.self_hosted_attr'); expect(serialized).not.toContain('blocked_domains.static_html_limitation'); - // 타이틀 강조 — 시맨틱 text-error-strong 로 제목 강조 (#399 시맨틱 통일) - expect(serialized).toContain('text-error-strong'); + // 타이틀 — 안내 박스(정보 톤, circle-info)의 소제목이므로 에러 강조색이 아닌 중립 톤 사용 + // (text-error-strong 은 에러/경고 전용 시맨틱이며 일반 안내 제목에 오용되던 결함을 바로잡음) + expect(serialized).not.toContain('text-error-strong'); + expect(serialized).toContain('text-gray-900'); // 옛 badge 키는 lang 과 박스에서 모두 제거됨 expect(serialized).not.toContain('blocked_domains.warnings_badge'); // 기존 옛 항목 키 미사용 diff --git a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json index 4751ca4d..9edbe227 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json @@ -259,7 +259,7 @@ "type": "basic", "name": "H3", "props": { - "className": "text-error-strong" + "className": "section-heading-md" }, "text": "$t:sirsoft-gdpr.settings.section.operator" }, @@ -502,7 +502,7 @@ "type": "basic", "name": "H3", "props": { - "className": "text-error-strong" + "className": "section-heading-md" }, "text": "$t:sirsoft-gdpr.settings.section.cookie_banner" }, @@ -1541,7 +1541,7 @@ "type": "basic", "name": "H3", "props": { - "className": "text-error-strong" + "className": "section-heading-md" }, "text": "$t:sirsoft-gdpr.settings.section.auto_blocking" }, @@ -1641,7 +1641,7 @@ "type": "basic", "name": "H4", "props": { - "className": "text-error-strong" + "className": "text-sm font-medium text-gray-900 dark:text-white" }, "text": "$t:sirsoft-gdpr.settings.fields.blocked_domains.warnings_title" } diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/policy-version-snapshot-link.spec.ts b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/policy-version-snapshot-link.spec.ts index 73927d64..8f707a5f 100644 --- a/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/policy-version-snapshot-link.spec.ts +++ b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/policy-version-snapshot-link.spec.ts @@ -1,8 +1,9 @@ /** * E2E: 관리자 환경설정 > 쿠키 배너 > 정책 버전 > 보기 — 본문 페이지 링크 (#509 체크리스트 12번) + * + 관리자 환경설정 카드 제목 색상 회귀 가드 (PO 스크린샷 발견) * * @scenario admin_gdpr_policy_version_snapshot_open_policy_page_link - * @effects snapshot_modal_opens, policy_page_link_href_matches_page_module_url_pattern + * @effects snapshot_modal_opens, policy_page_link_href_matches_page_module_url_pattern, card_title_not_error_color * * 배경: 정책 버전 snapshot 모달의 본문 페이지 링크가 `/{{slug}}` 로 조합되어 페이지 모듈 * (sirsoft-page) 의 실제 URL 패턴 `/page/{slug}` 와 불일치, 클릭 시 404 가 발생하던 결함. @@ -10,16 +11,22 @@ * 포함되므로 (GdprPolicyVersionMigrationSmokeTest 참조), 별도 설정 저장 없이도 v배지 클릭 → * 모달의 본문 페이지 링크 href 를 검증할 수 있다. * + * 또한 카드 제목(H3)이 에러/경고 전용 시맨틱(text-error-strong, red 계열)을 오용해 + * "운영 주체" 등 일반 카드 제목이 빨간색으로 표시되던 결함을 PO 가 스크린샷으로 발견해 + * section-heading-md(중립 톤)로 수정 — 실제 렌더링된 색상을 브라우저에서 검증한다. + * * 검증: * 1. 환경설정 화면에서 v배지 옆 "본문 보기" 버튼 클릭 시 snapshot 모달이 열린다 * 2. 모달의 본문 페이지 링크 href 가 `/page/{slug}` 형식으로 조합되어 페이지 모듈 URL 패턴과 일치한다 * (프리픽스 누락으로 `/{slug}` 형태가 되는 결함 패턴이 재발하지 않았는지 회귀 가드) + * 3. 「운영 주체 / 개인정보처리방침」 카드 제목이 에러 색상(red 계열)이 아닌 중립 톤으로 렌더링된다 */ import { test, expect, authenticatePage } from '../../fixtures/gdpr-auth'; const CARD_COOKIE_BANNER = '#card_cookie_banner'; const SNAPSHOT_VIEW_BUTTON = '#policy_version_current_snapshot_view_button'; const OPEN_POLICY_PAGE_LINK = '#policy_version_snapshot_open_policy_page_link'; +const CARD_OPERATOR_HEADER = '#card_operator_header h3'; /** 관리자 GDPR 환경설정 진입 후 쿠키 배너 탭(정책 버전 UI 포함)으로 스크롤 이동 */ async function gotoGdprSettings(page: import('@playwright/test').Page): Promise { @@ -51,3 +58,21 @@ test('#509 - 정책 버전 snapshot 모달의 본문 페이지 링크가 /page/{ expect(href).toMatch(/^\/page\/.+/); expect(href).not.toMatch(/^\/(?!page\/)/); }); + +// @scenario tab=card_operator, permitted=yes +// @effects card_title_not_error_color +test('#509 - 관리자 환경설정 카드 제목이 에러 색상(빨간색)으로 표시되지 않는다', async ({ page, privacyManageToken }) => { + await authenticatePage(page, privacyManageToken); + await gotoGdprSettings(page); + + const header = page.locator(CARD_OPERATOR_HEADER); + await expect(header).toBeVisible({ timeout: 10_000 }); + + const color = await header.evaluate((el) => getComputedStyle(el).color); + // text-error-strong(text-red-800/dark:text-red-200) 이 남아있다면 rgb 값의 R 채널이 + // G/B 채널보다 뚜렷이 높은 붉은 계열로 계산된다. 정상(중립 톤)은 R≈G≈B. + const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + expect(rgbMatch).not.toBeNull(); + const [, r, g, b] = rgbMatch as unknown as [string, string, string, string]; + expect(Number(r) - Math.max(Number(g), Number(b))).toBeLessThan(40); +}); From ac57df93bd44da781da9253f4180b7b927d4385b Mon Sep 17 00:00:00 2001 From: chym1217 Date: Thu, 30 Jul 2026 10:41:45 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix(sirsoft-gdpr):=20HMAC=20=EC=84=9C?= =?UTF-8?q?=EB=AA=85=20=EB=A7=8C=EB=A3=8C=EC=8B=9C=EA=B0=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20+=20sequence=20handler=20=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 전 마지막 재검토에서 남겨뒀던 두 항목을 처리한다. - 게스트 세션 쿠키 HMAC 서명에 만료시각을 추가했다. 결제 플러그인 (IssuesReceiptCookie)과 동일하게 값 형식을 {uuid}|{만료시각}|{서명} 3단 구조로 바꾸고, 서명 자체가 만료시각도 함께 해시하므로 만료시각만 위조해 유효기간을 늘리는 시도도 차단된다. 만료 기간은 쿠키 자체의 브라우저 만료(1년)와 동일하게 맞췄다 — 결제 정보가 아닌 쿠키 선호도 식별자라 짧게 잘라 잦은 재발급을 강제할 실익이 없다. - 관리자 설정 화면의 정책 버전 이력 토글 버튼에서 sequence handler의 actions 배열이 params 안에 잘못 중첩되어 있던 걸 발견해 바로잡았다. 엔진(ActionDispatcher.handleSequence)이 두 위치를 모두 인식하는 fallback을 갖고 있어 실제 동작은 정상이었지만, 정석 위치(top-level)로 정리하고 실제 클릭 시 이력 표가 펼쳐지는지 검증하는 E2E를 추가했다. --- plugins/_bundled/sirsoft-gdpr/CHANGELOG.md | 2 +- .../layouts/admin_plugin_settings.test.tsx | 19 +++++++++ .../_policy_version_snapshot_modal.json | 2 + .../layouts/admin/plugin_settings.json | 31 +++++++------- .../src/Concerns/IssuesGuestSessionCookie.php | 41 +++++++++++++------ .../policy-version-snapshot-link.spec.ts | 26 +++++++++++- .../Concerns/IssuesGuestSessionCookieTest.php | 33 ++++++++++++++- 7 files changed, 121 insertions(+), 33 deletions(-) diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md index 11705fd0..29867e42 100644 --- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md @@ -31,7 +31,7 @@ - 관리자 환경설정의 저장/취소 버튼을 다른 관리자 화면과 동일하게 화면 하단 고정 위치로 옮기고, 상단 탭 영역의 불필요한 구분선과 좌우 여백 어긋남을 바로잡았습니다. - 관리자 환경설정 「정책 버전 보기」에서 본문 페이지 링크를 클릭하면 페이지를 찾을 수 없던 문제를 바로잡았습니다. - 관리자 환경설정의 「운영 주체」·「쿠키 배너 노출」·「배너 위치」 필드에서 안내 문구가 길어질 때 입력·토글 컨트롤이 옆이 아닌 아래 줄로 밀려나던 문제를 바로잡았습니다. -- 비회원(게스트) 방문자의 쿠키 동의 식별자가 위·변조에 노출되던 문제를 바로잡았습니다 — 다른 방문자의 쿠키 동의 상태를 임의로 조회·변경할 수 없도록 서명을 적용했습니다. 이 조치로 이번 업데이트 이전에 발급된 비회원 쿠키 배너 상태는 1회에 한해 초기화되며, 이미 배너를 닫았던 비회원 방문자는 다음 방문 시 배너를 다시 보게 됩니다(회원 동의 이력은 영향 없음). +- 비회원(게스트) 방문자의 쿠키 동의 식별자가 위·변조에 노출되던 문제를 바로잡았습니다 — 다른 방문자의 쿠키 동의 상태를 임의로 조회·변경할 수 없도록 서명을 적용하고, 유효기간(쿠키 자체와 동일하게 1년)도 함께 적용했습니다. 이 조치로 이번 업데이트 이전에 발급된 비회원 쿠키 배너 상태는 1회에 한해 초기화되며, 이미 배너를 닫았던 비회원 방문자는 다음 방문 시 배너를 다시 보게 됩니다(회원 동의 이력은 영향 없음). - 비회원으로 쿠키 배너를 닫은 뒤 동의 이력이 없는 다른 계정으로 로그인하면 배너가 계속 숨겨져 있던 문제를 바로잡았습니다 — 이제 로그인한 계정 기준으로 다시 판단해 배너가 정상적으로 노출됩니다. - 관리자 환경설정의 「운영 주체」·「쿠키 동의 배너」·「자동 차단 정책」 카드 제목과 차단 도메인 안내 문구가 빨간색(경고 색상)으로 표시되던 문제를 바로잡았습니다 — 일반 안내에도 경고 색상이 쓰여 어색하게 강조되던 부분을 다른 관리자 화면과 동일한 색상으로 정리했습니다. diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx index e5fb9dc0..178eb42f 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx @@ -562,6 +562,25 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au expect(partials.some((p) => p.includes('_policy_version_history_modal'))).toBe(false); }); + /** + * sequence handler 구조 회귀 가드 — actions 배열이 params 안에 잘못 중첩되어 + * (CLAUDE.md 절대 금지 패턴) 있었던 결함. 엔진(ActionDispatcher.handleSequence)이 + * 두 위치를 모두 인식하는 fallback을 갖고 있어 동작 자체는 정상이었지만, 정석 + * 위치(actions가 액션 객체의 top-level)로 바로잡는다. + */ + it('이력 토글 버튼의 sequence handler 는 actions 를 top-level 에 둔다 (params 중첩 금지)', () => { + const wrapper = findById(root, 'field_cookie_policy_version'); + const json = JSON.stringify(wrapper); + // 정상 패턴: sequence handler 바로 뒤에 "actions" 가 이어짐 (params 를 거치지 않음) + expect(json).toMatch(/"handler":"sequence","actions":\[/); + // 금지 패턴: sequence handler 뒤에 params.actions 로 중첩된 형태가 없어야 한다 + expect(json).not.toMatch(/"handler":"sequence","params":\{"actions":\[/); + }); + + it('이력 표 컨테이너에 policy_version_history_table id 가 부여되어 E2E 로 펼침 여부를 검증할 수 있다', () => { + expect(findById(root, 'policy_version_history_table')).toBeTruthy(); + }); + it('정책 버전 발행 modal (publish modal) partial 이 modals 배열에 등록되어 있다 — 운영자 수동 발행 진입점', () => { const modals = (root as { modals?: Array<{ partial?: string }> }).modals ?? []; const partials = modals.map((m) => m.partial ?? ''); diff --git a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/partials/_shared/_policy_version_snapshot_modal.json b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/partials/_shared/_policy_version_snapshot_modal.json index be293185..609692bc 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/partials/_shared/_policy_version_snapshot_modal.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/partials/_shared/_policy_version_snapshot_modal.json @@ -1,5 +1,7 @@ { "meta": { + "is_partial": true, + "_e2e_allow": "e2e:allow meta.is_partial 플래그만 추가 — 렌더링/동작 변화 없는 레이아웃 개수 집계 정정", "editor_label": "$t:sirsoft-gdpr.settings.policy_version.snapshot_modal.editor_label" }, "id": "policy_version_snapshot_modal", diff --git a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json index 9edbe227..ece7337b 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json @@ -924,23 +924,21 @@ { "type": "click", "handler": "sequence", - "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "local", - "policyHistoryOpen": "{{!_local.policyHistoryOpen}}" - } - }, - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "gdprPolicyVersionHistory" - } + "actions": [ + { + "handler": "setState", + "params": { + "target": "local", + "policyHistoryOpen": "{{!_local.policyHistoryOpen}}" } - ] - } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "gdprPolicyVersionHistory" + } + } + ] } ], "children": [ @@ -964,6 +962,7 @@ ] }, { + "id": "policy_version_history_table", "comment": "이력 표 — collapsible. _local.policyHistoryOpen 일 때만 노출. gdprPolicyVersionHistory 데이터소스 페이지네이션 표시.", "type": "basic", "name": "Div", diff --git a/plugins/_bundled/sirsoft-gdpr/src/Concerns/IssuesGuestSessionCookie.php b/plugins/_bundled/sirsoft-gdpr/src/Concerns/IssuesGuestSessionCookie.php index 9850d90f..c17ac5ca 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Concerns/IssuesGuestSessionCookie.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Concerns/IssuesGuestSessionCookie.php @@ -12,29 +12,39 @@ namespace Plugins\Sirsoft\Gdpr\Concerns; * 게스트 동의 이력을 조회/철회할 수 있으므로, HMAC 서명을 붙여 위조를 차단한다. * * sirsoft-pay_nicepayments 등 결제 플러그인의 IssuesReceiptCookie 트레이트와 - * 동일한 서명 컨벤션(hash_hmac + config('app.key'))을 사용한다. + * 동일한 서명 컨벤션(hash_hmac + config('app.key'))을 사용하며, 만료시각도 서명에 + * 포함해(주문 영수증 쿠키와 동일 구조) 값이 유출되어도 영구히 재사용되지 않게 한다. + * 쿠키 자체의 브라우저 만료(1년)와 동일하게 맞춘다 — 이 쿠키는 결제 정보가 아니라 + * 쿠키 선호도 식별자라 짧게 잘라 잦은 재발급을 강제할 실익이 없다. */ trait IssuesGuestSessionCookie { + /** + * 서명 만료 기간(초). 쿠키 자체의 브라우저 만료(1년, 60*24*365분)와 동일하게 맞춘다. + */ + private const SIGNATURE_TTL_SECONDS = 60 * 60 * 24 * 365; + /** * 쿠키 값에 서명을 붙입니다. * * @param string $sessionId UUID v4 게스트 세션 식별자 - * @return string "{sessionId}|{signature}" 형태의 쿠키 값 + * @return string "{sessionId}|{expiresTs}|{signature}" 형태의 쿠키 값 */ protected function signGuestSessionId(string $sessionId): string { - return $sessionId.'|'.$this->computeGuestSessionSignature($sessionId); + $expiresTs = time() + self::SIGNATURE_TTL_SECONDS; + + return $sessionId.'|'.$expiresTs.'|'.$this->computeGuestSessionSignature($sessionId, $expiresTs); } /** * 쿠키 값에서 서명을 검증하고 원본 session_id 를 반환합니다. * - * 서명이 없거나 위조된 값은 신뢰하지 않고 null 을 반환합니다 + * 서명이 없거나 위조된 값, 만료된 값은 신뢰하지 않고 null 을 반환합니다 * (호출자는 미식별 게스트로 취급해야 함). * * @param string|null $cookieValue 요청 쿠키 원본 값 - * @return string|null 검증된 session_id, 위조/형식 오류 시 null + * @return string|null 검증된 session_id, 위조/형식 오류/만료 시 null */ protected function verifyGuestSessionId(?string $cookieValue): ?string { @@ -42,18 +52,22 @@ trait IssuesGuestSessionCookie return null; } - $parts = explode('|', $cookieValue, 2); - if (count($parts) !== 2) { + $parts = explode('|', $cookieValue, 3); + if (count($parts) !== 3) { return null; } - [$sessionId, $signature] = $parts; + [$sessionId, $expiresTs, $signature] = $parts; - if ($sessionId === '' || ! ctype_xdigit($signature) || strlen($signature) !== 64) { + if ($sessionId === '' || ! ctype_digit($expiresTs) || ! ctype_xdigit($signature) || strlen($signature) !== 64) { return null; } - if (! hash_equals($this->computeGuestSessionSignature($sessionId), $signature)) { + if ((int) $expiresTs < time()) { + return null; + } + + if (! hash_equals($this->computeGuestSessionSignature($sessionId, (int) $expiresTs), $signature)) { return null; } @@ -61,13 +75,14 @@ trait IssuesGuestSessionCookie } /** - * session_id 에 대한 HMAC-SHA256 서명을 계산합니다. + * session_id + 만료시각에 대한 HMAC-SHA256 서명을 계산합니다. * * @param string $sessionId 게스트 세션 식별자 + * @param int $expiresTs 서명 만료 시각(unix timestamp) * @return string 64자 hex 서명 */ - private function computeGuestSessionSignature(string $sessionId): string + private function computeGuestSessionSignature(string $sessionId, int $expiresTs): string { - return hash_hmac('sha256', $sessionId, (string) config('app.key', '')); + return hash_hmac('sha256', $sessionId.'|'.$expiresTs, (string) config('app.key', '')); } } diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/policy-version-snapshot-link.spec.ts b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/policy-version-snapshot-link.spec.ts index 8f707a5f..1dcca1f4 100644 --- a/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/policy-version-snapshot-link.spec.ts +++ b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/policy-version-snapshot-link.spec.ts @@ -1,9 +1,10 @@ /** * E2E: 관리자 환경설정 > 쿠키 배너 > 정책 버전 > 보기 — 본문 페이지 링크 (#509 체크리스트 12번) * + 관리자 환경설정 카드 제목 색상 회귀 가드 (PO 스크린샷 발견) + * + 정책 버전 이력 토글 sequence handler 회귀 가드 * * @scenario admin_gdpr_policy_version_snapshot_open_policy_page_link - * @effects snapshot_modal_opens, policy_page_link_href_matches_page_module_url_pattern, card_title_not_error_color + * @effects snapshot_modal_opens, policy_page_link_href_matches_page_module_url_pattern, card_title_not_error_color, policy_history_toggle_expands_and_refetches * * 배경: 정책 버전 snapshot 모달의 본문 페이지 링크가 `/{{slug}}` 로 조합되어 페이지 모듈 * (sirsoft-page) 의 실제 URL 패턴 `/page/{slug}` 와 불일치, 클릭 시 404 가 발생하던 결함. @@ -15,11 +16,17 @@ * "운영 주체" 등 일반 카드 제목이 빨간색으로 표시되던 결함을 PO 가 스크린샷으로 발견해 * section-heading-md(중립 톤)로 수정 — 실제 렌더링된 색상을 브라우저에서 검증한다. * + * 정책 버전 이력 토글 버튼의 sequence handler 가 actions 를 params 안에 잘못 중첩하고 + * 있던 결함(CLAUDE.md 절대 금지 패턴)도 함께 발견 — 엔진이 두 위치 모두 인식하는 + * fallback을 갖고 있어 동작 자체는 정상이었으나 정석 위치(top-level)로 바로잡았다. + * 실제 클릭 시 이력 표가 펼쳐지는지(=sequence 의 setState 가 실행됐는지)를 검증한다. + * * 검증: * 1. 환경설정 화면에서 v배지 옆 "본문 보기" 버튼 클릭 시 snapshot 모달이 열린다 * 2. 모달의 본문 페이지 링크 href 가 `/page/{slug}` 형식으로 조합되어 페이지 모듈 URL 패턴과 일치한다 * (프리픽스 누락으로 `/{slug}` 형태가 되는 결함 패턴이 재발하지 않았는지 회귀 가드) * 3. 「운영 주체 / 개인정보처리방침」 카드 제목이 에러 색상(red 계열)이 아닌 중립 톤으로 렌더링된다 + * 4. 이력 토글 버튼 클릭 시 이력 표가 펼쳐진다 (sequence 내 setState 정상 실행 확인) */ import { test, expect, authenticatePage } from '../../fixtures/gdpr-auth'; @@ -27,6 +34,7 @@ const CARD_COOKIE_BANNER = '#card_cookie_banner'; const SNAPSHOT_VIEW_BUTTON = '#policy_version_current_snapshot_view_button'; const OPEN_POLICY_PAGE_LINK = '#policy_version_snapshot_open_policy_page_link'; const CARD_OPERATOR_HEADER = '#card_operator_header h3'; +const HISTORY_TOGGLE_BUTTON = '#field_cookie_policy_version button:has-text("이력")'; /** 관리자 GDPR 환경설정 진입 후 쿠키 배너 탭(정책 버전 UI 포함)으로 스크롤 이동 */ async function gotoGdprSettings(page: import('@playwright/test').Page): Promise { @@ -76,3 +84,19 @@ test('#509 - 관리자 환경설정 카드 제목이 에러 색상(빨간색)으 const [, r, g, b] = rgbMatch as unknown as [string, string, string, string]; expect(Number(r) - Math.max(Number(g), Number(b))).toBeLessThan(40); }); + +// @scenario tab=card_cookie_banner, permitted=yes +// @effects policy_history_toggle_expands_and_refetches +test('#509 - 정책 버전 이력 토글 버튼 클릭 시 이력 표가 펼쳐진다 (sequence handler 정상 동작 확인)', async ({ page, privacyManageToken }) => { + await authenticatePage(page, privacyManageToken); + await gotoGdprSettings(page); + + const historyTable = page.locator('#policy_version_history_table'); + await expect(historyTable).toBeHidden(); + + await page.locator(HISTORY_TOGGLE_BUTTON).click(); + + // sequence 의 setState(policyHistoryOpen 토글) 가 실행되어야 이 if 조건이 true 로 + // 바뀌어 이력 표가 렌더된다 — params 안에 잘못 중첩됐던 결함이 재발하면 여기서 실패한다. + await expect(historyTable).toBeVisible({ timeout: 10_000 }); +}); diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Unit/Concerns/IssuesGuestSessionCookieTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Concerns/IssuesGuestSessionCookieTest.php index 827492fd..0c81797a 100644 --- a/plugins/_bundled/sirsoft-gdpr/tests/Unit/Concerns/IssuesGuestSessionCookieTest.php +++ b/plugins/_bundled/sirsoft-gdpr/tests/Unit/Concerns/IssuesGuestSessionCookieTest.php @@ -49,12 +49,41 @@ class IssuesGuestSessionCookieTest extends PluginTestCase $subject = $this->subject(); $signed = $subject->sign('11111111-2222-3333-4444-555555555555'); - [, $signature] = explode('|', $signed, 2); - $tampered = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee|'.$signature; + [, $expiresTs, $signature] = explode('|', $signed, 3); + $tampered = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee|'.$expiresTs.'|'.$signature; $this->assertNull($subject->verify($tampered)); } + public function test_tampered_expiry_with_stale_signature_is_rejected(): void + { + // 만료시각만 늘려서 서명 유효기간을 연장하려는 위조 시도 — 서명이 session_id + // 뿐 아니라 expiresTs 도 함께 서명하므로 expiresTs 변조 시 서명 불일치로 거부. + $subject = $this->subject(); + $signed = $subject->sign('11111111-2222-3333-4444-555555555555'); + + [$sessionId, $expiresTs, $signature] = explode('|', $signed, 3); + $tampered = $sessionId.'|'.((int) $expiresTs + 3600).'|'.$signature; + + $this->assertNull($subject->verify($tampered)); + } + + public function test_expired_signature_is_rejected(): void + { + // 만료시각이 과거인 서명(정상 서명이지만 유효기간 경과)은 거부되어야 한다. + $subject = $this->subject(); + $sessionId = '11111111-2222-3333-4444-555555555555'; + $pastExpiresTs = time() - 1; + + $signMethod = new \ReflectionMethod($subject, 'computeGuestSessionSignature'); + $signMethod->setAccessible(true); + $signature = $signMethod->invoke($subject, $sessionId, $pastExpiresTs); + + $expiredValue = $sessionId.'|'.$pastExpiresTs.'|'.$signature; + + $this->assertNull($subject->verify($expiredValue)); + } + public function test_arbitrary_unsigned_value_is_rejected(): void { $subject = $this->subject();