계획서 §검증 후반부를 다시 대조해 미구현 3건을 확인하고 모두 구현했다. 앞선 커밋에서 "미구현 1건" 으로 보고한 것은 §12 불변식 표만 파일로 대조하고 §검증 섹션의 표 두 개는 확인하지 않은 결과였다. - 설치 2단계 요구사항 화면에 자산 URL 방식 항목 추가. 판정은 서버가 아니라 브라우저가 한다 — 서버에서 자기 APP_URL 로 요청하면 loopback 이 vhost·프록시 체인을 우회해 실제 방문자와 다른 답을 낸다. 2단계와 3단계가 같은 판정 함수를 쓰도록 추출했다 - 대시보드가 스스로 프로브를 던져 **저장된** 방식과 대조하고 어긋나면 안내한다. 런타임 값이 아니라 저장값을 보는 이유는, 자가 복구가 이미 전환해 놓았을 때가 바로 알려야 할 상황이기 때문이다 — 봇은 자바스크립트를 실행하지 않아 자가 복구가 닿지 않는다. 알리기만 하고 저장하지 않으므로 클라이언트가 서버 설정을 뒤집지 않는다 - 레이아웃 렌더링 테스트를 두 모드로 추가. 핵심 단언은 두 모드의 DOM 이 완전히 동일하다는 것이다 — 모드가 화면 구조로 새어나가면 확장자 없는 환경에서만 깨지고 그 환경은 개발 중 거의 재현되지 않는다 함께 고친 테스트 격리 결함 4건 (이번 기능과 무관하나 같은 세션에서 발견): - 인스톨러 공유 클래스 로드가 오토로드를 막고 있어, 앱 루트를 임시 디렉토리로 바꾸는 테스트에서 없는 파일을 읽으려다 죽었다 (8건 fatal) - 테스트용 번역 스텁이 실제 구현과의 정의 경쟁에서 이기면 주입된 번역을 무시했다. 클래스 단독 실행은 통과하고 스위트 실행만 실패하던 원인 - 모듈 활성 목록은 "디스크 로드분 ∩ DB 활성분" 인데 부팅 시 테이블이 비어 디스크 스캔이 일어나지 않아, 파일 캐시 잔재가 있을 때만 통과하던 순서 의존 테스트 - 라우트 프로바이더 가드 테스트가 Git 미추적 파일(.env) 존재에 의존해 신규 클론·CI 에서 실패했다. 임시 앱 루트로 격리 또한 일부 테스트가 실제 개발 환경 파일을 삭제·재생성하고 자신의 정리 단계에서 되돌리는데, 그 테스트가 죽으면 되돌아오지 않아 복구 불가능한 유실이 발생한다. 실제로 이번 세션에서 겪었으므로 테스트 부트스트랩에 프로세스 수준 복원 안전망을 추가했다. 검증: 백엔드 344건 / 템플릿 2799건 / 레이아웃 7건 / 브라우저 9건 green, audit 위반 0.
349 lines
14 KiB
PHP
349 lines
14 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Extension;
|
|
|
|
use App\Contracts\Repositories\IdentityPolicyRepositoryInterface;
|
|
use App\Extension\ModuleManager;
|
|
use App\Extension\PluginManager;
|
|
use App\Extension\TemplateManager;
|
|
use App\Listeners\NotificationHookListener;
|
|
use App\Providers\ModuleRouteServiceProvider;
|
|
use App\Providers\PluginRouteServiceProvider;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\File;
|
|
use ReflectionClass;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* `config('app.installer_completed')` 가드가 Schema::hasTable() 호출을
|
|
* 건너뛰는지 검증합니다.
|
|
*
|
|
* 성능 회귀 방지 목적: 설치 완료 상태에서 매 요청 `information_schema.tables`
|
|
* 쿼리가 실행되지 않아야 합니다.
|
|
*/
|
|
class InstallerCompletedGuardTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
/** @var array<int, string>|null 원본 argv 백업 */
|
|
private ?array $originalArgv = null;
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
config(['app.installer_completed' => false]);
|
|
|
|
if ($this->originalArgv !== null) {
|
|
$_SERVER['argv'] = $this->originalArgv;
|
|
$this->originalArgv = null;
|
|
}
|
|
|
|
parent::tearDown();
|
|
}
|
|
|
|
/**
|
|
* $_SERVER['argv'] 를 마이그레이션 명령으로 임시 설정하고 콜백을 실행합니다.
|
|
*
|
|
* PHPUnit 은 이미 콘솔 컨텍스트이므로, argv[1] 만 'migrate' 로 바꾸면
|
|
* InstallerContext::isSchemaMutatingCommand() 가 true 가 되어 fast-path 가 무력화된다.
|
|
* argv 는 finally 에서 반드시 원복해 다른 테스트 오염을 막는다.
|
|
*
|
|
* @param callable $callback 마이그레이션 컨텍스트에서 실행할 코드
|
|
*/
|
|
private function withMigrationArgv(callable $callback): void
|
|
{
|
|
$this->originalArgv = $_SERVER['argv'] ?? [];
|
|
$_SERVER['argv'] = ['artisan', 'migrate'];
|
|
|
|
try {
|
|
$callback();
|
|
} finally {
|
|
$_SERVER['argv'] = $this->originalArgv;
|
|
$this->originalArgv = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `.env` 와 `modules`/`plugins` 디렉토리가 존재하는 임시 앱 루트에서 콜백을 실행합니다.
|
|
*
|
|
* 라우트 프로바이더는 인스톨러 실행 전 부팅을 위해 `.env` 부재 시 조기 반환한다.
|
|
* 그런데 `.env` 는 Git 추적 대상이 아니므로 신규 클론·CI 에는 존재하지 않고, 그러면
|
|
* 이 테스트가 검증하려는 `hasTable` 가드까지 도달하지도 못한 채 통과한 것처럼 보이거나
|
|
* (쿼리 0건) 실패한다. 실제로 그렇게 실패했다 — 개발자 로컬에 `.env` 가 있느냐에
|
|
* 결과가 좌우되는 상태였다.
|
|
*
|
|
* 프로젝트 루트에 파일을 만들지 않고 **임시 디렉토리를 앱 루트로 대체**해 전제를
|
|
* 결정적으로 만든다. DB 커넥션은 base path 와 무관하므로 `hasTable` 계측에는 영향이 없다.
|
|
*
|
|
* @param callable $callback 실행할 코드
|
|
*/
|
|
private function withFakeAppRoot(callable $callback): void
|
|
{
|
|
$root = sys_get_temp_dir().'/g7-installer-guard-'.bin2hex(random_bytes(4));
|
|
$originalBasePath = $this->app->basePath();
|
|
|
|
File::ensureDirectoryExists($root.'/modules');
|
|
File::ensureDirectoryExists($root.'/plugins');
|
|
File::put($root.'/.env', "APP_ENV=testing\n");
|
|
|
|
$this->app->setBasePath($root);
|
|
|
|
try {
|
|
$callback();
|
|
} finally {
|
|
$this->app->setBasePath($originalBasePath);
|
|
File::deleteDirectory($root);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 주어진 콜백 실행 중 발생한 information_schema.tables 쿼리만 수집합니다.
|
|
*
|
|
* @param callable $callback 계측할 코드
|
|
* @return array<int, array<string, mixed>> information_schema.tables 쿼리 목록
|
|
*/
|
|
private function captureSchemaTableQueries(callable $callback): array
|
|
{
|
|
DB::enableQueryLog();
|
|
$callback();
|
|
$queries = DB::getQueryLog();
|
|
DB::disableQueryLog();
|
|
|
|
return array_filter(
|
|
$queries,
|
|
fn ($q) => str_contains($q['query'], 'information_schema.tables')
|
|
);
|
|
}
|
|
|
|
public function test_module_trait_skips_has_table_when_installer_completed(): void
|
|
{
|
|
config(['app.installer_completed' => true]);
|
|
|
|
$schemaQueries = $this->captureSchemaTableQueries(
|
|
fn () => ModuleManager::getActiveModuleIdentifiers()
|
|
);
|
|
|
|
$this->assertCount(0, $schemaQueries, 'installer_completed=true 에서는 information_schema.tables 쿼리가 발생하지 않아야 합니다');
|
|
}
|
|
|
|
public function test_plugin_trait_skips_has_table_when_installer_completed(): void
|
|
{
|
|
config(['app.installer_completed' => true]);
|
|
|
|
$schemaQueries = $this->captureSchemaTableQueries(
|
|
fn () => PluginManager::getActivePluginIdentifiers()
|
|
);
|
|
|
|
$this->assertCount(0, $schemaQueries);
|
|
}
|
|
|
|
public function test_template_trait_skips_has_table_when_installer_completed(): void
|
|
{
|
|
config(['app.installer_completed' => true]);
|
|
|
|
$schemaQueries = $this->captureSchemaTableQueries(
|
|
fn () => TemplateManager::getActiveTemplateIdentifiers()
|
|
);
|
|
|
|
$this->assertCount(0, $schemaQueries);
|
|
}
|
|
|
|
public function test_module_trait_falls_back_to_has_table_when_installer_not_completed(): void
|
|
{
|
|
config(['app.installer_completed' => false]);
|
|
|
|
$schemaQueries = $this->captureSchemaTableQueries(
|
|
fn () => ModuleManager::getActiveModuleIdentifiers()
|
|
);
|
|
|
|
$this->assertGreaterThan(0, count($schemaQueries), 'installer_completed=false 에서는 기존 hasTable 경로가 실행되어야 합니다');
|
|
}
|
|
|
|
/**
|
|
* NotificationHookListener::registerDynamicHooks() 의 hasTable 가드 검증.
|
|
*
|
|
* 부팅 시점 동적 훅 등록 경로 — 설치 완료 상태에서 매 요청
|
|
* `information_schema.tables` 조회(notification_definitions)가 사라져야 합니다.
|
|
*/
|
|
public function test_notification_listener_skips_has_table_when_installer_completed(): void
|
|
{
|
|
config(['app.installer_completed' => true]);
|
|
|
|
$listener = app(NotificationHookListener::class);
|
|
|
|
$schemaQueries = $this->captureSchemaTableQueries(
|
|
fn () => $listener->registerDynamicHooks()
|
|
);
|
|
|
|
$this->assertCount(0, $schemaQueries, 'installer_completed=true 에서는 notification_definitions hasTable 조회가 발생하지 않아야 합니다');
|
|
}
|
|
|
|
public function test_notification_listener_falls_back_to_has_table_when_installer_not_completed(): void
|
|
{
|
|
config(['app.installer_completed' => false]);
|
|
|
|
$listener = app(NotificationHookListener::class);
|
|
|
|
$schemaQueries = $this->captureSchemaTableQueries(
|
|
fn () => $listener->registerDynamicHooks()
|
|
);
|
|
|
|
$this->assertGreaterThan(0, count($schemaQueries), 'installer_completed=false 에서는 기존 hasTable 경로가 실행되어야 합니다');
|
|
}
|
|
|
|
/**
|
|
* IdentityPolicyRepository::listHookTargets() 의 hasTable 가드 검증.
|
|
*
|
|
* 부팅 시점 IDV 동적 hook target 동기화 경로 — 설치 완료 상태에서
|
|
* `information_schema.tables` 조회(identity_policies)가 사라져야 합니다.
|
|
*/
|
|
public function test_identity_policy_list_hook_targets_skips_has_table_when_installer_completed(): void
|
|
{
|
|
config(['app.installer_completed' => true]);
|
|
|
|
$repository = app(IdentityPolicyRepositoryInterface::class);
|
|
|
|
$schemaQueries = $this->captureSchemaTableQueries(
|
|
fn () => $repository->listHookTargets()
|
|
);
|
|
|
|
$this->assertCount(0, $schemaQueries, 'installer_completed=true 에서는 identity_policies hasTable 조회가 발생하지 않아야 합니다');
|
|
}
|
|
|
|
public function test_identity_policy_list_hook_targets_falls_back_when_installer_not_completed(): void
|
|
{
|
|
config(['app.installer_completed' => false]);
|
|
|
|
$repository = app(IdentityPolicyRepositoryInterface::class);
|
|
|
|
$schemaQueries = $this->captureSchemaTableQueries(
|
|
fn () => $repository->listHookTargets()
|
|
);
|
|
|
|
$this->assertGreaterThan(0, count($schemaQueries), 'installer_completed=false 에서는 기존 hasTable 경로가 실행되어야 합니다');
|
|
}
|
|
|
|
/**
|
|
* IdentityPolicyRepository::activeExtensionIdentifiers() 의 hasTable 가드 검증.
|
|
*
|
|
* `resolveByScopeTarget()` → `applyActiveExtensionScope()` → `activeExtensionIdentifiers()`
|
|
* 경로로 modules/plugins 테이블 hasTable 조회가 트리거되며, 설치 완료 상태에서는
|
|
* 해당 `information_schema.tables` 조회가 사라져야 합니다.
|
|
*/
|
|
public function test_active_extension_scope_skips_has_table_when_installer_completed(): void
|
|
{
|
|
config(['app.installer_completed' => true]);
|
|
|
|
$repository = app(IdentityPolicyRepositoryInterface::class);
|
|
|
|
$schemaQueries = $this->captureSchemaTableQueries(
|
|
fn () => $repository->resolveByScopeTarget('hook', 'core.auth.signup_before_submit')
|
|
);
|
|
|
|
$this->assertCount(0, $schemaQueries, 'installer_completed=true 에서는 modules/plugins hasTable 조회가 발생하지 않아야 합니다');
|
|
}
|
|
|
|
public function test_active_extension_scope_falls_back_when_installer_not_completed(): void
|
|
{
|
|
config(['app.installer_completed' => false]);
|
|
|
|
$repository = app(IdentityPolicyRepositoryInterface::class);
|
|
|
|
$schemaQueries = $this->captureSchemaTableQueries(
|
|
fn () => $repository->resolveByScopeTarget('hook', 'core.auth.signup_before_submit')
|
|
);
|
|
|
|
$this->assertGreaterThan(0, count($schemaQueries), 'installer_completed=false 에서는 기존 hasTable 경로가 실행되어야 합니다');
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| 마이그레이션 컨텍스트 가드 (빈 DB 배포 안전성)
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| INSTALLER_COMPLETED=true .env 를 빈 DB 새 서버에 복사한 뒤 `php artisan migrate`
|
|
| 부팅 시, fast-path 가 테이블 존재를 잘못 전제해 뒤따르는 쿼리가 table not found 로
|
|
| 부팅을 깨뜨리던 결함을 막는다. 마이그레이션 계열 명령 컨텍스트에서는
|
|
| installer_completed=true 라도 fast-path 를 우회해 hasTable 실검증 경로로 진입해야 한다.
|
|
| (RefreshDatabase 는 테이블을 만들므로 실제 not-found 는 재현 불가 → 가드 발동으로
|
|
| hasTable 쿼리가 다시 발생하는지를 쿼리카운트로 검증)
|
|
*/
|
|
|
|
public function test_module_trait_uses_has_table_during_migration_despite_installer_completed(): void
|
|
{
|
|
config(['app.installer_completed' => true]);
|
|
|
|
$this->withMigrationArgv(function () {
|
|
$schemaQueries = $this->captureSchemaTableQueries(
|
|
fn () => ModuleManager::getActiveModuleIdentifiers()
|
|
);
|
|
|
|
$this->assertGreaterThan(0, count($schemaQueries), '마이그레이션 중에는 installer_completed=true 라도 hasTable 폴백 경로로 진입해야 합니다');
|
|
});
|
|
}
|
|
|
|
public function test_plugin_trait_uses_has_table_during_migration_despite_installer_completed(): void
|
|
{
|
|
config(['app.installer_completed' => true]);
|
|
|
|
$this->withMigrationArgv(function () {
|
|
$schemaQueries = $this->captureSchemaTableQueries(
|
|
fn () => PluginManager::getActivePluginIdentifiers()
|
|
);
|
|
|
|
$this->assertGreaterThan(0, count($schemaQueries), '마이그레이션 중에는 installer_completed=true 라도 hasTable 폴백 경로로 진입해야 합니다');
|
|
});
|
|
}
|
|
|
|
public function test_template_trait_uses_has_table_during_migration_despite_installer_completed(): void
|
|
{
|
|
config(['app.installer_completed' => true]);
|
|
|
|
$this->withMigrationArgv(function () {
|
|
$schemaQueries = $this->captureSchemaTableQueries(
|
|
fn () => TemplateManager::getActiveTemplateIdentifiers()
|
|
);
|
|
|
|
$this->assertGreaterThan(0, count($schemaQueries), '마이그레이션 중에는 installer_completed=true 라도 hasTable 폴백 경로로 진입해야 합니다');
|
|
});
|
|
}
|
|
|
|
public function test_module_route_provider_uses_has_table_during_migration_despite_installer_completed(): void
|
|
{
|
|
config(['app.installer_completed' => true]);
|
|
|
|
$provider = new ModuleRouteServiceProvider($this->app);
|
|
$method = (new ReflectionClass($provider))->getMethod('loadModuleRoutes');
|
|
$method->setAccessible(true);
|
|
|
|
$this->withFakeAppRoot(function () use ($provider, $method) {
|
|
$this->withMigrationArgv(function () use ($provider, $method) {
|
|
$schemaQueries = $this->captureSchemaTableQueries(
|
|
fn () => $method->invoke($provider)
|
|
);
|
|
|
|
$this->assertGreaterThan(0, count($schemaQueries), '마이그레이션 중에는 installer_completed=true 라도 modules hasTable 검증 경로로 진입해 무방비 pluck 을 막아야 합니다');
|
|
});
|
|
});
|
|
}
|
|
|
|
public function test_plugin_route_provider_uses_has_table_during_migration_despite_installer_completed(): void
|
|
{
|
|
config(['app.installer_completed' => true]);
|
|
|
|
$provider = new PluginRouteServiceProvider($this->app);
|
|
$method = (new ReflectionClass($provider))->getMethod('loadPluginRoutes');
|
|
$method->setAccessible(true);
|
|
|
|
$this->withFakeAppRoot(function () use ($provider, $method) {
|
|
$this->withMigrationArgv(function () use ($provider, $method) {
|
|
$schemaQueries = $this->captureSchemaTableQueries(
|
|
fn () => $method->invoke($provider)
|
|
);
|
|
|
|
$this->assertGreaterThan(0, count($schemaQueries), '마이그레이션 중에는 installer_completed=true 라도 plugins hasTable 검증 경로로 진입해야 합니다');
|
|
});
|
|
});
|
|
}
|
|
}
|