From e5e419de71a0301b46a42bd540d48f0944b2d9e0 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 20 Sep 2026 10:04:52 -0400 Subject: [PATCH 1/7] fix: prevent repeated database setup from duplicating the Default site Ports the Default-site concurrency/idempotency fix from PR #361 (by Thomas Vincent / somethingwithproof) forward onto current develop. mactrack_setup_database()/mactrack_database_upgrade() used a racy check-then-insert ('if no rows, INSERT') to guarantee a Default site existed, so two workers initializing at once (install + poller, concurrent web requests) could both pass the check and insert duplicate Default sites. Adds mactrack_ensure_default_site(), which: - takes a database-scoped GET_LOCK() advisory lock before the conditional INSERT ... SELECT ... WHERE NOT EXISTS, closing most of the race (durable name-level uniqueness is tracked separately in #360, since a reconnect can release the advisory lock) - verifies the site actually exists after seeding/after failing to acquire the lock, rather than trusting the insert's own result - tracks failed attempts with backoff (60s/5m/15m/30m/1h) via mt_default_site_seed_* config options, retried from plugin_mactrack_check_config() and the poller, instead of throwing and leaving the plugin partially registered plugin_mactrack_install()/mactrack_setup_table_new() take an flag so an operator-triggered (re)install always clears prior backoff state, while an automatic upgrade check preserves it. plugin_mactrack_uninstall() cleans up the tracking settings. Also hardens mactrack_check_upgrade()'s plugin_config UPDATE to use prepared statements. Closes #357 --- CHANGELOG.md | 1 + includes/database.php | 139 +++++++++++++++++++++++++++++++++++++++--- poller_mactrack.php | 3 + setup.php | 46 ++++++++++---- 4 files changed, 169 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60e70930..43e0160e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* issue#357: Prevent repeated database setup from duplicating the Default site * security: Restrict Net_DNS2 cache deserialization to DNS response classes * docs: Align the documented PHP floor with the PHP 7.4 compatibility contract * issue: Return the unformatted MAC when mt_mac_format is unset rather than a null address diff --git a/includes/database.php b/includes/database.php index 838bdcaf..1dbfb329 100644 --- a/includes/database.php +++ b/includes/database.php @@ -710,13 +710,138 @@ function mactrack_database_upgrade() { db_execute("UPDATE mac_track_oui_database SET vendor_mac = REPLACE(vendor_mac, ':', '')"); } - // default site must exist - if (!db_fetch_cell('SELECT count(*) FROM mac_track_sites')) { - db_execute("INSERT INTO mac_track_sites (site_name, site_info) VALUES ('Default','Default site')"); + mactrack_ensure_default_site(); +} + +function mactrack_site_configuration_exists(): bool { + $site_count = db_fetch_cell_prepared('SELECT COUNT(*) FROM mac_track_sites'); + + return is_numeric($site_count) && (int) $site_count > 0; +} + +function mactrack_seed_default_site(?int $lock_timeout = null): bool { + global $database_default; + + if (mactrack_site_configuration_exists()) { + return true; + } + + $lock_timeout = $lock_timeout ?? (PHP_SAPI === 'cli' ? 10 : 2); + $lock_timeout = max(0, $lock_timeout); + // This advisory lock improves the legacy check-then-insert behavior, but a + // reconnect can release it. Database-enforced name uniqueness needs the + // duplicate-safe legacy migration tracked in #360. + $lock_name = 'mactrack.default.' . sha1((string) $database_default); + $locked = db_fetch_cell_prepared('SELECT GET_LOCK(?, ?)', [$lock_name, $lock_timeout]); + + if ((string) $locked !== '1') { + if (mactrack_site_configuration_exists()) { + return true; + } + + cacti_log('Unable to acquire the MacTrack Default-site setup lock', false, 'MACTRACK'); + + return false; + } + + try { + $inserted = (bool) db_execute_prepared( + 'INSERT INTO mac_track_sites (site_name, site_info) + SELECT ?, ? FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM mac_track_sites)', + ['Default', 'Default site'] + ); + + if (mactrack_site_configuration_exists()) { + return true; + } + + cacti_log($inserted ? 'MacTrack site seeding completed without creating a site' : 'Unable to insert the default MacTrack site', false, 'MACTRACK'); + + return false; + } finally { + $released = db_fetch_cell_prepared('SELECT RELEASE_LOCK(?)', [$lock_name]); + + if ((string) $released !== '1') { + cacti_log('MacTrack Default-site setup lock was not owned when release was attempted', false, 'MACTRACK'); + } } } -function mactrack_setup_database() { +function mactrack_reset_default_site_retry(): void { + set_config_option('mt_default_site_seed_pending', 'off'); + set_config_option('mt_default_site_seed_attempts', '0'); + set_config_option('mt_default_site_seed_next_retry', '0'); +} + +function mactrack_raise_default_site_error(int $attempts): void { + if ($attempts >= 5) { + $message = __('MacTrack could not initialize its Default site after repeated attempts. Review the Cacti log before continuing.', 'mactrack'); + } else { + $message = __('MacTrack could not initialize its Default site. Review the Cacti log before continuing.', 'mactrack'); + } + + raise_message('mactrack_default_site_seed_failed', $message, MESSAGE_LEVEL_ERROR); +} + +function mactrack_ensure_default_site(?bool $notify_operator = null, bool $notify_immediately = false): bool { + $notify_operator = $notify_operator ?? (PHP_SAPI !== 'cli'); + $pending = read_config_option('mt_default_site_seed_pending', true) === 'on'; + $attempts = max(0, (int) read_config_option('mt_default_site_seed_attempts', true)); + $next_retry = max(0, (int) read_config_option('mt_default_site_seed_next_retry', true)); + + if ($pending && $next_retry > time()) { + // Another request or an administrator may have repaired the site while + // this worker was throttled. Clear stale retry state without taking the + // seed lock or attempting another insert. + if (mactrack_site_configuration_exists()) { + mactrack_reset_default_site_retry(); + + return true; + } + + if ($notify_operator && ($notify_immediately || $attempts >= 5)) { + mactrack_raise_default_site_error($attempts); + } + + return false; + } + + try { + $seeded = mactrack_seed_default_site(); + } catch (Throwable $exception) { + cacti_log('MacTrack Default-site initialization failed: ' . $exception->getMessage(), false, 'MACTRACK'); + $seeded = false; + } + + if ($seeded) { + mactrack_reset_default_site_retry(); + + return true; + } + + $retry_delays = [60, 300, 900, 1800, 3600]; + $attempts = min(5, $attempts + 1); + $delay = $retry_delays[$attempts - 1]; + set_config_option('mt_default_site_seed_pending', 'on'); + set_config_option('mt_default_site_seed_attempts', (string) $attempts); + set_config_option('mt_default_site_seed_next_retry', (string) (time() + $delay)); + + if ($notify_operator && ($notify_immediately || $attempts >= 5)) { + mactrack_raise_default_site_error($attempts); + } + + return false; +} + +function mactrack_retry_default_site(): bool { + if (read_config_option('mt_default_site_seed_pending', true) !== 'on') { + return true; + } + + return mactrack_ensure_default_site(); +} + +function mactrack_setup_database(bool $notify_seed_failure = false) { $data = []; $data['columns'][] = ['name' => 'row_id', 'unsigned' => true, 'type' => 'int(10)', 'NULL' => false, 'auto_increment' => true]; $data['columns'][] = ['name' => 'site_id', 'unsigned' => true, 'type' => 'int(10)', 'NULL' => false, 'default' => '0']; @@ -1121,9 +1246,6 @@ function mactrack_setup_database() { $data['comment'] = ''; api_plugin_db_table_create('mactrack', 'mac_track_sites', $data); - // default site must exist - db_execute("INSERT INTO mac_track_sites (site_name, site_info) VALUES ('Default','Default site')"); - $data = []; $data['columns'][] = ['name' => 'id', 'unsigned' => true, 'type' => 'int(10)', 'NULL' => false, 'auto_increment' => true]; $data['columns'][] = ['name' => 'name', 'type' => 'varchar(100)', 'NULL' => false, 'default' => '']; @@ -1380,4 +1502,7 @@ function mactrack_setup_database() { (description, vendor, device_type, sysDescr_match, sysObjectID_match, scanning_function, ip_scanning_function, dot1x_scanning_function, serial_number_oid, lowPort, highPort, disabled) VALUES ('92xx Switch-default','Cisco','1','*CAT9K_LITE_IOSXE*','','get_IOS_dot1dTpFdbEntry_ports','get_standard_arp_table','0','',0,0,'on')"); } + + // Seed only after the complete schema and built-in device types exist. + return mactrack_ensure_default_site($notify_seed_failure, $notify_seed_failure); } diff --git a/poller_mactrack.php b/poller_mactrack.php index 987f12ae..9f71a1da 100644 --- a/poller_mactrack.php +++ b/poller_mactrack.php @@ -47,8 +47,11 @@ include('./include/cli_check.php'); include_once($config['base_path'] . '/lib/poller.php'); +include_once($config['base_path'] . '/plugins/mactrack/includes/database.php'); include_once($config['base_path'] . '/plugins/mactrack/lib/mactrack_functions.php'); +mactrack_retry_default_site(); + // install signal handlers for UNIX only if (function_exists('pcntl_signal')) { pcntl_signal(SIGTERM, 'sig_handler'); diff --git a/setup.php b/setup.php index b24f3492..83bd1cd1 100644 --- a/setup.php +++ b/setup.php @@ -22,7 +22,7 @@ +-------------------------------------------------------------------------+ */ -function plugin_mactrack_install() { +function plugin_mactrack_install($operator_initiated = true) { api_plugin_register_hook('mactrack', 'top_header_tabs', 'mactrack_show_tab', 'setup.php'); api_plugin_register_hook('mactrack', 'top_graph_header_tabs', 'mactrack_show_tab', 'setup.php'); api_plugin_register_hook('mactrack', 'config_arrays', 'mactrack_config_arrays', 'setup.php'); @@ -45,10 +45,21 @@ function plugin_mactrack_install() { api_plugin_register_realm('mactrack', 'mactrack_view_ips.php,mactrack_view_arp.php,mactrack_view_macs.php,mactrack_view_dot1x.php,mactrack_view_sites.php,mactrack_view_devices.php,mactrack_view_interfaces.php,mactrack_view_graphs.php,mactrack_ajax.php', 'Mactrack Viewer', 1); api_plugin_register_realm('mactrack', 'mactrack_ajax_admin.php,mactrack_devices.php,mactrack_snmp.php,mactrack_sites.php,mactrack_device_types.php,mactrack_utilities.php,mactrack_macwatch.php,mactrack_macauth.php,mactrack_vendormacs.php', 'Mactrack Administrator', 1); - mactrack_setup_table_new(); + $site_ready = mactrack_setup_table_new($operator_initiated); + + if (!$site_ready && PHP_SAPI === 'cli') { + fwrite(STDERR, "WARNING: MacTrack installed without a Default site; review the Cacti log. The poller will retry with backoff.\n"); + } + + return $site_ready; } function plugin_mactrack_uninstall() { + db_execute_prepared( + 'DELETE FROM settings WHERE name IN (?, ?, ?)', + ['mt_default_site_seed_pending', 'mt_default_site_seed_attempts', 'mt_default_site_seed_next_retry'] + ); + return true; } @@ -94,7 +105,7 @@ function mactrack_check_upgrade() { // if the plugin is installed and/or active if (!cacti_sizeof($old) || $old['status'] == 1 || $old['status'] == 4) { // re-register the hooks - plugin_mactrack_install(); + plugin_mactrack_install(false); if (api_plugin_is_enabled('mactrack')) { // may sound ridiculous, but enables new hooks @@ -133,15 +144,18 @@ function mactrack_check_upgrade() { // update the plugin information $info = plugin_mactrack_version(); - $id = db_fetch_cell("SELECT id FROM plugin_config WHERE directory='mactrack'"); - - db_execute("UPDATE plugin_config - SET name='" . $info['longname'] . "', - author='" . $info['author'] . "', - webpage='" . $info['homepage'] . "', - version='" . $info['version'] . "' - WHERE id='$id'"); + $id = db_fetch_cell_prepared('SELECT id FROM plugin_config WHERE directory = ?', ['mactrack']); + + db_execute_prepared('UPDATE plugin_config + SET name = ?, + author = ?, + webpage = ?, + version = ? + WHERE id = ?', + [$info['longname'], $info['author'], $info['homepage'], $info['version'], $id]); } + + mactrack_retry_default_site(); } function mactrack_db_table_exists($table) { @@ -228,12 +242,18 @@ function mactrack_check_dependencies() { return true; } -function mactrack_setup_table_new() { +function mactrack_setup_table_new($operator_initiated = true) { global $config; include_once($config['base_path'] . '/plugins/mactrack/includes/database.php'); - mactrack_setup_database(); + // Preserve a prior failed seed's backoff when an incomplete upgrade causes + // Cacti to re-enter this hook on a later request. + if ($operator_initiated || read_config_option('mt_default_site_seed_pending', true) !== 'on') { + mactrack_reset_default_site_retry(); + } + + return mactrack_setup_database(PHP_SAPI !== 'cli'); } function mactrack_page_head() { From f9b375a1b86f81e50e48c9f1afc7eb2d4fd12c9c Mon Sep 17 00:00:00 2001 From: TheWitness Date: Sun, 20 Sep 2026 10:22:38 -0400 Subject: [PATCH 2/7] test: adopt the shared Cacti plugin test framework (Pest + PHP 8.2 floor) Replaces the plugin's ad-hoc snake_case standalone-script test suite and its custom .github/workflows/test-suite.yml with the framework used across other Cacti plugins (modeled on plugin_evidence): Pest via Cacti's own Composer-managed vendor tree, tests/bootstrap-unit.php, tests/TestCase.php, tests/Pest.php, tests/.cacti-version, and phpunit.xml, run through .github/workflows/plugin-ci-workflow.yml (PHP 8.2-8.4, CACTI/COMPOSER_ALLOW_SUPERUSER env vars, sudo composer throughout, SHA-pinned actions). Removes tests/Support/CactiStubs.php, the tests/e2e/ Docker harness, and every snake_case test_*.php file, reorganizing coverage into tests/Security, tests/Unit, and tests/Integration with PascalCase Pest files: - Security/Php82CompatibilityTest.php: renamed and adapted from test_php74_compatibility.php now that the plugin's floor is PHP 8.2, scanning for 8.3/8.4-only syntax instead of 8.0+ syntax. - Security/PreparedStatementConsistencyTest.php: the raw-SQL-call ratchet from test_prepared_statement_consistency.php, rebaselined against current source. - Security/SetupStructureTest.php: standard hook/realm/INFO structural checks, new to this plugin. - Security/NetDns2SecurityTest.php: converted from test_net_dns2_cache_security.php + test_net_dns2_precedence.php. - Security/SqlSafetyAndOutputEscapingTest.php: converted from test_device_type_sql_safety.php, re-verified against current source. - Unit/MacFormattingTest.php, Unit/XformMacAddressTest.php: converted from test_mac_formatting.php and rewritten as PHPUnit data-provider tests covering xform_mac_address()'s ASCII/HEX-/binary paths. - Unit/IgnorePortsPatternTest.php: converted from test_ignore_ports_pattern.php. - Unit/DefaultSiteSeedingTest.php, Integration/DefaultSiteIdempotencyTest.php: new coverage for the mactrack_ensure_default_site()/ mactrack_seed_default_site() advisory-lock seeding and retry/backoff state machine added in the prior commit (issue#357), including the install/upgrade entry points that call it. - Integration/FilterOutputWiringTest.php: converted from test_mactrack_filter_output_wiring.php. tests/bootstrap-unit.php extends the plugin_evidence model with an in-memory config-option store (mactrack's retry state lives entirely in read_config_option()/set_config_option()) and CactiStubs-style SQL-fragment-matched return values for db_fetch_cell_prepared(), needed to exercise the GET_LOCK/RELEASE_LOCK advisory-locking path. --- .github/workflows/plugin-ci-workflow.yml | 166 +++--- phpunit.xml | 35 ++ tests/.cacti-version | 1 + .../DefaultSiteIdempotencyTest.php | 117 +++++ tests/Integration/FilterOutputWiringTest.php | 42 ++ .../test_mactrack_filter_output_wiring.php | 36 -- tests/Pest.php | 14 + tests/Security/NetDns2SecurityTest.php | 101 ++++ tests/Security/Php82CompatibilityTest.php | 113 +++++ .../PreparedStatementConsistencyTest.php | 80 +++ tests/Security/SetupStructureTest.php | 71 +++ .../SqlSafetyAndOutputEscapingTest.php | 116 +++++ tests/Support/CactiStubs.php | 85 ---- tests/TestCase.php | 48 ++ tests/Unit/DefaultSiteSeedingTest.php | 190 +++++++ tests/Unit/IgnorePortsPatternTest.php | 139 +++++ tests/Unit/MacFormattingTest.php | 67 +++ tests/Unit/XformMacAddressTest.php | 77 +++ tests/Unit/test_device_type_sql_safety.php | 161 ------ tests/Unit/test_filter_option_escaping.php | 19 - tests/Unit/test_ignore_ports_pattern.php | 105 ---- tests/Unit/test_mac_formatting.php | 54 -- tests/Unit/test_net_dns2_cache_security.php | 66 --- tests/Unit/test_net_dns2_precedence.php | 45 -- tests/Unit/test_php74_compatibility.php | 47 -- .../test_prepared_statement_consistency.php | 76 --- tests/bootstrap-unit.php | 474 ++++++++++++++++++ tests/e2e/Dockerfile | 12 - tests/e2e/bootstrap-mactrack.sh | 32 -- tests/e2e/docker-compose.yml | 47 -- tests/e2e/mactrack_smoke.php | 37 -- tests/e2e/run-mactrack-e2e.sh | 28 -- .../test_mactrack_no_raw_filter_labels.php | 36 -- 33 files changed, 1765 insertions(+), 972 deletions(-) create mode 100644 phpunit.xml create mode 100644 tests/.cacti-version create mode 100644 tests/Integration/DefaultSiteIdempotencyTest.php create mode 100644 tests/Integration/FilterOutputWiringTest.php delete mode 100644 tests/Integration/test_mactrack_filter_output_wiring.php create mode 100644 tests/Pest.php create mode 100644 tests/Security/NetDns2SecurityTest.php create mode 100644 tests/Security/Php82CompatibilityTest.php create mode 100644 tests/Security/PreparedStatementConsistencyTest.php create mode 100644 tests/Security/SetupStructureTest.php create mode 100644 tests/Security/SqlSafetyAndOutputEscapingTest.php delete mode 100644 tests/Support/CactiStubs.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/DefaultSiteSeedingTest.php create mode 100644 tests/Unit/IgnorePortsPatternTest.php create mode 100644 tests/Unit/MacFormattingTest.php create mode 100644 tests/Unit/XformMacAddressTest.php delete mode 100644 tests/Unit/test_device_type_sql_safety.php delete mode 100644 tests/Unit/test_filter_option_escaping.php delete mode 100644 tests/Unit/test_ignore_ports_pattern.php delete mode 100644 tests/Unit/test_mac_formatting.php delete mode 100644 tests/Unit/test_net_dns2_cache_security.php delete mode 100644 tests/Unit/test_net_dns2_precedence.php delete mode 100644 tests/Unit/test_php74_compatibility.php delete mode 100644 tests/Unit/test_prepared_statement_consistency.php create mode 100644 tests/bootstrap-unit.php delete mode 100644 tests/e2e/Dockerfile delete mode 100755 tests/e2e/bootstrap-mactrack.sh delete mode 100644 tests/e2e/docker-compose.yml delete mode 100644 tests/e2e/mactrack_smoke.php delete mode 100755 tests/e2e/run-mactrack-e2e.sh delete mode 100644 tests/e2e/test_mactrack_no_raw_filter_labels.php diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index de55df0d..3365987f 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -31,16 +31,20 @@ on: - main - develop +env: + CACTI: 1.2.x + COMPOSER_ALLOW_SUPERUSER: 1 + jobs: integration-test: runs-on: ${{ matrix.os }} - + strategy: fail-fast: false matrix: - php: ['8.1', '8.2', '8.3', '8.4'] + php: ['8.2', '8.3', '8.4'] os: [ubuntu-latest] - + services: mariadb: image: mariadb:10.6 @@ -56,43 +60,44 @@ jobs: --health-interval=10s --health-timeout=5s --health-retries=3 - + name: PHP ${{ matrix.php }} Integration Test on ${{ matrix.os }} - + steps: - name: Checkout Cacti - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: Cacti/cacti - ref: release/1.2.31 + ref: ${{ env.CACTI }} path: cacti - - - name: Checkout mactrack Plugin - uses: actions/checkout@v7 + + - name: Checkout MacTrack Plugin + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: cacti/plugins/mactrack - + - name: Install PHP ${{ matrix.php }} - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ matrix.php }} - extensions: intl, mysql, gd, ldap, gmp, xml, curl, json, mbstring + extensions: intl, mysql, gd, ldap, gmp, xml, curl, json, mbstring, snmp ini-values: "post_max_size=256M, max_execution_time=60, date.timezone=America/New_York" - + coverage: xdebug + - name: Check PHP version run: php -v - + - name: Run apt-get update run: sudo apt-get update - + - name: Install System Dependencies run: sudo apt-get install -y apache2 snmp snmpd rrdtool fping - + - name: Start SNMPD Agent and Test run: | sudo systemctl start snmpd sudo snmpwalk -c public -v2c -On localhost .1.3.6.1.2.1.1 - + - name: Setup Permissions run: | sudo chown -R www-data:runner ${{ github.workspace }}/cacti @@ -100,12 +105,12 @@ jobs: sudo find ${{ github.workspace }}/cacti -type f -exec chmod 664 {} \; sudo chmod +x ${{ github.workspace }}/cacti/cmd.php sudo chmod +x ${{ github.workspace }}/cacti/poller.php - + - name: Create MySQL Config run: | echo -e "[client]\nuser = root\npassword = cactiroot\nhost = 127.0.0.1\n" > ~/.my.cnf cat ~/.my.cnf - + - name: Initialize Cacti Database env: MYSQL_AUTH_USR: '--defaults-file=~/.my.cnf' @@ -117,21 +122,29 @@ jobs: mysql $MYSQL_AUTH_USR -e "FLUSH PRIVILEGES;" mysql $MYSQL_AUTH_USR cacti < ${{ github.workspace }}/cacti/cacti.sql mysql $MYSQL_AUTH_USR -e "INSERT INTO settings (name, value) VALUES ('path_php_binary', '/usr/bin/php')" cacti - + - name: Validate composer files run: | cd ${{ github.workspace }}/cacti if [ -f composer.json ]; then - composer validate --strict || true + sudo composer validate --strict || true fi - + - name: Install Composer Dependencies run: | cd ${{ github.workspace }}/cacti if [ -f composer.json ]; then - sudo composer install --prefer-dist --no-progress + sudo composer config --no-plugins allow-plugins.pestphp/pest-plugin true + sudo composer require --no-progress --no-interaction "pestphp/pest: ^3" "pestphp/pest-plugin-drift: ^3.0" + sudo rm -f composer.lock + sudo composer install --dev --no-progress fi - + + - name: Restore vendor ownership for Pest + run: | + cd ${{ github.workspace }}/cacti + sudo chown -R runner:runner include/vendor composer.lock + - name: Create Cacti config.php run: | cat ${{ github.workspace }}/cacti/include/config.php.dist | \ @@ -140,96 +153,88 @@ jobs: sed -r "s/'cactiuser'/'cactiuser'/g" | \ sed -r "s/'cactiuser'/'cactiuser'/g" > ${{ github.workspace }}/cacti/include/config.php sudo chmod 664 ${{ github.workspace }}/cacti/include/config.php - + - name: Configure Apache run: | cat << 'EOF' | sed 's#GITHUB_WORKSPACE#${{ github.workspace }}#g' > /tmp/cacti.conf ServerAdmin webmaster@localhost DocumentRoot GITHUB_WORKSPACE/cacti - + Options Indexes FollowSymLinks AllowOverride All Require all granted - + ErrorLog ${APACHE_LOG_DIR}/error.log CustomLog ${APACHE_LOG_DIR}/access.log combined EOF sudo cp /tmp/cacti.conf /etc/apache2/sites-available/000-default.conf sudo systemctl restart apache2 - + - name: Install Cacti via CLI run: | cd ${{ github.workspace }}/cacti sudo php cli/install_cacti.php --accept-eula --install --force - - name: Install Mactrack Composer Dependencies - run: | - cd ${{ github.workspace }}/cacti/plugins/mactrack - if [ -f composer.json ]; then - sudo composer install --no-dev --prefer-dist --no-progress --no-interaction - fi - - - name: Install mactrack Plugin + - name: Install MacTrack Plugin run: | cd ${{ github.workspace }}/cacti sudo php cli/plugin_manage.php --plugin=mactrack --install --enable -# - name: import mactrack Plugin Sample Data -# run: | -# cd ${{ github.workspace }}/cacti/plugins/mactrack -# sudo php cli_import.php --filename=.github/workflows/mactrack_sample_data.xml -# if [ $? -ne 0 ]; then -# echo "Failed to import Thold sample data" -# exit 1 -# fi - - name: Check PHP Syntax for Plugin run: | cd ${{ github.workspace }}/cacti/plugins/mactrack - find . -path './vendor' -prune -o -name '*.php' -print0 | xargs -0 -n1 php -l + if find . -name '*.php' -exec php -l {} 2>&1 \; | grep -iv 'no syntax errors detected'; then + echo "Syntax errors found!" + exit 1 + fi - # Cacti 1.2.31 has no .phpstan.neon / lint scripts; only touch them when present. - - name: Remove the plugins directory exclusion from the .phpstan.neon + - name: Set expected Cacti version for unit tests + run: echo -n "${{ env.CACTI }}" | sudo tee ${{ github.workspace }}/cacti/plugins/mactrack/tests/.cacti-version > /dev/null + + - name: Run Pest Unit Tests + env: + COMPOSER_ROOT_VERSION: 1.3.0-dev run: | - if [ -f .phpstan.neon ]; then - sed '/plugins/d' -i .phpstan.neon - else - echo '.phpstan.neon not present; skipping' - fi + cd ${{ github.workspace }}/cacti + include/vendor/bin/pest --configuration=plugins/mactrack/phpunit.xml \ + --coverage-clover=plugins/mactrack/coverage/clover.xml + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-php${{ matrix.php }} + path: ${{ github.workspace }}/cacti/plugins/mactrack/coverage/ + if-no-files-found: warn + + - name: Remove the plugins directory exclusion from the .phpstan.neon + if: ${{ env.CACTI != '1.2.x' }} + run: sed '/plugins/d' -i .phpstan.neon working-directory: ${{ github.workspace }}/cacti - name: Mark composer scripts executable - run: | - if [ -d "${{ github.workspace }}/cacti/include/vendor/bin" ]; then - sudo find "${{ github.workspace }}/cacti/include/vendor/bin" -maxdepth 1 -type f -exec chmod +x {} + - fi + if: ${{ env.CACTI != '1.2.x' }} + run: sudo chmod +x ${{ github.workspace }}/cacti/include/vendor/bin/* - name: Run Linter on base code - run: | - if composer run-script --list | grep -qE '^ lint'; then - composer run-script lint ${{ github.workspace }}/cacti/plugins/mactrack - else - echo 'Composer lint script is not defined; skipping.' - fi + if: ${{ env.CACTI != '1.2.x' }} + run: sudo composer run-script lint ${{ github.workspace }}/cacti/plugins/mactrack working-directory: ${{ github.workspace }}/cacti - name: Checking coding standards on base code - run: | - if composer run-script --list | grep -qE '^ phpcsfixer'; then - composer run-script phpcsfixer ${{ github.workspace }}/cacti/plugins/mactrack - else - echo 'Composer phpcsfixer script is not defined; skipping.' - fi + if: ${{ env.CACTI != '1.2.x' }} + run: sudo composer run-script phpcsfixer ${{ github.workspace }}/cacti/plugins/mactrack + working-directory: ${{ github.workspace }}/cacti + + - name: Run PHPStan at Level 6 on base code outside of Composer due to technical issues + if: ${{ env.CACTI != '1.2.x' }} + run: sudo ./include/vendor/bin/phpstan analyze --level 6 ${{ github.workspace }}/cacti/plugins/mactrack working-directory: ${{ github.workspace }}/cacti -# - name: Run PHPStan at Level 6 on base code outside of Composer due to technical issues -# run: ./include/vendor/bin/phpstan analyze --level 6 ${{ github.workspace }}/cacti/plugins/mactrack -# working-directory: ${{ github.workspace }}/cacti - - name: Re-apply web user ownership before polling run: | # Ancestor directories above the checkout (e.g. /home/runner, .../work) @@ -251,23 +256,12 @@ jobs: run: | cd ${{ github.workspace }}/cacti sudo -u www-data php poller.php --poller=1 --force --debug + if ! grep -q "SYSTEM STATS" log/cacti.log; then echo "Cacti poller did not finish successfully" cat log/cacti.log exit 1 fi - - # The e2e scripts need the live Cacti and database this job already built. - - name: Run plugin e2e tests - run: | - rc=0 - for f in tests/e2e/*.php; do - [ -f "$f" ] || continue - printf '%s: ' "$f" - php "$f" || rc=1 - done - exit $rc - working-directory: ${{ github.workspace }}/cacti/plugins/mactrack - name: View Cacti Logs if: always() diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 00000000..6564cb42 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,35 @@ + + + + + + + + + tests/Security + tests/Unit + tests/Integration + + + + + + + setup.php + includes/database.php + lib/mactrack_functions.php + + + diff --git a/tests/.cacti-version b/tests/.cacti-version new file mode 100644 index 00000000..c971a7a8 --- /dev/null +++ b/tests/.cacti-version @@ -0,0 +1 @@ +1.2.x diff --git a/tests/Integration/DefaultSiteIdempotencyTest.php b/tests/Integration/DefaultSiteIdempotencyTest.php new file mode 100644 index 00000000..f2562f31 --- /dev/null +++ b/tests/Integration/DefaultSiteIdempotencyTest.php @@ -0,0 +1,117 @@ +assertTrue(mactrack_setup_table_new(true)); + $this->assertSame('off', read_config_option('mt_default_site_seed_pending')); + + $inserted = array_filter( + array_column($GLOBALS['__test_db_calls'], 'sql'), + static fn ($sql) => strpos($sql, 'INSERT INTO mac_track_sites') !== false + ); + $this->assertCount(1, $inserted, 'Exactly one Default-site insert should have been attempted'); + } + + /** + * @return void + */ + public function testReenteringUpgradeAfterASuccessfulSeedDoesNotInsertAgain(): void { + // The site already exists (e.g. install already succeeded); a later + // automatic upgrade check must not attempt another insert. + mactrack_test_queue_return_for('db_fetch_cell_prepared', 'COUNT(*) FROM mac_track_sites', '1'); + + $this->assertTrue(mactrack_setup_table_new(false)); + + foreach ($GLOBALS['__test_db_calls'] as $call) { + $this->assertStringNotContainsString('INSERT INTO mac_track_sites', $call['sql']); + $this->assertStringNotContainsString('GET_LOCK', $call['sql']); + } + } + + /** + * @return void + */ + public function testAnOperatorInitiatedReinstallClearsAPriorBackoffEvenWhileThrottled(): void { + set_config_option('mt_default_site_seed_pending', 'on'); + set_config_option('mt_default_site_seed_attempts', '4'); + set_config_option('mt_default_site_seed_next_retry', (string) (time() + 3600)); + + mactrack_test_queue_return_for('db_fetch_cell_prepared', 'GET_LOCK', '1'); + mactrack_test_queue_return_for('db_fetch_cell_prepared', 'RELEASE_LOCK', '1'); + mactrack_test_queue_return('db_fetch_cell_prepared', '0'); + mactrack_test_queue_return('db_fetch_cell_prepared', '1'); + mactrack_test_queue_return('db_execute_prepared', true); + + $this->assertTrue(mactrack_setup_table_new(true)); + $this->assertSame('off', read_config_option('mt_default_site_seed_pending')); + } + + /** + * @return void + */ + public function testAnAutomaticUpgradePreservesAPriorBackoffWindow(): void { + $future = (string) (time() + 3600); + set_config_option('mt_default_site_seed_pending', 'on'); + set_config_option('mt_default_site_seed_attempts', '2'); + set_config_option('mt_default_site_seed_next_retry', $future); + + mactrack_test_queue_return_for('db_fetch_cell_prepared', 'COUNT(*) FROM mac_track_sites', '0'); + + $this->assertFalse(mactrack_setup_table_new(false)); + $this->assertSame('on', read_config_option('mt_default_site_seed_pending')); + $this->assertSame($future, read_config_option('mt_default_site_seed_next_retry')); + + foreach ($GLOBALS['__test_db_calls'] as $call) { + $this->assertStringNotContainsString('GET_LOCK', $call['sql']); + } + } + + /** + * @return void + */ + public function testInstallReturnsTheSeedResultForTheCallerToAct(): void { + mactrack_test_queue_return_for('db_fetch_cell_prepared', 'GET_LOCK', '0'); + mactrack_test_queue_return('db_fetch_cell_prepared', '0'); + + $this->assertFalse(plugin_mactrack_install()); + $this->assertSame('on', read_config_option('mt_default_site_seed_pending')); + } +} diff --git a/tests/Integration/FilterOutputWiringTest.php b/tests/Integration/FilterOutputWiringTest.php new file mode 100644 index 00000000..488b817b --- /dev/null +++ b/tests/Integration/FilterOutputWiringTest.php @@ -0,0 +1,42 @@ + [ + "html_escape(\$site['site_name'])", + "html_escape(\$filter_device['device_name'] . '(' . \$filter_device['hostname'] . ')')", + ], + 'mactrack_device_types.php' => [ + "html_escape(\$type['vendor'])", + ], + ]; + + foreach ($checks as $file => $patterns) { + foreach ($patterns as $pattern) { + it("renders {$pattern} in {$file}", function () use ($file, $pattern) { + $source = plugin_test_read_source($file); + + expect($source)->toContain($pattern); + }); + } + } +}); diff --git a/tests/Integration/test_mactrack_filter_output_wiring.php b/tests/Integration/test_mactrack_filter_output_wiring.php deleted file mode 100644 index 8738ef70..00000000 --- a/tests/Integration/test_mactrack_filter_output_wiring.php +++ /dev/null @@ -1,36 +0,0 @@ - [ - "html_escape(\$site['site_name'])", - "html_escape(\$filter_device['device_name'] . '(' . \$filter_device['hostname'] . ')')", - ], - __DIR__ . '/../../mactrack_device_types.php' => [ - "html_escape(\$type['vendor'])", - ], -]; - -foreach ($checks as $path => $patterns) { - $contents = file_get_contents($path); - - if ($contents === false) { - fwrite(STDERR, "Unable to read {$path}\n"); - exit(1); - } - - foreach ($patterns as $pattern) { - if (strpos($contents, $pattern) === false) { - fwrite(STDERR, "Missing expected escaped output: {$pattern}\n"); - exit(1); - } - } -} - -print "OK\n"; diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 00000000..c639e23b --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,14 @@ +cache_serializer = 'serialize'; + $this->cache_data[$key] = ['object' => serialize($value)]; + } +} + +class MactrackNetDns2UnserializeProbe { + public static $awakened = false; + + public function __wakeup() { + self::$awakened = true; + } +} + +describe('Net_DNS2 cache deserialization safety in mactrack', function () { + it('restores a valid DNS response payload from the cache', function () { + $cache = new MactrackNetDns2TestCache(); + $reflection = new ReflectionClass('Net_DNS2_Packet_Response'); + $response = $reflection->newInstanceWithoutConstructor(); + + $response->rdata = ''; + $response->rdlength = 0; + $response->header = null; + $response->question = []; + $response->answer = []; + $response->authority = []; + $response->additional = []; + + $cache->seed('response', $response); + + expect($cache->get('response'))->toBeInstanceOf(Net_DNS2_Packet_Response::class); + }); + + it('refuses to instantiate an unexpected class from a cache payload', function () { + MactrackNetDns2UnserializeProbe::$awakened = false; + + $cache = new MactrackNetDns2TestCache(); + $cache->seed('probe', new MactrackNetDns2UnserializeProbe()); + + expect($cache->get('probe'))->toBeFalse(); + expect(MactrackNetDns2UnserializeProbe::$awakened)->toBeFalse(); + }); + + it('disables arbitrary class construction for both cache metadata loads', function () { + foreach (['Net/DNS2/Cache/File.php', 'Net/DNS2/Cache/Shm.php'] as $relative) { + $source = file_get_contents(__DIR__ . '/../../' . $relative); + + expect($source)->not->toBeFalse("Unable to read {$relative}"); + expect(substr_count($source, "['allowed_classes' => false]"))->toBe(2, "{$relative} must disable classes for both metadata loads"); + } + }); +}); + +describe('Net_DNS2 include-path precedence in mactrack', function () { + it('loads the bundled resolver even when a same-named class exists earlier on the include path', function () { + $temporary = sys_get_temp_dir() . '/mactrack_dns_shadow_' . getmypid(); + $shadow = $temporary . '/Net/DNS2'; + + expect(mkdir($shadow, 0700, true))->toBeTrue('Unable to create the DNS shadow fixture directory'); + expect(file_put_contents($shadow . '/Resolver.php', "not->toBeFalse(); + + $plugin_root = realpath(__DIR__ . '/../..'); + set_include_path($plugin_root . PATH_SEPARATOR . $temporary . PATH_SEPARATOR . get_include_path()); + + try { + require_once $plugin_root . '/Net/DNS2.php'; + + expect(class_exists('Net_DNS2_Resolver'))->toBeTrue('Bundled Net_DNS2 resolver did not autoload'); + + $reflection = new ReflectionClass('Net_DNS2_Resolver'); + $resolved = realpath((string) $reflection->getFileName()); + $expected = realpath($plugin_root . '/Net/DNS2/Resolver.php'); + + expect($resolved)->toBe($expected, 'A shadowed path resolved the bundled resolver to the wrong file'); + } finally { + @unlink($shadow . '/Resolver.php'); + @rmdir($shadow); + @rmdir($temporary . '/Net'); + @rmdir($temporary); + } + }); +}); diff --git a/tests/Security/Php82CompatibilityTest.php b/tests/Security/Php82CompatibilityTest.php new file mode 100644 index 00000000..8adcd618 --- /dev/null +++ b/tests/Security/Php82CompatibilityTest.php @@ -0,0 +1,113 @@ +toBe(0, "{$f} uses each() (removed in PHP 8.0)"); + } + }); + + it('does not use create_function() (removed in PHP 8.0)', function () use ($files) { + foreach ($files as $f) { + $c = file_get_contents(__DIR__ . '/../../' . $f); + if ($c === false) continue; + expect(preg_match('/\bcreate_function\s*\(/', $c))->toBe(0, "{$f} uses create_function() (removed in PHP 8.0)"); + } + }); + + it('does not use curly-brace string offset access (removed in PHP 8.0)', function () use ($files) { + foreach ($files as $f) { + $c = file_get_contents(__DIR__ . '/../../' . $f); + if ($c === false) continue; + expect(preg_match('/\$\w+\s*\{\s*\d+\s*\}/', $c))->toBe(0, "{$f} uses curly-brace string offset access (removed in PHP 8.0)"); + } + }); + + it('does not use json_validate() (PHP 8.3)', function () use ($files) { + foreach ($files as $f) { + $c = file_get_contents(__DIR__ . '/../../' . $f); + if ($c === false) continue; + expect(preg_match('/\bjson_validate\s*\(/', $c))->toBe(0, "{$f} uses json_validate() (PHP 8.3)"); + } + }); + + it('does not use dynamic class constant fetch syntax (PHP 8.3)', function () use ($files) { + foreach ($files as $f) { + $c = file_get_contents(__DIR__ . '/../../' . $f); + if ($c === false) continue; + expect(preg_match('/\w+::\{.+\}/', $c))->toBe(0, "{$f} uses dynamic class constant fetch (PHP 8.3)"); + } + }); + + it('does not use typed class constants (PHP 8.3)', function () use ($files) { + foreach ($files as $f) { + $c = file_get_contents(__DIR__ . '/../../' . $f); + if ($c === false) continue; + expect(preg_match('/\b(?:public|private|protected|final)\s+const\s+\??[\w|]+\s+\w+\s*=/', $c))->toBe(0, + "{$f} uses typed class constants (PHP 8.3)" + ); + } + }); + + it('does not use the #[Override] attribute (PHP 8.4)', function () use ($files) { + foreach ($files as $f) { + $c = file_get_contents(__DIR__ . '/../../' . $f); + if ($c === false) continue; + expect(preg_match('/#\[\s*\\\\?Override\s*\]/', $c))->toBe(0, "{$f} uses #[Override] attribute (PHP 8.4)"); + } + }); + + it('does not use asymmetric visibility (PHP 8.4)', function () use ($files) { + foreach ($files as $f) { + $c = file_get_contents(__DIR__ . '/../../' . $f); + if ($c === false) continue; + expect(preg_match('/\b(?:public|protected)\s*\(\s*set\s*\)/', $c))->toBe(0, + "{$f} uses asymmetric visibility (PHP 8.4)" + ); + } + }); + + it('does not use property hooks (PHP 8.4)', function () use ($files) { + foreach ($files as $f) { + $c = file_get_contents(__DIR__ . '/../../' . $f); + if ($c === false) continue; + expect(preg_match('/\b(?:get|set)\s*\{/', $c))->toBe(0, "{$f} uses property hooks (PHP 8.4)"); + } + }); + + it('parses under the running PHP version', function () use ($files) { + foreach ($files as $f) { + $path = realpath(__DIR__ . '/../../' . $f); + $result = shell_exec(escapeshellarg(PHP_BINARY) . ' -l ' . escapeshellarg($path) . ' 2>&1'); + expect($result)->toContain('No syntax errors detected', "{$f} failed to parse: {$result}"); + } + }); +}); diff --git a/tests/Security/PreparedStatementConsistencyTest.php b/tests/Security/PreparedStatementConsistencyTest.php new file mode 100644 index 00000000..2efa2428 --- /dev/null +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -0,0 +1,80 @@ + 141, + 'lib/mactrack_3com.php' => 1, + 'lib/mactrack_aruba_oscx.php' => 1, + 'lib/mactrack_cisco.php' => 5, + 'lib/mactrack_enterasys_N7.php' => 1, + 'lib/mactrack_extreme.php' => 1, + 'lib/mactrack_functions.php' => 28, + 'lib/mactrack_h3c_3com.php' => 1, + 'mactrack_actions.php' => 19, + 'mactrack_convert.php' => 9, + 'mactrack_device_types.php' => 11, + 'mactrack_devices.php' => 7, + 'mactrack_macauth.php' => 2, + 'mactrack_macwatch.php' => 2, + 'mactrack_resolver.php' => 3, + 'mactrack_scanner.php' => 1, + 'mactrack_sites.php' => 3, + 'mactrack_snmp.php' => 4, + 'mactrack_utilities.php' => 25, + 'mactrack_vendormacs.php' => 2, + 'mactrack_view_arp.php' => 7, + 'mactrack_view_devices.php' => 4, + 'mactrack_view_dot1x.php' => 6, + 'mactrack_view_graphs.php' => 2, + 'mactrack_view_interfaces.php' => 3, + 'mactrack_view_ips.php' => 3, + 'mactrack_view_macs.php' => 9, + 'mactrack_view_sites.php' => 3, + 'poller_mactrack.php' => 39, + 'setup.php' => 20, + ]; + + $root = realpath(__DIR__ . '/../..'); + $pattern = '/\bdb_(?:execute|fetch_row|fetch_assoc|fetch_cell)\s*\(/i'; + + it('never increases raw (non-prepared) database calls beyond the recorded baseline', function () use ($baseline, $root, $pattern) { + $files = mactrack_test_production_php_files(); + + foreach ($files as $file) { + if (strpos($file, 'tests/') === 0) { + continue; + } + + $source = file_get_contents($root . '/' . $file); + + expect($source)->not->toBeFalse("Unable to read {$file}"); + + $count = preg_match_all($pattern, $source); + + expect($count)->toBeLessThanOrEqual( + $baseline[$file] ?? 0, + "{$file} increased raw database calls from " . ($baseline[$file] ?? 0) . " to {$count}" + ); + } + }); +}); diff --git a/tests/Security/SetupStructureTest.php b/tests/Security/SetupStructureTest.php new file mode 100644 index 00000000..69dbc3ad --- /dev/null +++ b/tests/Security/SetupStructureTest.php @@ -0,0 +1,71 @@ +toContain('function plugin_mactrack_install'); + }); + + it('defines plugin_mactrack_uninstall function', function () use ($source) { + expect($source)->toContain('function plugin_mactrack_uninstall'); + }); + + it('defines plugin_mactrack_version function', function () use ($source) { + expect($source)->toContain('function plugin_mactrack_version'); + }); + + it('defines plugin_mactrack_check_config function', function () use ($source) { + expect($source)->toContain('function plugin_mactrack_check_config'); + }); + + it('registers hooks via api_plugin_register_hook', function () use ($source) { + expect($source)->toContain("api_plugin_register_hook('mactrack'"); + }); + + it('registers realms via api_plugin_register_realm', function () use ($source) { + expect($source)->toContain("api_plugin_register_realm('mactrack'"); + }); + + it('cleans up Default-site retry tracking settings on uninstall', function () use ($source) { + expect($source)->toContain('mt_default_site_seed_pending'); + expect($source)->toContain('mt_default_site_seed_attempts'); + expect($source)->toContain('mt_default_site_seed_next_retry'); + }); + + it('declares a plugin name in INFO', function () use ($info) { + expect($info)->toHaveKey('name'); + expect($info['name'])->toBe('mactrack'); + }); + + it('declares a plugin version in INFO', function () use ($info) { + expect($info)->toHaveKey('version'); + expect($info['version'])->not->toBe(''); + expect($info['version'])->toMatch('/^\d+\.\d+$/'); + }); +}); diff --git a/tests/Security/SqlSafetyAndOutputEscapingTest.php b/tests/Security/SqlSafetyAndOutputEscapingTest.php new file mode 100644 index 00000000..602d859a --- /dev/null +++ b/tests/Security/SqlSafetyAndOutputEscapingTest.php @@ -0,0 +1,116 @@ +not->toContain("(mtdt.vendor='\" . get_request_var('vendor')"); + expect($source)->toContain("mtdt.vendor = ' . db_qstr(get_request_var('vendor'))"); + }); + + it('normalizes aggregated MAC bulk-action IDs and uses prepared deletion SQL', function () { + $source = plugin_test_read_source('mactrack_view_macs.php'); + + expect($source)->toContain('function mactrack_normalize_ids(array $ids): array'); + expect($source)->toContain("db_execute_prepared('DELETE FROM mac_track_aggregated_ports WHERE row_id IN('"); + }); + + it('escapes MAC authorization and action output instead of deserializing request input', function () { + $source = plugin_test_read_source('mactrack_view_macs.php'); + + expect($source)->not->toContain("unserialize(get_nfilter_request_var('selected_items')"); + expect($source)->toContain("json_decode(get_nfilter_request_var('selected_items'), true)"); + expect($source)->toContain('html_escape(json_encode($mac_address_array))'); + expect($source)->not->toContain("sanitize_unserialize_selected_items(get_nfilter_request_var('selected_items'))"); + expect($source)->toContain('html_escape(json_encode($row_array))'); + expect($source)->toContain("html_escape((string) get_request_var('drp_action'))"); + }); + + it('escapes the rescan executable and script paths before command execution', function () { + $source = plugin_test_read_source('lib/mactrack_functions.php'); + + expect($source)->toContain("cacti_escapeshellcmd(read_config_option('path_php_binary'))"); + expect($source)->toContain('cacti_escapeshellarg($command_string)'); + }); + + it('parameterizes stale-process site filtering and normalizes process IDs', function () { + $source = plugin_test_read_source('poller_mactrack.php'); + + expect($source)->toContain('site_id = ?'); + expect($source)->toContain("intval(\$p['process_id'])"); + }); + + it('escapes Cabletron SNMP command arguments', function () { + $source = plugin_test_read_source('lib/mactrack_cabletron.php'); + + expect($source)->toContain("cacti_escapeshellcmd(read_config_option('path_snmpgetnext'))"); + expect($source)->toContain('cacti_escapeshellarg($device[\'hostname\'] . \':\' . intval($device[\'snmp_port\']))'); + }); + + it('safely quotes the ignored-interfaces RLIKE pattern and normalizes numeric filters', function () { + $source = plugin_test_read_source('mactrack_view_interfaces.php'); + + expect($source)->toContain('mactrack_get_ignore_ports_predicate($sql_params)'); + expect($source)->toContain('db_fetch_assoc_prepared($sql_query, $sql_params)'); + expect($source)->toContain('db_fetch_cell_prepared($rows_query_string, $sql_params)'); + expect($source)->not->toContain('db_qstr($match)'); + expect($source)->not->toContain('db_qstr_rlike'); + expect($source)->toContain("intval(get_filter_request_var('bwusage'))"); + }); + + it('normalizes numeric filters on the device report views', function () { + foreach (['mactrack_view_devices.php', 'mactrack_devices.php'] as $file) { + $source = plugin_test_read_source($file); + + expect($source)->toContain("intval(get_filter_request_var('status'))"); + expect($source)->toContain("intval(get_filter_request_var('site_id'))"); + } + }); + + it('validates the canonical local-file path before importing an OUI database', function () { + $source = plugin_test_read_source('mactrack_import_ouidb.php'); + + expect($source)->toContain('function mactrack_validate_oui_file(string $path): string'); + expect($source)->toContain('is_file($resolved)'); + expect($source)->toContain('is_readable($resolved)'); + }); + + it('does not call undefined MAC-formatting or graph-settings compatibility handlers', function () { + $arpSource = plugin_test_read_source('mactrack_view_arp.php'); + $ajaxSource = plugin_test_read_source('mactrack_ajax.php'); + + expect($arpSource)->toContain('mactrack_format_mac('); + expect($arpSource)->not->toContain('format_mac_address('); + expect($ajaxSource)->not->toContain('mactrack_save_graph_settings'); + }); + + it('does not gate plugin enablement on the DNS resolver library', function () { + // DNS resolution is an optional collector feature, so a missing or + // unloadable Net_DNS2 must never leave the plugin stuck in "needs + // configuration". + $source = plugin_test_read_source('setup.php'); + + expect($source)->not->toContain('Net_DNS2'); + expect($source)->not->toContain('Net/DNS2.php'); + }); +}); diff --git a/tests/Support/CactiStubs.php b/tests/Support/CactiStubs.php deleted file mode 100644 index a0938241..00000000 --- a/tests/Support/CactiStubs.php +++ /dev/null @@ -1,85 +0,0 @@ - $sql, 'params' => $params]; - - return true; -} -function db_fetch_assoc($sql) { - return []; -} -function db_fetch_assoc_prepared($sql, array $params = []) { - return []; -} -function db_fetch_cell($sql) { - return null; -} -function db_fetch_cell_prepared($sql, array $params = []) { - return null; -} -function db_fetch_row_prepared($sql, array $params = []) { - return []; -} -function db_qstr($value) { - return "''"; -} -function read_config_option($name, $force = false) { - global $mactrack_test_config_options; - - return $mactrack_test_config_options[$name] ?? ''; -} -function set_config_option($name, $value) { - global $mactrack_test_config_options; - - $mactrack_test_config_options[$name] = $value; - - return true; -} -function get_request_var($name, $default = null) { - return $default; -} -function get_filter_request_var($name, $default = null) { - return $default; -} -function get_nfilter_request_var($name, $default = null) { - return $default; -} -function isset_request_var($name) { - return false; -} -function isempty_request_var($name) { - return true; -} -function set_request_var($name, $value) { -} -function validate_store_request_vars(array $filters, $session_name) { -} -function form_input_validate($value, $field_name, $regex = '', $allow_null = false, $error_type = 3) { - return $value; -} -function sql_save(array $save, $table, $key = null, $autoincrement = true) { - return 0; -} -function cacti_log($message, $popup = false, $type = '') { -} -function cacti_escapeshellcmd($value) { - return escapeshellcmd($value); -} -function cacti_escapeshellarg($value) { - return escapeshellarg($value); -} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 00000000..7b35c3c3 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,48 @@ +assertFalse(mactrack_site_configuration_exists()); + + mactrack_test_queue_return('db_fetch_cell_prepared', '2'); + $this->assertTrue(mactrack_site_configuration_exists()); + } + + /** + * @return void + */ + public function testSeedDefaultSiteSkipsTheLockWhenASiteAlreadyExists(): void { + mactrack_test_queue_return_for('db_fetch_cell_prepared', 'COUNT(*) FROM mac_track_sites', '1'); + + $this->assertTrue(mactrack_seed_default_site()); + + foreach ($GLOBALS['__test_db_calls'] as $call) { + $this->assertStringNotContainsString('GET_LOCK', $call['sql']); + } + } + + /** + * @return void + */ + public function testSeedDefaultSiteAcquiresTheLockInsertsAndReleases(): void { + // db_fetch_cell_prepared() is called for the initial COUNT(*), then + // GET_LOCK, then the post-insert COUNT(*) recheck, then RELEASE_LOCK. + // GET_LOCK/RELEASE_LOCK are matched (sticky) since each occurs once; + // the two COUNT(*) calls need different answers, so they go through + // the plain FIFO queue instead (matched entries are checked first + // and never match the COUNT(*) SQL, so they don't interfere). + mactrack_test_queue_return_for('db_fetch_cell_prepared', 'GET_LOCK', '1'); + mactrack_test_queue_return_for('db_fetch_cell_prepared', 'RELEASE_LOCK', '1'); + mactrack_test_queue_return('db_fetch_cell_prepared', '0'); + mactrack_test_queue_return('db_fetch_cell_prepared', '1'); + mactrack_test_queue_return('db_execute_prepared', true); + + $this->assertTrue(mactrack_seed_default_site()); + + $sqlCalls = array_column($GLOBALS['__test_db_calls'], 'sql'); + $this->assertTrue((bool) array_filter($sqlCalls, static fn ($sql) => strpos($sql, 'GET_LOCK') !== false)); + $this->assertTrue((bool) array_filter($sqlCalls, static fn ($sql) => strpos($sql, 'INSERT INTO mac_track_sites') !== false)); + $this->assertTrue((bool) array_filter($sqlCalls, static fn ($sql) => strpos($sql, 'RELEASE_LOCK') !== false)); + } + + /** + * @return void + */ + public function testSeedDefaultSiteFailsClosedWhenTheLockCannotBeAcquired(): void { + mactrack_test_queue_return_for('db_fetch_cell_prepared', 'COUNT(*) FROM mac_track_sites', '0'); + mactrack_test_queue_return_for('db_fetch_cell_prepared', 'GET_LOCK', '0'); + + $this->assertFalse(mactrack_seed_default_site()); + + $sqlCalls = array_column($GLOBALS['__test_db_calls'], 'sql'); + $this->assertFalse((bool) array_filter($sqlCalls, static fn ($sql) => strpos($sql, 'INSERT INTO mac_track_sites') !== false)); + } + + /** + * @return void + */ + public function testSeedDefaultSiteRecoversIfAnotherWorkerWonTheRace(): void { + // First check: no site. Lock denied. Second check (inside the + // lock-denied branch): a concurrent worker already inserted one. + mactrack_test_queue_return('db_fetch_cell_prepared', '0'); + mactrack_test_queue_return_for('db_fetch_cell_prepared', 'GET_LOCK', '0'); + mactrack_test_queue_return('db_fetch_cell_prepared', '1'); + + $this->assertTrue(mactrack_seed_default_site()); + } + + /** + * @return void + */ + public function testEnsureDefaultSiteResetsRetryStateOnSuccess(): void { + set_config_option('mt_default_site_seed_pending', 'on'); + set_config_option('mt_default_site_seed_attempts', '2'); + set_config_option('mt_default_site_seed_next_retry', (string) (time() - 10)); + + mactrack_test_queue_return_for('db_fetch_cell_prepared', 'COUNT(*) FROM mac_track_sites', '1'); + + $this->assertTrue(mactrack_ensure_default_site(false)); + + $this->assertSame('off', read_config_option('mt_default_site_seed_pending')); + $this->assertSame('0', read_config_option('mt_default_site_seed_attempts')); + $this->assertSame('0', read_config_option('mt_default_site_seed_next_retry')); + } + + /** + * @return void + */ + public function testEnsureDefaultSiteSchedulesIncreasingBackoffOnRepeatedFailure(): void { + mactrack_test_queue_return_for('db_fetch_cell_prepared', 'COUNT(*) FROM mac_track_sites', '0'); + mactrack_test_queue_return_for('db_fetch_cell_prepared', 'GET_LOCK', '0'); + + $before = time(); + $this->assertFalse(mactrack_ensure_default_site(false)); + + $this->assertSame('on', read_config_option('mt_default_site_seed_pending')); + $this->assertSame('1', read_config_option('mt_default_site_seed_attempts')); + $this->assertGreaterThanOrEqual($before + 60, (int) read_config_option('mt_default_site_seed_next_retry')); + } + + /** + * @return void + */ + public function testEnsureDefaultSiteDoesNotRetryBeforeTheBackoffWindowElapses(): void { + set_config_option('mt_default_site_seed_pending', 'on'); + set_config_option('mt_default_site_seed_attempts', '1'); + set_config_option('mt_default_site_seed_next_retry', (string) (time() + 3600)); + + mactrack_test_queue_return_for('db_fetch_cell_prepared', 'COUNT(*) FROM mac_track_sites', '0'); + + $this->assertFalse(mactrack_ensure_default_site(false)); + + foreach ($GLOBALS['__test_db_calls'] as $call) { + $this->assertStringNotContainsString('GET_LOCK', $call['sql'], 'A throttled retry must not attempt to take the seed lock'); + } + } + + /** + * @return void + */ + public function testEnsureDefaultSiteClearsThrottleWhenTheSiteWasRepairedManually(): void { + set_config_option('mt_default_site_seed_pending', 'on'); + set_config_option('mt_default_site_seed_attempts', '3'); + set_config_option('mt_default_site_seed_next_retry', (string) (time() + 3600)); + + mactrack_test_queue_return_for('db_fetch_cell_prepared', 'COUNT(*) FROM mac_track_sites', '1'); + + $this->assertTrue(mactrack_ensure_default_site(false)); + $this->assertSame('off', read_config_option('mt_default_site_seed_pending')); + } + + /** + * @return void + */ + public function testRetryDefaultSiteIsANoOpWhenNothingIsPending(): void { + set_config_option('mt_default_site_seed_pending', 'off'); + + $this->assertTrue(mactrack_retry_default_site()); + $this->assertSame([], $GLOBALS['__test_db_calls']); + } + + /** + * @return void + */ + public function testRetryDefaultSiteAttemptsToSeedWhenPending(): void { + set_config_option('mt_default_site_seed_pending', 'on'); + set_config_option('mt_default_site_seed_next_retry', '0'); + mactrack_test_queue_return_for('db_fetch_cell_prepared', 'COUNT(*) FROM mac_track_sites', '1'); + + $this->assertTrue(mactrack_retry_default_site()); + } +} diff --git a/tests/Unit/IgnorePortsPatternTest.php b/tests/Unit/IgnorePortsPatternTest.php new file mode 100644 index 00000000..ef42fdf5 --- /dev/null +++ b/tests/Unit/IgnorePortsPatternTest.php @@ -0,0 +1,139 @@ + + */ + public static function validPatternProvider() { + return [ + 'default' => ['(Vlan|Loopback|Null)'], + 'anchored' => ['^(Gi|Te)[0-9/]+$'], + 'literal tilde' => ['Port~Channel'], + 'escaped tilde' => ['Vlan\\~Trunk'], + ]; + } + + /** + * @dataProvider validPatternProvider + * + * @param string $valid + * + * @return void + */ + public function testValidPatternsPassThroughUnchanged($valid): void { + $this->assertSame($valid, mactrack_validate_ignore_ports_pattern($valid)); + } + + /** + * @return array + */ + public static function invalidPatternProvider() { + return [ + 'empty' => [''], + 'null' => [null], + 'unclosed group' => ['(Vlan'], + 'unclosed class' => ['[a-'], + 'catastrophic backtracking' => ['(a+)+$'], + ]; + } + + /** + * @dataProvider invalidPatternProvider + * + * @param mixed $invalid + * + * @return void + */ + public function testInvalidPatternsFallBackToTheDefault($invalid): void { + $this->assertSame('(Vlan|Loopback|Null)', mactrack_validate_ignore_ports_pattern($invalid)); + } + + /** + * @return void + */ + public function testPredicateBindsTheConfiguredPatternTwice(): void { + set_config_option('mt_ignorePorts', '(Vlan|Loopback|Null)'); + + $params = []; + $predicate = mactrack_get_ignore_ports_predicate($params); + + $this->assertSame('(ifName NOT RLIKE ? AND ifDescr NOT RLIKE ?)', $predicate); + $this->assertSame(['(Vlan|Loopback|Null)', '(Vlan|Loopback|Null)'], $params); + } + + /** + * @return array + */ + public static function needsIgnoreProvider() { + return [ + '-4, no bandwidth filter' => ['-4', -1, true], + '-4, with bandwidth' => ['-4', 70, true], + '-3, no bandwidth filter' => ['-3', -1, true], + '-2, no bandwidth filter' => ['-2', -1, false], + '-2, with bandwidth' => ['-2', 70, false], + '-1' => ['-1', -1, true], + '0' => ['0', -1, true], + '1' => ['1', -1, true], + '2' => ['2', -1, true], + '3' => ['3', -1, true], + '7, no bandwidth filter' => ['7', -1, false], + '9, no bandwidth filter' => ['9', -1, false], + '9, with bandwidth' => ['9', 70, true], + '10, no bandwidth filter' => ['10', -1, false], + '10, with bandwidth' => ['10', 70, true], + '11, no bandwidth filter' => ['11', -1, false], + '11, with bandwidth' => ['11', 70, true], + ]; + } + + /** + * @dataProvider needsIgnoreProvider + * + * @param string $issues + * @param int $bwusage + * @param bool $expected + * + * @return void + */ + public function testNeedsIgnoreMatchesTheIssuesAndBandwidthFilterCombination($issues, $bwusage, $expected): void { + $this->assertSame($expected, mactrack_interface_filter_needs_ignore($issues, $bwusage)); + } + + /** + * @return void + */ + public function testInterfacesQueryUsesTheValidatedPatternAndPreparedSql(): void { + $source = plugin_test_read_source('mactrack_view_interfaces.php'); + + $this->assertStringContainsString('mactrack_get_ignore_ports_predicate($sql_params)', $source); + $this->assertStringContainsString('db_fetch_assoc_prepared($sql_query, $sql_params)', $source); + $this->assertStringContainsString('db_fetch_cell_prepared($rows_query_string, $sql_params)', $source); + } +} diff --git a/tests/Unit/MacFormattingTest.php b/tests/Unit/MacFormattingTest.php new file mode 100644 index 00000000..883e74f1 --- /dev/null +++ b/tests/Unit/MacFormattingTest.php @@ -0,0 +1,67 @@ + + */ + public static function macFormatProvider() { + return [ + 'colon' => ['aa:bb:cc:dd:ee:ff', 'aa:bb:cc:dd:ee:ff'], + 'dash' => ['aa-bb-cc-dd-ee-ff', 'aa-bb-cc-dd-ee-ff'], + 'raw' => ['aabbccddeeff', 'aabbccddeeff'], + 'quad-dash' => ['aabb-ccdd-eeff', 'aabb-ccdd-eeff'], + 'dot' => ['aabb.ccdd.eeff', 'aabb.ccdd.eeff'], + 'unset' => ['', 'aabbccddeeff'], + 'unrecognised' => ['not-a-format', 'aabbccddeeff'], + ]; + } + + /** + * @dataProvider macFormatProvider + * + * @param string $format + * @param string $expected + * + * @return void + */ + public function testFormatsAccordingToMtMacFormat($format, $expected): void { + set_config_option('mt_mac_format', $format); + + $this->assertSame($expected, mactrack_format_mac('aabbccddeeff')); + } + + /** + * @return void + */ + public function testShortOrNullAddressIsReturnedUnchanged(): void { + foreach ([null, '', 'aabbcc'] as $short) { + $this->assertSame($short, mactrack_format_mac($short)); + } + } +} diff --git a/tests/Unit/XformMacAddressTest.php b/tests/Unit/XformMacAddressTest.php new file mode 100644 index 00000000..691f10dc --- /dev/null +++ b/tests/Unit/XformMacAddressTest.php @@ -0,0 +1,77 @@ +assertSame('NOT USER', xform_mac_address('')); + $this->assertSame('NOT USER', xform_mac_address(' ')); + } + + /** + * @return void + */ + public function testColonDelimitedAsciiAddressIsNormalized(): void { + $this->assertSame('AABBCCDDEEFF', xform_mac_address('aa:bb:cc:dd:ee:ff')); + } + + /** + * @return void + */ + public function testDashDelimitedAsciiAddressIsNormalized(): void { + $this->assertSame('AABBCCDDEEFF', xform_mac_address('aa-bb-cc-dd-ee-ff')); + } + + /** + * @return void + */ + public function testHexPrefixedAddressStripsThePrefix(): void { + $this->assertSame('AABBCCDDEEFF', xform_mac_address('HEX-00:aa:bb:cc:dd:ee:ff')); + $this->assertSame('AABBCCDDEEFF', xform_mac_address('HEX-aa:bb:cc:dd:ee:ff')); + } + + /** + * @return void + */ + public function testQuotedAndSpacedAddressIsCleaned(): void { + $this->assertSame('AABBCCDDEEFF', xform_mac_address('"aa bb cc dd ee ff"')); + } + + /** + * @return void + */ + public function testShortBinaryOctetsAreConvertedFromBinary(): void { + // 6 raw binary bytes (<= 10 chars), as SNMP returns them, rather than + // an already-ASCII-formatted address. + $binary = "\xaa\xbb\xcc\xdd\xee\xff"; + + $this->assertSame('AABBCCDDEEFF', xform_mac_address($binary)); + } +} diff --git a/tests/Unit/test_device_type_sql_safety.php b/tests/Unit/test_device_type_sql_safety.php deleted file mode 100644 index 1d25c759..00000000 --- a/tests/Unit/test_device_type_sql_safety.php +++ /dev/null @@ -1,161 +0,0 @@ -'; -$escaped = htmlspecialchars($payload, ENT_QUOTES, 'UTF-8'); - -if (strpos($escaped, '