fix(core,gdpr): 신규 설치 시 확장 설정 기본값 단계의 "Class not found" 중단 수정

- 설치 흐름(installPlugin/installModule)이 파일 복사 직후 그 확장의 PSR-4 경로를
 등록하도록 앞당김 (종전에는 시더 직전에만 등록해 검증·설정 초기화 단계가 사각)
- gdpr: 잠금 집합을 진입 파일 Plugin::lockedNecessaryStorage 가 소유하고
 Support\NecessaryAllowlist::locked 는 위임 — plugin.php 가 src/ 오토로드에 비의존
- 회귀 테스트: EntryFileAutoloadIndependenceTest, ExtensionInstallAutoloadOrderTest
- CHANGELOG 정책 정리: 코어 항목에서 확장·쇼핑몰 문구 제거, 쇼핑몰 증상은
 이커머스 1.2.1 로 이동, gdpr 1.0.4 항목·AGENTS.md 갱신
This commit is contained in:
HeuJung committed 2026-09-06 22:23:15 +09:00
1 parent d8426c5341
commit 7946410ba6
10 files changed
+173 -15

No files matched your search

+9 -3
View File
@@ -7,7 +7,7 @@
```text
1. 유형: 플러그인 (sirsoft-gdpr) — 쿠키 동의 배너·동의 이력·GDPR/개인정보보호법 대응을 소유
2. 확장 방식: `sirsoft-gdpr.consent.granted`/`revoked` 훅 구독, `data-gdpr-category` HTML 속성으로 자체 호스팅 자원 등록
3. 건드리면 안 되는 것: 필수 허용목록의 **잠금 항목**(`Support\NecessaryAllowlist::locked()`), 동의 이력(immutable append-only) 직접 수정
3. 건드리면 안 되는 것: 필수 허용목록의 **잠금 항목**(`Plugin::lockedNecessaryStorage()`, `Support\NecessaryAllowlist::locked()` 가 위임), 동의 이력(immutable append-only) 직접 수정
4. 작업 위치: `plugins/_bundled/sirsoft-gdpr` — 활성 디렉토리 직접 수정 금지
5. 반영: `php artisan plugin:update sirsoft-gdpr --force`
```
@@ -47,6 +47,11 @@ immutable append-only)으로 이중 기록합니다 — 지금 상태 조회와
**잠금 항목만 설정 밖입니다** — `auth_token` · `XSRF-TOKEN` · 세션 쿠키(런타임 해석) ·
`gdpr_session` 넷은 없으면 사이트가 서지 못하므로 코드가 정하고, 판정에는 언제나 운영자 목록과
합집합으로 얹힙니다. 설정에 담으면 저장 요청 한 번으로 지워져 잠금이 아니게 됩니다.
그 정의는 진입 파일 `plugin.php`(`Plugin::lockedNecessaryStorage()`)가 소유하고 `Support\NecessaryAllowlist::locked()` 는
위임만 합니다. `getConfigValues()` 가 신규 설치 흐름에서 이 플러그인의 클래스 경로 등록보다 먼저 호출되기
때문입니다 — 진입 파일이 `src/` 클래스를 부르면 새로 설치하는 사이트에서만 "Class not found" 로 설치가
중단되고, 업그레이드는 기설치본 매핑이 있어 통과합니다.
<!-- @intent END -->
## 2. 디렉토리 지도
@@ -160,7 +165,8 @@ immutable append-only)으로 이중 기록합니다 — 지금 상태 조회와
| 회원탈퇴(`after_withdraw`)에서 신원 정보(user_id 등)를 제거 | 활성 동의만 철회 처리, 신원은 완전삭제(`before_delete`) 시점에만 익명화 | 두 이벤트를 섞으면 탈퇴 회원의 재가입·이력 조회가 깨진다 |
| 운영자가 등록하지 않은 functional 쿠키를 화이트리스트에 추가 | 필수 허용목록(운영자 설정) ∪ 잠금 집합만 예외 | GDPR 은 "동의 전 전면 차단"이 원칙이지 "등록된 것만 차단"이 아니다 |
| 소비자(인터셉터·정리기·미들웨어)가 자기 목록 사본을 들고 판정 | 넷 다 같은 설정을 같은 함수로 읽는다 | 사본은 갈라지는데 그 어긋남은 "그 항목만 안 되는" 상태로만 나타난다 |
| 잠금 항목을 설정(`necessary_storage_allowlist`)에 넣기 | 코드(`NecessaryAllowlist::locked()`)가 정하고 판정에서 합집합 | 설정에 있으면 저장 요청 한 번으로 지워져 잠금이 아니게 된다 |
| 잠금 항목을 설정(`necessary_storage_allowlist`)에 넣기 | 코드(`Plugin::lockedNecessaryStorage()`)가 정하고 판정에서 합집합 | 설정에 있으면 저장 요청 한 번으로 지워져 잠금이 아니게 된다 |
| `plugin.php` 의 `getConfigValues()`/`getSettingsSchema()` 등이 `src/` 클래스를 직접 호출(정적 메서드·`new`·상수) | 값은 진입 파일이 소유하고 `src/` 쪽이 위임한다 (`::class` 참조는 무방) | 신규 설치 흐름은 클래스 경로 등록 전에 진입 파일을 읽어 "Class not found" 로 멈춘다. 업그레이드에서는 재현되지 않아 출시 전 검증을 통과한다 (`tests/Unit/EntryFileAutoloadIndependenceTest.php` 가 잠근다) |
| 세션 쿠키 이름을 `'laravel_session'` 으로 하드코딩 | `config('session.cookie')` 런타임 해석 | `SESSION_COOKIE` 를 지정한 사이트에서 그 항목이 죽고, 그 사실이 어디에도 드러나지 않는다 |
| 새 확장의 저장 키를 플러그인이 관측·수집해 자동 등재 | 운영자가 화면에서 직접 추가 | 저장 키 관측은 그 자체가 추적이다 — 동의 없이 하는 관측을 이 플러그인이 할 수는 없다 |
| 정책 버전 발행을 코드/배치로 자동화 | 운영자가 매번 명시적으로 "+ 새 버전 발행" 클릭 | 자동화하면 사소한 문구 수정에도 전 회원이 재동의 화면을 보게 된다 |
@@ -172,7 +178,7 @@ immutable append-only)으로 이중 기록합니다 — 지금 상태 조회와
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 25개 | `plugins/_bundled/sirsoft-gdpr/tests` |
| PHPUnit | 26개 | `plugins/_bundled/sirsoft-gdpr/tests` |
| Vitest | 13개 | `vitest.config.ts` |
| Playwright | 4개 | `tests/Playwright` |
| 시나리오 매니페스트 | 6개 | `tests/scenarios` |
@@ -14,6 +14,7 @@
- GDPR 설정 화면에 「필수 저장 항목」 카드를 추가했습니다. 기능 쿠키에 동의하지 않은 방문자에게도 저장이 허용되는 항목을 브라우저 저장소·세션 저장소·쿠키로 나누어 직접 편집할 수 있습니다. 새로 설치한 확장이 저장하는 항목이 동의 전에 지워진다면, 이제 이 화면에서 추가하면 됩니다.
- 항목 이름 끝에 `*` 를 붙이면 앞부분이 같은 항목을 모두 포함합니다. 예를 들어 `g7_filters_*` 는 `g7_filters_orders_1` 을 함께 포함합니다.
- 사이트 동작에 반드시 필요한 항목(로그인 토큰, CSRF 토큰, 세션 쿠키, 쿠키 동의 기록)은 「잠금 항목」으로 카드 위쪽에 표시되며 삭제할 수 없습니다.
- 잠금 항목의 정의는 플러그인 진입 파일이 소유합니다. 설정 기본값을 만드는 시점에 다른 클래스를 불러오지 않으므로, 새로 설치하는 사이트에서도 설치가 중단되지 않습니다.
- 이미 설치된 사이트는 업데이트 시 현재 기본 목록이 설정으로 옮겨집니다. 이미 편집한 값이 있으면 그대로 둡니다.
### Fixed
+25 -3
View File
@@ -13,7 +13,6 @@ use Plugins\Sirsoft\Gdpr\Listeners\GdprAuthConsentListener;
use Plugins\Sirsoft\Gdpr\Listeners\GdprAuthLogoutListener;
use Plugins\Sirsoft\Gdpr\Listeners\GdprUserDeleteListener;
use Plugins\Sirsoft\Gdpr\Listeners\GdprUserWithdrawListener;
use Plugins\Sirsoft\Gdpr\Support\NecessaryAllowlist;
/**
* GDPR (일반 데이터 보호 규정) 플러그인
@@ -37,7 +36,7 @@ class Plugin extends AbstractPlugin
*
* 잠금 항목(`auth_token` / `XSRF-TOKEN` / 세션 쿠키 / `gdpr_session`)은 여기 넣지
* 않습니다 — 설정에 담기면 API 로 지울 수 있어 잠금이 아니게 됩니다
* (Support\NecessaryAllowlist::locked()).
* (self::lockedNecessaryStorage()).
*
* @var array<string, array<int, string>>
*/
@@ -458,6 +457,29 @@ class Plugin extends AbstractPlugin
*
* @return array 설정 값 배열
*/
/**
* strictly necessary 잠금 항목 — 운영자가 지울 수 없는 최소 집합.
*
* 설정에 넣지 않는다. 설정에 넣으면 API 로 지울 수 있어 잠금이 아니게 된다.
* 세션 쿠키 이름은 `session.cookie` 가 정하는 런타임 값이라 상수로 둘 수 없다 —
* 이름을 하드코딩하면 `SESSION_COOKIE` 를 지정한 사이트에서 그 항목이 죽는다.
*
* 이 정의는 `src/` 가 아니라 진입 파일에 둔다. `getConfigValues()` 는 신규 설치 흐름에서
* 이 플러그인의 PSR-4 오토로드가 등록되기 전에 호출되므로, `src/` 클래스를 부르면
* "Class not found" 로 설치가 중단된다 (업그레이드 경로는 기설치본 매핑이 있어 재현되지
* 않는다). `Support\NecessaryAllowlist::locked()` 는 이 메서드에 위임한다.
*
* @return array<string, array<int, string>> 스코프 => 항목 배열
*/
public static function lockedNecessaryStorage(): array
{
return [
'localStorage' => ['auth_token'],
'sessionStorage' => [],
'cookie' => ['XSRF-TOKEN', (string) config('session.cookie', 'laravel_session'), 'gdpr_session'],
];
}
public function getConfigValues(): array
{
return [
@@ -498,7 +520,7 @@ class Plugin extends AbstractPlugin
// 잠금 항목 — 설정이 아니라 코드가 정한다. 스키마에 넣지 않으므로 저장 요청에
// 섞여 와도 validated() 에서 배제되어 저장되지 않는다. 화면에는 읽기 전용으로
// 표시하고, 판정은 언제나 '운영자 목록 ∪ 이 집합' 이다.
'necessary_storage_locked' => NecessaryAllowlist::locked(),
'necessary_storage_locked' => self::lockedNecessaryStorage(),
'cookie_categories' => json_encode([
[
@@ -32,19 +32,15 @@ class NecessaryAllowlist
/**
* 잠금 항목 — 운영자가 지울 수 없는 최소 집합.
*
* 설정에 넣지 않는다. 설정에 넣으면 API 로 지울 수 있어 잠금이 아니게 된다.
* 세션 쿠키 이름은 `session.cookie` 가 정하는 런타임 값이라 상수로 둘 수 없다 —
* 이름을 하드코딩하면 `SESSION_COOKIE` 를 지정한 사이트에서 그 항목이 죽는다.
* 정의는 진입 파일(`Plugin::lockedNecessaryStorage()`)이 소유한다. `getConfigValues()` 가
* 신규 설치 흐름에서 이 클래스의 오토로드 등록보다 먼저 호출되기 때문이다. 여기서는
* 그 정의에 위임만 한다.
*
* @return array<string, array<int, string>> 스코프 => 항목 배열
*/
public static function locked(): array
{
return [
'localStorage' => ['auth_token'],
'sessionStorage' => [],
'cookie' => ['XSRF-TOKEN', (string) config('session.cookie', 'laravel_session'), 'gdpr_session'],
];
return Plugin::lockedNecessaryStorage();
}
/**
@@ -0,0 +1,61 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Tests\Unit;
use Plugins\Sirsoft\Gdpr\Plugin;
use Plugins\Sirsoft\Gdpr\Support\NecessaryAllowlist;
use Plugins\Sirsoft\Gdpr\Tests\PluginTestCase;
/**
* 진입 파일(plugin.php)은 자기 src/ 클래스의 오토로드에 의존하지 않는다.
*
* 신규 설치 흐름에서는 plugin.php 가 로드되어 getConfigValues() 가 호출되는 시점에 이 플러그인의
* PSR-4 매핑이 아직 없다. 진입 파일이 src/ 클래스를 실제로 호출(정적 메서드·new·상수)하면
* "Class not found" 로 설치가 중단되며, 업그레이드 경로는 기설치본 매핑이 있어 재현되지 않는다.
* (7.0.10 신규 설치에서 NecessaryAllowlist::locked() 호출로 실제 발생)
*/
class EntryFileAutoloadIndependenceTest extends PluginTestCase
{
/**
* plugin.php 가 use 한 자기 네임스페이스 클래스는 ::class 참조로만 쓰인다.
*/
public function test_entry_file_does_not_invoke_own_src_classes(): void
{
$file = dirname(__DIR__, 2).'/plugin.php';
$src = file_get_contents($file);
preg_match_all('/^use\s+Plugins\\\\Sirsoft\\\\Gdpr\\\\([A-Za-z\\\\]+?)(?:\s+as\s+([A-Za-z]+))?;/m', $src, $uses, PREG_SET_ORDER);
$this->assertNotEmpty($uses, 'plugin.php 의 자기 네임스페이스 use 문을 찾지 못했습니다');
$violations = [];
foreach (explode("\n", $src) as $i => $line) {
$t = trim($line);
if ($t === '' || str_starts_with($t, '*') || str_starts_with($t, '/*') || str_starts_with($t, '//') || str_starts_with($t, 'use ')) {
continue;
}
foreach ($uses as $u) {
$short = ! empty($u[2]) ? $u[2] : substr($u[1], strrpos('\\'.$u[1], '\\'));
if (preg_match('/(?<![A-Za-z\\\\])'.preg_quote($short, '/').'::(?!class\b)|new\s+'.preg_quote($short, '/').'\b/', $line)) {
$violations[] = ($i + 1).': '.$t;
}
}
}
$this->assertSame([], $violations, "plugin.php 가 src/ 클래스를 직접 호출합니다 (설치 시 오토로드 미등록):\n".implode("\n", $violations));
}
/**
* 잠금 집합은 진입 파일이 소유하고 Support 클래스는 같은 값을 돌려준다.
*/
public function test_locked_set_is_owned_by_entry_file_and_mirrored_by_support(): void
{
$fromEntry = Plugin::lockedNecessaryStorage();
$this->assertSame($fromEntry, NecessaryAllowlist::locked());
$this->assertSame($fromEntry, (new Plugin)->getConfigValues()['necessary_storage_locked']);
$this->assertSame(['auth_token'], $fromEntry['localStorage']);
$this->assertContains('XSRF-TOKEN', $fromEntry['cookie']);
$this->assertContains('gdpr_session', $fromEntry['cookie']);
$this->assertContains((string) config('session.cookie'), $fromEntry['cookie']);
}
}