From cdbea48773d4313385fe138eb2793eff1b047007 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:10:50 -0600 Subject: [PATCH 01/54] fix: bind the SNMP index in the graph link lookup Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + hmib.php | 15 ++++-- tests/Security/GraphUrlQueryTest.php | 68 ++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 tests/Security/GraphUrlQueryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d7fa63..3dd92f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* security: Bind the SNMP index as a query parameter in the graph link builder used by the Summary Processes view * issue: Fix the Fleet Dashboard Uptime Distribution showing all zeros by falling back to SNMP sysUpTime when the Host Resources hrSystemUptime is not reported, and apply the same fallback to the per-device Dashboard * issue: Stop the Dashboard card header from changing height when hovering the drag handle or tool buttons by reserving space for the theme's button hover border * feature: Make the Fleet Dashboard the first tab and the default landing view on the Host MIB interface diff --git a/hmib.php b/hmib.php index 3aac0c8..fa8be4a 100644 --- a/hmib.php +++ b/hmib.php @@ -3773,14 +3773,23 @@ function hmib_get_graph_url(int $data_query, int $host_type, int $host_id, strin } if (!empty($data_query)) { + $sql_params = [$data_query]; + + if ($index != '') { + $sql_index = ' AND gl.snmp_index = ?'; + $sql_params[] = $index; + } else { + $sql_index = ''; + } + $sql = "SELECT DISTINCT gl.id FROM graph_local AS gl - WHERE gl.snmp_query_id=$data_query " . - ($index!='' ? " AND gl.snmp_index IN ('$index')" : '') . + WHERE gl.snmp_query_id = ?" . + $sql_index . ($host_id!='' ? " AND gl.host_id=$host_id" : '') . ($hstr!='' ? " AND gl.host_id IN $hstr" : ''); - $graphs = db_fetch_assoc($sql); + $graphs = db_fetch_assoc_prepared($sql, $sql_params); $graph_add = ''; diff --git a/tests/Security/GraphUrlQueryTest.php b/tests/Security/GraphUrlQueryTest.php new file mode 100644 index 0000000..b549b7c --- /dev/null +++ b/tests/Security/GraphUrlQueryTest.php @@ -0,0 +1,68 @@ + $sql, 'params' => $params); + + return array(array('id' => 7)); + }); +}); + +it('binds the snmp index instead of embedding it in the SQL', function () { + $index = "x') OR ('1'='1"; + + $link = hmib_get_graph_url(12, 0, 0, $index, 'title', false); + + expect($GLOBALS['__graph_url_query'])->not->toBeNull(); + expect($GLOBALS['__graph_url_query']['sql'])->not->toContain('OR ('); + expect($GLOBALS['__graph_url_query']['params'])->toBe(array(12, $index)); + expect($link)->toContain('graph_add=7'); +}); + +it('adds no index condition when the index is empty', function () { + hmib_get_graph_url(12, 0, 0, '', 'title', false); + + expect($GLOBALS['__graph_url_query']['sql'])->not->toContain('snmp_index'); + expect($GLOBALS['__graph_url_query']['params'])->toBe(array(12)); +}); From eda418d343d21a05b6124373e904867ec3646fc6 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:11:47 -0600 Subject: [PATCH 02/54] fix: bind the OS type patterns in the rescan Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + hmib_types.php | 10 +-- tests/Security/RescanTypesBindingTest.php | 90 +++++++++++++++++++++++ 3 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 tests/Security/RescanTypesBindingTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dd92f1..3831283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* security: Bind the stored sysObjectID and sysDescr patterns in the OS type rescan and limit the match to devices that are still unclassified * security: Bind the SNMP index as a query parameter in the graph link builder used by the Summary Processes view * issue: Fix the Fleet Dashboard Uptime Distribution showing all zeros by falling back to SNMP sysUpTime when the Host Resources hrSystemUptime is not reported, and apply the same fallback to the per-device Dashboard * issue: Stop the Dashboard card header from changing height when hovering the drag handle or tool buttons by reserving space for the theme's button hover border diff --git a/hmib_types.php b/hmib_types.php index 135ff35..e74dc89 100644 --- a/hmib_types.php +++ b/hmib_types.php @@ -535,11 +535,11 @@ function rescan_types(): void { if (cacti_sizeof($known_types)) { foreach ($known_types as $known) { - db_execute('UPDATE plugin_hmib_hrSystem SET host_type=' . (int) $known['id'] . " - WHERE host_type=0 AND (sysObjectID LIKE '%" . $known['sysObjectID'] . "%' AND - sysDescr LIKE '%" . $known['sysDescrMatch'] . "%') - OR (sysObjectID RLIKE '" . $known['sysObjectID'] . "' AND - sysDescr RLIKE '" . $known['sysDescrMatch'] . "')"); + db_execute_prepared('UPDATE plugin_hmib_hrSystem SET host_type = ? + WHERE host_type = 0 + AND sysObjectID LIKE ? + AND sysDescr LIKE ?', + [$known['id'], '%' . $known['sysObjectID'] . '%', '%' . $known['sysDescrMatch'] . '%']); if (db_affected_rows() > 0) { $found = true; diff --git a/tests/Security/RescanTypesBindingTest.php b/tests/Security/RescanTypesBindingTest.php new file mode 100644 index 0000000..a244365 --- /dev/null +++ b/tests/Security/RescanTypesBindingTest.php @@ -0,0 +1,90 @@ + '.1.3.6', 'sysDescr' => 'Linux', 'host_type' => 0), + )); + + hmib_test_mock_db('db_fetch_assoc', 'SELECT id, sysDescrMatch', array( + array('id' => '4', 'sysDescrMatch' => "it's a 'quote'", 'sysObjectID' => ".1.3.6.1.4.1.8072"), + )); +}); + +it('binds the stored match patterns when assigning a type', function () { + rescan_types(); + + $updates = array_values(array_filter($GLOBALS['__test_db_calls'], function ($call) { + return strpos($call['sql'], 'UPDATE plugin_hmib_hrSystem') !== false; + })); + + expect($updates)->toHaveCount(1); + expect($updates[0]['fn'])->toBe('db_execute_prepared'); + expect($updates[0]['params'])->toBe(array('4', '%.1.3.6.1.4.1.8072%', "%it's a 'quote'%")); +}); + +it('never places a stored pattern in the SQL text', function () { + rescan_types(); + + foreach ($GLOBALS['__test_db_calls'] as $call) { + expect($call['sql'])->not->toContain("it's a"); + expect($call['sql'])->not->toContain('8072'); + } +}); + +it('only reassigns devices that are still unclassified', function () { + rescan_types(); + + $updates = array_values(array_filter($GLOBALS['__test_db_calls'], function ($call) { + return strpos($call['sql'], 'UPDATE plugin_hmib_hrSystem') !== false; + })); + + expect($updates[0]['sql'])->toContain('host_type = 0'); + expect($updates[0]['sql'])->not->toContain(' OR '); + expect($updates[0]['sql'])->not->toContain('RLIKE'); +}); From 8229fffea255060719ee8edb56a712d5f301a0b7 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:14:28 -0600 Subject: [PATCH 03/54] fix: treat device id 0 as no device filter in the graph link lookup Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + hmib.php | 2 +- tests/Security/GraphUrlQueryTest.php | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3831283..66e333f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* issue: Fix the Summary tab dropping the data-query graph links on PHP 8 because a device id of 0 was treated as a device filter * security: Bind the stored sysObjectID and sysDescr patterns in the OS type rescan and limit the match to devices that are still unclassified * security: Bind the SNMP index as a query parameter in the graph link builder used by the Summary Processes view * issue: Fix the Fleet Dashboard Uptime Distribution showing all zeros by falling back to SNMP sysUpTime when the Host Resources hrSystemUptime is not reported, and apply the same fallback to the per-device Dashboard diff --git a/hmib.php b/hmib.php index fa8be4a..a542a50 100644 --- a/hmib.php +++ b/hmib.php @@ -3786,7 +3786,7 @@ function hmib_get_graph_url(int $data_query, int $host_type, int $host_id, strin FROM graph_local AS gl WHERE gl.snmp_query_id = ?" . $sql_index . - ($host_id!='' ? " AND gl.host_id=$host_id" : '') . + ($host_id != 0 ? " AND gl.host_id=$host_id" : '') . ($hstr!='' ? " AND gl.host_id IN $hstr" : ''); $graphs = db_fetch_assoc_prepared($sql, $sql_params); diff --git a/tests/Security/GraphUrlQueryTest.php b/tests/Security/GraphUrlQueryTest.php index b549b7c..12a6f81 100644 --- a/tests/Security/GraphUrlQueryTest.php +++ b/tests/Security/GraphUrlQueryTest.php @@ -66,3 +66,17 @@ function hmib_test_define_function($file, $name) { expect($GLOBALS['__graph_url_query']['sql'])->not->toContain('snmp_index'); expect($GLOBALS['__graph_url_query']['params'])->toBe(array(12)); }); + +it('does not restrict by device when no device is given', function () { + hmib_get_graph_url(12, 0, 0, '', 'title', false); + + expect($GLOBALS['__graph_url_query']['sql'])->not->toContain('gl.host_id'); +}); + +it('restricts to one device, including the no-device sentinel', function () { + hmib_get_graph_url(12, 0, 5, '', 'title', false); + expect($GLOBALS['__graph_url_query']['sql'])->toContain('gl.host_id=5'); + + hmib_get_graph_url(12, 0, -1, '', 'title', false); + expect($GLOBALS['__graph_url_query']['sql'])->toContain('gl.host_id=-1'); +}); From add1a064986341224f65d60887eaaad8d3e70ef3 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:15:47 -0600 Subject: [PATCH 04/54] fix: give the Summary Processes count query its base WHERE clause Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + hmib.php | 5 ++-- tests/Security/SummaryProcessCountTest.php | 29 ++++++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 tests/Security/SummaryProcessCountTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 66e333f..5e0ed1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* issue: Fix the Summary Processes filter failing with a SQL error because the page count query had no WHERE clause * issue: Fix the Summary tab dropping the data-query graph links on PHP 8 because a device id of 0 was treated as a device filter * security: Bind the stored sysObjectID and sysDescr patterns in the OS type rescan and limit the match to devices that are still unclassified * security: Bind the SNMP index as a query parameter in the graph link builder used by the Summary Processes view diff --git a/hmib.php b/hmib.php index a542a50..d4d821c 100644 --- a/hmib.php +++ b/hmib.php @@ -3511,8 +3511,9 @@ function clearProcFilter() { $rows = db_fetch_assoc_prepared($sql, $sql_params); - $total_rows = db_fetch_cell_prepared("SELECT COUNT(DISTINCT name) - FROM plugin_hmib_hrSWRun + $total_rows = db_fetch_cell_prepared("SELECT COUNT(DISTINCT hrswr.name) + FROM plugin_hmib_hrSWRun AS hrswr + WHERE hrswr.name!='System Idle Process' AND hrswr.name!='' $sql_where", $sql_params); $display_text = [ diff --git a/tests/Security/SummaryProcessCountTest.php b/tests/Security/SummaryProcessCountTest.php new file mode 100644 index 0000000..c8f3a19 --- /dev/null +++ b/tests/Security/SummaryProcessCountTest.php @@ -0,0 +1,29 @@ +not->toBe(array()); + expect($count)->not->toBe(array()); + expect($fragment[0])->toContain('hrswr.name LIKE'); + expect($count[0])->toContain('FROM plugin_hmib_hrSWRun AS hrswr'); + expect($count[0])->toContain("WHERE hrswr.name!='System Idle Process' AND hrswr.name!=''"); + expect($count[0])->toContain('$sql_where'); +}); From 2dde9564eecd97bb9a41eec60d0c6512e3815159 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:16:55 -0600 Subject: [PATCH 05/54] fix: correct the processes link on the Devices tab Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + hmib.php | 2 +- tests/Security/DeviceLinksTest.php | 30 ++++++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 tests/Security/DeviceLinksTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e0ed1e..bc2c1d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* issue: Fix the Devices tab processes glyph opening an empty page because of a malformed action value in its link * issue: Fix the Summary Processes filter failing with a SQL error because the page count query had no WHERE clause * issue: Fix the Summary tab dropping the data-query graph links on PHP 8 because a device id of 0 was treated as a device filter * security: Bind the stored sysObjectID and sysDescr patterns in the OS type rescan and limit the match to devices that are still unclassified diff --git a/hmib.php b/hmib.php index d4d821c..0c8129c 100644 --- a/hmib.php +++ b/hmib.php @@ -2409,7 +2409,7 @@ function clearFilter() { "; $aurl .= " + href='" . html_escape("$url?action=running&reset=1&device=" . $row['host_id']) . "'> "; diff --git a/tests/Security/DeviceLinksTest.php b/tests/Security/DeviceLinksTest.php new file mode 100644 index 0000000..31c4872 --- /dev/null +++ b/tests/Security/DeviceLinksTest.php @@ -0,0 +1,30 @@ +not->toBe(array()); + + $known = array('summary', 'running', 'hardware', 'storage', 'devices', 'dashboard', 'summary_dashboard', 'history', 'software', 'graphs'); + + foreach ($matches[1] as $action) { + expect($known)->toContain($action); + } + + expect($source)->not->toContain('%action='); +}); From 7bdb0d243232029d06e3c7e14314a6cb34d9cb53 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:18:00 -0600 Subject: [PATCH 06/54] fix: limit the Summary sort column to the sortable columns Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + hmib.php | 34 ++++++++++++++++- tests/Security/SummaryOrderByTest.php | 54 +++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 tests/Security/SummaryOrderByTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index bc2c1d9..438f770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* security: Restrict the Summary tab sort column to the columns each table can sort on and limit the direction to ASC or DESC * issue: Fix the Devices tab processes glyph opening an empty page because of a malformed action value in its link * issue: Fix the Summary Processes filter failing with a SQL error because the page count query had no WHERE clause * issue: Fix the Summary tab dropping the data-query graph links on PHP 8 because a device id of 0 was treated as a device filter diff --git a/hmib.php b/hmib.php index 0c8129c..15f1977 100644 --- a/hmib.php +++ b/hmib.php @@ -2970,6 +2970,26 @@ function hmib_tabs(): void { print ''; } +/** + * Builds the ORDER BY clause for a Summary table from the stored sort + * request. The column is accepted only when the table can sort on it, so a + * stored or posted value never reaches the SQL text unchecked. + * + * @param string $column The requested sort column. + * @param string $direction The requested sort direction. + * @param array $allowed The columns the table can sort on. + * @param string $default The column used when $column is not allowed. + * + * @return string The ORDER BY clause. + */ +function hmib_summary_order_by(string $column, string $direction, array $allowed, string $default): string { + if (!in_array($column, $allowed, true)) { + $column = $default; + } + + return 'ORDER BY ' . $column . ' ' . (strtoupper($direction) === 'ASC' ? 'ASC' : 'DESC'); +} + /** * Renders the Host MIB dashboard/summary view: an overview of monitored * device counts by status/OS type, top running processes, and other @@ -3203,7 +3223,12 @@ function clearHostFilter() { $sql_limit = 'LIMIT 20'; } - $sql_order = ' ORDER BY ' . $_SESSION['sess_hmib_host_sort_column'] . ' ' . $_SESSION['sess_hmib_host_sort_direction']; + $sql_order = hmib_summary_order_by( + (string) $_SESSION['sess_hmib_host_sort_column'], + (string) $_SESSION['sess_hmib_host_sort_direction'], + ['name', 'version/1', '(version/1)', 'upHosts', 'recHosts', 'downHosts', 'disabledHosts', 'users', 'cpus', 'avgCpuPercent', 'maxCpuPercent', 'avgMem', 'maxMem', 'avgSwap', 'maxSwap', 'avgProcesses', 'maxProcesses'], + 'upHosts' + ); $sql = "SELECT hrst.id AS id, @@ -3482,7 +3507,12 @@ function clearProcFilter() { $sql_where = ''; $sql_params = []; $sql_limit = 'LIMIT ' . ($num_rows * (get_request_var('page') - 1)) . ',' . $num_rows; - $sql_order = 'ORDER BY ' . $_SESSION['sess_hmib_proc_sort_column'] . ' ' . $_SESSION['sess_hmib_proc_sort_direction']; + $sql_order = hmib_summary_order_by( + (string) $_SESSION['sess_hmib_proc_sort_column'], + (string) $_SESSION['sess_hmib_proc_sort_direction'], + ['name', 'paths', 'numHosts', 'numProcesses', 'avgCpu', 'maxCpu', 'avgMemory', 'maxMemory'], + 'maxCpu' + ); if (strlen(get_request_var('filter'))) { $sql_where = 'AND ( diff --git a/tests/Security/SummaryOrderByTest.php b/tests/Security/SummaryOrderByTest.php new file mode 100644 index 0000000..1c3d93d --- /dev/null +++ b/tests/Security/SummaryOrderByTest.php @@ -0,0 +1,54 @@ +toBe('ORDER BY maxCpu ASC'); + expect(hmib_summary_order_by('name', 'desc', array('maxCpu', 'name'), 'maxCpu'))->toBe('ORDER BY name DESC'); +}); + +it('falls back to the default column for a column the table cannot sort on', function () { + expect(hmib_summary_order_by('not_a_column desc', 'ASC', array('maxCpu', 'name'), 'maxCpu'))->toBe('ORDER BY maxCpu ASC'); +}); + +it('falls back to DESC for an unknown direction', function () { + expect(hmib_summary_order_by('name', 'sideways', array('name'), 'name'))->toBe('ORDER BY name DESC'); +}); + +it('no longer concatenates the stored sort values into the Summary queries', function () { + $source = plugin_test_read_source('hmib.php'); + + expect($source)->not->toContain("ORDER BY ' . \$_SESSION['sess_hmib"); + expect($source)->toContain('hmib_summary_order_by('); +}); From b1363fe43aa0dddcfccd17014c67155de600ddf1 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:19:06 -0600 Subject: [PATCH 07/54] fix: escape host type fields in the OS type list Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + hmib.php | 2 +- hmib_types.php | 8 ++--- tests/Security/HostTypeOutputEscapingTest.php | 35 +++++++++++++++++++ 4 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 tests/Security/HostTypeOutputEscapingTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 438f770..5c2abc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* security: Escape the OS type name, version, sysObjectID and sysDescr match when listing host types and the type version on the Summary tab * security: Restrict the Summary tab sort column to the columns each table can sort on and limit the direction to ASC or DESC * issue: Fix the Devices tab processes glyph opening an empty page because of a malformed action value in its link * issue: Fix the Summary Processes filter failing with a SQL error because the page count query had no WHERE clause diff --git a/hmib.php b/hmib.php index 15f1977..6cc8d3e 100644 --- a/hmib.php +++ b/hmib.php @@ -3393,7 +3393,7 @@ function clearHostFilter() { form_selectable_cell(($row['name'] != '' ? html_escape($row['name']) : __('Unknown', 'hmib')), $id); - form_selectable_cell($row['version'], $id, '', 'right'); + form_selectable_cell(html_escape($row['version']), $id, '', 'right'); form_selectable_cell($upHosts, $id, '', 'right'); form_selectable_cell($recHosts, $id, '', 'right'); form_selectable_cell($downHosts, $id, '', 'right'); diff --git a/hmib_types.php b/hmib_types.php index e74dc89..37485ec 100644 --- a/hmib_types.php +++ b/hmib_types.php @@ -1131,11 +1131,11 @@ function hmib_host_type(): void { if (cacti_sizeof($host_types)) { foreach ($host_types as $host_type) { form_alternate_row('line' . $host_type['id'], true); - form_selectable_cell('' . $host_type['name'] . '', $host_type['id']); - form_selectable_cell($host_type['version'], $host_type['id']); + form_selectable_cell('' . html_escape($host_type['name']) . '', $host_type['id']); + form_selectable_cell(html_escape($host_type['version']), $host_type['id']); form_selectable_cell($host_type['totals'], $host_type['id']); - form_selectable_cell($host_type['sysObjectID'], $host_type['id']); - form_selectable_cell($host_type['sysDescrMatch'], $host_type['id']); + form_selectable_cell(html_escape($host_type['sysObjectID']), $host_type['id']); + form_selectable_cell(html_escape($host_type['sysDescrMatch']), $host_type['id']); form_checkbox_cell($host_type['name'], $host_type['id']); form_end_row(); } diff --git a/tests/Security/HostTypeOutputEscapingTest.php b/tests/Security/HostTypeOutputEscapingTest.php new file mode 100644 index 0000000..5aec360 --- /dev/null +++ b/tests/Security/HostTypeOutputEscapingTest.php @@ -0,0 +1,35 @@ +not->toBe(array()); + + foreach (array('name', 'version', 'sysObjectID', 'sysDescrMatch') as $field) { + expect($loop[0])->toContain("html_escape(\$host_type['" . $field . "'])"); + } + + expect($loop[0])->not->toMatch('/form_selectable_cell\(\$host_type\[\'(version|sysObjectID|sysDescrMatch)\'\]/'); +}); + +it('escapes the host type version on the Summary tab', function () { + $source = plugin_test_read_source('hmib.php'); + + expect($source)->toContain("form_selectable_cell(html_escape(\$row['version'])"); + expect($source)->not->toContain("form_selectable_cell(\$row['version']"); +}); From 5aeca6939a67276de9e2392265b552dcda3a1709 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:20:03 -0600 Subject: [PATCH 08/54] fix: collapse duplicate placeholder types in the OS type rescan Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + hmib_types.php | 9 ++- tests/Security/RescanTypesBindingTest.php | 2 +- tests/Security/RescanTypesInsertTest.php | 90 +++++++++++++++++++++++ 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 tests/Security/RescanTypesInsertTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c2abc1..40a5e46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* issue: Fix the OS type rescan failing with a duplicate key error when several unclassified devices report the same sysDescr and sysObjectID, and stop it creating an empty match type * security: Escape the OS type name, version, sysObjectID and sysDescr match when listing host types and the type version on the Summary tab * security: Restrict the Summary tab sort column to the columns each table can sort on and limit the direction to ASC or DESC * issue: Fix the Devices tab processes glyph opening an empty page because of a malformed action value in its link diff --git a/hmib_types.php b/hmib_types.php index 37485ec..01b9d23 100644 --- a/hmib_types.php +++ b/hmib_types.php @@ -552,9 +552,14 @@ function rescan_types(): void { } // update the host types from unknown_host_types that have a value of 0 - db_execute("INSERT INTO plugin_hmib_hrSystemTypes + db_execute_prepared("INSERT IGNORE INTO plugin_hmib_hrSystemTypes (name, version, sysDescrMatch, sysObjectID) - SELECT '$new_name', '$new_version', sysDescr, sysObjectID FROM plugin_hmib_hrSystem WHERE host_type=0"); + SELECT DISTINCT ?, ?, LEFT(sysDescr, 100), LEFT(sysObjectID, 100) + FROM plugin_hmib_hrSystem + WHERE host_type = 0 + AND sysDescr != '' + AND sysObjectID != ''", + [$new_name, $new_version]); $new_types = db_affected_rows(); diff --git a/tests/Security/RescanTypesBindingTest.php b/tests/Security/RescanTypesBindingTest.php index a244365..d99da96 100644 --- a/tests/Security/RescanTypesBindingTest.php +++ b/tests/Security/RescanTypesBindingTest.php @@ -35,7 +35,7 @@ function hmib_test_define_function($file, $name) { if (!function_exists('db_affected_rows')) { function db_affected_rows() { - return 0; + return $GLOBALS['__affected_rows'] ?? 0; } } diff --git a/tests/Security/RescanTypesInsertTest.php b/tests/Security/RescanTypesInsertTest.php new file mode 100644 index 0000000..62dbd12 --- /dev/null +++ b/tests/Security/RescanTypesInsertTest.php @@ -0,0 +1,90 @@ +not->toBeNull(); + expect($insert['fn'])->toBe('db_execute_prepared'); + expect($insert['sql'])->toContain('INSERT IGNORE'); + expect($insert['sql'])->toContain('SELECT DISTINCT'); + expect($insert['sql'])->toContain('LEFT(sysDescr, 100)'); + expect($insert['sql'])->toContain('LEFT(sysObjectID, 100)'); + expect($insert['sql'])->toContain("sysDescr != ''"); + expect($insert['sql'])->toContain("sysObjectID != ''"); + expect($insert['params'])->toBe(array('New Type', 'Unknown')); +}); + +it('reports the number of types that were added', function () { + $GLOBALS['__affected_rows'] = 3; + + rescan_types(); + + expect($_SESSION['hmib_message'])->toContain('Device Types Added'); +}); + +it('reports when no new types were found', function () { + rescan_types(); + + expect($_SESSION['hmib_message'])->toBe('No New Host Types Found!'); +}); From 3cc54113be744d2d5e32cc7d8035000cbc5aa8f4 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:21:07 -0600 Subject: [PATCH 09/54] fix: run the OS type rescan from a POST request Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + hmib_types.php | 7 +++-- tests/Security/RescanRequestMethodTest.php | 32 ++++++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 tests/Security/RescanRequestMethodTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 40a5e46..b47f90c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* security: Run the OS type rescan from a POST request with a CSRF token instead of a GET link * issue: Fix the OS type rescan failing with a duplicate key error when several unclassified devices report the same sysDescr and sysObjectID, and stop it creating an empty match type * security: Escape the OS type name, version, sysObjectID and sysDescr match when listing host types and the type version on the Summary tab * security: Restrict the Summary tab sort column to the columns each table can sort on and limit the direction to ASC or DESC diff --git a/hmib_types.php b/hmib_types.php index 01b9d23..8dbd549 100644 --- a/hmib_types.php +++ b/hmib_types.php @@ -56,7 +56,7 @@ break; default: - if (isset_request_var('scan')) { + if (isset_request_var('scan') && $_SERVER['REQUEST_METHOD'] === 'POST') { rescan_types(); header('Location: hmib_types.php?header=false'); exit; @@ -1260,8 +1260,9 @@ function clearFilter() { } function rescanTypes() { - strURL = '?scan=true'; - loadPageNoHeader(strURL); + $.post('hmib_types.php', {scan: 'true', __csrf_magic: csrfMagicToken}).always(function() { + loadPageNoHeader('hmib_types.php?header=false'); + }); } function importTypes() { diff --git a/tests/Security/RescanRequestMethodTest.php b/tests/Security/RescanRequestMethodTest.php new file mode 100644 index 0000000..7bb2e59 --- /dev/null +++ b/tests/Security/RescanRequestMethodTest.php @@ -0,0 +1,32 @@ +toMatch('/if \(isset_request_var\(\'scan\'\) && \$_SERVER\[\'REQUEST_METHOD\'\] === \'POST\'\) \{\s*rescan_types\(\);/'); +}); + +it('starts the rescan from the page with a POST that carries the CSRF token', function () { + $source = plugin_test_read_source('hmib_types.php'); + + preg_match('/function rescanTypes\(\) \{.*?\n\t\}/s', $source, $script); + + expect($script)->not->toBe(array()); + expect($script[0])->toContain('$.post('); + expect($script[0])->toContain('__csrf_magic: csrfMagicToken'); + expect($source)->not->toContain("strURL = '?scan=true'"); +}); From 1880bea4c39f8d8e0e4c9e7c4a2a4c9f96e52ccb Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:21:50 -0600 Subject: [PATCH 10/54] fix: quote host type CSV export fields Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + hmib_types.php | 31 +++++++++--- tests/Security/HostTypeCsvExportTest.php | 62 ++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 tests/Security/HostTypeCsvExportTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b47f90c..ff7eecf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* issue: Fix the host type CSV export producing broken rows for values that contain a double quote, and open values that look like formulas as text * security: Run the OS type rescan from a POST request with a CSRF token instead of a GET link * issue: Fix the OS type rescan failing with a duplicate key error when several unclassified devices report the same sysDescr and sysObjectID, and stop it creating an empty match type * security: Escape the OS type name, version, sysObjectID and sysDescr match when listing host types and the type version on the Summary tab diff --git a/hmib_types.php b/hmib_types.php index 8dbd549..a9d38ff 100644 --- a/hmib_types.php +++ b/hmib_types.php @@ -444,6 +444,25 @@ function hmib_validate_request_vars(): void { // ================= input validation ================= } +/** + * Quotes one value for the host type CSV export. Embedded quotes are doubled, + * and a value that a spreadsheet would read as a formula gets a leading quote + * so it opens as text. + * + * @param mixed $value The value to export. + * + * @return string The quoted CSV field. + */ +function hmib_csv_field(mixed $value): string { + $value = (string) $value; + + if ($value !== '' && strpos("=+-@\t\r", $value[0]) !== false) { + $value = "'" . $value; + } + + return '"' . str_replace('"', '""', $value) . '"'; +} + /** * Exports all host types matching the current filter as a downloadable * CSV file. Called from this script's main request-dispatch switch @@ -476,12 +495,12 @@ function hmib_host_type_export(): void { if (cacti_sizeof($host_types)) { foreach ($host_types as $host_type) { - array_push($xport_array,'"' . - $host_type['id'] . '","' . - $host_type['name'] . '","' . - $host_type['version'] . '","' . - $host_type['sysDescrMatch'] . '","' . - $host_type['sysObjectID'] . '"'); + array_push($xport_array, + hmib_csv_field($host_type['id']) . ',' . + hmib_csv_field($host_type['name']) . ',' . + hmib_csv_field($host_type['version']) . ',' . + hmib_csv_field($host_type['sysDescrMatch']) . ',' . + hmib_csv_field($host_type['sysObjectID'])); } } diff --git a/tests/Security/HostTypeCsvExportTest.php b/tests/Security/HostTypeCsvExportTest.php new file mode 100644 index 0000000..cb706e4 --- /dev/null +++ b/tests/Security/HostTypeCsvExportTest.php @@ -0,0 +1,62 @@ +toBe('"Linux ""server"", build 2"'); + expect(str_getcsv($field, ',', '"', ''))->toBe(array('Linux "server", build 2')); +}); + +it('quotes plain values and integers', function () { + expect(hmib_csv_field('.1.3.6.1.4.1'))->toBe('".1.3.6.1.4.1"'); + expect(hmib_csv_field(12))->toBe('"12"'); + expect(hmib_csv_field(''))->toBe('""'); +}); + +it('marks values that start like a spreadsheet formula as text', function () { + foreach (array('=SUM', '+1', '-1', '@name') as $value) { + expect(hmib_csv_field($value))->toBe('"\'' . $value . '"'); + } +}); + +it('builds each export row from the field helper', function () { + $source = plugin_test_read_source('hmib_types.php'); + + preg_match('/function hmib_host_type_export\(\).*?\n}/s', $source, $function); + + expect($function[0])->toContain("hmib_csv_field(\$host_type['sysDescrMatch'])"); + expect($function[0])->not->toContain("\$host_type['name'] . '\",\"'"); +}); From 6bb8bf81face59b404c3767c0f51b5d9c702aee1 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:23:05 -0600 Subject: [PATCH 11/54] fix: check the processes graph template before adding the processes graph Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + poller_graphs.php | 2 +- tests/Unit/PollerGraphsTest.php | 120 ++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/PollerGraphsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ff7eecf..be621f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* issue: Fix graph automation checking the users graph template instead of the processes graph template before adding the processes graph * issue: Fix the host type CSV export producing broken rows for values that contain a double quote, and open values that look like formulas as text * security: Run the OS type rescan from a POST request with a CSRF token instead of a GET link * issue: Fix the OS type rescan failing with a duplicate key error when several unclassified devices report the same sysDescr and sysObjectID, and stop it creating an empty match type diff --git a/poller_graphs.php b/poller_graphs.php index 9838128..72d5127 100644 --- a/poller_graphs.php +++ b/poller_graphs.php @@ -217,7 +217,7 @@ function add_host_based_graphs(): void { debug('Users Graph Template Not Set'); } - if ($host_users_gt) { + if ($host_procs_gt) { debug('Processing Processes'); hmib_gt_graph($h['host_id'], $host_procs_gt); } else { diff --git a/tests/Unit/PollerGraphsTest.php b/tests/Unit/PollerGraphsTest.php new file mode 100644 index 0000000..a8a24b4 --- /dev/null +++ b/tests/Unit/PollerGraphsTest.php @@ -0,0 +1,120 @@ + '9', 'description' => 'host nine'), + )); +}); + +function hmib_test_templates($users, $procs) { + hmib_test_mock_db('db_fetch_cell', "hash='e8462bbe094e4e9e814d4e681671ea82'", $users); + hmib_test_mock_db('db_fetch_cell', "hash='62205afbd4066e5c4700338841e3901e'", $procs); +} + +it('adds the users and processes graph templates for a device', function () { + hmib_test_templates('11', '12'); + + add_host_based_graphs(); + + expect($GLOBALS['__graph_calls'])->toBe(array(array('gt', '9', '11'), array('gt', '9', '12'))); +}); + +it('does not add the processes graph when only the users template exists', function () { + hmib_test_templates('11', false); + + add_host_based_graphs(); + + expect($GLOBALS['__graph_calls'])->toBe(array(array('gt', '9', '11'))); +}); + +it('adds the processes graph when only the processes template exists', function () { + hmib_test_templates(false, '12'); + + add_host_based_graphs(); + + expect($GLOBALS['__graph_calls'])->toBe(array(array('gt', '9', '12'))); +}); + +it('adds the disk and processor data query graphs when those queries exist', function () { + hmib_test_templates(false, false); + hmib_test_mock_db('db_fetch_cell', "hash='0d1ab53fe37487a5d0b9e1d3ee8c1d0d'", '21'); + hmib_test_mock_db('db_fetch_cell', "hash='9343eab1f4d88b0e61ffc9d020f35414'", '22'); + + add_host_based_graphs(); + + expect($GLOBALS['__graph_calls'])->toBe(array(array('dq', '9', '22'), array('dq', '9', '21'))); +}); From b961dcf08dc7c88dfdcda52aa3b86fbeac03dd54 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:25:29 -0600 Subject: [PATCH 12/54] fix: look up the summary device after creating it Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + poller_graphs.php | 5 +++++ tests/Unit/PollerGraphsTest.php | 34 +++++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index be621f0..f6a9429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* issue: Fix graph automation not creating the Host MIB summary device graphs until the next scheduled run after the device is first created * issue: Fix graph automation checking the users graph template instead of the processes graph template before adding the processes graph * issue: Fix the host type CSV export producing broken rows for values that contain a double quote, and open values that look like formulas as text * security: Run the OS type rescan from a POST request with a CSRF token instead of a GET link diff --git a/poller_graphs.php b/poller_graphs.php index 72d5127..df10447 100644 --- a/poller_graphs.php +++ b/poller_graphs.php @@ -402,6 +402,11 @@ function add_summary_graphs(int $host_id, int $host_template): void { // add the host debug('Adding Host'); $result = exec("$php_bin -q $base/cli/add_device.php --description='Summary Device' --ip=summary --template=$host_template --version=0 --avail=none", $return_code); + + $host_id = (int) db_fetch_cell_prepared('SELECT id + FROM host + WHERE host_template_id = ?', + [$host_template]); } else { debug('Reindexing Host'); $result = exec("$php_bin -q $base/cli/poller_reindex_hosts.php -id=$host_id -qid=All", $return_code); diff --git a/tests/Unit/PollerGraphsTest.php b/tests/Unit/PollerGraphsTest.php index a8a24b4..ab7198f 100644 --- a/tests/Unit/PollerGraphsTest.php +++ b/tests/Unit/PollerGraphsTest.php @@ -118,3 +118,37 @@ function hmib_test_templates($users, $procs) { expect($GLOBALS['__graph_calls'])->toBe(array(array('dq', '9', '22'), array('dq', '9', '21'))); }); + +it('builds the summary graphs for the device it just created', function () { + $queries = array(); + + hmib_test_mock_db('db_fetch_cell_prepared', 'FROM host', '42'); + hmib_test_mock_db('db_fetch_assoc_prepared', function ($sql, $params) use (&$queries) { + $queries[] = array($sql, $params); + + return false; + }, array()); + + add_summary_graphs(0, 5); + + $data_query_lookup = array_values(array_filter($queries, function ($query) { + return strpos($query[0], 'FROM host_snmp_query') !== false; + })); + + expect($data_query_lookup)->toHaveCount(1); + expect($data_query_lookup[0][1])->toBe(array(42)); +}); + +it('keeps the existing summary device when there is one', function () { + $queries = array(); + + hmib_test_mock_db('db_fetch_assoc_prepared', function ($sql, $params) use (&$queries) { + $queries[] = array($sql, $params); + + return false; + }, array()); + + add_summary_graphs(7, 5); + + expect($queries[0][1])->toBe(array(7)); +}); From e85ee3d39d19f94958394fd775d73d5dea65daaf Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:26:31 -0600 Subject: [PATCH 13/54] fix: add to the time seen total only when process performance was collected Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + poller_hmib.php | 10 +- tests/Unit/PollerHmibCheckHostTest.php | 145 +++++++++++++++++++++++++ tests/bootstrap-unit.php | 2 +- 4 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/PollerHmibCheckHostTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f6a9429..3286846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* issue: Fix the process Time Seen total growing on every poller cycle instead of once per process performance collection * issue: Fix graph automation not creating the Host MIB summary device graphs until the next scheduled run after the device is first created * issue: Fix graph automation checking the users graph template instead of the processes graph template before adding the processes graph * issue: Fix the host type CSV export producing broken rows for values that contain a double quote, and open values that look like formulas as text diff --git a/poller_hmib.php b/poller_hmib.php index 371264b..d57111a 100644 --- a/poller_hmib.php +++ b/poller_hmib.php @@ -770,9 +770,13 @@ function checkHost(int $host_id): void { collect_hrSWRun($host); } + $perf_seconds = 0; + if (runCollector($start, $hrSWRunPerf_lastrun, $hrSWRunPerf_freq)) { cacti_log(sprintf('Running Running Process Performance Collection for Device[%s]', $host['id']), false, 'HMIB', POLLER_VERBOSITY_MEDIUM); collect_hrSWRunPerf($host); + + $perf_seconds = (int) $hrSWRunPerf_freq; } if (runCollector($start, $hrSWInstalled_lastrun, $hrSWInstalled_freq)) { @@ -794,15 +798,15 @@ function checkHost(int $host_id): void { $time = substr((string) time(), 0, 3); // update the most recent table - db_execute_prepared('INSERT INTO plugin_hmib_hrSWRun_last_seen (host_id, name, total_time) - SELECT DISTINCT host_id, name, ' . read_config_option('hmib_hrSWRunPerf_freq') . " AS `total_time` + db_execute_prepared("INSERT INTO plugin_hmib_hrSWRun_last_seen (host_id, name, total_time) + SELECT DISTINCT host_id, name, ? AS `total_time` FROM plugin_hmib_hrSWRun WHERE host_id = ? AND name NOT LIKE '$time%' ON DUPLICATE KEY UPDATE last_seen=NOW(), total_time=total_time+VALUES(total_time)", - [$host['id']]); + [$perf_seconds, $host['id']]); // remove the process lock db_execute_prepared('DELETE FROM plugin_hmib_processes diff --git a/tests/Unit/PollerHmibCheckHostTest.php b/tests/Unit/PollerHmibCheckHostTest.php new file mode 100644 index 0000000..30b3399 --- /dev/null +++ b/tests/Unit/PollerHmibCheckHostTest.php @@ -0,0 +1,145 @@ + '5', 'description' => 'host five', 'hostname' => 'h5')); +}); + +afterEach(function () { + $GLOBALS['__test_config'] = array(); +}); + +function hmib_test_last_seen_call() { + foreach ($GLOBALS['__test_db_calls'] as $call) { + if (strpos($call['sql'], 'plugin_hmib_hrSWRun_last_seen') !== false && strpos($call['sql'], 'INSERT') !== false) { + return $call; + } + } + + return null; +} + +it('treats a missing last run or frequency as not due unless the run is forced', function () { + expect(runCollector(100.0, null, null))->toBeFalse(); + expect(runCollector(100.0, null, 60))->toBeTrue(); + expect(runCollector(100.0, 90.0, 60))->toBeFalse(); + expect(runCollector(200.0, 90.0, 60))->toBeTrue(); + + $GLOBALS['forcerun'] = true; + + expect(runCollector(100.0, 90.0, 60))->toBeTrue(); +}); + +it('adds the performance interval to the time seen total when the performance collector ran', function () { + $GLOBALS['__test_config'] = array_merge(hmib_test_collector_defaults(), array('hmib_hrSWRunPerf_freq' => '300', 'hmib_hrSWRunPerf_lastrun' => '0')); + + checkHost(5); + + expect($GLOBALS['__collected'])->toContain('hrSWRunPerf'); + expect(hmib_test_last_seen_call()['params'])->toBe(array(300, '5')); +}); + +it('adds nothing to the time seen total when the performance collector was not due', function () { + $GLOBALS['__test_config'] = array_merge(hmib_test_collector_defaults(), array('hmib_hrSWRunPerf_freq' => '3600', 'hmib_hrSWRunPerf_lastrun' => '999900')); + + checkHost(5); + + expect($GLOBALS['__collected'])->not->toContain('hrSWRunPerf'); + expect(hmib_test_last_seen_call()['params'])->toBe(array(0, '5')); +}); + +it('runs each collector only when its own interval is due', function () { + $GLOBALS['__test_config'] = array_merge(hmib_test_collector_defaults(), array( + 'hmib_hrDevices_freq' => '60', + 'hmib_hrDevices_lastrun' => '0', + 'hmib_hrStorage_freq' => '3600', + 'hmib_hrStorage_lastrun' => '999900', + )); + + checkHost(5); + + expect($GLOBALS['__collected'])->toBe(array('hrSystem', 'hrDevices')); +}); + +it('removes the launch placeholder and registers its own process lock', function () { + $GLOBALS['key'] = '123'; + + checkHost(5); + + $sql = implode("\n", array_column($GLOBALS['__test_db_calls'], 'sql')); + + expect($sql)->toContain('DELETE FROM plugin_hmib_processes WHERE pid=123'); + expect($sql)->toContain('REPLACE INTO plugin_hmib_processes'); +}); + +it('does nothing for a device that no longer exists', function () { + hmib_test_reset_db_mocks(); + hmib_test_mock_db('db_fetch_row_prepared', 'FROM host', false); + + checkHost(5); + + expect($GLOBALS['__collected'])->toBe(array()); + expect(hmib_test_last_seen_call())->toBeNull(); +}); diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index bf37e18..ffc5360 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -260,7 +260,7 @@ function test_set_current_page($page) { if (!function_exists('read_config_option')) { function read_config_option($name, $force = false) { - return ''; + return $GLOBALS['__test_config'][$name] ?? ''; } } From fd99d394eed25ee24aac1237e855f81e82584250 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:27:12 -0600 Subject: [PATCH 14/54] test: treat poller_graphs.php as an unmeasured entry point Signed-off-by: Thomas Vincent --- phpunit.xml | 1 - tests/bin/patch-coverage.php | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/phpunit.xml b/phpunit.xml index 340e81c..a60fb49 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -29,7 +29,6 @@ setup.php includes/database.php - poller_graphs.php associate_os_type.php diff --git a/tests/bin/patch-coverage.php b/tests/bin/patch-coverage.php index 06994dc..d69ccfb 100644 --- a/tests/bin/patch-coverage.php +++ b/tests/bin/patch-coverage.php @@ -170,6 +170,10 @@ function changed_lines($base_ref) { // top level (exits without --host-id/-M), so it cannot be loaded into the unit // process; kept out of the coverage set like the web controllers above. 'poller_hmib.php', + // CLI automation: chdir()s, requires cli_check.php and exits at the top level, so + // it cannot be loaded into the unit process. Its functions are exercised by + // tests/Unit/PollerGraphsTest.php, which defines them from the source text. + 'poller_graphs.php', ]; $unmeasured = array_values(array_diff(array_keys($changed), array_keys($measured))); $unexpected_unmeasured = array_values(array_diff($unmeasured, $unmeasured_allowlist)); From d75b37327e8ae07149ed0df31d95037d6831398e Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:30:00 -0600 Subject: [PATCH 15/54] test: run the plugin SQL against MariaDB when a Cacti schema is available Signed-off-by: Thomas Vincent --- tests/Integration/HmibSqlDbTest.php | 251 ++++++++++++++++++++++ tests/Security/GraphUrlQueryTest.php | 20 +- tests/Security/HostTypeCsvExportTest.php | 16 -- tests/Security/RescanTypesBindingTest.php | 22 -- tests/Security/RescanTypesInsertTest.php | 22 -- tests/Security/SummaryOrderByTest.php | 16 -- tests/Unit/PollerGraphsTest.php | 16 -- tests/Unit/PollerHmibCheckHostTest.php | 16 -- tests/bootstrap-unit.php | 86 ++++++++ 9 files changed, 339 insertions(+), 126 deletions(-) create mode 100644 tests/Integration/HmibSqlDbTest.php diff --git a/tests/Integration/HmibSqlDbTest.php b/tests/Integration/HmibSqlDbTest.php new file mode 100644 index 0000000..e2085d6 --- /dev/null +++ b/tests/Integration/HmibSqlDbTest.php @@ -0,0 +1,251 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_TIMEOUT => 3, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + )); + + $has_schema = $pdo->query("SHOW TABLES LIKE 'graph_local'")->fetchColumn(); + + if (!$has_schema) { + $pdo = null; + } + } catch (PDOException $e) { + $pdo = null; + } + + return $pdo; +} + +function hmib_test_sql($sql, $params = array()) { + $statement = hmib_test_connect()->prepare($sql); + $statement->execute($params); + + return $statement; +} + +function hmib_test_create_plugin_tables() { + $GLOBALS['__test_db_calls'] = array(); + + hmib_setup_table(); + + foreach ($GLOBALS['__test_db_calls'] as $call) { + if (strpos($call['sql'], 'CREATE TABLE') === 0) { + hmib_test_connect()->exec(preg_replace('/^CREATE TABLE (?!IF NOT EXISTS)/', 'CREATE TABLE IF NOT EXISTS ', $call['sql'])); + } + } +} + +beforeAll(function () { + require_once __DIR__ . '/../../setup.php'; + + $stubLibraryPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'hmib-test-lib-stub'; + + if (!is_dir($stubLibraryPath)) { + mkdir($stubLibraryPath, 0777, true); + } + + file_put_contents($stubLibraryPath . '/database.php', "markTestSkipped('No MariaDB with a Cacti schema is reachable (see the header of this file).'); + } + + hmib_test_reset_db_mocks(); + hmib_test_create_plugin_tables(); + + foreach (HMIB_TEST_TABLES as $index => $table) { + hmib_test_sql("CREATE TEMPORARY TABLE hmib_bak_{$index} AS SELECT * FROM {$table}"); + hmib_test_sql("DELETE FROM {$table}"); + } + + $GLOBALS['__test_pdo'] = hmib_test_connect(); + $GLOBALS['__test_db_calls'] = array(); + $GLOBALS['__affected_rows'] = 0; + $GLOBALS['__test_config'] = array(); + $GLOBALS['__collected'] = array(); + $_SESSION = array(); +}); + +afterEach(function () { + unset($GLOBALS['__test_pdo']); + + if (hmib_test_connect() === null) { + return; + } + + foreach (HMIB_TEST_TABLES as $index => $table) { + hmib_test_sql("DELETE FROM {$table}"); + hmib_test_sql("INSERT INTO {$table} SELECT * FROM hmib_bak_{$index}"); + hmib_test_sql("DROP TEMPORARY TABLE hmib_bak_{$index}"); + } + + $GLOBALS['__test_config'] = array(); +}); + +function hmib_test_graph_ids($link) { + return preg_match('/graph_add=([0-9,]+)/', $link, $match) ? $match[1] : null; +} + +it('finds graphs by data query, index and device in the graph table', function () { + hmib_test_sql("INSERT INTO graph_local (id, host_id, snmp_query_id, snmp_index) VALUES + (1, 1, 12, 'a'), (2, 2, 12, \"o'neil\"), (3, 1, 99, 'a')"); + hmib_test_sql('INSERT INTO plugin_hmib_hrSystem (host_id, host_type) VALUES (1, 7), (2, 8)'); + + expect(hmib_test_graph_ids(hmib_get_graph_url(12, 0, 0, "o'neil", 't', false)))->toBe('2'); + expect(hmib_test_graph_ids(hmib_get_graph_url(12, 0, 0, '', 't', false)))->toBe('1,2'); + expect(hmib_test_graph_ids(hmib_get_graph_url(12, 0, 1, '', 't', false)))->toBe('1'); + expect(hmib_test_graph_ids(hmib_get_graph_url(12, 8, 0, '', 't', false)))->toBe('2'); + expect(hmib_get_graph_url(12, 0, 0, 'missing', 't', false))->toBe('t'); +}); + +it('counts Summary processes with the filter fragment the page appends', function () { + hmib_test_sql("INSERT INTO plugin_hmib_hrSWRun (host_id, `index`, name, path) VALUES + (1, 1, 'sshd', '/usr/sbin/sshd'), (2, 1, 'sshd', '/usr/sbin/sshd'), + (1, 2, 'System Idle Process', ''), (3, 1, '', ''), (3, 2, 'cron', '/usr/sbin/cron')"); + + $source = plugin_test_read_source('hmib.php'); + + preg_match('/db_fetch_cell_prepared\("(SELECT COUNT\(DISTINCT hrswr\.name\).*?)", \$sql_params\)/s', $source, $count_sql); + preg_match('/\$sql_where = \'(AND \(.*?\))\';/s', $source, $fragment); + + expect($count_sql)->not->toBe(array()); + expect($fragment)->not->toBe(array()); + + $unfiltered = str_replace('$sql_where', '', $count_sql[1]); + $filtered = str_replace('$sql_where', $fragment[1], $count_sql[1]); + + expect((int) hmib_test_sql($unfiltered)->fetchColumn())->toBe(2); + expect((int) hmib_test_sql($filtered, array('%sshd%', '%sshd%', '%sshd%'))->fetchColumn())->toBe(1); + expect((int) hmib_test_sql($filtered, array('%/usr/sbin/%', '%/usr/sbin/%', '%/usr/sbin/%'))->fetchColumn())->toBe(2); +}); + +it('rescans host types without key errors, long values or reclassifying typed devices', function () { + hmib_test_sql("INSERT INTO plugin_hmib_hrSystemTypes (id, sysObjectID, sysDescrMatch, name, version) VALUES + (20, '.1.3.5', 'Windows', 'Windows', '1'), + (21, '.1.3.50', \"it's quoted\", 'Quoted', '1'), + (22, '.1.3.99', 'Linux', 'Linux', '1')"); + + $long = str_repeat('x', 150); + + hmib_test_sql('INSERT INTO plugin_hmib_hrSystem (host_id, host_type, sysDescr, sysObjectID) VALUES + (1, 0, ?, ?), (2, 0, ?, ?), (3, 0, ?, ?), (4, 0, ?, ?), (5, 9, ?, ?), (6, 0, ?, ?), (7, 0, ?, ?)', array( + 'Linux box', '.1.3.99', 'Linux box', '.1.3.99', $long, '.1.3.77', '', '', + 'Windows 11', '.1.3.5', $long, '.1.3.77', 'Linux box', '.1.3.99')); + + rescan_types(); + + $types = hmib_test_sql('SELECT host_id, host_type FROM plugin_hmib_hrSystem ORDER BY host_id')->fetchAll(PDO::FETCH_KEY_PAIR); + + expect($types[1])->toBe(22); + expect($types[2])->toBe(22); + expect($types[7])->toBe(22); + expect($types[5])->toBe(9); + expect($types[3])->toBe(0); + expect($types[4])->toBe(0); + + $added = hmib_test_sql("SELECT sysObjectID, sysDescrMatch FROM plugin_hmib_hrSystemTypes WHERE name = 'New Type'")->fetchAll(); + + expect($added)->toHaveCount(1); + expect($added[0]['sysObjectID'])->toBe('.1.3.77'); + expect(strlen($added[0]['sysDescrMatch']))->toBe(100); + + rescan_types(); + + expect(hmib_test_sql("SELECT COUNT(*) FROM plugin_hmib_hrSystemTypes WHERE name = 'New Type'")->fetchColumn())->toBe(1); +}); + +it('adds the performance interval to the stored time seen only when it was collected', function () { + hmib_test_sql("INSERT INTO plugin_hmib_hrSWRun (host_id, `index`, name) VALUES (5, 1, 'sshd'), (5, 2, 'cron')"); + hmib_test_mock_db('db_fetch_row_prepared', 'FROM host', array('id' => '5', 'description' => 'host five', 'hostname' => 'h5')); + + $GLOBALS['start'] = 1000000.0; + $GLOBALS['seed'] = 77; + $GLOBALS['key'] = ''; + + $due = array('hmib_hrSWRunPerf_freq' => '300', 'hmib_hrSWRunPerf_lastrun' => '0'); + $not_due = array('hmib_hrSWRunPerf_freq' => '3600', 'hmib_hrSWRunPerf_lastrun' => '999900'); + $zero = array(); + + foreach (array('hrDevices', 'hrSWRun', 'hrSWInstalled', 'hrStorage', 'hrProcessor') as $name) { + $zero['hmib_' . $name . '_freq'] = '0'; + $zero['hmib_' . $name . '_lastrun'] = '0'; + } + + $total = function () { + return hmib_test_sql("SELECT total_time FROM plugin_hmib_hrSWRun_last_seen WHERE host_id = 5 AND name = 'sshd'")->fetchColumn(); + }; + + $GLOBALS['__test_config'] = array_merge($zero, $due); + checkHost(5); + checkHost(5); + + expect((int) $total())->toBe(600); + + $GLOBALS['__test_config'] = array_merge($zero, $not_due); + checkHost(5); + + expect((int) $total())->toBe(600); +}); diff --git a/tests/Security/GraphUrlQueryTest.php b/tests/Security/GraphUrlQueryTest.php index 12a6f81..7d57916 100644 --- a/tests/Security/GraphUrlQueryTest.php +++ b/tests/Security/GraphUrlQueryTest.php @@ -16,22 +16,6 @@ * the function is cut out of the source and defined here. */ -if (!function_exists('hmib_test_define_function')) { - function hmib_test_define_function($file, $name) { - if (function_exists($name)) { - return; - } - - $source = plugin_test_read_source($file); - - if (!preg_match('/^function ' . preg_quote($name, '/') . '\(.*?^}\n/ms', $source, $match)) { - throw new RuntimeException("Unable to locate {$name}() in {$file}"); - } - - eval($match[0]); - } -} - beforeAll(function () { hmib_test_define_function('hmib.php', 'hmib_get_graph_url'); }); @@ -50,12 +34,12 @@ function hmib_test_define_function($file, $name) { }); it('binds the snmp index instead of embedding it in the SQL', function () { - $index = "x') OR ('1'='1"; + $index = "idx'quoted"; $link = hmib_get_graph_url(12, 0, 0, $index, 'title', false); expect($GLOBALS['__graph_url_query'])->not->toBeNull(); - expect($GLOBALS['__graph_url_query']['sql'])->not->toContain('OR ('); + expect($GLOBALS['__graph_url_query']['sql'])->not->toContain('quoted'); expect($GLOBALS['__graph_url_query']['params'])->toBe(array(12, $index)); expect($link)->toContain('graph_add=7'); }); diff --git a/tests/Security/HostTypeCsvExportTest.php b/tests/Security/HostTypeCsvExportTest.php index cb706e4..a75842e 100644 --- a/tests/Security/HostTypeCsvExportTest.php +++ b/tests/Security/HostTypeCsvExportTest.php @@ -13,22 +13,6 @@ * start like a formula must not be evaluated. */ -if (!function_exists('hmib_test_define_function')) { - function hmib_test_define_function($file, $name) { - if (function_exists($name)) { - return; - } - - $source = plugin_test_read_source($file); - - if (!preg_match('/^function ' . preg_quote($name, '/') . '\(.*?^}\n/ms', $source, $match)) { - throw new RuntimeException("Unable to locate {$name}() in {$file}"); - } - - eval($match[0]); - } -} - beforeAll(function () { hmib_test_define_function('hmib_types.php', 'hmib_csv_field'); }); diff --git a/tests/Security/RescanTypesBindingTest.php b/tests/Security/RescanTypesBindingTest.php index d99da96..94a3dcb 100644 --- a/tests/Security/RescanTypesBindingTest.php +++ b/tests/Security/RescanTypesBindingTest.php @@ -17,28 +17,6 @@ * included, so the function is cut out of the source and defined here. */ -if (!function_exists('hmib_test_define_function')) { - function hmib_test_define_function($file, $name) { - if (function_exists($name)) { - return; - } - - $source = plugin_test_read_source($file); - - if (!preg_match('/^function ' . preg_quote($name, '/') . '\(.*?^}\n/ms', $source, $match)) { - throw new RuntimeException("Unable to locate {$name}() in {$file}"); - } - - eval($match[0]); - } -} - -if (!function_exists('db_affected_rows')) { - function db_affected_rows() { - return $GLOBALS['__affected_rows'] ?? 0; - } -} - beforeAll(function () { hmib_test_define_function('hmib_types.php', 'rescan_types'); }); diff --git a/tests/Security/RescanTypesInsertTest.php b/tests/Security/RescanTypesInsertTest.php index 62dbd12..d80988d 100644 --- a/tests/Security/RescanTypesInsertTest.php +++ b/tests/Security/RescanTypesInsertTest.php @@ -16,28 +16,6 @@ * against MariaDB in tests/Integration/HmibSqlDbTest.php. */ -if (!function_exists('hmib_test_define_function')) { - function hmib_test_define_function($file, $name) { - if (function_exists($name)) { - return; - } - - $source = plugin_test_read_source($file); - - if (!preg_match('/^function ' . preg_quote($name, '/') . '\(.*?^}\n/ms', $source, $match)) { - throw new RuntimeException("Unable to locate {$name}() in {$file}"); - } - - eval($match[0]); - } -} - -if (!function_exists('db_affected_rows')) { - function db_affected_rows() { - return $GLOBALS['__affected_rows'] ?? 0; - } -} - beforeAll(function () { hmib_test_define_function('hmib_types.php', 'rescan_types'); }); diff --git a/tests/Security/SummaryOrderByTest.php b/tests/Security/SummaryOrderByTest.php index 1c3d93d..208f8ba 100644 --- a/tests/Security/SummaryOrderByTest.php +++ b/tests/Security/SummaryOrderByTest.php @@ -13,22 +13,6 @@ * characters, so the clause has to be limited to known columns. */ -if (!function_exists('hmib_test_define_function')) { - function hmib_test_define_function($file, $name) { - if (function_exists($name)) { - return; - } - - $source = plugin_test_read_source($file); - - if (!preg_match('/^function ' . preg_quote($name, '/') . '\(.*?^}\n/ms', $source, $match)) { - throw new RuntimeException("Unable to locate {$name}() in {$file}"); - } - - eval($match[0]); - } -} - beforeAll(function () { hmib_test_define_function('hmib.php', 'hmib_summary_order_by'); }); diff --git a/tests/Unit/PollerGraphsTest.php b/tests/Unit/PollerGraphsTest.php index ab7198f..26a5964 100644 --- a/tests/Unit/PollerGraphsTest.php +++ b/tests/Unit/PollerGraphsTest.php @@ -15,22 +15,6 @@ * and defined here, with the helpers they call replaced by recorders. */ -if (!function_exists('hmib_test_define_function')) { - function hmib_test_define_function($file, $name) { - if (function_exists($name)) { - return; - } - - $source = plugin_test_read_source($file); - - if (!preg_match('/^function ' . preg_quote($name, '/') . '\(.*?^}\n/ms', $source, $match)) { - throw new RuntimeException("Unable to locate {$name}() in {$file}"); - } - - eval($match[0]); - } -} - if (!function_exists('debug')) { function debug($message) { } diff --git a/tests/Unit/PollerHmibCheckHostTest.php b/tests/Unit/PollerHmibCheckHostTest.php index 30b3399..be6edb9 100644 --- a/tests/Unit/PollerHmibCheckHostTest.php +++ b/tests/Unit/PollerHmibCheckHostTest.php @@ -15,22 +15,6 @@ * source and defined here, with the collectors replaced by recorders. */ -if (!function_exists('hmib_test_define_function')) { - function hmib_test_define_function($file, $name) { - if (function_exists($name)) { - return; - } - - $source = plugin_test_read_source($file); - - if (!preg_match('/^function ' . preg_quote($name, '/') . '\(.*?^}\n/ms', $source, $match)) { - throw new RuntimeException("Unable to locate {$name}() in {$file}"); - } - - eval($match[0]); - } -} - foreach (array('hrSystem', 'hrDevices', 'hrSWRun', 'hrSWRunPerf', 'hrSWInstalled', 'hrStorage', 'hrProcessor') as $hmib_test_collector) { if (!function_exists('collect_' . $hmib_test_collector)) { eval('function collect_' . $hmib_test_collector . '(array &$host) { $GLOBALS["__collected"][] = "' . $hmib_test_collector . '"; }'); diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index ffc5360..c6824e0 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -116,13 +116,65 @@ function hmib_test_db_result($fn, $sql, $params, $default) { return is_callable($result) ? $result($sql, $params) : $result; } + if (isset($GLOBALS['__test_pdo'])) { + return hmib_test_pdo_fetch($fn, $sql, $params, $default); + } + return $default; } } +/* + * Database-backed tests (tests/Integration/HmibSqlDbTest.php) set + * $GLOBALS['__test_pdo'] to a PDO connection. While it is set, the db_* + * stubs below run their SQL against it instead of returning canned values, + * unless a hmib_test_mock_db() fixture matches first. + */ +if (!function_exists('hmib_test_pdo_run')) { + function hmib_test_pdo_run($sql, $params) { + $statement = $GLOBALS['__test_pdo']->prepare($sql); + $statement->execute(array_values($params)); + + $GLOBALS['__affected_rows'] = $statement->rowCount(); + + return $statement; + } +} + +if (!function_exists('hmib_test_pdo_fetch')) { + function hmib_test_pdo_fetch($fn, $sql, $params, $default) { + $statement = hmib_test_pdo_run($sql, $params); + + if (substr($fn, -5) === '_cell' || substr($fn, -14) === '_cell_prepared') { + $value = $statement->fetchColumn(); + + return $value === false ? $default : $value; + } + + if (strpos($fn, '_row') !== false) { + $row = $statement->fetch(PDO::FETCH_ASSOC); + + return $row === false ? array() : $row; + } + + return $statement->fetchAll(PDO::FETCH_ASSOC); + } +} + +if (!function_exists('db_affected_rows')) { + function db_affected_rows() { + return $GLOBALS['__affected_rows'] ?? 0; + } +} + if (!function_exists('db_execute')) { function db_execute($sql) { $GLOBALS['__test_db_calls'][] = array('fn' => 'db_execute', 'sql' => $sql, 'params' => array()); + + if (isset($GLOBALS['__test_pdo'])) { + hmib_test_pdo_run($sql, array()); + } + return true; } } @@ -130,6 +182,11 @@ function db_execute($sql) { if (!function_exists('db_execute_prepared')) { function db_execute_prepared($sql, $params = array()) { $GLOBALS['__test_db_calls'][] = array('fn' => 'db_execute_prepared', 'sql' => $sql, 'params' => $params); + + if (isset($GLOBALS['__test_pdo'])) { + hmib_test_pdo_run($sql, $params); + } + return true; } } @@ -387,6 +444,35 @@ function plugin_test_read_source($relative_file) { } } +/** + * Define one function from a plugin source file that cannot be included. + * + * hmib.php, hmib_types.php, poller_hmib.php and poller_graphs.php chdir(), + * include Cacti's auth or CLI bootstrap, or exit at the top level, so a test + * cannot include them. The function is cut out of the source text and defined + * here, so the test runs the production code rather than a copy of it. + * + * @param string $file Plugin-relative source file. + * @param string $name Function to define. + * + * @return void + */ +if (!function_exists('hmib_test_define_function')) { + function hmib_test_define_function($file, $name) { + if (function_exists($name)) { + return; + } + + $source = plugin_test_read_source($file); + + if (!preg_match('/^function ' . preg_quote($name, '/') . '\(.*?^}\n/ms', $source, $match)) { + throw new RuntimeException("Unable to locate {$name}() in {$file}"); + } + + eval($match[0]); + } +} + /** * Load a plugin source file at global scope. * From f36adde2adc4057927bdb7b818c5b1515843171a Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:33:36 -0600 Subject: [PATCH 16/54] test: make the test bootstrap inert when requested over the web Signed-off-by: Thomas Vincent --- tests/Security/TestsDirectoryInertTest.php | 44 ++++++++++++++++++++++ tests/bootstrap-unit.php | 14 ++++++- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 tests/Security/TestsDirectoryInertTest.php diff --git a/tests/Security/TestsDirectoryInertTest.php b/tests/Security/TestsDirectoryInertTest.php new file mode 100644 index 0000000..d1161b2 --- /dev/null +++ b/tests/Security/TestsDirectoryInertTest.php @@ -0,0 +1,44 @@ + array('file', '/dev/null', 'w'), 2 => array('file', '/dev/null', 'w')), $pipes); + + expect($process)->not->toBeFalse(); + + try { + $context = stream_context_create(array('http' => array('ignore_errors' => true, 'timeout' => 2))); + $body = false; + $headers = array(); + + for ($attempt = 0; $attempt < 30 && $body === false; $attempt++) { + usleep(100000); + $body = @file_get_contents('http://127.0.0.1:' . $port . '/bootstrap-unit.php', false, $context); + + $headers = $http_response_header ?? array(); + } + + expect($body)->toBe(''); + expect($headers[0] ?? '')->toContain('404'); + } finally { + proc_terminate($process); + proc_close($process); + } +}); diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index c6824e0..940c354 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -14,12 +14,24 @@ +-------------------------------------------------------------------------+ */ +/* + * tests/ can end up inside the web root on a git-clone install. This file + * loads Cacti's autoloader and defines stubs, so it must do nothing when a + * web server is asked for it. + */ +if (PHP_SAPI !== 'cli' && PHP_SAPI !== 'phpdbg') { + http_response_code(404); + + exit; +} + /* * Test bootstrap. * * HMIB's sources expect to be included by Cacti, which has already * defined the db_*, request-variable, and logging helpers as plain global - * functions. Nothing here talks to a database or a network: each Cacti + * functions. Nothing here talks to a network, and the database only when a + * test sets $GLOBALS['__test_pdo'] (see hmib_test_pdo_run()): each Cacti * function is declared as a stub that records the call in * $GLOBALS['__test_db_calls'] and hands back a safe default. * From 2fd555b66c493bb47cd1a52d7fad5d0b15fdc9b1 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Fri, 23 Oct 2026 21:41:06 -0600 Subject: [PATCH 17/54] test: load extracted functions from a temporary file instead of eval Signed-off-by: Thomas Vincent --- tests/Unit/PollerHmibCheckHostTest.php | 42 ++++++++++++++++++++++++-- tests/bootstrap-unit.php | 19 ++++++++++-- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/tests/Unit/PollerHmibCheckHostTest.php b/tests/Unit/PollerHmibCheckHostTest.php index be6edb9..2174f85 100644 --- a/tests/Unit/PollerHmibCheckHostTest.php +++ b/tests/Unit/PollerHmibCheckHostTest.php @@ -15,9 +15,45 @@ * source and defined here, with the collectors replaced by recorders. */ -foreach (array('hrSystem', 'hrDevices', 'hrSWRun', 'hrSWRunPerf', 'hrSWInstalled', 'hrStorage', 'hrProcessor') as $hmib_test_collector) { - if (!function_exists('collect_' . $hmib_test_collector)) { - eval('function collect_' . $hmib_test_collector . '(array &$host) { $GLOBALS["__collected"][] = "' . $hmib_test_collector . '"; }'); +if (!function_exists('collect_hrSystem')) { + function collect_hrSystem(array &$host) { + $GLOBALS['__collected'][] = 'hrSystem'; + } +} + +if (!function_exists('collect_hrDevices')) { + function collect_hrDevices(array &$host) { + $GLOBALS['__collected'][] = 'hrDevices'; + } +} + +if (!function_exists('collect_hrSWRun')) { + function collect_hrSWRun(array &$host) { + $GLOBALS['__collected'][] = 'hrSWRun'; + } +} + +if (!function_exists('collect_hrSWRunPerf')) { + function collect_hrSWRunPerf(array &$host) { + $GLOBALS['__collected'][] = 'hrSWRunPerf'; + } +} + +if (!function_exists('collect_hrSWInstalled')) { + function collect_hrSWInstalled(array &$host) { + $GLOBALS['__collected'][] = 'hrSWInstalled'; + } +} + +if (!function_exists('collect_hrStorage')) { + function collect_hrStorage(array &$host) { + $GLOBALS['__collected'][] = 'hrStorage'; + } +} + +if (!function_exists('collect_hrProcessor')) { + function collect_hrProcessor(array &$host) { + $GLOBALS['__collected'][] = 'hrProcessor'; } } diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index 940c354..119c895 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -462,7 +462,8 @@ function plugin_test_read_source($relative_file) { * hmib.php, hmib_types.php, poller_hmib.php and poller_graphs.php chdir(), * include Cacti's auth or CLI bootstrap, or exit at the top level, so a test * cannot include them. The function is cut out of the source text and defined - * here, so the test runs the production code rather than a copy of it. + * here, so the test runs the production code rather than a copy of it. The + * text is written to a private temporary file, required, and deleted. * * @param string $file Plugin-relative source file. * @param string $name Function to define. @@ -481,7 +482,21 @@ function hmib_test_define_function($file, $name) { throw new RuntimeException("Unable to locate {$name}() in {$file}"); } - eval($match[0]); + // tempnam() creates the file exclusively with mode 0600; the path is + // built from nothing a request or the source file can influence. + $path = tempnam(sys_get_temp_dir(), 'hmib'); + + if ($path === false) { + throw new RuntimeException("Unable to create a temporary file for {$name}()"); + } + + try { + file_put_contents($path, " Date: Sat, 24 Oct 2026 00:50:03 -0600 Subject: [PATCH 18/54] test: share the database helpers and run the SQL tests in three SQL modes Signed-off-by: Thomas Vincent --- tests/Integration/HmibSqlDbTest.php | 120 ++++------- tests/support/db.php | 319 ++++++++++++++++++++++++++++ 2 files changed, 357 insertions(+), 82 deletions(-) create mode 100644 tests/support/db.php diff --git a/tests/Integration/HmibSqlDbTest.php b/tests/Integration/HmibSqlDbTest.php index e2085d6..f03ffbd 100644 --- a/tests/Integration/HmibSqlDbTest.php +++ b/tests/Integration/HmibSqlDbTest.php @@ -14,15 +14,16 @@ * * The functions under test are cut out of the plugin source and executed * with the db_* stubs bridged to a PDO connection (see tests/bootstrap-unit.php). - * The connection defaults match the database the CI workflow starts - * (127.0.0.1, cactiuser, cacti) and can be overridden with HMIB_TEST_DB_HOST, - * HMIB_TEST_DB_PORT, HMIB_TEST_DB_USER, HMIB_TEST_DB_PASS and HMIB_TEST_DB_NAME. - * Set HMIB_TEST_DB=0 to skip. The tests are skipped when no Cacti schema is - * reachable. Every table a test touches is copied aside first and restored - * afterwards, so a Cacti install in the same database keeps its rows. + * The connection settings and the table copy-and-restore are in + * tests/support/db.php. The tests are skipped when no Cacti schema is + * reachable or HMIB_TEST_DB=0 is set. Every table a test touches is copied + * aside first and restored afterwards, so a Cacti install in the same + * database keeps its rows. */ -const HMIB_TEST_TABLES = array( +require_once __DIR__ . '/../support/db.php'; + +const HMIB_SQL_TEST_TABLES = array( 'graph_local', 'plugin_hmib_hrSystem', 'plugin_hmib_hrSystemTypes', @@ -31,64 +32,6 @@ 'plugin_hmib_processes', ); -function hmib_test_connect() { - static $pdo = false; - - if ($pdo !== false) { - return $pdo ?: null; - } - - if (getenv('HMIB_TEST_DB') === '0' || !extension_loaded('pdo_mysql')) { - $pdo = null; - - return null; - } - - $dsn = sprintf( - 'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', - getenv('HMIB_TEST_DB_HOST') ?: '127.0.0.1', - getenv('HMIB_TEST_DB_PORT') ?: '3306', - getenv('HMIB_TEST_DB_NAME') ?: 'cacti' - ); - - try { - $pdo = new PDO($dsn, getenv('HMIB_TEST_DB_USER') ?: 'cactiuser', getenv('HMIB_TEST_DB_PASS') ?: 'cactiuser', array( - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_TIMEOUT => 3, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - )); - - $has_schema = $pdo->query("SHOW TABLES LIKE 'graph_local'")->fetchColumn(); - - if (!$has_schema) { - $pdo = null; - } - } catch (PDOException $e) { - $pdo = null; - } - - return $pdo; -} - -function hmib_test_sql($sql, $params = array()) { - $statement = hmib_test_connect()->prepare($sql); - $statement->execute($params); - - return $statement; -} - -function hmib_test_create_plugin_tables() { - $GLOBALS['__test_db_calls'] = array(); - - hmib_setup_table(); - - foreach ($GLOBALS['__test_db_calls'] as $call) { - if (strpos($call['sql'], 'CREATE TABLE') === 0) { - hmib_test_connect()->exec(preg_replace('/^CREATE TABLE (?!IF NOT EXISTS)/', 'CREATE TABLE IF NOT EXISTS ', $call['sql'])); - } - } -} - beforeAll(function () { require_once __DIR__ . '/../../setup.php'; @@ -113,13 +56,12 @@ function hmib_test_create_plugin_tables() { $this->markTestSkipped('No MariaDB with a Cacti schema is reachable (see the header of this file).'); } + $GLOBALS['__hmib_sql_mode'] = hmib_test_sql('SELECT @@SESSION.sql_mode')->fetchColumn(); + hmib_test_reset_db_mocks(); hmib_test_create_plugin_tables(); - foreach (HMIB_TEST_TABLES as $index => $table) { - hmib_test_sql("CREATE TEMPORARY TABLE hmib_bak_{$index} AS SELECT * FROM {$table}"); - hmib_test_sql("DELETE FROM {$table}"); - } + hmib_test_snapshot(HMIB_SQL_TEST_TABLES); $GLOBALS['__test_pdo'] = hmib_test_connect(); $GLOBALS['__test_db_calls'] = array(); @@ -132,24 +74,32 @@ function hmib_test_create_plugin_tables() { afterEach(function () { unset($GLOBALS['__test_pdo']); + if (isset($GLOBALS['__hmib_sql_mode'])) { + hmib_test_sql('SET SESSION sql_mode = ?', array($GLOBALS['__hmib_sql_mode'])); + } + if (hmib_test_connect() === null) { return; } - foreach (HMIB_TEST_TABLES as $index => $table) { - hmib_test_sql("DELETE FROM {$table}"); - hmib_test_sql("INSERT INTO {$table} SELECT * FROM hmib_bak_{$index}"); - hmib_test_sql("DROP TEMPORARY TABLE hmib_bak_{$index}"); - } + hmib_test_restore(HMIB_SQL_TEST_TABLES); $GLOBALS['__test_config'] = array(); }); +dataset('sql modes', array( + 'strict' => 'STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION', + 'not strict' => '', + 'strict with full group by' => 'STRICT_TRANS_TABLES,ONLY_FULL_GROUP_BY', +)); + function hmib_test_graph_ids($link) { return preg_match('/graph_add=([0-9,]+)/', $link, $match) ? $match[1] : null; } -it('finds graphs by data query, index and device in the graph table', function () { +it('finds graphs by data query, index and device in the graph table', function (string $mode) { + hmib_test_sql('SET SESSION sql_mode = ?', array($mode)); + hmib_test_sql("INSERT INTO graph_local (id, host_id, snmp_query_id, snmp_index) VALUES (1, 1, 12, 'a'), (2, 2, 12, \"o'neil\"), (3, 1, 99, 'a')"); hmib_test_sql('INSERT INTO plugin_hmib_hrSystem (host_id, host_type) VALUES (1, 7), (2, 8)'); @@ -159,9 +109,11 @@ function hmib_test_graph_ids($link) { expect(hmib_test_graph_ids(hmib_get_graph_url(12, 0, 1, '', 't', false)))->toBe('1'); expect(hmib_test_graph_ids(hmib_get_graph_url(12, 8, 0, '', 't', false)))->toBe('2'); expect(hmib_get_graph_url(12, 0, 0, 'missing', 't', false))->toBe('t'); -}); +})->with('sql modes'); + +it('counts Summary processes with the filter fragment the page appends', function (string $mode) { + hmib_test_sql('SET SESSION sql_mode = ?', array($mode)); -it('counts Summary processes with the filter fragment the page appends', function () { hmib_test_sql("INSERT INTO plugin_hmib_hrSWRun (host_id, `index`, name, path) VALUES (1, 1, 'sshd', '/usr/sbin/sshd'), (2, 1, 'sshd', '/usr/sbin/sshd'), (1, 2, 'System Idle Process', ''), (3, 1, '', ''), (3, 2, 'cron', '/usr/sbin/cron')"); @@ -180,9 +132,11 @@ function hmib_test_graph_ids($link) { expect((int) hmib_test_sql($unfiltered)->fetchColumn())->toBe(2); expect((int) hmib_test_sql($filtered, array('%sshd%', '%sshd%', '%sshd%'))->fetchColumn())->toBe(1); expect((int) hmib_test_sql($filtered, array('%/usr/sbin/%', '%/usr/sbin/%', '%/usr/sbin/%'))->fetchColumn())->toBe(2); -}); +})->with('sql modes'); + +it('rescans host types without key errors, long values or reclassifying typed devices', function (string $mode) { + hmib_test_sql('SET SESSION sql_mode = ?', array($mode)); -it('rescans host types without key errors, long values or reclassifying typed devices', function () { hmib_test_sql("INSERT INTO plugin_hmib_hrSystemTypes (id, sysObjectID, sysDescrMatch, name, version) VALUES (20, '.1.3.5', 'Windows', 'Windows', '1'), (21, '.1.3.50', \"it's quoted\", 'Quoted', '1'), @@ -215,9 +169,11 @@ function hmib_test_graph_ids($link) { rescan_types(); expect(hmib_test_sql("SELECT COUNT(*) FROM plugin_hmib_hrSystemTypes WHERE name = 'New Type'")->fetchColumn())->toBe(1); -}); +})->with('sql modes'); + +it('adds the performance interval to the stored time seen only when it was collected', function (string $mode) { + hmib_test_sql('SET SESSION sql_mode = ?', array($mode)); -it('adds the performance interval to the stored time seen only when it was collected', function () { hmib_test_sql("INSERT INTO plugin_hmib_hrSWRun (host_id, `index`, name) VALUES (5, 1, 'sshd'), (5, 2, 'cron')"); hmib_test_mock_db('db_fetch_row_prepared', 'FROM host', array('id' => '5', 'description' => 'host five', 'hostname' => 'h5')); @@ -248,4 +204,4 @@ function hmib_test_graph_ids($link) { checkHost(5); expect((int) $total())->toBe(600); -}); +})->with('sql modes'); diff --git a/tests/support/db.php b/tests/support/db.php new file mode 100644 index 0000000..2453d07 --- /dev/null +++ b/tests/support/db.php @@ -0,0 +1,319 @@ + array( + 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_graph_local AS SELECT * FROM graph_local', + 'clean' => 'DELETE FROM graph_local', + 'wipe' => 'DELETE FROM graph_local', + 'restore' => 'INSERT INTO graph_local SELECT * FROM hmib_bak_graph_local', + 'drop' => 'DROP TEMPORARY TABLE hmib_bak_graph_local', + ), + 'host' => array( + 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_host AS SELECT * FROM host', + 'clean' => 'DELETE FROM host WHERE id >= 9000', + 'wipe' => 'DELETE FROM host', + 'restore' => 'INSERT INTO host SELECT * FROM hmib_bak_host', + 'drop' => 'DROP TEMPORARY TABLE hmib_bak_host', + ), + 'host_graph' => array( + 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_host_graph AS SELECT * FROM host_graph', + 'clean' => 'DELETE FROM host_graph WHERE host_id >= 9000', + 'wipe' => 'DELETE FROM host_graph', + 'restore' => 'INSERT INTO host_graph SELECT * FROM hmib_bak_host_graph', + 'drop' => 'DROP TEMPORARY TABLE hmib_bak_host_graph', + ), + 'host_snmp_query' => array( + 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_host_snmp_query AS SELECT * FROM host_snmp_query', + 'clean' => 'DELETE FROM host_snmp_query WHERE host_id >= 9000', + 'wipe' => 'DELETE FROM host_snmp_query', + 'restore' => 'INSERT INTO host_snmp_query SELECT * FROM hmib_bak_host_snmp_query', + 'drop' => 'DROP TEMPORARY TABLE hmib_bak_host_snmp_query', + ), + 'host_template_graph' => array( + 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_host_template_graph AS SELECT * FROM host_template_graph', + 'clean' => 'DO 0', + 'wipe' => 'DELETE FROM host_template_graph', + 'restore' => 'INSERT INTO host_template_graph SELECT * FROM hmib_bak_host_template_graph', + 'drop' => 'DROP TEMPORARY TABLE hmib_bak_host_template_graph', + ), + 'host_template' => array( + 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_host_template AS SELECT * FROM host_template', + 'clean' => 'DELETE FROM host_template WHERE hash = \'7c13344910097cc599f0d0485305361d\'', + 'wipe' => 'DELETE FROM host_template', + 'restore' => 'INSERT INTO host_template SELECT * FROM hmib_bak_host_template', + 'drop' => 'DROP TEMPORARY TABLE hmib_bak_host_template', + ), + 'snmp_query' => array( + 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_snmp_query AS SELECT * FROM snmp_query', + 'clean' => 'DELETE FROM snmp_query WHERE hash IN (\'137aeab842986a76cf5bdef41b96c9a3\', \'0d1ab53fe37487a5d0b9e1d3ee8c1d0d\', \'9343eab1f4d88b0e61ffc9d020f35414\', \'6b0ef0fe7f1d85bbb6812801ca15a7c5\')', + 'wipe' => 'DELETE FROM snmp_query', + 'restore' => 'INSERT INTO snmp_query SELECT * FROM hmib_bak_snmp_query', + 'drop' => 'DROP TEMPORARY TABLE hmib_bak_snmp_query', + ), + 'graph_templates' => array( + 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_graph_templates AS SELECT * FROM graph_templates', + 'clean' => 'DELETE FROM graph_templates WHERE hash IN (\'e8462bbe094e4e9e814d4e681671ea82\', \'62205afbd4066e5c4700338841e3901e\')', + 'wipe' => 'DELETE FROM graph_templates', + 'restore' => 'INSERT INTO graph_templates SELECT * FROM hmib_bak_graph_templates', + 'drop' => 'DROP TEMPORARY TABLE hmib_bak_graph_templates', + ), + 'settings' => array( + 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_settings AS SELECT * FROM settings', + 'clean' => 'DELETE FROM settings WHERE name LIKE \'hmib%\'', + 'wipe' => 'DELETE FROM settings', + 'restore' => 'INSERT INTO settings SELECT * FROM hmib_bak_settings', + 'drop' => 'DROP TEMPORARY TABLE hmib_bak_settings', + ), + 'user_auth' => array( + 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_user_auth AS SELECT * FROM user_auth', + 'clean' => 'DO 0', + 'wipe' => 'DELETE FROM user_auth', + 'restore' => 'INSERT INTO user_auth SELECT * FROM hmib_bak_user_auth', + 'drop' => 'DROP TEMPORARY TABLE hmib_bak_user_auth', + ), + 'user_auth_realm' => array( + 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_user_auth_realm AS SELECT * FROM user_auth_realm', + 'clean' => 'DO 0', + 'wipe' => 'DELETE FROM user_auth_realm', + 'restore' => 'INSERT INTO user_auth_realm SELECT * FROM hmib_bak_user_auth_realm', + 'drop' => 'DROP TEMPORARY TABLE hmib_bak_user_auth_realm', + ), + 'plugin_hmib_hrSystem' => array( + 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrSystem AS SELECT * FROM plugin_hmib_hrSystem', + 'clean' => 'DELETE FROM plugin_hmib_hrSystem', + 'wipe' => 'DELETE FROM plugin_hmib_hrSystem', + 'restore' => 'INSERT INTO plugin_hmib_hrSystem SELECT * FROM hmib_bak_plugin_hmib_hrSystem', + 'drop' => 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrSystem', + ), + 'plugin_hmib_hrSystemTypes' => array( + 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrSystemTypes AS SELECT * FROM plugin_hmib_hrSystemTypes', + 'clean' => 'DELETE FROM plugin_hmib_hrSystemTypes', + 'wipe' => 'DELETE FROM plugin_hmib_hrSystemTypes', + 'restore' => 'INSERT INTO plugin_hmib_hrSystemTypes SELECT * FROM hmib_bak_plugin_hmib_hrSystemTypes', + 'drop' => 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrSystemTypes', + ), + 'plugin_hmib_hrSWRun' => array( + 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrSWRun AS SELECT * FROM plugin_hmib_hrSWRun', + 'clean' => 'DELETE FROM plugin_hmib_hrSWRun', + 'wipe' => 'DELETE FROM plugin_hmib_hrSWRun', + 'restore' => 'INSERT INTO plugin_hmib_hrSWRun SELECT * FROM hmib_bak_plugin_hmib_hrSWRun', + 'drop' => 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrSWRun', + ), + 'plugin_hmib_hrSWRun_last_seen' => array( + 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrSWRun_last_seen AS SELECT * FROM plugin_hmib_hrSWRun_last_seen', + 'clean' => 'DELETE FROM plugin_hmib_hrSWRun_last_seen', + 'wipe' => 'DELETE FROM plugin_hmib_hrSWRun_last_seen', + 'restore' => 'INSERT INTO plugin_hmib_hrSWRun_last_seen SELECT * FROM hmib_bak_plugin_hmib_hrSWRun_last_seen', + 'drop' => 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrSWRun_last_seen', + ), + 'plugin_hmib_hrSWInstalled' => array( + 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrSWInstalled AS SELECT * FROM plugin_hmib_hrSWInstalled', + 'clean' => 'DELETE FROM plugin_hmib_hrSWInstalled', + 'wipe' => 'DELETE FROM plugin_hmib_hrSWInstalled', + 'restore' => 'INSERT INTO plugin_hmib_hrSWInstalled SELECT * FROM hmib_bak_plugin_hmib_hrSWInstalled', + 'drop' => 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrSWInstalled', + ), + 'plugin_hmib_hrDevices' => array( + 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrDevices AS SELECT * FROM plugin_hmib_hrDevices', + 'clean' => 'DELETE FROM plugin_hmib_hrDevices', + 'wipe' => 'DELETE FROM plugin_hmib_hrDevices', + 'restore' => 'INSERT INTO plugin_hmib_hrDevices SELECT * FROM hmib_bak_plugin_hmib_hrDevices', + 'drop' => 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrDevices', + ), + 'plugin_hmib_hrStorage' => array( + 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrStorage AS SELECT * FROM plugin_hmib_hrStorage', + 'clean' => 'DELETE FROM plugin_hmib_hrStorage', + 'wipe' => 'DELETE FROM plugin_hmib_hrStorage', + 'restore' => 'INSERT INTO plugin_hmib_hrStorage SELECT * FROM hmib_bak_plugin_hmib_hrStorage', + 'drop' => 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrStorage', + ), + 'plugin_hmib_hrProcessor' => array( + 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrProcessor AS SELECT * FROM plugin_hmib_hrProcessor', + 'clean' => 'DELETE FROM plugin_hmib_hrProcessor', + 'wipe' => 'DELETE FROM plugin_hmib_hrProcessor', + 'restore' => 'INSERT INTO plugin_hmib_hrProcessor SELECT * FROM hmib_bak_plugin_hmib_hrProcessor', + 'drop' => 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrProcessor', + ), + 'plugin_hmib_processes' => array( + 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_processes AS SELECT * FROM plugin_hmib_processes', + 'clean' => 'DELETE FROM plugin_hmib_processes', + 'wipe' => 'DELETE FROM plugin_hmib_processes', + 'restore' => 'INSERT INTO plugin_hmib_processes SELECT * FROM hmib_bak_plugin_hmib_processes', + 'drop' => 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_processes', + ), +); + +/** + * Database settings of the Cacti install this plugin sits in. + * + * @return array The connection settings. + */ +function hmib_test_db_settings() { + $settings = array( + 'host' => gethostbyname('localhost'), + 'port' => '3306', + 'user' => 'cactiuser', + 'pass' => '', + 'name' => 'cacti', + ); + + $config_file = dirname(__DIR__, 4) . '/include/config.php'; + + if (is_readable($config_file)) { + $from_config = (function ($file) { + include $file; + + return array( + 'host' => $database_hostname ?? null, + 'port' => $database_port ?? null, + 'user' => $database_username ?? null, + 'pass' => $database_password ?? null, + 'name' => $database_default ?? null, + ); + })($config_file); + + $settings = array_merge($settings, array_filter($from_config, function ($value) { + return $value !== null && $value !== ''; + })); + } + + foreach (array('host' => 'HOST', 'port' => 'PORT', 'user' => 'USER', 'pass' => 'PASS', 'name' => 'NAME') as $key => $suffix) { + $value = getenv('HMIB_TEST_DB_' . $suffix); + + if ($value !== false && $value !== '') { + $settings[$key] = $value; + } + } + + return $settings; +} + +/** + * Open (once) a PDO connection to a database that has a Cacti schema. + * + * @return PDO|null The connection, or null when none is reachable. + */ +function hmib_test_connect() { + static $pdo = false; + + if ($pdo !== false) { + return $pdo ?: null; + } + + if (getenv('HMIB_TEST_DB') === '0' || !extension_loaded('pdo_mysql')) { + $pdo = null; + + return null; + } + + $settings = hmib_test_db_settings(); + $dsn = 'mysql:host=' . $settings['host'] . ';port=' . $settings['port'] . ';dbname=' . $settings['name'] . ';charset=utf8mb4'; + + try { + $pdo = new PDO($dsn, $settings['user'], $settings['pass'], array( + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_TIMEOUT => 3, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + )); + + if (!$pdo->query("SHOW TABLES LIKE 'graph_local'")->fetchColumn()) { + $pdo = null; + } + } catch (PDOException $e) { + $pdo = null; + } + + return $pdo; +} + +/** + * Run one statement with bound parameters. + * + * @param string $sql The statement. + * @param array $params The bound values. + * + * @return PDOStatement The executed statement. + */ +function hmib_test_sql($sql, $params = array()) { + $statement = hmib_test_connect()->prepare($sql); + $statement->execute($params); + + return $statement; +} + +/** + * Create the plugin tables from the install code when they are missing. + * + * @return void + */ +function hmib_test_create_plugin_tables() { + $GLOBALS['__test_db_calls'] = array(); + + hmib_setup_table(); + + foreach ($GLOBALS['__test_db_calls'] as $call) { + if (strpos($call['sql'], 'CREATE TABLE') === 0) { + hmib_test_connect()->exec(preg_replace('/^CREATE TABLE (?!IF NOT EXISTS)/', 'CREATE TABLE IF NOT EXISTS ', $call['sql'])); + } + } +} + +/** + * Copy the given tables aside and empty the ones the plugin owns. + * + * @param string[] $tables Keys of HMIB_TEST_SNAPSHOT_SQL. + * + * @return void + */ +function hmib_test_snapshot($tables) { + foreach ($tables as $table) { + hmib_test_sql(HMIB_TEST_SNAPSHOT_SQL[$table]['backup']); + hmib_test_sql(HMIB_TEST_SNAPSHOT_SQL[$table]['clean']); + } +} + +/** + * Put the copied tables back. + * + * @param string[] $tables Keys of HMIB_TEST_SNAPSHOT_SQL. + * + * @return void + */ +function hmib_test_restore($tables) { + foreach ($tables as $table) { + hmib_test_sql(HMIB_TEST_SNAPSHOT_SQL[$table]['wipe']); + hmib_test_sql(HMIB_TEST_SNAPSHOT_SQL[$table]['restore']); + hmib_test_sql(HMIB_TEST_SNAPSHOT_SQL[$table]['drop']); + } +} From a31c14747779341ac5a50af2dac6a00b479010ae Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 00:51:10 -0600 Subject: [PATCH 19/54] test: add helpers for end-to-end tests against a Cacti install Signed-off-by: Thomas Vincent --- tests/support/e2e.php | 536 +++++++++++++++++++++++++++++++++++++ tests/support/fixtures.php | 155 +++++++++++ 2 files changed, 691 insertions(+) create mode 100644 tests/support/e2e.php create mode 100644 tests/support/fixtures.php diff --git a/tests/support/e2e.php b/tests/support/e2e.php new file mode 100644 index 0000000..a82af86 --- /dev/null +++ b/tests/support/e2e.php @@ -0,0 +1,536 @@ +query("SHOW TABLES LIKE 'plugin_hmib_hrSystem'")->fetchColumn()) { + return 'The hmib plugin is not installed in the Cacti database.'; + } + + if ($pdo->query('SELECT cacti FROM version LIMIT 1')->fetchColumn() === 'new_install') { + return 'The Cacti install is not complete.'; + } + + return null; +} + +/** + * Point the coverage helper at the test that is about to run. + * + * @param string $name The test name. + * + * @return void + */ +function hmib_e2e_tag($name) { + $directory = getenv('HMIB_COVERAGE_DIR'); + + if ($directory !== false && $directory !== '' && is_dir($directory)) { + file_put_contents($directory . '/current-test', $name); + } +} + +/** + * PHP command line options that load the coverage helper, when asked for. + * + * @return string[] + */ +function hmib_e2e_php_options() { + $directory = getenv('HMIB_COVERAGE_DIR'); + + if ($directory === false || $directory === '' || !is_dir($directory)) { + return array(); + } + + return array('-d', 'auto_prepend_file=' . dirname(__DIR__) . '/bin/coverage-prepend.php', '-d', 'xdebug.mode=coverage'); +} + +/** + * A free local TCP port. + * + * @return int + */ +function hmib_e2e_free_port() { + $socket = stream_socket_server('tcp://localhost:0', $errno, $errstr); + $name = stream_socket_get_name($socket, false); + + fclose($socket); + + return (int) substr($name, strrpos($name, ':') + 1); +} + +/** + * Start the built-in web server on the Cacti install. + * + * The document root is a private temporary directory that holds a symlink to + * the Cacti root named like the install's url_path, so the install's own links + * resolve. + * + * @return array The server handle for hmib_e2e_http() and hmib_e2e_stop_server(). + */ +function hmib_e2e_start_server() { + $prefix = hmib_e2e_url_path(); + $docroot = sys_get_temp_dir() . '/hmib-e2e-' . bin2hex(random_bytes(6)); + $link = null; + + mkdir($docroot, 0700); + + if ($prefix !== '/') { + $link = $docroot . '/' . trim($prefix, '/'); + + symlink(hmib_e2e_root(), $link); + } else { + $docroot = hmib_e2e_root(); + } + + $port = hmib_e2e_free_port(); + $command = array_merge(array(PHP_BINARY), hmib_e2e_php_options(), array('-S', 'localhost:' . $port, '-t', $docroot)); + + $environment = array_merge(getenv(), array('HMIB_COVERAGE_PER_TEST' => '', 'HMIB_COVERAGE_PREFIXES' => $docroot . '/' . trim($prefix, '/') . '/plugins/hmib')); + $process = proc_open($command, array(1 => array('file', '/dev/null', 'w'), 2 => array('file', '/dev/null', 'w')), $pipes, null, $environment); + + $server = array('process' => $process, 'port' => $port, 'docroot' => $docroot, 'link' => $link, 'prefix' => $prefix); + + for ($attempt = 0; $attempt < 50; $attempt++) { + set_error_handler(function () { + return true; + }); + + $socket = stream_socket_client('tcp://localhost:' . $port, $errno, $errstr, 1); + + restore_error_handler(); + + if ($socket !== false) { + fclose($socket); + + return $server; + } + + usleep(100000); + } + + hmib_e2e_stop_server($server); + + throw new RuntimeException('The built-in web server did not start.'); +} + +/** + * Stop the web server and remove the temporary document root. + * + * Only the symlink and the directory this process created are removed, never + * the Cacti tree they point to. + * + * @param array $server The handle from hmib_e2e_start_server(). + * + * @return void + */ +function hmib_e2e_stop_server($server) { + proc_terminate($server['process']); + proc_close($server['process']); + + if ($server['link'] !== null) { + if (is_link($server['link'])) { + unlink($server['link']); + } + + rmdir($server['docroot']); + } +} + +/** + * Send one request to the built-in server. + * + * @param array $server The handle from hmib_e2e_start_server(). + * @param string $method GET or POST. + * @param string $path Path below the install's url_path, with any query string. + * @param array $fields POST fields. + * @param array $cookies Cookie name => value, updated from Set-Cookie. + * @param array $files Uploads: field name => array(filename, content). + * + * @return array status, headers (lowercase name => list), body. + */ +function hmib_e2e_http($server, $method, $path, $fields = array(), &$cookies = array(), $files = array()) { + $socket = stream_socket_client('tcp://localhost:' . $server['port'], $errno, $errstr, 5); + + if ($socket === false) { + throw new RuntimeException('Cannot reach the built-in web server: ' . $errstr); + } + + $body = ''; + $headers = array('Host: localhost', 'Connection: close', 'User-Agent: hmib-e2e'); + + if ($files !== array()) { + $boundary = 'hmib' . bin2hex(random_bytes(8)); + + foreach ($fields as $name => $value) { + $body .= "--$boundary\r\nContent-Disposition: form-data; name=\"$name\"\r\n\r\n$value\r\n"; + } + + foreach ($files as $name => $file) { + $body .= "--$boundary\r\nContent-Disposition: form-data; name=\"$name\"; filename=\"$file[0]\"\r\nContent-Type: text/csv\r\n\r\n$file[1]\r\n"; + } + + $body .= "--$boundary--\r\n"; + $headers[] = 'Content-Type: multipart/form-data; boundary=' . $boundary; + } elseif ($method === 'POST') { + $body = http_build_query($fields); + $headers[] = 'Content-Type: application/x-www-form-urlencoded'; + } + + if ($method === 'POST') { + $headers[] = 'Content-Length: ' . strlen($body); + } + + if ($cookies !== array()) { + $headers[] = 'Cookie: ' . implode('; ', array_map(function ($name, $value) { + return $name . '=' . $value; + }, array_keys($cookies), $cookies)); + } + + fwrite($socket, $method . ' ' . $server['prefix'] . ltrim($path, '/') . " HTTP/1.1\r\n" . implode("\r\n", $headers) . "\r\n\r\n" . $body); + stream_set_timeout($socket, 120); + + $raw = stream_get_contents($socket); + + fclose($socket); + + [$head, $content] = array_pad(explode("\r\n\r\n", (string) $raw, 2), 2, ''); + + $lines = explode("\r\n", $head); + $status = (int) substr($lines[0], 9, 3); + $parsed = array(); + + foreach (array_slice($lines, 1) as $line) { + [$name, $value] = array_pad(explode(':', $line, 2), 2, ''); + + $parsed[strtolower($name)][] = trim($value); + } + + foreach ($parsed['set-cookie'] ?? array() as $cookie) { + [$pair] = explode(';', $cookie, 2); + [$name, $value] = array_pad(explode('=', $pair, 2), 2, ''); + + $cookies[$name] = $value; + } + + if (strtolower($parsed['transfer-encoding'][0] ?? '') === 'chunked') { + $decoded = ''; + + while ($content !== '' && ($size = hexdec(strtok($content, "\r\n"))) > 0) { + $content = substr($content, strpos($content, "\r\n") + 2); + $decoded .= substr($content, 0, $size); + $content = substr($content, $size + 2); + } + + $content = $decoded; + } + + return array('status' => $status, 'headers' => $parsed, 'body' => $content); +} + +/** + * The CSRF token a Cacti page embeds for its scripts. + * + * @param string $body The page. + * + * @return string The token, or an empty string. + */ +function hmib_e2e_token($body) { + return preg_match('/csrfMagicToken\s*=\s*"([^"]+)"/', $body, $match) ? $match[1] : ''; +} + +/** + * Create a user for a test, with the given realm ids. + * + * @param string $username The user name. + * @param int[] $realms user_auth_realm ids to grant. + * + * @return string The generated password. + */ +function hmib_e2e_create_user($username, $realms) { + $password = bin2hex(random_bytes(10)); + + hmib_test_sql('DELETE FROM user_auth_realm WHERE user_id IN (SELECT id FROM user_auth WHERE username = ?)', array($username)); + hmib_test_sql('DELETE FROM user_auth WHERE username = ?', array($username)); + + hmib_test_sql('INSERT INTO user_auth (username, password, realm, full_name, must_change_password, password_change, enabled, lastchange) + VALUES (?, ?, 0, ?, ?, ?, ?, ?)', array($username, password_hash($password, PASSWORD_DEFAULT), $username, '', '', 'on', time())); + + $id = (int) hmib_test_connect()->lastInsertId(); + + foreach ($realms as $realm) { + hmib_test_sql('INSERT INTO user_auth_realm (realm_id, user_id) VALUES (?, ?)', array($realm, $id)); + } + + return $password; +} + +/** + * Log a user in through the login form. + * + * @param array $server The server handle. + * @param string $username The user name. + * @param string $password The password. + * + * @return array The session cookies. + */ +function hmib_e2e_login($server, $username, $password) { + $cookies = array(); + $page = hmib_e2e_http($server, 'GET', 'index.php', array(), $cookies); + + hmib_e2e_http($server, 'POST', 'index.php', array( + '__csrf_magic' => hmib_e2e_token($page['body']), + 'action' => 'login', + 'login_username' => $username, + 'login_password' => $password, + ), $cookies); + + return $cookies; +} + +/** + * Run a PHP script as a subprocess in the Cacti root. + * + * @param string $script Path relative to the Cacti root. + * @param string[] $args Script arguments. + * + * @return array exit code, stdout, stderr. + */ +function hmib_e2e_cli($script, $args = array()) { + $command = array_merge(array(PHP_BINARY), hmib_e2e_php_options(), array($script), $args); + $process = proc_open($command, array(1 => array('pipe', 'w'), 2 => array('pipe', 'w')), $pipes, hmib_e2e_root(), array_merge(getenv(), array('HMIB_COVERAGE_PER_TEST' => ''))); + + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + + fclose($pipes[1]); + fclose($pipes[2]); + + return array('exit' => proc_close($process), 'out' => $stdout, 'err' => $stderr); +} + +/** + * Make the PHP binary the poller launches carry the coverage options. + * + * The pollers start their workers with the path_php_binary setting. When + * coverage is being collected, that setting is pointed at a small wrapper in a + * private directory; the settings table is restored by the caller's snapshot. + * + * @return string|null The wrapper directory to remove afterwards, or null. + */ +function hmib_e2e_php_wrapper() { + $options = hmib_e2e_php_options(); + $dir = sys_get_temp_dir() . '/hmib-e2e-bin-' . bin2hex(random_bytes(6)); + + mkdir($dir, 0700); + + $script = $dir . '/php'; + + file_put_contents($script, "#!/bin/sh\nexec " . implode(' ', array_map('escapeshellarg', array_merge(array(PHP_BINARY), $options))) . " \"\$@\"\n"); + chmod($script, 0700); + + hmib_test_sql('REPLACE INTO settings (name, value) VALUES (?, ?)', array('path_php_binary', $script)); + + return $dir; +} + +/** + * Remove a wrapper directory made by hmib_e2e_php_wrapper(). + * + * @param string|null $dir The directory. + * + * @return void + */ +function hmib_e2e_remove_wrapper($dir) { + if ($dir !== null && is_file($dir . '/php')) { + unlink($dir . '/php'); + rmdir($dir); + } +} + +/** + * Plain text of a page, without scripts and markup. + * + * @param string $body The page. + * + * @return string + */ +function hmib_e2e_text($body) { + return trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags(preg_replace('//s', '', $body)), ENT_QUOTES | ENT_HTML5))); +} + +/** + * Start the server and log in three users: one with the Host MIB viewer and + * admin realms, one with the viewer realm only, and one with a core realm but no plugin realm. + * + * The user tables are copied aside and restored by hmib_e2e_close(). + * + * @return array The context: server, and cookies for admin, viewer and none. + */ +function hmib_e2e_open() { + hmib_test_snapshot(array('user_auth', 'user_auth_realm', 'settings')); + + $context = array('server' => hmib_e2e_start_server(), 'wrapper' => hmib_e2e_php_wrapper(), 'cookies' => array()); + + foreach (array('admin' => array(101, 102, 103), 'viewer' => array(102), 'none' => array(7)) as $role => $realms) { + $name = 'hmibe2e' . $role; + $context['passwords'][$role] = hmib_e2e_create_user($name, $realms); + $context['cookies'][$role] = hmib_e2e_login($context['server'], $name, $context['passwords'][$role]); + } + + return $context; +} + +/** + * Stop the server and put the user tables and settings back. + * + * @param array $context The value hmib_e2e_open() returned. + * + * @return void + */ +function hmib_e2e_close($context) { + hmib_e2e_stop_server($context['server']); + hmib_e2e_remove_wrapper($context['wrapper']); + hmib_test_restore(array('user_auth', 'user_auth_realm', 'settings')); +} + +/** + * Fetch a plugin page as one of the logged-in users. + * + * @param array $context The value hmib_e2e_open() returned. + * @param string $path Path below plugins/hmib/. + * @param string $role admin, viewer or none. + * + * @return array The response, with the plain text added as 'text'. + */ +function hmib_e2e_get($context, $path, $role = 'admin') { + $response = hmib_e2e_http($context['server'], 'GET', 'plugins/hmib/' . $path, array(), $context['cookies'][$role]); + + $response['text'] = hmib_e2e_text($response['body']); + + return $response; +} + +/** + * Post to a plugin page with a CSRF token taken from a page of the same session. + * + * @param array $context The value hmib_e2e_open() returned. + * @param string $path Path below plugins/hmib/. + * @param array $fields POST fields. + * @param string $role admin, viewer or none. + * @param array $files Uploads: field name => array(filename, content). + * @param bool $token False to leave the CSRF token out. + * + * @return array The response. + */ +function hmib_e2e_post($context, $path, $fields, $role = 'admin', $files = array(), $token = true) { + $page = hmib_e2e_http($context['server'], 'GET', 'plugins/hmib/hmib_types.php', array(), $context['cookies'][$role]); + + if ($token) { + $fields['__csrf_magic'] = hmib_e2e_token($page['body']); + } + + return hmib_e2e_http($context['server'], 'POST', 'plugins/hmib/' . $path, $fields, $context['cookies'][$role], $files); +} + +/** + * Assert that a page loaded and printed no PHP warning or SQL error. + * + * @param array $page The response from hmib_e2e_get(). + * + * @return void + */ +function hmib_e2e_assert_clean($page) { + expect($page['status'])->toBe(200); + expect($page['body'])->not->toContain('( ! )'); + expect($page['text'])->not->toContain('SQL'); +} + +/** + * Log all three users in again. + * + * Cacti ends a session when it rejects a request, for example one with a + * missing CSRF token, so a test that sends such a request calls this before + * the next one. + * + * @param array $context The value hmib_e2e_open() returned; updated in place. + * + * @return void + */ +function hmib_e2e_relogin(&$context) { + foreach ($context['passwords'] as $role => $password) { + $context['cookies'][$role] = hmib_e2e_login($context['server'], 'hmibe2e' . $role, $password); + } +} diff --git a/tests/support/fixtures.php b/tests/support/fixtures.php new file mode 100644 index 0000000..b22414a --- /dev/null +++ b/tests/support/fixtures.php @@ -0,0 +1,155 @@ + 3, 'users' => 0, 'cpu' => 0, 'cpus' => 1, 'processes' => 0, 'mem' => 0), $extra); + + hmib_test_sql('INSERT INTO plugin_hmib_hrSystem + (host_id, host_type, host_status, sysDescr, sysObjectID, users, cpuPercent, numCpus, processes, memUsed) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', array($host_id, $type, $values['status'], $descr, $oid, $values['users'], $values['cpu'], $values['cpus'], $values['processes'], $values['mem'])); +} + +/** + * Running process row. + * + * @param int $host_id Device id. + * @param int $index Process index. + * @param string $name Process name. + * @param array $extra Overrides for path, parameters, cpu and memory. + * + * @return void + */ +function hmib_fx_process($host_id, $index, $name, $extra = array()) { + $values = array_merge(array('path' => '', 'parameters' => '', 'cpu' => 0, 'memory' => 0), $extra); + + hmib_test_sql('INSERT INTO plugin_hmib_hrSWRun + (host_id, `index`, name, path, parameters, perfCPU, perfMemory) + VALUES (?, ?, ?, ?, ?, ?, ?)', array($host_id, $index, $name, $values['path'], $values['parameters'], $values['cpu'], $values['memory'])); +} + +/** + * Host type row. + * + * @param int $id Type id. + * @param string $oid sysObjectID pattern. + * @param string $descr sysDescr pattern. + * @param string $name Type name. + * @param string $version Type version. + * + * @return void + */ +function hmib_fx_type($id, $oid, $descr, $name, $version = '1') { + hmib_test_sql('INSERT INTO plugin_hmib_hrSystemTypes (id, sysObjectID, sysDescrMatch, name, version) VALUES (?, ?, ?, ?, ?)', array($id, $oid, $descr, $name, $version)); +} + +/** + * Many running-process rows for one device, to exercise paging. + * + * @param int $host_id Device id. + * @param int $count Number of rows; names are proc0, proc1, and so on, from index 1000. + * + * @return void + */ +function hmib_fx_bulk_processes($host_id, $count) { + $pdo = hmib_test_connect(); + $statement = $pdo->prepare('INSERT INTO plugin_hmib_hrSWRun (host_id, `index`, name, perfCPU) VALUES (?, ?, ?, ?)'); + + $pdo->beginTransaction(); + + for ($i = 0; $i < $count; $i++) { + $statement->execute(array($host_id, 1000 + $i, 'proc' . $i, $i)); + } + + $pdo->commit(); +} + +/** + * The templates and data queries the Summary tab looks up by hash, as bare rows. + * + * @return array Ids: hmib_fx_templates()['cpu'] and so on. + */ +function hmib_fx_templates() { + $ids = array(); + + foreach (array( + 'host_type_dq' => array('snmp_query', '137aeab842986a76cf5bdef41b96c9a3'), + 'cpu_dq' => array('snmp_query', '0d1ab53fe37487a5d0b9e1d3ee8c1d0d'), + 'disk_dq' => array('snmp_query', '9343eab1f4d88b0e61ffc9d020f35414'), + 'apps_dq' => array('snmp_query', '6b0ef0fe7f1d85bbb6812801ca15a7c5'), + 'users_gt' => array('graph_templates', 'e8462bbe094e4e9e814d4e681671ea82'), + 'procs_gt' => array('graph_templates', '62205afbd4066e5c4700338841e3901e'), + 'summary_ht' => array('host_template', '7c13344910097cc599f0d0485305361d'), + ) as $key => $row) { + $ids[$key] = hmib_fx_template_row($row[0], $row[1], $key); + } + + return $ids; +} + +/** + * Insert one template-like row with a fixed hash and return its id. + * + * @param string $table snmp_query, graph_templates or host_template. + * @param string $hash The hash the plugin looks up. + * @param string $name A name for the row. + * + * @return int The new id. + */ +function hmib_fx_template_row($table, $hash, $name) { + $statements = array( + 'snmp_query' => 'INSERT INTO snmp_query (hash, name) VALUES (?, ?)', + 'graph_templates' => 'INSERT INTO graph_templates (hash, name) VALUES (?, ?)', + 'host_template' => 'INSERT INTO host_template (hash, name) VALUES (?, ?)', + ); + + hmib_test_sql($statements[$table], array($hash, $name)); + + return (int) hmib_test_connect()->lastInsertId(); +} From 5e6f098b9b355df104c647c4a4e2cf85122537fc Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 00:52:15 -0600 Subject: [PATCH 20/54] fix: use the default Summary sort when the session has none stored Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + hmib.php | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3286846..bc778c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* issue: Fix PHP warnings on the Summary tab when a request names only the device type table or only the process table and the other table has no stored sort * issue: Fix the process Time Seen total growing on every poller cycle instead of once per process performance collection * issue: Fix graph automation not creating the Host MIB summary device graphs until the next scheduled run after the device is first created * issue: Fix graph automation checking the users graph template instead of the processes graph template before adding the processes graph diff --git a/hmib.php b/hmib.php index 6cc8d3e..ea641f5 100644 --- a/hmib.php +++ b/hmib.php @@ -3224,8 +3224,8 @@ function clearHostFilter() { } $sql_order = hmib_summary_order_by( - (string) $_SESSION['sess_hmib_host_sort_column'], - (string) $_SESSION['sess_hmib_host_sort_direction'], + (string) ($_SESSION['sess_hmib_host_sort_column'] ?? 'upHosts'), + (string) ($_SESSION['sess_hmib_host_sort_direction'] ?? 'DESC'), ['name', 'version/1', '(version/1)', 'upHosts', 'recHosts', 'downHosts', 'disabledHosts', 'users', 'cpus', 'avgCpuPercent', 'maxCpuPercent', 'avgMem', 'maxMem', 'avgSwap', 'maxSwap', 'avgProcesses', 'maxProcesses'], 'upHosts' ); @@ -3346,7 +3346,7 @@ function clearHostFilter() { ] ]; - html_header_sort($display_text, $_SESSION['sess_hmib_host_sort_column'], $_SESSION['sess_hmib_host_sort_direction'], 1, 'hmib.php?action=summary&area=hosts'); + html_header_sort($display_text, $_SESSION['sess_hmib_host_sort_column'] ?? 'upHosts', $_SESSION['sess_hmib_host_sort_direction'] ?? 'DESC', 1, 'hmib.php?action=summary&area=hosts'); if (cacti_sizeof($rows)) { $id = 0; @@ -3508,8 +3508,8 @@ function clearProcFilter() { $sql_params = []; $sql_limit = 'LIMIT ' . ($num_rows * (get_request_var('page') - 1)) . ',' . $num_rows; $sql_order = hmib_summary_order_by( - (string) $_SESSION['sess_hmib_proc_sort_column'], - (string) $_SESSION['sess_hmib_proc_sort_direction'], + (string) ($_SESSION['sess_hmib_proc_sort_column'] ?? 'maxCpu'), + (string) ($_SESSION['sess_hmib_proc_sort_direction'] ?? 'DESC'), ['name', 'paths', 'numHosts', 'numProcesses', 'avgCpu', 'maxCpu', 'avgMemory', 'maxMemory'], 'maxCpu' ); @@ -3600,7 +3600,7 @@ function clearProcFilter() { html_start_box('Process Summary Statistics', '100%', false, 3, 'center', ''); - html_header_sort($display_text, $_SESSION['sess_hmib_proc_sort_column'], $_SESSION['sess_hmib_proc_sort_direction'], 1, 'hmib.php?action=summary&area=processes'); + html_header_sort($display_text, $_SESSION['sess_hmib_proc_sort_column'] ?? 'maxCpu', $_SESSION['sess_hmib_proc_sort_direction'] ?? 'DESC', 1, 'hmib.php?action=summary&area=processes'); // set some defaults $url = $config['url_path'] . 'plugins/hmib/hmib.php'; From b520f7d60d5ccc5bc5bc10f5441f1775a1714345 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 00:53:30 -0600 Subject: [PATCH 21/54] test: end-to-end tests for the Summary tab Signed-off-by: Thomas Vincent --- phpunit.xml | 1 + tests/E2E/SummaryPageTest.php | 268 ++++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 tests/E2E/SummaryPageTest.php diff --git a/phpunit.xml b/phpunit.xml index a60fb49..159aa1f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -17,6 +17,7 @@ tests/Security tests/Unit tests/Integration + tests/E2E diff --git a/tests/E2E/SummaryPageTest.php b/tests/E2E/SummaryPageTest.php new file mode 100644 index 0000000..6819a11 --- /dev/null +++ b/tests/E2E/SummaryPageTest.php @@ -0,0 +1,268 @@ +markTestSkipped(hmib_e2e_unavailable() ?? 'The end-to-end context could not be opened.'); + } + + hmib_e2e_tag($this->name()); + hmib_test_snapshot(HMIB_E2E_SUMMARY_TABLES); + + hmib_fx_type(1, '.1.3.6', 'Linux', 'Linux', '5'); + hmib_fx_type(2, '.1.3.7', 'Windows', 'Windows', '10'); + hmib_fx_host(9001, 'alpha'); + hmib_fx_host(9002, 'beta'); + hmib_fx_host(9003, 'gamma'); + hmib_fx_system(9001, 1, 'Linux box', '.1.3.6', array('users' => 3, 'cpu' => 10, 'processes' => 50, 'mem' => 40)); + hmib_fx_system(9002, 2, 'Windows 11', '.1.3.7', array('users' => 1, 'cpu' => 20)); + hmib_fx_system(9003, 1, 'Linux box', '.1.3.6', array('cpu' => 30)); + hmib_fx_process(9001, 1, "quote'd", array('cpu' => 100, 'memory' => 500, 'path' => '/bin/x')); + hmib_fx_process(9002, 1, 'sshd', array('cpu' => 50)); +}); + +afterEach(function () { + if (isset($GLOBALS['__hmib_e2e'])) { + hmib_test_restore(HMIB_E2E_SUMMARY_TABLES); + } +}); + +function hmib_e2e_summary($query, $role = 'admin') { + // The page keeps its filter in the session, so every request names one. + if (strpos($query, 'filter=') === false) { + $query .= '&filter='; + } + + return hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib.php?action=summary&header=false&' . $query, $role); +} + +function hmib_e2e_position($text, $first, $second) { + return strpos($text, $first) < strpos($text, $second); +} + +it('limits the device type table to the chosen number of types', function (string $top, int $rows) { + hmib_fx_type(3, '.1.3.8', 'Solaris', 'Solaris', '11'); + hmib_fx_host(9004, 'delta'); + hmib_fx_system(9004, 3, 'Solaris box', '.1.3.8'); + + $page = hmib_e2e_summary('area=hosts&htop=' . $top); + + hmib_e2e_assert_clean($page); + expect(preg_match_all('/(Linux|Windows|Solaris) \d+ \d/', $page['text']))->toBe($rows); +})->with(array('five' => array('5', 3), 'one' => array('1', 1), 'all' => array('-1', 3), 'zero' => array('0', 3))); + +it('lists every device type with its device count', function () { + $page = hmib_e2e_summary('area=hosts'); + + expect($page['status'])->toBe(200); + expect($page['text'])->toContain('Device Type Summary Statistics'); + expect($page['text'])->toContain('Windows 10 1 0 0 0 1 1 20%'); + expect($page['text'])->toContain('Linux 5 2 0 0 0 3 2 20%'); +}); + +it('orders the device types by name in both directions', function () { + expect(hmib_e2e_position(hmib_e2e_summary('area=hosts&sort_column=name&sort_direction=ASC')['text'], 'Linux 5', 'Windows 10'))->toBeTrue(); + expect(hmib_e2e_position(hmib_e2e_summary('area=hosts&sort_column=name&sort_direction=DESC')['text'], 'Windows 10', 'Linux 5'))->toBeTrue(); +}); + +it('orders the device types by login count', function () { + expect(hmib_e2e_position(hmib_e2e_summary('area=hosts&sort_column=users&sort_direction=DESC')['text'], 'Linux 5', 'Windows 10'))->toBeTrue(); + expect(hmib_e2e_position(hmib_e2e_summary('area=hosts&sort_column=users&sort_direction=ASC')['text'], 'Windows 10', 'Linux 5'))->toBeTrue(); +}); + +it('renders the device type table for every sortable column and direction', function (string $column, string $direction) { + $page = hmib_e2e_summary('area=hosts&sort_column=' . rawurlencode($column) . '&sort_direction=' . $direction); + + hmib_e2e_assert_clean($page); + expect($page['text'])->toContain('Windows 10'); + expect($page['text'])->toContain('Linux 5'); +})->with(function () { + foreach (HMIB_E2E_HOST_SORTS as $column) { + foreach (array('ASC', 'DESC') as $direction) { + yield $column . ' ' . $direction => array($column, $direction); + } + } +}); + +it('renders the process table for every sortable column and direction', function (string $column, string $direction) { + $page = hmib_e2e_summary('area=processes&sort_column=' . $column . '&sort_direction=' . $direction); + + hmib_e2e_assert_clean($page); + expect($page['text'])->toContain('Process Summary Statistics'); + expect($page['text'])->toContain('sshd'); +})->with(function () { + foreach (HMIB_E2E_PROCESS_SORTS as $column) { + foreach (array('ASC', 'DESC') as $direction) { + yield $column . ' ' . $direction => array($column, $direction); + } + } +}); + +it('orders the processes by their highest CPU time', function () { + expect(hmib_e2e_position(hmib_e2e_summary('area=processes&sort_column=maxCpu&sort_direction=DESC')['text'], "quote'd", 'sshd'))->toBeTrue(); + expect(hmib_e2e_position(hmib_e2e_summary('area=processes&sort_column=maxCpu&sort_direction=ASC')['text'], 'sshd', "quote'd"))->toBeTrue(); +}); + +it('falls back to the default order for a sort column the table does not have', function (string $area, string $hostile, string $first, string $second) { + $page = hmib_e2e_summary('area=' . $area . '&sort_column=' . rawurlencode($hostile) . '&sort_direction=DESC'); + + hmib_e2e_assert_clean($page); + expect(hmib_e2e_position($page['text'], $first, $second))->toBeTrue(); +})->with(array( + 'unknown column, processes' => array('processes', 'not_a_column', "quote'd", 'sshd'), + 'column with a trailing clause' => array('processes', 'name desc limit 1', "quote'd", 'sshd'), + 'column with a comment' => array('processes', 'name -- ', "quote'd", 'sshd'), + 'column with a second statement' => array('processes', 'name; select 1', "quote'd", 'sshd'), + 'unknown column, device types' => array('hosts', 'not_a_column', 'Linux 5', 'Windows 10'), + 'column of the other table' => array('hosts', 'maxCpu', 'Linux 5', 'Windows 10'), +)); + +it('treats an unknown sort direction as descending', function () { + $page = hmib_e2e_summary('area=processes&sort_column=maxCpu&sort_direction=sideways'); + + hmib_e2e_assert_clean($page); + expect(hmib_e2e_position($page['text'], "quote'd", 'sshd'))->toBeTrue(); +}); + +it('filters the processes and counts the filtered rows', function (string $filter, array $present, array $absent, string $total) { + $page = hmib_e2e_summary('area=processes&filter=' . rawurlencode($filter)); + + hmib_e2e_assert_clean($page); + expect($page['text'])->toContain($total); + + foreach ($present as $name) { + expect($page['text'])->toContain($name); + } + + foreach ($absent as $name) { + expect($page['text'])->not->toContain($name); + } +})->with(array( + 'by name' => array('sshd', array('sshd'), array("quote'd"), 'All 1 Summary Stats'), + 'by path' => array('/bin/x', array("quote'd"), array('sshd'), 'All 1 Summary Stats'), + 'with a quote' => array("quote'd", array("quote'd"), array('sshd'), 'All 1 Summary Stats'), + 'with a wildcard' => array('%', array("quote'd", 'sshd'), array(), 'All 2 Summary Stats'), + 'matching nothing' => array('no-such-process', array('No Processes'), array('sshd', "quote'd"), 'No Processes'), +)); + +it('pages through many processes and keeps the filtered count', function () { + hmib_fx_bulk_processes(9001, 1200); + + $all = hmib_e2e_summary('area=processes&ptop=50'); + $filtered = hmib_e2e_summary('area=processes&ptop=50&filter=proc1'); + + hmib_e2e_assert_clean($all); + expect($all['text'])->toContain('1 to 50 of 1202'); + expect($filtered['text'])->toContain('1 to 50 of 311'); +}); + +it('shows an empty page, not an error, when no device reports', function () { + hmib_test_sql('DELETE FROM plugin_hmib_hrSWRun'); + hmib_test_sql('DELETE FROM plugin_hmib_hrSystem'); + + $page = hmib_e2e_summary('area=processes'); + + hmib_e2e_assert_clean($page); + expect($page['text'])->toContain('No Processes'); +}); + +it('escapes stored values in the device type table', function () { + hmib_test_sql('UPDATE plugin_hmib_hrSystemTypes SET version = ?, name = ? WHERE id = 1', array('5', 'Linux')); + + $page = hmib_e2e_summary('area=hosts'); + + expect($page['body'])->toContain('<b>5</b>'); + expect($page['body'])->not->toContain('5'); + expect($page['body'])->not->toContain('Linux'); +}); + +it('links the data query graphs of every device of a type', function () { + $ids = hmib_fx_templates(); + + hmib_test_sql('INSERT INTO graph_local (id, host_id, snmp_query_id, snmp_index, graph_template_id) VALUES (501, 9001, ?, ?, 0), (502, 9002, ?, ?, 0)', array($ids['cpu_dq'], '1', $ids['cpu_dq'], '1')); + + $page = hmib_e2e_summary('area=hosts'); + + expect($page['body'])->toContain('graph_add=501&'); + expect($page['body'])->toContain('graph_add=502&'); + expect($page['body'])->not->toContain('graph_add=501,502'); +}); + +it('links a data query graph for one index with a quote in it', function () { + $ids = hmib_fx_templates(); + + hmib_test_sql('INSERT INTO graph_local (id, host_id, snmp_query_id, snmp_index, graph_template_id) VALUES (503, 9001, ?, ?, 0)', array($ids['apps_dq'], "quote'd")); + + $page = hmib_e2e_summary('area=processes'); + + hmib_e2e_assert_clean($page); + expect($page['body'])->toContain('graph_add=503&'); +}); + +it('does not link graphs of other indexes or other queries', function () { + $ids = hmib_fx_templates(); + + hmib_test_sql('INSERT INTO graph_local (id, host_id, snmp_query_id, snmp_index, graph_template_id) VALUES (504, 9001, ?, ?, 0)', array($ids['apps_dq'], 'other')); + + $page = hmib_e2e_summary('area=processes'); + + expect($page['body'])->not->toContain('graph_add=504'); +}); + +it('refuses the Summary tab to a user without the viewer realm', function () { + $page = hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib.php?action=summary&header=false', 'none'); + + expect($page['text'])->toContain('Permission Denied'); + expect($page['text'])->not->toContain('Process Summary Statistics'); +}); + +it('sends an anonymous request to the login page', function () { + $cookies = array(); + $response = hmib_e2e_http($GLOBALS['__hmib_e2e']['server'], 'GET', 'plugins/hmib/hmib.php?action=summary', array(), $cookies); + + expect(hmib_e2e_text($response['body']))->toContain('Enter your Username and Password below'); +}); From dbcbbf37be32425e79012a0a838033601a577758 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 00:54:35 -0600 Subject: [PATCH 22/54] test: end-to-end tests for the Devices, Processes and Graphs tabs Signed-off-by: Thomas Vincent --- tests/E2E/DevicesPageTest.php | 166 ++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 tests/E2E/DevicesPageTest.php diff --git a/tests/E2E/DevicesPageTest.php b/tests/E2E/DevicesPageTest.php new file mode 100644 index 0000000..c8ab489 --- /dev/null +++ b/tests/E2E/DevicesPageTest.php @@ -0,0 +1,166 @@ +markTestSkipped(hmib_e2e_unavailable() ?? 'The end-to-end context could not be opened.'); + } + + hmib_e2e_tag($this->name()); + hmib_test_snapshot(HMIB_E2E_DEVICE_TABLES); + + hmib_fx_type(1, '.1.3.6', 'Linux', 'Linux', '5'); + hmib_fx_host(9001, 'alpha'); + hmib_fx_host(9002, 'beta'); + hmib_fx_system(9001, 1, 'Linux box', '.1.3.6', array('users' => 3, 'cpu' => 10, 'processes' => 50, 'mem' => 40)); + hmib_fx_system(9002, 1, 'Linux box', '.1.3.6', array('cpu' => 20)); + hmib_fx_process(9001, 1, 'alphad', array('cpu' => 100)); + hmib_fx_process(9002, 1, 'betad', array('cpu' => 50)); +}); + +afterEach(function () { + if (isset($GLOBALS['__hmib_e2e'])) { + hmib_test_restore(HMIB_E2E_DEVICE_TABLES); + } +}); + +function hmib_e2e_page($action, $query = '', $role = 'admin') { + return hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib.php?action=' . $action . '&header=false&filter=' . $query, $role); +} + +/** The links to a page that name a device, as the row glyphs do. */ +function hmib_e2e_links($body, $action) { + preg_match_all("/href='([^']*action=" . $action . "[^']*device=[^']*)'/", html_entity_decode($body), $matches); + + return array_values(array_unique($matches[1])); +} + +it('lists every device with its row actions', function () { + $page = hmib_e2e_page('devices'); + + hmib_e2e_assert_clean($page); + expect($page['text'])->toContain('alpha'); + expect($page['text'])->toContain('beta'); + expect(hmib_e2e_links($page['body'], 'running'))->toHaveCount(2); +}); + +it('opens the processes of one device from the glyph on its row', function () { + $links = hmib_e2e_links(hmib_e2e_page('devices')['body'], 'running'); + + expect($links)->toContain('/cacti/plugins/hmib/hmib.php?action=running&reset=1&device=9001'); + + $opened = hmib_e2e_get($GLOBALS['__hmib_e2e'], substr($links[0], strlen('/cacti/plugins/hmib/')) . '&header=false'); + + hmib_e2e_assert_clean($opened); + expect($opened['text'])->toContain('alphad'); + expect($opened['text'])->not->toContain('betad'); +}); + +it('opens every other glyph target on the device row', function (string $action) { + $link = hmib_e2e_links(hmib_e2e_page('devices')['body'], $action)[0]; + + $opened = hmib_e2e_get($GLOBALS['__hmib_e2e'], substr($link, strlen('/cacti/plugins/hmib/')) . '&header=false'); + + hmib_e2e_assert_clean($opened); +})->with(array('storage', 'hardware', 'software', 'dashboard')); + +it('lists the running processes of all devices and of one device', function () { + $all = hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib.php?action=running&header=false&device=-1&filter='); + $one = hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib.php?action=running&header=false&device=9002&filter='); + + hmib_e2e_assert_clean($all); + expect($all['text'])->toContain('alphad'); + expect($all['text'])->toContain('betad'); + expect($one['text'])->toContain('betad'); + expect($one['text'])->not->toContain('alphad'); +}); + +it('links the device graph counts to that device only', function () { + $ids = hmib_fx_templates(); + + hmib_test_sql('INSERT INTO graph_local (id, host_id, snmp_query_id, snmp_index, graph_template_id) VALUES (601, 9001, ?, ?, 0), (602, 9002, ?, ?, 0)', array($ids['cpu_dq'], '1', $ids['cpu_dq'], '1')); + + $page = hmib_e2e_page('devices'); + + hmib_e2e_assert_clean($page); + expect($page['body'])->toContain('graph_add=601&'); + expect($page['body'])->toContain('graph_add=602&'); + expect($page['body'])->not->toContain('graph_add=601,602'); +}); + +it('shows no graph link for a device that has no graphs', function () { + $ids = hmib_fx_templates(); + + hmib_test_sql('INSERT INTO graph_local (id, host_id, snmp_query_id, snmp_index, graph_template_id) VALUES (603, 9001, ?, ?, 0)', array($ids['cpu_dq'], '1')); + + $page = hmib_e2e_page('devices'); + + expect($page['body'])->toContain('graph_add=603&'); + expect(substr_count($page['body'], 'graph_add=603'))->toBe(substr_count($page['body'], 'graph_add=603&')); +}); + +it('shows a user graphs of the selected devices on the Graphs tab', function () { + $ids = hmib_fx_templates(); + + hmib_test_sql('INSERT INTO graph_local (id, host_id, snmp_query_id, snmp_index, graph_template_id) VALUES (604, 9001, ?, ?, ?)', array($ids['cpu_dq'], '1', $ids['users_gt'])); + + $page = hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib.php?action=graphs&header=false&reset=1&style=selective&graph_add=604&graph_list=&graph_template_id=0&filter='); + + if (strpos($page['body'], 'undefined function graph_view_has_untemplated_graphs') !== false) { + $this->markTestSkipped('Cacti 1.2.x defines graph_view_has_untemplated_graphs() in graph_view.php only, so its graph filter cannot be used from a plugin page.'); + } + + hmib_e2e_assert_clean($page); +}); + +it('refuses the Devices tab without the viewer realm', function () { + expect(hmib_e2e_page('devices', '', 'none')['text'])->toContain('Permission Denied'); +}); + +it('shows an empty Devices tab when no device reports', function () { + hmib_test_sql('DELETE FROM plugin_hmib_hrSystem'); + + $page = hmib_e2e_page('devices'); + + hmib_e2e_assert_clean($page); + expect($page['text'])->toContain('No Devices Found'); +}); From c056ca07853264cc324a87165ac7f3de840195d9 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 00:55:17 -0600 Subject: [PATCH 23/54] test: end-to-end tests for the OS type page, rescan, import and export Signed-off-by: Thomas Vincent --- tests/E2E/HostTypesTest.php | 332 ++++++++++++++++++++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 tests/E2E/HostTypesTest.php diff --git a/tests/E2E/HostTypesTest.php b/tests/E2E/HostTypesTest.php new file mode 100644 index 0000000..5f47a48 --- /dev/null +++ b/tests/E2E/HostTypesTest.php @@ -0,0 +1,332 @@ +markTestSkipped(hmib_e2e_unavailable() ?? 'The end-to-end context could not be opened.'); + } + + hmib_e2e_tag($this->name()); + hmib_e2e_relogin($GLOBALS['__hmib_e2e']); + hmib_test_snapshot(HMIB_E2E_TYPE_TABLES); +}); + +afterEach(function () { + if (isset($GLOBALS['__hmib_e2e'])) { + hmib_test_restore(HMIB_E2E_TYPE_TABLES); + } +}); + +function hmib_e2e_types() { + return array_map(function ($row) { + return array_map('strval', $row); + }, hmib_test_sql('SELECT id, name, version, sysDescrMatch, sysObjectID FROM plugin_hmib_hrSystemTypes ORDER BY id')->fetchAll()); +} + +function hmib_e2e_assignments() { + return hmib_test_sql('SELECT host_id, host_type FROM plugin_hmib_hrSystem ORDER BY host_id')->fetchAll(PDO::FETCH_KEY_PAIR); +} + +function hmib_e2e_rescan($role = 'admin', $token = true) { + return hmib_e2e_post($GLOBALS['__hmib_e2e'], 'hmib_types.php', array('scan' => 'true'), $role, array(), $token); +} + +function hmib_e2e_rescan_fixture() { + hmib_fx_type(20, '.1.3.5', 'Windows', 'Windows', '1'); + hmib_fx_type(21, '.1.3.50', "it's quoted", 'Quoted', '1'); + hmib_fx_type(22, '.1.3.99', 'Linux', 'Linux', '1'); + + $long = str_repeat('x', 150); + + foreach (array(1 => array('Linux box', '.1.3.99'), 2 => array('Linux box', '.1.3.99'), 3 => array($long, '.1.3.77'), 4 => array('', ''), 6 => array($long, '.1.3.77'), 7 => array('Linux box', '.1.3.99')) as $id => $reported) { + hmib_fx_host(9000 + $id, 'host' . $id); + hmib_fx_system(9000 + $id, 0, $reported[0], $reported[1]); + } + + hmib_fx_host(9005, 'host5'); + hmib_fx_system(9005, 9, 'Windows 11', '.1.3.5'); +} + +function hmib_e2e_import($csv, $update = false, $role = 'admin') { + $fields = array('action' => 'save', 'save_component_import' => '1'); + + if ($update) { + $fields['allow_update'] = 'on'; + } + + hmib_e2e_post($GLOBALS['__hmib_e2e'], 'hmib_types.php', $fields, $role, array('import_file' => array('types.csv', $csv))); + + return hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib_types.php?action=import&header=false', $role); +} + +it('lists the OS types', function () { + hmib_fx_type(1, '.1.3.6', 'Linux', 'Linux', '5'); + + $page = hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib_types.php?header=false&filter='); + + hmib_e2e_assert_clean($page); + expect($page['text'])->toContain('Linux 5 0 .1.3.6 Linux'); +}); + +it('escapes every stored field in the OS type list', function () { + hmib_fx_type(1, 'oid', 'descr', 'name', 'v'); + + $page = hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib_types.php?header=false&filter='); + + foreach (array('oid', 'descr', 'name', 'v') as $raw) { + expect($page['body'])->not->toContain($raw); + expect($page['body'])->toContain(htmlspecialchars($raw, ENT_QUOTES | ENT_HTML5)); + } +}); + +it('escapes the stored values on the edit form', function () { + hmib_fx_type(1, 'oid', 'descr', 'name', 'v'); + + $page = hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib_types.php?action=edit&header=false&id=1'); + + expect($page['status'])->toBe(200); + expect($page['body'])->not->toContain('name'); + expect($page['body'])->not->toContain('oid'); +}); + +it('refuses the OS type list to a user who only has the viewer realm', function () { + expect(hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib_types.php?header=false', 'viewer')['text'])->toContain('Permission Denied'); +}); + +it('rescans by POST: assigns known types, adds one placeholder type, keeps typed devices', function () { + hmib_e2e_rescan_fixture(); + + $response = hmib_e2e_rescan(); + + expect($response['status'])->toBe(302); + + $assigned = hmib_e2e_assignments(); + + expect($assigned[9001])->toBe(22); + expect($assigned[9002])->toBe(22); + expect($assigned[9007])->toBe(22); + expect($assigned[9005])->toBe(9); + expect($assigned[9003])->toBe(0); + expect($assigned[9004])->toBe(0); + + $added = array_values(array_filter(hmib_e2e_types(), function ($type) { + return $type['name'] === 'New Type'; + })); + + expect($added)->toHaveCount(1); + expect($added[0]['sysObjectID'])->toBe('.1.3.77'); + expect(strlen($added[0]['sysDescrMatch']))->toBe(100); +}); + +it('reports the types a rescan added, and none on the second run', function () { + hmib_e2e_rescan_fixture(); + hmib_e2e_rescan(); + + $first = hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib_types.php?filter='); + + hmib_e2e_rescan(); + + expect($first['body'])->toContain('Device Types Added'); + expect(array_filter(hmib_e2e_types(), function ($type) { + return $type['name'] === 'New Type'; + }))->toHaveCount(1); +}); + +it('reports when a rescan finds nothing new', function () { + hmib_fx_type(22, '.1.3.99', 'Linux', 'Linux', '1'); + hmib_fx_host(9001, 'host1'); + hmib_fx_system(9001, 22, 'Linux box', '.1.3.99'); + + hmib_e2e_rescan(); + + expect(hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib_types.php?filter=')['body'])->toContain('No New Host Types Found'); +}); + +it('does not rescan on a GET request', function () { + hmib_e2e_rescan_fixture(); + + $response = hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib_types.php?scan=true&header=false'); + + expect($response['status'])->toBe(200); + expect(hmib_e2e_assignments()[9001])->toBe(0); + expect(array_filter(hmib_e2e_types(), function ($type) { + return $type['name'] === 'New Type'; + }))->toHaveCount(0); +}); + +it('does not rescan for a POST without the CSRF token', function () { + hmib_e2e_rescan_fixture(); + + hmib_e2e_rescan('admin', false); + + expect(hmib_e2e_assignments()[9001])->toBe(0); + expect(array_filter(hmib_e2e_types(), function ($type) { + return $type['name'] === 'New Type'; + }))->toHaveCount(0); +}); + +it('does not rescan for a user without the admin realm', function (string $role) { + hmib_e2e_rescan_fixture(); + + hmib_e2e_rescan($role); + + expect(hmib_e2e_assignments()[9001])->toBe(0); +})->with(array('viewer', 'none')); + +it('does not rescan for an anonymous POST', function () { + hmib_e2e_rescan_fixture(); + + $cookies = array(); + + hmib_e2e_http($GLOBALS['__hmib_e2e']['server'], 'POST', 'plugins/hmib/hmib_types.php', array('scan' => 'true'), $cookies); + + expect(hmib_e2e_assignments()[9001])->toBe(0); +}); + +it('exports the OS types as quoted CSV that parses back unchanged', function () { + hmib_fx_type(1, '.1.3.6', 'Linux, "server"', 'Quoted "name"', '5'); + + $response = hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib_types.php?export=true'); + $lines = explode("\n", trim($response['body'])); + + expect($response['headers']['content-type'][0])->toContain('csv'); + expect($lines[0])->toBe('"id","name","version","sysDescrMatch","sysObjectID"'); + expect(str_getcsv($lines[1], ',', '"', ''))->toBe(array('1', 'Quoted "name"', '5', 'Linux, "server"', '.1.3.6')); +}); + +it('exports values that start like a formula as text', function (string $value) { + hmib_fx_type(1, '.1.3.6', $value, 'Formula', '1'); + + $lines = explode("\n", trim(hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib_types.php?export=true')['body'])); + + expect(str_getcsv($lines[1], ',', '"', '')[3])->toBe("'" . $value); +})->with(array('=1+1', '+5', '-5', '@sum')); + +it('exports an empty list as the header only', function () { + $lines = explode("\n", trim(hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib_types.php?export=true')['body'])); + + expect($lines)->toBe(array('"id","name","version","sysDescrMatch","sysObjectID"')); +}); + +it('imports new types and skips the ones that exist', function () { + hmib_fx_type(1, '.1.3.6', 'Linux', 'Linux', '5'); + + $result = hmib_e2e_import(HMIB_E2E_CSV_HEADER . "1,Linux,5,Linux,.1.3.6\n2,Windows,10,Windows,.1.3.7\n"); + + expect($result['text'])->toContain('INSERT SKIPPED, EXISTING'); + expect($result['text'])->toContain('INSERT SUCCEEDED'); + expect(array_column(hmib_e2e_types(), 'name'))->toBe(array('Linux', 'Windows')); +}); + +it('updates existing types only when asked to', function () { + hmib_fx_type(1, '.1.3.6', 'Linux', 'Linux', '5'); + + hmib_e2e_import(HMIB_E2E_CSV_HEADER . "1,Renamed,5,Linux,.1.3.6\n"); + + expect(hmib_e2e_types()[0]['name'])->toBe('Linux'); + + hmib_e2e_import(HMIB_E2E_CSV_HEADER . "1,Renamed,5,Linux,.1.3.6\n", true); + + expect(hmib_e2e_types()[0]['name'])->toBe('Renamed'); +}); + +it('imports values with quotes and commas', function () { + hmib_e2e_import(HMIB_E2E_CSV_HEADER . "5,\"A \"\"b\"\", c\",1,\"it's, here\",.1.3.9\n"); + + expect(hmib_e2e_types()[0])->toBe(array('id' => '5', 'name' => 'A "b", c', 'version' => '1', 'sysDescrMatch' => "it's, here", 'sysObjectID' => '.1.3.9')); +}); + +it('exports what it imported', function () { + hmib_e2e_import(HMIB_E2E_CSV_HEADER . "5,Name,1,Descr,.1.3.9\n6,Other,2,More,.1.3.8\n"); + + $before = hmib_e2e_types(); + $export = hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib_types.php?export=true')['body']; + + hmib_test_sql('DELETE FROM plugin_hmib_hrSystemTypes'); + hmib_e2e_import($export); + + expect(hmib_e2e_types())->toBe($before); +}); + +it('escapes the values it reports back after an import', function () { + $result = hmib_e2e_import(HMIB_E2E_CSV_HEADER . "5,n,1,d,.1.3.9\n"); + + expect($result['body'])->not->toContain('n'); + expect($result['body'])->toContain('<b>n</b>'); +}); + +it('rejects an import whose header lacks the required columns', function () { + $result = hmib_e2e_import("name,version\nLinux,5\n"); + + expect($result['text'])->toContain('HEADER LINE PROCESSING ERROR'); + expect(hmib_e2e_types())->toBe(array()); +}); + +it('does nothing for an import without a file', function () { + hmib_e2e_post($GLOBALS['__hmib_e2e'], 'hmib_types.php', array('action' => 'save', 'save_component_import' => '1')); + + expect(hmib_e2e_types())->toBe(array()); +}); + +it('does not import for a user without the admin realm', function () { + hmib_e2e_import(HMIB_E2E_CSV_HEADER . "5,Name,1,Descr,.1.3.9\n", false, 'viewer'); + + expect(hmib_e2e_types())->toBe(array()); +}); + +it('creates and updates a type from the edit form', function () { + $fields = array('action' => 'save', 'save_component_host_type' => '1', 'id' => '0', 'name' => 'Created', 'version' => '1', 'sysDescrMatch' => 'Descr', 'sysObjectID' => '.1.3.4'); + + hmib_e2e_post($GLOBALS['__hmib_e2e'], 'hmib_types.php', $fields); + + $created = hmib_e2e_types(); + + expect($created)->toHaveCount(1); + expect($created[0]['name'])->toBe('Created'); + + hmib_e2e_post($GLOBALS['__hmib_e2e'], 'hmib_types.php', array_merge($fields, array('id' => $created[0]['id'], 'name' => 'Updated'))); + + expect(hmib_e2e_types()[0]['name'])->toBe('Updated'); +}); + +it('refuses to create a type without a name', function () { + hmib_e2e_post($GLOBALS['__hmib_e2e'], 'hmib_types.php', array('action' => 'save', 'save_component_host_type' => '1', 'id' => '0', 'name' => '', 'version' => '1', 'sysDescrMatch' => 'Descr', 'sysObjectID' => '.1.3.4')); + + expect(hmib_e2e_types())->toBe(array()); +}); From cdb1aad8b1829b3fd2e0888f01bac8c9606f3103 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 00:56:16 -0600 Subject: [PATCH 24/54] test: end-to-end tests for the graph automation and the pollers Signed-off-by: Thomas Vincent --- tests/E2E/PollersTest.php | 336 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 tests/E2E/PollersTest.php diff --git a/tests/E2E/PollersTest.php b/tests/E2E/PollersTest.php new file mode 100644 index 0000000..884c225 --- /dev/null +++ b/tests/E2E/PollersTest.php @@ -0,0 +1,336 @@ +markTestSkipped($why); + } + + hmib_e2e_tag($this->name()); + hmib_test_snapshot(HMIB_E2E_POLLER_TABLES); + + $GLOBALS['__hmib_wrapper'] = hmib_e2e_php_wrapper(); + + hmib_test_sql('REPLACE INTO settings (name, value) VALUES (?, ?), (?, ?)', array('hmib_enabled', 'on', 'hmib_automation_frequency', '1')); +}); + +afterEach(function () { + if (isset($GLOBALS['__hmib_wrapper'])) { + hmib_e2e_remove_wrapper($GLOBALS['__hmib_wrapper']); + unset($GLOBALS['__hmib_wrapper']); + hmib_test_restore(HMIB_E2E_POLLER_TABLES); + } +}); + +function hmib_e2e_graphs($arguments = array('--force', '--debug')) { + $run = hmib_e2e_cli('plugins/hmib/poller_graphs.php', $arguments); + + expect($run['exit'])->toBe(0); + expect($run['out'])->not->toContain('Fatal error'); + + return $run['out']; +} + +function hmib_e2e_host_graphs() { + return hmib_test_sql('SELECT host_id, graph_template_id FROM host_graph WHERE host_id >= 9000 ORDER BY graph_template_id')->fetchAll(); +} + +it('adds the users and processes graphs when both templates exist', function () { + $ids = hmib_fx_templates(); + + hmib_fx_host(9001, 'alpha'); + hmib_fx_system(9001, 1, 'Linux', '.1.3.6'); + + $out = hmib_e2e_graphs(); + + expect($out)->toContain('DEBUG: Processing Users'); + expect($out)->toContain('DEBUG: Processing Processes'); + expect($out)->toContain("Adding Graph: 'users_gt' for Host: 9001"); + expect($out)->toContain("Adding Graph: 'procs_gt' for Host: 9001"); + expect($out)->toContain('Graph Added'); + expect(array_column(hmib_e2e_host_graphs(), 'graph_template_id'))->toEqual(array($ids['users_gt'], $ids['procs_gt'])); +}); + +it('adds only the users graph when only the users template exists', function () { + $id = hmib_fx_template_row('graph_templates', 'e8462bbe094e4e9e814d4e681671ea82', 'users_gt'); + + hmib_fx_host(9001, 'alpha'); + hmib_fx_system(9001, 1, 'Linux', '.1.3.6'); + + $out = hmib_e2e_graphs(); + + expect($out)->toContain('DEBUG: Processing Users'); + expect($out)->toContain('DEBUG: Processes Graph Template Not Set'); + expect(array_column(hmib_e2e_host_graphs(), 'graph_template_id'))->toEqual(array($id)); +}); + +it('adds only the processes graph when only the processes template exists', function () { + $id = hmib_fx_template_row('graph_templates', '62205afbd4066e5c4700338841e3901e', 'procs_gt'); + + hmib_fx_host(9001, 'alpha'); + hmib_fx_system(9001, 1, 'Linux', '.1.3.6'); + + $out = hmib_e2e_graphs(); + + expect($out)->toContain('DEBUG: Users Graph Template Not Set'); + expect($out)->toContain('DEBUG: Processing Processes'); + expect(array_column(hmib_e2e_host_graphs(), 'graph_template_id'))->toEqual(array($id)); +}); + +it('adds no graph and no association when neither template exists', function () { + hmib_fx_host(9001, 'alpha'); + hmib_fx_system(9001, 1, 'Linux', '.1.3.6'); + + $out = hmib_e2e_graphs(); + + expect($out)->toContain('DEBUG: Users Graph Template Not Set'); + expect($out)->toContain('DEBUG: Processes Graph Template Not Set'); + expect(hmib_e2e_host_graphs())->toBe(array()); +}); + +it('does not add a graph for a device that is down or disabled', function () { + hmib_fx_templates(); + hmib_fx_host(9001, 'alpha'); + hmib_fx_host(9002, 'beta'); + hmib_fx_system(9001, 1, 'Linux', '.1.3.6', array('status' => 1)); + hmib_fx_system(9002, 1, 'Linux', '.1.3.6'); + hmib_test_sql('UPDATE host SET disabled = ? WHERE id = ?', array('on', 9002)); + + $out = hmib_e2e_graphs(); + + expect($out)->toContain('No Hosts Found'); + expect(hmib_e2e_host_graphs())->toBe(array()); +}); + +it('creates the summary device from its template and gives it the template graphs', function () { + $ids = hmib_fx_templates(); + + hmib_test_sql('INSERT INTO host_template_graph (host_template_id, graph_template_id) VALUES (?, ?)', array($ids['summary_ht'], $ids['users_gt'])); + + $out = hmib_e2e_graphs(); + + $summary = hmib_test_sql('SELECT id FROM host WHERE host_template_id = ?', array($ids['summary_ht']))->fetchAll(PDO::FETCH_COLUMN); + + expect($out)->toContain('Host MIB Summary Device Not Found, Adding'); + expect($summary)->toHaveCount(1); + expect(hmib_test_sql('SELECT graph_template_id FROM host_graph WHERE host_id = ?', array($summary[0]))->fetchAll(PDO::FETCH_COLUMN))->toEqual(array($ids['users_gt'])); +}); + +it('reindexes the summary device on the next run instead of creating another', function () { + $ids = hmib_fx_templates(); + + hmib_e2e_graphs(); + hmib_e2e_graphs(); + + $out = hmib_e2e_graphs(); + + expect($out)->toContain('Host Exists Hostname is'); + expect($out)->toContain('Reindexing Host'); + expect(hmib_test_sql('SELECT COUNT(*) FROM host WHERE host_template_id = ?', array($ids['summary_ht']))->fetchColumn())->toBe(1); +}); + +it('skips the summary device when its template is not imported', function () { + hmib_fx_host(9001, 'alpha'); + hmib_fx_system(9001, 1, 'Linux', '.1.3.6'); + + expect(hmib_e2e_graphs())->not->toContain('Host MIB Summary Device Not Found'); +}); + +it('reports a disabled automation frequency without doing anything', function () { + hmib_fx_templates(); + hmib_test_sql('REPLACE INTO settings (name, value) VALUES (?, ?)', array('hmib_automation_frequency', '0')); + + expect(hmib_e2e_graphs(array('--debug')))->toContain('Graph Automation is Disabled'); + expect(hmib_test_sql('SELECT COUNT(*) FROM host WHERE host_template_id > 0')->fetchColumn())->toBe(0); +}); + +it('does not run the automation again before its frequency has passed', function () { + hmib_fx_templates(); + hmib_test_sql('REPLACE INTO settings (name, value) VALUES (?, ?)', array('hmib_automation_lastrun', (string) time())); + + expect(hmib_e2e_graphs(array('--debug')))->toContain('Not Time to Run Automation'); +}); + +it('does nothing while the collection is disabled', function () { + hmib_test_sql('REPLACE INTO settings (name, value) VALUES (?, ?)', array('hmib_enabled', '')); + + expect(hmib_e2e_graphs())->toContain('The Host Mib Collection is Down'); +}); + +it('rejects an unknown argument', function () { + $run = hmib_e2e_cli('plugins/hmib/poller_graphs.php', array('--no-such-option')); + + expect($run['out'])->toContain('Invalid Parameter'); +}); + +it('prints its version and usage', function (string $flag, string $text) { + $run = hmib_e2e_cli('plugins/hmib/poller_graphs.php', array($flag)); + + expect($run['out'])->toContain($text); +})->with(array('version' => array('--version', 'Host MIB Graph Automator, Version'), 'help' => array('--help', 'usage: poller_graphs.php'))); + +/* + * The per-device worker. The fixture host name does not resolve, so no + * collector finds data; what is checked is how the worker treats the process + * history it already has. + */ +function hmib_e2e_worker($perf_due, $swrun_due = false) { + $now = time(); + + foreach (array('hrDevices', 'hrSWRun', 'hrSWRunPerf', 'hrSWInstalled', 'hrStorage', 'hrProcessor') as $name) { + $due = ($perf_due && $name === 'hrSWRunPerf') || ($swrun_due && $name === 'hrSWRun'); + + hmib_test_sql('REPLACE INTO settings (name, value) VALUES (?, ?), (?, ?)', array('hmib_' . $name . '_freq', $due ? '300' : '3600', 'hmib_' . $name . '_lastrun', $due ? '0' : (string) $now)); + } + + return hmib_e2e_cli('plugins/hmib/poller_hmib.php', array('--host-id=9001', '--start=' . $now, '--seed=4242', '--key=4243')); +} + +function hmib_e2e_worker_fixture() { + hmib_fx_host(9001, 'alpha'); + hmib_fx_system(9001, 1, 'Linux', '.1.3.6'); + hmib_fx_process(9001, 1, 'sshd'); + hmib_fx_process(9001, 2, 'cron'); +} + +it('records processes it has seen without adding to their time when performance was not collected', function () { + hmib_e2e_worker_fixture(); + + $run = hmib_e2e_worker(false); + + expect($run['exit'])->toBe(0); + expect(hmib_test_sql('SELECT name, total_time FROM plugin_hmib_hrSWRun_last_seen WHERE host_id = 9001 ORDER BY name')->fetchAll(PDO::FETCH_KEY_PAIR))->toEqual(array('cron' => 0, 'sshd' => 0)); +}); + +it('keeps the time seen total unchanged by a worker run that skips performance, run after run', function () { + hmib_e2e_worker_fixture(); + hmib_test_sql('INSERT INTO plugin_hmib_hrSWRun_last_seen (host_id, name, total_time) VALUES (9001, ?, ?)', array('sshd', 900)); + + hmib_e2e_worker(false); + hmib_e2e_worker(false); + + expect((int) hmib_test_sql('SELECT total_time FROM plugin_hmib_hrSWRun_last_seen WHERE host_id = 9001 AND name = ?', array('sshd'))->fetchColumn())->toBe(900); +}); + +it('runs the performance collector when it is due and leaves no stale process lock', function () { + hmib_e2e_worker_fixture(); + + $run = hmib_e2e_worker(true); + + expect($run['exit'])->toBe(0); + expect($run['out'])->not->toContain('Fatal error'); + expect(hmib_test_sql('SELECT COUNT(*) FROM plugin_hmib_processes')->fetchColumn())->toBe(0); +}); + +it('adds the performance interval once per collection when an agent answers', function () { + hmib_e2e_worker_fixture(); + hmib_test_sql('UPDATE host SET hostname = ?, snmp_community = ? WHERE id = ?', array('localhost', 'public', 9001)); + + hmib_e2e_worker(true); + + $collected = hmib_test_sql('SELECT COUNT(*) FROM plugin_hmib_hrSWRun WHERE host_id = 9001')->fetchColumn(); + + if ($collected === 0 || hmib_test_sql('SELECT COUNT(*) FROM plugin_hmib_hrSWRun_last_seen WHERE host_id = 9001')->fetchColumn() === 0) { + $this->markTestSkipped('No SNMP agent answered on this machine.'); + } + + $first = hmib_test_sql('SELECT name, total_time FROM plugin_hmib_hrSWRun_last_seen WHERE host_id = 9001 ORDER BY name')->fetchAll(PDO::FETCH_KEY_PAIR); + + hmib_e2e_worker(false); + + expect(hmib_test_sql('SELECT name, total_time FROM plugin_hmib_hrSWRun_last_seen WHERE host_id = 9001 ORDER BY name')->fetchAll(PDO::FETCH_KEY_PAIR))->toEqual($first); + expect(max($first))->toBeLessThanOrEqual(300); +}); + +it('replaces the process list when the process collector is due and keeps the time seen history', function () { + hmib_e2e_worker_fixture(); + hmib_test_sql('INSERT INTO plugin_hmib_hrSWRun_last_seen (host_id, name, total_time) VALUES (9001, ?, ?)', array('sshd', 900)); + + $run = hmib_e2e_worker(false, true); + + expect($run['exit'])->toBe(0); + expect($run['out'])->not->toContain('Fatal error'); + expect((int) hmib_test_sql('SELECT total_time FROM plugin_hmib_hrSWRun_last_seen WHERE host_id = 9001 AND name = ?', array('sshd'))->fetchColumn())->toBe(900); +}); + +it('requires a device for a worker run', function () { + $run = hmib_e2e_cli('plugins/hmib/poller_hmib.php', array()); + + expect($run['out'])->toContain('You must specify a Cacti host-id run'); +}); + +it('stops the worker while the collection is disabled', function () { + hmib_test_sql('REPLACE INTO settings (name, value) VALUES (?, ?)', array('hmib_enabled', '')); + + expect(hmib_e2e_cli('plugins/hmib/poller_hmib.php', array('--host-id=9001'))['out'])->toContain('Collection is Down'); +}); + +it('does nothing for a device that is not in Cacti', function () { + $run = hmib_e2e_cli('plugins/hmib/poller_hmib.php', array('--host-id=9999', '--seed=1')); + + expect($run['exit'])->toBe(0); + expect(hmib_test_sql('SELECT COUNT(*) FROM plugin_hmib_hrSWRun_last_seen')->fetchColumn())->toBe(0); +}); + +it('runs the main collection over the discovered devices and then the graph automation', function () { + hmib_e2e_worker_fixture(); + hmib_fx_templates(); + hmib_test_sql('REPLACE INTO settings (name, value) VALUES (?, ?), (?, ?)', array('hmib_autodiscovery', 'on', 'hmib_concurrent_processes', '2')); + + $run = hmib_e2e_cli('plugins/hmib/poller_hmib.php', array('-M', '--force')); + + expect($run['exit'])->toBe(0); + expect($run['out'])->toContain("Launching Host Collector For: 'alpha"); + expect($run['out'])->toContain('Host Mib Polling Completed'); + expect(hmib_test_sql('SELECT COUNT(*) FROM plugin_hmib_processes')->fetchColumn())->toBe(0); +}); + +it('assigns the stored OS types to the devices that match them', function () { + hmib_fx_type(1, '.1.3.6', 'Linux', 'Linux', '5'); + hmib_fx_type(2, '.1.3.7', 'Windows', 'Windows', '10'); + hmib_fx_host(9001, 'alpha'); + hmib_fx_host(9002, 'beta'); + hmib_fx_host(9003, 'gamma'); + hmib_fx_system(9001, 0, 'Linux box', '.1.3.6.1'); + hmib_fx_system(9002, 0, 'Windows 11', '.1.3.7'); + hmib_fx_system(9003, 0, 'Plan 9', '.1.3.8'); + + $run = hmib_e2e_cli('plugins/hmib/associate_os_type.php'); + + expect($run['exit'])->toBe(0); + expect(hmib_test_sql('SELECT host_id, host_type FROM plugin_hmib_hrSystem ORDER BY host_id')->fetchAll(PDO::FETCH_KEY_PAIR))->toEqual(array(9001 => 1, 9002 => 2, 9003 => 0)); +}); From 8fa5f3f8c38cab0a78c1ca6b4c6e0ddec6abe437 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 00:57:19 -0600 Subject: [PATCH 25/54] test: give the summary device unit test a harmless PHP binary Signed-off-by: Thomas Vincent --- tests/Unit/PollerGraphsTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Unit/PollerGraphsTest.php b/tests/Unit/PollerGraphsTest.php index 26a5964..a92aa23 100644 --- a/tests/Unit/PollerGraphsTest.php +++ b/tests/Unit/PollerGraphsTest.php @@ -59,6 +59,9 @@ function hmib_dq_graphs($host_id, $query_id, $graph_template_id, $query_type_id) hmib_test_reset_db_mocks(); $GLOBALS['__graph_calls'] = array(); + // The summary device code starts a PHP binary; `true` accepts the arguments and does nothing. + $GLOBALS['__test_config'] = array('path_php_binary' => 'true'); + hmib_test_mock_db('db_fetch_assoc', 'plugin_hmib_hrSystem', array( array('host_id' => '9', 'description' => 'host nine'), )); @@ -136,3 +139,7 @@ function hmib_test_templates($users, $procs) { expect($queries[0][1])->toBe(array(7)); }); + +afterEach(function () { + $GLOBALS['__test_config'] = array(); +}); From 52fdfc339baf9dd85c0e43c1f4785ead1e9b86f3 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 00:58:24 -0600 Subject: [PATCH 26/54] test: record which test runs each line, and check the helpers stay inert on the web Signed-off-by: Thomas Vincent --- tests/Security/TestsDirectoryInertTest.php | 196 +++++++++++++++-- tests/bin/coverage-prepend.php | 111 ++++++++++ tests/bin/merge-coverage.php | 244 +++++++++++++++++++++ tests/bootstrap-unit.php | 14 +- tests/support/coverage.php | 124 +++++++++++ 5 files changed, 664 insertions(+), 25 deletions(-) create mode 100644 tests/bin/coverage-prepend.php create mode 100644 tests/bin/merge-coverage.php create mode 100644 tests/support/coverage.php diff --git a/tests/Security/TestsDirectoryInertTest.php b/tests/Security/TestsDirectoryInertTest.php index d1161b2..ad88563 100644 --- a/tests/Security/TestsDirectoryInertTest.php +++ b/tests/Security/TestsDirectoryInertTest.php @@ -8,37 +8,185 @@ */ /* - * tests/ can end up inside the web root on a git-clone install. The one file - * there that runs code when it is requested, the test bootstrap, must answer - * 404 with no body when a web server executes it. The other PHP files in - * tests/ only declare Pest closures or functions and take no input. + * tests/ can end up inside the web root on a git-clone install. Every PHP + * file there that runs code or loads other files when it is executed answers + * 404 with no body under a web SAPI, and the coverage helper does nothing + * unless the test process asks for it. The Pest test files only declare + * closures and take no input. */ -it('answers the test bootstrap with an empty 404 under a web SAPI', function () { - $port = random_int(20000, 40000); - $docroot = realpath(__DIR__ . '/..'); - $command = array(PHP_BINARY, '-S', '127.0.0.1:' . $port, '-t', $docroot); +const HMIB_INERT_FILES = array( + 'bootstrap-unit.php', + 'support/db.php', + 'support/e2e.php', + 'support/fixtures.php', + 'support/coverage.php', + 'bin/coverage-prepend.php', + 'bin/merge-coverage.php', +); - $process = proc_open($command, array(1 => array('file', '/dev/null', 'w'), 2 => array('file', '/dev/null', 'w')), $pipes); +function hmib_inert_request($port, $path) { + // The server may not be listening yet; a refused connection is not a test warning. + set_error_handler(function () { + return true; + }); - expect($process)->not->toBeFalse(); + $socket = stream_socket_client('tcp://localhost:' . $port, $errno, $errstr, 2); - try { - $context = stream_context_create(array('http' => array('ignore_errors' => true, 'timeout' => 2))); - $body = false; - $headers = array(); + restore_error_handler(); - for ($attempt = 0; $attempt < 30 && $body === false; $attempt++) { - usleep(100000); - $body = @file_get_contents('http://127.0.0.1:' . $port . '/bootstrap-unit.php', false, $context); + if ($socket === false) { + return null; + } + + fwrite($socket, "GET /$path HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + + $raw = stream_get_contents($socket); + + fclose($socket); + + [$head, $body] = array_pad(explode("\r\n\r\n", (string) $raw, 2), 2, ''); + + return array('status' => (int) substr($head, 9, 3), 'body' => $body); +} + +beforeAll(function () { + $socket = stream_socket_server('tcp://localhost:0', $errno, $errstr); + $name = stream_socket_get_name($socket, false); + + fclose($socket); + + $GLOBALS['__hmib_inert_port'] = (int) substr($name, strrpos($name, ':') + 1); + $command = array(PHP_BINARY, '-S', 'localhost:' . $GLOBALS['__hmib_inert_port'], '-t', realpath(__DIR__ . '/..')); + + $GLOBALS['__hmib_inert_process'] = proc_open($command, array(1 => array('file', '/dev/null', 'w'), 2 => array('file', '/dev/null', 'w')), $pipes); + + for ($attempt = 0; $attempt < 50 && hmib_inert_request($GLOBALS['__hmib_inert_port'], 'bootstrap-unit.php') === null; $attempt++) { + usleep(100000); + } +}); + +afterAll(function () { + proc_terminate($GLOBALS['__hmib_inert_process']); + proc_close($GLOBALS['__hmib_inert_process']); +}); + +it('answers a request for a test helper with an empty 404 under a web SAPI', function (string $file) { + $response = hmib_inert_request($GLOBALS['__hmib_inert_port'], $file); + + expect($response)->not->toBeNull(); + expect($response['status'])->toBe(404); + expect($response['body'])->toBe(''); +})->with(HMIB_INERT_FILES); + +function hmib_inert_run($options, $environment, $code) { + // auto_prepend_file is not applied to -r code, so the code runs as a script. + $script = tempnam(sys_get_temp_dir(), 'hmib'); + + file_put_contents($script, " array('pipe', 'w'), 2 => array('pipe', 'w')), $pipes, null, array_merge(array('PATH' => (string) getenv('PATH')), $environment)); + + $out = stream_get_contents($pipes[1]); + $err = stream_get_contents($pipes[2]); + + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + unlink($script); + + return array($out, $err); +} + +function hmib_inert_directory() { + $directory = sys_get_temp_dir() . '/hmib-inert-' . bin2hex(random_bytes(6)); + + mkdir($directory, 0700); + + return $directory; +} + +function hmib_inert_cleanup($directory) { + foreach (glob($directory . '/*') ?: array() as $file) { + unlink($file); + } - $headers = $http_response_header ?? array(); - } + rmdir($directory); +} - expect($body)->toBe(''); - expect($headers[0] ?? '')->toContain('404'); - } finally { - proc_terminate($process); - proc_close($process); +function hmib_inert_prepend() { + return 'auto_prepend_file=' . realpath(__DIR__ . '/../bin/coverage-prepend.php'); +} + +function hmib_inert_load_plugin() { + return 'require ' . var_export(realpath(__DIR__ . '/../../setup.php'), true) . '; echo "ok";'; +} + +it('collects nothing and prints nothing when the test process does not ask for coverage', function () { + $directory = hmib_inert_directory(); + + [$out, $err] = hmib_inert_run(array('-d', hmib_inert_prepend(), '-d', 'xdebug.mode=coverage'), array(), hmib_inert_load_plugin()); + + expect($out)->toBe('ok'); + expect($err)->toBe(''); + expect(glob($directory . '/*'))->toBe(array()); + + hmib_inert_cleanup($directory); +}); + +it('collects nothing when the named directory does not exist', function () { + [$out, $err] = hmib_inert_run(array('-d', hmib_inert_prepend(), '-d', 'xdebug.mode=coverage'), array('HMIB_COVERAGE_DIR' => sys_get_temp_dir() . '/hmib-no-such-directory'), hmib_inert_load_plugin()); + + expect($out)->toBe('ok'); + expect($err)->toBe(''); +}); + +it('collects nothing when coverage is switched off in PHP', function () { + $directory = hmib_inert_directory(); + + [$out] = hmib_inert_run(array('-d', hmib_inert_prepend(), '-d', 'xdebug.mode=off'), array('HMIB_COVERAGE_DIR' => $directory), hmib_inert_load_plugin()); + + expect($out)->toBe('ok'); + expect(glob($directory . '/*'))->toBe(array()); + + hmib_inert_cleanup($directory); +}); + +it('writes one dump inside the named directory, tagged with a sanitised name', function () { + if (!function_exists('xdebug_start_code_coverage')) { + $this->markTestSkipped('Xdebug is not loaded.'); + } + + $directory = hmib_inert_directory(); + + file_put_contents($directory . '/current-test', '../outside name'); + + [$out] = hmib_inert_run(array('-d', hmib_inert_prepend(), '-d', 'xdebug.mode=coverage'), array('HMIB_COVERAGE_DIR' => $directory), hmib_inert_load_plugin()); + + $dumps = array_map('basename', glob($directory . '/*.json')); + + expect($out)->toBe('ok'); + expect($dumps)->toHaveCount(1); + expect($dumps[0])->toMatch('/^[A-Za-z0-9_.-]+\.json$/'); + expect($dumps[0])->toStartWith('_outside_name.'); + expect(array_keys(json_decode((string) file_get_contents($directory . '/' . $dumps[0]), true)))->toContain('setup.php'); + expect(file_exists(dirname($directory) . '/outside name'))->toBeFalse(); + + unlink($directory . '/current-test'); + hmib_inert_cleanup($directory); +}); + +it('does not read the coverage tag from outside the named directory', function () { + $directory = hmib_inert_directory(); + + [$out] = hmib_inert_run(array('-d', hmib_inert_prepend(), '-d', 'xdebug.mode=coverage'), array('HMIB_COVERAGE_DIR' => $directory), hmib_inert_load_plugin()); + + if (function_exists('xdebug_start_code_coverage')) { + expect(array_map('basename', glob($directory . '/*.json'))[0])->toStartWith('untagged.'); } + + expect($out)->toBe('ok'); + + hmib_inert_cleanup($directory); }); diff --git a/tests/bin/coverage-prepend.php b/tests/bin/coverage-prepend.php new file mode 100644 index 0000000..2e53ed7 --- /dev/null +++ b/tests/bin/coverage-prepend.php @@ -0,0 +1,111 @@ +" and "-d xdebug.mode=coverage", so the + * code that really serves a request is measured, which an included test + * function cannot show. + * + * It is inert unless HMIB_COVERAGE_DIR names an existing writable directory. + * It reads nothing from the request. The tag in HMIB_COVERAGE_DIR/current-test, + * written by the test process, names the test that is running, and is reduced + * to [A-Za-z0-9_.-] without leading dots. At shutdown it writes one JSON file with the lines and + * branches that ran in files below the plugin directory. + * + * tests/ can end up in the web root, so a direct request for this file, or any + * SAPI other than cli and cli-server, gets a 404 and nothing else happens. + */ + +if (PHP_SAPI !== 'cli' && PHP_SAPI !== 'cli-server') { + http_response_code(404); + + exit; +} + +if (PHP_SAPI === 'cli-server' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) { + http_response_code(404); + + exit; +} + +(function () { + $directory = getenv('HMIB_COVERAGE_DIR'); + + if ($directory === false || $directory === '' || !is_dir($directory) || !is_writable($directory) || !function_exists('xdebug_start_code_coverage') || !function_exists('xdebug_info') || !in_array('coverage', xdebug_info('mode'), true)) { + return; + } + + $directory = realpath($directory); + $plugin = dirname(__DIR__, 2); + + // The web server's document root holds a symlink to the install, so the + // same file can be reported under that path as well. + $prefixes = array($plugin . '/'); + + foreach (explode(PATH_SEPARATOR, (string) getenv('HMIB_COVERAGE_PREFIXES')) as $prefix) { + if ($prefix !== '' && $prefix[0] === '/') { + $prefixes[] = rtrim($prefix, '/') . '/'; + } + } + + // Branch data for all of Cacti's files is large; the report only needs ours. + ini_set('memory_limit', '1G'); + + // The test runner itself is measured one test at a time by + // tests/support/coverage.php; here the filter alone keeps its own + // libraries, which load after this file, out of that data. + if (getenv('HMIB_COVERAGE_PER_TEST') === '1') { + xdebug_set_filter(XDEBUG_FILTER_CODE_COVERAGE, XDEBUG_PATH_INCLUDE, array_merge($prefixes, array(sys_get_temp_dir() . '/hmib'))); + + return; + } + + xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE | XDEBUG_CC_BRANCH_CHECK); + + register_shutdown_function(function () use ($directory, $prefixes) { + $coverage = xdebug_get_code_coverage(); + + xdebug_stop_code_coverage(); + + $tag = 'untagged'; + + if (is_readable($directory . '/current-test')) { + $tag = ltrim((string) preg_replace('/[^A-Za-z0-9_.-]/', '_', substr((string) file_get_contents($directory . '/current-test'), 0, 120)), '.') ?: 'untagged'; + } + + $kept = array(); + + // Path enumeration is large and the report does not use it. + foreach ($coverage as $file => $data) { + foreach (array_keys($data['functions'] ?? array()) as $function) { + unset($coverage[$file]['functions'][$function]['paths']); + } + } + + foreach ($coverage as $file => $data) { + foreach ($prefixes as $prefix) { + if (strncmp($file, $prefix, strlen($prefix)) === 0 && strncmp($file, $prefix . 'tests/', strlen($prefix) + 6) !== 0) { + $kept[substr($file, strlen($prefix))] = $data; + + break; + } + } + } + + if ($kept === array()) { + return; + } + + $name = sprintf('%s.%d.%s.json', $tag, getmypid(), bin2hex(random_bytes(4))); + + file_put_contents($directory . '/' . $name, json_encode($kept), LOCK_EX); + }); +})(); diff --git a/tests/bin/merge-coverage.php b/tests/bin/merge-coverage.php new file mode 100644 index 0000000..38df2eb --- /dev/null +++ b/tests/bin/merge-coverage.php @@ -0,0 +1,244 @@ + [options] + * + * --clover= also write the merged line coverage as a Clover file + * --merge-clover= add the lines of a Clover file (the Pest report) + * --matrix print every changed executable line with its tests + * + * Without --matrix it prints one summary line per changed file and the + * uncovered changed lines and branches. Exits 1 when a changed executable line + * was not run, 2 on bad input. + */ + +if (PHP_SAPI !== 'cli') { + http_response_code(404); + + exit; +} + +$options = array(); +$operands = array(); + +foreach (array_slice($argv, 1) as $argument) { + if (strncmp($argument, '--', 2) === 0) { + [$name, $value] = array_pad(explode('=', substr($argument, 2), 2), 2, true); + $options[$name] = $value; + } else { + $operands[] = $argument; + } +} + +if (count($operands) < 2) { + fwrite(STDERR, "usage: merge-coverage.php [--clover=file] [--merge-clover=file] [--matrix]\n"); + + exit(2); +} + +[$directory, $base_ref] = $operands; + +if (!is_dir($directory)) { + fwrite(STDERR, "not a directory: $directory\n"); + + exit(2); +} + +/** + * Changed line numbers of the plugin's production PHP files. + * + * @param string $base_ref Git ref to diff the working tree against. + * + * @return array> + */ +function merge_changed_lines($base_ref) { + $output = array(); + $status = 0; + + exec('git diff --no-ext-diff --unified=0 --no-color --diff-filter=AM ' . escapeshellarg($base_ref) . ' -- "*.php"', $output, $status); + + if ($status !== 0) { + fwrite(STDERR, "git diff failed\n"); + + exit(2); + } + + $changed = array(); + $file = null; + + foreach ($output as $line) { + if (strncmp($line, '+++ b/', 6) === 0) { + $file = substr($line, 6); + + if (strncmp($file, 'tests/', 6) === 0) { + $file = null; + + continue; + } + + $changed[$file] = array(); + } elseif (strncmp($line, '@@', 2) === 0 && $file !== null && preg_match('/\+(\d+)(?:,(\d+))?/', $line, $match)) { + $count = isset($match[2]) ? (int) $match[2] : 1; + + for ($i = 0; $i < $count; $i++) { + $changed[$file][(int) $match[1] + $i] = true; + } + } + } + + return $changed; +} + +/** + * Read every dump into per-file line and branch tables. + * + * @param string $directory The dump directory. + * + * @return array{lines: array, branches: array} + */ +function merge_read_dumps($directory) { + $lines = array(); + $branches = array(); + + foreach (glob(rtrim($directory, '/') . '/*.json') ?: array() as $dump) { + $tag = preg_replace('/^__pest_evaluable_(it_)?/', '', preg_replace('/\.\d+\.[0-9a-f]+\.json$/', '', basename($dump))); + $data = json_decode((string) file_get_contents($dump), true); + + foreach (is_array($data) ? $data : array() as $file => $coverage) { + foreach ($coverage['lines'] ?? array() as $number => $state) { + $lines[$file][(int) $number]['executable'] = $state !== -2; + + if ($state === 1) { + $lines[$file][(int) $number]['tests'][$tag] = true; + } + } + + foreach ($coverage['functions'] ?? array() as $function => $detail) { + foreach ($detail['branches'] ?? array() as $index => $branch) { + $key = $file . '|' . $function . '|' . $index; + + $branches[$key]['file'] = $file; + $branches[$key]['name'] = $function; + $branches[$key]['start'] = $branch['line_start']; + $branches[$key]['end'] = $branch['line_end']; + $branches[$key]['hit'] = ($branches[$key]['hit'] ?? false) || !empty($branch['hit']); + } + } + } + } + + return array('lines' => $lines, 'branches' => $branches); +} + +$changed = merge_changed_lines($base_ref); +$dumps = merge_read_dumps($directory); +$lines = $dumps['lines']; +$branches = $dumps['branches']; + +if (isset($options['merge-clover']) && is_readable((string) $options['merge-clover'])) { + $clover = simplexml_load_file((string) $options['merge-clover']); + + foreach ($clover ? $clover->xpath('//file') : array() as $file) { + $name = (string) $file['name']; + + foreach ($changed as $candidate => $unused) { + if (substr($name, -strlen('/' . $candidate)) === '/' . $candidate) { + foreach ($file->line as $line) { + if ((string) $line['type'] === 'stmt') { + $lines[$candidate][(int) $line['num']]['executable'] = true; + + if ((int) $line['count'] > 0) { + $lines[$candidate][(int) $line['num']]['tests']['pest'] = true; + } + } + } + } + } + } +} + +if (isset($options['clover']) && is_string($options['clover'])) { + $xml = new SimpleXMLElement(''); + + foreach ($lines as $file => $numbers) { + $node = $xml->project->addChild('file'); + $node->addAttribute('name', realpath('.') . '/' . $file); + + ksort($numbers); + + foreach ($numbers as $number => $detail) { + if ($detail['executable']) { + $line = $node->addChild('line'); + $line->addAttribute('num', (string) $number); + $line->addAttribute('type', 'stmt'); + $line->addAttribute('count', (string) count($detail['tests'] ?? array())); + } + } + } + + $xml->asXML($options['clover']); +} + +$missing_total = 0; + +foreach ($changed as $file => $numbers) { + ksort($numbers); + + $executable = 0; + $covered = 0; + $uncovered = array(); + + foreach (array_keys($numbers) as $number) { + $detail = $lines[$file][$number] ?? null; + + if ($detail === null || !$detail['executable']) { + continue; + } + + $executable++; + + if (!empty($detail['tests'])) { + $covered++; + + if (isset($options['matrix'])) { + printf(" %s:%d %s\n", $file, $number, count($detail['tests']) . ' tests: ' . implode(', ', array_slice(array_keys($detail['tests']), 0, 3)) . (count($detail['tests']) > 3 ? ', ...' : '')); + } + } else { + $uncovered[] = $number; + } + } + + printf("%-22s changed lines %4d executable %4d covered %4d %6.2f%%\n", $file, count($numbers), $executable, $covered, $executable ? 100 * $covered / $executable : 100); + + if ($uncovered !== array()) { + printf(" not run: %s\n", implode(', ', $uncovered)); + $missing_total += count($uncovered); + } + + foreach ($branches as $branch) { + if ($branch['file'] !== $file || $branch['hit']) { + continue; + } + + foreach (array_keys($numbers) as $number) { + if ($number >= $branch['start'] && $number <= $branch['end']) { + printf(" branch not taken in %s lines %d-%d\n", $branch['name'], $branch['start'], $branch['end']); + + break; + } + } + } +} + +exit($missing_total > 0 ? 1 : 0); diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index 119c895..d4383a1 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -80,6 +80,12 @@ require_once $autoload; +require_once __DIR__ . '/support/coverage.php'; + +if (hmib_cov_enabled()) { + hmib_cov_register(); +} + /* * base_path has to point at the Cacti root two levels above this plugin: * hmib's source files build include paths from it at runtime. @@ -478,10 +484,13 @@ function hmib_test_define_function($file, $name) { $source = plugin_test_read_source($file); - if (!preg_match('/^function ' . preg_quote($name, '/') . '\(.*?^}\n/ms', $source, $match)) { + if (!preg_match('/^function ' . preg_quote($name, '/') . '\(.*?^}\n/ms', $source, $match, PREG_OFFSET_CAPTURE)) { throw new RuntimeException("Unable to locate {$name}() in {$file}"); } + $start = 1 + substr_count(substr($source, 0, $match[0][1]), "\n"); + $match = array($match[0][0]); + // tempnam() creates the file exclusively with mode 0600; the path is // built from nothing a request or the source file can influence. $path = tempnam(sys_get_temp_dir(), 'hmib'); @@ -493,6 +502,9 @@ function hmib_test_define_function($file, $name) { try { file_put_contents($path, " $data) { + $map = $GLOBALS['__hmib_function_map'][$file] ?? null; + + foreach (array_keys($data['functions'] ?? array()) as $function) { + unset($data['functions'][$function]['paths']); + } + + if ($map !== null) { + $shift = $map[1] - 2; + + foreach ($data['lines'] as $number => $state) { + $kept[$map[0]]['lines'][$number + $shift] = $state; + } + + foreach ($data['functions'] as $function => $detail) { + foreach ($detail['branches'] ?? array() as $index => $branch) { + $branch['line_start'] += $shift; + $branch['line_end'] += $shift; + + $kept[$map[0]]['functions'][$function]['branches'][$index] = $branch; + } + } + } elseif (strncmp($file, $plugin, strlen($plugin)) === 0 && strncmp($file, $plugin . 'tests/', strlen($plugin) + 6) !== 0) { + $kept[substr($file, strlen($plugin))] = $data; + } + } + + if ($kept !== array()) { + $tag = ltrim((string) preg_replace('/[^A-Za-z0-9_.-]/', '_', substr($name, 0, 120)), '.') ?: 'untagged'; + + file_put_contents(getenv('HMIB_COVERAGE_DIR') . '/' . sprintf('%s.%d.%s.json', $tag, getmypid(), bin2hex(random_bytes(4))), json_encode($kept), LOCK_EX); + } +} + +/** + * Hook the collection into PHPUnit's test events. + * + * @return void + */ +function hmib_cov_register() { + $start = new class implements PHPUnit\Event\Test\PreparationStartedSubscriber { + public function notify(PHPUnit\Event\Test\PreparationStarted $event): void { + hmib_cov_start(); + } + }; + + $finish = new class implements PHPUnit\Event\Test\FinishedSubscriber { + public function notify(PHPUnit\Event\Test\Finished $event): void { + $id = $event->test()->id(); + + hmib_cov_dump(substr($id, (int) strrpos($id, ':') + 1)); + } + }; + + PHPUnit\Event\Facade::instance()->registerSubscribers($start, $finish); +} From 62729dc65a276c52d297a6a597fb55d0c51012cd Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 00:59:30 -0600 Subject: [PATCH 27/54] ci: measure the page scripts and pollers from the end-to-end runs Signed-off-by: Thomas Vincent --- .github/workflows/plugin-ci-workflow.yml | 20 ++++++++++++++++++-- tests/bin/patch-coverage.php | 24 ++++-------------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 4fbbeea..679addd 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -200,9 +200,24 @@ jobs: "$CACTI_PEST_BIN" --configuration=plugins/hmib/phpunit.xml \ --coverage-clover=plugins/hmib/coverage/clover.xml + # The page scripts and the pollers cannot be included in the test process, + # so the end-to-end tests run them as real requests and subprocesses. This + # second run records, test by test, which of their lines ran (see + # tests/bin/coverage-prepend.php and tests/support/coverage.php). + - name: Run Pest with per-test coverage of the entry points + if: github.event_name == 'pull_request' + env: + HMIB_COVERAGE_DIR: ${{ github.workspace }}/cacti/plugins/hmib/coverage/e2e + HMIB_COVERAGE_PER_TEST: '1' + run: | + cd ${{ github.workspace }}/cacti + mkdir -p "$HMIB_COVERAGE_DIR" + php -d auto_prepend_file="$PWD/plugins/hmib/tests/bin/coverage-prepend.php" -d xdebug.mode=coverage \ + "$CACTI_PEST_BIN" --configuration=plugins/hmib/phpunit.xml + # Whole-file coverage is meaningless here: most of the plugin only runs # inside a live Cacti. What is enforceable is that a change covers the - # lines it adds. + # lines it adds, from the unit run and from the end-to-end runs together. - name: Enforce coverage of changed lines if: github.event_name == 'pull_request' env: @@ -210,7 +225,8 @@ jobs: run: | cd ${{ github.workspace }}/cacti/plugins/hmib git config --global --add safe.directory ${{ github.workspace }}/cacti/plugins/hmib - php tests/bin/patch-coverage.php coverage/clover.xml "$BASE_REF" 100 + php tests/bin/merge-coverage.php coverage/e2e "$BASE_REF" --merge-clover=coverage/clover.xml --clover=coverage/merged.xml + php tests/bin/patch-coverage.php coverage/merged.xml "$BASE_REF" 100 - name: Upload coverage report if: always() diff --git a/tests/bin/patch-coverage.php b/tests/bin/patch-coverage.php index d69ccfb..ccee953 100644 --- a/tests/bin/patch-coverage.php +++ b/tests/bin/patch-coverage.php @@ -151,30 +151,14 @@ function changed_lines($base_ref) { } /* - * Production PHP entry points that cannot safely be loaded into the isolated - * unit process (CLI/daemon/web entry points that chdir + include auth.php, - * do pcntl signal handling, or execute at the top level) belong here, each + * Production PHP entry points that cannot be measured at all belong here, each * with a one-line justification. Keep the exception explicit: any newly * changed production PHP file must either appear in Clover or be added here. * - * Empty by default; add entries per repository as the need arises. + * Empty: the page scripts and the pollers are measured by the end-to-end runs + * (tests/bin/merge-coverage.php writes the Clover file this script reads in CI). */ -$unmeasured_allowlist = [ - // Web page controller: chdir()s and includes auth.php at the top level with a - // request-dispatch switch, so it cannot be loaded into the isolated unit process. - 'hmib_types.php', - // Web page controller: renders general_header()/tabs/footer at the top level and - // dispatches on ?action, so it cannot be loaded into the isolated unit process. - 'hmib.php', - // CLI collector: chdir()s and includes cli_check.php then runs the poller at the - // top level (exits without --host-id/-M), so it cannot be loaded into the unit - // process; kept out of the coverage set like the web controllers above. - 'poller_hmib.php', - // CLI automation: chdir()s, requires cli_check.php and exits at the top level, so - // it cannot be loaded into the unit process. Its functions are exercised by - // tests/Unit/PollerGraphsTest.php, which defines them from the source text. - 'poller_graphs.php', -]; +$unmeasured_allowlist = []; $unmeasured = array_values(array_diff(array_keys($changed), array_keys($measured))); $unexpected_unmeasured = array_values(array_diff($unmeasured, $unmeasured_allowlist)); From a6fa75aa8df71d3c579bc1c2c0b7ecf2c61cda66 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 01:44:05 -0600 Subject: [PATCH 28/54] test: write the table snapshot statements as one row per table Signed-off-by: Thomas Vincent --- tests/support/db.php | 176 ++++++++----------------------------------- 1 file changed, 30 insertions(+), 146 deletions(-) diff --git a/tests/support/db.php b/tests/support/db.php index 2453d07..d31e521 100644 --- a/tests/support/db.php +++ b/tests/support/db.php @@ -22,7 +22,8 @@ * * Every table a test touches is copied aside first and restored afterwards, * so a Cacti install in the same database keeps its rows. The statements are - * spelled out rather than built from table names. + * spelled out rather than built from table names: backup, wipe, restore, drop, + * and the statement that empties a table the plugin owns (null for core tables). */ if (PHP_SAPI !== 'cli' && PHP_SAPI !== 'phpdbg') { @@ -32,146 +33,26 @@ } const HMIB_TEST_SNAPSHOT_SQL = array( - 'graph_local' => array( - 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_graph_local AS SELECT * FROM graph_local', - 'clean' => 'DELETE FROM graph_local', - 'wipe' => 'DELETE FROM graph_local', - 'restore' => 'INSERT INTO graph_local SELECT * FROM hmib_bak_graph_local', - 'drop' => 'DROP TEMPORARY TABLE hmib_bak_graph_local', - ), - 'host' => array( - 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_host AS SELECT * FROM host', - 'clean' => 'DELETE FROM host WHERE id >= 9000', - 'wipe' => 'DELETE FROM host', - 'restore' => 'INSERT INTO host SELECT * FROM hmib_bak_host', - 'drop' => 'DROP TEMPORARY TABLE hmib_bak_host', - ), - 'host_graph' => array( - 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_host_graph AS SELECT * FROM host_graph', - 'clean' => 'DELETE FROM host_graph WHERE host_id >= 9000', - 'wipe' => 'DELETE FROM host_graph', - 'restore' => 'INSERT INTO host_graph SELECT * FROM hmib_bak_host_graph', - 'drop' => 'DROP TEMPORARY TABLE hmib_bak_host_graph', - ), - 'host_snmp_query' => array( - 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_host_snmp_query AS SELECT * FROM host_snmp_query', - 'clean' => 'DELETE FROM host_snmp_query WHERE host_id >= 9000', - 'wipe' => 'DELETE FROM host_snmp_query', - 'restore' => 'INSERT INTO host_snmp_query SELECT * FROM hmib_bak_host_snmp_query', - 'drop' => 'DROP TEMPORARY TABLE hmib_bak_host_snmp_query', - ), - 'host_template_graph' => array( - 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_host_template_graph AS SELECT * FROM host_template_graph', - 'clean' => 'DO 0', - 'wipe' => 'DELETE FROM host_template_graph', - 'restore' => 'INSERT INTO host_template_graph SELECT * FROM hmib_bak_host_template_graph', - 'drop' => 'DROP TEMPORARY TABLE hmib_bak_host_template_graph', - ), - 'host_template' => array( - 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_host_template AS SELECT * FROM host_template', - 'clean' => 'DELETE FROM host_template WHERE hash = \'7c13344910097cc599f0d0485305361d\'', - 'wipe' => 'DELETE FROM host_template', - 'restore' => 'INSERT INTO host_template SELECT * FROM hmib_bak_host_template', - 'drop' => 'DROP TEMPORARY TABLE hmib_bak_host_template', - ), - 'snmp_query' => array( - 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_snmp_query AS SELECT * FROM snmp_query', - 'clean' => 'DELETE FROM snmp_query WHERE hash IN (\'137aeab842986a76cf5bdef41b96c9a3\', \'0d1ab53fe37487a5d0b9e1d3ee8c1d0d\', \'9343eab1f4d88b0e61ffc9d020f35414\', \'6b0ef0fe7f1d85bbb6812801ca15a7c5\')', - 'wipe' => 'DELETE FROM snmp_query', - 'restore' => 'INSERT INTO snmp_query SELECT * FROM hmib_bak_snmp_query', - 'drop' => 'DROP TEMPORARY TABLE hmib_bak_snmp_query', - ), - 'graph_templates' => array( - 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_graph_templates AS SELECT * FROM graph_templates', - 'clean' => 'DELETE FROM graph_templates WHERE hash IN (\'e8462bbe094e4e9e814d4e681671ea82\', \'62205afbd4066e5c4700338841e3901e\')', - 'wipe' => 'DELETE FROM graph_templates', - 'restore' => 'INSERT INTO graph_templates SELECT * FROM hmib_bak_graph_templates', - 'drop' => 'DROP TEMPORARY TABLE hmib_bak_graph_templates', - ), - 'settings' => array( - 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_settings AS SELECT * FROM settings', - 'clean' => 'DELETE FROM settings WHERE name LIKE \'hmib%\'', - 'wipe' => 'DELETE FROM settings', - 'restore' => 'INSERT INTO settings SELECT * FROM hmib_bak_settings', - 'drop' => 'DROP TEMPORARY TABLE hmib_bak_settings', - ), - 'user_auth' => array( - 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_user_auth AS SELECT * FROM user_auth', - 'clean' => 'DO 0', - 'wipe' => 'DELETE FROM user_auth', - 'restore' => 'INSERT INTO user_auth SELECT * FROM hmib_bak_user_auth', - 'drop' => 'DROP TEMPORARY TABLE hmib_bak_user_auth', - ), - 'user_auth_realm' => array( - 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_user_auth_realm AS SELECT * FROM user_auth_realm', - 'clean' => 'DO 0', - 'wipe' => 'DELETE FROM user_auth_realm', - 'restore' => 'INSERT INTO user_auth_realm SELECT * FROM hmib_bak_user_auth_realm', - 'drop' => 'DROP TEMPORARY TABLE hmib_bak_user_auth_realm', - ), - 'plugin_hmib_hrSystem' => array( - 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrSystem AS SELECT * FROM plugin_hmib_hrSystem', - 'clean' => 'DELETE FROM plugin_hmib_hrSystem', - 'wipe' => 'DELETE FROM plugin_hmib_hrSystem', - 'restore' => 'INSERT INTO plugin_hmib_hrSystem SELECT * FROM hmib_bak_plugin_hmib_hrSystem', - 'drop' => 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrSystem', - ), - 'plugin_hmib_hrSystemTypes' => array( - 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrSystemTypes AS SELECT * FROM plugin_hmib_hrSystemTypes', - 'clean' => 'DELETE FROM plugin_hmib_hrSystemTypes', - 'wipe' => 'DELETE FROM plugin_hmib_hrSystemTypes', - 'restore' => 'INSERT INTO plugin_hmib_hrSystemTypes SELECT * FROM hmib_bak_plugin_hmib_hrSystemTypes', - 'drop' => 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrSystemTypes', - ), - 'plugin_hmib_hrSWRun' => array( - 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrSWRun AS SELECT * FROM plugin_hmib_hrSWRun', - 'clean' => 'DELETE FROM plugin_hmib_hrSWRun', - 'wipe' => 'DELETE FROM plugin_hmib_hrSWRun', - 'restore' => 'INSERT INTO plugin_hmib_hrSWRun SELECT * FROM hmib_bak_plugin_hmib_hrSWRun', - 'drop' => 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrSWRun', - ), - 'plugin_hmib_hrSWRun_last_seen' => array( - 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrSWRun_last_seen AS SELECT * FROM plugin_hmib_hrSWRun_last_seen', - 'clean' => 'DELETE FROM plugin_hmib_hrSWRun_last_seen', - 'wipe' => 'DELETE FROM plugin_hmib_hrSWRun_last_seen', - 'restore' => 'INSERT INTO plugin_hmib_hrSWRun_last_seen SELECT * FROM hmib_bak_plugin_hmib_hrSWRun_last_seen', - 'drop' => 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrSWRun_last_seen', - ), - 'plugin_hmib_hrSWInstalled' => array( - 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrSWInstalled AS SELECT * FROM plugin_hmib_hrSWInstalled', - 'clean' => 'DELETE FROM plugin_hmib_hrSWInstalled', - 'wipe' => 'DELETE FROM plugin_hmib_hrSWInstalled', - 'restore' => 'INSERT INTO plugin_hmib_hrSWInstalled SELECT * FROM hmib_bak_plugin_hmib_hrSWInstalled', - 'drop' => 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrSWInstalled', - ), - 'plugin_hmib_hrDevices' => array( - 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrDevices AS SELECT * FROM plugin_hmib_hrDevices', - 'clean' => 'DELETE FROM plugin_hmib_hrDevices', - 'wipe' => 'DELETE FROM plugin_hmib_hrDevices', - 'restore' => 'INSERT INTO plugin_hmib_hrDevices SELECT * FROM hmib_bak_plugin_hmib_hrDevices', - 'drop' => 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrDevices', - ), - 'plugin_hmib_hrStorage' => array( - 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrStorage AS SELECT * FROM plugin_hmib_hrStorage', - 'clean' => 'DELETE FROM plugin_hmib_hrStorage', - 'wipe' => 'DELETE FROM plugin_hmib_hrStorage', - 'restore' => 'INSERT INTO plugin_hmib_hrStorage SELECT * FROM hmib_bak_plugin_hmib_hrStorage', - 'drop' => 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrStorage', - ), - 'plugin_hmib_hrProcessor' => array( - 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrProcessor AS SELECT * FROM plugin_hmib_hrProcessor', - 'clean' => 'DELETE FROM plugin_hmib_hrProcessor', - 'wipe' => 'DELETE FROM plugin_hmib_hrProcessor', - 'restore' => 'INSERT INTO plugin_hmib_hrProcessor SELECT * FROM hmib_bak_plugin_hmib_hrProcessor', - 'drop' => 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrProcessor', - ), - 'plugin_hmib_processes' => array( - 'backup' => 'CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_processes AS SELECT * FROM plugin_hmib_processes', - 'clean' => 'DELETE FROM plugin_hmib_processes', - 'wipe' => 'DELETE FROM plugin_hmib_processes', - 'restore' => 'INSERT INTO plugin_hmib_processes SELECT * FROM hmib_bak_plugin_hmib_processes', - 'drop' => 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_processes', - ), + 'graph_local' => array('CREATE TEMPORARY TABLE hmib_bak_graph_local AS SELECT * FROM graph_local', 'DELETE FROM graph_local', 'INSERT INTO graph_local SELECT * FROM hmib_bak_graph_local', 'DROP TEMPORARY TABLE hmib_bak_graph_local', 'DELETE FROM graph_local'), + 'host' => array('CREATE TEMPORARY TABLE hmib_bak_host AS SELECT * FROM host', 'DELETE FROM host', 'INSERT INTO host SELECT * FROM hmib_bak_host', 'DROP TEMPORARY TABLE hmib_bak_host', 'DELETE FROM host WHERE id >= 9000'), + 'host_graph' => array('CREATE TEMPORARY TABLE hmib_bak_host_graph AS SELECT * FROM host_graph', 'DELETE FROM host_graph', 'INSERT INTO host_graph SELECT * FROM hmib_bak_host_graph', 'DROP TEMPORARY TABLE hmib_bak_host_graph', 'DELETE FROM host_graph WHERE host_id >= 9000'), + 'host_snmp_query' => array('CREATE TEMPORARY TABLE hmib_bak_host_snmp_query AS SELECT * FROM host_snmp_query', 'DELETE FROM host_snmp_query', 'INSERT INTO host_snmp_query SELECT * FROM hmib_bak_host_snmp_query', 'DROP TEMPORARY TABLE hmib_bak_host_snmp_query', 'DELETE FROM host_snmp_query WHERE host_id >= 9000'), + 'host_template_graph' => array('CREATE TEMPORARY TABLE hmib_bak_host_template_graph AS SELECT * FROM host_template_graph', 'DELETE FROM host_template_graph', 'INSERT INTO host_template_graph SELECT * FROM hmib_bak_host_template_graph', 'DROP TEMPORARY TABLE hmib_bak_host_template_graph', null), + 'host_template' => array('CREATE TEMPORARY TABLE hmib_bak_host_template AS SELECT * FROM host_template', 'DELETE FROM host_template', 'INSERT INTO host_template SELECT * FROM hmib_bak_host_template', 'DROP TEMPORARY TABLE hmib_bak_host_template', 'DELETE FROM host_template WHERE hash = \'7c13344910097cc599f0d0485305361d\''), + 'snmp_query' => array('CREATE TEMPORARY TABLE hmib_bak_snmp_query AS SELECT * FROM snmp_query', 'DELETE FROM snmp_query', 'INSERT INTO snmp_query SELECT * FROM hmib_bak_snmp_query', 'DROP TEMPORARY TABLE hmib_bak_snmp_query', 'DELETE FROM snmp_query WHERE hash IN (\'137aeab842986a76cf5bdef41b96c9a3\', \'0d1ab53fe37487a5d0b9e1d3ee8c1d0d\', \'9343eab1f4d88b0e61ffc9d020f35414\', \'6b0ef0fe7f1d85bbb6812801ca15a7c5\')'), + 'graph_templates' => array('CREATE TEMPORARY TABLE hmib_bak_graph_templates AS SELECT * FROM graph_templates', 'DELETE FROM graph_templates', 'INSERT INTO graph_templates SELECT * FROM hmib_bak_graph_templates', 'DROP TEMPORARY TABLE hmib_bak_graph_templates', 'DELETE FROM graph_templates WHERE hash IN (\'e8462bbe094e4e9e814d4e681671ea82\', \'62205afbd4066e5c4700338841e3901e\')'), + 'settings' => array('CREATE TEMPORARY TABLE hmib_bak_settings AS SELECT * FROM settings', 'DELETE FROM settings', 'INSERT INTO settings SELECT * FROM hmib_bak_settings', 'DROP TEMPORARY TABLE hmib_bak_settings', 'DELETE FROM settings WHERE name LIKE \'hmib%\''), + 'user_auth' => array('CREATE TEMPORARY TABLE hmib_bak_user_auth AS SELECT * FROM user_auth', 'DELETE FROM user_auth', 'INSERT INTO user_auth SELECT * FROM hmib_bak_user_auth', 'DROP TEMPORARY TABLE hmib_bak_user_auth', null), + 'user_auth_realm' => array('CREATE TEMPORARY TABLE hmib_bak_user_auth_realm AS SELECT * FROM user_auth_realm', 'DELETE FROM user_auth_realm', 'INSERT INTO user_auth_realm SELECT * FROM hmib_bak_user_auth_realm', 'DROP TEMPORARY TABLE hmib_bak_user_auth_realm', null), + 'plugin_hmib_hrSystem' => array('CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrSystem AS SELECT * FROM plugin_hmib_hrSystem', 'DELETE FROM plugin_hmib_hrSystem', 'INSERT INTO plugin_hmib_hrSystem SELECT * FROM hmib_bak_plugin_hmib_hrSystem', 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrSystem', 'DELETE FROM plugin_hmib_hrSystem'), + 'plugin_hmib_hrSystemTypes' => array('CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrSystemTypes AS SELECT * FROM plugin_hmib_hrSystemTypes', 'DELETE FROM plugin_hmib_hrSystemTypes', 'INSERT INTO plugin_hmib_hrSystemTypes SELECT * FROM hmib_bak_plugin_hmib_hrSystemTypes', 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrSystemTypes', 'DELETE FROM plugin_hmib_hrSystemTypes'), + 'plugin_hmib_hrSWRun' => array('CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrSWRun AS SELECT * FROM plugin_hmib_hrSWRun', 'DELETE FROM plugin_hmib_hrSWRun', 'INSERT INTO plugin_hmib_hrSWRun SELECT * FROM hmib_bak_plugin_hmib_hrSWRun', 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrSWRun', 'DELETE FROM plugin_hmib_hrSWRun'), + 'plugin_hmib_hrSWRun_last_seen' => array('CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrSWRun_last_seen AS SELECT * FROM plugin_hmib_hrSWRun_last_seen', 'DELETE FROM plugin_hmib_hrSWRun_last_seen', 'INSERT INTO plugin_hmib_hrSWRun_last_seen SELECT * FROM hmib_bak_plugin_hmib_hrSWRun_last_seen', 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrSWRun_last_seen', 'DELETE FROM plugin_hmib_hrSWRun_last_seen'), + 'plugin_hmib_hrSWInstalled' => array('CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrSWInstalled AS SELECT * FROM plugin_hmib_hrSWInstalled', 'DELETE FROM plugin_hmib_hrSWInstalled', 'INSERT INTO plugin_hmib_hrSWInstalled SELECT * FROM hmib_bak_plugin_hmib_hrSWInstalled', 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrSWInstalled', 'DELETE FROM plugin_hmib_hrSWInstalled'), + 'plugin_hmib_hrDevices' => array('CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrDevices AS SELECT * FROM plugin_hmib_hrDevices', 'DELETE FROM plugin_hmib_hrDevices', 'INSERT INTO plugin_hmib_hrDevices SELECT * FROM hmib_bak_plugin_hmib_hrDevices', 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrDevices', 'DELETE FROM plugin_hmib_hrDevices'), + 'plugin_hmib_hrStorage' => array('CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrStorage AS SELECT * FROM plugin_hmib_hrStorage', 'DELETE FROM plugin_hmib_hrStorage', 'INSERT INTO plugin_hmib_hrStorage SELECT * FROM hmib_bak_plugin_hmib_hrStorage', 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrStorage', 'DELETE FROM plugin_hmib_hrStorage'), + 'plugin_hmib_hrProcessor' => array('CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_hrProcessor AS SELECT * FROM plugin_hmib_hrProcessor', 'DELETE FROM plugin_hmib_hrProcessor', 'INSERT INTO plugin_hmib_hrProcessor SELECT * FROM hmib_bak_plugin_hmib_hrProcessor', 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_hrProcessor', 'DELETE FROM plugin_hmib_hrProcessor'), + 'plugin_hmib_processes' => array('CREATE TEMPORARY TABLE hmib_bak_plugin_hmib_processes AS SELECT * FROM plugin_hmib_processes', 'DELETE FROM plugin_hmib_processes', 'INSERT INTO plugin_hmib_processes SELECT * FROM hmib_bak_plugin_hmib_processes', 'DROP TEMPORARY TABLE hmib_bak_plugin_hmib_processes', 'DELETE FROM plugin_hmib_processes'), ); /** @@ -298,8 +179,11 @@ function hmib_test_create_plugin_tables() { */ function hmib_test_snapshot($tables) { foreach ($tables as $table) { - hmib_test_sql(HMIB_TEST_SNAPSHOT_SQL[$table]['backup']); - hmib_test_sql(HMIB_TEST_SNAPSHOT_SQL[$table]['clean']); + hmib_test_sql(HMIB_TEST_SNAPSHOT_SQL[$table][0]); + + if (HMIB_TEST_SNAPSHOT_SQL[$table][4] !== null) { + hmib_test_sql(HMIB_TEST_SNAPSHOT_SQL[$table][4]); + } } } @@ -312,8 +196,8 @@ function hmib_test_snapshot($tables) { */ function hmib_test_restore($tables) { foreach ($tables as $table) { - hmib_test_sql(HMIB_TEST_SNAPSHOT_SQL[$table]['wipe']); - hmib_test_sql(HMIB_TEST_SNAPSHOT_SQL[$table]['restore']); - hmib_test_sql(HMIB_TEST_SNAPSHOT_SQL[$table]['drop']); + foreach (array_slice(HMIB_TEST_SNAPSHOT_SQL[$table], 1, 3) as $statement) { + hmib_test_sql($statement); + } } } From 9f87728c6d8db24d65e87c57f616601e58ba2cef Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 01:45:08 -0600 Subject: [PATCH 29/54] ci: make the Cacti log writable before the test run Signed-off-by: Thomas Vincent --- .github/workflows/plugin-ci-workflow.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 679addd..a7b683a 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -194,6 +194,15 @@ jobs: - name: Set expected Cacti version for unit tests run: echo -n "${{ env.CACTI }}" | sudo tee ${{ github.workspace }}/cacti/plugins/hmib/tests/.cacti-version > /dev/null + # The installer ran as root and created the Cacti log, but the end-to-end + # tests start Cacti's web server and CLI scripts as the runner, and Cacti + # serves nothing while its log is not writable. + - name: Make the Cacti log writable for the test run + run: | + sudo touch ${{ github.workspace }}/cacti/log/cacti.log + sudo chown www-data:runner ${{ github.workspace }}/cacti/log/cacti.log + sudo chmod 664 ${{ github.workspace }}/cacti/log/cacti.log + - name: Run Pest Unit Tests run: | cd ${{ github.workspace }}/cacti From 8ae4aa2290cfa8c9dd29a789e13cb86274d53864 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 01:46:11 -0600 Subject: [PATCH 30/54] refactor: read the Summary sort inside hmib_summary_order_by() Signed-off-by: Thomas Vincent --- hmib.php | 30 ++++++++++----------------- tests/Security/SummaryOrderByTest.php | 26 +++++++++++++++++++---- 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/hmib.php b/hmib.php index ea641f5..4865808 100644 --- a/hmib.php +++ b/hmib.php @@ -2971,18 +2971,20 @@ function hmib_tabs(): void { } /** - * Builds the ORDER BY clause for a Summary table from the stored sort - * request. The column is accepted only when the table can sort on it, so a + * Builds the ORDER BY clause for a Summary table from the sort stored in the + * session. The column is accepted only when the table can sort on it, so a * stored or posted value never reaches the SQL text unchecked. * - * @param string $column The requested sort column. - * @param string $direction The requested sort direction. - * @param array $allowed The columns the table can sort on. - * @param string $default The column used when $column is not allowed. + * @param string $table The table's session prefix: host or proc. + * @param array $allowed The columns the table can sort on. + * @param string $default The column used when the stored one is missing or not allowed. * * @return string The ORDER BY clause. */ -function hmib_summary_order_by(string $column, string $direction, array $allowed, string $default): string { +function hmib_summary_order_by(string $table, array $allowed, string $default): string { + $column = (string) ($_SESSION['sess_hmib_' . $table . '_sort_column'] ?? $default); + $direction = (string) ($_SESSION['sess_hmib_' . $table . '_sort_direction'] ?? 'DESC'); + if (!in_array($column, $allowed, true)) { $column = $default; } @@ -3223,12 +3225,7 @@ function clearHostFilter() { $sql_limit = 'LIMIT 20'; } - $sql_order = hmib_summary_order_by( - (string) ($_SESSION['sess_hmib_host_sort_column'] ?? 'upHosts'), - (string) ($_SESSION['sess_hmib_host_sort_direction'] ?? 'DESC'), - ['name', 'version/1', '(version/1)', 'upHosts', 'recHosts', 'downHosts', 'disabledHosts', 'users', 'cpus', 'avgCpuPercent', 'maxCpuPercent', 'avgMem', 'maxMem', 'avgSwap', 'maxSwap', 'avgProcesses', 'maxProcesses'], - 'upHosts' - ); + $sql_order = hmib_summary_order_by('host', ['name', 'version/1', '(version/1)', 'upHosts', 'recHosts', 'downHosts', 'disabledHosts', 'users', 'cpus', 'avgCpuPercent', 'maxCpuPercent', 'avgMem', 'maxMem', 'avgSwap', 'maxSwap', 'avgProcesses', 'maxProcesses'], 'upHosts'); $sql = "SELECT hrst.id AS id, @@ -3507,12 +3504,7 @@ function clearProcFilter() { $sql_where = ''; $sql_params = []; $sql_limit = 'LIMIT ' . ($num_rows * (get_request_var('page') - 1)) . ',' . $num_rows; - $sql_order = hmib_summary_order_by( - (string) ($_SESSION['sess_hmib_proc_sort_column'] ?? 'maxCpu'), - (string) ($_SESSION['sess_hmib_proc_sort_direction'] ?? 'DESC'), - ['name', 'paths', 'numHosts', 'numProcesses', 'avgCpu', 'maxCpu', 'avgMemory', 'maxMemory'], - 'maxCpu' - ); + $sql_order = hmib_summary_order_by('proc', ['name', 'paths', 'numHosts', 'numProcesses', 'avgCpu', 'maxCpu', 'avgMemory', 'maxMemory'], 'maxCpu'); if (strlen(get_request_var('filter'))) { $sql_where = 'AND ( diff --git a/tests/Security/SummaryOrderByTest.php b/tests/Security/SummaryOrderByTest.php index 208f8ba..4f2eeda 100644 --- a/tests/Security/SummaryOrderByTest.php +++ b/tests/Security/SummaryOrderByTest.php @@ -17,17 +17,35 @@ hmib_test_define_function('hmib.php', 'hmib_summary_order_by'); }); +beforeEach(function () { + $_SESSION = array(); +}); + +function hmib_order_by($column, $direction, $allowed = array('maxCpu', 'name'), $default = 'name') { + $_SESSION['sess_hmib_proc_sort_column'] = $column; + $_SESSION['sess_hmib_proc_sort_direction'] = $direction; + + return hmib_summary_order_by('proc', $allowed, $default); +} + it('keeps an allowed column and direction', function () { - expect(hmib_summary_order_by('maxCpu', 'ASC', array('maxCpu', 'name'), 'name'))->toBe('ORDER BY maxCpu ASC'); - expect(hmib_summary_order_by('name', 'desc', array('maxCpu', 'name'), 'maxCpu'))->toBe('ORDER BY name DESC'); + expect(hmib_order_by('maxCpu', 'ASC'))->toBe('ORDER BY maxCpu ASC'); + expect(hmib_order_by('name', 'desc', array('maxCpu', 'name'), 'maxCpu'))->toBe('ORDER BY name DESC'); }); it('falls back to the default column for a column the table cannot sort on', function () { - expect(hmib_summary_order_by('not_a_column desc', 'ASC', array('maxCpu', 'name'), 'maxCpu'))->toBe('ORDER BY maxCpu ASC'); + expect(hmib_order_by('not_a_column desc', 'ASC', array('maxCpu', 'name'), 'maxCpu'))->toBe('ORDER BY maxCpu ASC'); }); it('falls back to DESC for an unknown direction', function () { - expect(hmib_summary_order_by('name', 'sideways', array('name'), 'name'))->toBe('ORDER BY name DESC'); + expect(hmib_order_by('name', 'sideways', array('name')))->toBe('ORDER BY name DESC'); +}); + +it('uses the default column and DESC when the session holds no sort for the table', function () { + $_SESSION['sess_hmib_host_sort_column'] = 'name'; + + expect(hmib_summary_order_by('proc', array('maxCpu', 'name'), 'maxCpu'))->toBe('ORDER BY maxCpu DESC'); + expect(hmib_summary_order_by('host', array('maxCpu', 'name'), 'maxCpu'))->toBe('ORDER BY name DESC'); }); it('no longer concatenates the stored sort values into the Summary queries', function () { From cf0ed1140b776afdcc7663cd199a22502cfc7c95 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 01:47:14 -0600 Subject: [PATCH 31/54] test: show the page when an end-to-end request fails, skip without a writable log, and share the suite setup Signed-off-by: Thomas Vincent --- tests/E2E/DevicesPageTest.php | 34 +++-------- tests/E2E/HostTypesTest.php | 27 ++------- tests/E2E/SummaryPageTest.php | 26 ++------- tests/support/e2e.php | 103 +++++++++++++++++++++++++++++++++- 4 files changed, 116 insertions(+), 74 deletions(-) diff --git a/tests/E2E/DevicesPageTest.php b/tests/E2E/DevicesPageTest.php index c8ab489..12e3622 100644 --- a/tests/E2E/DevicesPageTest.php +++ b/tests/E2E/DevicesPageTest.php @@ -27,26 +27,12 @@ 'plugin_hmib_hrSWRun', ); -beforeAll(function () { - if (hmib_e2e_unavailable() === null) { - $GLOBALS['__hmib_e2e'] = hmib_e2e_open(); - } -}); +beforeAll(fn () => hmib_e2e_suite_open()); -afterAll(function () { - if (isset($GLOBALS['__hmib_e2e'])) { - hmib_e2e_close($GLOBALS['__hmib_e2e']); - unset($GLOBALS['__hmib_e2e']); - } -}); +afterAll(fn () => hmib_e2e_suite_close()); beforeEach(function () { - if (!isset($GLOBALS['__hmib_e2e'])) { - $this->markTestSkipped(hmib_e2e_unavailable() ?? 'The end-to-end context could not be opened.'); - } - - hmib_e2e_tag($this->name()); - hmib_test_snapshot(HMIB_E2E_DEVICE_TABLES); + hmib_e2e_test_start($this, HMIB_E2E_DEVICE_TABLES); hmib_fx_type(1, '.1.3.6', 'Linux', 'Linux', '5'); hmib_fx_host(9001, 'alpha'); @@ -57,11 +43,7 @@ hmib_fx_process(9002, 1, 'betad', array('cpu' => 50)); }); -afterEach(function () { - if (isset($GLOBALS['__hmib_e2e'])) { - hmib_test_restore(HMIB_E2E_DEVICE_TABLES); - } -}); +afterEach(fn () => hmib_e2e_test_end(HMIB_E2E_DEVICE_TABLES)); function hmib_e2e_page($action, $query = '', $role = 'admin') { return hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib.php?action=' . $action . '&header=false&filter=' . $query, $role); @@ -139,16 +121,16 @@ function hmib_e2e_links($body, $action) { }); it('shows a user graphs of the selected devices on the Graphs tab', function () { + if (hmib_e2e_core_lacks_graph_filter()) { + $this->markTestSkipped('Cacti 1.2.x declares graph_view_has_untemplated_graphs() in graph_view.php only, so its graph filter cannot be used from a plugin page.'); + } + $ids = hmib_fx_templates(); hmib_test_sql('INSERT INTO graph_local (id, host_id, snmp_query_id, snmp_index, graph_template_id) VALUES (604, 9001, ?, ?, ?)', array($ids['cpu_dq'], '1', $ids['users_gt'])); $page = hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib.php?action=graphs&header=false&reset=1&style=selective&graph_add=604&graph_list=&graph_template_id=0&filter='); - if (strpos($page['body'], 'undefined function graph_view_has_untemplated_graphs') !== false) { - $this->markTestSkipped('Cacti 1.2.x defines graph_view_has_untemplated_graphs() in graph_view.php only, so its graph filter cannot be used from a plugin page.'); - } - hmib_e2e_assert_clean($page); }); diff --git a/tests/E2E/HostTypesTest.php b/tests/E2E/HostTypesTest.php index 5f47a48..f756a03 100644 --- a/tests/E2E/HostTypesTest.php +++ b/tests/E2E/HostTypesTest.php @@ -24,34 +24,15 @@ const HMIB_E2E_CSV_HEADER = "id,name,version,sysDescrMatch,sysObjectID\n"; -beforeAll(function () { - if (hmib_e2e_unavailable() === null) { - $GLOBALS['__hmib_e2e'] = hmib_e2e_open(); - } -}); +beforeAll(fn () => hmib_e2e_suite_open()); -afterAll(function () { - if (isset($GLOBALS['__hmib_e2e'])) { - hmib_e2e_close($GLOBALS['__hmib_e2e']); - unset($GLOBALS['__hmib_e2e']); - } -}); +afterAll(fn () => hmib_e2e_suite_close()); beforeEach(function () { - if (!isset($GLOBALS['__hmib_e2e'])) { - $this->markTestSkipped(hmib_e2e_unavailable() ?? 'The end-to-end context could not be opened.'); - } - - hmib_e2e_tag($this->name()); - hmib_e2e_relogin($GLOBALS['__hmib_e2e']); - hmib_test_snapshot(HMIB_E2E_TYPE_TABLES); + hmib_e2e_test_start($this, HMIB_E2E_TYPE_TABLES, true); }); -afterEach(function () { - if (isset($GLOBALS['__hmib_e2e'])) { - hmib_test_restore(HMIB_E2E_TYPE_TABLES); - } -}); +afterEach(fn () => hmib_e2e_test_end(HMIB_E2E_TYPE_TABLES)); function hmib_e2e_types() { return array_map(function ($row) { diff --git a/tests/E2E/SummaryPageTest.php b/tests/E2E/SummaryPageTest.php index 6819a11..afc81f6 100644 --- a/tests/E2E/SummaryPageTest.php +++ b/tests/E2E/SummaryPageTest.php @@ -32,26 +32,12 @@ const HMIB_E2E_HOST_SORTS = array('name', 'version/1', 'upHosts', 'recHosts', 'downHosts', 'disabledHosts', 'users', 'cpus', 'avgCpuPercent', 'maxCpuPercent', 'avgMem', 'maxMem', 'avgSwap', 'maxSwap', 'avgProcesses', 'maxProcesses'); const HMIB_E2E_PROCESS_SORTS = array('name', 'paths', 'numHosts', 'numProcesses', 'avgCpu', 'maxCpu', 'avgMemory', 'maxMemory'); -beforeAll(function () { - if (hmib_e2e_unavailable() === null) { - $GLOBALS['__hmib_e2e'] = hmib_e2e_open(); - } -}); +beforeAll(fn () => hmib_e2e_suite_open()); -afterAll(function () { - if (isset($GLOBALS['__hmib_e2e'])) { - hmib_e2e_close($GLOBALS['__hmib_e2e']); - unset($GLOBALS['__hmib_e2e']); - } -}); +afterAll(fn () => hmib_e2e_suite_close()); beforeEach(function () { - if (!isset($GLOBALS['__hmib_e2e'])) { - $this->markTestSkipped(hmib_e2e_unavailable() ?? 'The end-to-end context could not be opened.'); - } - - hmib_e2e_tag($this->name()); - hmib_test_snapshot(HMIB_E2E_SUMMARY_TABLES); + hmib_e2e_test_start($this, HMIB_E2E_SUMMARY_TABLES); hmib_fx_type(1, '.1.3.6', 'Linux', 'Linux', '5'); hmib_fx_type(2, '.1.3.7', 'Windows', 'Windows', '10'); @@ -65,11 +51,7 @@ hmib_fx_process(9002, 1, 'sshd', array('cpu' => 50)); }); -afterEach(function () { - if (isset($GLOBALS['__hmib_e2e'])) { - hmib_test_restore(HMIB_E2E_SUMMARY_TABLES); - } -}); +afterEach(fn () => hmib_e2e_test_end(HMIB_E2E_SUMMARY_TABLES)); function hmib_e2e_summary($query, $role = 'admin') { // The page keeps its filter in the session, so every request names one. diff --git a/tests/support/e2e.php b/tests/support/e2e.php index a82af86..161152b 100644 --- a/tests/support/e2e.php +++ b/tests/support/e2e.php @@ -70,6 +70,12 @@ function hmib_e2e_unavailable() { return 'No Cacti install with an include/config.php is next to the plugin.'; } + $log = hmib_e2e_root() . '/log/cacti.log'; + + if (file_exists($log) ? !is_writable($log) : !is_writable(dirname($log))) { + return 'The Cacti log is not writable by this user, and Cacti serves nothing while it is not.'; + } + $pdo = hmib_test_connect(); if ($pdo === null) { @@ -480,6 +486,10 @@ function hmib_e2e_get($context, $path, $role = 'admin') { $response['text'] = hmib_e2e_text($response['body']); + if (strpos($response['text'], 'System log file is not available for writing') === 0) { + throw new RuntimeException('Cacti served its log error instead of ' . $path . ' (status ' . $response['status'] . '): ' . substr($response['text'], 0, 300)); + } + return $response; } @@ -513,9 +523,11 @@ function hmib_e2e_post($context, $path, $fields, $role = 'admin', $files = array * @return void */ function hmib_e2e_assert_clean($page) { - expect($page['status'])->toBe(200); - expect($page['body'])->not->toContain('( ! )'); - expect($page['text'])->not->toContain('SQL'); + $excerpt = ' Page text: ' . substr($page['text'], 0, 400); + + expect($page['status'])->toBe(200, 'Unexpected status ' . $page['status'] . '.' . $excerpt); + expect(strpos($page['body'], '( ! )') === false)->toBeTrue('The page printed a PHP error.' . $excerpt); + expect(strpos($page['text'], 'SQL') === false)->toBeTrue('The page printed an SQL error.' . $excerpt); } /** @@ -534,3 +546,88 @@ function hmib_e2e_relogin(&$context) { $context['cookies'][$role] = hmib_e2e_login($context['server'], 'hmibe2e' . $role, $password); } } + +/** + * Open the end-to-end context for a test file, unless the install is missing. + * + * Call from beforeAll(); the context is kept in $GLOBALS['__hmib_e2e']. + * + * @return void + */ +function hmib_e2e_suite_open() { + if (hmib_e2e_unavailable() === null) { + $GLOBALS['__hmib_e2e'] = hmib_e2e_open(); + } +} + +/** + * Close the context opened by hmib_e2e_suite_open(). Call from afterAll(). + * + * @return void + */ +function hmib_e2e_suite_close() { + if (isset($GLOBALS['__hmib_e2e'])) { + hmib_e2e_close($GLOBALS['__hmib_e2e']); + unset($GLOBALS['__hmib_e2e']); + } +} + +/** + * Prepare one test: skip it when there is no context, tag it for the coverage + * report and copy the tables it touches aside. + * + * @param object $test The Pest test case ($this in beforeEach()). + * @param string[] $tables Keys of HMIB_TEST_SNAPSHOT_SQL. + * @param bool $relogin Whether to log the users in again first. + * + * @return void + */ +function hmib_e2e_test_start($test, $tables, $relogin = false) { + if (!isset($GLOBALS['__hmib_e2e'])) { + $test->markTestSkipped(hmib_e2e_unavailable() ?? 'The end-to-end context could not be opened.'); + } + + hmib_e2e_tag($test->name()); + + if ($relogin) { + hmib_e2e_relogin($GLOBALS['__hmib_e2e']); + } + + hmib_test_snapshot($tables); +} + +/** + * Put back the tables copied by hmib_e2e_test_start(). Call from afterEach(). + * + * @param string[] $tables Keys of HMIB_TEST_SNAPSHOT_SQL. + * + * @return void + */ +function hmib_e2e_test_end($tables) { + if (isset($GLOBALS['__hmib_e2e'])) { + hmib_test_restore($tables); + } +} + +/** + * Whether the Cacti install calls graph_view_has_untemplated_graphs() from + * lib/html_graph.php without declaring it in lib/. Cacti 1.2.x declares it + * only in graph_view.php, so its graph filter cannot be used by a plugin page. + * + * @return bool + */ +function hmib_e2e_core_lacks_graph_filter() { + $library = hmib_e2e_root() . '/lib'; + + if (strpos((string) file_get_contents($library . '/html_graph.php'), 'graph_view_has_untemplated_graphs(') === false) { + return false; + } + + foreach (glob($library . '/*.php') as $file) { + if (strpos((string) file_get_contents($file), 'function graph_view_has_untemplated_graphs') !== false) { + return false; + } + } + + return true; +} From ebe8e627c7c7a61c0b7e41bb0019c1adf4900649 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 02:04:57 -0600 Subject: [PATCH 32/54] test: merge the two rescan unit test files Signed-off-by: Thomas Vincent --- tests/Security/RescanTypesBindingTest.php | 48 ++++++++++++++++ tests/Security/RescanTypesInsertTest.php | 68 ----------------------- 2 files changed, 48 insertions(+), 68 deletions(-) delete mode 100644 tests/Security/RescanTypesInsertTest.php diff --git a/tests/Security/RescanTypesBindingTest.php b/tests/Security/RescanTypesBindingTest.php index 94a3dcb..588d455 100644 --- a/tests/Security/RescanTypesBindingTest.php +++ b/tests/Security/RescanTypesBindingTest.php @@ -13,6 +13,12 @@ * seeded from device-reported sysDescr, so they have to be bound parameters, * and the match must stay limited to devices that are still unclassified. * + * The placeholder type it registers for the devices that match nothing has to + * collapse duplicates (plugin_hmib_hrSystemTypes is keyed on sysObjectID and + * sysDescrMatch), truncate to the column width and skip devices that have not + * reported a description yet; the statement is executed against MariaDB in + * tests/Integration/HmibSqlDbTest.php. + * * hmib_types.php dispatches on ?action at the top level and cannot be * included, so the function is cut out of the source and defined here. */ @@ -24,6 +30,8 @@ beforeEach(function () { hmib_test_reset_db_mocks(); $GLOBALS['__test_db_calls'] = array(); + $GLOBALS['__affected_rows'] = 0; + $_SESSION = array(); hmib_test_mock_db('db_fetch_assoc', 'SELECT DISTINCT sysObjectID', array( array('sysObjectID' => '.1.3.6', 'sysDescr' => 'Linux', 'host_type' => 0), @@ -66,3 +74,43 @@ expect($updates[0]['sql'])->not->toContain(' OR '); expect($updates[0]['sql'])->not->toContain('RLIKE'); }); + +function hmib_test_rescan_insert() { + foreach ($GLOBALS['__test_db_calls'] as $call) { + if (strpos($call['sql'], 'INSERT') !== false) { + return $call; + } + } + + return null; +} + +it('inserts distinct, truncated, non-empty placeholder types with the key collisions ignored', function () { + rescan_types(); + + $insert = hmib_test_rescan_insert(); + + expect($insert)->not->toBeNull(); + expect($insert['fn'])->toBe('db_execute_prepared'); + expect($insert['sql'])->toContain('INSERT IGNORE'); + expect($insert['sql'])->toContain('SELECT DISTINCT'); + expect($insert['sql'])->toContain('LEFT(sysDescr, 100)'); + expect($insert['sql'])->toContain('LEFT(sysObjectID, 100)'); + expect($insert['sql'])->toContain("sysDescr != ''"); + expect($insert['sql'])->toContain("sysObjectID != ''"); + expect($insert['params'])->toBe(array('New Type', 'Unknown')); +}); + +it('reports the number of types that were added', function () { + $GLOBALS['__affected_rows'] = 3; + + rescan_types(); + + expect($_SESSION['hmib_message'])->toContain('Device Types Added'); +}); + +it('reports when no new types were found', function () { + rescan_types(); + + expect($_SESSION['hmib_message'])->toBe('No New Host Types Found!'); +}); diff --git a/tests/Security/RescanTypesInsertTest.php b/tests/Security/RescanTypesInsertTest.php deleted file mode 100644 index d80988d..0000000 --- a/tests/Security/RescanTypesInsertTest.php +++ /dev/null @@ -1,68 +0,0 @@ -not->toBeNull(); - expect($insert['fn'])->toBe('db_execute_prepared'); - expect($insert['sql'])->toContain('INSERT IGNORE'); - expect($insert['sql'])->toContain('SELECT DISTINCT'); - expect($insert['sql'])->toContain('LEFT(sysDescr, 100)'); - expect($insert['sql'])->toContain('LEFT(sysObjectID, 100)'); - expect($insert['sql'])->toContain("sysDescr != ''"); - expect($insert['sql'])->toContain("sysObjectID != ''"); - expect($insert['params'])->toBe(array('New Type', 'Unknown')); -}); - -it('reports the number of types that were added', function () { - $GLOBALS['__affected_rows'] = 3; - - rescan_types(); - - expect($_SESSION['hmib_message'])->toContain('Device Types Added'); -}); - -it('reports when no new types were found', function () { - rescan_types(); - - expect($_SESSION['hmib_message'])->toBe('No New Host Types Found!'); -}); From fb5c39eb8e78557dd4ccb1b06ea155ac948c36cf Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 02:28:18 -0600 Subject: [PATCH 33/54] test: compare host counts in the disabled automation test and restore cacti.log after each test Signed-off-by: Thomas Vincent --- tests/E2E/PollersTest.php | 12 +++++++--- tests/support/e2e.php | 46 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/tests/E2E/PollersTest.php b/tests/E2E/PollersTest.php index 884c225..b5fd89c 100644 --- a/tests/E2E/PollersTest.php +++ b/tests/E2E/PollersTest.php @@ -41,6 +41,7 @@ } hmib_e2e_tag($this->name()); + hmib_e2e_log_mark(); hmib_test_snapshot(HMIB_E2E_POLLER_TABLES); $GLOBALS['__hmib_wrapper'] = hmib_e2e_php_wrapper(); @@ -54,13 +55,16 @@ unset($GLOBALS['__hmib_wrapper']); hmib_test_restore(HMIB_E2E_POLLER_TABLES); } + + hmib_e2e_log_restore(); }); function hmib_e2e_graphs($arguments = array('--force', '--debug')) { $run = hmib_e2e_cli('plugins/hmib/poller_graphs.php', $arguments); - expect($run['exit'])->toBe(0); - expect($run['out'])->not->toContain('Fatal error'); + if ($run['exit'] !== 0 || strpos($run['out'], 'Fatal error') !== false) { + throw new RuntimeException('poller_graphs.php exited ' . $run['exit'] . "\nstdout:\n" . $run['out'] . "\nstderr:\n" . $run['err']); + } return $run['out']; } @@ -174,8 +178,10 @@ function hmib_e2e_host_graphs() { hmib_fx_templates(); hmib_test_sql('REPLACE INTO settings (name, value) VALUES (?, ?)', array('hmib_automation_frequency', '0')); + $before = hmib_test_sql('SELECT COUNT(*) FROM host')->fetchColumn(); + expect(hmib_e2e_graphs(array('--debug')))->toContain('Graph Automation is Disabled'); - expect(hmib_test_sql('SELECT COUNT(*) FROM host WHERE host_template_id > 0')->fetchColumn())->toBe(0); + expect(hmib_test_sql('SELECT COUNT(*) FROM host')->fetchColumn())->toBe($before); }); it('does not run the automation again before its frequency has passed', function () { diff --git a/tests/support/e2e.php b/tests/support/e2e.php index 161152b..754d2fa 100644 --- a/tests/support/e2e.php +++ b/tests/support/e2e.php @@ -588,6 +588,7 @@ function hmib_e2e_test_start($test, $tables, $relogin = false) { } hmib_e2e_tag($test->name()); + hmib_e2e_log_mark(); if ($relogin) { hmib_e2e_relogin($GLOBALS['__hmib_e2e']); @@ -606,9 +607,54 @@ function hmib_e2e_test_start($test, $tables, $relogin = false) { function hmib_e2e_test_end($tables) { if (isset($GLOBALS['__hmib_e2e'])) { hmib_test_restore($tables); + hmib_e2e_log_restore(); } } +/** + * Remember how long cacti.log is before a test runs. + * + * Cacti and the pollers log warnings for the throwaway fixtures (a graph + * template with no items, a reindex of a device that was just created). The + * workflow fails the job on any WARNING: line left in the log, so a test that + * provokes them puts the log back with hmib_e2e_log_restore(). + * + * @return void + */ +function hmib_e2e_log_mark() { + clearstatcache(); + + $log = hmib_e2e_root() . '/log/cacti.log'; + + $GLOBALS['__hmib_e2e_log_length'] = file_exists($log) ? filesize($log) : 0; +} + +/** + * Cut cacti.log back to the length hmib_e2e_log_mark() recorded. + * + * @return void + */ +function hmib_e2e_log_restore() { + $log = hmib_e2e_root() . '/log/cacti.log'; + + if (!isset($GLOBALS['__hmib_e2e_log_length']) || !file_exists($log)) { + return; + } + + clearstatcache(); + + if (filesize($log) > $GLOBALS['__hmib_e2e_log_length']) { + $handle = fopen($log, 'r+'); + + if ($handle !== false) { + ftruncate($handle, $GLOBALS['__hmib_e2e_log_length']); + fclose($handle); + } + } + + unset($GLOBALS['__hmib_e2e_log_length']); +} + /** * Whether the Cacti install calls graph_view_has_untemplated_graphs() from * lib/html_graph.php without declaring it in lib/. Cacti 1.2.x declares it From 12ee9484a400576d1ae7861acf12d8d9fc7dac31 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 01:55:09 -0600 Subject: [PATCH 34/54] fix(poller): keep stored rows when a table walk returns nothing A timed out walk wiped the host's rows for that table. Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + poller_hmib.php | 20 +++- tests/E2E/PollersTest.php | 9 ++ tests/Unit/PollerHmibIndexedOidTest.php | 119 ++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/PollerHmibIndexedOidTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index bc778c5..4a96c47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* issue: Keep a device's stored process, storage, hardware, software and processor rows when the SNMP walk for that table fails or returns nothing, instead of deleting them; core returns an empty walk for a timeout, so a device that really reports an empty table keeps its last rows * issue: Fix PHP warnings on the Summary tab when a request names only the device type table or only the process table and the other table has no stored sort * issue: Fix the process Time Seen total growing on every poller cycle instead of once per process performance collection * issue: Fix graph automation not creating the Host MIB summary device graphs until the next scheduled run after the device is first created diff --git a/poller_hmib.php b/poller_hmib.php index d57111a..dfd9696 100644 --- a/poller_hmib.php +++ b/poller_hmib.php @@ -1006,11 +1006,9 @@ function collectHostIndexedOid(array &$host, array $tree, string $table, string $cols = db_get_table_column_types($table); if (cacti_sizeof($host)) { - // mark for deletion - db_execute("UPDATE $table SET present=0 WHERE host_id=" . $host['id']); - debug("Polling $name from '" . $host['description'] . '[' . $host['hostname'] . "]'"); - $hostMib = []; + $hostMib = []; + $walk_failed = false; foreach ($tree as $mname => $oid) { // baseOID is a container node, not a pollable branch. @@ -1026,6 +1024,7 @@ function collectHostIndexedOid(array &$host, array $tree, string $table, string if (hmib_snmp_request_failed($walk)) { $snmp_errors++; + $walk_failed = true; } // A failed walk can return false/null; keep the merge operand an array. @@ -1036,6 +1035,17 @@ function collectHostIndexedOid(array &$host, array $tree, string $table, string $hostMib = array_merge($hostMib, $walk); } + // Core returns an empty array for a walk that timed out, so an empty + // table cannot be told from an unreachable device: keep the stored rows. + if ($walk_failed || !cacti_sizeof($hostMib)) { + debug("No $name data from '" . $host['description'] . "', keeping the stored rows"); + + return; + } + + // mark for deletion + db_execute_prepared("UPDATE $table SET present = 0 WHERE host_id = ?", [$host['id']]); + $set_string = ''; $values = ''; $sql_suffix = ''; @@ -1273,7 +1283,7 @@ function collectHostIndexedOid(array &$host, array $tree, string $table, string } // remove old records - db_execute("DELETE FROM $table WHERE present=0 AND host_id=" . $host['id']); + db_execute_prepared("DELETE FROM $table WHERE present = 0 AND host_id = ?", [$host['id']]); } } diff --git a/tests/E2E/PollersTest.php b/tests/E2E/PollersTest.php index b5fd89c..2fa5e29 100644 --- a/tests/E2E/PollersTest.php +++ b/tests/E2E/PollersTest.php @@ -293,6 +293,15 @@ function hmib_e2e_worker_fixture() { expect((int) hmib_test_sql('SELECT total_time FROM plugin_hmib_hrSWRun_last_seen WHERE host_id = 9001 AND name = ?', array('sshd'))->fetchColumn())->toBe(900); }); +it('keeps the stored process list when the process walk returns nothing', function () { + hmib_e2e_worker_fixture(); + + $run = hmib_e2e_worker(false, true); + + expect($run['exit'])->toBe(0); + expect(hmib_test_sql('SELECT name FROM plugin_hmib_hrSWRun WHERE host_id = 9001 ORDER BY name')->fetchAll(PDO::FETCH_COLUMN))->toBe(array('cron', 'sshd')); +}); + it('requires a device for a worker run', function () { $run = hmib_e2e_cli('plugins/hmib/poller_hmib.php', array()); diff --git a/tests/Unit/PollerHmibIndexedOidTest.php b/tests/Unit/PollerHmibIndexedOidTest.php new file mode 100644 index 0000000..eb3f33a --- /dev/null +++ b/tests/Unit/PollerHmibIndexedOidTest.php @@ -0,0 +1,119 @@ + array('type' => 'varchar(128)'), 'load' => array('type' => 'int(10)')); + } +} + +const HMIB_TEST_STORAGE_TREE = array( + 'baseOID' => '.1.3.6.1.2.1.25.2.3', + 'index' => '.1.3.6.1.2.1.25.2.3.1.1', + 'description' => '.1.3.6.1.2.1.25.2.3.1.3', +); + +beforeAll(function () { + hmib_test_define_function('poller_hmib.php', 'hmib_snmp_request_failed'); + hmib_test_define_function('poller_hmib.php', 'hmib_splitBaseIndex'); + hmib_test_define_function('poller_hmib.php', 'collectHostIndexedOid'); +}); + +beforeEach(function () { + hmib_test_reset_db_mocks(); + $GLOBALS['__test_db_calls'] = array(); + $GLOBALS['__walks'] = array(); + $GLOBALS['__gets'] = array(); + $GLOBALS['__walk_error'] = ''; + $GLOBALS['snmp_errors'] = 0; +}); + +afterEach(function () { + $GLOBALS['snmp_error'] = ''; +}); + +function hmib_test_storage_host() { + return array('id' => 7, 'description' => 'seven', 'hostname' => 'h7', 'snmp_community' => 'public', 'snmp_version' => 2, + 'snmp_username' => '', 'snmp_password' => '', 'snmp_auth_protocol' => '', 'snmp_priv_passphrase' => '', + 'snmp_priv_protocol' => '', 'snmp_context' => '', 'snmp_port' => 161, 'snmp_timeout' => 500, 'max_oids' => 10); +} + +function hmib_test_table_writes() { + return array_values(array_filter($GLOBALS['__test_db_calls'], function ($call) { + return strpos($call['sql'], 'plugin_hmib_hrStorage') !== false; + })); +} + +it('leaves the stored rows alone when every walk of the table comes back empty', function () { + $host = hmib_test_storage_host(); + + collectHostIndexedOid($host, HMIB_TEST_STORAGE_TREE, 'plugin_hmib_hrStorage', 'hrStorage'); + + expect(hmib_test_table_writes())->toBe(array()); +}); + +it('leaves the stored rows alone when a walk of the table fails', function () { + $host = hmib_test_storage_host(); + + $GLOBALS['__walks']['.1.3.6.1.2.1.25.2.3.1.3'] = array(array('oid' => '.1.3.6.1.2.1.25.2.3.1.3.1', 'value' => '/')); + $GLOBALS['__walk_error'] = 'Timeout'; + + collectHostIndexedOid($host, HMIB_TEST_STORAGE_TREE, 'plugin_hmib_hrStorage', 'hrStorage'); + + expect(hmib_test_table_writes())->toBe(array()); + expect($GLOBALS['snmp_errors'])->toBe(2); +}); + +it('replaces the stored rows of the device when the walk answers', function () { + $host = hmib_test_storage_host(); + + $GLOBALS['__walks']['.1.3.6.1.2.1.25.2.3.1.3'] = array(array('oid' => '.1.3.6.1.2.1.25.2.3.1.3.1', 'value' => '/')); + + collectHostIndexedOid($host, HMIB_TEST_STORAGE_TREE, 'plugin_hmib_hrStorage', 'hrStorage'); + + $writes = hmib_test_table_writes(); + + expect(array_column($writes, 'fn'))->toBe(array('db_execute_prepared', 'db_execute_prepared', 'db_execute_prepared')); + expect($writes[0]['sql'])->toBe('UPDATE plugin_hmib_hrStorage SET present = 0 WHERE host_id = ?'); + expect($writes[0]['params'])->toBe(array(7)); + expect($writes[1]['params'])->toBe(array(7, 1, '/')); + expect($writes[2]['sql'])->toBe('DELETE FROM plugin_hmib_hrStorage WHERE present = 0 AND host_id = ?'); + expect($writes[2]['params'])->toBe(array(7)); +}); From ddfdd3d306c8016b62ffc1e1f6bf3b52b5a55d96 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 01:59:30 -0600 Subject: [PATCH 35/54] fix(poller): hold a lock for the main run and bound its wait A run past the 600 s purge could overlap the next one, and a stuck collector made it wait forever. Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + poller_hmib.php | 43 ++++++++++++---- tests/E2E/PollersTest.php | 37 ++++++++++++++ tests/Unit/PollerHmibCheckHostTest.php | 6 +-- tests/bin/coverage-prepend.php | 8 ++- tests/support/e2e.php | 68 +++++++++++++++++++++++--- 6 files changed, 142 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a96c47..8f2005c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* issue: Stop a main poller run from starting while another is still running by holding a database lock for the whole run, instead of relying on collector rows that are purged after 10 minutes; the main run now stops launching and waiting on collectors 10 minutes after it began, and the launch placeholders use keys that cannot match a process id * issue: Keep a device's stored process, storage, hardware, software and processor rows when the SNMP walk for that table fails or returns nothing, instead of deleting them; core returns an empty walk for a timeout, so a device that really reports an empty table keeps its last rows * issue: Fix PHP warnings on the Summary tab when a request names only the device type table or only the process table and the other table has no stored sort * issue: Fix the process Time Seen total growing on every poller cycle instead of once per process performance collection diff --git a/poller_hmib.php b/poller_hmib.php index dfd9696..67207a0 100644 --- a/poller_hmib.php +++ b/poller_hmib.php @@ -32,6 +32,10 @@ include_once('./lib/snmp.php'); include_once('./lib/ping.php'); +// Collector rows older than this are left by a process that died, and the main +// run stops waiting on its collectors once this long has passed since it began. +const HMIB_MAX_RUNTIME = 600; + // process calling arguments $parms = $_SERVER['argv']; array_shift($parms); @@ -339,6 +343,17 @@ function process_hosts(): void { print "NOTE: Processing Hosts Begins\n"; + // The server frees a connection's lock when the connection ends, so a run + // that dies cannot leave it behind. NULL from an error counts as held. + if (db_fetch_cell("SELECT GET_LOCK(CONCAT('hmib_poller_', DATABASE()), 0)") != 1) { + print "WARNING: Another Host Mib Collector is still running! Exiting\n"; + exit(0); + } + + register_shutdown_function(function () { + db_fetch_cell("SELECT RELEASE_LOCK(CONCAT('hmib_poller_', DATABASE()))"); + }); + /* All time/dates will be stored in timestamps * Get Autodiscovery Lastrun Information */ @@ -359,7 +374,7 @@ function process_hosts(): void { } // Purge collectors that run longer than 10 minutes - db_execute('DELETE FROM plugin_hmib_processes WHERE (UNIX_TIMESTAMP() - UNIX_TIMESTAMP(started)) > 600'); + db_execute_prepared('DELETE FROM plugin_hmib_processes WHERE (UNIX_TIMESTAMP() - UNIX_TIMESTAMP(started)) > ?', [HMIB_MAX_RUNTIME]); // Do not process collectors are still running if (db_fetch_cell('SELECT count(*) FROM plugin_hmib_processes') > 0) { @@ -415,7 +430,8 @@ function process_hosts(): void { print "NOTE: Launching Collectors Starting\n"; - $i = 0; + $i = 0; + $deadline = $start + HMIB_MAX_RUNTIME; if (cacti_sizeof($hosts)) { foreach ($hosts as $host) { @@ -423,16 +439,21 @@ function process_hosts(): void { $processes = db_fetch_cell('SELECT COUNT(*) FROM plugin_hmib_processes'); if ($processes < $concurrent_processes) { - // put a placeholder in place to prevent overloads on slow systems - $key = rand(); + // put a placeholder in place to prevent overloads on slow systems; + // process ids stay below 2^31, so the key cannot match a collector's own row + $key = 2147483648 + $i++; - db_execute("INSERT INTO plugin_hmib_processes (pid, taskid, started) VALUES ($key, $seed, NOW())"); + db_execute_prepared('INSERT INTO plugin_hmib_processes (pid, taskid, started) VALUES (?, ?, NOW())', [$key, $seed]); print "NOTE: Launching Host Collector For: '" . $host['description'] . '[' . $host['hostname'] . "]'\n"; process_host($host['host_id'], $seed, $key); usleep(10000); break; + } elseif (microtime(true) > $deadline) { + print 'WARNING: No free collector after ' . HMIB_MAX_RUNTIME . " seconds, not launching the remaining hosts\n"; + + break 2; } else { sleep(1); } @@ -447,12 +468,16 @@ function process_hosts(): void { // wait for all processes to end or max run time while (true) { - $processes_left = db_fetch_cell("SELECT count(*) FROM plugin_hmib_processes WHERE taskid=$seed"); - $pl = db_fetch_cell('SELECT count(*) FROM plugin_hmib_processes'); + $processes_left = db_fetch_cell_prepared('SELECT COUNT(*) FROM plugin_hmib_processes WHERE taskid = ?', [$seed]); if ($processes_left == 0) { print "NOTE: All Processes Complete, Exiting\n"; + break; + } elseif (microtime(true) > $deadline) { + print "WARNING: Stopped waiting on '$processes_left' Processes after " . HMIB_MAX_RUNTIME . " seconds\n"; + cacti_log("WARNING: Stopped waiting on $processes_left Host MIB collectors after " . HMIB_MAX_RUNTIME . ' seconds', false, 'HMIB'); + break; } else { print "NOTE: Waiting on '$processes_left' Processes\n"; @@ -744,10 +769,10 @@ function checkHost(int $host_id): void { // remove the key process and insert the set a process lock if ($key != '') { - db_execute("DELETE FROM plugin_hmib_processes WHERE pid=$key"); + db_execute_prepared('DELETE FROM plugin_hmib_processes WHERE pid = ?', [$key]); } - db_execute('REPLACE INTO plugin_hmib_processes (pid, taskid) VALUES (' . getmypid() . ", $seed)"); + db_execute_prepared('REPLACE INTO plugin_hmib_processes (pid, taskid) VALUES (?, ?)', [getmypid(), $seed]); // obtain host information $host = db_fetch_row_prepared('SELECT * diff --git a/tests/E2E/PollersTest.php b/tests/E2E/PollersTest.php index 2fa5e29..101d4f6 100644 --- a/tests/E2E/PollersTest.php +++ b/tests/E2E/PollersTest.php @@ -334,6 +334,43 @@ function hmib_e2e_worker_fixture() { expect(hmib_test_sql('SELECT COUNT(*) FROM plugin_hmib_processes')->fetchColumn())->toBe(0); }); +it('refuses a main run while another run holds the run lock, even after its collector rows look stale', function () { + hmib_e2e_worker_fixture(); + hmib_test_sql('INSERT INTO plugin_hmib_processes (pid, taskid, started) VALUES (4242, 1, NOW() - INTERVAL 700 SECOND)'); + + $pdo = hmib_test_connect(); + + expect((int) $pdo->query("SELECT GET_LOCK(CONCAT('hmib_poller_', DATABASE()), 0)")->fetchColumn())->toBe(1); + + try { + $run = hmib_e2e_cli('plugins/hmib/poller_hmib.php', array('-M', '--force'), 60); + } finally { + $pdo->query("SELECT RELEASE_LOCK(CONCAT('hmib_poller_', DATABASE()))"); + } + + expect($run['out'])->toContain('Another Host Mib Collector is still running'); + expect($run['out'])->not->toContain('Launching Host Collector'); +}); + +it('stops launching and waiting on collectors that never finish once the run time is up', function () { + hmib_fx_host(9001, 'alpha'); + hmib_fx_host(9002, 'beta'); + hmib_fx_system(9001, 1, 'Linux', '.1.3.6'); + hmib_fx_system(9002, 1, 'Linux', '.1.3.6'); + + // /bin/true stands in for PHP, so the collector placeholders are never removed. + hmib_test_sql('REPLACE INTO settings (name, value) VALUES (?, ?), (?, ?)', array('hmib_concurrent_processes', '1', 'path_php_binary', '/bin/true')); + + $run = hmib_e2e_cli('plugins/hmib/poller_hmib.php', array('-M', '--start=' . (time() - 601)), 60); + + expect($run['killed'])->toBeFalse(); + expect($run['out'])->toContain("Launching Host Collector For: 'alpha"); + expect($run['out'])->not->toContain("Launching Host Collector For: 'beta"); + expect($run['out'])->toContain('No free collector after 600 seconds'); + expect($run['out'])->toContain("Stopped waiting on '1' Processes after 600 seconds"); + expect($run['out'])->toContain('Host Mib Polling Completed'); +}); + it('assigns the stored OS types to the devices that match them', function () { hmib_fx_type(1, '.1.3.6', 'Linux', 'Linux', '5'); hmib_fx_type(2, '.1.3.7', 'Windows', 'Windows', '10'); diff --git a/tests/Unit/PollerHmibCheckHostTest.php b/tests/Unit/PollerHmibCheckHostTest.php index 2174f85..2d6ea4a 100644 --- a/tests/Unit/PollerHmibCheckHostTest.php +++ b/tests/Unit/PollerHmibCheckHostTest.php @@ -148,10 +148,10 @@ function hmib_test_last_seen_call() { checkHost(5); - $sql = implode("\n", array_column($GLOBALS['__test_db_calls'], 'sql')); + $calls = array_slice($GLOBALS['__test_db_calls'], 0, 2); - expect($sql)->toContain('DELETE FROM plugin_hmib_processes WHERE pid=123'); - expect($sql)->toContain('REPLACE INTO plugin_hmib_processes'); + expect($calls[0])->toBe(array('fn' => 'db_execute_prepared', 'sql' => 'DELETE FROM plugin_hmib_processes WHERE pid = ?', 'params' => array('123'))); + expect($calls[1])->toBe(array('fn' => 'db_execute_prepared', 'sql' => 'REPLACE INTO plugin_hmib_processes (pid, taskid) VALUES (?, ?)', 'params' => array(getmypid(), 77))); }); it('does nothing for a device that no longer exists', function () { diff --git a/tests/bin/coverage-prepend.php b/tests/bin/coverage-prepend.php index 2e53ed7..bf434cc 100644 --- a/tests/bin/coverage-prepend.php +++ b/tests/bin/coverage-prepend.php @@ -70,7 +70,7 @@ xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE | XDEBUG_CC_BRANCH_CHECK); - register_shutdown_function(function () use ($directory, $prefixes) { + $dump = function () use ($directory, $prefixes) { $coverage = xdebug_get_code_coverage(); xdebug_stop_code_coverage(); @@ -107,5 +107,11 @@ $name = sprintf('%s.%d.%s.json', $tag, getmypid(), bin2hex(random_bytes(4))); file_put_contents($directory . '/' . $name, json_encode($kept), LOCK_EX); + }; + + // Queued from a shutdown function, the dump runs after the shutdown + // functions the script registers itself, so their lines are measured too. + register_shutdown_function(function () use ($dump) { + register_shutdown_function($dump); }); })(); diff --git a/tests/support/e2e.php b/tests/support/e2e.php index 754d2fa..09eb39f 100644 --- a/tests/support/e2e.php +++ b/tests/support/e2e.php @@ -367,24 +367,76 @@ function hmib_e2e_login($server, $username, $password) { } /** - * Run a PHP script as a subprocess in the Cacti root. + * Start a PHP script as a subprocess in the Cacti root, without waiting for it. * * @param string $script Path relative to the Cacti root. * @param string[] $args Script arguments. * - * @return array exit code, stdout, stderr. + * @return array The handle for hmib_e2e_wait(). */ -function hmib_e2e_cli($script, $args = array()) { +function hmib_e2e_spawn($script, $args = array()) { $command = array_merge(array(PHP_BINARY), hmib_e2e_php_options(), array($script), $args); $process = proc_open($command, array(1 => array('pipe', 'w'), 2 => array('pipe', 'w')), $pipes, hmib_e2e_root(), array_merge(getenv(), array('HMIB_COVERAGE_PER_TEST' => ''))); - $stdout = stream_get_contents($pipes[1]); - $stderr = stream_get_contents($pipes[2]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); - fclose($pipes[1]); - fclose($pipes[2]); + return array('process' => $process, 'pipes' => $pipes); +} - return array('exit' => proc_close($process), 'out' => $stdout, 'err' => $stderr); +/** + * Wait for a subprocess from hmib_e2e_spawn(), killing it after a time limit + * so a script that never ends fails its test instead of hanging the suite. + * + * @param array $run The handle from hmib_e2e_spawn(). + * @param int $seconds The time limit. + * + * @return array exit code, stdout, stderr, and whether it was killed. + */ +function hmib_e2e_wait($run, $seconds = 300) { + $out = ''; + $err = ''; + $killed = false; + $expires = microtime(true) + $seconds; + + while (true) { + $out .= stream_get_contents($run['pipes'][1]); + $err .= stream_get_contents($run['pipes'][2]); + $status = proc_get_status($run['process']); + + if (!$status['running']) { + break; + } + + if (microtime(true) > $expires) { + proc_terminate($run['process'], 9); + $killed = true; + } + + usleep(50000); + } + + $out .= stream_get_contents($run['pipes'][1]); + $err .= stream_get_contents($run['pipes'][2]); + + fclose($run['pipes'][1]); + fclose($run['pipes'][2]); + proc_close($run['process']); + + return array('exit' => $status['exitcode'], 'out' => $out, 'err' => $err, 'killed' => $killed); +} + +/** + * Run a PHP script as a subprocess in the Cacti root. + * + * @param string $script Path relative to the Cacti root. + * @param string[] $args Script arguments. + * @param int $seconds Time limit, see hmib_e2e_wait(). + * + * @return array exit code, stdout, stderr, and whether it was killed. + */ +function hmib_e2e_cli($script, $args = array(), $seconds = 300) { + return hmib_e2e_wait(hmib_e2e_spawn($script, $args), $seconds); } /** From d82f1b5ea9d101f991ac32db8209235f94611ee4 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 02:06:29 -0600 Subject: [PATCH 36/54] fix(poller): release the autodiscovery lock on any exit Also stop the scan once the run time is used up. Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + poller_hmib.php | 45 ++++++++++++++++++++++--------- tests/E2E/PollersTest.php | 56 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f2005c..062effa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* issue: Remove the autodiscovery process lock however the poller run ends, including on SIGTERM or SIGINT and on a fatal error, so a scan that dies no longer blocks every later run, and stop autodiscovery once the run has used its 10 minutes * issue: Stop a main poller run from starting while another is still running by holding a database lock for the whole run, instead of relying on collector rows that are purged after 10 minutes; the main run now stops launching and waiting on collectors 10 minutes after it began, and the launch placeholders use keys that cannot match a process id * issue: Keep a device's stored process, storage, hardware, software and processor rows when the SNMP walk for that table fails or returns nothing, instead of deleting them; core returns an empty walk for a timeout, so a device that really reports an empty table keeps its last rows * issue: Fix PHP warnings on the Summary tab when a request names only the device type table or only the process table and the other table has no stored sort diff --git a/poller_hmib.php b/poller_hmib.php index 67207a0..4fabe1a 100644 --- a/poller_hmib.php +++ b/poller_hmib.php @@ -127,6 +127,18 @@ exit(0); } +// Turn SIGTERM and SIGINT into a normal exit, so the shutdown functions still +// remove the locks this run holds. +if (function_exists('pcntl_signal')) { + $hmib_stop = function () { + exit(1); + }; + + pcntl_async_signals(true); + pcntl_signal(SIGTERM, $hmib_stop); + pcntl_signal(SIGINT, $hmib_stop); +} + if ($seed == '') { $seed = rand(); } @@ -255,7 +267,7 @@ function hmib_snmp_request_failed($result): bool { * the summary warning logged at the end. */ function autoDiscoverHosts(): bool { - global $debug, $snmp_errors; + global $debug, $snmp_errors, $start; $hosts = db_fetch_assoc("SELECT * FROM host @@ -266,12 +278,24 @@ function autoDiscoverHosts(): bool { debug("Starting AutoDiscovery for '" . sizeof($hosts) . "' Hosts"); // set a process lock - db_execute('REPLACE INTO plugin_hmib_processes (pid, taskid) VALUES (' . getmypid() . ', 0)'); + db_execute_prepared('REPLACE INTO plugin_hmib_processes (pid, taskid) VALUES (?, 0)', [getmypid()]); + + // a lock left behind by a scan that died would hold off every later run + register_shutdown_function(function () { + db_execute_prepared('DELETE FROM plugin_hmib_processes WHERE pid = ? AND taskid = 0', [getmypid()]); + }); $snmp_errors = 0; if (cacti_sizeof($hosts)) { foreach ($hosts as $host) { + if (microtime(true) > $start + HMIB_MAX_RUNTIME) { + print 'WARNING: AutoDiscovery stopped after ' . HMIB_MAX_RUNTIME . " seconds, the remaining hosts wait for its next run\n"; + cacti_log('WARNING: Host MIB AutoDiscovery stopped after ' . HMIB_MAX_RUNTIME . ' seconds', false, 'HMIB'); + + break; + } + debug("AutoDiscovery Check for Host '" . $host['description'] . '[' . $host['hostname'] . "]'"); $hostMib = cacti_snmp_walk($host['hostname'], $host['snmp_community'], '.1.3.6.1.2.1.25.1', $host['snmp_version'], $host['snmp_username'], $host['snmp_password'], @@ -294,14 +318,11 @@ function autoDiscoverHosts(): bool { } if (cacti_sizeof($hostMib)) { - $add = true; - - if ($add) { - debug("Host '" . $host['description'] . '[' . $host['hostname'] . "]' Supports Host MIB Resources"); - db_execute('INSERT INTO plugin_hmib_hrSystem - (host_id) VALUES (' . $host['id'] . ') - ON DUPLICATE KEY UPDATE host_id=VALUES(host_id)'); - } + debug("Host '" . $host['description'] . '[' . $host['hostname'] . "]' Supports Host MIB Resources"); + db_execute_prepared('INSERT INTO plugin_hmib_hrSystem + (host_id) VALUES (?) + ON DUPLICATE KEY UPDATE host_id=VALUES(host_id)', + [$host['id']]); } } } @@ -311,8 +332,8 @@ function autoDiscoverHosts(): bool { } // remove the process lock - db_execute('DELETE FROM plugin_hmib_processes WHERE pid=' . getmypid()); - db_execute("REPLACE INTO settings (name,value) VALUES ('hmib_autodiscovery_lastrun', '" . time() . "')"); + db_execute_prepared('DELETE FROM plugin_hmib_processes WHERE pid = ?', [getmypid()]); + db_execute_prepared("REPLACE INTO settings (name,value) VALUES ('hmib_autodiscovery_lastrun', ?)", [time()]); return true; } diff --git a/tests/E2E/PollersTest.php b/tests/E2E/PollersTest.php index 101d4f6..4bac6d0 100644 --- a/tests/E2E/PollersTest.php +++ b/tests/E2E/PollersTest.php @@ -371,6 +371,62 @@ function hmib_e2e_worker_fixture() { expect($run['out'])->toContain('Host Mib Polling Completed'); }); +it('discovers a device that answers the Host Resources MIB', function () { + hmib_fx_host(9001, 'alpha'); + hmib_test_sql('UPDATE host SET hostname = ?, snmp_community = ? WHERE id = ?', array('localhost', 'public', 9001)); + hmib_test_sql('REPLACE INTO settings (name, value) VALUES (?, ?)', array('hmib_autodiscovery', 'on')); + + $run = hmib_e2e_cli('plugins/hmib/poller_hmib.php', array('-M', '--force'), 120); + + if (strpos($run['out'], "Launching Host Collector For: 'alpha") === false) { + $this->markTestSkipped('No SNMP agent answered on this machine.'); + } + + expect($run['out'])->toContain('Auto Discovery Complete'); + expect(hmib_test_sql('SELECT COUNT(*) FROM plugin_hmib_processes')->fetchColumn())->toBe(0); +}); + +it('stops autodiscovery once the run time is up', function () { + hmib_fx_host(9001, 'alpha'); + hmib_test_sql('UPDATE host SET hostname = ?, snmp_community = ? WHERE id = ?', array('localhost', 'public', 9001)); + hmib_test_sql('REPLACE INTO settings (name, value) VALUES (?, ?)', array('hmib_autodiscovery', 'on')); + + $run = hmib_e2e_cli('plugins/hmib/poller_hmib.php', array('-M', '--force', '--start=' . (time() - 601)), 60); + + expect($run['out'])->toContain('AutoDiscovery stopped after 600 seconds'); + expect(hmib_test_sql('SELECT COUNT(*) FROM plugin_hmib_hrSystem WHERE host_id = 9001')->fetchColumn())->toBe(0); + expect(hmib_test_sql('SELECT COUNT(*) FROM plugin_hmib_processes')->fetchColumn())->toBe(0); +}); + +it('removes the autodiscovery lock when the run is stopped part way', function () { + // An agent that never answers keeps the scan inside its first request. + $agent = stream_socket_server('udp://127.0.0.1:0', $errno, $errstr, STREAM_SERVER_BIND); + $name = stream_socket_get_name($agent, false); + + hmib_fx_host(9001, 'alpha'); + hmib_test_sql('UPDATE host SET hostname = ?, snmp_community = ?, snmp_port = ?, snmp_timeout = ? WHERE id = ?', array('127.0.0.1', 'public', (int) substr($name, strrpos($name, ':') + 1), 2000, 9001)); + hmib_test_sql('REPLACE INTO settings (name, value) VALUES (?, ?), (?, ?)', array('hmib_autodiscovery', 'on', 'snmp_retries', '0')); + + $run = hmib_e2e_spawn('plugins/hmib/poller_hmib.php', array('-M', '--force')); + $locked = false; + + for ($i = 0; $i < 400 && !$locked; $i++) { + usleep(50000); + + $locked = hmib_test_sql('SELECT COUNT(*) FROM plugin_hmib_processes WHERE taskid = 0')->fetchColumn() > 0; + } + + proc_terminate($run['process'], 15); + + $result = hmib_e2e_wait($run, 60); + + fclose($agent); + + expect($locked)->toBeTrue(); + expect($result['out'])->not->toContain('Auto Discovery Complete'); + expect(hmib_test_sql('SELECT COUNT(*) FROM plugin_hmib_processes WHERE taskid = 0')->fetchColumn())->toBe(0); +}); + it('assigns the stored OS types to the devices that match them', function () { hmib_fx_type(1, '.1.3.6', 'Linux', 'Linux', '5'); hmib_fx_type(2, '.1.3.7', 'Windows', 'Windows', '10'); From d61e9c83117badc21b9f1319a80894444bdd2d40 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 02:09:09 -0600 Subject: [PATCH 37/54] perf: hoist repeated lookups out of the Summary and poller loops Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + hmib.php | 43 ++++++++++++------------- poller_hmib.php | 16 ++++++--- tests/Security/GraphUrlQueryTest.php | 22 +++++++++++-- tests/Unit/PollerHmibIndexedOidTest.php | 22 +++++++++++++ 5 files changed, 75 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 062effa..dcd3f9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* issue: Stop repeating per-row lookups: the Summary tab looks up the summary device once per page instead of once per device type, the graph links find an OS type's devices with a join instead of a separate query, and the processor collector asks once per device whether it runs Linux; the device and type ids in those queries and the summary statistics insert are bound or cast to numbers * issue: Remove the autodiscovery process lock however the poller run ends, including on SIGTERM or SIGINT and on a fatal error, so a scan that dies no longer blocks every later run, and stop autodiscovery once the run has used its 10 minutes * issue: Stop a main poller run from starting while another is still running by holding a database lock for the whole run, instead of relying on collector rows that are purged after 10 minutes; the main run now stops launching and waiting on collectors 10 minutes after it began, and the launch placeholders use keys that cannot match a process id * issue: Keep a device's stored process, storage, hardware, software and processor rows when the SNMP walk for that table fails or returns nothing, instead of deleting them; core returns an empty walk for a timeout, so a device that really reports an empty table keeps its last rows diff --git a/hmib.php b/hmib.php index 4865808..ae8cde0 100644 --- a/hmib.php +++ b/hmib.php @@ -3345,18 +3345,18 @@ function clearHostFilter() { html_header_sort($display_text, $_SESSION['sess_hmib_host_sort_column'] ?? 'upHosts', $_SESSION['sess_hmib_host_sort_direction'] ?? 'DESC', 1, 'hmib.php?action=summary&area=hosts'); + if (!$templates_missing) { + $host_id = db_fetch_cell_prepared('SELECT id FROM host WHERE host_template_id = ?', [$htsd]); + } else { + $host_id = '-1'; + } + if (cacti_sizeof($rows)) { $id = 0; foreach ($rows as $row) { form_alternate_row(); - if (!$templates_missing) { - $host_id = db_fetch_cell("SELECT id FROM host WHERE host_template_id=$htsd"); - } else { - $host_id = '-1'; - } - $graph_url = hmib_get_graph_url($htdq, 0, $host_id, $row['id']); $graph_ncpu = hmib_get_graph_url($hcpudq, $row['id'], 0, '', $row['cpus'], false); @@ -3781,22 +3781,17 @@ function hmib_get_graph_url(int $data_query, int $host_type, int $host_id, strin $nograph = $config['url_path'] . 'plugins/hmib/images/view_graphs_disabled.gif'; $graph = $config['url_path'] . 'plugins/hmib/images/view_graphs.gif'; - $hsql = ''; - $hstr = ''; - - if ($host_type > 0) { - $hosts = db_fetch_assoc("SELECT host_id FROM plugin_hmib_hrSystem WHERE host_type=$host_type"); + if (!empty($data_query)) { + $sql_join = ''; + $sql_params = []; - if (cacti_sizeof($hosts)) { - foreach ($hosts as $host) { - $hstr .= (strlen($hstr) ? ',' : '(') . $host['host_id']; - } - $hstr .= ')'; + // joined rather than looked up first, so each link costs one query + if ($host_type > 0) { + $sql_join = 'INNER JOIN plugin_hmib_hrSystem AS hrs ON hrs.host_id = gl.host_id AND hrs.host_type = ?'; + $sql_params[] = $host_type; } - } - if (!empty($data_query)) { - $sql_params = [$data_query]; + $sql_params[] = $data_query; if ($index != '') { $sql_index = ' AND gl.snmp_index = ?'; @@ -3805,12 +3800,16 @@ function hmib_get_graph_url(int $data_query, int $host_type, int $host_id, strin $sql_index = ''; } + if ($host_id != 0) { + $sql_index .= ' AND gl.host_id = ?'; + $sql_params[] = $host_id; + } + $sql = "SELECT DISTINCT gl.id FROM graph_local AS gl + $sql_join WHERE gl.snmp_query_id = ?" . - $sql_index . - ($host_id != 0 ? " AND gl.host_id=$host_id" : '') . - ($hstr!='' ? " AND gl.host_id IN $hstr" : ''); + $sql_index; $graphs = db_fetch_assoc_prepared($sql, $sql_params); diff --git a/poller_hmib.php b/poller_hmib.php index 4fabe1a..a5bbf80 100644 --- a/poller_hmib.php +++ b/poller_hmib.php @@ -603,10 +603,10 @@ function process_hosts(): void { foreach ($stats as $s) { $sql_insert .= (strlen($sql_insert) ? ', ' : '') . '(' . - $s['host_id'] . ', ' . - $s['host_status'] . ', ' . - (!empty($s['cpuPercent']) ? $s['cpuPercent'] : '0') . ', ' . - (!empty($s['numCpus']) ? $s['numCpus'] : '0') . ')'; + (int) $s['host_id'] . ', ' . + (int) $s['host_status'] . ', ' . + (float) $s['cpuPercent'] . ', ' . + (int) $s['numCpus'] . ')'; $j++; @@ -1115,6 +1115,7 @@ function collectHostIndexedOid(array &$host, array $tree, string $table, string $wonky = false; $hrProcValid = false; $effective = 0; + $is_linux = null; if (cacti_sizeof($hostMib)) { foreach ($hostMib as $mib) { @@ -1140,7 +1141,12 @@ function collectHostIndexedOid(array &$host, array $tree, string $table, string $key = 'load'; $mib['value'] = $effective; } elseif (!$hrProcValid) { - if (db_fetch_cell("SELECT count(*) FROM plugin_hmib_hrSystem WHERE sysDescr LIKE '%Linux%' AND host_id=" . $host['id'])) { + // asked once per device, not once per processor row + if ($is_linux === null) { + $is_linux = (bool) db_fetch_cell_prepared("SELECT COUNT(*) FROM plugin_hmib_hrSystem WHERE sysDescr LIKE '%Linux%' AND host_id = ?", [$host['id']]); + } + + if ($is_linux) { // look for the hrProcessorLoad value $temp_mib = $hostMib; diff --git a/tests/Security/GraphUrlQueryTest.php b/tests/Security/GraphUrlQueryTest.php index 7d57916..0fc4adc 100644 --- a/tests/Security/GraphUrlQueryTest.php +++ b/tests/Security/GraphUrlQueryTest.php @@ -59,8 +59,26 @@ it('restricts to one device, including the no-device sentinel', function () { hmib_get_graph_url(12, 0, 5, '', 'title', false); - expect($GLOBALS['__graph_url_query']['sql'])->toContain('gl.host_id=5'); + expect($GLOBALS['__graph_url_query']['sql'])->toContain('gl.host_id = ?'); + expect($GLOBALS['__graph_url_query']['params'])->toBe(array(12, 5)); hmib_get_graph_url(12, 0, -1, '', 'title', false); - expect($GLOBALS['__graph_url_query']['sql'])->toContain('gl.host_id=-1'); + expect($GLOBALS['__graph_url_query']['params'])->toBe(array(12, -1)); +}); + +it('finds the graphs of an OS type with one query per link', function () { + $GLOBALS['__graph_url_lookups'] = 0; + + hmib_test_mock_db('db_fetch_assoc', 'plugin_hmib_hrSystem', function ($sql, $params) { + $GLOBALS['__graph_url_lookups']++; + + return array(array('host_id' => 3)); + }); + + hmib_get_graph_url(12, 8, 0, '', 'title', false); + hmib_get_graph_url(12, 8, 0, '', 'other title', false); + + expect($GLOBALS['__graph_url_lookups'])->toBe(0); + expect($GLOBALS['__graph_url_query']['sql'])->toContain('hrs.host_type = ?'); + expect($GLOBALS['__graph_url_query']['params'])->toBe(array(8, 12)); }); diff --git a/tests/Unit/PollerHmibIndexedOidTest.php b/tests/Unit/PollerHmibIndexedOidTest.php index eb3f33a..6dea872 100644 --- a/tests/Unit/PollerHmibIndexedOidTest.php +++ b/tests/Unit/PollerHmibIndexedOidTest.php @@ -117,3 +117,25 @@ function hmib_test_table_writes() { expect($writes[2]['sql'])->toBe('DELETE FROM plugin_hmib_hrStorage WHERE present = 0 AND host_id = ?'); expect($writes[2]['params'])->toBe(array(7)); }); + +it('asks once per device whether it runs Linux, not once per processor row', function () { + $host = hmib_test_storage_host(); + + $GLOBALS['__linux_checks'] = 0; + + foreach (array('db_fetch_cell', 'db_fetch_cell_prepared') as $fn) { + hmib_test_mock_db($fn, 'Linux', function ($sql, $params) { + $GLOBALS['__linux_checks']++; + + return '0'; + }); + } + + foreach (range(1, 4) as $cpu) { + $GLOBALS['__walks']['.1.3.6.1.2.1.25.3.3.1'][] = array('oid' => '.1.3.6.1.2.1.25.3.3.1.1.' . $cpu, 'value' => '.0.0'); + } + + collectHostIndexedOid($host, array('baseOID' => '.1.3.6.1.2.1.25.3.3.1', 'load' => '.1.3.6.1.2.1.25.3.3.1.2'), 'plugin_hmib_hrProcessor', 'hrProcessor'); + + expect($GLOBALS['__linux_checks'])->toBe(1); +}); From feabec2c20701bdee6549609325b4267da065460 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 02:10:14 -0600 Subject: [PATCH 38/54] fix(poller): purge removed devices in one pass and age out process history Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + poller_hmib.php | 21 ++++++++++++++------- tests/E2E/PollersTest.php | 22 ++++++++++++++++++++++ 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd3f9d..0bb1c31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* issue: Purge the Host MIB rows of devices removed from Cacti with one statement per table instead of seven per device, including rows left without a system entry, and, while Automatically Purge Devices is on, drop process history (the Process Use History tab) for processes not seen for 365 days * issue: Stop repeating per-row lookups: the Summary tab looks up the summary device once per page instead of once per device type, the graph links find an OS type's devices with a join instead of a separate query, and the processor collector asks once per device whether it runs Linux; the device and type ids in those queries and the summary statistics insert are bound or cast to numbers * issue: Remove the autodiscovery process lock however the poller run ends, including on SIGTERM or SIGINT and on a fatal error, so a scan that dies no longer blocks every later run, and stop autodiscovery once the run has used its 10 minutes * issue: Stop a main poller run from starting while another is still running by holding a database lock for the whole run, instead of relying on collector rows that are purged after 10 minutes; the main run now stops launching and waiting on collectors 10 minutes after it began, and the launch placeholders use keys that cannot match a process id diff --git a/poller_hmib.php b/poller_hmib.php index a5bbf80..101a5c9 100644 --- a/poller_hmib.php +++ b/poller_hmib.php @@ -36,6 +36,10 @@ // run stops waiting on its collectors once this long has passed since it began. const HMIB_MAX_RUNTIME = 600; +// With auto purge on, process history is dropped this long after a process +// was last seen running. +const HMIB_LAST_SEEN_DAYS = 365; + // process calling arguments $parms = $_SERVER['argv']; array_shift($parms); @@ -561,16 +565,19 @@ function process_hosts(): void { if (cacti_sizeof($dead_hosts)) { foreach ($dead_hosts as $host) { - db_execute('DELETE FROM plugin_hmib_hrSystem WHERE host_id=' . $host['host_id']); - db_execute('DELETE FROM plugin_hmib_hrSWRun WHERE host_id=' . $host['host_id']); - db_execute('DELETE FROM plugin_hmib_hrSWRun_last_seen WHERE host_id=' . $host['host_id']); - db_execute('DELETE FROM plugin_hmib_hrDevices WHERE host_id=' . $host['host_id']); - db_execute('DELETE FROM plugin_hmib_hrStorage WHERE host_id=' . $host['host_id']); - db_execute('DELETE FROM plugin_hmib_hrProcessor WHERE host_id=' . $host['host_id']); - db_execute('DELETE FROM plugin_hmib_hrSWInstalled WHERE host_id=' . $host['host_id']); print "Purging Host with ID '" . $host['host_id'] . "'\n"; } + + // one statement per table covers every removed device + foreach (['plugin_hmib_hrSWRun', 'plugin_hmib_hrSWRun_last_seen', 'plugin_hmib_hrDevices', 'plugin_hmib_hrStorage', 'plugin_hmib_hrProcessor', 'plugin_hmib_hrSWInstalled', 'plugin_hmib_hrSystem'] as $table) { + db_execute("DELETE hr FROM $table AS hr + LEFT JOIN host + ON host.id=hr.host_id + WHERE host.id IS NULL"); + } } + + db_execute_prepared('DELETE FROM plugin_hmib_hrSWRun_last_seen WHERE last_seen < NOW() - INTERVAL ? DAY', [HMIB_LAST_SEEN_DAYS]); } print "NOTE: Updating Summary Statistics for Each Host\n"; diff --git a/tests/E2E/PollersTest.php b/tests/E2E/PollersTest.php index 4bac6d0..a2c8dd2 100644 --- a/tests/E2E/PollersTest.php +++ b/tests/E2E/PollersTest.php @@ -32,6 +32,10 @@ 'plugin_hmib_hrSystemTypes', 'plugin_hmib_hrSWRun', 'plugin_hmib_hrSWRun_last_seen', + 'plugin_hmib_hrSWInstalled', + 'plugin_hmib_hrDevices', + 'plugin_hmib_hrStorage', + 'plugin_hmib_hrProcessor', 'plugin_hmib_processes', ); @@ -427,6 +431,24 @@ function hmib_e2e_worker_fixture() { expect(hmib_test_sql('SELECT COUNT(*) FROM plugin_hmib_processes WHERE taskid = 0')->fetchColumn())->toBe(0); }); +it('purges the rows of removed devices and the process history not seen for a year', function () { + hmib_e2e_worker_fixture(); + hmib_fx_system(9005, 1, 'Linux', '.1.3.6'); + hmib_fx_process(9005, 1, 'gone'); + hmib_test_sql('INSERT INTO plugin_hmib_hrStorage (host_id, `index`, description) VALUES (9005, 1, ?)', array('/')); + hmib_test_sql('INSERT INTO plugin_hmib_hrSWRun_last_seen (host_id, name, total_time, last_seen) VALUES + (9001, ?, 0, NOW()), (9001, ?, 0, NOW() - INTERVAL 400 DAY), (9005, ?, 0, NOW())', array('sshd', 'oldd', 'gone')); + hmib_test_sql('REPLACE INTO settings (name, value) VALUES (?, ?)', array('hmib_autopurge', 'on')); + + $run = hmib_e2e_cli('plugins/hmib/poller_hmib.php', array('-M', '--force'), 120); + + expect($run['out'])->toContain("Purging Host with ID '9005'"); + expect(hmib_test_sql('SELECT host_id FROM plugin_hmib_hrSystem ORDER BY host_id')->fetchAll(PDO::FETCH_COLUMN))->toBe(array(9001)); + expect(hmib_test_sql('SELECT DISTINCT host_id FROM plugin_hmib_hrSWRun')->fetchAll(PDO::FETCH_COLUMN))->toBe(array(9001)); + expect(hmib_test_sql('SELECT COUNT(*) FROM plugin_hmib_hrStorage')->fetchColumn())->toBe(0); + expect(hmib_test_sql('SELECT CONCAT(host_id, name) FROM plugin_hmib_hrSWRun_last_seen ORDER BY name')->fetchAll(PDO::FETCH_COLUMN))->toBe(array('9001cron', '9001sshd')); +}); + it('assigns the stored OS types to the devices that match them', function () { hmib_fx_type(1, '.1.3.6', 'Linux', 'Linux', '5'); hmib_fx_type(2, '.1.3.7', 'Windows', 'Windows', '10'); From 95764692e2583384d8f06e0878bdcbc63a788687 Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 02:18:28 -0600 Subject: [PATCH 39/54] fix: limit sort columns and bind request values on the Host MIB pages Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + hmib.php | 38 ++++++++-------- hmib_types.php | 86 ++++++++++++++--------------------- tests/E2E/DevicesPageTest.php | 29 ++++++++++++ tests/E2E/HostTypesTest.php | 11 +++++ 5 files changed, 95 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bb1c31..c2f6cc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* security: Restrict the sort column on the Devices, Storage, Hardware, Processes, Use History and Inventory tabs and on the OS type list to the columns each table shows, bind the OS type id on the edit form and the values of the OS type CSV import, and cast the remaining numeric filter option values * issue: Purge the Host MIB rows of devices removed from Cacti with one statement per table instead of seven per device, including rows left without a system entry, and, while Automatically Purge Devices is on, drop process history (the Process Use History tab) for processes not seen for 365 days * issue: Stop repeating per-row lookups: the Summary tab looks up the summary device once per page instead of once per device type, the graph links find an OS type's devices with a join instead of a separate query, and the processor collector asks once per device whether it runs Linux; the device and type ids in those queries and the summary statistics insert are bound or cast to numbers * issue: Remove the autodiscovery process lock however the poller run ends, including on SIGTERM or SIGINT and on a fatal error, so a scan that dies no longer blocks every later run, and stop autodiscovery once the run has used its 10 minutes diff --git a/hmib.php b/hmib.php index ae8cde0..0d5bb7e 100644 --- a/hmib.php +++ b/hmib.php @@ -366,7 +366,7 @@ function hmib_history(): void { $name) { - print "'; + print "'; } } ?> @@ -427,7 +427,7 @@ function clearFilter() { $sql_where = "WHERE hrswls.name!='' AND hrswls.name!='System Idle Process'"; $sql_params = []; $sql_limit = ' LIMIT ' . ($num_rows * (get_request_var('page') - 1)) . ',' . $num_rows; - $sql_order = get_order_string(); + $sql_order = hmib_summary_order_by('hist', ['name', 'description', 'hrswls.name', 'last_seen', 'total_time'], 'name'); if (get_request_var('template') != '-1') { $sql_where .= ($sql_where != '' ? ' AND' : 'WHERE') . ' host.host_template_id = ?'; @@ -777,7 +777,7 @@ function hmib_running(): void { $name) { - print "'; + print "'; } } ?> @@ -838,7 +838,7 @@ function clearFilter() { $sql_limit = ' LIMIT ' . ($num_rows * (get_request_var('page') - 1)) . ',' . $num_rows; $sql_where = "WHERE hrswr.name != '' AND hrswr.name != 'System Idle Process'"; $sql_params = []; - $sql_order = get_order_string(); + $sql_order = hmib_summary_order_by('run', ['name', 'description', 'hrswr.name', 'path', 'parameters', 'perfCpu', 'perfMemory', 'type', 'status'], 'name'); if (get_request_var('template') != '-1') { $sql_where .= ($sql_where != '' ? ' AND' : 'WHERE') . ' host.host_template_id = ?'; @@ -1222,7 +1222,7 @@ function hmib_hardware(): void { if (cacti_sizeof($types)) { foreach ($types as $t) { - print "'; + print "'; } } ?> @@ -1236,7 +1236,7 @@ function hmib_hardware(): void { $sval) { - print "'; + print "'; } ?> @@ -1250,7 +1250,7 @@ function hmib_hardware(): void { $name) { - print "'; + print "'; } } ?> @@ -1322,7 +1322,7 @@ function clearFilter() { $sql_where = "WHERE (hrd.description IS NOT NULL AND hrd.description!='')"; $sql_params = []; $sql_limit = ' LIMIT ' . ($num_rows * (get_request_var('page') - 1)) . ',' . $num_rows; - $sql_order = get_order_string(); + $sql_order = hmib_summary_order_by('hw', ['hrd.description', 'host.description', 'type', 'status', 'errors'], 'hrd.description'); if (get_request_var('template') != '-1') { $sql_where .= ($sql_where != '' ? ' AND' : 'WHERE') . ' host.host_template_id = ?'; @@ -1731,7 +1731,7 @@ function hmib_storage(): void { if (cacti_sizeof($types)) { foreach ($types as $t) { - print "'; + print "'; } } ?> @@ -1746,7 +1746,7 @@ function hmib_storage(): void { $name) { - print "'; + print "'; } } ?> @@ -1807,7 +1807,7 @@ function clearFilter() { $sql_where = "WHERE (hrsto.description IS NOT NULL AND hrsto.description!='')"; $sql_params = []; $sql_limit = ' LIMIT ' . ($num_rows * (get_request_var('page') - 1)) . ',' . $num_rows; - $sql_order = get_order_string(); + $sql_order = hmib_summary_order_by('st', ['hrsto.description', 'host.description', 'type', 'failures', 'percent', 'used', 'size', 'allocationUnits'], 'hrsto.description'); if (get_request_var('template') != '-1') { $sql_where .= ($sql_where != '' ? ' AND' : 'WHERE') . ' host.host_template_id = ?'; @@ -2148,7 +2148,7 @@ function hmib_devices(): void { break; } - print "'; + print "'; } } ?> @@ -2163,7 +2163,7 @@ function hmib_devices(): void { $name) { - print "'; + print "'; } } ?> @@ -2224,7 +2224,7 @@ function clearFilter() { $sql_limit = ' LIMIT ' . ($num_rows * (get_request_var('page') - 1)) . ',' . $num_rows; $sql_where = ''; $sql_params = []; - $sql_order = get_order_string(); + $sql_order = hmib_summary_order_by('devices', ['description', 'host_status', 'uptime', 'users', 'cpuPercent', 'numCpus', 'processes', 'memSize', 'memUsed', 'swapSize', 'swapUsed'], 'description'); if (get_request_var('template') != '-1') { $sql_where .= ($sql_where != '' ? ' AND' : 'WHERE') . ' host.host_template_id = ?'; @@ -2715,7 +2715,7 @@ function hmib_software(): void { if (cacti_sizeof($types)) { foreach ($types as $t) { - print "'; + print "'; } } ?> @@ -2730,7 +2730,7 @@ function hmib_software(): void { $name) { - print "'; + print "'; } } ?> @@ -2791,7 +2791,7 @@ function clearFilter() { $sql_limit = ' LIMIT ' . ($num_rows * (get_request_var('page') - 1)) . ',' . $num_rows; $sql_where = ''; $sql_params = []; - $sql_order = get_order_string(); + $sql_order = hmib_summary_order_by('sw', ['name', 'description', 'type', 'date'], 'name'); if (get_request_var('template') != '-1') { $sql_where .= ($sql_where != '' ? ' AND' : 'WHERE') . ' host.host_template_id = ?'; @@ -2971,11 +2971,11 @@ function hmib_tabs(): void { } /** - * Builds the ORDER BY clause for a Summary table from the sort stored in the + * Builds the ORDER BY clause for a Host MIB table from the sort stored in the * session. The column is accepted only when the table can sort on it, so a * stored or posted value never reaches the SQL text unchecked. * - * @param string $table The table's session prefix: host or proc. + * @param string $table The table's session prefix after sess_hmib_, such as host, proc or hist. * @param array $allowed The columns the table can sort on. * @param string $default The column used when the stored one is missing or not allowed. * diff --git a/hmib_types.php b/hmib_types.php index a9d38ff..141031a 100644 --- a/hmib_types.php +++ b/hmib_types.php @@ -832,10 +832,11 @@ function hmib_host_type_import_processor(array &$host_types): array { for ($i = 1; $i < $line_count; $i++) { $line_array = str_getcsv((string) $lines[$i]); - $save_value = '('; + $save_value = []; + $save_params = []; $j = 0; - $first_column = true; - $sql_where = ''; + $sql_where = []; + $where_params = []; $name = ''; $version = ''; $sysDescrMatch = ''; @@ -843,48 +844,24 @@ function hmib_host_type_import_processor(array &$host_types): array { foreach ($line_array as $line_item) { if (in_array($j, $insert_columns, true)) { - if (!$first_column) { - $save_value .= ','; - } else { - $first_column = false; - } + switch ($j) { + case $host_type_id: + $sql_where[] = 'id = ?'; + $where_params[] = $line_item; - if ($j == $host_type_id || $j == $sysDescrMatch_id || $j == $sysObjectID_id) { - if (strlen($sql_where)) { - switch ($j) { - case $host_type_id: - $sql_where .= ' AND id=' . db_qstr($line_item); - - break; - case $sysDescrMatch_id: - $sql_where .= ' AND sysDescrMatch=' . db_qstr($line_item); - - break; - case $sysObjectID_id: - $sql_where .= ' AND sysObjectID=' . db_qstr($line_item); - - break; - default: - // do nothing - } - } else { - switch ($j) { - case $host_type_id: - $sql_where .= 'WHERE id=' . db_qstr($line_item); - - break; - case $sysDescrMatch_id: - $sql_where .= 'WHERE sysDescrMatch=' . db_qstr($line_item); - - break; - case $sysObjectID_id: - $sql_where .= 'WHERE sysObjectID=' . db_qstr($line_item); - - break; - default: - // do nothing - } - } + break; + case $sysDescrMatch_id: + $sql_where[] = 'sysDescrMatch = ?'; + $where_params[] = $line_item; + + break; + case $sysObjectID_id: + $sql_where[] = 'sysObjectID = ?'; + $where_params[] = $line_item; + + break; + default: + // not part of the existing row lookup } if ($j == $sysDescrMatch_id) { @@ -903,26 +880,28 @@ function hmib_host_type_import_processor(array &$host_types): array { $name = $line_item; } - $save_value .= db_qstr($line_item); + $save_value[] = '?'; + $save_params[] = $line_item; } $j++; } - $save_value .= ')'; + $save_value = '(' . implode(',', $save_value) . ')'; + $sql_where = (cacti_sizeof($sql_where) ? 'WHERE ' . implode(' AND ', $sql_where) : ''); if (isset_request_var('allow_update')) { $sql_execute = 'INSERT INTO plugin_hmib_hrSystemTypes ' . $save_order . ' VALUES' . $save_value . $update_suffix; - if (db_execute($sql_execute)) { + if (db_execute_prepared($sql_execute, $save_params)) { array_push($return_array,'INSERT SUCCEEDED: Name: ' . html_escape($name) . ', Version: ' . html_escape($version) . ', sysDescr: ' . html_escape($sysDescrMatch) . ', sysObjectID: ' . html_escape($sysObjectID)); } else { array_push($return_array,'INSERT FAILED: Name: ' . html_escape($name) . ', Version: ' . html_escape($version) . ', sysDescr: ' . html_escape($sysDescrMatch) . ', sysObjectID: ' . html_escape($sysObjectID)); } } else { // perform check to see if the row exists - $existing_row = db_fetch_row("SELECT * FROM plugin_hmib_hrSystemTypes $sql_where"); + $existing_row = db_fetch_row_prepared("SELECT * FROM plugin_hmib_hrSystemTypes $sql_where", $where_params); if (cacti_sizeof($existing_row)) { array_push($return_array,'INSERT SKIPPED, EXISTING: Name: ' . html_escape($name) . ', Version: ' . html_escape($version) . ', sysDescr: ' . html_escape($sysDescrMatch) . ', sysObjectID: ' . html_escape($sysObjectID)); @@ -930,7 +909,7 @@ function hmib_host_type_import_processor(array &$host_types): array { $sql_execute = 'INSERT INTO plugin_hmib_hrSystemTypes ' . $save_order . ' VALUES' . $save_value; - if (db_execute($sql_execute)) { + if (db_execute_prepared($sql_execute, $save_params)) { array_push($return_array,'INSERT SUCCEEDED: Name: ' . html_escape($name) . ', Version: ' . html_escape($version) . ', sysDescr: ' . html_escape($sysDescrMatch) . ', sysObjectID: ' . html_escape($sysObjectID)); } else { array_push($return_array,'INSERT FAILED: Name: ' . html_escape($name) . ', Version: ' . html_escape($version) . ', sysDescr: ' . html_escape($sysDescrMatch) . ', sysObjectID: ' . html_escape($sysObjectID)); @@ -1011,7 +990,7 @@ function hmib_host_type_edit(): void { ]; if (!isempty_request_var('id')) { - $host_type = db_fetch_row('SELECT * FROM plugin_hmib_hrSystemTypes WHERE id=' . get_filter_request_var('id')); + $host_type = db_fetch_row_prepared('SELECT * FROM plugin_hmib_hrSystemTypes WHERE id = ?', [get_filter_request_var('id')]); $header_label = __esc('Host MIB OS Types [edit: %s]', is_array($host_type) ? $host_type['name'] : '', 'hmib'); } else { $header_label = __('Host MIB OS Types [new]', 'hmib'); @@ -1065,7 +1044,12 @@ function hmib_get_host_types(string &$sql_where, int $rows, bool $apply_limits = OR plugin_hmib_hrSystemTypes.sysDescrMatch LIKE ' . db_qstr('%' . get_request_var('filter') . '%') . ')'; } - $sql_order = get_order_string(); + // the column reaches the ORDER BY, so only a column this table shows is used + if (!in_array(get_request_var('sort_column'), ['name', 'version', 'totals', 'sysObjectID', 'sysDescrMatch'], true)) { + set_request_var('sort_column', 'name'); + } + + $sql_order = 'ORDER BY `' . get_request_var('sort_column') . '` ' . (get_request_var('sort_direction') == 'DESC' ? 'DESC' : 'ASC'); if ($apply_limits) { $sql_limit = ' LIMIT ' . ($rows * (get_request_var('page') - 1)) . ',' . $rows; diff --git a/tests/E2E/DevicesPageTest.php b/tests/E2E/DevicesPageTest.php index 12e3622..fa93d68 100644 --- a/tests/E2E/DevicesPageTest.php +++ b/tests/E2E/DevicesPageTest.php @@ -25,6 +25,10 @@ 'plugin_hmib_hrSystem', 'plugin_hmib_hrSystemTypes', 'plugin_hmib_hrSWRun', + 'plugin_hmib_hrSWRun_last_seen', + 'plugin_hmib_hrSWInstalled', + 'plugin_hmib_hrDevices', + 'plugin_hmib_hrStorage', ); beforeAll(fn () => hmib_e2e_suite_open()); @@ -134,6 +138,31 @@ function hmib_e2e_links($body, $action) { hmib_e2e_assert_clean($page); }); +it('falls back to the default order for a sort column the tab does not show', function (string $action, string $row) { + hmib_test_sql('UPDATE plugin_hmib_hrSWRun SET path = ? WHERE host_id = 9001', array('/opt/runpath')); + hmib_test_sql('INSERT INTO plugin_hmib_hrSWRun_last_seen (host_id, name, last_seen) VALUES (9001, ?, ?)', array('alphad', '2001-02-03 04:05:06')); + hmib_test_sql('INSERT INTO plugin_hmib_hrSWInstalled (host_id, `index`, name) VALUES (9001, 1, ?)', array('alpha-tools')); + hmib_test_sql('INSERT INTO plugin_hmib_hrDevices (host_id, `index`, description) VALUES (9001, 1, ?)', array('alpha disk')); + hmib_test_sql('INSERT INTO plugin_hmib_hrStorage (host_id, `index`, description, size) VALUES (9001, 1, ?, 10)', array('/alpha')); + + // the tab keeps its filters in the session, so start from its defaults + hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib.php?action=' . $action . '&header=false&clear=true'); + + $page = hmib_e2e_page($action, '&sort_column=nosuchcolumn&sort_direction=DESC'); + + hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib.php?action=' . $action . '&header=false&clear=true'); + + hmib_e2e_assert_clean($page); + expect(html_entity_decode($page['body']))->toContain($row); +})->with(array( + 'devices' => array('devices', 'action=running&reset=1&device=9001'), + 'running' => array('running', '/opt/runpath'), + 'history' => array('history', '2001-02-03 04:05:06'), + 'hardware' => array('hardware', 'alpha disk'), + 'storage' => array('storage', '/alpha'), + 'software' => array('software', 'alpha-tools'), +)); + it('refuses the Devices tab without the viewer realm', function () { expect(hmib_e2e_page('devices', '', 'none')['text'])->toContain('Permission Denied'); }); diff --git a/tests/E2E/HostTypesTest.php b/tests/E2E/HostTypesTest.php index f756a03..a64f00f 100644 --- a/tests/E2E/HostTypesTest.php +++ b/tests/E2E/HostTypesTest.php @@ -85,6 +85,17 @@ function hmib_e2e_import($csv, $update = false, $role = 'admin') { expect($page['text'])->toContain('Linux 5 0 .1.3.6 Linux'); }); +it('falls back to the name order for a sort column the OS type list does not show', function () { + hmib_fx_type(1, '.1.3.6', 'Linux', 'Linux', '5'); + + $page = hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib_types.php?header=false&filter=&sort_column=nosuchcolumn&sort_direction=DESC'); + + hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib_types.php?header=false&clear=true'); + + hmib_e2e_assert_clean($page); + expect($page['text'])->toContain('Linux 5 0 .1.3.6 Linux'); +}); + it('escapes every stored field in the OS type list', function () { hmib_fx_type(1, 'oid', 'descr', 'name', 'v'); From a5a4be2c3a69b0a303a10d031f17034e05b0c3aa Mon Sep 17 00:00:00 2001 From: Thomas Vincent Date: Sat, 24 Oct 2026 02:28:38 -0600 Subject: [PATCH 40/54] refactor: share the OS type, device and template filters between the tabs The Device list now binds the chosen OS type. Signed-off-by: Thomas Vincent --- CHANGELOG.md | 1 + hmib.php | 484 ++++++++-------------------------- locales/po/cacti.pot | 26 +- tests/E2E/DevicesPageTest.php | 29 ++ 4 files changed, 147 insertions(+), 393 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2f6cc4..6aef5b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ChangeLog --- develop --- +* dev: Print the OS Type, Device and Template filters of the Host MIB tabs from one function instead of six copies; its Device list binds the chosen OS type and the option values are cast to numbers * security: Restrict the sort column on the Devices, Storage, Hardware, Processes, Use History and Inventory tabs and on the OS type list to the columns each table shows, bind the OS type id on the edit form and the values of the OS type CSV import, and cast the remaining numeric filter option values * issue: Purge the Host MIB rows of devices removed from Cacti with one statement per table instead of seven per device, including rows left without a system entry, and, while Automatically Purge Devices is on, drop process history (the Process Use History tab) for processes not seen for 365 days * issue: Stop repeating per-row lookups: the Summary tab looks up the summary device once per page instead of once per device type, the graph links find an OS type's devices with a join instead of a separate query, and the processor collector asks once per device whether it runs Linux; the device and type ids in those queries and the summary statistics insert are bound or cast to numbers diff --git a/hmib.php b/hmib.php index 0d5bb7e..26d1f8f 100644 --- a/hmib.php +++ b/hmib.php @@ -167,6 +167,104 @@ bottom_footer(); +/** + * Prints the OS Type, Device and Template filter cells the Host MIB tabs + * share. Each list offers only what has Host MIB data, and the Device list + * follows the chosen OS type. + * + * @param bool $device Whether to include the Device filter. + * + * @return void + */ +function hmib_filter_type_device_template(bool $device = true): void { + ?> + + + + + + + + + + + + + + + + + + + + + - - - - - - +
- - - - - - - - - - - - @@ -665,73 +697,7 @@ function hmib_running(): void {
- - - - - - +
- - - - - - - - - - - - @@ -1125,73 +1091,7 @@ function hmib_hardware(): void { - - - - - - +
- - - - - - - - - - - - @@ -1634,73 +1534,7 @@ function hmib_storage(): void { - - - - - - +
- - - - - - - - - - - - @@ -2027,51 +1861,7 @@ function hmib_devices(): void { - - - - + @@ -2619,73 +2409,7 @@ function hmib_software(): void {
- - - - - - - -
- - - - - - +
- - - - - - - - - - - - diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index a3fb30c..03adbb1 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-10 17:47-0400\n" +"POT-Creation-Date: 2026-10-24 04:20-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" @@ -69,10 +69,6 @@ msgstr "" msgid "Down" msgstr "" -#: hmib.php -msgid "Running Process History" -msgstr "" - #: hmib.php hmib_types.php msgid "OS Type" msgstr "" @@ -89,6 +85,10 @@ msgstr "" msgid "Template" msgstr "" +#: hmib.php +msgid "Running Process History" +msgstr "" + #: hmib.php hmib_types.php msgid "Go" msgstr "" @@ -361,6 +361,10 @@ msgstr "" msgid "No Software Packages Found" msgstr "" +#: hmib.php +msgid "Fleet Dashboard" +msgstr "" + #: hmib.php msgid "Summary" msgstr "" @@ -385,10 +389,6 @@ msgstr "" msgid "Graphs" msgstr "" -#: hmib.php -msgid "Fleet Dashboard" -msgstr "" - #: hmib.php msgid "Dashboard" msgstr "" @@ -1298,19 +1298,19 @@ msgid "General Administration" msgstr "" #: setup.php -msgid "Host MIB Inventory Summary" +msgid "Host MIB Fleet Dashboard" msgstr "" #: setup.php -msgid "Host MIB Device Dashboard" +msgid "Host MIB Inventory Summary" msgstr "" #: setup.php -msgid "Host MIB Fleet Dashboard" +msgid "Host MIB Details" msgstr "" #: setup.php -msgid "Host MIB Details" +msgid "Host MIB Device Dashboard" msgstr "" #: setup.php diff --git a/tests/E2E/DevicesPageTest.php b/tests/E2E/DevicesPageTest.php index fa93d68..f3a6f25 100644 --- a/tests/E2E/DevicesPageTest.php +++ b/tests/E2E/DevicesPageTest.php @@ -163,6 +163,35 @@ function hmib_e2e_links($body, $action) { 'software' => array('software', 'alpha-tools'), )); +it('offers in the Device filter only the devices of the chosen OS type', function (string $action) { + hmib_fx_type(2, '.1.3.7', 'Windows', 'Windows', '10'); + hmib_test_sql('UPDATE plugin_hmib_hrSystem SET host_type = 2 WHERE host_id = 9002'); + + $template = hmib_fx_template_row('host_template', str_repeat('e', 32), 'Server '); + + hmib_test_sql('UPDATE host SET host_template_id = ? WHERE id = 9001', array($template)); + + hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib.php?action=' . $action . '&header=false&clear=true'); + + $all = hmib_e2e_page($action); + $page = hmib_e2e_page($action, '&ostype=1'); + + hmib_e2e_get($GLOBALS['__hmib_e2e'], 'hmib.php?action=' . $action . '&header=false&clear=true'); + + hmib_e2e_assert_clean($page); + + foreach (array('all' => $all, 'linux' => $page) as $name => $response) { + preg_match_all("/