Files
cacti/tests/Unit/Core/Cli/CactiApplicationTest.php
Thomas Vincent 1ae479d4f5 feat(cli): add unified command frontend and normalize legacy failures (#7903)
* feat(cli): add unified command frontend and normalize legacy failures

Add the Symfony-backed bin/cacti entry point while preserving legacy argument, terminal, signal, and exit-status behavior. Fold in the focused legacy CLI correctness fixes from #7906 without adding its unused parser abstraction.

Keep path containment and the PHP 8.3 floor out of this review; the latter remains in #7939.

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

* fix(cli): enforce PHP floor and deny web dispatch

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
2026-09-07 18:36:55 -07:00

439 lines
16 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 |
+-------------------------------------------------------------------------+
*/
declare(strict_types = 1);
use Cacti\Console\CactiApplication;
use Cacti\Console\Command\LegacyScriptCommand;
use Cacti\Console\Input\RawArgvInput;
use Cacti\Console\LegacyCommandMap;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Tester\ApplicationTester;
use Symfony\Component\Process\Process;
use Symfony\Component\Process\Exception\LogicException as ProcessLogicException;
use Symfony\Component\Process\Exception\ProcessSignaledException;
final class CactiControllableLegacyProcess extends Process {
public bool $tty_enabled = false;
public bool $output_disabled = false;
public bool $running = false;
public int|null $received_signal = null;
public function __construct(private readonly int $result = 0, private readonly int|null $term_signal = null) {
parent::__construct(['true']);
}
public function setTimeout(float|null $timeout): static {
return $this;
}
public function setTty(bool $tty): static {
$this->tty_enabled = $tty;
return $this;
}
public function disableOutput(): static {
$this->output_disabled = true;
return $this;
}
public function run(callable|null $callback = null, array $env = []): int {
if ($this->term_signal !== null) {
throw new ProcessSignaledException($this);
}
return $this->result;
}
public function getTermSignal(): int {
return $this->term_signal ?? 0;
}
public function isRunning(): bool {
return $this->running;
}
public function signal(int $signal): static {
if (!$this->running) {
throw new ProcessLogicException('Cannot send signal on a non running process.');
}
$this->received_signal = $signal;
return $this;
}
}
it('registers every executable legacy CLI script exactly once', function (): void {
$root = dirname(__DIR__, 4);
$non_commands = ['cacti', 'index'];
$scripts = array_map(
static fn (string $path): string => basename($path, '.php'),
glob($root . '/cli/*.php') ?: []
);
$mapped = array_keys(LegacyCommandMap::commands());
$missing = array_values(array_diff($mapped, $scripts));
$unexpected = array_values(array_diff($scripts, $mapped, $non_commands));
expect($missing)->toBe([], 'Every mapped command must name an existing cli/*.php script.')
->and($unexpected)->toBe([], 'Classify new non-command files in $non_commands, or add commands to LegacyCommandMap.')
->and(array_unique(array_values(LegacyCommandMap::commands())))
->toHaveCount(count($mapped));
});
it('lists commands without bootstrapping the database', function (): void {
$root = dirname(__DIR__, 4);
$application = new CactiApplication($root);
$application->setAutoExit(false);
$tester = new ApplicationTester($application);
$status = $tester->run(['command' => 'list', '--format' => 'json']);
$commands = json_decode($tester->getDisplay(), true, flags: JSON_THROW_ON_ERROR)['commands'];
$names = array_column($commands, 'name');
expect($status)->toBe(0)
->and($names)->toContain('device:add', 'database:audit', 'poller:reindex', 'rrd:resize');
});
it('reports an unknown version when the version file is unavailable', function (): void {
$application = new CactiApplication('/path/that/does/not/exist');
expect($application->getVersion())->toBe('unknown');
});
it('leaves unknown commands to Symfony after legacy lookup fails', function (): void {
$application = new CactiApplication(dirname(__DIR__, 4));
$application->setAutoExit(false);
$application->setCatchExceptions(false);
expect(fn (): int => $application->run(new RawArgvInput(['bin/cacti', 'not:a:command']), new BufferedOutput()))
->toThrow(Symfony\Component\Console\Exception\CommandNotFoundException::class);
});
it('hands a terminal directly to a legacy process', function (): void {
$process = new CactiControllableLegacyProcess(17);
$command = new LegacyScriptCommand(
'probe:run',
'probe',
dirname(__DIR__, 3) . '/fixtures/Console',
static fn (array $command): Process => $process,
static fn (): bool => true,
false
);
expect($command->run(new RawArgvInput(['bin/cacti', 'probe:run']), new BufferedOutput()))->toBe(17)
->and($process->tty_enabled)->toBeTrue()
->and($process->output_disabled)->toBeFalse();
});
it('maps a signaled legacy process to the shell exit status', function (): void {
$process = new CactiControllableLegacyProcess(term_signal: SIGKILL);
$command = new LegacyScriptCommand(
'probe:run',
'probe',
dirname(__DIR__, 3) . '/fixtures/Console',
static fn (array $command): Process => $process,
static fn (): bool => false,
false
);
expect($command->run(new RawArgvInput(['bin/cacti', 'probe:run']), new BufferedOutput()))->toBe(128 + SIGKILL);
});
it('relays registered termination signals only while the child is running', function (): void {
$process = new CactiControllableLegacyProcess();
$handlers = [];
$command = new LegacyScriptCommand(
'probe:run',
'probe',
dirname(__DIR__, 3) . '/fixtures/Console',
static fn (array $command): Process => $process,
static fn (): bool => false,
static function (int $signal, callable $handler) use (&$handlers): void {
$handlers[$signal] = $handler;
}
);
$command->run(new RawArgvInput(['bin/cacti', 'probe:run']), new BufferedOutput());
$process->running = true;
$handlers[SIGTERM](SIGTERM);
expect($process->received_signal)->toBe(SIGTERM)
->and($handlers)->toHaveKeys([SIGTERM, SIGINT, SIGHUP]);
$process->running = false;
$process->received_signal = null;
$handlers[SIGINT](SIGINT);
expect($process->received_signal)->toBeNull();
});
it('preserves the child status when a signal arrives before process startup', function (): void {
$process = new CactiControllableLegacyProcess(17);
$command = new LegacyScriptCommand(
'probe:run',
'probe',
dirname(__DIR__, 3) . '/fixtures/Console',
static fn (array $command): Process => $process,
static fn (): bool => false,
static function (int $signal, callable $handler): void {
if ($signal === SIGTERM) {
$handler($signal);
}
}
);
expect($command->run(new RawArgvInput(['bin/cacti', 'probe:run']), new BufferedOutput()))->toBe(17)
->and($process->received_signal)->toBeNull();
});
it('forwards raw legacy arguments and preserves the exit status', function (): void {
$root = dirname(__DIR__, 3) . '/fixtures/Console';
$application = new Application('test');
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$application->add(new LegacyScriptCommand('probe:run', 'probe', $root));
$input = new RawArgvInput(['bin/cacti', 'probe:run', '--arbitrary=value', '-x', 'plain']);
$output = new BufferedOutput();
$status = $application->run($input, $output);
expect($status)->toBe(23)
->and($output->fetch())->toBe('["--arbitrary=value","-x","plain"]probe-error');
});
it('routes child stderr separately for console outputs and accepts aliases', function (): void {
$root = dirname(__DIR__, 3) . '/fixtures/Console';
$application = new Application('test');
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$application->add(new LegacyScriptCommand('probe:run', 'probe', $root));
$output = new ConsoleOutput(decorated: false);
$errorOutput = new BufferedOutput();
$output->setErrorOutput($errorOutput);
$status = $application->run(
new RawArgvInput(['bin/cacti', 'probe', 'alias-value']),
$output
);
expect($status)->toBe(23)
->and($errorOutput->fetch())->toBe('probe-error');
});
it('forwards arguments when the command name is abbreviated', function (): void {
$root = dirname(__DIR__, 3) . '/fixtures/Console';
$application = new Application('test');
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$application->add(new LegacyScriptCommand('probe:run', 'probe', $root));
$output = new BufferedOutput();
$status = $application->run(
new RawArgvInput(['bin/cacti', 'pro:r', '--arbitrary=value', 'plain']),
$output
);
expect($status)->toBe(23)
->and($output->fetch())->toBe('["--arbitrary=value","plain"]probe-error');
});
it('leaves --help, -h, --version and -V to the legacy script', function (string $flag): void {
$application = new CactiApplication(dirname(__DIR__, 4));
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$application->add(new LegacyScriptCommand('probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console'));
$output = new BufferedOutput();
$status = $application->run(new RawArgvInput(['bin/cacti', 'probe:run', $flag]), $output);
expect($status)->toBe(23)
->and($output->fetch())->toBe(sprintf('["%s"]probe-error', $flag));
})->with(['--help', '-h', '--version', '-V']);
it('forwards a flag written before the command name', function (string $flag): void {
$application = new CactiApplication(dirname(__DIR__, 4));
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$application->add(new LegacyScriptCommand('probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console'));
$output = new BufferedOutput();
// getFirstArgument() skips leading options, so the command dispatches with
// the flag ahead of its name. Slicing only forward handed the script an
// empty argv, and a script whose flags are all optional then ran its
// default path.
$status = $application->run(new RawArgvInput(['bin/cacti', $flag, 'probe:run']), $output);
expect($status)->toBe(23)
->and($output->fetch())->toBe(sprintf('["%s"]probe-error', $flag));
})->with(['--help', '-h', '--version', '-V']);
it('preserves the order of flags on both sides of the command name', function (): void {
$application = new CactiApplication(dirname(__DIR__, 4));
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$application->add(new LegacyScriptCommand('probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console'));
$output = new BufferedOutput();
$status = $application->run(
new RawArgvInput(['bin/cacti', '-v', 'probe:run', '--arbitrary=value', 'plain']),
$output
);
expect($status)->toBe(23)
->and($output->fetch())->toBe('["-v","--arbitrary=value","plain"]probe-error');
});
it('still answers --version and --help for the application itself', function (): void {
$application = new CactiApplication(dirname(__DIR__, 4));
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$output = new BufferedOutput();
expect($application->run(new RawArgvInput(['bin/cacti', '--version']), $output))->toBe(0)
->and($output->fetch())->toStartWith('Cacti');
});
it('does not swallow legacy output when -q is passed through', function (): void {
$application = new CactiApplication(dirname(__DIR__, 4));
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$application->add(new LegacyScriptCommand('probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console'));
$output = new BufferedOutput();
$status = $application->run(new RawArgvInput(['bin/cacti', 'probe:run', '-q', 'plain']), $output);
expect($status)->toBe(23)
->and($output->fetch())->toBe('["-q","plain"]probe-error');
});
it('streams large legacy output without retaining it in the parent', function (): void {
$root = dirname(__DIR__, 3) . '/fixtures/Console';
$application = new Application('test');
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$application->add(new LegacyScriptCommand('bulk:run', 'bulk', $root));
$output = new BufferedOutput();
$status = $application->run(new RawArgvInput(['bin/cacti', 'bulk:run', '20000']), $output);
// 20000 lines is past the 1MB php://temp threshold Process spools at.
expect($status)->toBe(0)
->and(strlen($output->fetch()))->toBe(20000 * 100);
});
it('reports a killed legacy script as the shell does', function (): void {
if (!function_exists('pcntl_fork') || !function_exists('posix_kill') || !defined('SIGKILL')) {
$this->markTestSkipped('pcntl and posix signal support are required.');
}
$root = dirname(__DIR__, 3) . '/fixtures/Console';
$runner = dirname(__DIR__, 3) . '/fixtures/Console/kill_runner.php';
$descriptor = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open([PHP_BINARY, $runner, $root], $descriptor, $pipes);
expect($process)->toBeResource();
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
// 128 + SIGKILL, the status a shell reports for the same death.
expect(trim($stdout))->toBe('137')
->and($stderr)->not->toContain('ProcessSignaledException');
});
it('runs the legacy script in the directory the caller was in', function (): void {
$root = dirname(__DIR__, 3) . '/fixtures/Console';
$original = getcwd();
$elsewhere = sys_get_temp_dir();
try {
chdir($elsewhere);
$application = new Application('test');
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$application->add(new LegacyScriptCommand('pwd:run', 'pwd', $root));
$output = new BufferedOutput();
$application->run(new RawArgvInput(['bin/cacti', 'pwd:run']), $output);
// A relative path argument has to mean what it would have meant to
// `php cli/<script>.php` from the same shell.
expect(realpath(trim($output->fetch())))->toBe(realpath($elsewhere));
} finally {
chdir((string) $original);
}
});
it('rejects parsed inputs that cannot preserve the raw argument vector', function (): void {
$command = new LegacyScriptCommand('probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console');
expect(fn () => $command->run(new ArrayInput([]), new BufferedOutput()))
->toThrow(LogicException::class, 'Legacy commands require RawArgvInput.');
});
it('fails closed when no command token is present', function (): void {
$input = new RawArgvInput(['bin/cacti', '--version']);
expect(fn (): array => $input->argumentsAfterCommand(['probe:run', 'probe']))
->toThrow(LogicException::class, 'resolved command token is missing');
});
it('uses the server argument vector when one is not supplied', function (): void {
$original = $_SERVER['argv'] ?? null;
$_SERVER['argv'] = ['bin/cacti', 'probe:run', 'server-value'];
try {
$input = new RawArgvInput();
expect($input->argumentsAfterCommand(['probe:run']))->toBe(['server-value']);
} finally {
if ($original === null) {
unset($_SERVER['argv']);
} else {
$_SERVER['argv'] = $original;
}
}
});
it('runs the installed CLI entry point without application bootstrap', function (): void {
$root = dirname(__DIR__, 4);
$process = new Process([PHP_BINARY, $root . '/bin/cacti', 'list', '--raw']);
expect($process->run())->toBe(0)
->and($process->getOutput())->toContain('device:add')
->and($process->getOutput())->toContain('system:version');
});
it('fails clearly when the CLI entry point has no installed dependencies', function (): void {
$fixture = sys_get_temp_dir() . '/cacti-cli-no-deps-' . bin2hex(random_bytes(8));
mkdir($fixture . '/bin', 0777, true);
mkdir($fixture . '/include', 0777, true);
copy(dirname(__DIR__, 4) . '/bin/cacti', $fixture . '/bin/cacti');
copy(dirname(__DIR__, 4) . '/include/cli_only.php', $fixture . '/include/cli_only.php');
$process = new Process([PHP_BINARY, $fixture . '/bin/cacti']);
expect($process->run())->toBe(1)
->and($process->getErrorOutput())->toContain('Cacti dependencies are not installed');
});