mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-31 19:03:43 +01:00
One module whose files could not load (billing 1.1.0 shipped ionCube-encoded without its module.lic) killed every admin request and every console.php command with an uncatchable loader fatal. - bootAll(): a module that throws while booting is dropped for that process; the other modules and the panel carry on. - Module event listeners are wrapped: what they throw is logged and the core operation that dispatched the event completes. - ModuleQuarantine: a shutdown function reads a fatal raised inside a module directory (parse/compile/ionCube licence errors, not memory or time limits or uncaught exceptions) and records the module with a fingerprint of its files in Modules/.quarantine.json. ModuleLoader skips it, so only the process that hit the fatal is lost. Changed files (an update, a rollback, a .lic) lift the record. The file is written via an exclusive temp file + rename, never through a link. - ModuleManager never requires a quarantined module's code: install and update refuse with the reason, uninstall/delete skip its uninstall() hook and still drop its schema from database_drop.sql. The module list shows the quarantine as a warning.
351 lines
14 KiB
PHP
351 lines
14 KiB
PHP
<?php
|
|
|
|
use XcVm\Core\Http\Router;
|
|
use XcVm\Core\Module\ModuleLoader;
|
|
use XcVm\Core\Module\NavbarRegistry;
|
|
use XcVm\Core\Module\ResellerNavbarRegistry;
|
|
use XcVm\Core\Module\ModuleInterface;
|
|
use XcVm\Core\Container\ServiceContainer;
|
|
use XcVm\Core\Events\EventDispatcher;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
// Shared tracker — loaded by PHPUnit before any module PHP file is require_once'd by ModuleLoader.
|
|
// Generated module classes reference this class directly in their method bodies.
|
|
class TestBootCallTracker {
|
|
public static array $calls = [];
|
|
|
|
public static function record(string $event): void {
|
|
self::$calls[] = $event;
|
|
}
|
|
|
|
public static function reset(): void {
|
|
self::$calls = [];
|
|
}
|
|
|
|
public static function has(string $event): bool {
|
|
return in_array($event, self::$calls, true);
|
|
}
|
|
}
|
|
|
|
final class TestIsolationEvent {
|
|
public bool $handled = false;
|
|
}
|
|
|
|
final class ModuleLoaderBootTest extends TestCase {
|
|
|
|
private ServiceContainer $container;
|
|
|
|
protected function setUp(): void {
|
|
ServiceContainer::resetInstance();
|
|
Router::resetInstance();
|
|
NavbarRegistry::reset();
|
|
ResellerNavbarRegistry::reset();
|
|
EventDispatcher::resetInstance();
|
|
TestBootCallTracker::reset();
|
|
|
|
$this->container = ServiceContainer::getInstance();
|
|
|
|
// Store a fresh EventDispatcher instance in the container and wire
|
|
// it as the static singleton so both DI callers and static callers
|
|
// share the same listener store within this test.
|
|
$dispatcher = new EventDispatcher();
|
|
EventDispatcher::setInstance($dispatcher);
|
|
$this->container->set('events', $dispatcher);
|
|
}
|
|
|
|
protected function tearDown(): void {
|
|
ServiceContainer::resetInstance();
|
|
Router::resetInstance();
|
|
NavbarRegistry::reset();
|
|
ResellerNavbarRegistry::reset();
|
|
EventDispatcher::resetInstance();
|
|
TestBootCallTracker::reset();
|
|
}
|
|
|
|
// ── bootAll() lifecycle ───────────────────────────────────────────────
|
|
|
|
public function testBootAllCallsBootOnModule(): void {
|
|
$root = $this->createRoot();
|
|
$this->createModule($root, 'boot-alpha', boot: "\\TestBootCallTracker::record('boot:boot-alpha');");
|
|
|
|
$loader = new ModuleLoader();
|
|
$loader->loadAll($root);
|
|
$loader->bootAll($this->container);
|
|
|
|
$this->assertTrue(TestBootCallTracker::has('boot:boot-alpha'));
|
|
}
|
|
|
|
public function testBootAllCallsRegisterNavbarOnModule(): void {
|
|
$root = $this->createRoot();
|
|
$this->createModule($root, 'navbar-beta', navbar: "\\TestBootCallTracker::record('navbar:navbar-beta');");
|
|
|
|
$loader = new ModuleLoader();
|
|
$loader->loadAll($root);
|
|
$loader->bootAll($this->container);
|
|
|
|
$this->assertTrue(TestBootCallTracker::has('navbar:navbar-beta'));
|
|
}
|
|
|
|
public function testBootAllCallsRegisterResellerNavbarOnModule(): void {
|
|
$root = $this->createRoot();
|
|
$this->createModule($root, 'reseller-navbar-zeta', resellerNavbar: "\\TestBootCallTracker::record('reseller-navbar:reseller-navbar-zeta');");
|
|
|
|
$loader = new ModuleLoader();
|
|
$loader->loadAll($root);
|
|
$loader->bootAll($this->container);
|
|
|
|
$this->assertTrue(TestBootCallTracker::has('reseller-navbar:reseller-navbar-zeta'));
|
|
}
|
|
|
|
public function testBootAllSkipsRegisterResellerNavbarWhenModuleDoesNotImplementInterface(): void {
|
|
$root = $this->createRoot();
|
|
// Plain ModuleInterface module — no ResellerNavbarProviderInterface.
|
|
$this->createModule($root, 'no-reseller-navbar-eta');
|
|
|
|
$loader = new ModuleLoader();
|
|
$loader->loadAll($root);
|
|
// Would throw if bootAll() called registerResellerNavbar() on a module
|
|
// that does not implement ResellerNavbarProviderInterface.
|
|
$loader->bootAll($this->container);
|
|
|
|
$this->assertFalse(TestBootCallTracker::has('reseller-navbar:no-reseller-navbar-eta'));
|
|
}
|
|
|
|
public function testBootAllCallsRegisterRoutesWhenRouterProvided(): void {
|
|
$root = $this->createRoot();
|
|
$this->createModule($root, 'routes-gamma', routes: "\\TestBootCallTracker::record('routes:routes-gamma');");
|
|
|
|
$loader = new ModuleLoader();
|
|
$loader->loadAll($root);
|
|
$loader->bootAll($this->container, Router::getInstance());
|
|
|
|
$this->assertTrue(TestBootCallTracker::has('routes:routes-gamma'));
|
|
}
|
|
|
|
public function testBootAllSkipsRoutesWhenRouterIsNull(): void {
|
|
$root = $this->createRoot();
|
|
$this->createModule($root, 'noroutes-delta', routes: "\\TestBootCallTracker::record('routes:noroutes-delta');");
|
|
|
|
$loader = new ModuleLoader();
|
|
$loader->loadAll($root);
|
|
$loader->bootAll($this->container, null);
|
|
|
|
$this->assertFalse(TestBootCallTracker::has('routes:noroutes-delta'));
|
|
}
|
|
|
|
public function testBootAllRegistersEventSubscribers(): void {
|
|
$root = $this->createRoot();
|
|
$this->createModule($root, 'events-epsilon', subscribers: "'test.event.epsilon' => function() {}");
|
|
|
|
$loader = new ModuleLoader();
|
|
$loader->loadAll($root);
|
|
$loader->bootAll($this->container);
|
|
|
|
$this->assertTrue(EventDispatcher::hasListeners('test.event.epsilon'));
|
|
}
|
|
|
|
public function testBootAllWithMultipleModulesCallsAllBoots(): void {
|
|
$root = $this->createRoot();
|
|
$this->createModule($root, 'multi-one', boot: "\\TestBootCallTracker::record('boot:multi-one');");
|
|
$this->createModule($root, 'multi-two', boot: "\\TestBootCallTracker::record('boot:multi-two');");
|
|
|
|
$loader = new ModuleLoader();
|
|
$loader->loadAll($root);
|
|
$loader->bootAll($this->container);
|
|
|
|
$this->assertTrue(TestBootCallTracker::has('boot:multi-one'));
|
|
$this->assertTrue(TestBootCallTracker::has('boot:multi-two'));
|
|
}
|
|
|
|
// ── Isolation: a broken module never takes the others down ───────────
|
|
|
|
public function testModuleWhoseBootThrowsIsDroppedAndTheRestBoot(): void {
|
|
$root = $this->createRoot();
|
|
$this->createModule($root, 'iso-broken', boot: "throw new \\RuntimeException('boom');");
|
|
$this->createModule($root, 'iso-healthy', boot: "\\TestBootCallTracker::record('boot:iso-healthy');");
|
|
|
|
$loader = new ModuleLoader();
|
|
$loader->loadAll($root);
|
|
$loader->bootAll($this->container);
|
|
|
|
$this->assertTrue(TestBootCallTracker::has('boot:iso-healthy'));
|
|
$this->assertFalse($loader->isLoaded('iso-broken'));
|
|
$this->assertNull($loader->getManifest('iso-broken'));
|
|
$this->assertTrue($loader->isLoaded('iso-healthy'));
|
|
}
|
|
|
|
public function testModuleListenerThatThrowsDoesNotFailTheDispatch(): void {
|
|
$root = $this->createRoot();
|
|
$this->createModule($root, 'iso-listener', subscribers: "\\TestIsolationEvent::class => function () { throw new \\RuntimeException('listener boom'); }");
|
|
|
|
$loader = new ModuleLoader();
|
|
$loader->loadAll($root);
|
|
$loader->bootAll($this->container);
|
|
EventDispatcher::listen(TestIsolationEvent::class, function (TestIsolationEvent $e): void {
|
|
$e->handled = true;
|
|
}, -10);
|
|
|
|
$event = EventDispatcher::dispatch(new TestIsolationEvent());
|
|
|
|
$this->assertTrue($event->handled, 'listeners after the throwing module one still run');
|
|
}
|
|
|
|
// ── BaseModule subclass integration ───────────────────────────────────
|
|
|
|
public function testModuleExtendingBaseModuleBootsCorrectly(): void {
|
|
$root = $this->createRoot();
|
|
$this->createBaseModuleSubclass($root, 'base-eta');
|
|
|
|
$loader = new ModuleLoader();
|
|
$loader->loadAll($root);
|
|
$loader->bootAll($this->container);
|
|
|
|
$this->assertTrue(TestBootCallTracker::has('boot:base-eta'));
|
|
}
|
|
|
|
// ── getModule() / getManifest() / isLoaded() ─────────────────────────
|
|
|
|
public function testGetModuleReturnsInstanceAfterLoad(): void {
|
|
$root = $this->createRoot();
|
|
$this->createModule($root, 'get-zeta');
|
|
|
|
$loader = new ModuleLoader();
|
|
$loader->loadAll($root);
|
|
|
|
$module = $loader->getModule('get-zeta');
|
|
$this->assertInstanceOf(ModuleInterface::class, $module);
|
|
$this->assertSame('get-zeta', $module->getName());
|
|
}
|
|
|
|
public function testGetModuleReturnsNullForUnknown(): void {
|
|
$loader = new ModuleLoader();
|
|
$loader->loadAll($this->createRoot());
|
|
|
|
$this->assertNull($loader->getModule('no-such-module'));
|
|
}
|
|
|
|
public function testIsLoadedReturnsFalseForUnknown(): void {
|
|
$loader = new ModuleLoader();
|
|
$loader->loadAll($this->createRoot());
|
|
|
|
$this->assertFalse($loader->isLoaded('no-such-module'));
|
|
}
|
|
|
|
public function testGetManifestReturnsModuleMetadata(): void {
|
|
$root = $this->createRoot();
|
|
$this->createModule($root, 'manifest-theta');
|
|
|
|
$loader = new ModuleLoader();
|
|
$loader->loadAll($root);
|
|
|
|
$manifest = $loader->getManifest('manifest-theta');
|
|
$this->assertIsArray($manifest);
|
|
$this->assertSame('manifest-theta', $manifest['name']);
|
|
$this->assertSame('1.0.0', $manifest['version']);
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
|
|
private function createRoot(): string {
|
|
$path = sys_get_temp_dir() . '/xc_vm_boot_' . bin2hex(random_bytes(6));
|
|
mkdir($path, 0775, true);
|
|
return $path;
|
|
}
|
|
|
|
private function className(string $name): string {
|
|
return implode('', array_map('ucfirst', explode('-', $name))) . 'Module';
|
|
}
|
|
|
|
private function writeManifest(string $root, string $name): void {
|
|
$manifest = [
|
|
'name' => $name,
|
|
'description' => 'boot test module',
|
|
'version' => '1.0.0',
|
|
'requires_core' => '>=2.0',
|
|
'environment' => 'main',
|
|
'dependencies' => [],
|
|
'has_navbar' => false,
|
|
'has_settings' => false,
|
|
];
|
|
file_put_contents(
|
|
$root . '/' . $name . '/module.json',
|
|
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
|
|
);
|
|
}
|
|
|
|
private function createModule(
|
|
string $root,
|
|
string $name,
|
|
string $boot = '',
|
|
string $navbar = '',
|
|
string $routes = '',
|
|
string $subscribers = '',
|
|
string $resellerNavbar = ''
|
|
): void {
|
|
$dir = $root . '/' . $name;
|
|
mkdir($dir, 0775, true);
|
|
$this->writeManifest($root, $name);
|
|
|
|
$cls = $this->className($name);
|
|
$ns = $this->namespace($name);
|
|
$subscriberReturn = $subscribers !== '' ? "return [{$subscribers}];" : 'return [];';
|
|
$implements = $resellerNavbar !== '' ? 'ModuleInterface, \\XcVm\\Core\\Module\\Contract\\ResellerNavbarProviderInterface' : 'ModuleInterface';
|
|
$resellerNavbarMethod = $resellerNavbar !== ''
|
|
? "\tpublic function registerResellerNavbar(\\XcVm\\Core\\Module\\ResellerNavbarRegistry \$registry): void { {$resellerNavbar} }\n"
|
|
: '';
|
|
|
|
$code = "<?php\n"
|
|
. "namespace {$ns};\n"
|
|
. "use XcVm\Core\Module\ModuleInterface;\n"
|
|
. "use XcVm\Core\Container\ServiceContainer;\n"
|
|
. "use XcVm\\Core\\Http\\Router;\n"
|
|
. "use XcVm\Cli\CommandRegistry;\n"
|
|
. "use XcVm\Core\Module\NavbarRegistry;\n"
|
|
. "class {$cls} implements {$implements} {\n"
|
|
. "\tpublic function getName(): string { return '{$name}'; }\n"
|
|
. "\tpublic function getVersion(): string { return '1.0.0'; }\n"
|
|
. "\tpublic function boot(ServiceContainer \$container): void { {$boot} }\n"
|
|
. "\tpublic function registerRoutes(\\XcVm\\Core\\Http\\Router \$router): void { {$routes} }\n"
|
|
. "\tpublic function registerCommands(CommandRegistry \$registry): void {}\n"
|
|
. "\tpublic function getEventSubscribers(): array { {$subscriberReturn} }\n"
|
|
. "\tpublic function install(): void {}\n"
|
|
. "\tpublic function uninstall(): void {}\n"
|
|
. "\tpublic function registerNavbar(NavbarRegistry \$registry): void { {$navbar} }\n"
|
|
. $resellerNavbarMethod
|
|
. "}\n";
|
|
|
|
file_put_contents($dir . '/' . $cls . '.php', $code);
|
|
}
|
|
|
|
private function createBaseModuleSubclass(string $root, string $name): void {
|
|
$dir = $root . '/' . $name;
|
|
mkdir($dir, 0775, true);
|
|
$this->writeManifest($root, $name);
|
|
|
|
$cls = $this->className($name);
|
|
$ns = $this->namespace($name);
|
|
|
|
$code = "<?php\n"
|
|
. "namespace {$ns};\n"
|
|
. "use XcVm\Core\Module\BaseModule;\n"
|
|
. "use XcVm\Core\Container\ServiceContainer;\n"
|
|
. "class {$cls} extends BaseModule {\n"
|
|
. "\tpublic function getName(): string { return '{$name}'; }\n"
|
|
. "\tpublic function getVersion(): string { return '1.0.0'; }\n"
|
|
. "\tpublic function boot(ServiceContainer \$container): void {\n"
|
|
. "\t\t\\TestBootCallTracker::record('boot:{$name}');\n"
|
|
. "\t}\n"
|
|
. "}\n";
|
|
|
|
file_put_contents($dir . '/' . $cls . '.php', $code);
|
|
}
|
|
|
|
private function namespace(string $name): string {
|
|
return 'XcVm\\Module\\' . $this->pascalName($name);
|
|
}
|
|
|
|
private function pascalName(string $name): string {
|
|
return implode('', array_map('ucfirst', explode('-', $name)));
|
|
}
|
|
}
|