diff --git a/CHANGELOG.md b/CHANGELOG.md
index bc778c5..2cfa2b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,8 @@ ChangeLog
* 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 Graphs tab failing with a fatal error on Cacti 1.2.x, where the graph filter calls a function that core declares only in graph_view.php
+* issue: Speed up the OS type rescan by trying each stored type once against all unclassified devices instead of once per stored type and per distinct reported sysObjectID and sysDescr pair
* 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 4865808..6578502 100644
--- a/hmib.php
+++ b/hmib.php
@@ -3868,6 +3868,7 @@ function hmib_view_graphs(): void {
include('./lib/timespan_settings.php');
include('./lib/html_graph.php');
+ require_once(__DIR__ . '/includes/compat.php');
html_graph_validate_preview_request_vars();
diff --git a/hmib_types.php b/hmib_types.php
index a9d38ff..d34a5e0 100644
--- a/hmib_types.php
+++ b/hmib_types.php
@@ -25,6 +25,7 @@
chdir('../../');
include('./include/auth.php');
include_once('./lib/snmp.php');
+require_once(__DIR__ . '/includes/types.php');
$host_types_actions = [
1 => __('Delete', 'hmib'),
@@ -544,30 +545,9 @@ function rescan_types(): void {
WHERE name='" . $new_name . "'
AND version='" . $new_version . "'");
- // get all known devices types from the device type database
- $known_types = db_fetch_assoc('SELECT id, sysDescrMatch, sysObjectID FROM plugin_hmib_hrSystemTypes');
-
- // loop through all device rows and look for a matching type
+ // look for a matching type for the devices that have none
if (cacti_sizeof($unknown_host_types)) {
- foreach ($unknown_host_types as $type) {
- $found = false;
-
- if (cacti_sizeof($known_types)) {
- foreach ($known_types as $known) {
- 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;
-
- break;
- }
- }
- }
- }
+ hmib_assign_host_types();
}
// update the host types from unknown_host_types that have a value of 0
diff --git a/includes/compat.php b/includes/compat.php
new file mode 100644
index 0000000..1e741f5
--- /dev/null
+++ b/includes/compat.php
@@ -0,0 +1,49 @@
+ 0;
+ }
+}
diff --git a/includes/types.php b/includes/types.php
new file mode 100644
index 0000000..e04dfc1
--- /dev/null
+++ b/includes/types.php
@@ -0,0 +1,59 @@
+
setup.php
includes/database.php
+ includes/compat.php
+ includes/types.php
associate_os_type.php
diff --git a/tests/E2E/DevicesPageTest.php b/tests/E2E/DevicesPageTest.php
index 12e3622..3f22d78 100644
--- a/tests/E2E/DevicesPageTest.php
+++ b/tests/E2E/DevicesPageTest.php
@@ -121,10 +121,6 @@ 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']));
diff --git a/tests/Integration/AssignHostTypesDbTest.php b/tests/Integration/AssignHostTypesDbTest.php
new file mode 100644
index 0000000..65f05d3
--- /dev/null
+++ b/tests/Integration/AssignHostTypesDbTest.php
@@ -0,0 +1,378 @@
+ array('CREATE TEMPORARY TABLE hmib_bak_hrSystem AS SELECT * FROM plugin_hmib_hrSystem', 'DELETE FROM plugin_hmib_hrSystem', 'INSERT INTO plugin_hmib_hrSystem SELECT * FROM hmib_bak_hrSystem', 'DROP TEMPORARY TABLE hmib_bak_hrSystem'),
+ 'plugin_hmib_hrSystemTypes' => array('CREATE TEMPORARY TABLE hmib_bak_hrSystemTypes AS SELECT * FROM plugin_hmib_hrSystemTypes', 'DELETE FROM plugin_hmib_hrSystemTypes', 'INSERT INTO plugin_hmib_hrSystemTypes SELECT * FROM hmib_bak_hrSystemTypes', 'DROP TEMPORARY TABLE hmib_bak_hrSystemTypes'),
+);
+
+function hmib_assign_pdo() {
+ static $pdo = false;
+
+ if ($pdo !== false) {
+ return $pdo ?: null;
+ }
+
+ if (getenv('HMIB_TEST_DB') === '0' || !extension_loaded('pdo_mysql')) {
+ return $pdo = null;
+ }
+
+ $settings = array('host' => gethostbyname('localhost'), 'port' => '3306', 'user' => 'cactiuser', 'pass' => '', 'name' => 'cacti');
+ $file = dirname(__DIR__, 4) . '/include/config.php';
+
+ if (is_readable($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);
+ })($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) {
+ if (getenv('HMIB_TEST_DB_' . $suffix) !== false && getenv('HMIB_TEST_DB_' . $suffix) !== '') {
+ $settings[$key] = getenv('HMIB_TEST_DB_' . $suffix);
+ }
+ }
+
+ try {
+ $pdo = new PDO('mysql:host=' . $settings['host'] . ';port=' . $settings['port'] . ';dbname=' . $settings['name'] . ';charset=utf8mb4', $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;
+}
+
+function hmib_assign_sql($sql, $params = array()) {
+ $statement = hmib_assign_pdo()->prepare($sql);
+ $statement->execute($params);
+
+ return $statement;
+}
+
+function hmib_assign_load($fixture) {
+ foreach ($fixture['types'] as $type) {
+ hmib_assign_sql('INSERT INTO plugin_hmib_hrSystemTypes (id, sysObjectID, sysDescrMatch, name, version) VALUES (?, ?, ?, ?, ?)', array($type[0], $type[1], $type[2], 'T' . $type[0], '1'));
+ }
+
+ foreach ($fixture['hosts'] as $id => $host) {
+ hmib_assign_sql('INSERT INTO plugin_hmib_hrSystem (host_id, host_type, sysObjectID, sysDescr) VALUES (?, ?, ?, ?)', array($id, $host[0], $host[1], $host[2]));
+ }
+}
+
+function hmib_assign_clear() {
+ hmib_assign_sql('DELETE FROM plugin_hmib_hrSystem');
+ hmib_assign_sql('DELETE FROM plugin_hmib_hrSystemTypes');
+}
+
+function hmib_assign_result() {
+ return hmib_assign_sql('SELECT host_id, host_type FROM plugin_hmib_hrSystem ORDER BY host_id')->fetchAll(PDO::FETCH_KEY_PAIR);
+}
+
+/** The code under test: its statements are captured, then run on the connection. */
+function hmib_assign_run_new() {
+ $GLOBALS['__test_db_calls'] = array();
+
+ hmib_test_reset_db_mocks();
+ hmib_test_mock_db('db_fetch_assoc', 'plugin_hmib_hrSystemTypes', function ($sql) {
+ return hmib_assign_sql($sql)->fetchAll();
+ });
+
+ hmib_assign_types();
+
+ $count = 0;
+
+ foreach ($GLOBALS['__test_db_calls'] as $call) {
+ if ($call['fn'] === 'db_execute_prepared') {
+ hmib_assign_sql($call['sql'], $call['params']);
+ $count++;
+ }
+ }
+
+ return $count;
+}
+
+function hmib_assign_types() {
+ hmib_assign_host_types();
+}
+
+/** The reference: one UPDATE per stored type for each distinct reported pair. */
+function hmib_assign_run_reference() {
+ $count = 0;
+ $pairs = hmib_assign_sql("SELECT DISTINCT sysObjectID, sysDescr, host_type FROM plugin_hmib_hrSystem WHERE sysObjectID != '' AND sysDescr != '' AND host_type = 0")->fetchAll();
+ $known = hmib_assign_sql('SELECT id, sysDescrMatch, sysObjectID FROM plugin_hmib_hrSystemTypes ORDER BY id')->fetchAll();
+ $update = hmib_assign_pdo()->prepare('UPDATE plugin_hmib_hrSystem SET host_type = ?
+ WHERE host_type = 0 AND sysObjectID LIKE ? AND sysDescr LIKE ?');
+
+ foreach ($pairs as $pair) {
+ foreach ($known as $type) {
+ $update->execute(array($type['id'], '%' . $type['sysObjectID'] . '%', '%' . $type['sysDescrMatch'] . '%'));
+ $count++;
+
+ if ($update->rowCount() > 0) {
+ break;
+ }
+ }
+ }
+
+ return $count;
+}
+
+/** A reproducible fixture of stored types and devices. */
+function hmib_assign_fixture($seed) {
+ $prng = hmib_prng_seed($seed);
+ $types = array();
+ $ids = hmib_prng_shuffle($prng, range(1, hmib_prng_int($prng, 3, 12)));
+
+ foreach ($ids as $id) {
+ $oid = HMIB_ASSIGN_OIDS[hmib_prng_int($prng, 0, count(HMIB_ASSIGN_OIDS) - 1)];
+ $descr = HMIB_ASSIGN_DESCRS[hmib_prng_int($prng, 0, count(HMIB_ASSIGN_DESCRS) - 1)];
+
+ $types[$oid . '|' . $descr] = array($id, $oid, $descr);
+ }
+
+ $hosts = array();
+ $count = hmib_prng_int($prng, 5, 40);
+
+ for ($host = 1; $host <= $count; $host++) {
+ $reported = HMIB_ASSIGN_REPORTED[hmib_prng_int($prng, 0, count(HMIB_ASSIGN_REPORTED) - 1)];
+ $typed = hmib_prng_int($prng, 1, 10) > 8 ? hmib_prng_int($prng, 1, 14) : 0;
+
+ $hosts[$host] = array($typed, $reported[0], $reported[1]);
+ }
+
+ return array('types' => array_values($types), 'hosts' => $hosts);
+}
+
+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',
+));
+
+beforeAll(function () {
+ require_once __DIR__ . '/../../setup.php';
+ require_once __DIR__ . '/../../includes/types.php';
+
+ $stub = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'hmib-test-lib-stub';
+
+ if (!is_dir($stub)) {
+ mkdir($stub, 0777, true);
+ }
+
+ file_put_contents($stub . '/database.php', "markTestSkipped('No MariaDB with a Cacti schema is reachable (see the header of this file).');
+ }
+
+ $GLOBALS['__test_db_calls'] = array();
+
+ hmib_setup_table();
+
+ foreach ($GLOBALS['__test_db_calls'] as $call) {
+ if (strpos($call['sql'], 'CREATE TABLE') === 0) {
+ hmib_assign_pdo()->exec(preg_replace('/^CREATE TABLE (?!IF NOT EXISTS)/', 'CREATE TABLE IF NOT EXISTS ', $call['sql']));
+ }
+ }
+
+ $GLOBALS['__hmib_assign_mode'] = hmib_assign_sql('SELECT @@SESSION.sql_mode')->fetchColumn();
+
+ foreach (HMIB_ASSIGN_SNAPSHOT as $statements) {
+ hmib_assign_sql($statements[0]);
+ hmib_assign_sql($statements[1]);
+ }
+});
+
+afterEach(function () {
+ if (hmib_assign_pdo() === null) {
+ return;
+ }
+
+ hmib_assign_sql('SET SESSION sql_mode = ?', array($GLOBALS['__hmib_assign_mode']));
+
+ foreach (HMIB_ASSIGN_SNAPSHOT as $statements) {
+ hmib_assign_sql($statements[1]);
+ hmib_assign_sql($statements[2]);
+ hmib_assign_sql($statements[3]);
+ }
+});
+
+it('classifies the unclassified devices exactly as the per-pair loop did, for 150 random fixtures', function (string $mode) {
+ hmib_assign_sql('SET SESSION sql_mode = ?', array($mode));
+
+ for ($seed = 1; $seed <= 150; $seed++) {
+ $fixture = hmib_assign_fixture($seed);
+
+ hmib_assign_clear();
+ hmib_assign_load($fixture);
+ hmib_assign_run_reference();
+
+ $expected = hmib_assign_result();
+
+ hmib_assign_clear();
+ hmib_assign_load($fixture);
+ hmib_assign_run_new();
+
+ expect(hmib_assign_result())->toBe($expected, "fixture seed {$seed}");
+ }
+})->with('sql modes');
+
+it('assigns something in most random fixtures, so the comparison is not vacuous', function () {
+ $assigned = 0;
+
+ for ($seed = 1; $seed <= 150; $seed++) {
+ hmib_assign_clear();
+ hmib_assign_load(hmib_assign_fixture($seed));
+ hmib_assign_run_new();
+
+ $assigned += count(array_filter(hmib_assign_result(), function ($type) {
+ return $type > 0;
+ })) > 0 ? 1 : 0;
+ }
+
+ expect($assigned)->toBeGreaterThan(120);
+});
+
+it('leaves a device that has a type alone and gives the first matching type to the rest', function () {
+ hmib_assign_load(array(
+ 'types' => array(array(2, '.1.3', 'Linux'), array(1, '.1.3.6', 'Linux')),
+ 'hosts' => array(1 => array(0, '.1.3.6.1', 'Linux box'), 2 => array(9, '.1.3.6.1', 'Linux box'), 3 => array(0, '.9', 'Plan 9')),
+ ));
+
+ hmib_assign_run_new();
+
+ expect(hmib_assign_result())->toBe(array(1 => 1, 2 => 9, 3 => 0));
+});
+
+it('matches IPv4, IPv6 and numeric looking values as text', function () {
+ hmib_assign_load(array(
+ 'types' => array(array(1, '10.0', 'switch'), array(2, '2001:db8', 'router'), array(3, '1e3', 'probe'), array(4, '0x', 'old')),
+ 'hosts' => array(
+ 1 => array(0, '10.0.0.7', 'a switch'),
+ 2 => array(0, '2001:db8::7', 'a router'),
+ 3 => array(0, '1000', 'a probe'),
+ 4 => array(0, '1e3', 'a probe'),
+ 5 => array(0, '0x1A', 'an old box'),
+ 6 => array(0, '010.0.1', 'a switch'),
+ ),
+ ));
+
+ hmib_assign_run_new();
+
+ expect(hmib_assign_result())->toBe(array(1 => 1, 2 => 2, 3 => 0, 4 => 3, 5 => 4, 6 => 1));
+});
+
+it('treats the stored patterns as LIKE substrings, not as regular expressions', function () {
+ hmib_assign_load(array(
+ 'types' => array(array(1, '.1.3.6', 'Linux.2')),
+ 'hosts' => array(1 => array(0, '.1.3.6.1', 'Linux 2.6'), 2 => array(0, '.1.3.6.1', 'Linux.2 box')),
+ ));
+
+ hmib_assign_run_new();
+
+ expect(hmib_assign_result())->toBe(array(1 => 0, 2 => 1));
+});
+
+it('does nothing when there are no stored types or no devices', function () {
+ hmib_assign_load(array('types' => array(), 'hosts' => array(1 => array(0, '.1.3', 'Linux'))));
+ expect(hmib_assign_run_new())->toBe(0);
+ expect(hmib_assign_result())->toBe(array(1 => 0));
+
+ hmib_assign_clear();
+ hmib_assign_load(array('types' => array(array(1, '.1.3', 'Linux')), 'hosts' => array()));
+ expect(hmib_assign_run_new())->toBe(1);
+ expect(hmib_assign_result())->toBe(array());
+});
+
+it('applies every matching type where the per-pair loop stopped early', function () {
+ // One device with both values reported, one with only a sysObjectID. The loop
+ // tried as many types as there were fully reported pairs (one), and the first
+ // type matched the second device, so the first device kept no type.
+ $fixture = array(
+ 'types' => array(array(1, '.9', ''), array(2, '.1.3', 'Linux')),
+ 'hosts' => array(1 => array(0, '.1.3.6', 'Linux box'), 2 => array(0, '.9.1', '')),
+ );
+
+ hmib_assign_load($fixture);
+ hmib_assign_run_reference();
+
+ expect(hmib_assign_result())->toBe(array(1 => 0, 2 => 1));
+
+ hmib_assign_clear();
+ hmib_assign_load($fixture);
+ hmib_assign_run_new();
+
+ expect(hmib_assign_result())->toBe(array(1 => 2, 2 => 1));
+});
+
+it('issues one statement per stored type where the loop issued one per type and pair', function (int $devices, int $types) {
+ $fixture = array('types' => array(), 'hosts' => array());
+
+ for ($i = 1; $i <= $types; $i++) {
+ $fixture['types'][] = array($i, '.1.3.' . $i, 'No such description ' . $i);
+ }
+
+ for ($i = 1; $i <= $devices; $i++) {
+ $fixture['hosts'][$i] = array(0, '.1.9.' . $i, 'Unmatched device ' . $i);
+ }
+
+ hmib_assign_load($fixture);
+ expect(hmib_assign_run_reference())->toBe($devices * $types);
+
+ hmib_assign_clear();
+ hmib_assign_load($fixture);
+ expect(hmib_assign_run_new())->toBe($types);
+})->with(array('20 devices, 5 types' => array(20, 5), '200 devices, 20 types' => array(200, 20)));
diff --git a/tests/Security/RescanTypesBindingTest.php b/tests/Security/RescanTypesBindingTest.php
index 588d455..734edee 100644
--- a/tests/Security/RescanTypesBindingTest.php
+++ b/tests/Security/RescanTypesBindingTest.php
@@ -24,6 +24,7 @@
*/
beforeAll(function () {
+ require_once __DIR__ . '/../../includes/types.php';
hmib_test_define_function('hmib_types.php', 'rescan_types');
});
diff --git a/tests/Unit/AssignHostTypesTest.php b/tests/Unit/AssignHostTypesTest.php
new file mode 100644
index 0000000..6edc6b6
--- /dev/null
+++ b/tests/Unit/AssignHostTypesTest.php
@@ -0,0 +1,103 @@
+toContain('FROM plugin_hmib_hrSystemTypes');
+ expect($sql)->toContain('ORDER BY id');
+});
+
+it('issues no statement when there are no stored types', function () {
+ hmib_assign_host_types();
+
+ expect(hmib_assign_updates())->toBe(array());
+});
+
+it('issues one UPDATE per stored type, however many devices there are', function (int $types) {
+ $rows = array();
+
+ for ($i = 1; $i <= $types; $i++) {
+ $rows[] = array('id' => (string) $i, 'sysObjectID' => '.1.3.' . $i, 'sysDescrMatch' => 'Type ' . $i);
+ }
+
+ hmib_test_mock_db('db_fetch_assoc', 'plugin_hmib_hrSystemTypes', $rows);
+
+ hmib_assign_host_types();
+
+ expect(hmib_assign_updates())->toHaveCount($types);
+})->with(array('one' => array(1), 'twenty' => array(20), 'two hundred' => array(200)));
+
+it('binds the id and both patterns and limits the match to unclassified devices', function () {
+ hmib_test_mock_db('db_fetch_assoc', 'plugin_hmib_hrSystemTypes', array(array('id' => '7', 'sysObjectID' => '.1.3.6.1', 'sysDescrMatch' => 'Linux%gentoo')));
+
+ hmib_assign_host_types();
+
+ $updates = hmib_assign_updates();
+
+ expect($updates[0]['params'])->toBe(array('7', '%.1.3.6.1%', '%Linux%gentoo%'));
+ expect(preg_replace('/\s+/', ' ', $updates[0]['sql']))->toContain('WHERE host_type = 0 AND sysObjectID LIKE ? AND sysDescr LIKE ?');
+ expect($updates[0]['sql'])->not->toContain('RLIKE');
+});
+
+it('never places a stored value in the SQL text and treats every value as a string', function (string $oid, string $descr) {
+ hmib_test_mock_db('db_fetch_assoc', 'plugin_hmib_hrSystemTypes', array(array('id' => '3', 'sysObjectID' => $oid, 'sysDescrMatch' => $descr)));
+
+ hmib_assign_host_types();
+
+ $update = hmib_assign_updates()[0];
+
+ foreach (array($oid, $descr) as $value) {
+ if ($value !== '') {
+ expect($update['sql'])->not->toContain($value);
+ }
+ }
+ expect($update['params'][1])->toBe('%' . $oid . '%');
+ expect($update['params'][2])->toBe('%' . $descr . '%');
+ expect($update['params'])->toHaveCount(3);
+})->with(array(
+ 'quote and backslash' => array("it's", 'back\\slash'),
+ 'ipv4 address' => array('192.168.0.10', '10'),
+ 'ipv6 address' => array('2001:db8::1', 'fe80::1%eth0'),
+ 'numeric looking' => array('0x1A', '1e3'),
+ 'leading zeros' => array('007', '00'),
+ 'empty patterns' => array('', ''),
+ 'a statement fragment' => array("x' OR '1'='1", '; DROP TABLE t'),
+));
diff --git a/tests/Unit/GraphViewCompatTest.php b/tests/Unit/GraphViewCompatTest.php
new file mode 100644
index 0000000..8c1e3fb
--- /dev/null
+++ b/tests/Unit/GraphViewCompatTest.php
@@ -0,0 +1,75 @@
+toBeTrue();
+});
+
+it('reports whether an allowed graph without a template exists', function (int $rows, bool $expected) {
+ $GLOBALS['__allowed_graphs_total'] = $rows;
+
+ expect(graph_view_has_untemplated_graphs())->toBe($expected);
+})->with(array('none' => array(0, false), 'one' => array(1, true), 'many' => array(250, true)));
+
+it('asks only for one untemplated graph, restricted to the current devices when they are given', function (string $devices, string $where) {
+ graph_view_has_untemplated_graphs($devices);
+
+ expect($GLOBALS['__allowed_graphs_call'])->toBe(array($where, '', '1'));
+})->with(array(
+ 'all devices' => array('', '(gl.graph_template_id = 0)'),
+ 'selected devices' => array('gl.host_id IN (3,4)', '(gl.graph_template_id = 0) AND gl.host_id IN (3,4)'),
+));
+
+it('leaves core\'s own declaration alone when core has one', function () {
+ $script = tempnam(sys_get_temp_dir(), 'hmib');
+
+ file_put_contents($script, " array('pipe', 'w'), 2 => array('pipe', 'w')), $pipes);
+ $out = stream_get_contents($pipes[1]);
+ $err = stream_get_contents($pipes[2]);
+
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ proc_close($process);
+ unlink($script);
+
+ expect($out)->toBe('core');
+ expect($err)->toBe('');
+});
+
+it('is loaded by the Graphs tab after core\'s graph library', function () {
+ $source = plugin_test_read_source('hmib.php');
+
+ expect($source)->toMatch('/include\(\'\.\/lib\/html_graph\.php\'\);\s*require_once\(__DIR__ \. \'\/includes\/compat\.php\'\);/');
+});
diff --git a/tests/support/e2e.php b/tests/support/e2e.php
index 754d2fa..43d5b0a 100644
--- a/tests/support/e2e.php
+++ b/tests/support/e2e.php
@@ -654,26 +654,3 @@ function hmib_e2e_log_restore() {
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
- * 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;
-}
diff --git a/tests/support/prng.php b/tests/support/prng.php
new file mode 100644
index 0000000..8813a5f
--- /dev/null
+++ b/tests/support/prng.php
@@ -0,0 +1,51 @@
+> 17;
+ $x ^= ($x << 5) & 0xFFFFFFFF;
+
+ $prng[0] = $x;
+
+ return $x;
+}
+
+/** An integer from min to max, both included. */
+function hmib_prng_int(array &$prng, int $min, int $max): int {
+ return $min + hmib_prng_next($prng) % ($max - $min + 1);
+}
+
+/** A Fisher-Yates shuffle driven by the seeded generator. */
+function hmib_prng_shuffle(array &$prng, array $items): array {
+ for ($i = count($items) - 1; $i > 0; $i--) {
+ $j = hmib_prng_int($prng, 0, $i);
+
+ $swap = $items[$i];
+ $items[$i] = $items[$j];
+ $items[$j] = $swap;
+ }
+
+ return $items;
+}