mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-23 11:36:53 +09:00
* refactor(tests): reorganize unit tests into domain category folders - Consolidate per-advisory GHSA regression tests into Security/ subfolders by vuln class - Group all 186 test files into domain folders: DataCollection/, Ui/, Core/, Database/, Installer/, Scripts/, Security/, Plugin/ - Strip GitHub issue/GHSA numbers from filenames; keep IDs in test descriptions - Fix all __DIR__ relative paths to match new folder nesting depth - Update CI workflow path triggers and phpunit XML configs for new file locations - Update .github/copilot-instructions.md with unit test organization guidelines - Add tests/vendor/ to .gitignore * add gaurds * fix up guard for unit tests * fix redeclaration of cacti_log in tests * fix updates to unit tests * fix test patjhs * fix: update test __DIR__ paths for reorganized test structure * fix: conditionally define read_user_setting to prevent redeclaration in tests * fix: conditionally define functions to prevent redeclaration in tests - Wrap clean_up_lines, raise_message, cacti_debug_backtrace, and get_debug_prefix with function_exists() checks to prevent conflicts when test stubs are loaded before lib/functions.php in the same PHP process * fix: add unit test bootstrap to prevent function redeclaration errors - Create tests/bootstrap-unit.php to load core libraries before tests - Ensures lib/functions.php loads before test stubs define stub functions - Test stubs use function_exists() checks, so they detect pre-loaded functions - Update phpunit.xml to use bootstrap-unit.php for Unit testsuite - Fixes 'Cannot redeclare read_user_setting()' errors when tests run in same process * fix: load test stubs before global.php in unit test bootstrap - Reorder bootstrap to load CactiStubs.php and UnitStubs.php first - Ensures stub functions like __() are available when global.php loads - global.php calls get_cacti_version_text() which needs __() for translations - Fixes 'Call to undefined function __()' error during bootstrap * fix: remove function conflicts from test stubs - Remove stub definitions for functions already in lib/functions.php: cacti_sizeof, cacti_log, cacti_strtolower, read_config_option, read_user_setting, cacti_count - Keep translation function stubs (__(), __esc()) needed by global.php - Update bootstrap to load lib/functions.php after stubs - This prevents function redeclaration errors while maintaining test isolation * fix: prevent constant and variable redeclaration in bootstrap - Remove explicit PHP_TESTING and CACTI_TEST_BOOTSTRAP defines from bootstrap (CactiStubs.php now handles this with guards) - Add guards to CactiStubs.php to prevent redefinition of PHP_TESTING - Only set CACTI_TEST_BOOTSTRAP env vars if not already set - Fixes PHP warnings about 'Constant X already defined' * fix: remove redundant lib/functions.php load from bootstrap include/global.php already loads lib/functions.php at lines 287-288. The bootstrap was trying to load it again after global.php, but global.php needs format_cacti_version() to be available on lines 293-294 to define CACTI_VERSION and CACTI_VERSION_FULL constants. Solution: Let global.php handle loading lib/functions.php itself, and don't load it separately in the bootstrap. * fix: remove more conflicting function stubs from test helpers - Remove stubs for functions also defined in lib/functions.php: raise_message, clean_up_lines, cacti_debug_backtrace, get_debug_prefix - Remove POLLER_ID constant stub (defined unconditionally by global.php) - Keep only translation functions and test-specific helpers - This prevents function/constant redeclaration errors * fix: minimize test stubs to only translation functions Remove ALL helper functions and constants from UnitStubs.php that are: - Defined in lib/functions.php (raise_message, clean_up_lines, etc.) - Defined in lib/html_utility.php (srv, set_request_var) - Defined in global_constants.php (POLLER_VERBOSITY_*, MESSAGE_LEVEL_*) - Defined in global.php (POLLER_ID) Keep ONLY translation functions (__(), __esc()) that are: - Called by global.php during initialization - Not defined elsewhere - Required for proper bootstrap sequence This eliminates all constant and function redeclaration errors. * fix: remove __esc stub - already defined in global_languages.php Keep only __() translation function stub, which is needed by global.php and not defined elsewhere in Cacti core. * fix: handle no-arg case in __() translation stub The __() function may be called with just a string and no format arguments. Previously, vsprintf() would fail with 'arguments array must contain 2 items'. Now, if no args are provided, return the text as-is.
271 lines
10 KiB
PHP
271 lines
10 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 |
|
|
| as published by the Free Software Foundation; either version 2 |
|
|
| of the License, or (at your option) any later version. |
|
|
+-------------------------------------------------------------------------+
|
|
| Cacti: The Complete RRDtool-based Graphing Solution |
|
|
+-------------------------------------------------------------------------+
|
|
*/
|
|
|
|
/*
|
|
* Hand-off contract for the Symfony Console plumbing. Exercises every place a
|
|
* value crosses a boundary on its way from argv to a Command and back to a
|
|
* process exit code:
|
|
*
|
|
* argv -> ArgvInput -> Command::execute() arguments
|
|
* legacy shim entrypoint <-> cli/cacti.php entrypoint (parity)
|
|
* shell metacharacters in --oldrrd -> rejection before any file is touched
|
|
* Command::SUCCESS / Command::FAILURE -> subprocess exit code
|
|
* parent env CACTI_PHP_TESTING -> CactiCommand::initialize() bootstrap skip
|
|
*
|
|
* The in-process cases use Symfony's CommandTester. The cross-process cases
|
|
* reuse the runCli helper from CliInvocationTest so the bootstrap chain
|
|
* (cli_check.php -> autoload.php -> CactiApplication) is a real subprocess
|
|
* and not a mocked include graph.
|
|
*/
|
|
|
|
use Symfony\Component\Console\Application;
|
|
use Symfony\Component\Console\Command\Command;
|
|
use Symfony\Component\Console\Input\InputInterface;
|
|
use Symfony\Component\Console\Output\OutputInterface;
|
|
use Symfony\Component\Console\Tester\CommandTester;
|
|
|
|
if (!file_exists(dirname(__DIR__, 2) . '/lib/CactiCommand.php')) {
|
|
test('CLI invocation hand-off: feature not present on this branch', function () {})
|
|
->skip('lib/CactiCommand.php absent — feature PR #7075 not merged into develop yet');
|
|
return;
|
|
}
|
|
|
|
const CLI_HANDOFF_REPO_ROOT = __DIR__ . '/../..';
|
|
|
|
require_once dirname(__DIR__, 1) . '/Helpers/CactiStubs.php';
|
|
require_once dirname(__DIR__, 2) . '/lib/CactiCommand.php';
|
|
require_once dirname(__DIR__, 2) . '/lib/CmdRealtimeCommand.php';
|
|
require_once dirname(__DIR__, 2) . '/lib/SpliceRrdCommand.php';
|
|
|
|
/**
|
|
* Spawn a fresh PHP process for `php <script> <args...>`, returning stdout,
|
|
* stderr and the exit code. Mirrors the helper in CliInvocationTest so the
|
|
* two suites stay in sync; duplicated rather than shared because Pest does
|
|
* not autoload from tests/integration into tests/HandOff.
|
|
*
|
|
* @return array{stdout:string,stderr:string,exit:int}
|
|
*/
|
|
function runCli(string $script, array $args = [], array $extraEnv = []) : array {
|
|
$php = PHP_BINARY;
|
|
$path = CLI_HANDOFF_REPO_ROOT . '/' . ltrim($script, '/');
|
|
|
|
$cmd = escapeshellcmd($php) . ' ' . escapeshellarg($path);
|
|
|
|
foreach ($args as $arg) {
|
|
$cmd .= ' ' . escapeshellarg($arg);
|
|
}
|
|
|
|
$descriptors = [
|
|
1 => ['pipe', 'w'],
|
|
2 => ['pipe', 'w'],
|
|
];
|
|
|
|
// Default to skipping the Cacti bootstrap; individual tests may override
|
|
// CACTI_PHP_TESTING via $extraEnv to assert the unset-env branch.
|
|
$baseEnv = ['CACTI_PHP_TESTING' => '1'];
|
|
$env = $extraEnv + $baseEnv + $_ENV + getenv();
|
|
|
|
$proc = proc_open($cmd, $descriptors, $pipes, null, $env);
|
|
|
|
if (!is_resource($proc)) {
|
|
return ['stdout' => '', 'stderr' => 'failed to spawn', 'exit' => 127];
|
|
}
|
|
|
|
$stdout = stream_get_contents($pipes[1]);
|
|
$stderr = stream_get_contents($pipes[2]);
|
|
fclose($pipes[1]);
|
|
fclose($pipes[2]);
|
|
|
|
$exit = proc_close($proc);
|
|
|
|
return [
|
|
'stdout' => (string) $stdout,
|
|
'stderr' => (string) $stderr,
|
|
'exit' => $exit,
|
|
];
|
|
}
|
|
|
|
beforeEach(function () {
|
|
if (!file_exists(CLI_HANDOFF_REPO_ROOT . '/include/vendor/autoload.php')
|
|
|| !class_exists(Application::class)) {
|
|
$this->markTestSkipped('symfony/console not installed; run composer install');
|
|
}
|
|
});
|
|
|
|
test('argv hand-off: ArgvInput delivers cmd-realtime 5 7 60 to execute() with the right typed values', function () {
|
|
// Build an in-process Application and parse a real argv-shaped input
|
|
// so we exercise ArgvInput, not the array shortcut CommandTester offers.
|
|
$probe = new class extends CactiCommand {
|
|
public array $captured = [];
|
|
|
|
public function __construct() {
|
|
parent::__construct('realtime');
|
|
}
|
|
|
|
protected function configure() : void {
|
|
$this
|
|
->addArgument('poller-id', \Symfony\Component\Console\Input\InputArgument::REQUIRED)
|
|
->addArgument('graph-id', \Symfony\Component\Console\Input\InputArgument::REQUIRED)
|
|
->addArgument('interval', \Symfony\Component\Console\Input\InputArgument::REQUIRED);
|
|
}
|
|
|
|
protected function execute(InputInterface $input, OutputInterface $output) : int {
|
|
$this->captured = [
|
|
'poller_id' => $input->getArgument('poller-id'),
|
|
'graph_id' => $this->validateInt($input, 'graph-id', 1),
|
|
'interval' => $this->validateInt($input, 'interval', 1),
|
|
];
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
};
|
|
|
|
$app = new Application();
|
|
$app->setAutoExit(false);
|
|
$app->add($probe);
|
|
|
|
$input = new \Symfony\Component\Console\Input\ArgvInput(['cli/cacti.php', 'realtime', '5', '7', '60']);
|
|
$output = new \Symfony\Component\Console\Output\BufferedOutput();
|
|
|
|
$exit = $app->run($input, $output);
|
|
|
|
expect($exit)->toBe(Command::SUCCESS);
|
|
expect($probe->captured)->toBe([
|
|
'poller_id' => '5',
|
|
'graph_id' => 7,
|
|
'interval' => 60,
|
|
]);
|
|
});
|
|
|
|
test('legacy entrypoint parity: cmd_realtime.php and cli/cacti.php realtime produce equivalent stdout and exit', function () {
|
|
$legacy = runCli('cmd_realtime.php', ['5', '7', '60']);
|
|
$modern = runCli('cli/cacti.php', ['realtime', '5', '7', '60']);
|
|
|
|
expect($legacy['exit'])->toBe(0);
|
|
expect($modern['exit'])->toBe(0);
|
|
|
|
// Strip trailing whitespace; both shims funnel through the same
|
|
// CmdRealtimeCommand under PHP_TESTING and must emit the same OK line.
|
|
$legacyOut = trim($legacy['stdout']);
|
|
$modernOut = trim($modern['stdout']);
|
|
|
|
expect($legacyOut)->toBe($modernOut);
|
|
expect($legacyOut)->toContain('OK realtime poller_id=5 graph_id=7 interval=60');
|
|
});
|
|
|
|
test('shell-metachar argv hand-off: --oldrrd with semicolon is rejected before the file is created', function () {
|
|
$target = sys_get_temp_dir() . '/cacti_handoff_metachar_' . bin2hex(random_bytes(4)) . '.rrd';
|
|
$payload = $target . ';touch ' . sys_get_temp_dir() . '/cacti_handoff_pwn';
|
|
|
|
// Belt-and-braces: ensure neither the literal nor the post-semicolon
|
|
// side-effect file exists before the run.
|
|
@unlink($target);
|
|
@unlink(sys_get_temp_dir() . '/cacti_handoff_pwn');
|
|
|
|
$result = runCli('cli/splice_rrd.php', ['--oldrrd=' . $payload, '--newrrd=' . sys_get_temp_dir() . '/cacti_handoff_new.rrd']);
|
|
|
|
expect($result['exit'])->not->toBe(0);
|
|
expect(file_exists($target))->toBeFalse();
|
|
expect(file_exists($payload))->toBeFalse();
|
|
expect(file_exists(sys_get_temp_dir() . '/cacti_handoff_pwn'))->toBeFalse();
|
|
|
|
$combined = $result['stdout'] . $result['stderr'];
|
|
expect($combined)->not->toContain('Fatal error');
|
|
});
|
|
|
|
test('Command::SUCCESS hand-off to subprocess: returning self::SUCCESS yields exit code 0', function () {
|
|
// cmd_realtime.php under PHP_TESTING runs the OK branch and returns
|
|
// Command::SUCCESS, which the shim must propagate as exit 0.
|
|
$result = runCli('cmd_realtime.php', ['5', '7', '60']);
|
|
|
|
expect($result['exit'])->toBe(0);
|
|
});
|
|
|
|
test('Command::FAILURE hand-off to subprocess: validation failure yields a non-zero exit', function () {
|
|
// Garbage poller-id throws InvalidArgumentException, which Symfony
|
|
// surfaces as a non-zero exit (typically 1) without a PHP fatal.
|
|
$result = runCli('cmd_realtime.php', ['notanumber', '7', '60']);
|
|
|
|
expect($result['exit'])->not->toBe(0);
|
|
$combined = $result['stdout'] . $result['stderr'];
|
|
expect($combined)->not->toContain('Fatal error');
|
|
expect($combined)->not->toContain('Stack trace');
|
|
});
|
|
|
|
test('environment hand-off: CACTI_PHP_TESTING=1 makes CactiCommand::initialize a no-op', function () {
|
|
// With CACTI_PHP_TESTING=1 (default in runCli) the bootstrap is skipped
|
|
// and the command runs to completion using the in-process stubs.
|
|
$result = runCli('cmd_realtime.php', ['5', '7', '60']);
|
|
|
|
expect($result['exit'])->toBe(0);
|
|
expect($result['stdout'])->toContain('OK realtime');
|
|
});
|
|
|
|
test('environment hand-off: absence of CACTI_PHP_TESTING would trigger the Cacti bootstrap path', function () {
|
|
// We cannot let initialize() run cli_check.php here (no DB), so verify
|
|
// the gate condition in-process: the same boolean the env check feeds.
|
|
// Without the env var and without PHP_TESTING constant, initialize()
|
|
// would fall through to the require_once cli_check.php branch.
|
|
$envSeen = (bool) getenv('CACTI_PHP_TESTING');
|
|
$constantSeen = defined('PHP_TESTING');
|
|
|
|
// Inside the Pest harness PHP_TESTING is defined by Helpers/CactiStubs,
|
|
// which is exactly why the in-process tests do not need the env var.
|
|
expect($constantSeen)->toBeTrue();
|
|
|
|
// Simulate the unset-env case: with neither signal, initialize() would
|
|
// proceed to bootstrap. We assert the truth table the gate uses without
|
|
// actually executing the require_once.
|
|
$wouldBootstrap = !$constantSeen && !$envSeen
|
|
? true
|
|
: false;
|
|
|
|
expect($wouldBootstrap)->toBeFalse();
|
|
});
|
|
|
|
test('argv hand-off via CommandTester: cmd-realtime arguments land on execute() with poller_id=5 graph_id=7 interval=60', function () {
|
|
$cmd = new CmdRealtimeCommand();
|
|
$app = new Application();
|
|
$app->add($cmd);
|
|
|
|
$tester = new CommandTester($app->find('realtime'));
|
|
|
|
$tester->execute([
|
|
'poller-id' => '5',
|
|
'graph-id' => '7',
|
|
'interval' => '60',
|
|
]);
|
|
|
|
$tester->assertCommandIsSuccessful();
|
|
expect($tester->getDisplay())->toContain('OK realtime poller_id=5 graph_id=7 interval=60');
|
|
});
|
|
|
|
test('shell-metachar in-process: SpliceRrdCommand rejects --oldrrd with semicolon and never returns SUCCESS', function () {
|
|
$cmd = new SpliceRrdCommand();
|
|
$app = new Application();
|
|
$app->add($cmd);
|
|
|
|
$tester = new CommandTester($app->find('splice-rrd'));
|
|
$target = sys_get_temp_dir() . '/cacti_handoff_inproc_' . bin2hex(random_bytes(4)) . '.rrd';
|
|
|
|
@unlink($target);
|
|
|
|
expect(fn () => $tester->execute([
|
|
'--oldrrd' => $target . ';rm -rf /',
|
|
'--newrrd' => sys_get_temp_dir() . '/cacti_handoff_inproc_new.rrd',
|
|
]))->toThrow(\InvalidArgumentException::class, 'shell metacharacter');
|
|
|
|
expect(file_exists($target))->toBeFalse();
|
|
});
|