diff --git a/phpunit.xml b/phpunit.xml index e2d7137..6cf36a5 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -14,6 +14,7 @@ + tests/Security tests/Unit tests/Integration diff --git a/tests/.cacti-version b/tests/.cacti-version index 585c5bc..4afa1e8 100644 --- a/tests/.cacti-version +++ b/tests/.cacti-version @@ -1 +1 @@ -1.2.31 +1.2.x diff --git a/tests/Integration/MonitorInstallHooksTest.php b/tests/Integration/MonitorInstallHooksTest.php new file mode 100644 index 0000000..e772e49 --- /dev/null +++ b/tests/Integration/MonitorInstallHooksTest.php @@ -0,0 +1,48 @@ +toHaveKey($expected); + expect($hooks[$expected]['name'])->toBe('monitor'); + } + + expect($GLOBALS['__test_registered_realms'])->toHaveCount(1); + expect($GLOBALS['__test_registered_realms'][0]['file'])->toBe('monitor.php'); + + expect($GLOBALS['__test_config_options']['monitor_view'])->toBe('default'); + expect($GLOBALS['__test_config_options']['monitor_trim'])->toBe('4000'); + + $sql = implode("\n", array_column($GLOBALS['__test_db_calls'], 'sql')); + + expect($sql)->toContain('plugin_monitor_notify_history'); +}); diff --git a/tests/Security/PhpCompatibilityTest.php b/tests/Security/PhpCompatibilityTest.php new file mode 100644 index 0000000..1621294 --- /dev/null +++ b/tests/Security/PhpCompatibilityTest.php @@ -0,0 +1,149 @@ +getExtension() !== 'php') { + continue; + } + + $relativeFile = ltrim(str_replace($pluginRoot, '', $file->getPathname()), DIRECTORY_SEPARATOR); + $relativeFile = str_replace(DIRECTORY_SEPARATOR, '/', $relativeFile); + + if (strpos($relativeFile, 'tests/') === 0 || strpos($relativeFile, 'vendor/') === 0 || strpos($relativeFile, 'include/vendor/') === 0) { + continue; + } + + $files[] = $relativeFile; +} + +sort($files); + +function plugin_test_read_source_file($relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + + if ($path === false) { + throw new RuntimeException("Unable to resolve required plugin source: {$relativeFile}"); + } + + $contents = file_get_contents($path); + + if ($contents === false) { + throw new RuntimeException("Unable to read required plugin source: {$relativeFile}"); + } + + return $contents; +} + +it('does not use asymmetric visibility (PHP 8.4)', function () use ($files) { + foreach ($files as $relativeFile) { + $contents = plugin_test_read_source_file($relativeFile); + + expect(preg_match('/\bprivate\s*\(\s*set\s*\)|\bprotected\s*\(\s*set\s*\)/', $contents))->toBe(0, + "{$relativeFile} uses asymmetric visibility which requires PHP 8.4" + ); + } +}); + +it('does not use the #[Override] attribute (PHP 8.3)', function () use ($files) { + foreach ($files as $relativeFile) { + $contents = plugin_test_read_source_file($relativeFile); + + expect(preg_match('/#\[\s*\\\\?Override\s*\]/i', $contents))->toBe(0, + "{$relativeFile} uses #[Override] which requires PHP 8.3" + ); + } +}); + +it('does not use the #[Deprecated] attribute (PHP 8.4)', function () use ($files) { + foreach ($files as $relativeFile) { + $contents = plugin_test_read_source_file($relativeFile); + + expect(preg_match('/#\[\s*\\\\?Deprecated\b/i', $contents))->toBe(0, + "{$relativeFile} uses #[Deprecated] which requires PHP 8.4" + ); + } +}); + +it('does not use json_validate() (PHP 8.3)', function () use ($files) { + foreach ($files as $relativeFile) { + $contents = plugin_test_read_source_file($relativeFile); + + expect(preg_match('/\bjson_validate\s*\(/', $contents))->toBe(0, + "{$relativeFile} uses json_validate() which requires PHP 8.3" + ); + } +}); + +it('does not use array_find()/array_any()/array_all() (PHP 8.4)', function () use ($files) { + foreach ($files as $relativeFile) { + $contents = plugin_test_read_source_file($relativeFile); + + expect(preg_match('/\barray_(find|any|all)\s*\(/', $contents))->toBe(0, + "{$relativeFile} uses array_find()/array_any()/array_all() which requires PHP 8.4" + ); + } +}); + +it('does not use typed class constants (PHP 8.3)', function () use ($files) { + foreach ($files as $relativeFile) { + $contents = plugin_test_read_source_file($relativeFile); + + expect(preg_match('/\bconst\s+(?:\?\s*)?[A-Za-z_\\\\][A-Za-z0-9_\\\\]*(?:\s*\|\s*(?:\?\s*)?[A-Za-z_\\\\][A-Za-z0-9_\\\\]*)*\s+[A-Za-z_][A-Za-z0-9_]*\s*=/', $contents))->toBe(0, + "{$relativeFile} uses a typed class constant which requires PHP 8.3" + ); + } +}); + +it('does not use a dynamic class constant fetch (PHP 8.3)', function () use ($files) { + foreach ($files as $relativeFile) { + $contents = plugin_test_read_source_file($relativeFile); + + expect(preg_match('/::\s*\{\s*\$/', $contents))->toBe(0, + "{$relativeFile} uses a dynamic class constant fetch (Foo::" . '{$bar}' . ") which requires PHP 8.3" + ); + } +}); + +it('does not use each() (removed in PHP 8.0)', function () use ($files) { + foreach ($files as $relativeFile) { + $contents = plugin_test_read_source_file($relativeFile); + + expect(preg_match('/(?])\beach\s*\(/', $contents))->toBe(0, + "{$relativeFile} uses each() which was removed in PHP 8.0" + ); + } +}); + +it('does not use create_function() (removed in PHP 8.0)', function () use ($files) { + foreach ($files as $relativeFile) { + $contents = plugin_test_read_source_file($relativeFile); + + expect(preg_match('/(?])\bcreate_function\s*\(/', $contents))->toBe(0, + "{$relativeFile} uses create_function() which was removed in PHP 8.0" + ); + } +}); diff --git a/tests/Security/SetupStructureTest.php b/tests/Security/SetupStructureTest.php new file mode 100644 index 0000000..6f73e10 --- /dev/null +++ b/tests/Security/SetupStructureTest.php @@ -0,0 +1,35 @@ +toContain('function plugin_monitor_install'); +}); + +it('defines plugin_monitor_version function', function () use ($source) { + expect($source)->toContain('function plugin_monitor_version'); +}); + +it('defines plugin_monitor_uninstall function', function () use ($source) { + expect($source)->toContain('function plugin_monitor_uninstall'); +}); + +it('declares a plugin name in INFO', function () use ($info) { + expect($info)->toHaveKey('name'); +}); + +it('declares a plugin version in INFO', function () use ($info) { + expect($info)->toHaveKey('version'); +}); diff --git a/tests/Unit/MonitorDeviceHooksTest.php b/tests/Unit/MonitorDeviceHooksTest.php new file mode 100644 index 0000000..376f187 --- /dev/null +++ b/tests/Unit/MonitorDeviceHooksTest.php @@ -0,0 +1,79 @@ + array('friendly_name' => 'Other'))); + + expect($filters)->toHaveKey('other'); + expect($filters)->toHaveKey('criticality'); + expect($filters['criticality']['default'])->toBe('-1'); +}); + +it('does not add a criticality clause when criticality is -1 (any)', function () { + $GLOBALS['__test_request']['criticality'] = '-1'; + + expect(monitor_device_sql_where(''))->toBe(''); +}); + +it('adds a criticality clause when a specific criticality is selected', function () { + $GLOBALS['__test_request']['criticality'] = '2'; + + expect(monitor_device_sql_where(''))->toBe('WHERE monitor_criticality = 2'); + expect(monitor_device_sql_where('WHERE host_status = 1'))->toBe('WHERE host_status = 1 AND monitor_criticality = 2'); +}); + +it('adds the monitoring actions to the device action dropdown', function () { + $actions = monitor_device_action_array(array('delete' => 'Delete')); + + expect($actions)->toHaveKey('delete'); + expect($actions)->toHaveKey('monitor_settings'); + expect($actions)->toHaveKey('monitor_enable'); + expect($actions)->toHaveKey('monitor_disable'); +}); + +it('removes every per-device record on device_remove', function () { + $result = monitor_device_remove(array(3, 4)); + + expect($GLOBALS['__test_db_calls'])->toHaveCount(3); + expect($result)->toBe(array(3, 4)); + + foreach ($GLOBALS['__test_db_calls'] as $call) { + expect($call['sql'])->toContain('IN(3,4)'); + } +}); + +it('adds the monitor breadcrumb entry without disturbing existing ones', function () { + $nav = monitor_draw_navigation_text(array('other.php:' => array('title' => 'Other'))); + + expect($nav)->toHaveKey('other.php:'); + expect($nav)->toHaveKey('monitor.php:'); +}); + +it('reads the default monitor state only for a new (unsaved) host', function () { + $GLOBALS['__test_config_options']['monitor_new_enabled'] = 'on'; + + expect(monitor_get_default(0))->toBe('on'); + expect(monitor_get_default(-1))->toBe('on'); + expect(monitor_get_default(5))->toBe(''); +}); diff --git a/tests/Unit/MonitorLifecycleTest.php b/tests/Unit/MonitorLifecycleTest.php new file mode 100644 index 0000000..dcf368c --- /dev/null +++ b/tests/Unit/MonitorLifecycleTest.php @@ -0,0 +1,87 @@ +toHaveCount(3); +}); + +it('normalizes an out-of-range monitor_refresh setting to 300', function () { + $GLOBALS['__test_config_options']['monitor_refresh'] = '9999'; + + expect(plugin_monitor_check_config())->toBeTrue(); + + expect($GLOBALS['__test_config_options']['monitor_refresh'])->toBe('300'); +}); + +it('leaves a valid monitor_refresh setting untouched', function () { + $GLOBALS['__test_config_options']['monitor_refresh'] = '60'; + + plugin_monitor_check_config(); + + expect($GLOBALS['__test_config_options']['monitor_refresh'])->toBe('60'); +}); + +it('reports that no upgrade is pending', function () { + expect(plugin_monitor_upgrade())->toBeFalse(); +}); + +it('skips the version check on pages that do not need it', function () { + $_SERVER['PHP_SELF'] = '/graphs.php'; + + monitor_check_upgrade(); + + expect($GLOBALS['__test_db_calls'])->toBeEmpty(); +}); + +it('updates the stored plugin_config version when it drifts', function () { + monitor_test_mock_db('db_fetch_cell', 'plugin_config', '0.0.0'); + + monitor_check_upgrade(); + + $sql = implode("\n", array_column($GLOBALS['__test_db_calls'], 'sql')); + + expect($sql)->toContain('ALTER TABLE host'); +}); diff --git a/tests/Unit/MonitorSetupTableAndPollerTest.php b/tests/Unit/MonitorSetupTableAndPollerTest.php new file mode 100644 index 0000000..d4569d4 --- /dev/null +++ b/tests/Unit/MonitorSetupTableAndPollerTest.php @@ -0,0 +1,66 @@ +toContain($table); + } +}); + +it('launches poller_monitor.php in the background on the primary poller', function () { + $GLOBALS['__test_config_options']['path_php_binary'] = '/usr/bin/php'; + + monitor_poller_bottom(); + + expect($GLOBALS['__test_exec_calls'])->toHaveCount(1); + expect($GLOBALS['__test_exec_calls'][0]['command'])->toBe('/usr/bin/php'); + expect($GLOBALS['__test_exec_calls'][0]['args'])->toContain('poller_monitor.php'); +}); + +it('does nothing on a non-primary poller', function () { + $GLOBALS['config']['poller_id'] = 2; + + monitor_poller_bottom(); + + expect($GLOBALS['__test_exec_calls'])->toBeEmpty(); +}); diff --git a/tests/Unit/MonitorVersionTest.php b/tests/Unit/MonitorVersionTest.php new file mode 100644 index 0000000..878291a --- /dev/null +++ b/tests/Unit/MonitorVersionTest.php @@ -0,0 +1,23 @@ +toBeArray(); + expect($info)->toHaveKey('name'); + expect($info)->toHaveKey('version'); + expect($info['name'])->toBe('monitor'); +}); diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index 822b69e..411ce83 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -81,6 +81,45 @@ $GLOBALS['__test_db_calls'] = array(); $GLOBALS['__test_request'] = array(); +$GLOBALS['__test_db_fixtures'] = array(); + +if (!function_exists('monitor_test_mock_db')) { + function monitor_test_mock_db($fn, $match, $result) { + $GLOBALS['__test_db_fixtures'][] = array('fn' => $fn, 'match' => $match, 'result' => $result); + } +} + +if (!function_exists('monitor_test_reset_db_mocks')) { + function monitor_test_reset_db_mocks() { + $GLOBALS['__test_db_fixtures'] = array(); + } +} + +if (!function_exists('monitor_test_db_result')) { + function monitor_test_db_result($fn, $sql, $params, $default) { + foreach (array_reverse($GLOBALS['__test_db_fixtures']) as $fixture) { + if ($fixture['fn'] !== $fn) { + continue; + } + + $match = $fixture['match']; + + if (is_callable($match)) { + if (!$match($sql, $params)) { + continue; + } + } elseif (strpos($sql, $match) === false) { + continue; + } + + $result = $fixture['result']; + + return is_callable($result) ? $result($sql, $params) : $result; + } + + return $default; + } +} if (!function_exists('db_execute')) { function db_execute($sql) { @@ -98,7 +137,7 @@ function db_execute_prepared($sql, $params = array()) { if (!function_exists('db_fetch_assoc')) { function db_fetch_assoc($sql) { - return array(); + return monitor_test_db_result('db_fetch_assoc', $sql, array(), array()); } } @@ -122,13 +161,13 @@ function db_fetch_row_prepared($sql, $params = array()) { if (!function_exists('db_fetch_cell')) { function db_fetch_cell($sql) { - return ''; + return monitor_test_db_result('db_fetch_cell', $sql, array(), ''); } } if (!function_exists('db_fetch_cell_prepared')) { function db_fetch_cell_prepared($sql, $params = array()) { - return ''; + return monitor_test_db_result('db_fetch_cell_prepared', $sql, $params, ''); } } @@ -144,6 +183,12 @@ function db_column_exists($table, $column) { } } +if (!function_exists('db_table_exists')) { + function db_table_exists($table) { + return monitor_test_db_result('db_table_exists', $table, array(), false); + } +} + if (!function_exists('api_plugin_db_add_column')) { function api_plugin_db_add_column($plugin, $table, $data) { return true; @@ -156,14 +201,53 @@ function api_plugin_db_table_create($plugin, $table, $data) { } } +$GLOBALS['__test_registered_hooks'] = array(); + +if (!function_exists('api_plugin_register_hook')) { + function api_plugin_register_hook($plugin, $hook, $function, $file, $subtype = '') { + $GLOBALS['__test_registered_hooks'][] = array( + 'name' => $plugin, + 'hook' => $hook, + 'function' => $function, + 'file' => $file, + ); + + return true; + } +} + +$GLOBALS['__test_registered_realms'] = array(); + +if (!function_exists('api_plugin_register_realm')) { + function api_plugin_register_realm($plugin, $file, $description, $enabled) { + $GLOBALS['__test_registered_realms'][] = array( + 'name' => $plugin, + 'file' => $file, + 'description' => $description, + 'enabled' => $enabled, + ); + + return true; + } +} + +$GLOBALS['__test_exec_calls'] = array(); + +if (!function_exists('exec_background')) { + function exec_background($command, $args = '') { + $GLOBALS['__test_exec_calls'][] = array('command' => $command, 'args' => $args); + } +} + if (!function_exists('read_config_option')) { function read_config_option($name, $force = false) { - return ''; + return isset($GLOBALS['__test_config_options'][$name]) ? $GLOBALS['__test_config_options'][$name] : ''; } } if (!function_exists('set_config_option')) { function set_config_option($name, $value) { + $GLOBALS['__test_config_options'][$name] = $value; } }