Files
cacti/tests/Unit/Installer/InstallerResilienceTest.php
Thomas Vincent 06c32ab13f fix(installer): preserve upgrade state and propagate failures (#8266)
* fix(installer): preserve upgrade state and propagate failures

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* docs(changelog): record installer recovery fixes for #8266

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test(security): refresh installer sink inventories

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(installer): retain merge precedence for browser parameters

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(installer): defer debug settings until the process lock is held

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(installer): enforce table conversion failures and postconditions

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test(installer): refresh SQL audit baseline after clause refactor

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
2026-10-11 17:08:12 -04:00

140 lines
6.9 KiB
PHP

<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
+-------------------------------------------------------------------------+
*/
require_once dirname(__DIR__, 2) . '/Helpers/CactiStubs.php';
require_once CACTI_PATH_INCLUDE . '/global.php';
require_once CACTI_PATH_BASE . '/install/functions.php';
require_once CACTI_PATH_LIBRARY . '/installer.php';
test('remote database version results expose success rather than the failure flag', function () {
$compatible = install_remote_database_version_result('1.3.0', '1.3.0');
$tooNew = install_remote_database_version_result('1.2.31', '1.3.0');
expect($compatible)
->toMatchArray(['status' => 'true'])
->and($tooNew)
->toMatchArray(['status' => 'false'])
->and($tooNew['message'])
->toContain('Main Primary at 1.2.31')
->toContain('Remote at 1.3.0');
});
test('template selection reads and writes the same installer setting namespace', function () {
$source = file_get_contents(CACTI_PATH_LIBRARY . '/installer.php');
expect($source)->not->toBeFalse()
->and($source)->toContain('$this->setTemplates($this->getTemplates());')
->and(substr_count($source, "LIKE 'install_tp_%'"))->toBeGreaterThanOrEqual(2)
->and($source)->not->toContain("LIKE \\'install_template_%\\'")
->and($source)->toContain('key: install_tp_$key');
});
test('package imports fail when their result or any file handoff is incomplete', function () {
$installer = (new ReflectionClass(Installer::class))->newInstanceWithoutConstructor();
$method = new ReflectionMethod(Installer::class, 'packageImportSucceeded');
expect($method->invoke($installer, false))->toBeFalse()
->and($method->invoke($installer, []))->toBeFalse()
->and($method->invoke($installer, [[], []]))->toBeTrue()
->and($method->invoke($installer, [[], ['/tmp/file' => __('written')]]))->toBeTrue()
->and($method->invoke($installer, [[], ['/tmp/file' => __('not writable')]]))->toBeFalse();
});
test('installer completion is guarded by schema and version postconditions', function () {
$source = file_get_contents(CACTI_PATH_LIBRARY . '/installer.php');
expect($source)->not->toBeFalse()
->and(substr_count($source, '$this->validateRequiredSchema()'))->toBe(2)
->and($source)->toContain('private function writeInstalledVersion() : bool')
->and($source)->toContain('SELECT COUNT(*) AS row_count, MAX(cacti) AS cacti FROM version')
->and($source)->not->toContain('TRUNCATE TABLE version');
});
test('installer completion validates schema and version with one query each', function () {
$source = file_get_contents(CACTI_PATH_LIBRARY . '/installer.php');
expect($source)->not->toBeFalse();
$schemaStart = strpos($source, 'private function validateRequiredSchema()');
$schemaEnd = strpos($source, 'private function writeInstalledVersion()', $schemaStart);
$schema = substr($source, $schemaStart, $schemaEnd - $schemaStart);
$versionStart = strpos($source, 'private function writeInstalledVersion()');
$versionEnd = strpos($source, 'private function installTemplate()', $versionStart);
$version = substr($source, $versionStart, $versionEnd - $versionStart);
expect($schemaStart)->not->toBeFalse()
->and($schemaEnd)->not->toBeFalse()
->and(substr_count($schema, 'information_schema.TABLES'))->toBe(1)
->and($schema)->not->toContain('foreach ($requiredTables as $table)')
->and($schema)->toContain('array_diff($requiredTables, $actualTables)')
->and($versionStart)->not->toBeFalse()
->and($versionEnd)->not->toBeFalse()
->and(substr_count($version, 'SELECT '))->toBe(1);
});
test('table conversion loads schema metadata once instead of once per table', function () {
$source = file_get_contents(CACTI_PATH_BASE . '/install/functions.php');
expect($source)->not->toBeFalse();
$start = strpos($source, 'function install_setup_get_tables()');
$end = strpos($source, "\n}\n", $start);
expect($start)->not->toBeFalse()
->and($end)->not->toBeFalse();
$function = substr($source, $start, $end - $start);
expect(substr_count($function, 'information_schema.TABLES'))->toBe(1)
->and($function)->toContain("'Name', ['Engine', 'Rows', 'Collation', 'Row_format']")
->and($function)->toContain('$table_status = $table_statuses[$table] ?? false;')
->and($function)->not->toContain('SHOW TABLE STATUS LIKE');
});
test('installer command-line adapters propagate failures and release locks', function () {
$cli = file_get_contents(CACTI_PATH_BASE . '/cli/install_cacti.php');
$background = file_get_contents(CACTI_PATH_BASE . '/install/background.php');
$conversion = file_get_contents(CACTI_PATH_BASE . '/cli/convert_tables.php');
$installer = file_get_contents(CACTI_PATH_LIBRARY . '/installer.php');
$import = file_get_contents(CACTI_PATH_LIBRARY . '/import.php');
$utility = file_get_contents(CACTI_PATH_LIBRARY . '/utility.php');
expect($cli)->toContain('exit($exitCode);')
->and($cli)->toContain('if (!isset($options[$key])) {')
->and($cli)->toContain('$options[$key] = [];')
->and($cli)->toContain("set_install_multioption(\$options, 'Tables', 'Table', \$value, 'chk_table_');")
->and($background)->toContain('$installerProcessTimeout = 86400;')
->and($background)->toContain("register_process_start('install', 'master', 0, \$installerProcessTimeout)")
->and($background)->not->toContain("register_process_start('install', 'master', 0, 600)")
->and($background)->toContain('finally {')
->and($background)->toContain('exit($completed ? 0 : 1);')
->and($conversion)->toContain('exit($conversion_failed ? 1 : 0);')
->and($installer)->toContain("['bypass_shell' => true]")
->and($import)->toContain('if (file_exists($filename) && !unlink($filename)) {')
->and($utility)->toContain('is_file($config_file) && is_readable($config_file)')
->and(substr_count($installer, 'shell_exec('))->toBe(2)
->and($installer)->not->toContain("cacti_escapeshellarg(CACTI_PATH_CLI . '/add_tree.php')")
->and($installer)->not->toContain("cacti_escapeshellarg(CACTI_PATH_CLI . '/convert_tables.php')");
});
test('installer CLI handoff bypasses the shell and preserves output and status', function () {
$installer = (new ReflectionClass(Installer::class))->newInstanceWithoutConstructor();
$method = new ReflectionMethod(Installer::class, 'runCliCommand');
$script = dirname(__DIR__, 2) . '/fixtures/InstallerCliProbe.php';
$result = $method->invoke($installer, $script, ['value with spaces', '--exit=7'], PHP_BINARY);
expect($result['exitCode'])->toBe(7)
->and($result['output'])->toContain('value with spaces|--exit=7')
->toContain('probe-error')
->and($method->invoke($installer, $script, [], '/does/not/exist'))
->toMatchArray(['exitCode' => 127]);
});