From 0b864ecedb97379399ce2a927c28968225d9d7ec Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:08:05 -0600 Subject: [PATCH 01/27] fix(poller): expire stale wmi_processes locks and bound the collector wait Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + includes/functions.php | 36 +++++++++++++++++++ poller_wmi.php | 6 +++- tests/Unit/WmiProcessLockTest.php | 58 +++++++++++++++++++++++++++++++ tests/bootstrap-unit.php | 18 ++++++++++ 5 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/WmiProcessLockTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ceffec..12b2a1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* issue: Expire stale wmi_processes lock rows and stop the master poller waiting indefinitely for collectors that died * security: Replace the confirmation pages' inline `onClick='cactiReturnTo()'` Cancel buttons with the CSP-safe `cactiReturnTo` class so the pages no longer trip Cacti's Content-Security-Policy script-src-attr directive * security: Move the WMI Accounts and Queries filter rows selects' inline onChange handlers into the ready block so the pages no longer trip Cacti's Content-Security-Policy script-src-attr directive * dev: Measure CI coverage with xdebug instead of pcov so the plugin's own sources are instrumented (pcov auto-scopes to the Composer root and skipped cacti/plugins/, leaving the patch-coverage gate with nothing to measure) diff --git a/includes/functions.php b/includes/functions.php index e936993..141fd75 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -548,6 +548,42 @@ function run_store_wmi_query($host_id, $wmi_query_id) { [$host_id, $wmi_query_id]); } +/** + * Deletes wmi_processes lock rows older than $max_age seconds. A collector + * that was killed or never started leaves its row behind, and the master + * poller refuses to run while any row exists. + * + * @param int $max_age Age in seconds after which a lock row is stale. + * + * @return void + */ +function wmi_purge_stale_processes($max_age = 3600) { + db_execute_prepared('DELETE FROM wmi_processes WHERE started < DATE_SUB(NOW(), INTERVAL ? SECOND)', [(int) $max_age]); +} + +/** + * Returns how many collectors launched under $seed are still running. Once + * $max_age seconds have passed since $began, the remaining lock rows are + * released and 0 is returned so the master poller stops waiting for them. + * + * @param int $seed The master run's task identifier. + * @param int $began Unix time the master started waiting. + * @param int $max_age Longest time in seconds to wait for the collectors. + * + * @return int The number of collectors still running. + */ +function wmi_collectors_running($seed, $began, $max_age = 3600) { + if (time() - $began > $max_age) { + db_execute_prepared('DELETE FROM wmi_processes WHERE taskid = ?', [$seed]); + + cacti_log('WARNING: Gave up waiting for WMI collectors after ' . $max_age . ' seconds', false, 'WMI'); + + return 0; + } + + return (int) db_fetch_cell_prepared('SELECT COUNT(*) FROM wmi_processes WHERE taskid = ?', [$seed]); +} + /** * Returns the current unique hash for a WMI query, or generates a new * one if the stored value doesn't already look like a valid 128-bit hex diff --git a/poller_wmi.php b/poller_wmi.php index 98706d0..f68a39d 100644 --- a/poller_wmi.php +++ b/poller_wmi.php @@ -175,6 +175,8 @@ function process_all_devices() { print 'NOTE: Processing Hosts Begins' . PHP_EOL; + wmi_purge_stale_processes(); + // Do not process collectors are still running if (db_fetch_cell('SELECT COUNT(*) FROM wmi_processes') > 0) { print 'WARNING: Another WMI Collector is still running! Exiting' . PHP_EOL; @@ -247,8 +249,10 @@ function process_all_devices() { print 'NOTE: All WMI Devices Launched, proceeding to wait for completion' . PHP_EOL; // wait for all processes to end or max run time + $wait_began = time(); + while (true) { - $processes_left = db_fetch_cell_prepared('SELECT COUNT(*) FROM wmi_processes WHERE taskid = ?', [$seed]); + $processes_left = wmi_collectors_running($seed, $wait_began); $pl = db_fetch_cell('SELECT COUNT(*) FROM wmi_processes'); if ($processes_left == 0) { diff --git a/tests/Unit/WmiProcessLockTest.php b/tests/Unit/WmiProcessLockTest.php new file mode 100644 index 0000000..02e2b2a --- /dev/null +++ b/tests/Unit/WmiProcessLockTest.php @@ -0,0 +1,58 @@ +toBe('db_execute_prepared'); + expect($call['sql'])->toContain('DELETE FROM wmi_processes WHERE started <'); + expect($call['params'])->toBe(array(900)); +}); + +it('reports the running collectors for a seed while inside the deadline', function () { + $GLOBALS['__test_fetch_cell_map']['FROM wmi_processes WHERE taskid'] = '2'; + + expect(wmi_collectors_running(77, time() - 10, 3600))->toBe(2); + expect($GLOBALS['__test_db_calls'])->toBe(array()); +}); + +it('stops waiting and releases the seed rows once the deadline passes', function () { + $GLOBALS['__test_fetch_cell_map']['FROM wmi_processes WHERE taskid'] = '2'; + + expect(wmi_collectors_running(77, time() - 7200, 3600))->toBe(0); + + $call = $GLOBALS['__test_db_calls'][0]; + + expect($call['sql'])->toContain('DELETE FROM wmi_processes WHERE taskid = ?'); + expect($call['params'])->toBe(array(77)); + expect($GLOBALS['__test_cacti_log'][0])->toContain('WMI collectors'); +}); + +it('expires stale locks before the master checks for a running collector', function () { + $source = plugin_test_read_source('poller_wmi.php'); + + expect(strpos($source, 'wmi_purge_stale_processes()'))->toBeLessThan(strpos($source, "SELECT COUNT(*) FROM wmi_processes') > 0")); + expect($source)->toContain('wmi_collectors_running($seed, $wait_began)'); +}); diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index 326d9b4..fcdd2cf 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -108,6 +108,12 @@ function db_fetch_row($sql) { if (!function_exists('db_fetch_row_prepared')) { function db_fetch_row_prepared($sql, $params = array()) { + foreach (($GLOBALS['__test_fetch_row_map'] ?? array()) as $needle => $row) { + if (strpos($sql, $needle) !== false) { + return $row; + } + } + return array(); } } @@ -120,10 +126,22 @@ function db_fetch_cell($sql) { if (!function_exists('db_fetch_cell_prepared')) { function db_fetch_cell_prepared($sql, $params = array()) { + foreach (($GLOBALS['__test_fetch_cell_map'] ?? array()) as $needle => $value) { + if (strpos($sql, $needle) !== false) { + return $value; + } + } + return ''; } } +if (!function_exists('db_qstr')) { + function db_qstr($text) { + return "'" . addslashes((string) $text) . "'"; + } +} + if (!function_exists('db_index_exists')) { function db_index_exists($table, $index) { return false; From dd42f89e7c70be8d5cbdc451800c82e78ad76094 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:10:11 -0600 Subject: [PATCH 02/27] fix(cache): purge stale cache rows only after a successful refresh Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + includes/functions.php | 32 ++++++---- tests/Unit/WmiRunStoreQueryTest.php | 93 +++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 tests/Unit/WmiRunStoreQueryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 12b2a1b..cb5859f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* issue: Purge WMI cache rows the device stopped returning, and keep the existing rows when a query fails * issue: Expire stale wmi_processes lock rows and stop the master poller waiting indefinitely for collectors that died * security: Replace the confirmation pages' inline `onClick='cactiReturnTo()'` Cancel buttons with the CSP-safe `cactiReturnTo` class so the pages no longer trip Cacti's Content-Security-Policy script-src-attr directive * security: Move the WMI Accounts and Queries filter rows selects' inline onChange handlers into the ready block so the pages no longer trip Cacti's Content-Security-Policy script-src-attr directive diff --git a/includes/functions.php b/includes/functions.php index 141fd75..1fb9661 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -380,6 +380,8 @@ function plugin_wmi_create_resource_xml($id) { * @param int $host_id The Cacti host id to query. * @param int $wmi_query_id The wmi_wql_queries.id to run against the * host. + * @param Wmi_Transport|null $transport Transport override; the OS default + * is used when null. * * @return bool|void True when the query succeeded and results were * cached; false when the host's WMI account/query @@ -391,7 +393,7 @@ function plugin_wmi_create_resource_xml($id) { * @global array $config Cacti global configuration array; used to load * this plugin's linux_wmi.php client. */ -function run_store_wmi_query($host_id, $wmi_query_id) { +function run_store_wmi_query($host_id, $wmi_query_id, $transport = null) { global $config; $host_info = db_fetch_row_prepared('SELECT * @@ -401,13 +403,6 @@ function run_store_wmi_query($host_id, $wmi_query_id) { $host_info = is_array($host_info) ? $host_info : []; - // Prepared old entries for removal - db_execute_prepared('UPDATE host_wmi_cache - SET present = 0 - WHERE host_id = ? - AND wmi_query_id = ?', - [$host_id, $wmi_query_id]); - if (cacti_sizeof($host_info)) { $auth_info = db_fetch_row_prepared('SELECT * FROM wmi_user_accounts @@ -447,7 +442,7 @@ function run_store_wmi_query($host_id, $wmi_query_id) { // Cacti server and the wmic shell transport otherwise (see // Linux_WMI::default_transport()), so the same client is used here // regardless of $config['cacti_server_os']. - $wmi = new Linux_WMI(); + $wmi = new Linux_WMI('', $transport); $wmi->hostname = $host; $wmi->username = $username; $wmi->password = $wmi->decode($password); @@ -520,6 +515,13 @@ function run_store_wmi_query($host_id, $wmi_query_id) { } } + // Mark existing rows for removal only once fresh data is in hand + db_execute_prepared('UPDATE host_wmi_cache + SET present = 0 + WHERE host_id = ? + AND wmi_query_id = ?', + [$host_id, $wmi_query_id]); + $parts = array_chunk($sql, 200); foreach ($parts as $part) { @@ -532,6 +534,13 @@ function run_store_wmi_query($host_id, $wmi_query_id) { present=1'); } + // Remove entries the device no longer returns + db_execute_prepared('DELETE FROM host_wmi_cache + WHERE present = 0 + AND host_id = ? + AND wmi_query_id = ?', + [$host_id, $wmi_query_id]); + return true; } else { print $wmi->error; @@ -540,10 +549,9 @@ function run_store_wmi_query($host_id, $wmi_query_id) { } } - // Remove old entries + // The device no longer exists, so drop whatever was cached for it db_execute_prepared('DELETE FROM host_wmi_cache - WHERE present = 0 - AND host_id = ? + WHERE host_id = ? AND wmi_query_id = ?', [$host_id, $wmi_query_id]); } diff --git a/tests/Unit/WmiRunStoreQueryTest.php b/tests/Unit/WmiRunStoreQueryTest.php new file mode 100644 index 0000000..bd9c13d --- /dev/null +++ b/tests/Unit/WmiRunStoreQueryTest.php @@ -0,0 +1,93 @@ +|false $lines */ + public function __construct(private array|false $lines, private ?string $message = null) { + } + + public function query(Wmi_Request $request): array|false { + return $this->lines; + } + + public function error(): ?string { + return $this->message; + } +} + +/** @return array */ +function wmi_test_cache_statements(): array { + $out = array(); + + foreach ($GLOBALS['__test_db_calls'] as $call) { + if (strpos($call['sql'] ?? '', 'host_wmi_cache') !== false) { + $out[] = preg_replace('/\s+/', ' ', $call['sql']); + } + } + + return $out; +} + +beforeEach(function () { + $w = new Linux_WMI(); + + $GLOBALS['__test_db_calls'] = array(); + $GLOBALS['__test_cacti_log'] = array(); + $GLOBALS['__test_fetch_row_map'] = array( + 'FROM host' => array('id' => 5, 'hostname' => 'winsrv', 'wmi_account' => 1), + 'wmi_user_accounts' => array('username' => 'u', 'password' => $w->encode('pw')), + 'wmi_wql_queries' => array('id' => 9, 'namespace' => 'root\\CIMV2', 'query' => 'SELECT Name,State FROM Win32_Service', 'primary_key' => 'Name'), + ); +}); + +it('purges cache rows the device stopped returning after a successful refresh', function () { + $t = new Wmi_Test_Canned_Transport(array('Win32_Service', 'Name|+|State', 'Spooler|+|Running')); + + ob_start(); + $ok = run_store_wmi_query(5, 9, $t); + ob_end_clean(); + + $statements = wmi_test_cache_statements(); + + expect($ok)->toBeTrue(); + expect($statements[0])->toContain('UPDATE host_wmi_cache SET present = 0'); + expect($statements[1])->toContain('INSERT INTO host_wmi_cache'); + expect($statements[2])->toContain('DELETE FROM host_wmi_cache WHERE present = 0'); +}); + +it('leaves the cache untouched when the query fails', function () { + $t = new Wmi_Test_Canned_Transport(false, 'ERROR: no route'); + + ob_start(); + $ok = run_store_wmi_query(5, 9, $t); + ob_end_clean(); + + expect($ok)->toBeFalse(); + expect(wmi_test_cache_statements())->toBe(array()); +}); + +it('drops the cached rows of a device that no longer exists', function () { + $GLOBALS['__test_fetch_row_map'] = array(); + + run_store_wmi_query(5, 9, new Wmi_Test_Canned_Transport(false)); + + $statements = wmi_test_cache_statements(); + + expect($statements)->toHaveCount(1); + expect($statements[0])->toContain('DELETE FROM host_wmi_cache WHERE host_id = ? AND wmi_query_id = ?'); +}); From 4a55d48116be396dff2fb4fbb93eae45ae1f6e05 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:11:17 -0600 Subject: [PATCH 03/27] fix(cache): index multi-column primary keys per row Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + includes/functions.php | 42 ++++++++++++++++++++--------- tests/Unit/WmiRunStoreQueryTest.php | 37 +++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb5859f..464ad3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* issue: Index cached WMI rows by every column of a comma separated primary key so multi-row results are no longer overwritten * issue: Purge WMI cache rows the device stopped returning, and keep the existing rows when a query fails * issue: Expire stale wmi_processes lock rows and stop the master poller waiting indefinitely for collectors that died * security: Replace the confirmation pages' inline `onClick='cactiReturnTo()'` Cancel buttons with the CSP-safe `cactiReturnTo` class so the pages no longer trip Cacti's Content-Security-Policy script-src-attr directive diff --git a/includes/functions.php b/includes/functions.php index 1fb9661..14709bf 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -471,21 +471,9 @@ function run_store_wmi_query($host_id, $wmi_query_id, $transport = null) { if (cacti_sizeof($data)) { $sql = []; - $pk_index = -1; - - if (cacti_sizeof($indexes)) { - foreach ($indexes as $index => $value) { - if ($value == $wmi_query['primary_key']) { - $pk_index = $index; - - break; - } - } - } - if (cacti_sizeof($data)) { foreach ($data as $row) { - $pk = isset($row[$pk_index]) ? $row[$pk_index] : 'N/A'; + $pk = wmi_row_index($row, $indexes, $wmi_query['primary_key']); foreach ($row as $index => $value) { if (!isset($indexes[$index])) { @@ -556,6 +544,34 @@ function run_store_wmi_query($host_id, $wmi_query_id, $transport = null) { [$host_id, $wmi_query_id]); } +/** + * Builds the host_wmi_cache index value for one result row from the query's + * primary key. A comma separated key joins its columns' values with a comma. + * 'None', an empty key, or a key column missing from the result header yields + * 'N/A', which is the single shared index for queries that return one row. + * + * @param array $row One result row. + * @param array $indexes The result's column header. + * @param string $primary_key The query's primary key setting. + * + * @return string The cache index for the row. + */ +function wmi_row_index($row, $indexes, $primary_key) { + $values = []; + + foreach (explode(',', $primary_key) as $column) { + $position = array_search(trim($column), $indexes, true); + + if ($position === false || !isset($row[$position])) { + return 'N/A'; + } + + $values[] = $row[$position]; + } + + return implode(',', $values); +} + /** * Deletes wmi_processes lock rows older than $max_age seconds. A collector * that was killed or never started leaves its row behind, and the master diff --git a/tests/Unit/WmiRunStoreQueryTest.php b/tests/Unit/WmiRunStoreQueryTest.php index bd9c13d..cbbcff8 100644 --- a/tests/Unit/WmiRunStoreQueryTest.php +++ b/tests/Unit/WmiRunStoreQueryTest.php @@ -91,3 +91,40 @@ function wmi_test_cache_statements(): array { expect($statements)->toHaveCount(1); expect($statements[0])->toContain('DELETE FROM host_wmi_cache WHERE host_id = ? AND wmi_query_id = ?'); }); + +it('indexes a row by a single primary key column', function () { + expect(wmi_row_index(array('Spooler', 'Running'), array('Name', 'State'), 'Name'))->toBe('Spooler'); +}); + +it('indexes a row by every column of a comma separated primary key', function () { + expect(wmi_row_index(array('C:', 'D1', 'x'), array('Drive', 'Disk', 'Size'), 'Drive, Disk'))->toBe('C:,D1'); +}); + +it('uses the shared index when the key is None or missing from the header', function () { + $header = array('Name', 'State'); + + expect(wmi_row_index(array('a', 'b'), $header, 'None'))->toBe('N/A'); + expect(wmi_row_index(array('a', 'b'), $header, 'Name,Missing'))->toBe('N/A'); + expect(wmi_row_index(array('a'), $header, 'State'))->toBe('N/A'); +}); + +it('caches every row of a result whose primary key spans two columns', function () { + $GLOBALS['__test_fetch_row_map']['wmi_wql_queries']['primary_key'] = 'Name,State'; + + $t = new Wmi_Test_Canned_Transport(array('Win32_Service', 'Name|+|State', 'Spooler|+|Running', 'Spooler|+|Stopped')); + + ob_start(); + run_store_wmi_query(5, 9, $t); + ob_end_clean(); + + $insert = ''; + + foreach (wmi_test_cache_statements() as $statement) { + if (strpos($statement, 'INSERT INTO') === 0) { + $insert = $statement; + } + } + + expect($insert)->toContain("'Spooler,Running'"); + expect($insert)->toContain("'Spooler,Stopped'"); +}); From af3d541123934b65233c4d4cb9ffc58b49e39977 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:13:34 -0600 Subject: [PATCH 04/27] fix(poller): log the reason a WMI query failed Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + includes/functions.php | 4 ++++ tests/Unit/WmiRunStoreQueryTest.php | 22 ++++++++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 464ad3b..d4b67d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* issue: Log the reason a WMI query failed during polling * issue: Index cached WMI rows by every column of a comma separated primary key so multi-row results are no longer overwritten * issue: Purge WMI cache rows the device stopped returning, and keep the existing rows when a query fails * issue: Expire stale wmi_processes lock rows and stop the master poller waiting indefinitely for collectors that died diff --git a/includes/functions.php b/includes/functions.php index 14709bf..03e9a5d 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -531,6 +531,10 @@ function run_store_wmi_query($host_id, $wmi_query_id, $transport = null) { return true; } else { + $reason = is_string($wmi->error) ? $wmi->error : 'WMI returned no rows'; + + cacti_log('WARNING: WMI Query[' . $wmi_query_id . '] for Device[' . $host_id . '] failed: ' . $reason, false, 'WMI', POLLER_VERBOSITY_MEDIUM); + print $wmi->error; return false; diff --git a/tests/Unit/WmiRunStoreQueryTest.php b/tests/Unit/WmiRunStoreQueryTest.php index cbbcff8..c083726 100644 --- a/tests/Unit/WmiRunStoreQueryTest.php +++ b/tests/Unit/WmiRunStoreQueryTest.php @@ -128,3 +128,25 @@ function wmi_test_cache_statements(): array { expect($insert)->toContain("'Spooler,Running'"); expect($insert)->toContain("'Spooler,Stopped'"); }); + +it('logs why a query failed', function () { + $t = new Wmi_Test_Canned_Transport(false, 'ERROR: access denied'); + + ob_start(); + run_store_wmi_query(5, 9, $t); + ob_end_clean(); + + expect($GLOBALS['__test_cacti_log'])->toHaveCount(1); + expect($GLOBALS['__test_cacti_log'][0])->toContain('WMI Query[9] for Device[5] failed: ERROR: access denied'); +}); + +it('logs a plain reason when a query succeeds with no rows', function () { + $t = new Wmi_Test_Canned_Transport(array('Win32_Service', 'Name|+|State')); + + ob_start(); + $ok = run_store_wmi_query(5, 9, $t); + ob_end_clean(); + + expect($ok)->toBeFalse(); + expect($GLOBALS['__test_cacti_log'][0])->toContain('failed: WMI returned no rows'); +}); From bfdcb787a8d8d1afecd95b919a42a2d14d8be19f Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:15:00 -0600 Subject: [PATCH 05/27] fix(wmic): pass doubled-backslash namespaces with a single backslash Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + includes/linux_wmi.php | 11 ++++++- tests/Unit/WmiNamespaceArgumentTest.php | 44 +++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/WmiNamespaceArgumentTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d4b67d4..de5b4f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* issue: Pass the default WMI namespace to wmic with a single backslash * issue: Log the reason a WMI query failed during polling * issue: Index cached WMI rows by every column of a comma separated primary key so multi-row results are no longer overwritten * issue: Purge WMI cache rows the device stopped returning, and keep the existing rows when a query fails diff --git a/includes/linux_wmi.php b/includes/linux_wmi.php index e928977..e8206e5 100644 --- a/includes/linux_wmi.php +++ b/includes/linux_wmi.php @@ -106,7 +106,7 @@ public function error(): ?string { */ public function build_command(Wmi_Request $request): string { $namespace = $request->namespace !== '' - ? ' --namespace=' . cacti_escapeshellarg($this->strip_metachars($request->namespace)) + ? ' --namespace=' . cacti_escapeshellarg($this->single_backslashes($this->strip_metachars($request->namespace))) : ''; return cacti_escapeshellarg($request->binary) . @@ -118,6 +118,15 @@ public function build_command(Wmi_Request $request): string { ' ' . cacti_escapeshellarg($request->query); } + /** + * Namespaces are stored with doubled backslashes between segments, a form + * that only collapsed to one backslash inside a double-quoted shell + * string. The value is now single-quoted, so collapse it here. + */ + private function single_backslashes(string $value): string { + return preg_replace('/\\\\+/', '\\\\', $value); + } + /** * A hostname or namespace never legitimately contains shell or cmd.exe * metacharacters. cmd.exe ignores \" , toggles quoting on every " , and diff --git a/tests/Unit/WmiNamespaceArgumentTest.php b/tests/Unit/WmiNamespaceArgumentTest.php new file mode 100644 index 0000000..9ab3680 --- /dev/null +++ b/tests/Unit/WmiNamespaceArgumentTest.php @@ -0,0 +1,44 @@ +username = 'u'; + $w->password = 'p'; + $w->hostname = 'host'; + $w->command = 'SELECT 1'; + $w->querynspace = $namespace; + + return (string) $w->getcommand(); +} + +it('passes a doubled-backslash namespace to wmic with a single backslash', function () { + expect(wmi_test_namespace_command('root\\\\CIMV2'))->toContain("--namespace='root\\CIMV2'"); +}); + +it('leaves a single-backslash namespace unchanged', function () { + expect(wmi_test_namespace_command('root\\CIMV2'))->toContain("--namespace='root\\CIMV2'"); +}); + +it('omits the namespace argument when none is set', function () { + expect(wmi_test_namespace_command(''))->not->toContain('--namespace'); +}); From 1ac2e723f7e3ce2846b9b4b8d64f081afe811fa8 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:16:06 -0600 Subject: [PATCH 06/27] fix(tools): do not store or redisplay the query tool password Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + tests/Security/WmiToolsPasswordTest.php | 37 +++++++++++++++++++++++++ wmi_tools.php | 7 +---- 3 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 tests/Security/WmiToolsPasswordTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index de5b4f5..856906b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* security: Stop keeping the Query Tool password in the session and echoing it back into the form * issue: Pass the default WMI namespace to wmic with a single backslash * issue: Log the reason a WMI query failed during polling * issue: Index cached WMI rows by every column of a comma separated primary key so multi-row results are no longer overwritten diff --git a/tests/Security/WmiToolsPasswordTest.php b/tests/Security/WmiToolsPasswordTest.php new file mode 100644 index 0000000..2c8a013 --- /dev/null +++ b/tests/Security/WmiToolsPasswordTest.php @@ -0,0 +1,37 @@ +toContain("'username' =>"); + expect($block)->not->toContain("'password'"); + }); + + it('does not write the password back into the form', function () use ($source) { + expect($source)->not->toContain("html_escape_request_var('password')"); + expect($source)->toContain("id='password' value=''"); + }); + + it('still reads the submitted password for the query itself', function () use ($source) { + expect($source)->toContain("get_nfilter_request_var('password')"); + }); +}); diff --git a/wmi_tools.php b/wmi_tools.php index 7dc7d32..9b72ea1 100644 --- a/wmi_tools.php +++ b/wmi_tools.php @@ -69,11 +69,6 @@ function process_request_vars() { 'default' => '', 'options' => ['options' => 'sanitize_search_string'] ], - 'password' => [ - 'filter' => FILTER_DEFAULT, - 'pageset' => true, - 'default' => '', - ], 'namespace' => [ 'filter' => FILTER_CALLBACK, 'pageset' => true, @@ -395,7 +390,7 @@ function show_tools() { print ""; print ''; print "" . __('Password', 'wmi') . ''; - print ""; + print ""; print ''; print "" . __('Namespace', 'wmi') . ''; print ""; From 26d8e5281f8d3ac13a2d3aab42cb429b757ca8e4 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:17:12 -0600 Subject: [PATCH 07/27] fix(ui): count all matching rows for the list pagers Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + tests/Security/WmiListPaginationTest.php | 30 ++++++++++++++++++++++++ wmi_accounts.php | 4 +++- wmi_queries.php | 4 +++- 4 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 tests/Security/WmiListPaginationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 856906b..2707b74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* issue: Count all matching rows for the WMI Queries and Accounts pagers * security: Stop keeping the Query Tool password in the session and echoing it back into the form * issue: Pass the default WMI namespace to wmic with a single backslash * issue: Log the reason a WMI query failed during polling diff --git a/tests/Security/WmiListPaginationTest.php b/tests/Security/WmiListPaginationTest.php new file mode 100644 index 0000000..c6e00dc --- /dev/null +++ b/tests/Security/WmiListPaginationTest.php @@ -0,0 +1,30 @@ +not->toContain('$total_rows = sizeof($queries)'); + expect(preg_replace('/\s+/', ' ', $source))->toContain('$total_rows = db_fetch_cell("SELECT COUNT(*) FROM wmi_wql_queries $sql_where");'); + }); + + it('counts only the accounts matching the search filter', function () { + $source = plugin_test_read_source('wmi_accounts.php'); + + expect($source)->not->toContain("'SELECT COUNT(*) FROM wmi_user_accounts'"); + expect(preg_replace('/\s+/', ' ', $source))->toContain('$total_rows = db_fetch_cell("SELECT COUNT(*) FROM wmi_user_accounts $sql_where");'); + }); +}); diff --git a/wmi_accounts.php b/wmi_accounts.php index 2c26628..3567a2e 100644 --- a/wmi_accounts.php +++ b/wmi_accounts.php @@ -467,7 +467,9 @@ function show_accounts() { ORDER BY name LIMIT " . ($rows * (get_request_var('page') - 1)) . ', ' . $rows); - $total_rows = db_fetch_cell('SELECT COUNT(*) FROM wmi_user_accounts'); + $total_rows = db_fetch_cell("SELECT COUNT(*) + FROM wmi_user_accounts + $sql_where"); account_filter(); diff --git a/wmi_queries.php b/wmi_queries.php index 1399f4e..baf35bd 100644 --- a/wmi_queries.php +++ b/wmi_queries.php @@ -501,7 +501,9 @@ function show_queries() { ORDER BY name LIMIT " . ($rows * (get_request_var('page') - 1)) . ', ' . $rows); - $total_rows = sizeof($queries); + $total_rows = db_fetch_cell("SELECT COUNT(*) + FROM wmi_wql_queries + $sql_where"); $nav = html_nav_bar('wmi_queries.php', MAX_DISPLAY_PAGES, get_request_var('page'), $rows, $total_rows, 5, __('Queries', 'wmi'), 'page', 'main'); From 1bc4d4663dbfa700fb456ba2ddd5a81e274acaeb Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:18:18 -0600 Subject: [PATCH 08/27] fix(accounts): validate an account before saving it Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + includes/functions.php | 34 ++++++++++++++++++++++++ tests/Security/WmiAccountSaveTest.php | 22 ++++++++++++++++ tests/Unit/WmiAccountInputTest.php | 38 +++++++++++++++++++++++++++ wmi_accounts.php | 22 +++++++++------- 5 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 tests/Security/WmiAccountSaveTest.php create mode 100644 tests/Unit/WmiAccountInputTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2707b74..89562c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* issue: Reject WMI account saves with a mismatched, missing or oversized password instead of saving a partial account * issue: Count all matching rows for the WMI Queries and Accounts pagers * security: Stop keeping the Query Tool password in the session and echoing it back into the form * issue: Pass the default WMI namespace to wmic with a single backslash diff --git a/includes/functions.php b/includes/functions.php index 03e9a5d..824cc08 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -548,6 +548,40 @@ function run_store_wmi_query($host_id, $wmi_query_id, $transport = null) { [$host_id, $wmi_query_id]); } +/** + * Checks the fields of a submitted WMI account. A name and username are + * required, the password must match its confirmation, and a new account + * must have a password. + * + * @param string $name The account name. + * @param string $username The Windows user name. + * @param string $password The new password, or '' to keep the stored one. + * @param string $confirm The password confirmation. + * @param int $id The account id, 0 for a new account. + * + * @return bool True when the account can be saved. + */ +function wmi_account_input_valid($name, $username, $password, $confirm, $id) { + if ($name === '' || $username === '' || $password !== $confirm) { + return false; + } + + return $password !== '' || $id > 0; +} + +/** + * Checks that an encoded password fits the wmi_user_accounts.password + * column (varchar(256)) instead of being truncated or rejected by the + * database. + * + * @param string $encoded The value returned by Linux_WMI::encode(). + * + * @return bool True when the value fits. + */ +function wmi_account_password_fits($encoded) { + return strlen($encoded) <= 256; +} + /** * Builds the host_wmi_cache index value for one result row from the query's * primary key. A comma separated key joins its columns' values with a comma. diff --git a/tests/Security/WmiAccountSaveTest.php b/tests/Security/WmiAccountSaveTest.php new file mode 100644 index 0000000..fc7427e --- /dev/null +++ b/tests/Security/WmiAccountSaveTest.php @@ -0,0 +1,22 @@ +toContain('wmi_account_input_valid('); + expect($source)->toContain('wmi_account_password_fits('); + expect($source)->toContain("if (\$valid) { \$id = sql_save(\$save, 'wmi_user_accounts', 'id'); } else { raise_message(4);"); +}); diff --git a/tests/Unit/WmiAccountInputTest.php b/tests/Unit/WmiAccountInputTest.php new file mode 100644 index 0000000..1eae6ca --- /dev/null +++ b/tests/Unit/WmiAccountInputTest.php @@ -0,0 +1,38 @@ +toBeTrue(); +}); + +it('rejects a password that does not match its confirmation', function () { + expect(wmi_account_input_valid('Lab', 'dom\\user', 'secret', 'other', 0))->toBeFalse(); + expect(wmi_account_input_valid('Lab', 'dom\\user', 'secret', 'other', 4))->toBeFalse(); +}); + +it('requires a password for a new account but not when editing one', function () { + expect(wmi_account_input_valid('Lab', 'dom\\user', '', '', 0))->toBeFalse(); + expect(wmi_account_input_valid('Lab', 'dom\\user', '', '', 4))->toBeTrue(); +}); + +it('requires a name and a username', function () { + expect(wmi_account_input_valid('', 'dom\\user', 'secret', 'secret', 4))->toBeFalse(); + expect(wmi_account_input_valid('Lab', '', 'secret', 'secret', 4))->toBeFalse(); +}); + +it('accepts an encoded password that fits the column and rejects one that does not', function () { + $w = new Linux_WMI(); + + expect(wmi_account_password_fits($w->encode(str_repeat('a', 64))))->toBeTrue(); + expect(wmi_account_password_fits($w->encode(str_repeat('a', 200))))->toBeFalse(); + expect(wmi_account_password_fits(str_repeat('x', 256)))->toBeTrue(); + expect(wmi_account_password_fits(str_repeat('x', 257)))->toBeFalse(); +}); diff --git a/wmi_accounts.php b/wmi_accounts.php index 3567a2e..60b0c27 100644 --- a/wmi_accounts.php +++ b/wmi_accounts.php @@ -215,19 +215,23 @@ function save_accounts() { $save['name'] = get_nfilter_request_var('name'); $save['username'] = get_nfilter_request_var('username'); - if (get_nfilter_request_var('password') == get_nfilter_request_var('password_confirm')) { - if (get_nfilter_request_var('password') != '') { - $wmi = new Linux_WMI(); + $password = get_nfilter_request_var('password'); + $valid = wmi_account_input_valid($save['name'], $save['username'], $password, get_nfilter_request_var('password_confirm'), (int) $save['id']); - $save['password'] = $wmi->encode(get_nfilter_request_var('password')); - } elseif ($save['id'] < 1) { - raise_message(4); - } + if ($valid && $password !== '') { + $wmi = new Linux_WMI(); + + $save['password'] = $wmi->encode($password); + $valid = wmi_account_password_fits($save['password']); + } + + if ($valid) { + $id = sql_save($save, 'wmi_user_accounts', 'id'); } else { raise_message(4); - } - $id = sql_save($save, 'wmi_user_accounts', 'id'); + $id = 0; + } if (is_error_message()) { header('Location: wmi_accounts.php?header=false&action=edit&id=' . (empty($id) ? get_request_var('id') : $id)); From 59dd838077b1361ea7203e114a1e72c2d74c276b Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:19:24 -0600 Subject: [PATCH 09/27] fix(templates): correct the remove-query form action Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + setup.php | 2 +- tests/Unit/WmiTemplateRemoveFormTest.php | 20 ++++++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/WmiTemplateRemoveFormTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 89562c6..22bbbe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* issue: Fix the form action of the remove WMI Query confirmation on the Device Template page * issue: Reject WMI account saves with a mismatched, missing or oversized password instead of saving a partial account * issue: Count all matching rows for the WMI Queries and Accounts pagers * security: Stop keeping the Query Tool password in the session and echoing it back into the form diff --git a/setup.php b/setup.php index 59006bd..8ecb16d 100644 --- a/setup.php +++ b/setup.php @@ -724,7 +724,7 @@ function wmi_device_template_top() { get_filter_request_var('host_template_id'); // ==================================================== - form_start('host_templates.php?action=edit&id' . get_request_var('host_template_id')); + form_start('host_templates.php?action=edit&id=' . get_request_var('host_template_id')); html_start_box('', '100%', false, 3, 'center', ''); diff --git a/tests/Unit/WmiTemplateRemoveFormTest.php b/tests/Unit/WmiTemplateRemoveFormTest.php new file mode 100644 index 0000000..0d31852 --- /dev/null +++ b/tests/Unit/WmiTemplateRemoveFormTest.php @@ -0,0 +1,20 @@ +toContain("form_start('host_templates.php?action=edit&id=' . get_request_var('host_template_id'))"); + expect($source)->not->toContain("action=edit&id' ."); +}); From 7b098001a049d2196925428610f02fbcca19c470 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:24:21 -0600 Subject: [PATCH 10/27] test(e2e): drive the collector through a fake wmic against a real Cacti Signed-off-by: Thomas Vincent --- .github/workflows/plugin-ci-workflow.yml | 2 + CHANGELOG.md | 1 + phpunit.xml | 1 + tests/E2E/CollectorFlowTest.php | 91 +++++++++++++ tests/E2E/collector_flow.php | 166 +++++++++++++++++++++++ 5 files changed, 261 insertions(+) create mode 100644 tests/E2E/CollectorFlowTest.php create mode 100644 tests/E2E/collector_flow.php diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 650d1fc..f97a0ca 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -197,6 +197,8 @@ jobs: run: echo -n "${{ env.CACTI }}" | sudo tee ${{ github.workspace }}/cacti/plugins/wmi/tests/.cacti-version > /dev/null - name: Run Pest Unit Tests + env: + WMI_E2E: '1' run: | cd ${{ github.workspace }}/cacti "$CACTI_PEST_BIN" --configuration=plugins/wmi/phpunit.xml \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 22bbbe3..866cec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* dev: Add an end-to-end collector test that drives poller_wmi.php and the cache refresh through a fake wmic against the CI Cacti install and database * issue: Fix the form action of the remove WMI Query confirmation on the Device Template page * issue: Reject WMI account saves with a mismatched, missing or oversized password instead of saving a partial account * issue: Count all matching rows for the WMI Queries and Accounts pagers diff --git a/phpunit.xml b/phpunit.xml index 20406df..4f78006 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -17,6 +17,7 @@ tests/Security tests/Unit tests/Integration + tests/E2E tests/test_tab_wrapper.php tests/WmiCommandInjectionTest.php tests/WmiPowerShellTransportTest.php diff --git a/tests/E2E/CollectorFlowTest.php b/tests/E2E/CollectorFlowTest.php new file mode 100644 index 0000000..aa777ba --- /dev/null +++ b/tests/E2E/CollectorFlowTest.php @@ -0,0 +1,91 @@ + */ +function wmi_e2e_observations(string $root): array { + static $cache = null; + + if ($cache === null) { + $output = []; + $status = 0; + + exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($root . '/plugins/wmi/tests/E2E/collector_flow.php') . ' 2>&1', $output, $status); + + $json = json_decode((string) end($output), true); + $cache = is_array($json) ? $json : ['driver_failed' => implode("\n", $output)]; + } + + return $cache; +} + +describe('collector flow against a real Cacti', function () use ($wmi_e2e_root, $wmi_e2e_ready) { + beforeEach(function () use ($wmi_e2e_ready) { + if (!$wmi_e2e_ready) { + $this->markTestSkipped('Set WMI_E2E=1 with an installed Cacti and database to run.'); + } + }); + + it('runs the driver to completion', function () use ($wmi_e2e_root) { + expect(wmi_e2e_observations($wmi_e2e_root))->not->toHaveKey('driver_failed'); + }); + + it('caches every row of the first refresh', function () use ($wmi_e2e_root) { + $o = wmi_e2e_observations($wmi_e2e_root); + + expect($o['first_ok'])->toBeTrue(); + expect($o['first_indexes'])->toBe(['A', 'B', 'C']); + }); + + it('passes the namespace to wmic with a single backslash', function () use ($wmi_e2e_root) { + expect(wmi_e2e_observations($wmi_e2e_root)['namespace_args'])->toBe(['--namespace=root\\CIMV2']); + }); + + it('purges a row the device stopped returning', function () use ($wmi_e2e_root) { + expect(wmi_e2e_observations($wmi_e2e_root)['second_indexes'])->toBe(['A', 'B']); + }); + + it('keeps the cached rows intact when a query fails and logs why', function () use ($wmi_e2e_root) { + $o = wmi_e2e_observations($wmi_e2e_root); + + expect($o['failed_ok'])->toBeFalse(); + expect($o['failed_indexes'])->toBe(['A', 'B']); + expect($o['failed_present'])->toBe(0); + expect($o['failure_logged'])->toBeTrue(); + }); + + it('keeps both rows that share a name when the primary key has two columns', function () use ($wmi_e2e_root) { + expect(wmi_e2e_observations($wmi_e2e_root)['composite_indexes'])->toBe(['A,Running', 'A,Stopped']); + }); + + it('does not start while a recent collector lock exists', function () use ($wmi_e2e_root) { + $o = wmi_e2e_observations($wmi_e2e_root); + + expect($o['fresh_lock_output'])->toContain('still running'); + expect($o['fresh_lock_indexes'])->toBe([]); + }); + + it('clears a stale collector lock and polls the device', function () use ($wmi_e2e_root) { + $o = wmi_e2e_observations($wmi_e2e_root); + + expect($o['stale_lock_output'])->toContain('All Processes Complete'); + expect($o['stale_lock_indexes'])->toBe(['A']); + expect($o['locks_left'])->toBe(0); + }); +}); diff --git a/tests/E2E/collector_flow.php b/tests/E2E/collector_flow.php new file mode 100644 index 0000000..761fcca --- /dev/null +++ b/tests/E2E/collector_flow.php @@ -0,0 +1,166 @@ + The wmi_index values currently cached. */ +function cached_indexes(): array { + return array_column(db_fetch_assoc_prepared( + "SELECT DISTINCT wmi_index FROM host_wmi_cache WHERE host_id = ? AND wmi_query_id = ? AND field_name = 'Name' ORDER BY wmi_index", + [ID, ID] + ), 'wmi_index'); +} + +function cleanup(string $work, array $saved): void { + db_execute_prepared('DELETE FROM host_wmi_cache WHERE host_id = ?', [ID]); + db_execute_prepared('DELETE FROM host_wmi_query WHERE host_id = ?', [ID]); + db_execute_prepared('DELETE FROM host_template_wmi_query WHERE wmi_query_id = ?', [ID]); + db_execute_prepared('DELETE FROM wmi_wql_queries WHERE id = ?', [ID]); + db_execute_prepared('DELETE FROM wmi_user_accounts WHERE id = ?', [ID]); + db_execute_prepared('DELETE FROM host WHERE id = ?', [ID]); + db_execute('DELETE FROM wmi_processes'); + + foreach (['path_wmi', 'log_verbosity'] as $name) { + if (isset($saved[$name]) && $saved[$name] !== false) { + db_execute_prepared('REPLACE INTO settings (name, value) VALUES (?, ?)', [$name, $saved[$name]]); + } else { + db_execute_prepared('DELETE FROM settings WHERE name = ?', [$name]); + } + } + + if (!empty($saved['plugin_row_added'])) { + db_execute_prepared("DELETE FROM plugin_config WHERE directory = 'wmi'"); + } + + foreach (glob($work . '/*') ?: [] as $file) { + unlink($file); + } + + @rmdir($work); +} + +mkdir($work, 0700); + +register_shutdown_function(function () use ($work, &$saved) { + cleanup($work, $saved); +}); + +plugin_wmi_setup_tables(); + +foreach (['path_wmi', 'log_verbosity'] as $name) { + $saved[$name] = db_fetch_cell_prepared('SELECT value FROM settings WHERE name = ?', [$name]) ?: false; +} + +cleanup($work, []); +mkdir($work, 0700); + +if (db_fetch_cell_prepared("SELECT COUNT(*) FROM plugin_config WHERE directory = 'wmi'") == 0) { + db_execute("INSERT INTO plugin_config (directory, name, status, author, webpage, version) VALUES ('wmi', 'wmi', 1, '', '', '1')"); + + $saved['plugin_row_added'] = true; +} + +file_put_contents($work . '/wmic', "#!/bin/sh\nprintf '%s\\n' \"\$@\" > " . escapeshellarg($work . '/argv') . "\ncat " . escapeshellarg($work . '/out') . "\nexit \$(cat " . escapeshellarg($work . '/status') . ")\n"); +chmod($work . '/wmic', 0700); + +db_execute_prepared('REPLACE INTO settings (name, value) VALUES (?, ?)', ['path_wmi', $work . '/wmic']); +db_execute_prepared('REPLACE INTO settings (name, value) VALUES (?, ?)', ['log_verbosity', (string) POLLER_VERBOSITY_HIGH]); + +$wmi = new Linux_WMI(); + +db_execute_prepared("INSERT INTO wmi_user_accounts (id, name, username, password) VALUES (?, 'e2e', 'dom\\\\user', ?)", [ID, $wmi->encode('pw')]); +db_execute_prepared("INSERT INTO host (id, description, hostname, host_template_id, status, disabled, wmi_account) VALUES (?, 'wmi-e2e', 'winsrv.invalid', ?, 3, '', ?)", [ID, ID, ID]); +db_execute_prepared("INSERT INTO wmi_wql_queries (id, hash, name, frequency, enabled, namespace, query, primary_key) VALUES (?, '', 'e2e', 60, 'on', ?, 'SELECT Name,State FROM Win32_Service', 'Name')", [ID, 'root\\\\CIMV2']); +db_execute_prepared('INSERT INTO host_template_wmi_query (host_template_id, wmi_query_id) VALUES (?, ?)', [ID, ID]); + +$sep = '|+|'; + +// First refresh: three services are cached. +fake_wmic_script($work, ['Win32_Service', 'Name' . $sep . 'State', 'A' . $sep . 'Running', 'B' . $sep . 'Running', 'C' . $sep . 'Stopped'], 0); +ob_start(); +$out['first_ok'] = run_store_wmi_query(ID, ID); +ob_end_clean(); +$out['first_indexes'] = cached_indexes(); +$out['namespace_args'] = array_values(array_filter(explode("\n", (string) file_get_contents($work . '/argv')), fn ($a) => strpos($a, '--namespace=') === 0)); + +// Second refresh: service C is gone and must be purged. +fake_wmic_script($work, ['Win32_Service', 'Name' . $sep . 'State', 'A' . $sep . 'Running', 'B' . $sep . 'Stopped'], 0); +ob_start(); +$out['second_ok'] = run_store_wmi_query(ID, ID); +ob_end_clean(); +$out['second_indexes'] = cached_indexes(); + +// A failing query keeps the cached rows and logs the reason. +fake_wmic_script($work, ['ERROR: Login to remote object.'], 1); +ob_start(); +$out['failed_ok'] = run_store_wmi_query(ID, ID); +ob_end_clean(); +$out['failed_indexes'] = cached_indexes(); +$out['failed_present'] = (int) db_fetch_cell_prepared('SELECT COUNT(*) FROM host_wmi_cache WHERE host_id = ? AND present = 0', [ID]); + +$log = (string) @file_get_contents(read_config_option('path_cactilog') ?: $root . '/log/cacti.log'); + +$out['failure_logged'] = strpos($log, 'WMI Query[' . ID . '] for Device[' . ID . '] failed: ERROR: ') !== false; + +// A two-column primary key keeps both rows that share a Name. +db_execute_prepared("UPDATE wmi_wql_queries SET primary_key = 'Name,State' WHERE id = ?", [ID]); +fake_wmic_script($work, ['Win32_Service', 'Name' . $sep . 'State', 'A' . $sep . 'Running', 'A' . $sep . 'Stopped'], 0); +ob_start(); +run_store_wmi_query(ID, ID); +ob_end_clean(); +$out['composite_indexes'] = cached_indexes(); + +// Master poller: a fresh lock row blocks the run, a stale one is cleared. +db_execute_prepared("UPDATE wmi_wql_queries SET primary_key = 'Name' WHERE id = ?", [ID]); +db_execute_prepared('DELETE FROM host_wmi_cache WHERE host_id = ?', [ID]); +fake_wmic_script($work, ['Win32_Service', 'Name' . $sep . 'State', 'A' . $sep . 'Running'], 0); + +$poller = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($root . '/plugins/wmi/poller_wmi.php') . ' -M 2>&1'; + +db_execute('DELETE FROM wmi_processes'); +db_execute('INSERT INTO wmi_processes (pid, taskid, started) VALUES (1, 1, NOW())'); +$out['fresh_lock_output'] = shell_exec($poller); +$out['fresh_lock_indexes'] = cached_indexes(); + +db_execute('DELETE FROM wmi_processes'); +db_execute('INSERT INTO wmi_processes (pid, taskid, started) VALUES (1, 1, DATE_SUB(NOW(), INTERVAL 2 HOUR))'); +$out['stale_lock_output'] = shell_exec($poller); +$out['stale_lock_indexes'] = cached_indexes(); +$out['locks_left'] = (int) db_fetch_cell('SELECT COUNT(*) FROM wmi_processes'); + +print "\n" . json_encode($out) . "\n"; From 7652032c53fa87bc917b6d75e061f12171caa917 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:27:09 -0600 Subject: [PATCH 11/27] test(templates): execute the remove-query confirmation hook Signed-off-by: Thomas Vincent --- tests/Unit/WmiTemplateRemoveFormTest.php | 26 +++++++++++++++++++----- tests/bootstrap-unit.php | 2 +- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/tests/Unit/WmiTemplateRemoveFormTest.php b/tests/Unit/WmiTemplateRemoveFormTest.php index 0d31852..d9c3860 100644 --- a/tests/Unit/WmiTemplateRemoveFormTest.php +++ b/tests/Unit/WmiTemplateRemoveFormTest.php @@ -8,13 +8,29 @@ */ /* - * The remove-query confirmation dialog is printed by a hook that also calls - * exit, so its form action is checked in the source. + * The remove-query confirmation dialog is printed by a hook that ends in + * exit, so the form_start() stub throws once it has recorded the action URL. */ +if (!function_exists('form_start')) { + function form_start($action, $id = '') { + throw new RuntimeException($action); + } +} + +require_once __DIR__ . '/../../setup.php'; + it('builds the confirmation form action with an id parameter', function () { - $source = plugin_test_read_source('setup.php'); + $GLOBALS['__test_request'] = array('action' => 'item_remove_wq_confirm', 'host_template_id' => '12'); + + try { + wmi_device_template_top(); + $action = ''; + } catch (RuntimeException $e) { + $action = $e->getMessage(); + } finally { + $GLOBALS['__test_request'] = array(); + } - expect($source)->toContain("form_start('host_templates.php?action=edit&id=' . get_request_var('host_template_id'))"); - expect($source)->not->toContain("action=edit&id' ."); + expect($action)->toBe('host_templates.php?action=edit&id=12'); }); diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index fcdd2cf..6e44c3e 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -222,7 +222,7 @@ function raise_message($id, $text = '', $level = 0) { if (!function_exists('get_request_var')) { function get_request_var($name) { - return ''; + return $GLOBALS['__test_request'][$name] ?? ''; } } From a38f972fd137e95d13cd793b6b2fabc471a521ad Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:31:01 -0600 Subject: [PATCH 12/27] test(e2e): keep the E2E entry script inert outside the test environment Signed-off-by: Thomas Vincent --- tests/E2E/CollectorFlowTest.php | 29 ++++++++++++++++++++++++++++- tests/E2E/collector_flow.php | 25 +++++++++++++++++-------- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/tests/E2E/CollectorFlowTest.php b/tests/E2E/CollectorFlowTest.php index aa777ba..18158a4 100644 --- a/tests/E2E/CollectorFlowTest.php +++ b/tests/E2E/CollectorFlowTest.php @@ -25,8 +25,13 @@ function wmi_e2e_observations(string $root): array { if ($cache === null) { $output = []; $status = 0; + $work = sys_get_temp_dir() . '/wmi-e2e-' . bin2hex(random_bytes(6)); - exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($root . '/plugins/wmi/tests/E2E/collector_flow.php') . ' 2>&1', $output, $status); + mkdir($work, 0700); + + exec('WMI_E2E=1 WMI_E2E_WORKDIR=' . escapeshellarg($work) . ' ' . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($root . '/plugins/wmi/tests/E2E/collector_flow.php') . ' 2>&1', $output, $status); + + @rmdir($work); $json = json_decode((string) end($output), true); $cache = is_array($json) ? $json : ['driver_failed' => implode("\n", $output)]; @@ -89,3 +94,25 @@ function wmi_e2e_observations(string $root): array { expect($o['locks_left'])->toBe(0); }); }); + +describe('collector flow entry script', function () { + it('does nothing and prints nothing without the test environment', function () { + $script = __DIR__ . '/collector_flow.php'; + $output = []; + $status = 0; + + exec('env -u WMI_E2E -u WMI_E2E_WORKDIR ' . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($script) . ' 2>&1', $output, $status); + + expect($output)->toBe([]); + expect($status)->toBe(0); + }); + + it('does nothing when the work directory is missing', function () { + $script = __DIR__ . '/collector_flow.php'; + $output = []; + + exec('WMI_E2E=1 WMI_E2E_WORKDIR=/nonexistent-wmi-e2e ' . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($script) . ' 2>&1', $output); + + expect($output)->toBe([]); + }); +}); diff --git a/tests/E2E/collector_flow.php b/tests/E2E/collector_flow.php index 761fcca..3f81459 100644 --- a/tests/E2E/collector_flow.php +++ b/tests/E2E/collector_flow.php @@ -16,6 +16,19 @@ * Every row it creates uses ids from 900001 upward and is removed again. */ +/* + * tests/ ships inside the plugin directory, so this file must do nothing when + * a web server runs it. It needs the CLI and a work directory that the test + * process names and creates; it takes no path or command from a request. + */ +$work = getenv('WMI_E2E_WORKDIR'); + +if (PHP_SAPI !== 'cli' || getenv('WMI_E2E') !== '1' || $work === false || !is_dir($work)) { + http_response_code(404); + + exit; +} + $root = dirname(__DIR__, 4); chdir($root); @@ -28,7 +41,6 @@ const ID = 900001; -$work = sys_get_temp_dir() . '/wmi-e2e-' . getmypid(); $out = []; $saved = []; @@ -67,15 +79,13 @@ function cleanup(string $work, array $saved): void { db_execute_prepared("DELETE FROM plugin_config WHERE directory = 'wmi'"); } - foreach (glob($work . '/*') ?: [] as $file) { - unlink($file); + foreach (['wmic', 'out', 'status', 'argv'] as $name) { + if (is_file($work . '/' . $name)) { + unlink($work . '/' . $name); + } } - - @rmdir($work); } -mkdir($work, 0700); - register_shutdown_function(function () use ($work, &$saved) { cleanup($work, $saved); }); @@ -87,7 +97,6 @@ function cleanup(string $work, array $saved): void { } cleanup($work, []); -mkdir($work, 0700); if (db_fetch_cell_prepared("SELECT COUNT(*) FROM plugin_config WHERE directory = 'wmi'") == 0) { db_execute("INSERT INTO plugin_config (directory, name, status, author, webpage, version) VALUES ('wmi', 'wmi', 1, '', '', '1')"); From 0dd49314f1e9fbb4cc9c8156d0d5b9024592b9f8 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:32:08 -0600 Subject: [PATCH 13/27] style: align the run_store_wmi_query docblock Signed-off-by: Thomas Vincent --- includes/functions.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/includes/functions.php b/includes/functions.php index 824cc08..092cf39 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -377,11 +377,11 @@ function plugin_wmi_create_resource_xml($id) { * Called from poller_wmi.php's polling loop for each host/query pairing * configured to use WMI-based data collection. * - * @param int $host_id The Cacti host id to query. - * @param int $wmi_query_id The wmi_wql_queries.id to run against the - * host. - * @param Wmi_Transport|null $transport Transport override; the OS default - * is used when null. + * @param int $host_id The Cacti host id to query. + * @param int $wmi_query_id The wmi_wql_queries.id to run against the + * host. + * @param Wmi_Transport|null $transport Transport override; the OS default + * is used when null. * * @return bool|void True when the query succeeded and results were * cached; false when the host's WMI account/query From 8c567aae261591daf65181da1e797986c2f31e77 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:38:12 -0600 Subject: [PATCH 14/27] ci: make the Cacti log writable before the Pest step Signed-off-by: Thomas Vincent --- .github/workflows/plugin-ci-workflow.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index f97a0ca..9810c70 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -196,6 +196,16 @@ jobs: - name: Set expected Cacti version for unit tests run: echo -n "${{ env.CACTI }}" | sudo tee ${{ github.workspace }}/cacti/plugins/wmi/tests/.cacti-version > /dev/null + # Installing Cacti with sudo leaves log/cacti.log owned by root, and + # Cacti's CLI bootstrap refuses to start when it cannot write that file. + # The end-to-end collector test runs Cacti as the runner user. + - name: Make the Cacti log writable for the test user + run: | + sudo mkdir -p ${{ github.workspace }}/cacti/log + sudo chown -R www-data:runner ${{ github.workspace }}/cacti/log + sudo chmod 775 ${{ github.workspace }}/cacti/log + sudo find ${{ github.workspace }}/cacti/log -type f -exec chmod 664 {} \; + - name: Run Pest Unit Tests env: WMI_E2E: '1' From 2c43a14500e91fde2b9205b74ede94a33c6d729c Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:39:18 -0600 Subject: [PATCH 15/27] test(e2e): show the driver output on failure and skip when the Cacti log is not writable Signed-off-by: Thomas Vincent --- tests/E2E/CollectorFlowTest.php | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/E2E/CollectorFlowTest.php b/tests/E2E/CollectorFlowTest.php index 18158a4..4607e99 100644 --- a/tests/E2E/CollectorFlowTest.php +++ b/tests/E2E/CollectorFlowTest.php @@ -16,8 +16,13 @@ $wmi_e2e_root = dirname(__DIR__, 4); +$wmi_e2e_log = $wmi_e2e_root . '/log/cacti.log'; $wmi_e2e_ready = getenv('WMI_E2E') === '1' && is_readable($wmi_e2e_root . '/include/config.php'); +// Cacti's CLI bootstrap exits when it cannot write its log file, so a driver +// started by this user would print nothing useful. Skip with the reason. +$wmi_e2e_log_ok = is_file($wmi_e2e_log) ? is_writable($wmi_e2e_log) : is_writable(dirname($wmi_e2e_log)); + /** @return array */ function wmi_e2e_observations(string $root): array { static $cache = null; @@ -34,21 +39,26 @@ function wmi_e2e_observations(string $root): array { @rmdir($work); $json = json_decode((string) end($output), true); - $cache = is_array($json) ? $json : ['driver_failed' => implode("\n", $output)]; + $cache = is_array($json) ? $json : ['driver_failed' => 'exit status ' . $status . "\n" . implode("\n", $output)]; } return $cache; } -describe('collector flow against a real Cacti', function () use ($wmi_e2e_root, $wmi_e2e_ready) { - beforeEach(function () use ($wmi_e2e_ready) { +describe('collector flow against a real Cacti', function () use ($wmi_e2e_root, $wmi_e2e_ready, $wmi_e2e_log_ok) { + beforeEach(function () use ($wmi_e2e_ready, $wmi_e2e_log_ok) { if (!$wmi_e2e_ready) { $this->markTestSkipped('Set WMI_E2E=1 with an installed Cacti and database to run.'); } + + if (!$wmi_e2e_log_ok) { + $this->markTestSkipped('Cacti log/cacti.log is not writable by the test user; Cacti would refuse to start.'); + } }); it('runs the driver to completion', function () use ($wmi_e2e_root) { - expect(wmi_e2e_observations($wmi_e2e_root))->not->toHaveKey('driver_failed'); + // Comparing the text, not the key, puts the driver's output in the failure. + expect(wmi_e2e_observations($wmi_e2e_root)['driver_failed'] ?? '')->toBe(''); }); it('caches every row of the first refresh', function () use ($wmi_e2e_root) { From 8fd6267fac246b9e5e69b5b200fbdc3291c3df62 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:47:09 -0600 Subject: [PATCH 16/27] test(e2e): restore cacti.log after the run Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + tests/E2E/collector_flow.php | 36 ++++++++++++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 866cec3..b04ecb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* dev: Restore cacti.log after the end-to-end collector test so the CI log check stays clean * dev: Add an end-to-end collector test that drives poller_wmi.php and the cache refresh through a fake wmic against the CI Cacti install and database * issue: Fix the form action of the remove WMI Query confirmation on the Device Template page * issue: Reject WMI account saves with a mismatched, missing or oversized password instead of saving a partial account diff --git a/tests/E2E/collector_flow.php b/tests/E2E/collector_flow.php index 3f81459..1d3613b 100644 --- a/tests/E2E/collector_flow.php +++ b/tests/E2E/collector_flow.php @@ -86,8 +86,35 @@ function cleanup(string $work, array $saved): void { } } -register_shutdown_function(function () use ($work, &$saved) { +/* + * The failure case logs a WARNING on purpose, and the CI workflow fails a job + * whose cacti.log holds any WARNING, ERROR or FATAL line. Remember how long + * the log was and cut it back to that length when the run ends, keeping the + * file's owner and mode. + */ +$log_file = read_config_option('path_cactilog') ?: $root . '/log/cacti.log'; +$log_exists = is_file($log_file); +$log_before = $log_exists ? (int) filesize($log_file) : 0; + +register_shutdown_function(function () use ($work, &$saved, $log_file, $log_exists, $log_before) { cleanup($work, $saved); + + clearstatcache(true, $log_file); + + if (!is_file($log_file)) { + return; + } + + if (!$log_exists) { + unlink($log_file); + } elseif (filesize($log_file) > $log_before) { + $handle = fopen($log_file, 'r+'); + + if ($handle !== false) { + ftruncate($handle, $log_before); + fclose($handle); + } + } }); plugin_wmi_setup_tables(); @@ -135,16 +162,17 @@ function cleanup(string $work, array $saved): void { $out['second_indexes'] = cached_indexes(); // A failing query keeps the cached rows and logs the reason. -fake_wmic_script($work, ['ERROR: Login to remote object.'], 1); +fake_wmic_script($work, ['Login to remote object.'], 1); ob_start(); $out['failed_ok'] = run_store_wmi_query(ID, ID); ob_end_clean(); $out['failed_indexes'] = cached_indexes(); $out['failed_present'] = (int) db_fetch_cell_prepared('SELECT COUNT(*) FROM host_wmi_cache WHERE host_id = ? AND present = 0', [ID]); -$log = (string) @file_get_contents(read_config_option('path_cactilog') ?: $root . '/log/cacti.log'); +clearstatcache(true, $log_file); +$log = (string) substr((string) @file_get_contents($log_file), $log_before); -$out['failure_logged'] = strpos($log, 'WMI Query[' . ID . '] for Device[' . ID . '] failed: ERROR: ') !== false; +$out['failure_logged'] = strpos($log, 'WMI Query[' . ID . '] for Device[' . ID . '] failed: ERROR: Login to remote object.') !== false; // A two-column primary key keeps both rows that share a Name. db_execute_prepared("UPDATE wmi_wql_queries SET primary_key = 'Name,State' WHERE id = ?", [ID]); From d5f1ab277b73778166c8d1990fb49d3fbae78358 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:49:35 -0600 Subject: [PATCH 17/27] fix(poller): default the collector count when the setting is empty Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + poller_wmi.php | 2 ++ tests/E2E/collector_flow.php | 9 ++++++--- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b04ecb1..17c2755 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* issue: Use the default of 10 concurrent WMI collectors when the setting is empty instead of waiting forever * dev: Restore cacti.log after the end-to-end collector test so the CI log check stays clean * dev: Add an end-to-end collector test that drives poller_wmi.php and the cache refresh through a fake wmic against the CI Cacti install and database * issue: Fix the form action of the remove WMI Query confirmation on the Device Template page diff --git a/poller_wmi.php b/poller_wmi.php index f68a39d..65f25cf 100644 --- a/poller_wmi.php +++ b/poller_wmi.php @@ -215,6 +215,8 @@ function process_all_devices() { $concurrent_processes = read_config_option('wmi_processes'); if (empty($concurrent_processes)) { + $concurrent_processes = 10; + set_config_option('wmi_processes', '10'); } diff --git a/tests/E2E/collector_flow.php b/tests/E2E/collector_flow.php index 1d3613b..6d36dd3 100644 --- a/tests/E2E/collector_flow.php +++ b/tests/E2E/collector_flow.php @@ -67,7 +67,7 @@ function cleanup(string $work, array $saved): void { db_execute_prepared('DELETE FROM host WHERE id = ?', [ID]); db_execute('DELETE FROM wmi_processes'); - foreach (['path_wmi', 'log_verbosity'] as $name) { + foreach (['path_wmi', 'log_verbosity', 'wmi_processes'] as $name) { if (isset($saved[$name]) && $saved[$name] !== false) { db_execute_prepared('REPLACE INTO settings (name, value) VALUES (?, ?)', [$name, $saved[$name]]); } else { @@ -119,7 +119,7 @@ function cleanup(string $work, array $saved): void { plugin_wmi_setup_tables(); -foreach (['path_wmi', 'log_verbosity'] as $name) { +foreach (['path_wmi', 'log_verbosity', 'wmi_processes'] as $name) { $saved[$name] = db_fetch_cell_prepared('SELECT value FROM settings WHERE name = ?', [$name]) ?: false; } @@ -187,8 +187,11 @@ function cleanup(string $work, array $saved): void { db_execute_prepared('DELETE FROM host_wmi_cache WHERE host_id = ?', [ID]); fake_wmic_script($work, ['Win32_Service', 'Name' . $sep . 'State', 'A' . $sep . 'Running'], 0); -$poller = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($root . '/plugins/wmi/poller_wmi.php') . ' -M 2>&1'; +// An unset concurrency setting must not stall the master; bound the run anyway. +$limit = trim((string) shell_exec('command -v timeout')) !== '' ? 'timeout 120 ' : ''; +$poller = $limit . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($root . '/plugins/wmi/poller_wmi.php') . ' -M 2>&1'; +db_execute_prepared("DELETE FROM settings WHERE name = 'wmi_processes'"); db_execute('DELETE FROM wmi_processes'); db_execute('INSERT INTO wmi_processes (pid, taskid, started) VALUES (1, 1, NOW())'); $out['fresh_lock_output'] = shell_exec($poller); From 262acce14e3a24c4fbdb101e398aacdbc7b0101d Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 23:33:40 -0600 Subject: [PATCH 18/27] fix(nav): point the account edit and tool breadcrumbs at an existing parent Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + setup.php | 4 ++-- tests/Unit/WmiConfigAndNavigationTest.php | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17c2755..3b1b6f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* issue: Fix the breadcrumb parents of the Edit WMI Account and WMI Query Tool pages so the account edit page no longer fails on Cacti develop * issue: Use the default of 10 concurrent WMI collectors when the setting is empty instead of waiting forever * dev: Restore cacti.log after the end-to-end collector test so the CI log check stays clean * dev: Add an end-to-end collector test that drives poller_wmi.php and the cache refresh through a fake wmic against the CI Cacti install and database diff --git a/setup.php b/setup.php index 8ecb16d..3971a9e 100644 --- a/setup.php +++ b/setup.php @@ -336,7 +336,7 @@ function wmi_draw_navigation_text($nav) { $nav['wmi_accounts.php:edit'] = [ 'title' => __('(Edit)', 'wmi'), - 'mapping' => 'index.php:wmi_accounts.php:', + 'mapping' => 'index.php:,wmi_accounts.php:', 'url' => 'wmi_accounts.php', 'level' => '2' ]; @@ -378,7 +378,7 @@ function wmi_draw_navigation_text($nav) { $nav['wmi_tools.php:query'] = [ 'title' => __('(Query)', 'wmi'), - 'mapping' => 'index.php:wmi_tools.php:', + 'mapping' => 'index.php:,wmi_tools.php:', 'url' => 'wmi_tools.php', 'level' => '2' ]; diff --git a/tests/Unit/WmiConfigAndNavigationTest.php b/tests/Unit/WmiConfigAndNavigationTest.php index e11e2ba..63dc0e7 100644 --- a/tests/Unit/WmiConfigAndNavigationTest.php +++ b/tests/Unit/WmiConfigAndNavigationTest.php @@ -71,3 +71,18 @@ expect($execCalls)->toBeEmpty(); }); + +it('maps every breadcrumb to parents that exist in the navigation array', function () { + $nav = wmi_draw_navigation_text(array('index.php:' => array('title' => 'Console'))); + $missing = array(); + + foreach ($nav as $key => $entry) { + foreach (explode(',', $entry['mapping'] ?? '') as $parent) { + if ($parent !== '' && !isset($nav[$parent])) { + $missing[] = $key . ' -> ' . $parent; + } + } + } + + expect($missing)->toBe(array()); +}); From 0e0abb2de1797e6321d72f5068f7a9be6a60ef15 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 23:40:24 -0600 Subject: [PATCH 19/27] test(e2e): record plugin line coverage across the end-to-end processes Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + tests/E2E/coverage_prepend.php | 52 +++++ tests/bin/e2e-coverage-report.php | 305 ++++++++++++++++++++++++++++++ 3 files changed, 358 insertions(+) create mode 100644 tests/E2E/coverage_prepend.php create mode 100644 tests/bin/e2e-coverage-report.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b1b6f8..44646ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* dev: Record which plugin lines the end-to-end run executes and report the changed lines no suite reaches (tests/bin/e2e-coverage-report.php) * issue: Fix the breadcrumb parents of the Edit WMI Account and WMI Query Tool pages so the account edit page no longer fails on Cacti develop * issue: Use the default of 10 concurrent WMI collectors when the setting is empty instead of waiting forever * dev: Restore cacti.log after the end-to-end collector test so the CI log check stays clean diff --git a/tests/E2E/coverage_prepend.php b/tests/E2E/coverage_prepend.php new file mode 100644 index 0000000..46adcf0 --- /dev/null +++ b/tests/E2E/coverage_prepend.php @@ -0,0 +1,52 @@ + $hits) { + if (strpos($file, $plugin . '/') !== 0 || strpos($file, $plugin . '/tests/') === 0) { + continue; + } + + $lines[substr($file, strlen($plugin) + 1)] = $hits; + } + + xdebug_stop_code_coverage(false); + + if ($lines !== []) { + file_put_contents($dir . '/' . getmypid() . '-' . bin2hex(random_bytes(4)) . '.json', json_encode($lines)); + } + }); +})(); diff --git a/tests/bin/e2e-coverage-report.php b/tests/bin/e2e-coverage-report.php new file mode 100644 index 0000000..306be84 --- /dev/null +++ b/tests/bin/e2e-coverage-report.php @@ -0,0 +1,305 @@ + [clover.xml] [dump-dir] + * + * The dump directory holds one sub-directory per driver (collector_flow, + * pages_flow), each filled by a run with WMI_E2E_COV set. + * + * Exits 1 when a changed executable line ran in no suite, 2 on bad input. + */ + +if (PHP_SAPI !== 'cli') { + http_response_code(404); + + exit; +} + +if ($argc < 2) { + fwrite(STDERR, "usage: e2e-coverage-report.php [clover.xml] [dump-dir]\n"); + + exit(2); +} + +$base_ref = $argv[1]; +$clover = $argv[2] ?? ''; +$dump_dir = $argv[3] ?? ''; + +/** + * Changed line numbers per production PHP file. + * + * @return array> + */ +function wmi_report_changed_lines(string $base_ref): array { + $lines = []; + $status = 0; + + exec('git diff --no-ext-diff --unified=0 --no-color --diff-filter=AM ' . escapeshellarg($base_ref) . '...HEAD -- "*.php"', $lines, $status); + + if ($status !== 0) { + fwrite(STDERR, "git diff failed\n"); + + exit(2); + } + + $changed = []; + $file = null; + + foreach ($lines as $line) { + if (strncmp($line, '+++ b/', 6) === 0) { + $file = substr($line, 6); + + if (strncmp($file, 'tests/', 6) === 0) { + $file = null; + + continue; + } + + $changed[$file] = []; + } elseif ($file !== null && strncmp($line, '@@', 2) === 0 && preg_match('/\+(\d+)(?:,(\d+))?/', $line, $m)) { + $count = isset($m[2]) ? (int) $m[2] : 1; + + for ($i = 0; $i < $count; $i++) { + $changed[$file][(int) $m[1] + $i] = true; + } + } + } + + return $changed; +} + +/** + * Statement lines of the unit-test Clover report, keyed by file then line, + * with the number of times each ran. + * + * @return array> + */ +function wmi_report_clover(string $path): array { + if ($path === '' || !is_readable($path)) { + return []; + } + + $xml = simplexml_load_file($path); + + if ($xml === false) { + fwrite(STDERR, "cannot parse $path\n"); + + exit(2); + } + + $result = []; + + foreach ($xml->xpath('//file') as $file) { + $name = (string) $file['name']; + + if (($pos = strpos($name, '/plugins/wmi/')) !== false) { + $name = substr($name, $pos + strlen('/plugins/wmi/')); + } + + foreach ($file->line as $line) { + if ((string) $line['type'] === 'stmt') { + $result[$name][(int) $line['num']] = (int) $line['count']; + } + } + } + + return $result; +} + +/** + * Merge the dumps of every driver. Lines: 1 ran, -1 did not run, -2 dead code. + * + * @return array{lines: array>>, branches: array>>} + */ +function wmi_report_dumps(string $dir): array { + $merged = ['lines' => [], 'branches' => []]; + + if ($dir === '' || !is_dir($dir)) { + return $merged; + } + + foreach (glob(rtrim($dir, '/') . '/*', GLOB_ONLYDIR) ?: [] as $driver_dir) { + $tag = basename($driver_dir); + + foreach (glob($driver_dir . '/*.json') ?: [] as $dump) { + $data = json_decode((string) file_get_contents($dump), true); + + if (!is_array($data)) { + continue; + } + + foreach ($data as $file => $entry) { + foreach ($entry['lines'] ?? [] as $line => $state) { + $current = $merged['lines'][$file][$tag][(int) $line] ?? -1; + + $merged['lines'][$file][$tag][(int) $line] = max($current, (int) $state); + } + + foreach ($entry['functions'] ?? [] as $function => $info) { + foreach ($info['branches'] ?? [] as $id => $branch) { + $key = $function . '#' . $id; + $old = $merged['branches'][$file][$tag][$key] ?? [(int) $branch['line_start'], (int) $branch['line_end'], 0]; + + $old[2] = max($old[2], (int) $branch['hit']); + + $merged['branches'][$file][$tag][$key] = $old; + } + } + } + } + } + + return $merged; +} + +$changed = wmi_report_changed_lines($base_ref); +$unit = wmi_report_clover($clover); +$dumps = wmi_report_dumps($dump_dir); + +$failed = false; +$summary = []; + +print "Traceability of changed production lines (base $base_ref)\n\n"; + +ksort($changed); + +foreach ($changed as $file => $lines) { + $rows = []; + $executable = 0; + $covered = 0; + $branches = []; + + ksort($lines); + + $source = is_readable($file) ? file($file, FILE_IGNORE_NEW_LINES) : []; + + foreach (array_keys($lines) as $number) { + $by = []; + $known = false; + + // A closing brace is reported as a statement when every path returns + // earlier; it is not code a test can reach. + if (in_array(trim($source[$number - 1] ?? ''), ['}', '};', '{'], true)) { + continue; + } + + if (isset($unit[$file][$number])) { + $known = true; + + if ($unit[$file][$number] > 0) { + $by[] = 'unit'; + } + } + + foreach ($dumps['lines'][$file] ?? [] as $tag => $state) { + if (isset($state[$number]) && $state[$number] !== -2) { + $known = true; + + if ($state[$number] === 1) { + $by[] = $tag; + } + } + } + + if (!$known) { + continue; + } + + $executable++; + + if ($by === []) { + $failed = true; + $rows[] = sprintf(" %5d NOT EXECUTED", $number); + } else { + $covered++; + $rows[] = sprintf(" %5d %s", $number, implode(', ', $by)); + } + } + + foreach ($dumps['branches'][$file] ?? [] as $tag => $list) { + foreach ($list as $key => [$start, $end, $hit]) { + foreach (array_keys($lines) as $number) { + if ($number >= $start && $number <= $end) { + $branches[$key][$tag] = $hit; + $branches[$key]['range'] = $start . '-' . $end; + } + } + } + } + + $missed = []; + + foreach ($branches as $key => $tags) { + $hit = false; + + foreach ($tags as $tag => $value) { + if ($tag !== 'range' && $value > 0) { + $hit = true; + } + } + + if (!$hit) { + $missed[] = $key . ' (lines ' . $tags['range'] . ')'; + } + } + + $summary[$file] = [$executable, $covered, count($branches), count($missed)]; + + print $file . "\n"; + print $rows === [] ? " no executable changed lines\n" : implode("\n", $rows) . "\n"; + + if ($missed !== []) { + print " branches no end-to-end run took (the unit suites report lines only): " . implode('; ', $missed) . "\n"; + } + + print "\n"; +} + +print "Summary (changed executable lines; branches overlapping the changed lines)\n"; +printf(" %-28s %9s %8s %9s %8s\n", 'file', 'executable', 'covered', 'e2e br.', 'not hit'); + +foreach ($summary as $file => [$executable, $covered, $branch_count, $branch_missed]) { + printf(" %-28s %9d %8d %9d %8d\n", $file, $executable, $covered, $branch_count, $branch_missed); +} + +print "\nWhole-file line coverage (executable lines known to the unit report or the dumps)\n"; +printf(" %-28s %9s %8s %7s\n", 'file', 'executable', 'covered', 'percent'); + +foreach (array_keys($changed) as $file) { + $state = []; + + foreach ($unit[$file] ?? [] as $line => $count) { + $state[$line] = $count > 0; + } + + foreach ($dumps['lines'][$file] ?? [] as $lines) { + foreach ($lines as $line => $value) { + if ($value !== -2) { + $state[$line] = ($state[$line] ?? false) || $value === 1; + } + } + } + + $total = count($state); + $ran = count(array_filter($state)); + + printf(" %-28s %9d %8d %6.1f%%\n", $file, $total, $ran, $total === 0 ? 0 : $ran * 100 / $total); +} + +exit($failed ? 1 : 0); From 9b106fdb7ebd660842a4a7482175c466c9a72e88 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 23:41:30 -0600 Subject: [PATCH 20/27] test(e2e): drive the cache refresh and the poller through a fake wmic Signed-off-by: Thomas Vincent --- tests/E2E/CollectorFlowTest.php | 236 +++++++++----- tests/E2E/collector_flow.php | 373 +++++++++++++--------- tests/E2E/e2e_common.php | 526 ++++++++++++++++++++++++++++++++ tests/E2E/runner.php | 118 +++++++ 4 files changed, 1030 insertions(+), 223 deletions(-) create mode 100644 tests/E2E/e2e_common.php create mode 100644 tests/E2E/runner.php diff --git a/tests/E2E/CollectorFlowTest.php b/tests/E2E/CollectorFlowTest.php index 4607e99..970e524 100644 --- a/tests/E2E/CollectorFlowTest.php +++ b/tests/E2E/CollectorFlowTest.php @@ -14,115 +14,207 @@ * it runs only when WMI_E2E=1 and include/config.php is present. */ -$wmi_e2e_root = dirname(__DIR__, 4); +require_once __DIR__ . '/runner.php'; -$wmi_e2e_log = $wmi_e2e_root . '/log/cacti.log'; -$wmi_e2e_ready = getenv('WMI_E2E') === '1' && is_readable($wmi_e2e_root . '/include/config.php'); +/** @return array */ +function wmi_e2e_collector(): array { + return wmi_e2e_run('collector_flow.php'); +} -// Cacti's CLI bootstrap exits when it cannot write its log file, so a driver -// started by this user would print nothing useful. Skip with the reason. -$wmi_e2e_log_ok = is_file($wmi_e2e_log) ? is_writable($wmi_e2e_log) : is_writable(dirname($wmi_e2e_log)); +describe('cache refresh against a real Cacti', function () { + beforeEach(function () { + $reason = wmi_e2e_unready(); -/** @return array */ -function wmi_e2e_observations(string $root): array { - static $cache = null; + if ($reason !== '') { + $this->markTestSkipped($reason); + } + }); - if ($cache === null) { - $output = []; - $status = 0; - $work = sys_get_temp_dir() . '/wmi-e2e-' . bin2hex(random_bytes(6)); + it('runs the driver to completion', function () { + // Comparing the text, not the key, puts the driver's output in the failure. + expect(wmi_e2e_collector()['driver_failed'] ?? '')->toBe(''); + }); - mkdir($work, 0700); + it('caches every row of the first refresh', function () { + $o = wmi_e2e_collector()['first']; - exec('WMI_E2E=1 WMI_E2E_WORKDIR=' . escapeshellarg($work) . ' ' . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($root . '/plugins/wmi/tests/E2E/collector_flow.php') . ' 2>&1', $output, $status); + expect($o['result'])->toBeTrue(); + expect($o['indexes'])->toBe(['A', 'B', 'C']); + expect($o['rows'])->toBe(6); + expect($o['present0'])->toBe(0); + }); - @rmdir($work); + it('passes the credentials and query to wmic', function () { + $argv = wmi_e2e_collector()['first']['argv']; - $json = json_decode((string) end($output), true); - $cache = is_array($json) ? $json : ['driver_failed' => 'exit status ' . $status . "\n" . implode("\n", $output)]; - } + expect($argv)->toContain('--user=dom\\user'); + expect($argv)->toContain('--password=pw'); + expect($argv)->toContain('//winsrv1.invalid'); + expect($argv)->toContain('SELECT Name,State FROM Win32_Service'); + }); - return $cache; -} + it('purges an instance the device stopped returning and updates the rest', function () { + $o = wmi_e2e_collector(); -describe('collector flow against a real Cacti', function () use ($wmi_e2e_root, $wmi_e2e_ready, $wmi_e2e_log_ok) { - beforeEach(function () use ($wmi_e2e_ready, $wmi_e2e_log_ok) { - if (!$wmi_e2e_ready) { - $this->markTestSkipped('Set WMI_E2E=1 with an installed Cacti and database to run.'); - } + expect($o['dropped']['result'])->toBeTrue(); + expect($o['dropped']['indexes'])->toBe(['A', 'B']); + expect($o['dropped']['rows'])->toBe(4); + expect($o['dropped']['present0'])->toBe(0); + expect($o['state_b'])->toBe('Stopped'); + }); - if (!$wmi_e2e_log_ok) { - $this->markTestSkipped('Cacti log/cacti.log is not writable by the test user; Cacti would refuse to start.'); - } + it('keeps the cached rows when the query fails in each way', function (string $case) { + $o = wmi_e2e_collector()[$case]; + + expect($o['result'])->toBeFalse(); + expect($o['indexes'])->toBe(['A', 'B']); + expect($o['rows'])->toBe(4); + expect($o['present0'])->toBe(0); + })->with(['midway', 'empty_output', 'silent_failure', 'header_only']); + + it('indexes rows by the primary key shape', function (string $case, array $indexes, int $rows) { + $o = wmi_e2e_collector()['key_' . $case]; + + expect($o['result'])->toBeTrue(); + expect($o['indexes'])->toBe($indexes); + expect($o['rows'])->toBe($rows); + })->with([ + 'duplicate single key keeps one row' => ['duplicate_single', ['A'], 2], + 'None with one row' => ['none_one_row', ['N/A'], 2], + 'None with several rows' => ['none_many_rows', ['N/A'], 2], + 'composite key' => ['composite', ['A,Running', 'A,Stopped'], 4], + 'composite key with a space' => ['composite_spaced', ['A,Running', 'A,Stopped'], 4], + 'unknown key column' => ['unknown_column', ['N/A'], 2], + ]); + + it('keeps the last row when two rows share a single-column key', function () { + expect(wmi_e2e_collector()['key_duplicate_state'])->toBe('Stopped'); }); - it('runs the driver to completion', function () use ($wmi_e2e_root) { - // Comparing the text, not the key, puts the driver's output in the failure. - expect(wmi_e2e_observations($wmi_e2e_root)['driver_failed'] ?? '')->toBe(''); + it('passes every namespace form to wmic with single backslashes', function (string $form, string $expected) { + expect(wmi_e2e_collector()['namespaces'][$form])->toBe(['--namespace=' . $expected]); + })->with([ + 'doubled backslash' => ['double', 'root\\CIMV2'], + 'single backslash' => ['single', 'root\\CIMV2'], + 'empty' => ['empty', 'root\\CIMV2'], + 'another namespace' => ['cluster', 'root\\MSCluster'], + ]); + + it('leaves the cache alone without an account or a query', function () { + $o = wmi_e2e_collector(); + + expect($o['no_account']['result'])->toBeFalse(); + expect($o['no_account']['indexes'])->toBe(['A', 'B']); + expect($o['no_query'])->toBeFalse(); + expect($o['no_query_rows'])->toBe(4); }); - it('caches every row of the first refresh', function () use ($wmi_e2e_root) { - $o = wmi_e2e_observations($wmi_e2e_root); + it('drops the cached rows of a device that no longer exists', function () { + $o = wmi_e2e_collector()['no_host']; - expect($o['first_ok'])->toBeTrue(); - expect($o['first_indexes'])->toBe(['A', 'B', 'C']); + expect($o['result'])->toBeNull(); + expect($o['rows'])->toBe(0); }); - it('passes the namespace to wmic with a single backslash', function () use ($wmi_e2e_root) { - expect(wmi_e2e_observations($wmi_e2e_root)['namespace_args'])->toBe(['--namespace=root\\CIMV2']); - }); + it('logs why a query failed', function () { + $log = wmi_e2e_collector()['log']; - it('purges a row the device stopped returning', function () use ($wmi_e2e_root) { - expect(wmi_e2e_observations($wmi_e2e_root)['second_indexes'])->toBe(['A', 'B']); + expect($log)->toContain('WMI Query[900001] for Device[900001] failed: ERROR: WMI Returned no Data'); + expect($log)->toContain('failed: ERROR: '); + expect($log)->toContain('failed: WMI returned no rows'); }); +}); - it('keeps the cached rows intact when a query fails and logs why', function () use ($wmi_e2e_root) { - $o = wmi_e2e_observations($wmi_e2e_root); +describe('poller master and child against a real Cacti', function () { + beforeEach(function () { + $reason = wmi_e2e_unready(); - expect($o['failed_ok'])->toBeFalse(); - expect($o['failed_indexes'])->toBe(['A', 'B']); - expect($o['failed_present'])->toBe(0); - expect($o['failure_logged'])->toBeTrue(); + if ($reason !== '') { + $this->markTestSkipped($reason); + } }); - it('keeps both rows that share a name when the primary key has two columns', function () use ($wmi_e2e_root) { - expect(wmi_e2e_observations($wmi_e2e_root)['composite_indexes'])->toBe(['A,Running', 'A,Stopped']); + /** The cache every healthy device and query ends up with. */ + $polled = ['svc' => ['A', 'B'], 'prc' => ['10', '11', '12']]; + + it('does not start while a recent collector lock exists', function () { + $o = wmi_e2e_collector()['live_lock']; + + expect($o['output'])->toContain('still running'); + expect($o['state']['locks'])->toBe(1); + + foreach ($o['state']['cache'] as $cache) { + expect($cache)->toBe(['svc' => [], 'prc' => []]); + } }); - it('does not start while a recent collector lock exists', function () use ($wmi_e2e_root) { - $o = wmi_e2e_observations($wmi_e2e_root); + it('clears a stale lock, polls every device and query, and records the failing pair', function () use ($polled) { + $o = wmi_e2e_collector()['stale_lock']; - expect($o['fresh_lock_output'])->toContain('still running'); - expect($o['fresh_lock_indexes'])->toBe([]); + expect($o['output'])->toContain('All Processes Complete'); + expect($o['state']['locks'])->toBe(0); + expect($o['state']['cache']['900001'])->toBe($polled); + expect($o['state']['cache']['900002'])->toBe($polled); + expect($o['state']['cache']['900003'])->toBe(['svc' => [], 'prc' => $polled['prc']]); + expect($o['last_failed'])->not->toBe('0000-00-00 00:00:00'); + expect($o['last_failed_ok'])->toBe('0000-00-00 00:00:00'); }); - it('clears a stale collector lock and polls the device', function () use ($wmi_e2e_root) { - $o = wmi_e2e_observations($wmi_e2e_root); + it('finds nothing due right after a run', function () { + expect(wmi_e2e_collector()['not_due']['output'])->toContain('No Devices found this pass'); + }); - expect($o['stale_lock_output'])->toContain('All Processes Complete'); - expect($o['stale_lock_indexes'])->toBe(['A']); - expect($o['locks_left'])->toBe(0); + it('polls all devices whatever the concurrency setting holds', function (string $case) use ($polled) { + $o = wmi_e2e_collector()['concurrency_' . $case]; + + expect($o['status'])->toBe(0); + expect($o['output'])->toContain('All Processes Complete'); + expect($o['state']['locks'])->toBe(0); + expect($o['state']['cache']['900001'])->toBe($polled); + expect($o['state']['cache']['900002'])->toBe($polled); + })->with(['empty', 'one', 'many']); + + it('keeps waiting while a collector cannot start, then recovers once its lock is old', function () use ($polled) { + $o = wmi_e2e_collector(); + + expect($o['child_dies']['status'])->toBe(124); + expect($o['child_dies']['state']['locks'])->toBe(3); + expect($o['after_dead_child_live']['output'])->toContain('still running'); + expect($o['after_dead_child_live']['state']['locks'])->toBe(3); + expect($o['after_dead_child_aged']['output'])->toContain('All Processes Complete'); + expect($o['after_dead_child_aged']['state']['locks'])->toBe(0); + expect($o['after_dead_child_aged']['state']['cache']['900001'])->toBe($polled); }); -}); -describe('collector flow entry script', function () { - it('does nothing and prints nothing without the test environment', function () { - $script = __DIR__ . '/collector_flow.php'; - $output = []; - $status = 0; + it('skips down and disabled devices and drops the cache of a down device', function () use ($polled) { + $o = wmi_e2e_collector()['skipped_hosts']; - exec('env -u WMI_E2E -u WMI_E2E_WORKDIR ' . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($script) . ' 2>&1', $output, $status); + expect($o['output'])->toContain("wmi-e2e-900003[winsrv3.invalid]"); + expect($o['output'])->not->toContain('wmi-e2e-900001'); + expect($o['output'])->not->toContain('wmi-e2e-900002'); + expect($o['state']['cache']['900001'])->toBe(['svc' => [], 'prc' => []]); + expect($o['state']['cache']['900002'])->toBe(['svc' => [], 'prc' => []]); + }); - expect($output)->toBe([]); - expect($status)->toBe(0); + it('finds nothing to poll when no query is enabled', function () { + expect(wmi_e2e_collector()['no_enabled_query']['output'])->toContain('No Devices found this pass'); }); - it('does nothing when the work directory is missing', function () { - $script = __DIR__ . '/collector_flow.php'; - $output = []; + it('does nothing while the plugin is disabled', function () { + $o = wmi_e2e_collector()['plugin_disabled']; + + expect($o['output'])->toContain('Collection is Down'); + expect($o['state']['cache']['900001'])->toBe(['svc' => [], 'prc' => []]); + }); - exec('WMI_E2E=1 WMI_E2E_WORKDIR=/nonexistent-wmi-e2e ' . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($script) . ' 2>&1', $output); + it('polls one device when run as a child, and reports bad command lines', function () use ($polled) { + $o = wmi_e2e_collector(); - expect($output)->toBe([]); + expect($o['child_alone']['state']['cache']['900002'])->toBe($polled); + expect($o['child_alone']['state']['cache']['900001'])->toBe(['svc' => [], 'prc' => []]); + expect($o['child_no_id']['output'])->toContain('You must specify a Cacti host-id'); + expect($o['bad_parameter']['output'])->toContain('Invalid Parameter --bogus'); + expect($o['version']['output'])->toContain('Device WMI Poller Process, Version'); + expect($o['help']['output'])->toContain('usage:'); }); }); diff --git a/tests/E2E/collector_flow.php b/tests/E2E/collector_flow.php index 6d36dd3..66e78a5 100644 --- a/tests/E2E/collector_flow.php +++ b/tests/E2E/collector_flow.php @@ -8,15 +8,11 @@ */ /* - * Drives the collector against a real Cacti installation and its database, - * with a fake wmic executable standing in for the Windows host. Run by - * CollectorFlowTest.php in its own process; prints one JSON document of - * observations on the last line of stdout. + * Drives the cache refresh and the poller_wmi.php master and child against a + * real Cacti installation and its database, with a fake wmic executable + * standing in for the Windows hosts. Run by CollectorFlowTest.php in its own + * process; prints one JSON document of observations on the last line. * - * Every row it creates uses ids from 900001 upward and is removed again. - */ - -/* * tests/ ships inside the plugin directory, so this file must do nothing when * a web server runs it. It needs the CLI and a work directory that the test * process names and creates; it takes no path or command from a request. @@ -29,178 +25,253 @@ exit; } -$root = dirname(__DIR__, 4); +require_once __DIR__ . '/e2e_common.php'; -chdir($root); +wmi_e2e_boot(); -$_SERVER['argv'] = ['collector_flow.php']; +$root = $GLOBALS['wmi_e2e']['root']; +$out = []; -require_once $root . '/include/cli_check.php'; -require_once $root . '/plugins/wmi/includes/database.php'; -require_once $root . '/plugins/wmi/includes/functions.php'; +const HOST_A = 900001; +const HOST_B = 900002; +const HOST_C = 900003; +const QUERY_SVC = 900001; +const QUERY_PRC = 900002; +const TEMPLATE = 900001; +const ACCOUNT = 900001; -const ID = 900001; +$svc_header = ['Name', 'State']; -$out = []; -$saved = []; +wmi_e2e_add_account(ACCOUNT, 'e2e', 'dom\\user', 'pw'); +wmi_e2e_add_host(HOST_A, TEMPLATE, ACCOUNT, 'winsrv1.invalid'); +wmi_e2e_add_query(QUERY_SVC, 'e2e-svc', 'root\\\\CIMV2', 'SELECT Name,State FROM Win32_Service', 'Name'); -/** Write the canned wmic output and exit status the fake executable will use. */ -function fake_wmic_script(string $work, array $lines, int $status): void { - file_put_contents($work . '/out', implode("\n", $lines) . "\n"); - file_put_contents($work . '/status', (string) $status); +/** + * Run the refresh quietly and report the outcome with the cache state. + * + * @return array + */ +function refresh(int $host = HOST_A, int $query = QUERY_SVC): array { + $result = wmi_e2e_quiet(fn () => run_store_wmi_query($host, $query)); + + return [ + 'result' => $result, + 'indexes' => wmi_e2e_cached($host, $query), + 'rows' => wmi_e2e_cache_count($host, $query), + 'present0' => (int) db_fetch_cell_prepared('SELECT COUNT(*) FROM host_wmi_cache WHERE host_id = ? AND wmi_query_id = ? AND present = 0', [$host, $query]), + 'argv' => wmi_e2e_last_argv(), + ]; } -/** @return array The wmi_index values currently cached. */ -function cached_indexes(): array { - return array_column(db_fetch_assoc_prepared( - "SELECT DISTINCT wmi_index FROM host_wmi_cache WHERE host_id = ? AND wmi_query_id = ? AND field_name = 'Name' ORDER BY wmi_index", - [ID, ID] - ), 'wmi_index'); +// First refresh caches every row. +wmi_e2e_wmic_default(wmi_e2e_result('Win32_Service', $svc_header, [['A', 'Running'], ['B', 'Running'], ['C', 'Stopped']]), 0); +$out['first'] = refresh(); + +// The device drops an instance, which is purged. +wmi_e2e_wmic_default(wmi_e2e_result('Win32_Service', $svc_header, [['A', 'Running'], ['B', 'Stopped']]), 0); +$out['dropped'] = refresh(); +$out['state_b'] = db_fetch_cell_prepared("SELECT field_value FROM host_wmi_cache WHERE host_id = ? AND wmi_query_id = ? AND field_name = 'State' AND wmi_index = 'B'", [HOST_A, QUERY_SVC]); + +// Failures keep the cached rows: partial output then non-zero exit, empty +// output, non-zero exit with no output, and a header without rows. +wmi_e2e_wmic_default(['Win32_Service', 'Name' . WMI_E2E_SEP . 'State', 'A' . WMI_E2E_SEP . 'Running'], 1); +$out['midway'] = refresh(); + +wmi_e2e_wmic_default([], 0); +$out['empty_output'] = refresh(); + +wmi_e2e_wmic_default([], 3); +$out['silent_failure'] = refresh(); + +wmi_e2e_wmic_default(['Win32_Service', 'Name' . WMI_E2E_SEP . 'State'], 0); +$out['header_only'] = refresh(); + +// Primary key shapes: duplicate values of a single key, None with one and +// with several rows, a composite key with and without spaces, and one naming +// a column the result does not have. +$key_cases = [ + 'duplicate_single' => ['Name', [['A', 'Running'], ['A', 'Stopped']]], + 'none_one_row' => ['None', [['A', 'Running']]], + 'none_many_rows' => ['None', [['A', 'Running'], ['B', 'Running']]], + 'composite' => ['Name,State', [['A', 'Running'], ['A', 'Stopped']]], + 'composite_spaced' => ['Name, State', [['A', 'Running'], ['A', 'Stopped']]], + 'unknown_column' => ['Name,Nope', [['A', 'Running'], ['B', 'Running']]], +]; + +foreach ($key_cases as $label => [$key, $rows]) { + db_execute_prepared('UPDATE wmi_wql_queries SET primary_key = ? WHERE id = ?', [$key, QUERY_SVC]); + db_execute_prepared('DELETE FROM host_wmi_cache WHERE host_id = ?', [HOST_A]); + wmi_e2e_wmic_default(wmi_e2e_result('Win32_Service', $svc_header, $rows), 0); + + $out['key_' . $label] = refresh(); } -function cleanup(string $work, array $saved): void { - db_execute_prepared('DELETE FROM host_wmi_cache WHERE host_id = ?', [ID]); - db_execute_prepared('DELETE FROM host_wmi_query WHERE host_id = ?', [ID]); - db_execute_prepared('DELETE FROM host_template_wmi_query WHERE wmi_query_id = ?', [ID]); - db_execute_prepared('DELETE FROM wmi_wql_queries WHERE id = ?', [ID]); - db_execute_prepared('DELETE FROM wmi_user_accounts WHERE id = ?', [ID]); - db_execute_prepared('DELETE FROM host WHERE id = ?', [ID]); - db_execute('DELETE FROM wmi_processes'); +$out['key_duplicate_state'] = null; - foreach (['path_wmi', 'log_verbosity', 'wmi_processes'] as $name) { - if (isset($saved[$name]) && $saved[$name] !== false) { - db_execute_prepared('REPLACE INTO settings (name, value) VALUES (?, ?)', [$name, $saved[$name]]); - } else { - db_execute_prepared('DELETE FROM settings WHERE name = ?', [$name]); - } - } +db_execute_prepared('UPDATE wmi_wql_queries SET primary_key = ? WHERE id = ?', ['Name', QUERY_SVC]); +db_execute_prepared('DELETE FROM host_wmi_cache WHERE host_id = ?', [HOST_A]); +wmi_e2e_wmic_default(wmi_e2e_result('Win32_Service', $svc_header, [['A', 'Running'], ['A', 'Stopped']]), 0); +refresh(); +$out['key_duplicate_state'] = db_fetch_cell_prepared("SELECT field_value FROM host_wmi_cache WHERE host_id = ? AND wmi_query_id = ? AND field_name = 'State'", [HOST_A, QUERY_SVC]); - if (!empty($saved['plugin_row_added'])) { - db_execute_prepared("DELETE FROM plugin_config WHERE directory = 'wmi'"); - } +// Namespace forms as they reach wmic. +$out['namespaces'] = []; - foreach (['wmic', 'out', 'status', 'argv'] as $name) { - if (is_file($work . '/' . $name)) { - unlink($work . '/' . $name); - } - } +foreach (['double' => 'root\\\\CIMV2', 'single' => 'root\\CIMV2', 'empty' => '', 'cluster' => 'root\\\\MSCluster'] as $label => $namespace) { + db_execute_prepared('UPDATE wmi_wql_queries SET namespace = ? WHERE id = ?', [$namespace, QUERY_SVC]); + wmi_e2e_wmic_default(wmi_e2e_result('Win32_Service', $svc_header, [['A', 'Running']]), 0); + refresh(); + + $out['namespaces'][$label] = array_values(array_filter(wmi_e2e_last_argv(), fn ($a) => strpos($a, '--namespace=') === 0)); } -/* - * The failure case logs a WARNING on purpose, and the CI workflow fails a job - * whose cacti.log holds any WARNING, ERROR or FATAL line. Remember how long - * the log was and cut it back to that length when the run ends, keeping the - * file's owner and mode. - */ -$log_file = read_config_option('path_cactilog') ?: $root . '/log/cacti.log'; -$log_exists = is_file($log_file); -$log_before = $log_exists ? (int) filesize($log_file) : 0; +db_execute_prepared('UPDATE wmi_wql_queries SET namespace = ? WHERE id = ?', ['root\\\\CIMV2', QUERY_SVC]); -register_shutdown_function(function () use ($work, &$saved, $log_file, $log_exists, $log_before) { - cleanup($work, $saved); +// Account, query and host problems leave the cache alone or clear it. +wmi_e2e_wmic_default(wmi_e2e_result('Win32_Service', $svc_header, [['A', 'Running'], ['B', 'Running']]), 0); +db_execute_prepared('DELETE FROM host_wmi_cache WHERE host_id = ?', [HOST_A]); +refresh(); - clearstatcache(true, $log_file); +db_execute_prepared('UPDATE host SET wmi_account = 0 WHERE id = ?', [HOST_A]); +$out['no_account'] = refresh(); +db_execute_prepared('UPDATE host SET wmi_account = ? WHERE id = ?', [ACCOUNT, HOST_A]); - if (!is_file($log_file)) { - return; - } +$out['no_query'] = wmi_e2e_quiet(fn () => run_store_wmi_query(HOST_A, QUERY_SVC + 50)); +$out['no_query_rows'] = wmi_e2e_cache_count(HOST_A, QUERY_SVC); - if (!$log_exists) { - unlink($log_file); - } elseif (filesize($log_file) > $log_before) { - $handle = fopen($log_file, 'r+'); +db_execute_prepared('DELETE FROM host WHERE id = ?', [HOST_A]); +$out['no_host'] = refresh(); - if ($handle !== false) { - ftruncate($handle, $log_before); - fclose($handle); - } - } -}); +$out['log'] = wmi_e2e_log_since_boot(); + +// Poller: three hosts, two queries, one failing host/query pair. +db_execute_prepared('DELETE FROM host_wmi_cache WHERE host_id >= ?', [HOST_A]); +db_execute_prepared('DELETE FROM wmi_wql_queries WHERE id >= ?', [QUERY_SVC]); +wmi_e2e_add_query(QUERY_SVC, 'e2e-svc', 'root\\\\CIMV2', 'SELECT Name,State FROM Win32_Service', 'Name'); +wmi_e2e_add_query(QUERY_PRC, 'e2e-prc', 'root\\\\CIMV2', 'SELECT ProcessId,Name FROM Win32_Process', 'ProcessId'); +wmi_e2e_link_template(TEMPLATE, QUERY_SVC); +wmi_e2e_link_template(TEMPLATE, QUERY_PRC); -plugin_wmi_setup_tables(); +$hosts = [HOST_A => 'winsrv1.invalid', HOST_B => 'winsrv2.invalid', HOST_C => 'winsrv3.invalid']; -foreach (['path_wmi', 'log_verbosity', 'wmi_processes'] as $name) { - $saved[$name] = db_fetch_cell_prepared('SELECT value FROM settings WHERE name = ?', [$name]) ?: false; +foreach ($hosts as $id => $name) { + wmi_e2e_add_host($id, TEMPLATE, ACCOUNT, $name); } -cleanup($work, []); +$services = wmi_e2e_result('Win32_Service', $svc_header, [['A', 'Running'], ['B', 'Running']]); +$processes = wmi_e2e_result('Win32_Process', ['ProcessId', 'Name'], [['10', 'sys'], ['11', 'app'], ['12', 'db']]); -if (db_fetch_cell_prepared("SELECT COUNT(*) FROM plugin_config WHERE directory = 'wmi'") == 0) { - db_execute("INSERT INTO plugin_config (directory, name, status, author, webpage, version) VALUES ('wmi', 'wmi', 1, '', '', '1')"); +$set_wmic = function () use ($services, $processes): void { + wmi_e2e_wmic_default([], 0); + wmi_e2e_wmic_rules([ + ['winsrv3.invalid SELECT Name,State', 1, ['ERROR: access denied']], + ['Win32_Service', 0, $services], + ['Win32_Process', 0, $processes], + ]); +}; - $saved['plugin_row_added'] = true; -} +$set_wmic(); + +$php = escapeshellarg(PHP_BINARY); +$script = escapeshellarg($root . '/plugins/wmi/poller_wmi.php'); +$has_timeout = trim((string) shell_exec('command -v timeout')) !== ''; + +/** + * Run the poller and return its output and exit status. + * + * @return array{output: string, status: int} + */ +$poller = function (string $arguments, int $seconds = 120) use ($php, $script, $has_timeout): array { + $lines = []; + $status = 0; + + exec(($has_timeout ? 'timeout ' . $seconds . ' ' : '') . $php . ' ' . $script . ' ' . $arguments . ' 2>&1', $lines, $status); + + return ['output' => implode("\n", $lines), 'status' => $status]; +}; + +$poll_state = function () use ($hosts): array { + $state = ['locks' => (int) db_fetch_cell('SELECT COUNT(*) FROM wmi_processes'), 'cache' => []]; -file_put_contents($work . '/wmic', "#!/bin/sh\nprintf '%s\\n' \"\$@\" > " . escapeshellarg($work . '/argv') . "\ncat " . escapeshellarg($work . '/out') . "\nexit \$(cat " . escapeshellarg($work . '/status') . ")\n"); -chmod($work . '/wmic', 0700); - -db_execute_prepared('REPLACE INTO settings (name, value) VALUES (?, ?)', ['path_wmi', $work . '/wmic']); -db_execute_prepared('REPLACE INTO settings (name, value) VALUES (?, ?)', ['log_verbosity', (string) POLLER_VERBOSITY_HIGH]); - -$wmi = new Linux_WMI(); - -db_execute_prepared("INSERT INTO wmi_user_accounts (id, name, username, password) VALUES (?, 'e2e', 'dom\\\\user', ?)", [ID, $wmi->encode('pw')]); -db_execute_prepared("INSERT INTO host (id, description, hostname, host_template_id, status, disabled, wmi_account) VALUES (?, 'wmi-e2e', 'winsrv.invalid', ?, 3, '', ?)", [ID, ID, ID]); -db_execute_prepared("INSERT INTO wmi_wql_queries (id, hash, name, frequency, enabled, namespace, query, primary_key) VALUES (?, '', 'e2e', 60, 'on', ?, 'SELECT Name,State FROM Win32_Service', 'Name')", [ID, 'root\\\\CIMV2']); -db_execute_prepared('INSERT INTO host_template_wmi_query (host_template_id, wmi_query_id) VALUES (?, ?)', [ID, ID]); - -$sep = '|+|'; - -// First refresh: three services are cached. -fake_wmic_script($work, ['Win32_Service', 'Name' . $sep . 'State', 'A' . $sep . 'Running', 'B' . $sep . 'Running', 'C' . $sep . 'Stopped'], 0); -ob_start(); -$out['first_ok'] = run_store_wmi_query(ID, ID); -ob_end_clean(); -$out['first_indexes'] = cached_indexes(); -$out['namespace_args'] = array_values(array_filter(explode("\n", (string) file_get_contents($work . '/argv')), fn ($a) => strpos($a, '--namespace=') === 0)); - -// Second refresh: service C is gone and must be purged. -fake_wmic_script($work, ['Win32_Service', 'Name' . $sep . 'State', 'A' . $sep . 'Running', 'B' . $sep . 'Stopped'], 0); -ob_start(); -$out['second_ok'] = run_store_wmi_query(ID, ID); -ob_end_clean(); -$out['second_indexes'] = cached_indexes(); - -// A failing query keeps the cached rows and logs the reason. -fake_wmic_script($work, ['Login to remote object.'], 1); -ob_start(); -$out['failed_ok'] = run_store_wmi_query(ID, ID); -ob_end_clean(); -$out['failed_indexes'] = cached_indexes(); -$out['failed_present'] = (int) db_fetch_cell_prepared('SELECT COUNT(*) FROM host_wmi_cache WHERE host_id = ? AND present = 0', [ID]); - -clearstatcache(true, $log_file); -$log = (string) substr((string) @file_get_contents($log_file), $log_before); - -$out['failure_logged'] = strpos($log, 'WMI Query[' . ID . '] for Device[' . ID . '] failed: ERROR: Login to remote object.') !== false; - -// A two-column primary key keeps both rows that share a Name. -db_execute_prepared("UPDATE wmi_wql_queries SET primary_key = 'Name,State' WHERE id = ?", [ID]); -fake_wmic_script($work, ['Win32_Service', 'Name' . $sep . 'State', 'A' . $sep . 'Running', 'A' . $sep . 'Stopped'], 0); -ob_start(); -run_store_wmi_query(ID, ID); -ob_end_clean(); -$out['composite_indexes'] = cached_indexes(); - -// Master poller: a fresh lock row blocks the run, a stale one is cleared. -db_execute_prepared("UPDATE wmi_wql_queries SET primary_key = 'Name' WHERE id = ?", [ID]); -db_execute_prepared('DELETE FROM host_wmi_cache WHERE host_id = ?', [ID]); -fake_wmic_script($work, ['Win32_Service', 'Name' . $sep . 'State', 'A' . $sep . 'Running'], 0); - -// An unset concurrency setting must not stall the master; bound the run anyway. -$limit = trim((string) shell_exec('command -v timeout')) !== '' ? 'timeout 120 ' : ''; -$poller = $limit . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($root . '/plugins/wmi/poller_wmi.php') . ' -M 2>&1'; - -db_execute_prepared("DELETE FROM settings WHERE name = 'wmi_processes'"); -db_execute('DELETE FROM wmi_processes'); + foreach (array_keys($hosts) as $id) { + $state['cache'][$id] = [ + 'svc' => wmi_e2e_cached($id, QUERY_SVC), + 'prc' => array_column(db_fetch_assoc_prepared("SELECT DISTINCT wmi_index FROM host_wmi_cache WHERE host_id = ? AND wmi_query_id = ? AND field_name = 'Name' ORDER BY wmi_index", [$id, QUERY_PRC]), 'wmi_index'), + ]; + } + + return $state; +}; + +$reset = function () use ($hosts): void { + db_execute_prepared('DELETE FROM host_wmi_cache WHERE host_id >= ?', [HOST_A]); + db_execute_prepared('DELETE FROM host_wmi_query WHERE host_id >= ?', [HOST_A]); + db_execute('DELETE FROM wmi_processes'); + db_execute_prepared('UPDATE host SET status = 3, disabled = ? WHERE id >= ?', ['', HOST_A]); + db_execute_prepared('UPDATE wmi_wql_queries SET enabled = ? WHERE id >= ?', ['on', QUERY_SVC]); + db_execute_prepared('UPDATE plugin_config SET status = 1 WHERE directory = ?', ['wmi']); +}; + +// A recent lock blocks the run. +$reset(); db_execute('INSERT INTO wmi_processes (pid, taskid, started) VALUES (1, 1, NOW())'); -$out['fresh_lock_output'] = shell_exec($poller); -$out['fresh_lock_indexes'] = cached_indexes(); +$out['live_lock'] = $poller('-M') + ['state' => $poll_state()]; -db_execute('DELETE FROM wmi_processes'); +// A stale lock is cleared, and the setting being absent still launches the +// collectors. +$reset(); +db_execute_prepared('DELETE FROM settings WHERE name = ?', ['wmi_processes']); db_execute('INSERT INTO wmi_processes (pid, taskid, started) VALUES (1, 1, DATE_SUB(NOW(), INTERVAL 2 HOUR))'); -$out['stale_lock_output'] = shell_exec($poller); -$out['stale_lock_indexes'] = cached_indexes(); -$out['locks_left'] = (int) db_fetch_cell('SELECT COUNT(*) FROM wmi_processes'); +$out['stale_lock'] = $poller('-M') + ['state' => $poll_state()]; +$out['stale_lock']['last_failed'] = db_fetch_cell_prepared('SELECT last_failed FROM host_wmi_query WHERE host_id = ? AND wmi_query_id = ?', [HOST_C, QUERY_SVC]); +$out['stale_lock']['last_failed_ok'] = db_fetch_cell_prepared('SELECT last_failed FROM host_wmi_query WHERE host_id = ? AND wmi_query_id = ?', [HOST_A, QUERY_SVC]); + +// A second run right away finds nothing due. +$out['not_due'] = $poller('-M'); + +// Concurrency settings: empty string, one process, a larger value. +foreach (['empty' => '', 'one' => '1', 'many' => '8'] as $label => $value) { + $reset(); + db_execute_prepared('REPLACE INTO settings (name, value) VALUES (?, ?)', ['wmi_processes', $value]); + $out['concurrency_' . $label] = $poller('-M') + ['state' => $poll_state()]; +} + +// A collector that cannot start leaves its placeholder row. The master keeps +// waiting, the next run sees a live lock, and once the row is old it is +// cleared and the devices are polled. +$reset(); +db_execute_prepared('REPLACE INTO settings (name, value) VALUES (?, ?)', ['path_php_binary', '/bin/false']); +$out['child_dies'] = $poller('-M', 8) + ['state' => $poll_state()]; +db_execute_prepared('REPLACE INTO settings (name, value) VALUES (?, ?)', ['path_php_binary', $GLOBALS['wmi_e2e']['saved']['path_php_binary'] ?: PHP_BINARY]); +$out['after_dead_child_live'] = $poller('-M') + ['state' => $poll_state()]; +db_execute('UPDATE wmi_processes SET started = DATE_SUB(NOW(), INTERVAL 2 HOUR)'); +$out['after_dead_child_aged'] = $poller('-M') + ['state' => $poll_state()]; + +// Devices that are down, disabled or without an enabled query are skipped, +// and the cache of a down device is dropped. +$reset(); +db_execute_prepared('UPDATE host SET status = 1 WHERE id = ?', [HOST_A]); +db_execute_prepared('UPDATE host SET disabled = ? WHERE id = ?', ['on', HOST_B]); +db_execute_prepared("INSERT INTO host_wmi_cache (host_id, wmi_query_id, field_name, field_value, wmi_index, present) VALUES (?, ?, 'Name', 'old', 'old', 1)", [HOST_A, QUERY_SVC]); +$out['skipped_hosts'] = $poller('-M') + ['state' => $poll_state()]; + +$reset(); +db_execute_prepared('UPDATE wmi_wql_queries SET enabled = ? WHERE id >= ?', ['', QUERY_SVC]); +$out['no_enabled_query'] = $poller('-M') + ['state' => $poll_state()]; + +// The plugin disabled in Plugin Management. +$reset(); +db_execute_prepared('UPDATE plugin_config SET status = 0 WHERE directory = ?', ['wmi']); +$out['plugin_disabled'] = $poller('-M') + ['state' => $poll_state()]; + +// A child run by itself, and the command line. +$reset(); +$out['child_alone'] = $poller('--host-id=' . HOST_B . ' --debug --force') + ['state' => $poll_state()]; +$out['child_no_id'] = $poller(''); +$out['bad_parameter'] = $poller('--bogus'); +$out['version'] = $poller('--version'); +$out['help'] = $poller('--help'); + +$out['log_after'] = wmi_e2e_log_since_boot(); print "\n" . json_encode($out) . "\n"; diff --git a/tests/E2E/e2e_common.php b/tests/E2E/e2e_common.php new file mode 100644 index 0000000..fa534ff --- /dev/null +++ b/tests/E2E/e2e_common.php @@ -0,0 +1,526 @@ + dirname(__DIR__, 4), + 'work' => rtrim((string) getenv('WMI_E2E_WORKDIR'), '/'), + 'saved' => [], + 'log' => ['file' => '', 'exists' => false, 'size' => 0], +]; + +// Cacti's configuration variables must land in the global scope, so the +// libraries are loaded here at file scope, not inside a function. +chdir($GLOBALS['wmi_e2e']['root']); + +$_SERVER['argv'] = ['wmi_e2e']; + +require_once $GLOBALS['wmi_e2e']['root'] . '/include/cli_check.php'; +require_once $GLOBALS['wmi_e2e']['root'] . '/plugins/wmi/includes/database.php'; +require_once $GLOBALS['wmi_e2e']['root'] . '/plugins/wmi/includes/functions.php'; + +/** + * Remember what the run must restore, register that restoration for the end + * of the process and reset the fixtures. + */ +function wmi_e2e_boot(): void { + $root = $GLOBALS['wmi_e2e']['root']; + + $log_file = read_config_option('path_cactilog') ?: $root . '/log/cacti.log'; + + $GLOBALS['wmi_e2e']['log'] = [ + 'file' => $log_file, + 'exists' => is_file($log_file), + 'size' => is_file($log_file) ? (int) filesize($log_file) : 0, + ]; + + register_shutdown_function('wmi_e2e_finish'); + + plugin_wmi_setup_tables(); + + foreach (['path_wmi', 'log_verbosity', 'wmi_processes', 'path_php_binary'] as $name) { + $GLOBALS['wmi_e2e']['saved'][$name] = db_fetch_cell_prepared('SELECT value FROM settings WHERE name = ?', [$name]) ?: false; + } + + $GLOBALS['wmi_e2e']['saved']['plugin_row_added'] = false; + + if ((int) db_fetch_cell_prepared('SELECT COUNT(*) FROM plugin_config WHERE directory = ?', ['wmi']) === 0) { + db_execute("INSERT INTO plugin_config (directory, name, status, author, webpage, version) VALUES ('wmi', 'wmi', 1, '', '', '1')"); + + $GLOBALS['wmi_e2e']['saved']['plugin_row_added'] = true; + } + + wmi_e2e_purge(); + + set_config_option('log_verbosity', (string) POLLER_VERBOSITY_HIGH); + + wmi_e2e_write_wmic(); +} + +/** + * Remove every row this run may have created. + */ +function wmi_e2e_purge(): void { + $base = WMI_E2E_ID; + + db_execute_prepared('DELETE FROM host_wmi_cache WHERE host_id >= ?', [$base]); + db_execute_prepared('DELETE FROM host_wmi_query WHERE host_id >= ?', [$base]); + db_execute_prepared('DELETE FROM host_template_wmi_query WHERE host_template_id >= ?', [$base]); + db_execute_prepared('DELETE FROM host_template_wmi_query WHERE wmi_query_id IN (SELECT id FROM wmi_wql_queries WHERE name LIKE ?)', ['e2e%']); + db_execute_prepared('DELETE FROM wmi_wql_queries WHERE id >= ? OR name LIKE ?', [$base, 'e2e%']); + db_execute_prepared('DELETE FROM wmi_user_accounts WHERE id >= ? OR name LIKE ?', [$base, 'e2e%']); + db_execute_prepared('DELETE FROM host WHERE id >= ?', [$base]); + db_execute_prepared('DELETE FROM host_template WHERE id >= ?', [$base]); + db_execute_prepared('DELETE FROM user_auth_realm WHERE user_id >= ?', [$base]); + db_execute_prepared('DELETE FROM user_auth WHERE id >= ?', [$base]); + db_execute('DELETE FROM wmi_processes'); +} + +/** + * Shutdown handler: purge rows, restore settings, the plugin row and the log. + */ +function wmi_e2e_finish(): void { + $state = $GLOBALS['wmi_e2e']; + + wmi_e2e_purge(); + + foreach (['path_wmi', 'log_verbosity', 'wmi_processes', 'path_php_binary'] as $name) { + if ($state['saved'][$name] !== false) { + db_execute_prepared('REPLACE INTO settings (name, value) VALUES (?, ?)', [$name, $state['saved'][$name]]); + } else { + db_execute_prepared('DELETE FROM settings WHERE name = ?', [$name]); + } + } + + if ($state['saved']['plugin_row_added']) { + db_execute_prepared('DELETE FROM plugin_config WHERE directory = ?', ['wmi']); + } + + foreach (['wmic', 'out', 'status', 'argv', 'calls', 'rules'] as $name) { + $path = $state['work'] . '/' . $name; + + if (is_file($path)) { + unlink($path); + } + } + + foreach (glob($state['work'] . '/out.*') ?: [] as $path) { + unlink($path); + } + + $log = $state['log']; + + clearstatcache(true, $log['file']); + + if (!is_file($log['file'])) { + return; + } + + if (!$log['exists']) { + unlink($log['file']); + } elseif (filesize($log['file']) > $log['size']) { + $handle = fopen($log['file'], 'r+'); + + if ($handle !== false) { + ftruncate($handle, $log['size']); + fclose($handle); + } + } +} + +/** + * Log lines written since boot. + */ +function wmi_e2e_log_since_boot(): string { + $log = $GLOBALS['wmi_e2e']['log']; + + clearstatcache(true, $log['file']); + + return is_file($log['file']) ? (string) substr((string) file_get_contents($log['file']), $log['size']) : ''; +} + +/** + * Write the fake wmic executable. It prints the canned output chosen by the + * first matching rule in the rules file (tab separated: substring of the + * argument list, exit status, output file name) or the default output, and + * records its arguments and every call. + */ +function wmi_e2e_write_wmic(): void { + $work = $GLOBALS['wmi_e2e']['work']; + $path = $work . '/wmic'; + + file_put_contents($path, <<<'SH' +#!/bin/sh +W="$(dirname "$0")" +ALL="$*" +printf '%s\n' "$@" > "$W/argv" +printf '%s\n' "$ALL" >> "$W/calls" +OUT="$W/out" +ST="$(cat "$W/status")" +TAB="$(printf '\t')" +if [ -f "$W/rules" ]; then + while IFS="$TAB" read -r PAT RST RFILE; do + case "$ALL" in + *"$PAT"*) OUT="$W/$RFILE"; ST="$RST"; break ;; + esac + done < "$W/rules" +fi +cat "$OUT" +exit "$ST" +SH + ); + chmod($path, 0700); + + wmi_e2e_wmic_default([], 0); + set_config_option('path_wmi', $path); +} + +/** + * Canned wmic output used when no rule matches. + * + * @param array $lines + */ +function wmi_e2e_wmic_default(array $lines, int $status): void { + $work = $GLOBALS['wmi_e2e']['work']; + + file_put_contents($work . '/out', $lines === [] ? '' : implode("\n", $lines) . "\n"); + file_put_contents($work . '/status', (string) $status); + + foreach (['rules', 'calls'] as $name) { + if (is_file($work . '/' . $name)) { + unlink($work . '/' . $name); + } + } +} + +/** + * Canned output for calls whose argument list contains a substring (a host + * name or a part of the query). + * + * @param array}> $rules pattern, status, stdout lines + */ +function wmi_e2e_wmic_rules(array $rules): void { + $work = $GLOBALS['wmi_e2e']['work']; + $text = ''; + + foreach ($rules as $n => [$pattern, $status, $lines]) { + file_put_contents($work . '/out.' . $n, $lines === [] ? '' : implode("\n", $lines) . "\n"); + + $text .= $pattern . "\t" . $status . "\tout." . $n . "\n"; + } + + file_put_contents($work . '/rules', $text); +} + +/** + * Result lines in the shape wmic prints: class, header, then one row each. + * + * @param array $header + * @param array> $rows + * + * @return array + */ +function wmi_e2e_result(string $class, array $header, array $rows): array { + $lines = [$class, implode(WMI_E2E_SEP, $header)]; + + foreach ($rows as $row) { + $lines[] = implode(WMI_E2E_SEP, $row); + } + + return $lines; +} + +/** + * Arguments of the last wmic call. + * + * @return array + */ +function wmi_e2e_last_argv(): array { + $path = $GLOBALS['wmi_e2e']['work'] . '/argv'; + + return is_file($path) ? array_values(array_filter(explode("\n", (string) file_get_contents($path)), 'strlen')) : []; +} + +/** + * Every wmic call so far, one argument string per call. + * + * @return array + */ +function wmi_e2e_calls(): array { + $path = $GLOBALS['wmi_e2e']['work'] . '/calls'; + + return is_file($path) ? array_values(array_filter(explode("\n", (string) file_get_contents($path)), 'strlen')) : []; +} + +function wmi_e2e_add_account(int $id, string $name, string $username, string $password): void { + $wmi = new Linux_WMI(); + + db_execute_prepared('INSERT INTO wmi_user_accounts (id, name, username, password) VALUES (?, ?, ?, ?)', [$id, $name, $username, $wmi->encode($password)]); +} + +function wmi_e2e_add_host(int $id, int $template, int $account, string $hostname, int $status = 3, string $disabled = ''): void { + db_execute_prepared('INSERT INTO host (id, description, hostname, host_template_id, status, disabled, wmi_account) VALUES (?, ?, ?, ?, ?, ?, ?)', [$id, 'wmi-e2e-' . $id, $hostname, $template, $status, $disabled, $account]); +} + +function wmi_e2e_add_query(int $id, string $name, string $namespace, string $query, string $primary_key, int $frequency = 60, string $enabled = 'on'): void { + db_execute_prepared("INSERT INTO wmi_wql_queries (id, hash, name, frequency, enabled, namespace, query, primary_key) VALUES (?, '', ?, ?, ?, ?, ?, ?)", [$id, $name, $frequency, $enabled, $namespace, $query, $primary_key]); +} + +function wmi_e2e_link_template(int $template, int $query): void { + db_execute_prepared('INSERT INTO host_template_wmi_query (host_template_id, wmi_query_id) VALUES (?, ?)', [$template, $query]); +} + +/** + * Distinct cache indexes for a host and query, sorted. + * + * @return array + */ +function wmi_e2e_cached(int $host, int $query): array { + return array_column(db_fetch_assoc_prepared( + "SELECT DISTINCT wmi_index FROM host_wmi_cache WHERE host_id = ? AND wmi_query_id = ? AND field_name = 'Name' ORDER BY wmi_index", + [$host, $query] + ), 'wmi_index'); +} + +function wmi_e2e_cache_count(int $host, int $query): int { + return (int) db_fetch_cell_prepared('SELECT COUNT(*) FROM host_wmi_cache WHERE host_id = ? AND wmi_query_id = ?', [$host, $query]); +} + +/** + * Run a callable with its stdout captured and discarded. + * + * @return mixed What the callable returned. + */ +function wmi_e2e_quiet(callable $call) { + ob_start(); + + try { + return $call(); + } finally { + ob_end_clean(); + } +} + +/** + * Create a user cloned from admin with the given realms. + * + * @param array $realms + */ +function wmi_e2e_add_user(int $id, string $username, string $password, array $realms): void { + $row = db_fetch_row_prepared('SELECT * FROM user_auth WHERE id = 1'); + + $row['id'] = $id; + $row['username'] = $username; + $row['password'] = password_hash($password, PASSWORD_DEFAULT); + $row['must_change_password'] = ''; + $row['password_change'] = ''; + $row['enabled'] = 'on'; + $row['locked'] = ''; + $row['failed_attempts'] = 0; + $row['tfa_enabled'] = ''; + + sql_save($row, 'user_auth', 'id', false); + + foreach ($realms as $realm) { + db_execute_prepared('INSERT INTO user_auth_realm (user_id, realm_id) VALUES (?, ?)', [$id, $realm]); + } +} + +/** + * A small HTTP client with a cookie jar for the page flows. + */ +class Wmi_E2e_Http { + private string $jar; + + public function __construct(private string $base) { + $this->jar = tempnam($GLOBALS['wmi_e2e']['work'], 'jar'); + } + + /** + * @param array $query + * @param array|null $post + * + * @return array{code: int, body: string, location: string} + */ + public function request(string $path, array $query = [], ?array $post = null): array { + $url = $this->base . $path . ($query === [] ? '' : '?' . http_build_query($query)); + $ch = curl_init($url); + + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_COOKIEJAR => $this->jar, + CURLOPT_COOKIEFILE => $this->jar, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_TIMEOUT => 60, + CURLOPT_HEADER => true, + CURLOPT_USERAGENT => 'wmi-e2e', + ]); + + if ($post !== null) { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post)); + } + + $response = (string) curl_exec($ch); + $info = curl_getinfo($ch); + + curl_close($ch); + + $headers = substr($response, 0, (int) $info['header_size']); + + return [ + 'code' => (int) $info['http_code'], + 'body' => substr($response, (int) $info['header_size']), + 'location' => preg_match('/^Location:\s*(\S+)/mi', $headers, $m) ? $m[1] : '', + ]; + } + + /** + * The CSRF token embedded in a page, or '' when there is none. + */ + public static function token(string $html): string { + if (preg_match('/name=.__csrf_magic. value="([^"]+)"/', $html, $m) || preg_match("/csrfMagicToken='([^']+)'/", $html, $m)) { + return $m[1]; + } + + return ''; + } + + public function login(string $username, string $password): bool { + $page = $this->request('index.php'); + $done = $this->request('index.php', [], [ + '__csrf_magic' => self::token($page['body']), + 'action' => 'login', + 'login_username' => $username, + 'login_password' => $password, + ]); + + return $done['code'] === 302; + } + + /** + * GET a page and return it together with a CSRF token valid for the + * session, so a following POST can be sent the way a browser sends it. + * + * @param array $query + * + * @return array{code: int, body: string, location: string, token: string} + */ + public function page(string $path, array $query = []): array { + $page = $this->request($path, $query); + + $page['token'] = self::token($page['body']); + + return $page; + } + + /** + * POST with the session's CSRF token, or without one. + * + * @param array $fields + * @param array $query + * + * @return array{code: int, body: string, location: string} + */ + public function post(string $path, array $fields, string $token, array $query = []): array { + if ($token !== '') { + $fields['__csrf_magic'] = $token; + } + + return $this->request($path, $query, $fields); + } +} + +/** + * Start PHP's built-in server for the Cacti tree under the URL prefix + * /cacti/, as the default url_path expects, and wait until it answers. + * + * @return array{process: resource, base: string} + */ +function wmi_e2e_start_server(): array { + $state = $GLOBALS['wmi_e2e']; + $www = $state['work'] . '/www'; + + if (!is_dir($www)) { + mkdir($www, 0700); + } + + if (!is_link($www . '/cacti')) { + symlink($state['root'], $www . '/cacti'); + } + + $probe = stream_socket_server('tcp://127.0.0.1:0'); + $name = stream_socket_get_name($probe, false); + $port = (int) substr($name, strrpos($name, ':') + 1); + + fclose($probe); + + $process = proc_open( + [PHP_BINARY, '-S', '127.0.0.1:' . $port, '-t', $www], + [0 => ['file', '/dev/null', 'r'], 1 => ['file', $state['work'] . '/server.log', 'a'], 2 => ['file', $state['work'] . '/server.log', 'a']], + $pipes, + $state['root'] + ); + + for ($i = 0; $i < 100; $i++) { + $socket = @fsockopen('127.0.0.1', $port, $errno, $error, 0.2); + + if ($socket !== false) { + fclose($socket); + + break; + } + + usleep(100000); + } + + return ['process' => $process, 'base' => 'http://127.0.0.1:' . $port . '/cacti/']; +} + +/** + * @param array{process: resource, base: string} $server + */ +function wmi_e2e_stop_server(array $server): void { + proc_terminate($server['process']); + proc_close($server['process']); + + $work = $GLOBALS['wmi_e2e']['work']; + + if (is_link($work . '/www/cacti')) { + unlink($work . '/www/cacti'); + } + + if (is_dir($work . '/www')) { + rmdir($work . '/www'); + } + + if (is_file($work . '/server.log')) { + unlink($work . '/server.log'); + } +} diff --git a/tests/E2E/runner.php b/tests/E2E/runner.php new file mode 100644 index 0000000..c0e8b98 --- /dev/null +++ b/tests/E2E/runner.php @@ -0,0 +1,118 @@ +// for tests/bin/e2e-coverage-report.php. + * + * @return array + */ +function wmi_e2e_run(string $driver): array { + static $cache = []; + + if (isset($cache[$driver])) { + return $cache[$driver]; + } + + $root = dirname(__DIR__, 4); + $work = sys_get_temp_dir() . '/wmi-e2e-' . bin2hex(random_bytes(6)); + $env = 'WMI_E2E=1 WMI_E2E_WORKDIR=' . escapeshellarg($work); + + mkdir($work, 0700); + + $cov = getenv('WMI_E2E_COV'); + + if ($cov !== false && $cov !== '' && is_dir($cov)) { + $dump = rtrim($cov, '/') . '/' . pathinfo($driver, PATHINFO_FILENAME); + + if (!is_dir($dump)) { + mkdir($dump, 0777, true); + } + + mkdir($work . '/ini', 0700); + file_put_contents($work . '/ini/99-wmi-cov.ini', "xdebug.mode=coverage\nauto_prepend_file=" . __DIR__ . "/coverage_prepend.php\n"); + + $env .= ' PHP_INI_SCAN_DIR=' . escapeshellarg(':' . $work . '/ini') . ' WMI_COV_DIR=' . escapeshellarg($dump); + } + + $output = []; + $status = 0; + + exec($env . ' ' . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__DIR__ . '/' . $driver) . ' 2>&1', $output, $status); + + wmi_e2e_remove($work); + + $json = json_decode((string) end($output), true); + + $cache[$driver] = is_array($json) ? $json : ['driver_failed' => 'exit status ' . $status . "\n" . implode("\n", $output)]; + + return $cache[$driver]; +} + +/** + * Remove a work directory this runner created, and nothing else. + */ +function wmi_e2e_remove(string $dir): void { + $tmp = realpath(sys_get_temp_dir()); + + if (strpos(basename($dir), 'wmi-e2e-') !== 0 || !is_dir($dir) || $tmp === false || strpos((string) realpath($dir), $tmp . '/') !== 0) { + return; + } + + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $item) { + if ($item->isLink() || $item->isFile()) { + unlink($item->getPathname()); + } else { + rmdir($item->getPathname()); + } + } + + rmdir($dir); +} From c6f4239f21bd516b1f4d73c065c25d0838329617 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 23:46:58 -0600 Subject: [PATCH 21/27] test(e2e): drive the WMI pages through real requests Signed-off-by: Thomas Vincent --- tests/E2E/PagesFlowTest.php | 187 ++++++++++++++++++++ tests/E2E/e2e_common.php | 32 ++-- tests/E2E/pages_flow.php | 330 ++++++++++++++++++++++++++++++++++++ 3 files changed, 537 insertions(+), 12 deletions(-) create mode 100644 tests/E2E/PagesFlowTest.php create mode 100644 tests/E2E/pages_flow.php diff --git a/tests/E2E/PagesFlowTest.php b/tests/E2E/PagesFlowTest.php new file mode 100644 index 0000000..fb3568d --- /dev/null +++ b/tests/E2E/PagesFlowTest.php @@ -0,0 +1,187 @@ + */ +function wmi_e2e_pages(): array { + return wmi_e2e_run('pages_flow.php'); +} + +describe('WMI pages through real requests', function () { + beforeEach(function () { + $reason = wmi_e2e_unready(); + + if ($reason !== '') { + $this->markTestSkipped($reason); + } + + if (!function_exists('curl_init')) { + $this->markTestSkipped('The curl extension is needed for the page flows.'); + } + }); + + it('runs the driver to completion and logs in the test users', function () { + $o = wmi_e2e_pages(); + + expect($o['driver_failed'] ?? '')->toBe(''); + expect($o['realm_found'])->toBeTrue(); + expect($o['admin_login'])->toBeTrue(); + expect($o['nowmi_login'])->toBeTrue(); + expect($o['bad_login'])->toBeFalse(); + }); + + it('serves the login to anonymous users and refuses users without the WMI realm', function (string $page) { + $o = wmi_e2e_pages()['access_' . $page]; + + expect($o['anon_login_form'])->toBeTrue(); + expect($o['anon_sees_page'])->toBeFalse(); + expect($o['nowmi_denied'])->toBeTrue(); + expect($o['admin_code'])->toBe(200); + })->with(['accounts', 'queries', 'tools']); + + it('creates an account and stores a password that decodes', function () { + $o = wmi_e2e_pages()['acct_create']; + + expect($o['count'])->toBe(1); + expect($o['username'])->toBe('DOM\\svc'); + expect($o['password'])->toBe('pw-one'); + }); + + it('rejects an account save with bad input and writes nothing', function (string $case) { + expect(wmi_e2e_pages()['acct_reject_' . $case]['count'])->toBe(0); + })->with(['mismatch', 'empty_password', 'empty_name', 'empty_username', 'too_long']); + + it('accepts a 64 character password and stores it within the column', function () { + $o = wmi_e2e_pages()['acct_64']; + + expect($o['count'])->toBe(1); + expect($o['password_ok'])->toBeTrue(); + expect($o['stored_length'])->toBeLessThanOrEqual(256); + }); + + it('keeps the stored password when an edit leaves it blank, replaces it when confirmed and ignores a mismatch', function () { + $o = wmi_e2e_pages(); + + expect($o['acct_edit_blank'])->toBe(['renamed' => 1, 'password' => 'pw-one']); + expect($o['acct_edit_new_password']['password'])->toBe('pw-two'); + expect($o['acct_edit_mismatch'])->toBe(['password' => 'pw-two', 'name' => 'e2e-acct-1b']); + }); + + it('writes nothing without a CSRF token or by GET', function () { + $o = wmi_e2e_pages(); + + expect($o['acct_no_csrf']['count'])->toBe(0); + expect($o['acct_get_save']['count'])->toBe(0); + expect($o['query_no_csrf']['count'])->toBe(0); + expect($o['query_get_save']['count'])->toBe(0); + }); + + it('counts only the accounts matching the search in the pager', function () { + $o = wmi_e2e_pages()['acct_list_filtered']; + + expect($o['matching'])->toBeLessThan($o['total']); + expect($o['nav'])->toBe($o['matching']); + expect($o['has_x1'])->toBeTrue(); + expect($o['has_other'])->toBeFalse(); + }); + + it('lists the selected accounts in the delete confirmation and then deletes them', function () { + $o = wmi_e2e_pages(); + + expect($o['acct_delete_confirm']['lists_names'])->toBeTrue(); + expect($o['acct_delete']['left'])->toBe(0); + }); + + it('creates and edits a query and keeps its hash', function () { + $o = wmi_e2e_pages(); + + expect($o['query_create']['row'])->toBe(['name' => 'e2eq-new', 'frequency' => 300, 'enabled' => 'on', 'namespace' => 'root\\\\CIMV2', 'query' => 'SELECT Name FROM Win32_Service', 'primary_key' => 'Name']); + expect($o['query_create']['hash_len'])->toBe(32); + expect($o['query_edit']['frequency'])->toBe(600); + expect($o['query_edit']['same_hash'])->toBeTrue(); + }); + + it('counts all matching queries across pages in the pager', function () { + $o = wmi_e2e_pages()['query_pager']; + + expect($o['total_matching'])->toBe(7); + expect($o['p1']['nav'])->toBe(7); + expect($o['p1']['rows_shown'])->toBe(3); + expect($o['p3']['nav'])->toBe(7); + expect($o['p3']['rows_shown'])->toBe(1); + expect($o['one']['rows_shown'])->toBe(1); + expect($o['none']['rows_shown'])->toBe(0); + }); + + it('lists the selected queries in the delete confirmation and then deletes them', function () { + $o = wmi_e2e_pages()['query_delete']; + + expect($o['confirm_lists'])->toBeTrue(); + expect($o['left'])->toBe(0); + }); + + it('runs a query in the tool and escapes what the host returned', function () { + $o = wmi_e2e_pages()['tool_run']; + + expect($o['shows_rows'])->toBeTrue(); + expect($o['escapes_markup'])->toBeTrue(); + expect($o['argv'])->toContain('//win-host.invalid'); + }); + + it('never stores or echoes a password submitted to the tool', function () { + $o = wmi_e2e_pages()['tool_run']; + + expect($o['secret_in_reply'])->toBeFalse(); + expect($o['secret_in_page'])->toBeFalse(); + expect($o['secret_in_session'])->toBeFalse(); + expect($o['password_field_empty'])->toBeTrue(); + expect($o['username_in_page'])->toBeTrue(); + }); + + it('asks for the missing tool fields', function (string $field) { + expect(wmi_e2e_pages()['tool_missing_fields'][$field])->toBeTrue(); + })->with(['no_password', 'no_host', 'no_user']); + + it('shows a failed host query as escaped text', function () { + $o = wmi_e2e_pages()['tool_failure']; + + expect($o['has_error'])->toBeTrue(); + expect($o['escaped'])->toBeTrue(); + }); + + it('passes every namespace form from the tool to wmic with single backslashes', function (string $form) { + expect(wmi_e2e_pages()['tool_namespaces'][$form])->toBe(['--namespace=root\\CIMV2']); + })->with(['empty', 'single', 'double']); + + it('does not run a tool query without a CSRF token or the WMI realm', function () { + $o = wmi_e2e_pages(); + + expect($o['tool_no_csrf']['ran'])->toBeFalse(); + expect($o['tool_no_realm']['ran'])->toBeFalse(); + }); + + it('builds the device template remove form with an id parameter and links and unlinks queries', function () { + $o = wmi_e2e_pages(); + + expect($o['hook_registered'])->toBeTrue(); + expect($o['hook_confirm']['has_name'])->toBeTrue(); + expect($o['hook_confirm']['action'])->toBe('host_templates.php?action=edit&id=900008'); + expect($o['hook_linked'])->toBe(1); + expect($o['hook_edit_lists'])->toBeTrue(); + expect($o['hook_unlinked'])->toBe(0); + }); +}); diff --git a/tests/E2E/e2e_common.php b/tests/E2E/e2e_common.php index fa534ff..b8f7ea1 100644 --- a/tests/E2E/e2e_common.php +++ b/tests/E2E/e2e_common.php @@ -335,15 +335,16 @@ function wmi_e2e_quiet(callable $call) { function wmi_e2e_add_user(int $id, string $username, string $password, array $realms): void { $row = db_fetch_row_prepared('SELECT * FROM user_auth WHERE id = 1'); - $row['id'] = $id; - $row['username'] = $username; - $row['password'] = password_hash($password, PASSWORD_DEFAULT); - $row['must_change_password'] = ''; - $row['password_change'] = ''; - $row['enabled'] = 'on'; - $row['locked'] = ''; - $row['failed_attempts'] = 0; - $row['tfa_enabled'] = ''; + $row['id'] = $id; + $row['username'] = $username; + $row['password'] = password_hash($password, PASSWORD_DEFAULT); + + // Columns differ between Cacti releases; only reset the ones this one has. + foreach (['must_change_password' => '', 'password_change' => '', 'enabled' => 'on', 'locked' => '', 'failed_attempts' => 0, 'tfa_enabled' => ''] as $column => $value) { + if (array_key_exists($column, $row)) { + $row[$column] = $value; + } + } sql_save($row, 'user_auth', 'id', false); @@ -488,12 +489,19 @@ function wmi_e2e_start_server(): array { $state['root'] ); + // Probe with curl: a failed connect there raises no PHP warning, which + // Cacti would write to its log. for ($i = 0; $i < 100; $i++) { - $socket = @fsockopen('127.0.0.1', $port, $errno, $error, 0.2); + $probe = curl_init('http://127.0.0.1:' . $port . '/'); + + curl_setopt_array($probe, [CURLOPT_NOBODY => true, CURLOPT_TIMEOUT => 2, CURLOPT_RETURNTRANSFER => true]); + curl_exec($probe); + + $reached = curl_getinfo($probe, CURLINFO_HTTP_CODE) > 0; - if ($socket !== false) { - fclose($socket); + curl_close($probe); + if ($reached) { break; } diff --git a/tests/E2E/pages_flow.php b/tests/E2E/pages_flow.php new file mode 100644 index 0000000..c96f271 --- /dev/null +++ b/tests/E2E/pages_flow.php @@ -0,0 +1,330 @@ + 100; + +$admin_realms = array_map('intval', array_column(db_fetch_assoc_prepared('SELECT realm_id FROM user_auth_realm WHERE user_id = 1'), 'realm_id')); + +if (!in_array($wmi_realm, $admin_realms, true)) { + $admin_realms[] = $wmi_realm; +} + +wmi_e2e_add_user(USER_ADMIN, 'wmie2e_admin', PW, $admin_realms); +wmi_e2e_add_user(USER_NOWMI, 'wmie2e_nowmi', PW, [CONSOLE]); + +$server = wmi_e2e_start_server(); + +register_shutdown_function(function () use ($server) { + wmi_e2e_stop_server($server); +}); + +$anon = new Wmi_E2e_Http($server['base']); +$admin = new Wmi_E2e_Http($server['base']); +$nowmi = new Wmi_E2e_Http($server['base']); + +$out['admin_login'] = $admin->login('wmie2e_admin', PW); +$out['nowmi_login'] = $nowmi->login('wmie2e_nowmi', PW); +$out['bad_login'] = (new Wmi_E2e_Http($server['base']))->login('wmie2e_admin', 'wrong'); + +$pages = ['accounts' => 'plugins/wmi/wmi_accounts.php', 'queries' => 'plugins/wmi/wmi_queries.php', 'tools' => 'plugins/wmi/wmi_tools.php']; + +// Access control: anonymous users are sent to the login, users without the +// WMI realm are refused, the administrator is served. +foreach ($pages as $label => $path) { + $a = $anon->request($path); + $n = $nowmi->request($path); + $s = $admin->request($path); + + $out['access_' . $label] = [ + 'anon_code' => $a['code'], + 'anon_location' => $a['location'], + 'anon_login_form' => strpos($a['body'], 'login_username') !== false, + 'anon_sees_page' => strpos($a['body'], 'wmi_') !== false && strpos($a['body'], 'login_username') === false, + 'nowmi_denied' => stripos($n['body'], 'permission') !== false || stripos($n['body'], 'denied') !== false || $n['code'] >= 400, + 'nowmi_has_form' => strpos($n['body'], 'wmi_') !== false && strpos($n['body'], 'Authentication') !== false && stripos($n['body'], 'denied') === false, + 'admin_code' => $s['code'], + ]; +} + +/** Number of accounts with a given name. */ +$count_accounts = fn (string $name): int => (int) db_fetch_cell_prepared('SELECT COUNT(*) FROM wmi_user_accounts WHERE name = ?', [$name]); +$account_by = fn (string $name): array => db_fetch_row_prepared('SELECT * FROM wmi_user_accounts WHERE name = ?', [$name]) ?: []; +$decode = fn (array $row): string => $row === [] ? '' : (new Linux_WMI())->decode($row['password']); + +$save_account = function (array $fields, bool $csrf = true) use ($admin, $pages): array { + $page = $admin->page($pages['accounts'], ['action' => 'edit']); + + return $admin->post($pages['accounts'], $fields + ['action' => 'save'], $csrf ? $page['token'] : ''); +}; + +$base_fields = ['id' => 0, 'name' => 'e2e-acct-1', 'username' => 'DOM\\svc', 'password' => 'pw-one', 'password_confirm' => 'pw-one']; + +$r = $save_account($base_fields); + +$out['acct_create'] = ['code' => $r['code'], 'location' => $r['location'], 'count' => $count_accounts('e2e-acct-1'), 'username' => $account_by('e2e-acct-1')['username'] ?? '', 'password' => $decode($account_by('e2e-acct-1'))]; + +$acct1 = (int) ($account_by('e2e-acct-1')['id'] ?? 0); + +$rejections = [ + 'mismatch' => ['name' => 'e2e-rej-mismatch', 'password_confirm' => 'different'], + 'empty_password' => ['name' => 'e2e-rej-empty', 'password' => '', 'password_confirm' => ''], + 'empty_name' => ['name' => ''], + 'empty_username' => ['name' => 'e2e-rej-user', 'username' => ''], + 'too_long' => ['name' => 'e2e-rej-long', 'password' => str_repeat('p', 200), 'password_confirm' => str_repeat('p', 200)], +]; + +foreach ($rejections as $label => $change) { + $fields = $change + $base_fields; + $r = $save_account($fields); + + $out['acct_reject_' . $label] = ['code' => $r['code'], 'location' => $r['location'], 'count' => (int) db_fetch_cell_prepared('SELECT COUNT(*) FROM wmi_user_accounts WHERE name = ?', [$fields['name']])]; +} + +// Boundary: a 64 character password is accepted and round-trips. +$long = str_repeat('q', 64); +$r = $save_account(['name' => 'e2e-acct-64', 'password' => $long, 'password_confirm' => $long] + $base_fields); + +$out['acct_64'] = ['count' => $count_accounts('e2e-acct-64'), 'password_ok' => $decode($account_by('e2e-acct-64')) === $long, 'stored_length' => strlen($account_by('e2e-acct-64')['password'] ?? '')]; + +// Editing keeps the stored password when the field is left blank, and +// replaces it when a new one is confirmed. +$save_account(['id' => $acct1, 'name' => 'e2e-acct-1b', 'password' => '', 'password_confirm' => ''] + $base_fields); +$out['acct_edit_blank'] = ['renamed' => $count_accounts('e2e-acct-1b'), 'password' => $decode($account_by('e2e-acct-1b'))]; + +$save_account(['id' => $acct1, 'name' => 'e2e-acct-1b', 'password' => 'pw-two', 'password_confirm' => 'pw-two'] + $base_fields); +$out['acct_edit_new_password'] = ['password' => $decode($account_by('e2e-acct-1b'))]; + +$save_account(['id' => $acct1, 'name' => 'e2e-acct-1b', 'password' => 'pw-three', 'password_confirm' => 'oops'] + $base_fields); +$out['acct_edit_mismatch'] = ['password' => $decode($account_by('e2e-acct-1b')), 'name' => $account_by('e2e-acct-1b')['name'] ?? '']; + +// Without a CSRF token, or by GET, nothing is written. +$r = $save_account(['name' => 'e2e-acct-nocsrf'] + $base_fields, false); +$out['acct_no_csrf'] = ['code' => $r['code'], 'count' => $count_accounts('e2e-acct-nocsrf')]; + +$r = $admin->request($pages['accounts'], ['action' => 'save', 'name' => 'e2e-acct-get'] + $base_fields); +$out['acct_get_save'] = ['code' => $r['code'], 'count' => $count_accounts('e2e-acct-get')]; + +// The list: the search filter and the pager total count matching rows. +foreach (['e2e-acct-x1' => 51, 'e2e-acct-x2' => 52, 'e2e-other' => 53] as $name => $offset) { + wmi_e2e_add_account(WMI_E2E_ID + $offset, $name, 'u', 'p'); +} + +$total_accounts = (int) db_fetch_cell('SELECT COUNT(*) FROM wmi_user_accounts'); +$matching = (int) db_fetch_cell_prepared('SELECT COUNT(*) FROM wmi_user_accounts WHERE name LIKE ?', ['%e2e-acct-x%']); + +$list = $admin->request($pages['accounts'], ['filter' => 'e2e-acct-x', 'rows' => 1, 'page' => 1]); + +$out['acct_list_filtered'] = ['matching' => $matching, 'total' => $total_accounts, 'nav' => preg_match('/of (\d+)/', strip_tags($list['body']), $m) ? (int) $m[1] : -1, 'has_x1' => strpos($list['body'], 'e2e-acct-x1') !== false, 'has_other' => strpos($list['body'], 'e2e-other') !== false]; + +// Bulk delete: confirmation page, then the delete itself. +$ids = array_column(db_fetch_assoc_prepared('SELECT id FROM wmi_user_accounts WHERE name LIKE ?', ['e2e-acct-x%']), 'id'); +$page = $admin->page($pages['accounts']); +$conf = $admin->post($pages['accounts'], ['action' => 'actions', 'drp_action' => 1, 'chk_' . $ids[0] => 'on', 'chk_' . $ids[1] => 'on'], $page['token']); + +$out['acct_delete_confirm'] = ['code' => $conf['code'], 'lists_names' => strpos($conf['body'], 'e2e-acct-x1') !== false || strpos($conf['body'], 'e2e-acct-x2') !== false]; + +$del = $admin->post($pages['accounts'], ['action' => 'actions', 'drp_action' => 1, 'selected_items' => serialize(array_map('strval', $ids))], $page['token']); + +$out['acct_delete'] = ['code' => $del['code'], 'left' => (int) db_fetch_cell_prepared('SELECT COUNT(*) FROM wmi_user_accounts WHERE name LIKE ?', ['e2e-acct-x%'])]; + +// Queries. +$qlist = fn (array $q) => $admin->request($pages['queries'], $q); + +$save_query = function (array $fields, bool $csrf = true) use ($admin, $pages): array { + $page = $admin->page($pages['queries'], ['action' => 'edit']); + + return $admin->post($pages['queries'], $fields + ['action' => 'save'], $csrf ? $page['token'] : ''); +}; + +$query_fields = ['id' => 0, 'name' => 'e2eq-new', 'frequency' => 300, 'enabled' => 'on', 'namespace' => 'root\\\\CIMV2', 'query' => 'SELECT Name FROM Win32_Service', 'primary_key' => 'Name']; + +$r = $save_query($query_fields); +$row = db_fetch_row_prepared('SELECT * FROM wmi_wql_queries WHERE name = ?', ['e2eq-new']) ?: []; + +$out['query_create'] = ['code' => $r['code'], 'location' => $r['location'], 'row' => $row === [] ? [] : array_intersect_key($row, array_flip(['name', 'frequency', 'enabled', 'namespace', 'query', 'primary_key'])), 'hash_len' => strlen($row['hash'] ?? '')]; + +$r = $save_query(['id' => (int) ($row['id'] ?? 0), 'name' => 'e2eq-new', 'frequency' => 600, 'enabled' => ''] + $query_fields); +$row2 = db_fetch_row_prepared('SELECT * FROM wmi_wql_queries WHERE name = ?', ['e2eq-new']) ?: []; + +$out['query_edit'] = ['frequency' => $row2['frequency'] ?? '', 'enabled' => $row2['enabled'] ?? 'x', 'same_hash' => ($row2['hash'] ?? 'a') === ($row['hash'] ?? 'b')]; + +$r = $save_query(['name' => 'e2eq-nocsrf'] + $query_fields, false); +$out['query_no_csrf'] = ['code' => $r['code'], 'count' => (int) db_fetch_cell_prepared('SELECT COUNT(*) FROM wmi_wql_queries WHERE name = ?', ['e2eq-nocsrf'])]; + +$r = $admin->request($pages['queries'], ['action' => 'save', 'name' => 'e2eq-get'] + $query_fields); +$out['query_get_save'] = ['code' => $r['code'], 'count' => (int) db_fetch_cell_prepared('SELECT COUNT(*) FROM wmi_wql_queries WHERE name = ?', ['e2eq-get'])]; + +// Pager: seven more queries, three rows per page. +for ($i = 1; $i <= 7; $i++) { + wmi_e2e_add_query(WMI_E2E_ID + 100 + $i, 'e2eq-page-' . $i, 'root\\\\CIMV2', 'SELECT 1', 'Name'); +} + +$total_queries = (int) db_fetch_cell_prepared('SELECT COUNT(*) FROM wmi_wql_queries WHERE name LIKE ?', ['e2eq-%']); + +$pager = []; + +foreach (['p1' => ['filter' => 'e2eq-page', 'rows' => 3, 'page' => 1], 'p3' => ['filter' => 'e2eq-page', 'rows' => 3, 'page' => 3], 'one' => ['filter' => 'page-4', 'rows' => 3, 'page' => 1], 'none' => ['filter' => 'no-such-query', 'rows' => 3, 'page' => 1]] as $label => $q) { + $list = $qlist($q); + $text = preg_replace('/\s+/', ' ', strip_tags($list['body'])); + + $pager[$label] = ['nav' => preg_match('/of (\d+)/', $text, $m) ? (int) $m[1] : -1, 'rows_shown' => substr_count($list['body'], "id='line"), 'rows_shown_alt' => preg_match_all('/e2eq-page-\d/', $list['body']) ?: 0]; +} + +$out['query_pager'] = $pager + ['total_matching' => (int) db_fetch_cell_prepared('SELECT COUNT(*) FROM wmi_wql_queries WHERE name LIKE ?', ['e2eq-page-%']), 'total_all' => $total_queries]; + +// Bulk delete of two queries. +$ids = array_column(db_fetch_assoc_prepared('SELECT id FROM wmi_wql_queries WHERE name IN (?, ?)', ['e2eq-page-6', 'e2eq-page-7']), 'id'); +$page = $admin->page($pages['queries']); +$conf = $admin->post($pages['queries'], ['action' => 'actions', 'drp_action' => 1, 'chk_' . $ids[0] => 'on', 'chk_' . $ids[1] => 'on'], $page['token']); +$del = $admin->post($pages['queries'], ['action' => 'actions', 'drp_action' => 1, 'selected_items' => serialize(array_map('strval', $ids))], $page['token']); + +$out['query_delete'] = ['confirm_lists' => strpos($conf['body'], 'e2eq-page-6') !== false, 'left' => (int) db_fetch_cell_prepared('SELECT COUNT(*) FROM wmi_wql_queries WHERE name IN (?, ?)', ['e2eq-page-6', 'e2eq-page-7'])]; + +// The query tool. +$secret = 'T00l-secret-' . bin2hex(random_bytes(4)); +$result = wmi_e2e_result('Win32_Service', ['Name', 'State'], [['Spooler', 'Running'], ['Evil', '']]); + +wmi_e2e_wmic_default($result, 0); + +$tool = function (array $fields, bool $csrf = true) use ($admin, $pages): array { + $page = $admin->page($pages['tools']); + + return $admin->post($pages['tools'], $fields, $csrf ? $page['token'] : '', ['action' => 'query', 'header' => 'false']); +}; + +$tool_fields = ['host' => 'Win-Host.invalid', 'username' => 'DOM\\tool', 'password' => $secret, 'namespace' => '', 'command' => 'SELECT Name,State FROM Win32_Service']; + +$first = $tool($tool_fields); +$second = $tool($tool_fields); +$page = $admin->request($pages['tools']); +$again = $admin->request($pages['tools']); + +$save_path = session_save_path() ?: sys_get_temp_dir(); +$in_session = false; + +foreach (glob(rtrim($save_path, '/') . '/sess_*') ?: [] as $file) { + if (is_readable($file) && strpos((string) file_get_contents($file), $secret) !== false) { + $in_session = true; + } +} + +$out['tool_run'] = [ + 'code' => $first['code'], + 'shows_rows' => strpos($first['body'], 'Spooler') !== false, + 'escapes_markup' => strpos($first['body'], '') === false && strpos($first['body'], '<script>') !== false, + 'secret_in_reply' => strpos($first['body'], $secret) !== false || strpos($second['body'], $secret) !== false, + 'secret_in_page' => strpos($page['body'], $secret) !== false || strpos($again['body'], $secret) !== false, + 'secret_in_session' => $in_session, + 'username_in_page' => strpos($page['body'], 'DOM') !== false, + 'password_field_empty' => preg_match("/id='password' value=''/", $page['body']) === 1, + 'argv' => wmi_e2e_last_argv(), +]; + +$bad = []; + +foreach (['no_password' => ['password' => ''], 'no_host' => ['host' => ''], 'no_user' => ['username' => '']] as $label => $change) { + $r = $tool($change + $tool_fields); + + $bad[$label] = strpos($r['body'], 'ERROR:') !== false; +} + +$out['tool_missing_fields'] = $bad; + +wmi_e2e_wmic_default(['ERROR: NTSTATUS: access '], 1); +$r = $tool($tool_fields); + +$out['tool_failure'] = ['has_error' => strpos($r['body'], 'ERROR:') !== false, 'escaped' => strpos($r['body'], '') === false && strpos($r['body'], '<denied>') !== false]; + +wmi_e2e_wmic_default($result, 0); + +$namespaces = []; + +foreach (['empty' => '', 'single' => 'root\\CIMV2', 'double' => 'root\\\\CIMV2'] as $label => $namespace) { + $tool(['namespace' => $namespace] + $tool_fields); + + $namespaces[$label] = array_values(array_filter(wmi_e2e_last_argv(), fn ($a) => strpos($a, '--namespace=') === 0)); +} + +$out['tool_namespaces'] = $namespaces; + +$r = $tool($tool_fields, false); +$out['tool_no_csrf'] = ['code' => $r['code'], 'ran' => strpos($r['body'], 'Spooler') !== false]; + +$r = $nowmi->post($pages['tools'], $tool_fields, $nowmi->page($pages['tools'])['token'], ['action' => 'query', 'header' => 'false']); +$out['tool_no_realm'] = ['ran' => strpos($r['body'], 'Spooler') !== false]; + +$r = $admin->request($pages['tools'], ['action' => 'query', 'header' => 'false'] + $tool_fields); +$out['tool_get'] = ['code' => $r['code'], 'ran' => strpos($r['body'], 'Spooler') !== false]; + +// The Device Template hook: the confirmation form posts back to the edit page. +$template = WMI_E2E_ID + 7; + +db_execute_prepared("INSERT INTO host_template (id, hash, name) VALUES (?, 'e2ehash', 'wmi-e2e-template')", [$template]); + +$hooks = (int) db_fetch_cell_prepared("SELECT COUNT(*) FROM plugin_hooks WHERE name = 'wmi' AND hook = 'device_template_top' AND status = 1"); + +$out['hook_registered'] = $hooks > 0; + +if ($hooks > 0) { + $query = (int) db_fetch_cell_prepared('SELECT id FROM wmi_wql_queries WHERE name = ?', ['e2eq-new']); + + $conf = $admin->request('host_templates.php', ['action' => 'item_remove_wq_confirm', 'id' => $query, 'host_template_id' => $template]); + + $out['hook_confirm'] = ['code' => $conf['code'], 'action' => preg_match('/]*?\\baction=[\'"]([^\'"]+)/', $conf['body'], $m) ? $m[1] : '', 'has_name' => strpos($conf['body'], 'e2eq-new') !== false]; + + $page = $admin->page('host_templates.php', ['action' => 'edit', 'id' => $template]); + + $admin->post('host_templates.php', ['host_template_id' => $template, 'wmi_query_id' => $query], $page['token'], ['action' => 'item_add_wq']); + $out['hook_linked'] = (int) db_fetch_cell_prepared('SELECT COUNT(*) FROM host_template_wmi_query WHERE host_template_id = ? AND wmi_query_id = ?', [$template, $query]); + + $edit = $admin->request('host_templates.php', ['action' => 'edit', 'id' => $template]); + $out['hook_edit_lists'] = strpos($edit['body'], 'e2eq-new') !== false; + + $admin->post('host_templates.php', ['host_template_id' => $template, 'id' => $query], $page['token'], ['action' => 'item_remove_wq']); + $out['hook_unlinked'] = (int) db_fetch_cell_prepared('SELECT COUNT(*) FROM host_template_wmi_query WHERE host_template_id = ?', [$template]); +} + +$out['log'] = wmi_e2e_log_since_boot(); + +print "\n" . json_encode($out) . "\n"; From 6c0d87f95bc92274396532afbf6a7bc4ad694a3e Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 23:48:04 -0600 Subject: [PATCH 22/27] test: cover the wmic transport, lock boundaries and the inert helper scripts Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + tests/E2E/EntryScriptsTest.php | 76 +++++++++++++++++ tests/Security/WmiAccountSaveTest.php | 22 ----- tests/Security/WmiListPaginationTest.php | 30 ------- tests/Security/WmiToolsPasswordTest.php | 37 --------- tests/Unit/WmiProcessLockTest.php | 18 +++- tests/Unit/WmiWmicTransportTest.php | 101 +++++++++++++++++++++++ 7 files changed, 192 insertions(+), 93 deletions(-) create mode 100644 tests/E2E/EntryScriptsTest.php delete mode 100644 tests/Security/WmiAccountSaveTest.php delete mode 100644 tests/Security/WmiListPaginationTest.php delete mode 100644 tests/Security/WmiToolsPasswordTest.php create mode 100644 tests/Unit/WmiWmicTransportTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 44646ef..93697a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* dev: Cover the Authentication, Queries and Query Tool pages and the Device Template hook with real-request end-to-end tests and replace the source-text checks * dev: Record which plugin lines the end-to-end run executes and report the changed lines no suite reaches (tests/bin/e2e-coverage-report.php) * issue: Fix the breadcrumb parents of the Edit WMI Account and WMI Query Tool pages so the account edit page no longer fails on Cacti develop * issue: Use the default of 10 concurrent WMI collectors when the setting is empty instead of waiting forever diff --git a/tests/E2E/EntryScriptsTest.php b/tests/E2E/EntryScriptsTest.php new file mode 100644 index 0000000..72f81cd --- /dev/null +++ b/tests/E2E/EntryScriptsTest.php @@ -0,0 +1,76 @@ +&1', $output, $status); + + expect($output)->toBe([]); + expect($status)->toBe(0); + })->with(['collector_flow.php', 'pages_flow.php', 'e2e_common.php', 'coverage_prepend.php']); + + it('do nothing when the work directory is missing', function (string $script) { + $output = []; + + exec('WMI_E2E=1 WMI_E2E_WORKDIR=/nonexistent-wmi-e2e ' . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__DIR__ . '/' . $script) . ' 2>&1', $output); + + expect($output)->toBe([]); + })->with(['collector_flow.php', 'pages_flow.php', 'e2e_common.php']); + + it('record no coverage unless a coverage directory is named', function () { + $work = sys_get_temp_dir() . '/wmi-e2e-' . bin2hex(random_bytes(4)); + + mkdir($work, 0700); + file_put_contents($work . '/probe.php', "&1', $output); + + expect($output)->toBe(['done']); + expect(glob($work . '/*.json'))->toBe([]); + + wmi_e2e_remove($work); + }); + + it('record the plugin lines that ran when a coverage directory is named', function () { + if (!function_exists('xdebug_start_code_coverage')) { + $this->markTestSkipped('Xdebug is not loaded.'); + } + + $work = sys_get_temp_dir() . '/wmi-e2e-' . bin2hex(random_bytes(4)); + + mkdir($work, 0700); + file_put_contents($work . '/probe.php', "&1', $output); + + $dumps = glob($work . '/*.json') ?: []; + $data = $dumps === [] ? [] : json_decode((string) file_get_contents($dumps[0]), true); + + expect($output)->toBe(['done']); + expect($dumps)->toHaveCount(1); + expect(array_keys($data))->toBe(['includes/linux_wmi.php']); + + wmi_e2e_remove($work); + }); +}); diff --git a/tests/Security/WmiAccountSaveTest.php b/tests/Security/WmiAccountSaveTest.php deleted file mode 100644 index fc7427e..0000000 --- a/tests/Security/WmiAccountSaveTest.php +++ /dev/null @@ -1,22 +0,0 @@ -toContain('wmi_account_input_valid('); - expect($source)->toContain('wmi_account_password_fits('); - expect($source)->toContain("if (\$valid) { \$id = sql_save(\$save, 'wmi_user_accounts', 'id'); } else { raise_message(4);"); -}); diff --git a/tests/Security/WmiListPaginationTest.php b/tests/Security/WmiListPaginationTest.php deleted file mode 100644 index c6e00dc..0000000 --- a/tests/Security/WmiListPaginationTest.php +++ /dev/null @@ -1,30 +0,0 @@ -not->toContain('$total_rows = sizeof($queries)'); - expect(preg_replace('/\s+/', ' ', $source))->toContain('$total_rows = db_fetch_cell("SELECT COUNT(*) FROM wmi_wql_queries $sql_where");'); - }); - - it('counts only the accounts matching the search filter', function () { - $source = plugin_test_read_source('wmi_accounts.php'); - - expect($source)->not->toContain("'SELECT COUNT(*) FROM wmi_user_accounts'"); - expect(preg_replace('/\s+/', ' ', $source))->toContain('$total_rows = db_fetch_cell("SELECT COUNT(*) FROM wmi_user_accounts $sql_where");'); - }); -}); diff --git a/tests/Security/WmiToolsPasswordTest.php b/tests/Security/WmiToolsPasswordTest.php deleted file mode 100644 index 2c8a013..0000000 --- a/tests/Security/WmiToolsPasswordTest.php +++ /dev/null @@ -1,37 +0,0 @@ -toContain("'username' =>"); - expect($block)->not->toContain("'password'"); - }); - - it('does not write the password back into the form', function () use ($source) { - expect($source)->not->toContain("html_escape_request_var('password')"); - expect($source)->toContain("id='password' value=''"); - }); - - it('still reads the submitted password for the query itself', function () use ($source) { - expect($source)->toContain("get_nfilter_request_var('password')"); - }); -}); diff --git a/tests/Unit/WmiProcessLockTest.php b/tests/Unit/WmiProcessLockTest.php index 02e2b2a..45309ec 100644 --- a/tests/Unit/WmiProcessLockTest.php +++ b/tests/Unit/WmiProcessLockTest.php @@ -50,9 +50,19 @@ expect($GLOBALS['__test_cacti_log'][0])->toContain('WMI collectors'); }); -it('expires stale locks before the master checks for a running collector', function () { - $source = plugin_test_read_source('poller_wmi.php'); +it('keeps waiting right at the deadline and counts the collectors still running', function () { + $GLOBALS['__test_fetch_cell_map']['FROM wmi_processes WHERE taskid'] = '1'; - expect(strpos($source, 'wmi_purge_stale_processes()'))->toBeLessThan(strpos($source, "SELECT COUNT(*) FROM wmi_processes') > 0")); - expect($source)->toContain('wmi_collectors_running($seed, $wait_began)'); + expect(wmi_collectors_running(77, time() - 3600, 3600))->toBe(1); + expect($GLOBALS['__test_db_calls'])->toBe(array()); +}); + +it('treats a missing count as no running collectors', function () { + expect(wmi_collectors_running(77, time(), 3600))->toBe(0); +}); + +it('purges with the default age of one hour', function () { + wmi_purge_stale_processes(); + + expect($GLOBALS['__test_db_calls'][0]['params'])->toBe(array(3600)); }); diff --git a/tests/Unit/WmiWmicTransportTest.php b/tests/Unit/WmiWmicTransportTest.php new file mode 100644 index 0000000..2ee5005 --- /dev/null +++ b/tests/Unit/WmiWmicTransportTest.php @@ -0,0 +1,101 @@ + $lines + */ +function wmi_test_fake_wmic(array $lines, int $status): string { + $dir = sys_get_temp_dir() . '/wmi-test-' . bin2hex(random_bytes(6)); + $path = $dir . '/wmic'; + + mkdir($dir, 0700); + file_put_contents($path, "#!/bin/sh\n" . ($lines === [] ? '' : 'printf \'%s\\n\' ' . implode(' ', array_map('escapeshellarg', $lines)) . "\n") . 'exit ' . $status . "\n"); + chmod($path, 0700); + + return $path; +} + +function wmi_test_run_wmic(string $binary): Linux_WMI { + $w = new Linux_WMI('', new Wmic_Shell_Transport()); + $w->username = 'u'; + $w->password = 'p'; + $w->hostname = 'host'; + $w->command = 'SELECT 1'; + $w->binary = $binary; + + $w->fetch(); + + return $w; +} + +afterAll(function () { + foreach (glob(sys_get_temp_dir() . '/wmi-test-*') ?: [] as $dir) { + foreach (glob($dir . '/*') ?: [] as $file) { + unlink($file); + } + + rmdir($dir); + } +}); + +it('returns the lines the executable printed on success', function () { + $w = wmi_test_run_wmic(wmi_test_fake_wmic(['CLASS: Win32_Service', 'Name|+|State', 'A|+|Running'], 0)); + + expect($w->error)->toBeFalse(); + expect($w->fetch_class())->toBe('CLASS: Win32_Service'); + expect($w->fetch_data())->toBe([['A', 'Running']]); +}); + +it('reports the output as the error when the executable fails', function () { + $w = wmi_test_run_wmic(wmi_test_fake_wmic(['NTSTATUS: access denied'], 1)); + + expect($w->error)->toBe('ERROR: NTSTATUS: access denied'); +}); + +it('reports an empty error when the executable fails without output', function () { + $w = wmi_test_run_wmic(wmi_test_fake_wmic([], 3)); + + expect($w->error)->toBe('ERROR: '); +}); + +it('reports no data when the executable succeeds without output', function () { + $w = wmi_test_run_wmic(wmi_test_fake_wmic([], 0)); + + expect($w->error)->toBe('ERROR: WMI Returned no Data'); +}); + +it('fails cleanly when the executable does not exist', function () { + $w = wmi_test_run_wmic('/nonexistent/wmic-for-tests'); + + expect($w->error)->toStartWith('ERROR: '); + expect($w->fetch_data())->toBe([]); +}); + +it('refuses to run without credentials', function () { + $w = new Linux_WMI('', new Wmic_Shell_Transport()); + $w->command = 'SELECT 1'; + + expect($w->fetch())->toBeFalse(); + expect($w->error)->toBe('ERROR: Username or Password not set!'); +}); From 2027a3c2c82d2462af21c73813d9ac4be2bcf5ad Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 23:52:46 -0600 Subject: [PATCH 23/27] fix(tools): run the query action only for POST requests Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + tests/E2E/PagesFlowTest.php | 9 +++++++++ tests/E2E/pages_flow.php | 12 ++++++++++-- wmi_tools.php | 11 ++++++++++- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93697a2..2348962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* security: Run the WMI Query Tool query action only for POST requests so credentials stay out of URLs, access logs and browser history * dev: Cover the Authentication, Queries and Query Tool pages and the Device Template hook with real-request end-to-end tests and replace the source-text checks * dev: Record which plugin lines the end-to-end run executes and report the changed lines no suite reaches (tests/bin/e2e-coverage-report.php) * issue: Fix the breadcrumb parents of the Edit WMI Account and WMI Query Tool pages so the account edit page no longer fails on Cacti develop diff --git a/tests/E2E/PagesFlowTest.php b/tests/E2E/PagesFlowTest.php index fb3568d..b9ba7b5 100644 --- a/tests/E2E/PagesFlowTest.php +++ b/tests/E2E/PagesFlowTest.php @@ -174,6 +174,15 @@ function wmi_e2e_pages(): array { expect($o['tool_no_realm']['ran'])->toBeFalse(); }); + it('does not run a tool query sent by GET and echoes no credentials', function () { + $o = wmi_e2e_pages()['tool_get']; + + expect($o['ran'])->toBeFalse(); + expect($o['wmic_calls'])->toBe(0); + expect($o['shows_form'])->toBeTrue(); + expect($o['secret_echoed'])->toBeFalse(); + }); + it('builds the device template remove form with an id parameter and links and unlinks queries', function () { $o = wmi_e2e_pages(); diff --git a/tests/E2E/pages_flow.php b/tests/E2E/pages_flow.php index c96f271..fc8bd39 100644 --- a/tests/E2E/pages_flow.php +++ b/tests/E2E/pages_flow.php @@ -294,8 +294,16 @@ $r = $nowmi->post($pages['tools'], $tool_fields, $nowmi->page($pages['tools'])['token'], ['action' => 'query', 'header' => 'false']); $out['tool_no_realm'] = ['ran' => strpos($r['body'], 'Spooler') !== false]; -$r = $admin->request($pages['tools'], ['action' => 'query', 'header' => 'false'] + $tool_fields); -$out['tool_get'] = ['code' => $r['code'], 'ran' => strpos($r['body'], 'Spooler') !== false]; +$calls_before = count(wmi_e2e_calls()); +$r = $admin->request($pages['tools'], ['action' => 'query', 'header' => 'false'] + $tool_fields); +$page_get = $admin->request($pages['tools'], ['action' => 'query']); +$out['tool_get'] = [ + 'code' => $r['code'], + 'ran' => strpos($r['body'], 'Spooler') !== false || strpos($page_get['body'], 'Spooler') !== false, + 'wmic_calls' => count(wmi_e2e_calls()) - $calls_before, + 'shows_form' => strpos($page_get['body'], "id='command'") !== false, + 'secret_echoed' => strpos($r['body'], $secret) !== false || strpos($page_get['body'], $secret) !== false, +]; // The Device Template hook: the confirmation form posts back to the edit page. $template = WMI_E2E_ID + 7; diff --git a/wmi_tools.php b/wmi_tools.php index 9b72ea1..5453f24 100644 --- a/wmi_tools.php +++ b/wmi_tools.php @@ -31,7 +31,16 @@ process_request_vars(); -switch (get_request_var('action')) { +$action = get_request_var('action'); + +// The query action sends credentials to a host. Run it only for a POST, where +// the CSRF token applies and nothing travels in the URL, a log or the history; +// any other method gets the empty form. +if ($action == 'query' && ($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + $action = ''; +} + +switch ($action) { case 'query': walk_host(); From 3bc7a52dd1e27658706bb5d39de37b64440374c5 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 23:57:53 -0600 Subject: [PATCH 24/27] fix(poller): take the query start time from the database clock Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + poller_wmi.php | 8 +++++++- tests/E2E/collector_flow.php | 6 ++---- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2348962..621b44d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* issue: Record the WMI query start time from the database clock so devices are not polled again every cycle when PHP and the database use different time zones * security: Run the WMI Query Tool query action only for POST requests so credentials stay out of URLs, access logs and browser history * dev: Cover the Authentication, Queries and Query Tool pages and the Device Template hook with real-request end-to-end tests and replace the source-text checks * dev: Record which plugin lines the end-to-end run executes and report the changed lines no suite reaches (tests/bin/e2e-coverage-report.php) diff --git a/poller_wmi.php b/poller_wmi.php index 65f25cf..7dd7623 100644 --- a/poller_wmi.php +++ b/poller_wmi.php @@ -395,7 +395,13 @@ function process_device($host_id) { db_execute_prepared('REPLACE INTO wmi_processes (pid, taskid) VALUES (?, ?)', [getmypid(), $seed]); db_execute_prepared('DELETE FROM wmi_processes WHERE pid = ? AND taskid = ?', [$key, $seed]); - $qstart = date('Y-m-d H:i:s'); + // last_started is compared with NOW() in SQL, so take the time from the + // database clock: PHP and the database session may use different time zones. + $qstart = db_fetch_cell('SELECT NOW()'); + + if (!$qstart) { + $qstart = date('Y-m-d H:i:s'); + } if (cacti_sizeof($queries_to_run)) { foreach ($queries_to_run as $q) { diff --git a/tests/E2E/collector_flow.php b/tests/E2E/collector_flow.php index 66e78a5..f9fddf1 100644 --- a/tests/E2E/collector_flow.php +++ b/tests/E2E/collector_flow.php @@ -106,8 +106,6 @@ function refresh(int $host = HOST_A, int $query = QUERY_SVC): array { $out['key_' . $label] = refresh(); } -$out['key_duplicate_state'] = null; - db_execute_prepared('UPDATE wmi_wql_queries SET primary_key = ? WHERE id = ?', ['Name', QUERY_SVC]); db_execute_prepared('DELETE FROM host_wmi_cache WHERE host_id = ?', [HOST_A]); wmi_e2e_wmic_default(wmi_e2e_result('Win32_Service', $svc_header, [['A', 'Running'], ['A', 'Stopped']]), 0); @@ -147,8 +145,8 @@ function refresh(int $host = HOST_A, int $query = QUERY_SVC): array { // Poller: three hosts, two queries, one failing host/query pair. db_execute_prepared('DELETE FROM host_wmi_cache WHERE host_id >= ?', [HOST_A]); db_execute_prepared('DELETE FROM wmi_wql_queries WHERE id >= ?', [QUERY_SVC]); -wmi_e2e_add_query(QUERY_SVC, 'e2e-svc', 'root\\\\CIMV2', 'SELECT Name,State FROM Win32_Service', 'Name'); -wmi_e2e_add_query(QUERY_PRC, 'e2e-prc', 'root\\\\CIMV2', 'SELECT ProcessId,Name FROM Win32_Process', 'ProcessId'); +wmi_e2e_add_query(QUERY_SVC, 'e2e-svc', 'root\\\\CIMV2', 'SELECT Name,State FROM Win32_Service', 'Name', 3600); +wmi_e2e_add_query(QUERY_PRC, 'e2e-prc', 'root\\\\CIMV2', 'SELECT ProcessId,Name FROM Win32_Process', 'ProcessId', 3600); wmi_e2e_link_template(TEMPLATE, QUERY_SVC); wmi_e2e_link_template(TEMPLATE, QUERY_PRC); From e3ca60c87d51463be844a2d2432e0213db47fb47 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 23:58:59 -0600 Subject: [PATCH 25/27] test: match Cacti function signatures in the test stubs Signed-off-by: Thomas Vincent --- tests/Unit/WmiNamespaceArgumentTest.php | 4 +-- tests/Unit/WmiTemplateRemoveFormTest.php | 4 +-- tests/Unit/WmiWmicTransportTest.php | 4 +-- tests/bootstrap-unit.php | 44 ++++++++++++------------ 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/tests/Unit/WmiNamespaceArgumentTest.php b/tests/Unit/WmiNamespaceArgumentTest.php index 9ab3680..2c4beed 100644 --- a/tests/Unit/WmiNamespaceArgumentTest.php +++ b/tests/Unit/WmiNamespaceArgumentTest.php @@ -13,8 +13,8 @@ */ if (!function_exists('cacti_escapeshellarg')) { - function cacti_escapeshellarg($s) { - return escapeshellarg($s); + function cacti_escapeshellarg($string, $quote = true) { + return escapeshellarg($string); } } diff --git a/tests/Unit/WmiTemplateRemoveFormTest.php b/tests/Unit/WmiTemplateRemoveFormTest.php index d9c3860..fff0f29 100644 --- a/tests/Unit/WmiTemplateRemoveFormTest.php +++ b/tests/Unit/WmiTemplateRemoveFormTest.php @@ -13,8 +13,8 @@ */ if (!function_exists('form_start')) { - function form_start($action, $id = '') { - throw new RuntimeException($action); + function form_start($url, $name = '', $multipart = false) { + throw new RuntimeException($url); } } diff --git a/tests/Unit/WmiWmicTransportTest.php b/tests/Unit/WmiWmicTransportTest.php index 2ee5005..e9d2a3c 100644 --- a/tests/Unit/WmiWmicTransportTest.php +++ b/tests/Unit/WmiWmicTransportTest.php @@ -13,8 +13,8 @@ */ if (!function_exists('cacti_escapeshellarg')) { - function cacti_escapeshellarg($s) { - return escapeshellarg($s); + function cacti_escapeshellarg($string, $quote = true) { + return escapeshellarg($string); } } diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index 6e44c3e..1ff317f 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -75,39 +75,39 @@ $GLOBALS['__test_db_calls'] = array(); if (!function_exists('db_execute')) { - function db_execute($sql) { + function db_execute($sql, $log = true, $db_conn = false, $timeout = 0) { $GLOBALS['__test_db_calls'][] = array('fn' => 'db_execute', 'sql' => $sql, 'params' => array()); return true; } } if (!function_exists('db_execute_prepared')) { - function db_execute_prepared($sql, $params = array()) { + function db_execute_prepared($sql, $params = array(), $log = true, $db_conn = false, $execute_name = 'Exec', $default_value = true, $return_func = 'no_return_function', $return_params = array(), $timeout = 0) { $GLOBALS['__test_db_calls'][] = array('fn' => 'db_execute_prepared', 'sql' => $sql, 'params' => $params); return true; } } if (!function_exists('db_fetch_assoc')) { - function db_fetch_assoc($sql) { + function db_fetch_assoc($sql, $log = true, $db_conn = false, $timeout = 0) { return array(); } } if (!function_exists('db_fetch_assoc_prepared')) { - function db_fetch_assoc_prepared($sql, $params = array()) { + function db_fetch_assoc_prepared($sql, $params = array(), $log = true, $db_conn = false, $timeout = 0) { return array(); } } if (!function_exists('db_fetch_row')) { - function db_fetch_row($sql) { + function db_fetch_row($sql, $log = true, $db_conn = false, $timeout = 0) { return array(); } } if (!function_exists('db_fetch_row_prepared')) { - function db_fetch_row_prepared($sql, $params = array()) { + function db_fetch_row_prepared($sql, $params = array(), $log = true, $db_conn = false, $timeout = 0) { foreach (($GLOBALS['__test_fetch_row_map'] ?? array()) as $needle => $row) { if (strpos($sql, $needle) !== false) { return $row; @@ -119,13 +119,13 @@ function db_fetch_row_prepared($sql, $params = array()) { } if (!function_exists('db_fetch_cell')) { - function db_fetch_cell($sql) { + function db_fetch_cell($sql, $col_name = '', $log = true, $db_conn = false, $timeout = 0) { return ''; } } if (!function_exists('db_fetch_cell_prepared')) { - function db_fetch_cell_prepared($sql, $params = array()) { + function db_fetch_cell_prepared($sql, $params = array(), $col_name = '', $log = true, $db_conn = false, $timeout = 0) { foreach (($GLOBALS['__test_fetch_cell_map'] ?? array()) as $needle => $value) { if (strpos($sql, $needle) !== false) { return $value; @@ -137,19 +137,19 @@ function db_fetch_cell_prepared($sql, $params = array()) { } if (!function_exists('db_qstr')) { - function db_qstr($text) { - return "'" . addslashes((string) $text) . "'"; + function db_qstr($s, $db_conn = false) { + return "'" . addslashes((string) $s) . "'"; } } if (!function_exists('db_index_exists')) { - function db_index_exists($table, $index) { + function db_index_exists($table, $index, $log = true, $db_conn = false) { return false; } } if (!function_exists('db_column_exists')) { - function db_column_exists($table, $column) { + function db_column_exists($table, $column, $log = true, $db_conn = false) { return false; } } @@ -174,7 +174,7 @@ function read_config_option($name, $force = false) { } if (!function_exists('set_config_option')) { - function set_config_option($name, $value) { + function set_config_option($config_name, $value, $remote = false) { } } @@ -216,24 +216,24 @@ function is_realm_allowed($realm) { } if (!function_exists('raise_message')) { - function raise_message($id, $text = '', $level = 0) { + function raise_message($message_id, $message = '', $message_level = 0, $message_title = null) { } } if (!function_exists('get_request_var')) { - function get_request_var($name) { - return $GLOBALS['__test_request'][$name] ?? ''; + function get_request_var($name, $default = '') { + return $GLOBALS['__test_request'][$name] ?? $default; } } if (!function_exists('get_nfilter_request_var')) { - function get_nfilter_request_var($name) { - return ''; + function get_nfilter_request_var($name, $default = '') { + return $default; } } if (!function_exists('get_filter_request_var')) { - function get_filter_request_var($name) { + function get_filter_request_var($name, $filter = FILTER_VALIDATE_INT, $options = array()) { return ''; } } @@ -251,9 +251,9 @@ function is_error_message() { } if (!function_exists('sql_save')) { - function sql_save($array, $table, $key = 'id') { - $GLOBALS['__test_sql_save_calls'][] = array('array' => $array, 'table' => $table, 'key' => $key); - return $array['id'] ?? 1; + function sql_save($array_items, $table_name, $key_cols = 'id', $autoinc = true, $db_conn = false) { + $GLOBALS['__test_sql_save_calls'][] = array('array' => $array_items, 'table' => $table_name, 'key' => $key_cols); + return $array_items['id'] ?? 1; } } From d854806a14f686e52a2f31d32e03d6b328e67359 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 00:03:09 -0600 Subject: [PATCH 26/27] fix(poller): drop an unreachable clock fallback Signed-off-by: Thomas Vincent --- poller_wmi.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/poller_wmi.php b/poller_wmi.php index 7dd7623..619fbd8 100644 --- a/poller_wmi.php +++ b/poller_wmi.php @@ -399,10 +399,6 @@ function process_device($host_id) { // database clock: PHP and the database session may use different time zones. $qstart = db_fetch_cell('SELECT NOW()'); - if (!$qstart) { - $qstart = date('Y-m-d H:i:s'); - } - if (cacti_sizeof($queries_to_run)) { foreach ($queries_to_run as $q) { $qmstart = microtime(true); From ff9d7400ee329f53fe29882ff16ee5c83ab47412 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Mon, 26 Oct 2026 17:59:44 -0700 Subject: [PATCH 27/27] Validate hosts including IPv6, keep credentials off the command line, fix script paths (stacked on #44) (#46) --- CHANGELOG.md | 6 + includes/functions.php | 51 +++- includes/linux_wmi.php | 233 ++++++++++++++++- locales/po/cacti.pot | 8 + script/wmi-script.php | 8 +- setup.php | 12 + .../script_server/exchange_messages.xml | 2 +- tests/E2E/CollectorFlowTest.php | 23 +- tests/E2E/PagesFlowTest.php | 27 ++ tests/E2E/collector_flow.php | 19 ++ tests/E2E/e2e_common.php | 27 +- tests/E2E/pages_flow.php | 22 ++ tests/Unit/WmiScriptServerTest.php | 142 +++++++++++ tests/Unit/WmiValidHostTest.php | 145 +++++++++++ tests/Unit/WmiWmicAuthFileTest.php | 238 ++++++++++++++++++ tests/Unit/WmiWmicStderrTest.php | 135 ++++++++++ tests/bootstrap-unit.php | 2 +- wmi_script.php | 3 +- wmi_tools.php | 31 +-- 19 files changed, 1089 insertions(+), 45 deletions(-) create mode 100644 tests/Unit/WmiScriptServerTest.php create mode 100644 tests/Unit/WmiValidHostTest.php create mode 100644 tests/Unit/WmiWmicAuthFileTest.php create mode 100644 tests/Unit/WmiWmicStderrTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 621b44d..5bf1599 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ --- develop --- +* dev: Extend the end-to-end fake wmic with --authentication-file and a stderr line, and cover host validation, the credentials file and the script-server scripts end to end +* issue: Point the script-server resource and its scripts at the files that exist, load Cacti from where it is, and register the wmic path as the path_wmi setting (default /usr/bin/wmic) +* security: Pass the Windows credentials to wmic in a private authentication file instead of --user and --password arguments, so they no longer show up in the process list +* issue: Read wmic's stderr apart from its stdout, add it to the error text only, and stop a wmic that does not finish within two minutes +* issue: Validate the Query Tool host as an IPv4 address, an IPv6 address or an RFC 1123 host name; the old check refused IPv6, short names and longer top level labels and was never called * issue: Record the WMI query start time from the database clock so devices are not polled again every cycle when PHP and the database use different time zones * security: Run the WMI Query Tool query action only for POST requests so credentials stay out of URLs, access logs and browser history * dev: Cover the Authentication, Queries and Query Tool pages and the Device Template hook with real-request end-to-end tests and replace the source-text checks @@ -19,6 +24,7 @@ * issue: Index cached WMI rows by every column of a comma separated primary key so multi-row results are no longer overwritten * issue: Purge WMI cache rows the device stopped returning, and keep the existing rows when a query fails * issue: Expire stale wmi_processes lock rows and stop the master poller waiting indefinitely for collectors that died +||||||| parent of 7c3e181 (fix(tools): validate the host as an IP address or an RFC 1123 name) * security: Replace the confirmation pages' inline `onClick='cactiReturnTo()'` Cancel buttons with the CSP-safe `cactiReturnTo` class so the pages no longer trip Cacti's Content-Security-Policy script-src-attr directive * security: Move the WMI Accounts and Queries filter rows selects' inline onChange handlers into the ready block so the pages no longer trip Cacti's Content-Security-Policy script-src-attr directive * dev: Measure CI coverage with xdebug instead of pcov so the plugin's own sources are instrumented (pcov auto-scopes to the Composer root and skipped cacti/plugins/, leaving the patch-coverage gate with nothing to measure) diff --git a/includes/functions.php b/includes/functions.php index 092cf39..ac5fae8 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -331,7 +331,7 @@ function plugin_wmi_create_resource_xml($id) { if (isset($wmic['id'])) { $data = "\n"; $data .= ' ' . $wmic['name'] . "\n"; - $data .= " |path_cacti|/scripts/wmi-script.php\n"; + $data .= " |path_cacti|/plugins/wmi/script/wmi-script.php\n"; $data .= " wmi_script\n"; $data .= ' WMI Query for ' . $wmic['name'] . "\n"; $data .= " php\n"; @@ -646,6 +646,55 @@ function wmi_collectors_running($seed, $began, $max_age = 3600) { return (int) db_fetch_cell_prepared('SELECT COUNT(*) FROM wmi_processes WHERE taskid = ?', [$seed]); } +/** + * Validates a host as typed into the Query Tool: an IPv4 address, an IPv6 + * address (optionally in brackets), or a host name under RFC 1123 (labels of + * 1 to 63 letters, digits and hyphens that neither start nor end with a + * hyphen, at most 253 characters, optional trailing dot). IPv6 zone ids + * (fe80::1%eth0) are refused, as is a name whose last label is all digits. + * + * @param string $host The host as entered. + * + * @return string|false The host to use (brackets removed), or false when it is + * not a valid address or name. + */ +function wmi_valid_host($host) { + if ($host === '') { + return false; + } + + if ($host[0] === '[' && substr($host, -1) === ']') { + $inner = substr($host, 1, -1); + + return filter_var($inner, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false ? $inner : false; + } + + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + return $host; + } + + $name = substr($host, -1) === '.' ? substr($host, 0, -1) : $host; + + if ($name === '' || strlen($name) > 253) { + return false; + } + + $labels = explode('.', $name); + + foreach ($labels as $label) { + if (!preg_match('/^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/D', $label)) { + return false; + } + } + + // Not a number: 999.1.1.1 is a malformed address, not a host name. + if (ctype_digit((string) end($labels))) { + return false; + } + + return $host; +} + /** * Returns the current unique hash for a WMI query, or generates a new * one if the stored value doesn't already look like a valid 128-bit hex diff --git a/includes/linux_wmi.php b/includes/linux_wmi.php index e8206e5..7ebc2e6 100644 --- a/includes/linux_wmi.php +++ b/includes/linux_wmi.php @@ -66,32 +66,73 @@ public function error(): ?string; } /** - * Default transport: the Linux `wmic` client invoked through the shell. + * Default transport: the Linux `wmic` client. + * + * The credentials go to wmic in a private authentication file (-A) so they + * never appear in the process list. stdout is the result; stderr is kept + * apart and only added to the error text. */ class Wmic_Shell_Transport implements Wmi_Transport { private ?string $error = null; + /** + * @param int $timeout Seconds to wait for wmic before it is stopped. + * @param string|null $temp_dir Directory for the authentication file; the + * system temporary directory when null. + */ + public function __construct( + private int $timeout = 120, + private ?string $temp_dir = null + ) { + } + public function query(Wmi_Request $request): array|false { $this->error = null; - $output = []; - $status = 0; + if (preg_match('/[\r\n\0]/', $request->username . $request->password)) { + $this->error = 'ERROR: WMI credentials must not contain line breaks'; + + return false; + } + + $auth = $this->create_auth_file($request); + + try { + $result = $this->run( + (DIRECTORY_SEPARATOR === '/' ? 'exec ' : '') . $this->build_command($request, $auth['file'] ?? null) + ); + } finally { + if ($auth !== null) { + @unlink($auth['file']); + @rmdir($auth['dir']); + } + } + + if ($result === null) { + $this->error = 'ERROR: Unable to start ' . $request->binary; + + return false; + } + + if ($result['timed_out']) { + $this->error = $this->with_stderr('ERROR: wmic did not finish within ' . $this->timeout . ' seconds', $result['stderr']); - exec($this->build_command($request), $output, $status); + return false; + } - if ($status !== 0) { - $this->error = 'ERROR: ' . implode("\n", $output); + if ($result['status'] !== 0) { + $this->error = $this->with_stderr('ERROR: ' . implode("\n", $result['lines']), $result['stderr']); return false; } - if (count($output) === 0) { - $this->error = 'ERROR: WMI Returned no Data'; + if (count($result['lines']) === 0) { + $this->error = $this->with_stderr('ERROR: WMI Returned no Data', $result['stderr']); return false; } - return $output; + return $result['lines']; } public function error(): ?string { @@ -102,17 +143,26 @@ public function error(): ?string { * Build the wmic command line. Every device-influenced value is shell * escaped; the hostname and namespace are additionally stripped of cmd.exe * metacharacters on win32. The delimiter is quoted so its pipe characters - * are not read as a shell pipeline. + * are not read as a shell pipeline. The hostname follows a literal `//`, so + * it can never be read as an option. + * + * @param string|null $auth_file Credentials file for --authentication-file; + * without it the credentials are passed as + * arguments, which any local user can read. */ - public function build_command(Wmi_Request $request): string { + public function build_command(Wmi_Request $request, ?string $auth_file = null): string { $namespace = $request->namespace !== '' ? ' --namespace=' . cacti_escapeshellarg($this->single_backslashes($this->strip_metachars($request->namespace))) : ''; + $credentials = $auth_file !== null + ? ' --authentication-file=' . cacti_escapeshellarg($auth_file) + : ' --user=' . cacti_escapeshellarg($request->username) . + ' --password=' . cacti_escapeshellarg($request->password); + return cacti_escapeshellarg($request->binary) . ' --delimiter=' . cacti_escapeshellarg($request->separator) . - ' --user=' . cacti_escapeshellarg($request->username) . - ' --password=' . cacti_escapeshellarg($request->password) . + $credentials . $namespace . ' //' . cacti_escapeshellarg($this->strip_metachars(trim($request->hostname))) . ' ' . cacti_escapeshellarg($request->query); @@ -127,6 +177,163 @@ private function single_backslashes(string $value): string { return preg_replace('/\\\\+/', '\\\\', $value); } + /** + * Write the credentials to a file only this user can read, inside a + * directory with a random name. Returns null, after logging a warning, + * when that is not possible; the caller then falls back to arguments. + * + * @return array{dir: string, file: string}|null + */ + private function create_auth_file(Wmi_Request $request): ?array { + $base = $this->temp_dir ?? sys_get_temp_dir(); + $dir = rtrim($base, '/') . '/wmi-' . bin2hex(random_bytes(8)); + + if (!is_dir($base) || !is_writable($base) || !@mkdir($dir, 0700)) { + cacti_log('WARNING: Unable to create a private directory for the wmic credentials; passing them as arguments', false, 'WMI'); + + return null; + } + + $file = $dir . '/' . bin2hex(random_bytes(8)); + $handle = $this->open_new_file($file); + $written = false; + + if ($handle !== false) { + $content = $this->auth_file_content($request); + $written = fwrite($handle, $content) === strlen($content); + + fclose($handle); + } + + if (!$written || !@chmod($file, 0600)) { + if (is_file($file)) { + unlink($file); + } + + rmdir($dir); + + cacti_log('WARNING: Unable to write the wmic credentials file; passing the credentials as arguments', false, 'WMI'); + + return null; + } + + return ['dir' => $dir, 'file' => $file]; + } + + /** + * Create a file that must not exist yet, readable only by this user. + * + * @return resource|false + */ + protected function open_new_file(string $file) { + $old = umask(0077); + $handle = @fopen($file, 'x'); + + umask($old); + + return $handle; + } + + /** + * Start a process with its stdout and stderr as pipes. + * + * @param array $pipes + * + * @return resource|false + */ + protected function start_process(string $command, ?array &$pipes) { + return proc_open($command, [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + } + + /** + * The samba credentials file format. A DOMAIN\user or DOMAIN/user name is + * split the way wmic splits --user. + */ + private function auth_file_content(Wmi_Request $request): string { + $content = ''; + $user = $request->username; + + if (preg_match('#^([^\\\\/]+)[\\\\/](.+)$#', $user, $match)) { + $content = 'domain=' . $match[1] . "\n"; + $user = $match[2]; + } + + return 'username=' . $user . "\n" . 'password=' . $request->password . "\n" . $content; + } + + /** + * Run a command with stdout and stderr read separately, stopping it when + * it takes longer than the timeout. + * + * @return array{status: int, lines: array, stderr: string, timed_out: bool}|null + */ + private function run(string $command): ?array { + $process = $this->start_process($command, $pipes); + + if (!is_resource($process)) { + return null; + } + + $open = [1 => $pipes[1], 2 => $pipes[2]]; + $buffer = [1 => '', 2 => '']; + $deadline = microtime(true) + $this->timeout; + $expired = false; + + foreach ($open as $stream) { + stream_set_blocking($stream, false); + } + + while ($open !== []) { + $left = $deadline - microtime(true); + + if ($left <= 0) { + $expired = true; + + break; + } + + $read = $open; + $write = null; + $except = null; + + @stream_select($read, $write, $except, 0, (int) min(200000, $left * 1000000)); + + foreach ($read as $key => $stream) { + $chunk = fread($stream, 8192); + + if ($chunk === false || ($chunk === '' && feof($stream))) { + unset($open[$key]); + } else { + $buffer[$key] .= $chunk; + } + } + } + + if ($expired) { + proc_terminate($process, 9); + } + + fclose($pipes[1]); + fclose($pipes[2]); + + $status = proc_close($process); + + if (substr($buffer[1], -1) === "\n") { + $buffer[1] = substr($buffer[1], 0, -1); + } + + return [ + 'status' => $expired ? -1 : $status, + 'lines' => $buffer[1] === '' ? [] : array_map('rtrim', explode("\n", $buffer[1])), + 'stderr' => substr(trim($buffer[2]), 0, 2000), + 'timed_out' => $expired, + ]; + } + + private function with_stderr(string $message, string $stderr): string { + return $stderr === '' ? $message : ($message === 'ERROR: ' ? 'ERROR: ' : $message . "\n") . $stderr; + } + /** * A hostname or namespace never legitimately contains shell or cmd.exe * metacharacters. cmd.exe ignores \" , toggles quoting on every " , and diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index af6a14a..33ced2d 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -566,3 +566,11 @@ msgstr "" #: wmi_tools.php msgid "Showing columns and first one or two rows of data." msgstr "" + +#: setup.php +msgid "WMI Client (wmic) Path" +msgstr "" + +#: setup.php +msgid "The path to the wmic binary used to query Windows devices from a Linux Cacti server." +msgstr "" diff --git a/script/wmi-script.php b/script/wmi-script.php index 91152df..9f5bf87 100644 --- a/script/wmi-script.php +++ b/script/wmi-script.php @@ -23,7 +23,7 @@ */ if (!isset($called_by_script_server)) { - require_once(__DIR__ . '/../include/cli_check.php'); + require_once(__DIR__ . '/../../../include/cli_check.php'); array_shift($_SERVER['argv']); if (isset($_SERVER['argv'][0]) && $_SERVER['argv'][0] == 'wmi_script') { @@ -35,8 +35,8 @@ /** * Script-server/CLI entry point that runs a named saved WMI query against - * a remote host (via the Linux_WMI wrapper around this plugin's bundled - * wmic binary) and either lists its index keys, prints an indexed + * a remote host (via the Linux_WMI wrapper around the wmic client set in + * the path_wmi setting) and either lists its index keys, prints an indexed * key/value pair, or prints a single fetched value, depending on $cmd. * Called by Cacti's script server (or directly from the CLI) when a Data * Input Method configured to use this script is polled. @@ -70,7 +70,7 @@ function wmi_script($hostname, $host_id, $wmiquery, $cmd = '', $arg1 = '', $arg2 $wmi = new Linux_WMI($host_id); $wmi->hostname = $hostname; - $wmi->binary = $config['base_path'] . '/plugins/wmi/wmic'; + $wmi->binary = read_config_option('path_wmi') ?: '/usr/bin/wmic'; // Fetch the info for this WMI query from the database, exit if not found $wmiinfo = db_fetch_row_prepared('SELECT * FROM wmi_wql_queries WHERE name = ?', [$wmiquery], false); diff --git a/setup.php b/setup.php index 3971a9e..eb2554d 100644 --- a/setup.php +++ b/setup.php @@ -528,6 +528,18 @@ function wmi_config_settings() { } else { $settings['misc'] = $temp; } + + if (!isset($settings['path'])) { + $settings['path'] = []; + } + + $settings['path']['path_wmi'] = [ + 'friendly_name' => __('WMI Client (wmic) Path', 'wmi'), + 'description' => __('The path to the wmic binary used to query Windows devices from a Linux Cacti server.', 'wmi'), + 'method' => 'filepath', + 'max_length' => '255', + 'default' => '/usr/bin/wmic' + ]; } /** diff --git a/templates/resource/script_server/exchange_messages.xml b/templates/resource/script_server/exchange_messages.xml index 4e71f21..92d650b 100644 --- a/templates/resource/script_server/exchange_messages.xml +++ b/templates/resource/script_server/exchange_messages.xml @@ -1,6 +1,6 @@ Exchange Messages - |path_cacti|/scripts/wmi-script.php + |path_cacti|/plugins/wmi/script/wmi-script.php wmi_script Queries an Exchange 2003 Server for its Mail Stores and the number of Messages Send and Delivered diff --git a/tests/E2E/CollectorFlowTest.php b/tests/E2E/CollectorFlowTest.php index 970e524..26dd9d3 100644 --- a/tests/E2E/CollectorFlowTest.php +++ b/tests/E2E/CollectorFlowTest.php @@ -47,12 +47,31 @@ function wmi_e2e_collector(): array { it('passes the credentials and query to wmic', function () { $argv = wmi_e2e_collector()['first']['argv']; - expect($argv)->toContain('--user=dom\\user'); - expect($argv)->toContain('--password=pw'); + expect($argv)->not->toContain('--user=dom\\user'); + expect($argv)->not->toContain('--password=pw'); expect($argv)->toContain('//winsrv1.invalid'); expect($argv)->toContain('SELECT Name,State FROM Win32_Service'); }); + it('hands the credentials to wmic in a private file that is gone afterwards', function () { + $o = wmi_e2e_collector(); + + expect(array_filter($o['first']['argv'], fn ($a) => strpos($a, '--authentication-file=') === 0))->toHaveCount(1); + expect($o['first_auth']['content'])->toBe("username=user\npassword=pw\ndomain=dom\n"); + expect($o['first_auth']['mode'])->toBe('600'); + expect($o['first_auth']['exists'])->toBeFalse(); + }); + + it('runs the script-server scripts from the command line against the cache data', function (string $script) { + $o = wmi_e2e_collector()['script_' . $script]; + + expect($o['index'])->toContain('A!A'); + expect($o['index'])->toContain('C!C'); + expect($o['pair'])->toContain('State!Stopped'); + expect($o['list'])->toContain('B'); + expect($o['none'])->toBe(''); + })->with(['dir', 'root']); + it('purges an instance the device stopped returning and updates the rest', function () { $o = wmi_e2e_collector(); diff --git a/tests/E2E/PagesFlowTest.php b/tests/E2E/PagesFlowTest.php index b9ba7b5..555c96a 100644 --- a/tests/E2E/PagesFlowTest.php +++ b/tests/E2E/PagesFlowTest.php @@ -167,6 +167,33 @@ function wmi_e2e_pages(): array { expect(wmi_e2e_pages()['tool_namespaces'][$form])->toBe(['--namespace=root\\CIMV2']); })->with(['empty', 'single', 'double']); + it('passes the tool credentials to wmic in a private file, not as arguments', function () { + $o = wmi_e2e_pages()['tool_run']; + + expect(array_filter($o['argv'], fn ($a) => strpos($a, '--password') === 0 || strpos($a, '--user') === 0))->toBe(array()); + expect($o['auth']['content'])->toStartWith("username=tool\npassword=T00l-secret-"); + expect($o['auth']['content'])->toEndWith("domain=DOM\n"); + expect($o['auth']['mode'])->toBe('600'); + expect($o['auth']['exists'])->toBeFalse(); + }); + + it('refuses hosts that are not an address or a name and runs nothing', function (string $host) { + $o = wmi_e2e_pages()['tool_hosts']['refused'][$host]; + + expect($o['message'])->toBeTrue(); + expect($o['calls'])->toBe(0); + })->with(['bad host', '-oProxy=x', 'a..b', 'fe80::1%eth0', '999.1.1.1', 'host;id']); + + it('accepts short names, IPv4 and IPv6 hosts and passes them after the // prefix', function (string $host, string $expected) { + expect(wmi_e2e_pages()['tool_hosts']['reached'][$host])->toBe($expected); + })->with([ + ['win-server', '//win-server'], + ['[2001:db8::1]', '//2001:db8::1'], + ['10.0.0.5', '//10.0.0.5'], + ['srv.corp.local', '//srv.corp.local'], + ['2001:0db8:0:0:0:0:0:1', '//2001:0db8:0:0:0:0:0:1'], + ]); + it('does not run a tool query without a CSRF token or the WMI realm', function () { $o = wmi_e2e_pages(); diff --git a/tests/E2E/collector_flow.php b/tests/E2E/collector_flow.php index f9fddf1..cf01c07 100644 --- a/tests/E2E/collector_flow.php +++ b/tests/E2E/collector_flow.php @@ -66,6 +66,25 @@ function refresh(int $host = HOST_A, int $query = QUERY_SVC): array { // First refresh caches every row. wmi_e2e_wmic_default(wmi_e2e_result('Win32_Service', $svc_header, [['A', 'Running'], ['B', 'Running'], ['C', 'Stopped']]), 0); $out['first'] = refresh(); +$out['first_auth'] = wmi_e2e_last_auth(); + +// The script-server scripts, run from the command line, against the same data. +$script = function (string $file, array $arguments) use ($root): string { + $lines = []; + + exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($root . '/plugins/wmi/' . $file) . ' ' . implode(' ', array_map('escapeshellarg', $arguments)) . ' 2>&1', $lines); + + return implode("\n", $lines); +}; + +foreach (['dir' => 'script/wmi-script.php', 'root' => 'wmi_script.php'] as $label => $file) { + $out['script_' . $label] = [ + 'index' => $script($file, ['winsrv1.invalid', (string) HOST_A, 'e2e-svc', 'query', 'index']), + 'pair' => $script($file, ['winsrv1.invalid', (string) HOST_A, 'e2e-svc', 'query', 'State', 'C']), + 'list' => $script($file, ['winsrv1.invalid', (string) HOST_A, 'e2e-svc', 'index']), + 'none' => $script($file, ['winsrv1.invalid', (string) HOST_A, 'no-such-query', 'query', 'index']), + ]; +} // The device drops an instance, which is purged. wmi_e2e_wmic_default(wmi_e2e_result('Win32_Service', $svc_header, [['A', 'Running'], ['B', 'Stopped']]), 0); diff --git a/tests/E2E/e2e_common.php b/tests/E2E/e2e_common.php index b8f7ea1..f713b30 100644 --- a/tests/E2E/e2e_common.php +++ b/tests/E2E/e2e_common.php @@ -122,7 +122,7 @@ function wmi_e2e_finish(): void { db_execute_prepared('DELETE FROM plugin_config WHERE directory = ?', ['wmi']); } - foreach (['wmic', 'out', 'status', 'argv', 'calls', 'rules'] as $name) { + foreach (['wmic', 'out', 'status', 'argv', 'calls', 'rules', 'auth_content', 'auth_path', 'auth_mode'] as $name) { $path = $state['work'] . '/' . $name; if (is_file($path)) { @@ -181,6 +181,17 @@ function wmi_e2e_write_wmic(): void { ALL="$*" printf '%s\n' "$@" > "$W/argv" printf '%s\n' "$ALL" >> "$W/calls" +for a in "$@"; do + case "$a" in + --authentication-file=*) + AUTH="${a#--authentication-file=}" + cp "$AUTH" "$W/auth_content" + printf '%s' "$AUTH" > "$W/auth_path" + stat -c '%a' "$AUTH" > "$W/auth_mode" 2>/dev/null || stat -f '%Lp' "$AUTH" > "$W/auth_mode" + ;; + esac +done +echo "wmic: note on stderr" >&2 OUT="$W/out" ST="$(cat "$W/status")" TAB="$(printf '\t')" @@ -278,6 +289,20 @@ function wmi_e2e_calls(): array { return is_file($path) ? array_values(array_filter(explode("\n", (string) file_get_contents($path)), 'strlen')) : []; } +/** + * What the last wmic call recorded about its credentials file: the content, + * its mode, and whether it still exists. + * + * @return array{content: string, mode: string, path: string, exists: bool} + */ +function wmi_e2e_last_auth(): array { + $work = $GLOBALS['wmi_e2e']['work']; + $read = fn (string $name): string => is_file($work . '/' . $name) ? (string) file_get_contents($work . '/' . $name) : ''; + $path = $read('auth_path'); + + return ['content' => $read('auth_content'), 'mode' => trim($read('auth_mode')), 'path' => $path, 'exists' => $path !== '' && file_exists($path)]; +} + function wmi_e2e_add_account(int $id, string $name, string $username, string $password): void { $wmi = new Linux_WMI(); diff --git a/tests/E2E/pages_flow.php b/tests/E2E/pages_flow.php index fc8bd39..3b67035 100644 --- a/tests/E2E/pages_flow.php +++ b/tests/E2E/pages_flow.php @@ -259,6 +259,7 @@ 'username_in_page' => strpos($page['body'], 'DOM') !== false, 'password_field_empty' => preg_match("/id='password' value=''/", $page['body']) === 1, 'argv' => wmi_e2e_last_argv(), + 'auth' => wmi_e2e_last_auth(), ]; $bad = []; @@ -288,6 +289,27 @@ $out['tool_namespaces'] = $namespaces; +// Hosts the tool refuses run nothing; valid forms reach wmic after the // prefix. +$refused = []; +$reached = []; + +foreach (['bad host', '-oProxy=x', 'a..b', 'fe80::1%eth0', '999.1.1.1', 'host;id'] as $bad) { + $calls = count(wmi_e2e_calls()); + $r = $tool(['host' => $bad] + $tool_fields); + + $refused[$bad] = ['message' => strpos($r['body'], 'The host must be an IPv4 or IPv6 address or a host name') !== false, 'calls' => count(wmi_e2e_calls()) - $calls]; +} + +foreach (['win-server', '[2001:db8::1]', '10.0.0.5', 'srv.corp.local', '2001:0db8:0:0:0:0:0:1'] as $good) { + $tool(['host' => $good] + $tool_fields); + + $target = array_values(array_filter(wmi_e2e_last_argv(), fn ($a) => strpos($a, '//') === 0)); + + $reached[$good] = $target[0] ?? ''; +} + +$out['tool_hosts'] = ['refused' => $refused, 'reached' => $reached]; + $r = $tool($tool_fields, false); $out['tool_no_csrf'] = ['code' => $r['code'], 'ran' => strpos($r['body'], 'Spooler') !== false]; diff --git a/tests/Unit/WmiScriptServerTest.php b/tests/Unit/WmiScriptServerTest.php new file mode 100644 index 0000000..5b29846 --- /dev/null +++ b/tests/Unit/WmiScriptServerTest.php @@ -0,0 +1,142 @@ +\|path_cacti\|/plugins/wmi/([^<]+)#', $xml, $m))->toBe(1); + expect(is_file(WMI_SS_PLUGIN . '/' . $m[1]))->toBeTrue(); +}); + +it('exports a resource that points at a script that exists', function () { + $GLOBALS['__test_fetch_row_map'] = array('wmi_wql_queries' => array('id' => 3, 'name' => 'svc', 'primary_key' => 'Name')); + + $xml = plugin_wmi_create_resource_xml(3); + + $GLOBALS['__test_fetch_row_map'] = array(); + + expect(preg_match('#\|path_cacti\|/plugins/wmi/([^<]+)#', $xml, $m))->toBe(1); + expect(is_file(WMI_SS_PLUGIN . '/' . $m[1]))->toBeTrue(); + expect($xml)->toContain('wmi_script'); +}); + +it('loads Cacti files the scripts need from where they are', function (string $script) { + $source = (string) file_get_contents(WMI_SS_PLUGIN . '/' . $script); + + expect(preg_match_all("#require(?:_once)?\(__DIR__ \. '([^']+)'\)#", $source, $m))->toBeGreaterThan(0); + + foreach ($m[1] as $relative) { + expect(is_file(dirname(WMI_SS_PLUGIN . '/' . $script) . $relative))->toBeTrue(); + } + + expect($source)->not->toContain('global.php'); + expect($source)->not->toContain('/plugins/wmi/wmic'); +})->with(['wmi_script.php', 'script/wmi-script.php']); + +it('registers the wmic path as a setting with a default', function () { + global $settings, $tabs; + + $settings = array(); + $tabs = array(); + + wmi_config_settings(); + + expect($settings['path']['path_wmi']['default'])->toBe('/usr/bin/wmic'); + expect($settings['path']['path_wmi']['method'])->toBe('filepath'); +}); + +it('keeps the settings that are already on the path tab', function () { + global $settings, $tabs; + + $settings = array('path' => array('path_rrdtool' => array('default' => '/usr/bin/rrdtool'))); + $tabs = array(); + + wmi_config_settings(); + + expect(array_keys($settings['path']))->toBe(array('path_rrdtool', 'path_wmi')); +}); + +/** + * Run the script-server function against a stand-in wmic. + * + * @param array $config + */ +function wmi_ss_run(array $config, string $command, string $arg1, string $arg2): string { + $called_by_script_server = true; + + require_once WMI_SS_PLUGIN . '/script/wmi-script.php'; + + $w = new Linux_WMI(); + + $GLOBALS['__test_config'] = $config; + $GLOBALS['__test_fetch_row_map'] = array( + 'wmi_user_accounts' => array('username' => 'svc', 'password' => $w->encode('pw')), + 'wmi_wql_queries' => array('query' => 'SELECT Name,State FROM Win32_Service', 'primary_key' => 'Name', 'namespace' => 'root\\CIMV2'), + ); + + ob_start(); + + try { + $returned = wmi_script('winsrv', 5, 'svc', $command, $arg1, $arg2); + } finally { + $output = ob_get_clean(); + } + + $GLOBALS['__test_config'] = array(); + $GLOBALS['__test_fetch_row_map'] = array(); + + return $output . (is_string($returned) ? $returned : ''); +} + +function wmi_ss_wmic(): string { + $dir = sys_get_temp_dir() . '/wmi-ss-' . bin2hex(random_bytes(6)); + + mkdir($dir, 0700); + file_put_contents($dir . '/wmic', "#!/bin/sh\nprintf '%s\\n' 'CLASS: Win32_Service' 'Name|+|State' 'Spooler|+|Running' 'Dhcp|+|Stopped'\n"); + chmod($dir . '/wmic', 0700); + + return $dir . '/wmic'; +} + +it('runs the script-server query through the wmic set in the setting', function () { + $wmic = wmi_ss_wmic(); + + expect(wmi_ss_run(array('path_wmi' => $wmic), 'get', 'State', 'Spooler'))->toContain('Running'); + expect(wmi_ss_run(array('path_wmi' => $wmic), 'query', 'index', ''))->toContain('Spooler!Spooler'); + expect(wmi_ss_run(array('path_wmi' => $wmic), 'query', 'State', 'Dhcp'))->toContain("State!Stopped"); + expect(wmi_ss_run(array('path_wmi' => $wmic), 'index', '', ''))->toContain('Spooler'); + + unlink($wmic); + rmdir(dirname($wmic)); +}); + +it('falls back to /usr/bin/wmic when the setting is empty', function () { + if (is_file('/usr/bin/wmic')) { + $this->markTestSkipped('A real wmic is installed.'); + } + + expect(wmi_ss_run(array(), 'get', 'State', 'Spooler'))->toBe(''); +}); diff --git a/tests/Unit/WmiValidHostTest.php b/tests/Unit/WmiValidHostTest.php new file mode 100644 index 0000000..9acc814 --- /dev/null +++ b/tests/Unit/WmiValidHostTest.php @@ -0,0 +1,145 @@ +toBe($host); +})->with(['192.168.1.10', '10.0.0.1', '255.255.255.255', '0.0.0.0']); + +it('accepts IPv6 addresses in compressed and expanded form', function (string $host) { + expect(wmi_valid_host($host))->toBe($host); +})->with([ + 'loopback' => '::1', + 'unspecified' => '::', + 'compressed' => '2001:db8::1', + 'expanded' => '2001:0db8:0000:0000:0000:0000:0000:0001', + 'link local' => 'fe80::1', + 'mapped IPv4' => '::ffff:192.0.2.1', +]); + +it('removes the brackets from a bracketed IPv6 address', function () { + expect(wmi_valid_host('[2001:db8::1]'))->toBe('2001:db8::1'); + expect(wmi_valid_host('[::1]'))->toBe('::1'); +}); + +it('accepts short names, dotted names and names with any top level label', function (string $host) { + expect(wmi_valid_host($host))->toBe($host); +})->with([ + 'short' => 'win-server', + 'upper case' => 'WIN-SERVER', + 'single letter' => 'a', + 'with digits' => 'srv01', + 'fqdn' => 'server.example.com', + 'local' => 'server.corp.local', + 'long tld' => 'server.example.international', + 'digits inside' => '3com.example.org', + 'trailing dot' => 'example.com.', +]); + +it('accepts labels and names at the length limits', function () { + $label = str_repeat('a', 63); + $name = $label . '.' . $label . '.' . $label . '.' . str_repeat('d', 61); + + expect(strlen($name))->toBe(253); + expect(wmi_valid_host($label . '.example.com'))->toBe($label . '.example.com'); + expect(wmi_valid_host($name))->toBe($name); + expect(wmi_valid_host($name . '.'))->toBe($name . '.'); +}); + +it('rejects labels and names over the length limits', function () { + $label = str_repeat('a', 63); + + expect(wmi_valid_host(str_repeat('a', 64) . '.example.com'))->toBeFalse(); + expect(wmi_valid_host($label . '.' . $label . '.' . $label . '.' . str_repeat('d', 62)))->toBeFalse(); +}); + +it('rejects empty input and malformed names', function (string $host) { + expect(wmi_valid_host($host))->toBeFalse(); +})->with([ + 'empty' => '', + 'space' => ' ', + 'inner space' => 'host name', + 'leading hyphen' => '-bad.example.com', + 'trailing hyphen' => 'bad-.example.com', + 'underscore' => 'bad_host', + 'empty label' => 'a..b', + 'leading dot' => '.example.com', + 'double trailing dot' => 'example.com..', + 'only a dot' => '.', + 'non ascii' => "\xc3\xbcn\xc3\xaf.example.com", + 'port' => 'example.com:445', + 'slashes' => '//host', + 'cidr' => '1.2.3.4/24', +]); + +it('rejects IPv6 zone ids and malformed brackets', function (string $host) { + expect(wmi_valid_host($host))->toBeFalse(); +})->with([ + 'zone id' => 'fe80::1%eth0', + 'bracketed zone id' => '[fe80::1%eth0]', + 'numeric zone id' => '::1%1', + 'bracketed IPv4' => '[192.168.1.1]', + 'bracketed name' => '[example.com]', + 'unclosed bracket' => '[::1', + 'unopened bracket' => '::1]', + 'text after bracket' => '[::1]x', + 'empty brackets' => '[]', + 'too many groups' => '1:2:3:4:5:6:7:8:9', +]); + +it('rejects malformed addresses that look like numbers', function (string $host) { + expect(wmi_valid_host($host))->toBeFalse(); +})->with(['999.1.1.1', '1.2.3', '1.2.3.4.5', '123', '010.0.0.1.1']); + +it('rejects input that looks like an injection attempt', function (string $host) { + expect(wmi_valid_host($host))->toBeFalse(); +})->with([ + 'semicolon' => 'host; id', + 'and' => 'host && id', + 'substitution' => '$(id)', + 'backticks' => '`id`', + 'option' => '-oProxyCommand=x', + 'newline' => "host\nname", + 'nul' => "ho\0st", + 'pipe' => 'host|id', + 'quote' => "host'", + 'percent' => '%USERNAME%', +]); + +it('keeps a host that starts with a hyphen behind the // prefix in the wmic command', function () { + $w = new Linux_WMI('', new Wmic_Shell_Transport()); + $w->username = 'u'; + $w->password = 'p'; + $w->command = 'SELECT 1'; + $w->hostname = '-oProxy'; + + expect($w->getcommand())->toContain(" //'-oProxy' "); +}); + +it('passes an IPv6 address to wmic as one escaped argument after //', function () { + $w = new Linux_WMI('', new Wmic_Shell_Transport()); + $w->username = 'u'; + $w->password = 'p'; + $w->command = 'SELECT 1'; + $w->hostname = '2001:db8::1'; + + expect($w->getcommand())->toContain(" //'2001:db8::1' "); +}); diff --git a/tests/Unit/WmiWmicAuthFileTest.php b/tests/Unit/WmiWmicAuthFileTest.php new file mode 100644 index 0000000..9da7c17 --- /dev/null +++ b/tests/Unit/WmiWmicAuthFileTest.php @@ -0,0 +1,238 @@ +failure === self::NO_FILE) { + return false; + } + + if ($this->failure === self::SHORT_FILE) { + // The file exists, but the stream cannot be written to, so the write comes up short. + file_put_contents($file, ''); + + return fopen('php://memory', 'r'); + } + + return parent::open_new_file($file); + } +} + +/** + * Create a stand-in wmic and the directory it records into. + * + * @param array $lines stdout lines + * + * @return array{binary: string, rec: string, tmp: string} + */ +function wmi_authtest_env(array $lines = [], int $status = 0, string $stderr = '', int $sleep = 0): array { + $root = sys_get_temp_dir() . '/wmi-authtest-' . bin2hex(random_bytes(6)); + $rec = $root . '/rec'; + $tmp = $root . '/tmp'; + + mkdir($root, 0700); + mkdir($rec, 0700); + mkdir($tmp, 0700); + + file_put_contents($rec . '/out', $lines === [] ? '' : implode("\n", $lines) . "\n"); + file_put_contents($rec . '/status', (string) $status); + file_put_contents($rec . '/stderr', $stderr); + file_put_contents($rec . '/sleep', (string) $sleep); + + file_put_contents($root . '/wmic', <<<'SH' +#!/bin/sh +REC="$(dirname "$0")/rec" +AUTH="" +for a in "$@"; do + case "$a" in + --authentication-file=*) AUTH="${a#--authentication-file=}" ;; + esac +done +printf '%s\n' "$@" > "$REC/argv" +if [ -n "$AUTH" ]; then + cp "$AUTH" "$REC/auth_content" + printf '%s' "$AUTH" > "$REC/auth_path" + stat -c '%a' "$AUTH" > "$REC/auth_mode" 2>/dev/null || stat -f '%Lp' "$AUTH" > "$REC/auth_mode" + stat -c '%a' "$(dirname "$AUTH")" > "$REC/dir_mode" 2>/dev/null || stat -f '%Lp' "$(dirname "$AUTH")" > "$REC/dir_mode" +fi +if [ -s "$REC/stderr" ]; then cat "$REC/stderr" >&2; fi +if [ "$(cat "$REC/sleep")" != "0" ]; then exec sleep "$(cat "$REC/sleep")"; fi +cat "$REC/out" +exit "$(cat "$REC/status")" +SH + ); + chmod($root . '/wmic', 0700); + + return ['binary' => $root . '/wmic', 'rec' => $rec, 'tmp' => $tmp]; +} + +function wmi_authtest_request(array $env, string $user = 'u', string $password = 'p'): Wmi_Request { + return new Wmi_Request('host.example.com', $user, $password, 'root\\CIMV2', 'SELECT 1', '|+|', $env['binary']); +} + +function wmi_authtest_recorded(array $env, string $name): string { + return is_file($env['rec'] . '/' . $name) ? (string) file_get_contents($env['rec'] . '/' . $name) : ''; +} + +afterAll(function () { + foreach (glob(sys_get_temp_dir() . '/wmi-authtest-*') ?: [] as $root) { + foreach (['rec', 'tmp'] as $sub) { + foreach (glob($root . '/' . $sub . '/*') ?: [] as $entry) { + if (is_dir($entry)) { + array_map('unlink', glob($entry . '/*') ?: []); + rmdir($entry); + } else { + unlink($entry); + } + } + + @rmdir($root . '/' . $sub); + } + + @unlink($root . '/wmic'); + @rmdir($root); + } +}); + +beforeEach(function () { + $GLOBALS['__test_cacti_log'] = array(); +}); + +it('keeps the credentials out of the arguments and gives wmic a private file', function () { + $env = wmi_authtest_env(['CLASS', 'Name', 'A']); + $t = new Wmic_Shell_Transport(120, $env['tmp']); + + $result = $t->query(wmi_authtest_request($env, 'DOM\\svc', 'S3cret pass')); + $argv = wmi_authtest_recorded($env, 'argv'); + + expect($result)->toBe(['CLASS', 'Name', 'A']); + expect($t->error())->toBeNull(); + expect($argv)->not->toContain('--user'); + expect($argv)->not->toContain('--password'); + expect($argv)->not->toContain('S3cret'); + expect($argv)->toContain('--authentication-file='); + expect($argv)->toContain('//host.example.com'); + expect(wmi_authtest_recorded($env, 'auth_content'))->toBe("username=svc\npassword=S3cret pass\ndomain=DOM\n"); + expect(trim(wmi_authtest_recorded($env, 'auth_mode')))->toBe('600'); + expect(trim(wmi_authtest_recorded($env, 'dir_mode')))->toBe('700'); +}); + +it('removes the credentials file and its directory after a run', function () { + $env = wmi_authtest_env(['CLASS']); + + (new Wmic_Shell_Transport(120, $env['tmp']))->query(wmi_authtest_request($env)); + + expect(file_exists(wmi_authtest_recorded($env, 'auth_path')))->toBeFalse(); + expect(glob($env['tmp'] . '/*'))->toBe(array()); +}); + +it('removes the credentials file when wmic fails or times out', function (array $env, int $timeout) { + $t = new Wmic_Shell_Transport($timeout, $env['tmp']); + + expect($t->query(wmi_authtest_request($env)))->toBeFalse(); + expect(wmi_authtest_recorded($env, 'auth_path'))->not->toBe(''); + expect(glob($env['tmp'] . '/*'))->toBe(array()); +})->with([ + 'non-zero exit' => fn () => [wmi_authtest_env(['NTSTATUS: denied'], 1), 120], + 'timeout' => fn () => [wmi_authtest_env([], 0, '', 10), 1], +]); + +it('writes passwords with special characters unchanged', function (string $password) { + $env = wmi_authtest_env(['CLASS']); + + (new Wmic_Shell_Transport(120, $env['tmp']))->query(wmi_authtest_request($env, 'svc', $password)); + + expect(wmi_authtest_recorded($env, 'auth_content'))->toBe("username=svc\npassword=" . $password . "\n"); +})->with([ + 'equals' => 'a=b=c', + 'quotes' => 'it\'s "quoted"', + 'spaces' => 'two words ', + 'shell' => '$(x) `y` ; | & > <', + 'backslash' => 'a\\b\\\\c', + 'hash' => '#not-a-comment', + 'unicode' => "p\xc3\xa4ss", +]); + +it('splits DOMAIN/user and leaves a UPN user name whole', function (string $user, string $expected) { + $env = wmi_authtest_env(['CLASS']); + + (new Wmic_Shell_Transport(120, $env['tmp']))->query(wmi_authtest_request($env, $user, 'pw')); + + expect(wmi_authtest_recorded($env, 'auth_content'))->toBe($expected); +})->with([ + 'backslash' => ['DOM\\svc', "username=svc\npassword=pw\ndomain=DOM\n"], + 'slash' => ['DOM/svc', "username=svc\npassword=pw\ndomain=DOM\n"], + 'upn' => ['svc@corp.example', "username=svc@corp.example\npassword=pw\n"], + 'plain' => ['svc', "username=svc\npassword=pw\n"], +]); + +it('refuses credentials with line breaks and never starts wmic', function (string $user, string $password) { + $env = wmi_authtest_env(['CLASS']); + $t = new Wmic_Shell_Transport(120, $env['tmp']); + + expect($t->query(wmi_authtest_request($env, $user, $password)))->toBeFalse(); + expect($t->error())->toBe('ERROR: WMI credentials must not contain line breaks'); + expect(wmi_authtest_recorded($env, 'argv'))->toBe(''); + expect(glob($env['tmp'] . '/*'))->toBe(array()); +})->with([ + 'newline in password' => ['u', "a\nb"], + 'return in password' => ['u', "a\rb"], + 'nul in password' => ['u', "a\0b"], + 'newline in user' => ["u\nusername=x", 'p'], +]); + +it('falls back to arguments with a warning when the credentials file cannot be made', function (string $failure) { + $env = wmi_authtest_env(['CLASS']); + $t = new Wmi_Test_Failing_Transport($failure, $env['tmp']); + + expect($t->query(wmi_authtest_request($env, 'svc', 'pw')))->toBe(['CLASS']); + expect(wmi_authtest_recorded($env, 'argv'))->toContain('--user=svc'); + expect(wmi_authtest_recorded($env, 'argv'))->toContain('--password=pw'); + expect($GLOBALS['__test_cacti_log'][0])->toContain('Unable to write the wmic credentials file'); + expect(glob($env['tmp'] . '/*'))->toBe(array()); +})->with([Wmi_Test_Failing_Transport::NO_FILE, Wmi_Test_Failing_Transport::SHORT_FILE]); + +it('falls back to arguments with a warning when no private directory can be made', function () { + $env = wmi_authtest_env(['CLASS']); + $t = new Wmic_Shell_Transport(120, $env['tmp'] . '/missing/deeper'); + + expect($t->query(wmi_authtest_request($env, 'svc', 'pw')))->toBe(['CLASS']); + expect(wmi_authtest_recorded($env, 'argv'))->toContain('--user=svc'); + expect($GLOBALS['__test_cacti_log'][0])->toContain('Unable to create a private directory'); +}); + +it('builds the legacy command line without a credentials file', function () { + $env = wmi_authtest_env(); + $cmd = (new Wmic_Shell_Transport())->build_command(wmi_authtest_request($env, 'svc', 'pw')); + + expect($cmd)->toContain(" --user='svc' --password='pw'"); + expect($cmd)->not->toContain('--authentication-file'); +}); diff --git a/tests/Unit/WmiWmicStderrTest.php b/tests/Unit/WmiWmicStderrTest.php new file mode 100644 index 0000000..4479cac --- /dev/null +++ b/tests/Unit/WmiWmicStderrTest.php @@ -0,0 +1,135 @@ + $lines stdout lines + */ +function wmi_stderrtest_wmic(array $lines = [], int $status = 0, string $stderr = '', int $sleep = 0): string { + $dir = sys_get_temp_dir() . '/wmi-stderrtest-' . bin2hex(random_bytes(6)); + + mkdir($dir, 0700); + file_put_contents($dir . '/out', $lines === [] ? '' : implode("\n", $lines) . "\n"); + file_put_contents($dir . '/stderr', $stderr); + file_put_contents($dir . '/wmic', "#!/bin/sh\nD=\"\$(dirname \"\$0\")\"\n" . + "if [ -s \"\$D/stderr\" ]; then cat \"\$D/stderr\" >&2; fi\n" . + ($sleep > 0 ? "exec sleep " . $sleep . "\n" : '') . + "cat \"\$D/out\"\nexit " . $status . "\n"); + chmod($dir . '/wmic', 0700); + + return $dir . '/wmic'; +} + +function wmi_stderrtest_request(string $binary): Wmi_Request { + return new Wmi_Request('host.example.com', 'u', 'p', 'root\\CIMV2', 'SELECT 1', '|+|', $binary); +} + +afterAll(function () { + foreach (glob(sys_get_temp_dir() . '/wmi-stderrtest-*') ?: [] as $dir) { + array_map('unlink', glob($dir . '/*') ?: []); + rmdir($dir); + } +}); + +it('keeps stderr out of the result', function () { + $t = new Wmic_Shell_Transport(); + + expect($t->query(wmi_stderrtest_request(wmi_stderrtest_wmic(['CLASS', 'Name', 'A'], 0, "warning: slow\n"))))->toBe(['CLASS', 'Name', 'A']); + expect($t->error())->toBeNull(); +}); + +it('adds stderr to the error text when wmic fails', function () { + $t = new Wmic_Shell_Transport(); + + expect($t->query(wmi_stderrtest_request(wmi_stderrtest_wmic(['NTSTATUS: denied'], 1, "ERROR: Login to remote object.\n"))))->toBeFalse(); + expect($t->error())->toBe("ERROR: NTSTATUS: denied\nERROR: Login to remote object."); +}); + +it('uses stderr as the whole detail when stdout is empty, and keeps the old text without it', function () { + $t = new Wmic_Shell_Transport(); + + $t->query(wmi_stderrtest_request(wmi_stderrtest_wmic([], 1, "ERROR: Login to remote object.\n"))); + + expect($t->error())->toBe('ERROR: ERROR: Login to remote object.'); + + $t->query(wmi_stderrtest_request(wmi_stderrtest_wmic([], 3))); + + expect($t->error())->toBe('ERROR: '); +}); + +it('reports no data, with stderr when there is some', function () { + $t = new Wmic_Shell_Transport(); + + expect($t->query(wmi_stderrtest_request(wmi_stderrtest_wmic([], 0))))->toBeFalse(); + expect($t->error())->toBe('ERROR: WMI Returned no Data'); + + $t->query(wmi_stderrtest_request(wmi_stderrtest_wmic([], 0, "note\n"))); + + expect($t->error())->toBe("ERROR: WMI Returned no Data\nnote"); +}); + +it('caps a long stderr in the error text', function () { + $t = new Wmic_Shell_Transport(); + + $t->query(wmi_stderrtest_request(wmi_stderrtest_wmic(['x'], 1, str_repeat('e', 5000)))); + + expect(strlen($t->error()))->toBe(strlen("ERROR: x\n") + 2000); +}); + +it('stops a wmic that does not finish within the timeout', function () { + $t = new Wmic_Shell_Transport(1); + $began = microtime(true); + + expect($t->query(wmi_stderrtest_request(wmi_stderrtest_wmic([], 0, "still connecting\n", 10))))->toBeFalse(); + expect(microtime(true) - $began)->toBeLessThan(6.0); + expect($t->error())->toBe("ERROR: wmic did not finish within 1 seconds\nstill connecting"); +}); + +it('keeps blank lines inside the output, trims line ends and drops only the final line break', function () { + $t = new Wmic_Shell_Transport(); + + expect($t->query(wmi_stderrtest_request(wmi_stderrtest_wmic(['CLASS', '', 'A ']))))->toBe(['CLASS', '', 'A']); +}); + +it('reports a wmic that cannot be started', function () { + $t = new Wmi_Test_Unstartable_Transport(); + + expect($t->query(wmi_stderrtest_request('/usr/bin/wmic')))->toBeFalse(); + expect($t->error())->toBe('ERROR: Unable to start /usr/bin/wmic'); +}); + +it('fails with the shell message when the executable does not exist', function () { + $t = new Wmic_Shell_Transport(); + + expect($t->query(wmi_stderrtest_request('/nonexistent/wmic-for-tests')))->toBeFalse(); + expect($t->error())->toStartWith('ERROR: '); + expect($t->error())->toContain('wmic-for-tests'); +}); diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index 1ff317f..63f5f1a 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -169,7 +169,7 @@ function api_plugin_db_table_create($plugin, $table, $data) { if (!function_exists('read_config_option')) { function read_config_option($name, $force = false) { - return ''; + return $GLOBALS['__test_config'][$name] ?? ''; } } diff --git a/wmi_script.php b/wmi_script.php index 988da82..02844f2 100644 --- a/wmi_script.php +++ b/wmi_script.php @@ -26,7 +26,6 @@ require_once($config['base_path'] . '/lib/snmp.php'); if (!isset($called_by_script_server)) { - require_once(__DIR__ . '/../include/global.php'); array_shift($_SERVER['argv']); if (isset($_SERVER['argv'][0]) && $_SERVER['argv'][0] == 'wmi_script') { @@ -72,7 +71,7 @@ function wmi_script($hostname, $host_id, $wmiquery, $cmd = '', $arg1 = '', $arg2 $wmi = new Linux_WMI($host_id); $wmi->hostname = $hostname; - $wmi->binary = '/usr/bin/wmic'; + $wmi->binary = read_config_option('path_wmi') ?: '/usr/bin/wmic'; // Fetch the info for this WMI query from the database, exit if not found $wmiinfo = db_fetch_row_prepared('SELECT * diff --git a/wmi_tools.php b/wmi_tools.php index 5453f24..977a422 100644 --- a/wmi_tools.php +++ b/wmi_tools.php @@ -24,6 +24,7 @@ chdir('../../'); require('./include/auth.php'); +require_once($config['base_path'] . '/plugins/wmi/includes/functions.php'); require_once('./lib/snmp.php'); require_once('./lib/utility.php'); @@ -593,13 +594,22 @@ function walk_host() { $command = get_nfilter_request_var('command'); } - $host = strtolower($host); + $host = strtolower(trim($host)); if ($username == '' || $password == '' || $host == '') { print __('ERROR: You must provide a host, username, password and query', 'wmi'); exit; } + $valid = wmi_valid_host($host); + + if ($valid === false) { + print 'ERROR: The host must be an IPv4 or IPv6 address or a host name'; + exit; + } + + $host = $valid; + // Linux_WMI self-selects the PowerShell/CIM transport on a Windows // Cacti server and the wmic shell transport otherwise, so the same // client is used here regardless of $config['cacti_server_os']. @@ -680,22 +690,3 @@ function walk_host() { print nl2br(html_escape($wmi->error)); } } - -/** - * Validates that a string looks like a syntactically plausible hostname - * or IPv4 address (format only; does not perform DNS/network - * verification). Currently unused/dead code: not called from anywhere - * else in this file. - * - * @param string $host The candidate hostname or IP address. - * - * @return bool True when $host matches the expected hostname/IPv4 - * pattern. - */ -function is_valid_host($host) { - if (preg_match('/^((([0-9]{1,3}\.){3}[0-9]{1,3})|([0-9a-z-.]{0,61})?\.[a-z]{2,4})$/i', $host)) { - return true; - } - - return false; -}