관리자 환경설정이 값을 저장은 하지만 실제 동작에는 닿지 않던 결함군을 정리했다. 설정 화면(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
154 lines
5.2 KiB
PHP
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,
|
|
};
|
|
}
|
|
}
|