diff --git a/src/Core/Bootstrap/BootKernel.php b/src/Core/Bootstrap/BootKernel.php new file mode 100644 index 00000000..abcaaa63 --- /dev/null +++ b/src/Core/Bootstrap/BootKernel.php @@ -0,0 +1,54 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class BootKernel { + /** + * @param array $options Caller options, merged over the context defaults. + */ + public function boot(BootContext $context, array $options = []): BootState { + $resolved = array_merge(self::defaults($context), $options); + + $container = ServiceContainer::getInstance(); + $container->set('context', $context->value); + $container->set('options', $resolved); + + $state = new BootState($context, $resolved, $container); + + (new BootPipeline(StageProfiles::for($context, $resolved)))->run($state); + + $state->booted = true; + + return $state; + } + + /** + * Default boot options for a context. + * + * @return array{cached: bool, redis: bool, process: string, shutdown: ?callable} + */ + public static function defaults(BootContext $context): array { + return match ($context) { + BootContext::Admin => ['cached' => false, 'redis' => true, 'process' => '', 'shutdown' => null], + BootContext::Stream => ['cached' => true, 'redis' => false, 'process' => '', 'shutdown' => null], + BootContext::Cli => ['cached' => false, 'redis' => false, 'process' => '', 'shutdown' => null], + BootContext::Minimal => ['cached' => false, 'redis' => false, 'process' => '', 'shutdown' => null], + }; + } +} diff --git a/src/Core/Bootstrap/BootPipeline.php b/src/Core/Bootstrap/BootPipeline.php new file mode 100644 index 00000000..099ebb4b --- /dev/null +++ b/src/Core/Bootstrap/BootPipeline.php @@ -0,0 +1,37 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class BootPipeline { + /** + * @param list $stages Ordered stages. + */ + public function __construct(private array $stages) { + } + + public function run(BootState $state): void { + foreach ($this->stages as $stage) { + $stage->run($state); + } + } + + /** + * @return list + */ + public function stages(): array { + return $this->stages; + } +} diff --git a/src/Core/Bootstrap/BootStageInterface.php b/src/Core/Bootstrap/BootStageInterface.php new file mode 100644 index 00000000..8bcba885 --- /dev/null +++ b/src/Core/Bootstrap/BootStageInterface.php @@ -0,0 +1,20 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +interface BootStageInterface { + public function run(BootState $state): void; +} diff --git a/src/Core/Bootstrap/BootState.php b/src/Core/Bootstrap/BootState.php new file mode 100644 index 00000000..c9f9b55f --- /dev/null +++ b/src/Core/Bootstrap/BootState.php @@ -0,0 +1,65 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class BootState { + public bool $booted = false; + + public bool $devMode = false; + + public bool $constantsLoaded = false; + + public bool $configLoaded = false; + + public bool $loggerStarted = false; + + public bool $databaseReady = false; + + public bool $coreReady = false; + + public bool $adminReady = false; + + public bool $sessionStarted = false; + + public bool $redisReady = false; + + public ?DatabaseHandler $db = null; + + /** + * @param array $options Resolved boot options (cached/redis/process/shutdown). + */ + public function __construct( + public readonly BootContext $context, + public readonly array $options, + public readonly ServiceContainer $container, + ) { + } + + /** + * Whether the process is running under the CLI SAPI. + * + * Several stages (flood/host/session) are HTTP-only and self-skip on CLI. + */ + public function isCli(): bool { + return php_sapi_name() === 'cli' || defined('STDIN'); + } +} diff --git a/src/Core/Bootstrap/Stage/AdminApiStage.php b/src/Core/Bootstrap/Stage/AdminApiStage.php new file mode 100644 index 00000000..aaf2da7c --- /dev/null +++ b/src/Core/Bootstrap/Stage/AdminApiStage.php @@ -0,0 +1,34 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class AdminApiStage implements BootStageInterface { + public function run(BootState $state): void { + if ($state->adminReady) { + return; + } + + if (isset($_SESSION['hash'])) { + $GLOBALS['rAdminUserInfo'] = UserRepository::getRegisteredUserById($_SESSION['hash']); + } + + ResellerAPI::init(); + + $state->adminReady = true; + } +} diff --git a/src/Core/Bootstrap/Stage/AdminGlobalsStage.php b/src/Core/Bootstrap/Stage/AdminGlobalsStage.php new file mode 100644 index 00000000..46e3eda4 --- /dev/null +++ b/src/Core/Bootstrap/Stage/AdminGlobalsStage.php @@ -0,0 +1,110 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class AdminGlobalsStage implements BootStageInterface { + public function run(BootState $state): void { + global $rDetect, $rMobile, $rTimeout, $rSQLTimeout, $rProtocol, + $allServers, $rServers, $rSettings, $rProxyServers, + $rPermissions, $allowedLangs; + + if (!defined('SERVER_ID')) { + define('SERVER_ID', intval(ConfigReader::get('server_id'))); + } + + $rDetect = new \Detection\MobileDetect(); + $rMobile = $rDetect->isMobile(); + + $rTimeout = 15; + $rSQLTimeout = 10; + set_time_limit($rTimeout); + ini_set('mysql.connect_timeout', (string) $rSQLTimeout); + ini_set('max_execution_time', (string) $rTimeout); + ini_set('default_socket_timeout', (string) $rTimeout); + + $rProtocol = $this->detectProtocol(); + $allServers = ServerRepository::getAllSimple(); + $rServers = ServerRepository::getStreamingSimple($rPermissions); + $rSettings = SettingsManager::getAll(); + if ($state->devMode) { + $rSettings['debug_show_errors'] = true; + } + $rProxyServers = ServerRepository::getProxySimple($rPermissions); + + $allowedLangs = Translator::available(); + + // Sort servers by order + if (is_array($rServers)) { + uasort( + $rServers, + function ($a, $b) { + return $a['order'] - $b['order']; + } + ); + } + + // Ensure the legacy 'reseller' assets alias (Public/assets/reseller → admin). + $this->ensureResellerAssetsSymlink(); + } + + /** + * Ensure the legacy 'reseller' assets alias exists (Public/assets/reseller → admin). + * + * Reseller pages reuse the admin asset bundle, and some nginx configs / legacy + * routes expect Public/assets/reseller to resolve to Public/assets/admin. The + * link is not stored in git (an absolute-path symlink broke the build), so the + * panel recreates it here. Idempotent, and repairs a stale/broken link. + */ + private function ensureResellerAssetsSymlink(): void { + $assetsBase = MAIN_HOME . 'Public/assets/'; + $resellerLink = $assetsBase . 'reseller'; + + // Nothing to point at yet — skip. + if (!is_dir($assetsBase . 'admin')) { + return; + } + + if (is_link($resellerLink)) { + // Correct, resolvable link → nothing to do. + if (readlink($resellerLink) === 'admin' && is_dir($resellerLink)) { + return; + } + @unlink($resellerLink); // stale/broken link — recreate below + } elseif (file_exists($resellerLink)) { + return; // a real directory/file lives here — leave it alone + } + + // Relative link so it is independent of MAIN_HOME and path case. + if (!@symlink('admin', $resellerLink)) { + // Without the link every reseller access code serves pages with no + // CSS/JS (nginx aliases /CODE/assets/ to Public/assets/reseller/). + error_log('AdminGlobalsStage: failed to create Public/assets/reseller -> admin symlink — check ownership of Public/assets/ (expected xc_vm).'); + } + } + + /** + * Detect HTTP protocol (http/https). + */ + private function detectProtocol(): string { + $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; + $port443 = isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443; + return ($https || $port443) ? 'https' : 'http'; + } +} diff --git a/src/Core/Bootstrap/Stage/AdminShutdownStage.php b/src/Core/Bootstrap/Stage/AdminShutdownStage.php new file mode 100644 index 00000000..149129b1 --- /dev/null +++ b/src/Core/Bootstrap/Stage/AdminShutdownStage.php @@ -0,0 +1,26 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class AdminShutdownStage implements BootStageInterface { + public function run(BootState $state): void { + register_shutdown_function(function () { + global $db; + if (is_object($db)) { + $db->close_mysql(); + } + }); + } +} diff --git a/src/Core/Bootstrap/Stage/ConfigStage.php b/src/Core/Bootstrap/Stage/ConfigStage.php new file mode 100644 index 00000000..444d3dc8 --- /dev/null +++ b/src/Core/Bootstrap/Stage/ConfigStage.php @@ -0,0 +1,24 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class ConfigStage implements BootStageInterface { + public function run(BootState $state): void { + $state->container->set('config', ConfigReader::getAll()); + $state->configLoaded = true; + } +} diff --git a/src/Core/Bootstrap/Stage/ConstantsStage.php b/src/Core/Bootstrap/Stage/ConstantsStage.php new file mode 100644 index 00000000..0c2682fd --- /dev/null +++ b/src/Core/Bootstrap/Stage/ConstantsStage.php @@ -0,0 +1,47 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class ConstantsStage implements BootStageInterface { + public function run(BootState $state): void { + if ($state->constantsLoaded) { + return; + } + + require_once MAIN_HOME . 'Core/Error/ErrorCodes.php'; + require_once MAIN_HOME . 'Core/Error/ErrorHandler.php'; + require_once MAIN_HOME . 'Core/Config/Paths.php'; + require_once MAIN_HOME . 'Core/Config/AppConfig.php'; + require_once MAIN_HOME . 'Core/Config/Binaries.php'; + + $state->devMode = DEV_MODE; + + if (!defined('PHP_ERRORS')) { + define('PHP_ERRORS', $state->devMode); + } + + Logger::init( + $state->devMode || PHP_ERRORS, + LOGS_TMP_PATH . 'error_log.log' + ); + + $state->constantsLoaded = true; + $state->configLoaded = true; + $state->loggerStarted = true; + } +} diff --git a/src/Core/Bootstrap/Stage/ContainerPopulateStage.php b/src/Core/Bootstrap/Stage/ContainerPopulateStage.php new file mode 100644 index 00000000..50949ddb --- /dev/null +++ b/src/Core/Bootstrap/Stage/ContainerPopulateStage.php @@ -0,0 +1,61 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class ContainerPopulateStage implements BootStageInterface { + public function run(BootState $state): void { + $container = $state->container; + + // Database + if ($state->databaseReady) { + global $db; + $container->set('db', $db); + DomainDatabaseWiring::wire($db); + } + + // Settings and core data + if ($state->coreReady) { + $container->set('settings', SettingsManager::getAll()); + $container->set('servers', ServerRepository::getAll()); + $container->set('bouquets', BouquetService::getAll()); + $container->set('categories', CategoryService::getFromDatabase()); + + if ($state->redisReady && RedisManager::isConnected()) { + $container->set('redis', RedisManager::instance()); + } + } + + // Translator + if (class_exists(Translator::class, false) && Translator::available()) { + $container->set('translator', Translator::class); + } + + // Events — create an instance, wire it as the static singleton bridge, + // and register it in the container so it can be injected via DI. + $dispatcher = new EventDispatcher(); + EventDispatcher::setInstance($dispatcher); + $container->set('events', $dispatcher); + } +} diff --git a/src/Core/Bootstrap/Stage/DatabaseStage.php b/src/Core/Bootstrap/Stage/DatabaseStage.php new file mode 100644 index 00000000..6a90f644 --- /dev/null +++ b/src/Core/Bootstrap/Stage/DatabaseStage.php @@ -0,0 +1,32 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class DatabaseStage implements BootStageInterface { + public function run(BootState $state): void { + if ($state->databaseReady) { + return; + } + + global $db; + + $db = new DatabaseHandler(); + + $state->db = $db; + $state->databaseReady = true; + } +} diff --git a/src/Core/Bootstrap/Stage/FloodProtectionStage.php b/src/Core/Bootstrap/Stage/FloodProtectionStage.php new file mode 100644 index 00000000..7c503a48 --- /dev/null +++ b/src/Core/Bootstrap/Stage/FloodProtectionStage.php @@ -0,0 +1,29 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class FloodProtectionStage implements BootStageInterface { + public function run(BootState $state): void { + if ($state->isCli()) { + return; + } + + $rIP = $_SERVER['REMOTE_ADDR'] ?? ''; + if (!empty($rIP) && file_exists(FLOOD_TMP_PATH . 'block_' . $rIP)) { + http_response_code(403); + exit(); + } + } +} diff --git a/src/Core/Bootstrap/Stage/HealthCheckStage.php b/src/Core/Bootstrap/Stage/HealthCheckStage.php new file mode 100644 index 00000000..c310b87b --- /dev/null +++ b/src/Core/Bootstrap/Stage/HealthCheckStage.php @@ -0,0 +1,47 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class HealthCheckStage implements BootStageInterface { + public function run(BootState $state): void { + $container = $state->container; + + $required = ['events']; + + if ($state->databaseReady) { + $required[] = 'db'; + } + + if ($state->redisReady) { + $required[] = 'redis'; + } + + $missing = []; + foreach ($required as $service) { + if (!$container->has($service)) { + $missing[] = $service; + } + } + + if ($missing !== []) { + throw new RuntimeException( + 'ServiceContainer health check failed — missing required services: ' + . implode(', ', $missing) + ); + } + } +} diff --git a/src/Core/Bootstrap/Stage/HostVerificationStage.php b/src/Core/Bootstrap/Stage/HostVerificationStage.php new file mode 100644 index 00000000..b1511d5a --- /dev/null +++ b/src/Core/Bootstrap/Stage/HostVerificationStage.php @@ -0,0 +1,49 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class HostVerificationStage implements BootStageInterface { + public function run(BootState $state): void { + if ($state->isCli()) { + return; + } + + if (!defined('HOST')) { + $host = trim(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]); + define('HOST', $host); + } + + // Domain check via settings cache + if (file_exists(CACHE_TMP_PATH . 'settings')) { + $rData = @file_get_contents(CACHE_TMP_PATH . 'settings'); + if ($rData !== false) { + $rSettings = @igbinary_unserialize($rData); + if (is_array($rSettings) && !empty($rSettings['verify_host'])) { + if (file_exists(CACHE_TMP_PATH . 'allowed_domains')) { + $rDomains = @igbinary_unserialize(@file_get_contents(CACHE_TMP_PATH . 'allowed_domains')); + if ( + is_array($rDomains) && count($rDomains) > 0 + && !in_array(HOST, $rDomains) && HOST !== 'xc_vm' + && !filter_var(HOST, FILTER_VALIDATE_IP) + ) { + \generateError('INVALID_HOST'); + } + } + } + } + } + } +} diff --git a/src/Core/Bootstrap/Stage/LegacyCoreStage.php b/src/Core/Bootstrap/Stage/LegacyCoreStage.php new file mode 100644 index 00000000..28745d3f --- /dev/null +++ b/src/Core/Bootstrap/Stage/LegacyCoreStage.php @@ -0,0 +1,56 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class LegacyCoreStage implements BootStageInterface { + /** + * @param bool $cached Load settings from the file cache instead of SQL + * (for high-load paths). + */ + public function __construct(private bool $cached = false) { + } + + public function run(BootState $state): void { + if ($state->coreReady) { + return; + } + + global $db; + + DatabaseFactory::set($db); + + // Wire $db into the domain service classes before initCore() runs, + // since initCore() calls ServerRepository::getAll() which requires it. + DomainDatabaseWiring::wire($db); + + LegacyInitializer::initCore($this->cached); + + // If cache was used and is incomplete — reconnect to DB + if ($this->cached && !SettingsManager::get('enable_cache')) { + $db = new DatabaseHandler(); + DatabaseFactory::set($db); + DomainDatabaseWiring::wire($db); + } + + $state->db = $db; + $state->coreReady = true; + } +} diff --git a/src/Core/Bootstrap/Stage/ProcessTitleStage.php b/src/Core/Bootstrap/Stage/ProcessTitleStage.php new file mode 100644 index 00000000..8d734292 --- /dev/null +++ b/src/Core/Bootstrap/Stage/ProcessTitleStage.php @@ -0,0 +1,26 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class ProcessTitleStage implements BootStageInterface { + public function __construct(private string $process) { + } + + public function run(BootState $state): void { + if ($this->process !== '') { + cli_set_process_title($this->process); + } + } +} diff --git a/src/Core/Bootstrap/Stage/RedisStage.php b/src/Core/Bootstrap/Stage/RedisStage.php new file mode 100644 index 00000000..5f183d85 --- /dev/null +++ b/src/Core/Bootstrap/Stage/RedisStage.php @@ -0,0 +1,28 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class RedisStage implements BootStageInterface { + public function run(BootState $state): void { + if ($state->redisReady) { + return; + } + + $state->redisReady = RedisManager::ensureConnected(); + } +} diff --git a/src/Core/Bootstrap/Stage/SessionStage.php b/src/Core/Bootstrap/Stage/SessionStage.php new file mode 100644 index 00000000..ae0e71ea --- /dev/null +++ b/src/Core/Bootstrap/Stage/SessionStage.php @@ -0,0 +1,39 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class SessionStage implements BootStageInterface { + public function run(BootState $state): void { + if ($state->sessionStarted || $state->isCli()) { + return; + } + + if (session_status() === PHP_SESSION_NONE) { + $rParams = session_get_cookie_params(); + $rParams['samesite'] = 'Strict'; + // The panel's scripts never read the session cookie, so an XSS must + // not be able to either. + $rParams['httponly'] = true; + session_set_cookie_params($rParams); + // Refuse session ids this server never issued, so a visitor cannot + // arrive carrying one an attacker chose. + ini_set('session.use_strict_mode', '1'); + session_start(); + } + + $state->sessionStarted = true; + } +} diff --git a/src/Core/Bootstrap/Stage/StatusConstantsStage.php b/src/Core/Bootstrap/Stage/StatusConstantsStage.php new file mode 100644 index 00000000..efc5e0ab --- /dev/null +++ b/src/Core/Bootstrap/Stage/StatusConstantsStage.php @@ -0,0 +1,22 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class StatusConstantsStage implements BootStageInterface { + public function run(BootState $state): void { + ConstantsInitializer::initStatus(); + } +} diff --git a/src/Core/Bootstrap/Stage/TranslatorStage.php b/src/Core/Bootstrap/Stage/TranslatorStage.php new file mode 100644 index 00000000..1bc092c1 --- /dev/null +++ b/src/Core/Bootstrap/Stage/TranslatorStage.php @@ -0,0 +1,22 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class TranslatorStage implements BootStageInterface { + public function run(BootState $state): void { + Translator::init(); + } +} diff --git a/src/Core/Bootstrap/StageProfiles.php b/src/Core/Bootstrap/StageProfiles.php new file mode 100644 index 00000000..284d6009 --- /dev/null +++ b/src/Core/Bootstrap/StageProfiles.php @@ -0,0 +1,91 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class StageProfiles { + /** + * @param array $options Resolved boot options. + * @return list + */ + public static function for(BootContext $context, array $options): array { + // Common prefix, run for every context (HTTP-only stages self-skip on CLI). + $stages = [ + new ConstantsStage(), + new ConfigStage(), + new FloodProtectionStage(), + new HostVerificationStage(), + ]; + + switch ($context) { + case BootContext::Minimal: + break; + + case BootContext::Cli: + $stages[] = new DatabaseStage(); + $stages[] = new LegacyCoreStage((bool) ($options['cached'] ?? false)); + if (!empty($options['redis'])) { + $stages[] = new RedisStage(); + } + if (!empty($options['process'])) { + $stages[] = new ProcessTitleStage((string) $options['process']); + } + break; + + case BootContext::Stream: + $stages[] = new DatabaseStage(); + break; + + case BootContext::Admin: + $stages[] = new SessionStage(); + $stages[] = new DatabaseStage(); + $stages[] = new LegacyCoreStage(false); + $stages[] = new RedisStage(); + $stages[] = new AdminApiStage(); + $stages[] = new TranslatorStage(); + $stages[] = new AdminShutdownStage(); + $stages[] = new StatusConstantsStage(); + $stages[] = new AdminGlobalsStage(); + break; + } + + $stages[] = new ContainerPopulateStage(); + + if ($context !== BootContext::Minimal) { + $stages[] = new HealthCheckStage(); + } + + return $stages; + } +} diff --git a/src/Infrastructure/Database/DatabaseFactory.php b/src/Infrastructure/Database/DatabaseFactory.php index 588e2ef7..3eaffcfa 100644 --- a/src/Infrastructure/Database/DatabaseFactory.php +++ b/src/Infrastructure/Database/DatabaseFactory.php @@ -65,4 +65,15 @@ class DatabaseFactory { } self::$instance = null; } + + /** + * Clears the singleton registry without closing the connection. + * + * Test seam for deterministic per-test state: unlike close(), it does not + * touch the global $db or call close_mysql(), so it is safe with the fake + * DatabaseHandler subtypes tests inject. + */ + public static function reset(): void { + self::$instance = null; + } } diff --git a/src/bootstrap.php b/src/bootstrap.php index 26b2c13d..c15231f1 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -46,6 +46,9 @@ * require_once '/home/xc_vm/bootstrap.php'; * XC_Bootstrap::boot(XC_Bootstrap::CONTEXT_MINIMAL); * + * The heavy lifting now lives in XcVm\Core\Bootstrap\BootKernel and its stages; + * XC_Bootstrap is a thin, backward-compatible static facade over that pipeline. + * * @package XC_VM * @author Divarion_D * @copyright 2025-2026 Vateron Media @@ -53,25 +56,14 @@ * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ -use XcVm\Core\Config\ConfigReader; +use XcVm\Core\Bootstrap\BootKernel; +use XcVm\Core\Bootstrap\BootState; use XcVm\Core\Config\ConstantsInitializer; -use XcVm\Core\Config\SettingsManager; use XcVm\Core\Container\ServiceContainer; use XcVm\Core\Database\Database; -use XcVm\Core\Database\DatabaseHandler; use XcVm\Core\Enum\BootContext; use XcVm\Core\Events\EventDispatcher; -use XcVm\Core\Init\LegacyInitializer; -use XcVm\Core\Localization\Translator; -use XcVm\Core\Logging\Logger; -use XcVm\Domain\Bouquet\BouquetService; -use XcVm\Domain\Server\ServerRepository; -use XcVm\Domain\Stream\CategoryService; -use XcVm\Domain\User\ResellerAPI; -use XcVm\Domain\User\UserRepository; -use XcVm\Infrastructure\Bootstrap\DomainDatabaseWiring; use XcVm\Infrastructure\Database\DatabaseFactory; -use XcVm\Infrastructure\Redis\RedisManager; // ───────────────────────────────────────────────────────────────── // 1. Class autoloader @@ -114,7 +106,7 @@ if (!function_exists('getallheaders')) { // ───────────────────────────────────────────────────────────────── -// 3. XC_Bootstrap class +// 3. XC_Bootstrap facade // ───────────────────────────────────────────────────────────────── class XC_Bootstrap { @@ -128,34 +120,11 @@ class XC_Bootstrap { /** @deprecated Use BootContext::Admin */ const CONTEXT_ADMIN = 'admin'; - // ── Internal state ─────────────────────────────────────── - private static bool $booted = false; - - private static ?string $context = null; - - private static array $options = []; - - private static bool $devMode = false; - - // ── Subsystem initialization flags ──────────────────────── - private static bool $constantsLoaded = false; - - private static bool $configLoaded = false; - - private static bool $loggerStarted = false; - - private static bool $databaseReady = false; - - private static bool $coreReady = false; - - private static bool $adminReady = false; - - private static bool $sessionStarted = false; - - private static bool $redisReady = false; + /** Result of the last boot(); null until the first boot. */ + private static ?BootState $state = null; /** - * Main entry point. + * Main entry point — delegates to the BootKernel pipeline. * * @param string|BootContext $context Boot context. Accepts BootContext enum or legacy string constant. * @param array $options Additional options: @@ -165,68 +134,27 @@ class XC_Bootstrap { * 'shutdown' => callable Shutdown callback (replaces register_shutdown_function) */ public static function boot(string|BootContext $context = BootContext::Cli, array $options = []): void { - if (self::$booted) { + if (self::$state?->booted) { return; } $ctx = $context instanceof BootContext ? $context : BootContext::from($context); - self::$context = $ctx->value; - self::$options = array_merge(self::defaults($ctx), $options); - - // ── Create container ──────────────────────────────────── - $container = ServiceContainer::getInstance(); - $container->set('context', $ctx->value); - $container->set('options', self::$options); - - // ── Common for all contexts ──────────────────────────── - - self::loadConstants(); - - $container->set('config', ConfigReader::getAll()); - - // ── Flood-protection (HTTP only) ─────────────────────── - if (!self::isCli()) { - self::floodProtection(); - self::hostVerification(); - } - - // ── Context-dependent initialization ─────────────────── - - match ($ctx) { - BootContext::Minimal => null, - BootContext::Cli => self::bootCli(), - BootContext::Stream => self::bootStream(), - BootContext::Admin => self::bootAdmin(), - }; - - // ── Register services in the container ────────────────── - self::populateContainer($container); - - // ── Verify that expected services were registered ──────── - if ($ctx !== BootContext::Minimal) { - self::assertContainerHealth($container); - } - - self::$booted = true; + self::$state = (new BootKernel())->boot($ctx, $options); } - // ───────────────────────────────────────────────────────── - // Public getters - // ───────────────────────────────────────────────────────── - /** * Current boot context. */ public static function getContext(): ?string { - return self::$context; + return self::$state?->context->value; } /** * Whether bootstrap has been executed. */ public static function isBooted(): bool { - return self::$booted; + return (bool) self::$state?->booted; } /** @@ -234,7 +162,7 @@ class XC_Bootstrap { * When true, PHP errors are displayed on-screen regardless of DB settings. */ public static function isDevMode(): bool { - return self::$devMode; + return (bool) self::$state?->devMode; } /** @@ -247,8 +175,6 @@ class XC_Bootstrap { /** * Get the ServiceContainer. - * - * @return ServiceContainer */ public static function getContainer(): ServiceContainer { return ServiceContainer::getInstance(); @@ -262,516 +188,22 @@ class XC_Bootstrap { } /** - * Force reset (for testing). + * Force reset (for testing): drops the boot state and the process-wide + * singletons the pipeline populates. */ public static function reset(): void { - self::$booted = false; - self::$context = null; - self::$options = []; - self::$constantsLoaded = false; - self::$configLoaded = false; - self::$loggerStarted = false; - self::$databaseReady = false; - self::$coreReady = false; - self::$adminReady = false; - self::$sessionStarted = false; - self::$redisReady = false; + self::$state = null; ServiceContainer::resetInstance(); + EventDispatcher::resetInstance(); + DatabaseFactory::reset(); } - // ───────────────────────────────────────────────────────── - // Context boot sequences - // ───────────────────────────────────────────────────────── - - /** - * Boot sequence for the CLI context: database, legacy core, optional redis - * and process title. - * - * @return void - */ - private static function bootCli(): void { - self::initDatabase(self::$options['cached']); - self::initLegacyCore(self::$options['cached']); - if (self::$options['redis']) { - self::initRedis(); - } - if (!empty(self::$options['process'])) { - cli_set_process_title(self::$options['process']); - } - } - - /** - * Boot sequence for the streaming context: cached database connection only. - * - * @return void - */ - private static function bootStream(): void { - self::initDatabase(true); - } - - /** - * Boot sequence for the admin context: session, database, legacy core, redis, - * admin API, translator, shutdown handler and status constants. - * - * @return void - */ - private static function bootAdmin(): void { - self::initSession(); - self::initDatabase(false); - self::initLegacyCore(false); - self::initRedis(); - self::initAdminAPI(); - self::initTranslator(); - self::registerAdminShutdown(); - self::defineStatusConstants(); - self::initAdminGlobals(); - } - - // ───────────────────────────────────────────────────────── - // Subsystem initialization (each called at most once) - // ───────────────────────────────────────────────────────── - - /** - * Load constants, paths, Logger, error functions. - * - * Loads core configuration directly (without www/constants.php): - * core/Error/ErrorCodes.php — $rErrorCodes - * core/Error/ErrorHandler.php — generateError(), generate404() - * core/Config/Paths.php — *_PATH constants - * core/Config/AppConfig.php — version, Git, flags - * core/Config/Binaries.php — FFMPEG, FFPROBE, GeoIP - */ - private static function loadConstants(): void { - if (self::$constantsLoaded) { - return; - } - - require_once MAIN_HOME . 'Core/Error/ErrorCodes.php'; - require_once MAIN_HOME . 'Core/Error/ErrorHandler.php'; - require_once MAIN_HOME . 'Core/Config/Paths.php'; - require_once MAIN_HOME . 'Core/Config/AppConfig.php'; - require_once MAIN_HOME . 'Core/Config/Binaries.php'; - - self::$devMode = DEV_MODE; - - if (!defined('PHP_ERRORS')) { - define('PHP_ERRORS', self::$devMode); - } - - Logger::init( - self::$devMode || PHP_ERRORS, - LOGS_TMP_PATH . 'error_log.log' - ); - - self::$constantsLoaded = true; - self::$configLoaded = true; - self::$loggerStarted = true; - } - - /** - * Flood-protection: block banned IPs. - * - * Called for HTTP contexts only. - */ - private static function floodProtection(): void { - if (self::isCli()) { - return; - } - - $rIP = $_SERVER['REMOTE_ADDR'] ?? ''; - if (!empty($rIP) && file_exists(FLOOD_TMP_PATH . 'block_' . $rIP)) { - http_response_code(403); - exit(); - } - } - - /** - * Host verification: ensure request comes from an allowed domain. - */ - private static function hostVerification(): void { - if (self::isCli()) { - return; - } - - if (!defined('HOST')) { - $host = trim(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]); - define('HOST', $host); - } - - // Domain check via settings cache - if (file_exists(CACHE_TMP_PATH . 'settings')) { - $rData = @file_get_contents(CACHE_TMP_PATH . 'settings'); - if ($rData !== false) { - $rSettings = @igbinary_unserialize($rData); - if (is_array($rSettings) && !empty($rSettings['verify_host'])) { - if (file_exists(CACHE_TMP_PATH . 'allowed_domains')) { - $rDomains = @igbinary_unserialize(@file_get_contents(CACHE_TMP_PATH . 'allowed_domains')); - if ( - is_array($rDomains) && count($rDomains) > 0 - && !in_array(HOST, $rDomains) && HOST !== 'xc_vm' - && !filter_var(HOST, FILTER_VALIDATE_IP) - ) { - generateError('INVALID_HOST'); - } - } - } - } - } - } - - /** - * Start PHP session with secure parameters. - * - * HTTP contexts only (admin/reseller). - */ - private static function initSession(): void { - if (self::$sessionStarted || self::isCli()) { - return; - } - - if (session_status() === PHP_SESSION_NONE) { - $rParams = session_get_cookie_params() ?: []; - $rParams['samesite'] = 'Strict'; - // The panel's scripts never read the session cookie, so an XSS must - // not be able to either. - $rParams['httponly'] = true; - session_set_cookie_params($rParams); - // Refuse session ids this server never issued, so a visitor cannot - // arrive carrying one an attacker chose. - ini_set('session.use_strict_mode', '1'); - session_start(); - } - - self::$sessionStarted = true; - } - - /** - * Connect to MySQL/MariaDB. - * - * Creates the global $db variable (backward compatibility). - * - * @param bool $cached If true, LegacyInitializer will use - * file cache instead of SQL queries for settings. - */ - private static function initDatabase(bool $cached = false): void { - if (self::$databaseReady) { - return; - } - - global $db; - - $db = new DatabaseHandler(); - - self::$databaseReady = true; - } - - /** - * Initialize legacy core subsystems. - * - * Sanitizes globals ($_GET, $_POST, $_SESSION, $_COOKIE), - * parses config, defines SERVER_ID, selects FFmpeg binaries, - * loads settings (from DB or cache). - * - * @param bool $cached Use cache for settings (for high-load paths) - */ - private static function initLegacyCore(bool $cached = false): void { - if (self::$coreReady) { - return; - } - - global $db; - - DatabaseFactory::set($db); - - // Wire $db into the domain service classes before initCore() runs, - // since initCore() calls ServerRepository::getAll() which requires it. - self::wireDomainDatabase($db); - - LegacyInitializer::initCore($cached); - - // If cache was used and is incomplete — reconnect to DB - if ($cached && !SettingsManager::get('enable_cache')) { - $db = new DatabaseHandler(); - DatabaseFactory::set($db); - self::wireDomainDatabase($db); - } - - self::$coreReady = true; - } - - /** - * Connect to Redis. - */ - private static function initRedis(): void { - if (self::$redisReady) { - return; - } - - // $redisReady means "connected", not "attempted": assertContainerHealth() - // requires the 'redis' service only when this flag is set, so a failed - // connection must leave it false — the panel degrades instead of 500ing. - self::$redisReady = RedisManager::ensureConnected(); - } - - /** - * Initialize Admin API + Reseller API. - * - * Initializes ResellerAPI class and admin user info. - */ - private static function initAdminAPI(): void { - if (self::$adminReady) { - return; - } - - global $db; - - - // Admin user info - if (isset($_SESSION['hash'])) { - $GLOBALS['rAdminUserInfo'] = UserRepository::getRegisteredUserById($_SESSION['hash']); - } - - ResellerAPI::init(); - - self::$adminReady = true; - } - - /** - * Initialize Translator (i18n). - */ - private static function initTranslator(): void { - Translator::init(); - } - - /** - * Register shutdown function for admin context. - * - * Closes the MySQL connection on script termination. - */ - private static function registerAdminShutdown(): void { - register_shutdown_function(function () { - global $db; - if (is_object($db)) { - $db->close_mysql(); - } - }); - } - - /** - * Populate the container with initialized services. - * - * Called at the end of boot() — all subsystems are already running, - * so it is safe to reference $db, SettingsManager, etc. - * - * Container stores: - * 'db' => Database — PDO wrapper - * 'config' => array — $_INFO from config.ini - * 'settings' => array — panel settings - * 'redis' => Redis|null — Redis connection - * 'servers' => array — server list - * 'bouquets' => array — bouquets - * 'categories' => array — categories - * 'translator' => string — Translator class - * 'events' => string — EventDispatcher class - * - */ - private static function populateContainer(ServiceContainer $container): void { - // Database - if (self::$databaseReady) { - global $db; - $container->set('db', $db); - self::wireDomainDatabase($db); - } - - // Settings and core data - if (self::$coreReady) { - $container->set('settings', SettingsManager::getAll()); - $container->set('servers', ServerRepository::getAll()); - $container->set('bouquets', BouquetService::getAll()); - $container->set('categories', CategoryService::getFromDatabase()); - - if (self::$redisReady && RedisManager::isConnected()) { - $container->set('redis', RedisManager::instance()); - } - } - - // Translator - if (class_exists(Translator::class, false) && Translator::available()) { - $container->set('translator', Translator::class); - } - - // Events — create an instance, wire it as the static singleton bridge, - // and register it in the container so it can be injected via DI. - $dispatcher = new EventDispatcher(); - EventDispatcher::setInstance($dispatcher); - $container->set('events', $dispatcher); - } - - /** - * Wire the injected $db instance into every domain service class. - * - * Domain classes use the static setDb() / db() pattern: setDb() stores - * the injected instance; db() returns it. Calling this method removes the - * need for the global $db fallback inside each db() helper. - * - * @param DatabaseHandler $db DatabaseHandler instance - */ - private static function wireDomainDatabase(object $db): void { - DomainDatabaseWiring::wire($db); - } - - /** - * Verify that services which should have been registered actually are. - * - * Called after populateContainer() for every context except CONTEXT_MINIMAL. - * Throws RuntimeException on the first missing service so the application - * fails loudly instead of silently degrading. - * - * @throws RuntimeException - */ - private static function assertContainerHealth(ServiceContainer $container): void { - $required = ['events']; - - if (self::$databaseReady) { - $required[] = 'db'; - } - - if (self::$redisReady) { - $required[] = 'redis'; - } - - $missing = []; - foreach ($required as $service) { - if (!$container->has($service)) { - $missing[] = $service; - } - } - - if ($missing !== []) { - throw new RuntimeException( - 'ServiceContainer health check failed — missing required services: ' - . implode(', ', $missing) - ); - } - } - - /** - * Default boot options for a context. - * - * @param BootContext $ctx Boot context. - * @return array Options: cached, redis, process, shutdown. - */ - private static function defaults(BootContext $ctx): array { - return match ($ctx) { - BootContext::Admin => ['cached' => false, 'redis' => true, 'process' => '', 'shutdown' => null], - BootContext::Stream => ['cached' => true, 'redis' => false, 'process' => '', 'shutdown' => null], - BootContext::Cli => ['cached' => false, 'redis' => false, 'process' => '', 'shutdown' => null], - BootContext::Minimal => ['cached' => false, 'redis' => false, 'process' => '', 'shutdown' => null], - }; - } - - /** - * Initialize admin globals: MobileDetect, timeouts, servers, protocol. - */ - private static function initAdminGlobals(): void { - global $rDetect, $rMobile, $rTimeout, $rSQLTimeout, $rProtocol, - $allServers, $rServers, $rSettings, $rProxyServers, - $rPermissions, $allowedLangs; - - if (!defined('SERVER_ID')) { - define('SERVER_ID', intval(ConfigReader::get('server_id'))); - } - - $rDetect = new \Detection\MobileDetect(); - $rMobile = $rDetect->isMobile(); - - $rTimeout = 15; - $rSQLTimeout = 10; - set_time_limit($rTimeout); - ini_set('mysql.connect_timeout', (string) $rSQLTimeout); - ini_set('max_execution_time', (string) $rTimeout); - ini_set('default_socket_timeout', (string) $rTimeout); - - $rProtocol = self::detectProtocol(); - $allServers = ServerRepository::getAllSimple(); - $rServers = ServerRepository::getStreamingSimple($rPermissions); - $rSettings = SettingsManager::getAll(); - if (self::$devMode) { - $rSettings['debug_show_errors'] = true; - } - $rProxyServers = ServerRepository::getProxySimple($rPermissions); - - $allowedLangs = Translator::available(); - - // Sort servers by order - if (is_array($rServers)) { - uasort( - $rServers, - function ($a, $b) { - return $a['order'] - $b['order']; - } - ); - } - - // Ensure the legacy 'reseller' assets alias (Public/assets/reseller → admin). - self::ensureResellerAssetsSymlink(); - } - - /** - * Ensure the legacy 'reseller' assets alias exists (Public/assets/reseller → admin). - * - * Reseller pages reuse the admin asset bundle, and some nginx configs / legacy - * routes expect Public/assets/reseller to resolve to Public/assets/admin. The - * link is not stored in git (an absolute-path symlink broke the build), so the - * panel recreates it here. Idempotent, and repairs a stale/broken link. - */ - private static function ensureResellerAssetsSymlink(): void { - $assetsBase = MAIN_HOME . 'Public/assets/'; - $resellerLink = $assetsBase . 'reseller'; - - // Nothing to point at yet — skip. - if (!is_dir($assetsBase . 'admin')) { - return; - } - - if (is_link($resellerLink)) { - // Correct, resolvable link → nothing to do. - if (readlink($resellerLink) === 'admin' && is_dir($resellerLink)) { - return; - } - @unlink($resellerLink); // stale/broken link — recreate below - } elseif (file_exists($resellerLink)) { - return; // a real directory/file lives here — leave it alone - } - - // Relative link so it is independent of MAIN_HOME and path case. - if (!@symlink('admin', $resellerLink)) { - // Without the link every reseller access code serves pages with no - // CSS/JS (nginx aliases /CODE/assets/ to Public/assets/reseller/). - error_log('XC_Bootstrap: failed to create Public/assets/reseller -> admin symlink — check ownership of Public/assets/ (expected xc_vm).'); - } - } - - /** - * Detect HTTP protocol (http/https). - */ - private static function detectProtocol(): string { - $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; - $port443 = isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443; - return ($https || $port443) ? 'https' : 'http'; - } - - // ───────────────────────────────────────────────────────── - // Status constants (from admin.php) - // ───────────────────────────────────────────────────────── - /** * Define status constants (STATUS_FAILURE, STATUS_SUCCESS, ...). * - * Used throughout admin and reseller API handlers. - * Called automatically in CONTEXT_ADMIN. - * Can be called manually when needed. + * Used throughout admin and reseller API handlers. Called automatically in + * the Admin context; can be called manually when needed. */ public static function defineStatusConstants(): void { ConstantsInitializer::initStatus(); diff --git a/tests/Unit/BootPipelineTest.php b/tests/Unit/BootPipelineTest.php new file mode 100644 index 00000000..f7bb050c --- /dev/null +++ b/tests/Unit/BootPipelineTest.php @@ -0,0 +1,162 @@ +log->append($this->name); + } + }; + } + + public function testPipelineRunsStagesInOrder(): void { + $log = new ArrayObject(); + + (new BootPipeline([ + $this->recordingStage('a', $log), + $this->recordingStage('b', $log), + $this->recordingStage('c', $log), + ]))->run($this->state()); + + $this->assertSame(['a', 'b', 'c'], $log->getArrayCopy()); + } + + public function testPipelineAbortsOnThrow(): void { + $log = new ArrayObject(); + $boom = new class implements BootStageInterface { + public function run(BootState $state): void { + throw new RuntimeException('boom'); + } + }; + + $this->expectException(RuntimeException::class); + try { + (new BootPipeline([$boom, $this->recordingStage('after', $log)]))->run($this->state()); + } finally { + $this->assertNotContains('after', $log->getArrayCopy(), 'stage after a throwing stage must not run'); + } + } + + /** + * @param array $options + * @return list + */ + private function classesFor(BootContext $ctx, array $options = []): array { + $opts = array_merge(BootKernel::defaults($ctx), $options); + return array_map('get_class', StageProfiles::for($ctx, $opts)); + } + + public function testMinimalProfile(): void { + $this->assertSame([ + ConstantsStage::class, + ConfigStage::class, + FloodProtectionStage::class, + HostVerificationStage::class, + ContainerPopulateStage::class, + ], $this->classesFor(BootContext::Minimal)); + } + + public function testCliProfileDefaultsOmitRedisAndProcessTitle(): void { + $this->assertSame([ + ConstantsStage::class, + ConfigStage::class, + FloodProtectionStage::class, + HostVerificationStage::class, + DatabaseStage::class, + LegacyCoreStage::class, + ContainerPopulateStage::class, + HealthCheckStage::class, + ], $this->classesFor(BootContext::Cli)); + } + + public function testCliProfileAddsRedisAndProcessTitleWhenRequested(): void { + $classes = $this->classesFor(BootContext::Cli, ['redis' => true, 'process' => 'worker']); + + $this->assertContains(RedisStage::class, $classes); + $this->assertContains(ProcessTitleStage::class, $classes); + } + + public function testStreamProfileIsDatabaseOnly(): void { + $this->assertSame([ + ConstantsStage::class, + ConfigStage::class, + FloodProtectionStage::class, + HostVerificationStage::class, + DatabaseStage::class, + ContainerPopulateStage::class, + HealthCheckStage::class, + ], $this->classesFor(BootContext::Stream)); + } + + public function testAdminProfileFullSequence(): void { + $this->assertSame([ + ConstantsStage::class, + ConfigStage::class, + FloodProtectionStage::class, + HostVerificationStage::class, + SessionStage::class, + DatabaseStage::class, + LegacyCoreStage::class, + RedisStage::class, + AdminApiStage::class, + TranslatorStage::class, + AdminShutdownStage::class, + StatusConstantsStage::class, + AdminGlobalsStage::class, + ContainerPopulateStage::class, + HealthCheckStage::class, + ], $this->classesFor(BootContext::Admin)); + } + + public function testMinimalHasNoHealthCheck(): void { + $this->assertNotContains(HealthCheckStage::class, $this->classesFor(BootContext::Minimal)); + } + + public function testDefaultsPerContext(): void { + $this->assertTrue(BootKernel::defaults(BootContext::Admin)['redis']); + $this->assertFalse(BootKernel::defaults(BootContext::Cli)['redis']); + $this->assertTrue(BootKernel::defaults(BootContext::Stream)['cached']); + $this->assertFalse(BootKernel::defaults(BootContext::Admin)['cached']); + } +} diff --git a/tests/Unit/BootStageTest.php b/tests/Unit/BootStageTest.php new file mode 100644 index 00000000..4019e7c5 --- /dev/null +++ b/tests/Unit/BootStageTest.php @@ -0,0 +1,100 @@ +freshState(); + + $this->assertFalse($state->booted); + $this->assertFalse($state->databaseReady); + $this->assertFalse($state->coreReady); + $this->assertFalse($state->redisReady); + $this->assertNull($state->db); + $this->assertSame(BootContext::Minimal, $state->context); + } + + public function testBootStateIsCliUnderCliSapi(): void { + // The test suite itself runs under the CLI SAPI. + $this->assertTrue($this->freshState()->isCli()); + } + + public function testContainerPopulateAlwaysRegistersEvents(): void { + $state = $this->freshState(); + + (new ContainerPopulateStage())->run($state); + + $this->assertTrue($state->container->has('events')); + $this->assertInstanceOf(EventDispatcher::class, $state->container->get('events')); + } + + public function testContainerPopulateSkipsDbWhenNotReady(): void { + $state = $this->freshState(); + + (new ContainerPopulateStage())->run($state); + + $this->assertFalse($state->container->has('db')); + $this->assertFalse($state->container->has('settings')); + } + + public function testHealthCheckPassesWhenEventsPresent(): void { + $state = $this->freshState(); + $state->container->set('events', new EventDispatcher()); + + (new HealthCheckStage())->run($state); + + $this->assertTrue(true); // reached without throwing + } + + public function testHealthCheckThrowsWhenEventsMissing(): void { + $state = $this->freshState(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('events'); + (new HealthCheckStage())->run($state); + } + + public function testHealthCheckRequiresDbWhenDatabaseReady(): void { + $state = $this->freshState(); + $state->container->set('events', new EventDispatcher()); + $state->databaseReady = true; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('db'); + (new HealthCheckStage())->run($state); + } + + public function testDatabaseFactoryResetClearsRegistry(): void { + $db = new TestDb(); + DatabaseFactory::set($db); + $this->assertSame($db, DatabaseFactory::get()); + + DatabaseFactory::reset(); + + $this->assertNull(DatabaseFactory::get()); + } +}