updateModule(...)` 호출 시 beta.3 메모리의 * ModuleManager 코드 사용. beta.3 의 ModuleManager 에는 `syncModuleIdentityPolicies` * 등이 부재 (`@since 7.0.0-beta.4`) → 사용자가 yes 를 선택해도 시드되지 않음. * 2. 활성 dir 에 신규 declaration 부재 — beta.3 출시본의 활성 dir module.php 에는 * IDV/메시지/알림 declaration 메서드(getIdentityPolicies 등) 자체가 도입되지 않았음. * 따라서 활성 dir 을 fresh-load 해도 declaration count 0 → 시드 안 됨. * * 수정: `Upgrade_7_0_0_beta_4` (spawn 자식, beta.4 메모리) 가 `resyncAllActiveDeclarativeArtifacts` * 호출. 해당 메서드는 _bundled 디렉토리(이번 코어 업그레이드로 출하된 NEW 코드) 의 * module.php/plugin.php 를 fresh-load 하므로 활성 dir 의 OLD 코드(declaration 부재) 와 * 무관하게 NEW declaration 을 시드. 멱등. * * 본 테스트는 _bundled fresh-load 경로를 진짜로 검증하기 위해 임시 sandbox 디렉토리에 * fake module/plugin 의 진입점 파일을 생성하고, ModuleManager/PluginManager 의 * `bundledModulesPath`/`bundledPluginsPath` 프로퍼티를 sandbox 로 교체한다. */ class BundledExtensionDeclarativeArtifactsRecoveryTest extends TestCase { use RefreshDatabase; private object $upgrade; private UpgradeContext $context; private string $sandboxModulesPath; private string $sandboxPluginsPath; private ?string $originalBundledModulesPath = null; private ?string $originalBundledPluginsPath = null; protected function setUp(): void { parent::setUp(); require_once base_path('upgrades/Upgrade_7_0_0_beta_4.php'); $class = 'App\\Upgrades\\Upgrade_7_0_0_beta_4'; $this->upgrade = new $class; $this->context = new UpgradeContext( fromVersion: '7.0.0-beta.3', toVersion: '7.0.0-beta.4', currentStep: '7.0.0-beta.4', ); // 임시 sandbox 디렉토리 — 실제 _bundled 트리를 오염시키지 않기 위함. $this->sandboxModulesPath = storage_path('framework/testing/issue326-bundled-modules-'.uniqid()); $this->sandboxPluginsPath = storage_path('framework/testing/issue326-bundled-plugins-'.uniqid()); File::ensureDirectoryExists($this->sandboxModulesPath); File::ensureDirectoryExists($this->sandboxPluginsPath); $this->swapBundledPath(ModuleManager::class, 'bundledModulesPath', $this->sandboxModulesPath, $this->originalBundledModulesPath); $this->swapBundledPath(PluginManager::class, 'bundledPluginsPath', $this->sandboxPluginsPath, $this->originalBundledPluginsPath); } protected function tearDown(): void { $this->swapBundledPath(ModuleManager::class, 'bundledModulesPath', $this->originalBundledModulesPath, $unused1); $this->swapBundledPath(PluginManager::class, 'bundledPluginsPath', $this->originalBundledPluginsPath, $unused2); File::deleteDirectory($this->sandboxModulesPath); File::deleteDirectory($this->sandboxPluginsPath); parent::tearDown(); } /** * 활성 모듈이 _bundled 에 선언한 IDV 정책이 step 호출 후 DB 에 시드되는지 검증. * * step 호출 전: 정책 0건. step 호출 후: 모듈 선언 정책이 source_type=module 로 저장. */ public function test_active_module_identity_policies_are_seeded_by_recovery_step(): void { $this->registerFakeModule( identifier: 'fake-recoverymodule', policies: [ [ 'key' => 'fake-recoverymodule.action.delete', 'scope' => 'hook', 'target' => 'fake-recoverymodule.action.before_delete', 'purpose' => 'sensitive_action', 'grace_minutes' => 5, 'enabled' => false, 'applies_to' => 'admin', 'fail_mode' => 'block', ], [ 'key' => 'fake-recoverymodule.action.publish', 'scope' => 'hook', 'target' => 'fake-recoverymodule.action.before_publish', 'purpose' => 'sensitive_action', 'grace_minutes' => 0, 'enabled' => false, 'applies_to' => 'self', 'fail_mode' => 'block', ], ], ); // pre-condition: 정책이 DB에 없음 (production 결함 상태 재현) $this->assertDatabaseMissing('identity_policies', [ 'source_type' => 'module', 'source_identifier' => 'fake-recoverymodule', ]); $this->invokeRecover(); $this->assertDatabaseHas('identity_policies', [ 'key' => 'fake-recoverymodule.action.delete', 'source_type' => 'module', 'source_identifier' => 'fake-recoverymodule', 'purpose' => 'sensitive_action', ]); $this->assertDatabaseHas('identity_policies', [ 'key' => 'fake-recoverymodule.action.publish', 'source_type' => 'module', 'source_identifier' => 'fake-recoverymodule', 'applies_to' => 'self', ]); } /** * 활성 플러그인이 _bundled 에 선언한 IDV 정책이 step 호출 후 DB 에 시드되는지 검증. */ public function test_active_plugin_identity_policies_are_seeded_by_recovery_step(): void { $this->registerFakePlugin( identifier: 'fake-recoveryplugin', policies: [ [ 'key' => 'fake-recoveryplugin.payment.cancel', 'scope' => 'hook', 'target' => 'fake-recoveryplugin.payment.before_cancel', 'purpose' => 'sensitive_action', 'grace_minutes' => 0, 'enabled' => false, 'applies_to' => 'admin', 'fail_mode' => 'block', ], ], ); $this->assertDatabaseMissing('identity_policies', [ 'source_type' => 'plugin', 'source_identifier' => 'fake-recoveryplugin', ]); $this->invokeRecover(); $this->assertDatabaseHas('identity_policies', [ 'key' => 'fake-recoveryplugin.payment.cancel', 'source_type' => 'plugin', 'source_identifier' => 'fake-recoveryplugin', ]); } /** * 멱등: 정상 환경(이미 시드됨)에서 재실행해도 user_overrides 가 보존되어야 함. */ public function test_recovery_is_idempotent_and_preserves_user_overrides(): void { $this->registerFakeModule( identifier: 'fake-idempotentmodule', policies: [[ 'key' => 'fake-idempotentmodule.toggle', 'scope' => 'hook', 'target' => 'fake-idempotentmodule.before_action', 'purpose' => 'sensitive_action', 'grace_minutes' => 0, 'enabled' => false, 'applies_to' => 'both', 'fail_mode' => 'block', ]], ); // 1차 호출 — 시드 $this->invokeRecover(); // 운영자가 enabled + grace_minutes 변경 + user_overrides 마킹 \App\Models\IdentityPolicy::where('key', 'fake-idempotentmodule.toggle')->update([ 'enabled' => true, 'grace_minutes' => 30, 'user_overrides' => ['enabled', 'grace_minutes'], ]); // 2차 호출 — 멱등 (선언 기본값으로 덮어쓰지 않아야 함) $this->invokeRecover(); $row = \App\Models\IdentityPolicy::where('key', 'fake-idempotentmodule.toggle')->first(); $this->assertNotNull($row, '1차 시드 후 정책 row 가 존재해야 함'); $this->assertTrue((bool) $row->enabled, 'user_overrides 의 enabled 가 보존되어야 함'); $this->assertSame(30, (int) $row->grace_minutes, 'user_overrides 의 grace_minutes 가 보존되어야 함'); } /** * 활성 모듈을 DB(modules 테이블) 에 등록하고 sandbox _bundled 에 module.php 진입점을 생성. * * 본 테스트의 sandbox 는 ModuleManager::$bundledModulesPath 를 임시 경로로 교체했으므로 * 여기에 module.php 를 두면 `resyncAllActiveDeclarativeArtifacts()` 의 fresh-load 가 실제로 * 동작한다 (in-memory 인스턴스 inject 우회 — production 흐름과 일치). */ private function registerFakeModule(string $identifier, array $policies): void { Module::query()->updateOrCreate( ['identifier' => $identifier], [ 'vendor' => explode('-', $identifier, 2)[0] ?? 'fake', 'name' => json_encode(['ko' => 'Fake', 'en' => 'Fake']), 'description' => json_encode(['ko' => 'fake', 'en' => 'fake']), 'version' => '1.0.0', 'status' => ExtensionStatus::Active->value, ], ); ModuleManager::invalidateModuleStatusCache(); $namespace = $this->namespaceFromIdentifier($identifier); $moduleDir = $this->sandboxModulesPath.DIRECTORY_SEPARATOR.$identifier; File::ensureDirectoryExists($moduleDir); $policiesPhp = var_export($policies, true); $contents = <<updateOrCreate( ['identifier' => $identifier], [ 'vendor' => explode('-', $identifier, 2)[0] ?? 'fake', 'name' => json_encode(['ko' => 'Fake', 'en' => 'Fake']), 'description' => json_encode(['ko' => 'fake', 'en' => 'fake']), 'version' => '1.0.0', 'status' => ExtensionStatus::Active->value, ], ); PluginManager::invalidatePluginStatusCache(); $namespace = $this->namespaceFromIdentifier($identifier); $pluginDir = $this->sandboxPluginsPath.DIRECTORY_SEPARATOR.$identifier; File::ensureDirectoryExists($pluginDir); $policiesPhp = var_export($policies, true); $contents = << str_replace(' ', '', ucwords(str_replace('_', ' ', $part))), explode('-', $identifier), ); return implode('\\', $parts); } private function swapBundledPath(string $managerClass, string $property, ?string $newValue, ?string &$captured): void { $manager = app($managerClass); $ref = new ReflectionClass($manager); $prop = $ref->getProperty($property); $prop->setAccessible(true); $captured = $prop->getValue($manager); if ($newValue !== null) { $prop->setValue($manager, $newValue); } } private function invokeRecover(): void { $reflection = new ReflectionClass($this->upgrade); $method = $reflection->getMethod('resyncBundledExtensionDeclarativeArtifacts'); $method->setAccessible(true); $method->invoke($this->upgrade, $this->context); } }