Files
Gnuboard7/app/Extension/Testing/ExtensionTestAllowlist.php
T
HeuJung d17ea28da5 fix(core,board,ecommerce,page,kginicis): 정책값 하드코딩·계약 분리 결함 일괄 수정
관리자 환경설정이 값을 저장은 하지만 실제 동작에는 닿지 않던 결함군을 정리했다.
설정 화면(min/max)·저장 검증(FormRequest)·런타임 적용(Service) 세 계층이 각자
리터럴을 들고 있어 서로 조용히 갈라지던 것이 공통 원인이다.

경계값 SSoT 도입:
- config/core.php `settings_limits` 를 단일 출처로 두고, FormRequest 는 이 값으로
 규칙을 만들고 화면은 설정 응답의 `_meta.limits` 로 같은 값을 받아 바인딩한다.
- 게시판·이커머스도 각 모듈 config 의 limits 를 동일 구조로 노출한다.

적용되지 않던 정책을 실제로 적용:
- 비밀번호 최소 길이·특수문자 필수(신규 지정 7개 경로. 로그인은 제외 — 정책을
 올렸을 때 기존 회원이 자기 계정에 접근조차 못 하는 데드락을 막기 위함)
- 계정 잠금 시간 0 = 무한대(전용 컬럼 분리. timestamp 상한과 NULL 규약 때문에
 기존 컬럼 재사용이 불가능하다) + 관리자 수동 해제 경로
- 업로드 최대 크기·허용 확장자, 게시판 첨부 개수·댓글 깊이, 페이지 첨부 정책,
 장바구니 수량·구매 한도, 마일리지 사용 한도, 주문 부가세

파생 결함:
- 큐 Job(sync 드라이버)이 진행 중인 요청의 Request 바인딩을 덮어써, 뒤이어 실행되는
 훅 리스너가 헤더를 잃고 조용히 건너뛰었다. Job 이 원 바인딩을 복원하도록 고치고,
 전송 헤더가 필요한 리스너는 동기 실행으로 선언한다.
- 확장 업데이트 실패 시 상태만 되돌리고 상태 캐시를 비우지 않아, 그 확장의 관리자
 화면만 캐시 TTL(기본 하루) 동안 404 로 남았다. 실패 경로에도 무효화를 대칭으로
 두고, 빈 목록 자체를 캐시하지 않는다.
- 글 작성 시 첨부 파일이 Service 로 전달되지 않아 검증·권한은 통과하고 첨부만 사라졌다.
- 환경설정 저장 실패 안내가 내부 설정 키를 그대로 노출했다(105개 항목에 라벨 부여).

재발 방지:
- audit 룰 3종(레이아웃 경계 리터럴 / FormRequest 경계 불일치 / Service 재클램프)
- 테스트 실행 범위 강제 훅(check-test-scope)
- 트러블슈팅 사례 2건(큐 Job 요청 오염 / 확장 상태 캐시 고착)

공개 이슈: gnuboard/g7
2026-07-30 07:52:24 +09:00

154 lines
5.2 KiB
PHP

<?php
namespace App\Extension\Testing;
use App\Extension\HookListenerRegistrar;
use App\Extension\HookManager;
/**
* 테스트 환경 확장 로딩 allowlist
*
* PHPUnit 환경에서 테스트 클래스가 명시한 확장만 ServiceProvider / route /
* hook listener 등록 대상으로 허용합니다. allowlist 밖 확장은 테스트 앱에
* 영향을 주지 않습니다 (예: GDPR 플러그인의 전역 미들웨어).
*
* provider register() 는 앱 부팅 단계라 테스트 인스턴스 메서드보다 먼저
* 실행되므로, 인스턴스 프로퍼티 대신 static 컨텍스트로 allowlist 를 공유합니다.
* 테스트 클래스의 setUp() 최상단(앱 생성 전)에서 set() 을 호출합니다.
*
* isActive() 가 false 인 환경(비-testing, 또는 allowlist 미설정)에서는
* 가드가 동작하지 않으므로 운영/개발 환경의 확장 로딩은 영향을 받지 않습니다.
*/
class ExtensionTestAllowlist
{
/**
* 허용된 플러그인 디렉토리명 목록 (예: 'sirsoft-gdpr')
*
* @var array<string>
*/
private static array $plugins = [];
/**
* 허용된 모듈 디렉토리명 목록 (예: 'sirsoft-ecommerce')
*
* @var array<string>
*/
private static array $modules = [];
/**
* allowlist 가 명시적으로 설정되었는지 여부
*
* 빈 배열로 set() 된 경우(= core-only 테스트)와
* 한 번도 set() 되지 않은 경우(= 비-테스트 부팅)를 구분합니다.
*/
private static bool $configured = false;
/**
* allowlist 를 설정합니다.
*
* requiredExtensions 형식의 상대 경로 문자열을 받아
* 'plugins/' / 'modules/' 프리픽스로 분류하고 디렉토리명만 추출합니다.
*
* @param array<string> $extensions 'plugins/sirsoft-gdpr' 형식의 확장 경로 배열
*/
public static function set(array $extensions): void
{
$previous = self::signature();
self::$plugins = [];
self::$modules = [];
self::$configured = true;
foreach ($extensions as $extension) {
$extension = trim($extension, '/');
if (str_starts_with($extension, 'plugins/')) {
self::$plugins[] = basename($extension);
} elseif (str_starts_with($extension, 'modules/')) {
self::$modules[] = basename($extension);
}
}
if (self::signature() !== $previous) {
self::forgetRegisteredHooks();
}
}
/**
* allowlist 가 바뀌었을 때 남아 있는 훅 등록을 비웁니다.
*
* `HookManager` 의 훅/필터 맵과 `HookListenerRegistrar` 의 등록 이력은 static 이라
* 테스트 클래스가 바뀌어도 프로세스에 그대로 남습니다. 앞 클래스가 허용했던 확장의
* 리스너가 남아 있으면, 그 확장을 배선하지 않은 다음 클래스의 앱에서 훅이 발화할 때
* `app($listenerClass)` 해석이 실패합니다 (Filter 훅은 동기 실행이라 그대로 500 이 된다).
*
* 비운 직후 새 앱이 부팅되며 코어·허용 확장 리스너를 다시 등록하므로, 이 초기화는
* "이번 allowlist 에 맞는 등록만 남긴다" 는 의미가 됩니다.
*/
private static function forgetRegisteredHooks(): void
{
HookManager::resetAll();
HookListenerRegistrar::clear();
}
/**
* allowlist 를 초기화합니다.
*
* 테스트 종료 시 호출하여 프로세스 내 테스트 클래스 간 누수를 방지합니다.
*/
public static function reset(): void
{
self::$plugins = [];
self::$modules = [];
self::$configured = false;
}
/**
* 현재 allowlist 를 비교 가능한 문자열로 반환합니다.
*
* @return string allowlist 서명 (미설정이면 빈 문자열)
*/
private static function signature(): string
{
if (! self::$configured) {
return '';
}
$modules = self::$modules;
$plugins = self::$plugins;
sort($modules);
sort($plugins);
return 'm:'.implode(',', $modules).'|p:'.implode(',', $plugins);
}
/**
* 가드 활성 여부를 반환합니다.
*
* testing 환경이면서 allowlist 가 명시적으로 설정된 경우에만 true.
* false 이면 provider 는 기존과 동일하게 전수 등록합니다.
*
* @return bool 가드 활성 여부
*/
public static function isActive(): bool
{
return self::$configured && app()->environment('testing');
}
/**
* 해당 확장이 allowlist 에 포함되어 있는지 반환합니다.
*
* @param string $type 확장 유형 ('plugin' | 'module')
* @param string $name 확장 디렉토리명 (예: 'sirsoft-gdpr')
* @return bool allowlist 포함 여부
*/
public static function isAllowed(string $type, string $name): bool
{
return match ($type) {
'plugin' => in_array($name, self::$plugins, true),
'module' => in_array($name, self::$modules, true),
default => false,
};
}
}