diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml new file mode 100644 index 0000000..779e8a4 --- /dev/null +++ b/.github/workflows/plugin-ci-workflow.yml @@ -0,0 +1,261 @@ +# +-------------------------------------------------------------------------+ +# | Copyright (C) 2004-2026 The Cacti Group | +# | | +# | This program is free software; you can redistribute it and/or | +# | modify it under the terms of the GNU General Public License | +# | as published by the Free Software Foundation; either version 2 | +# | of the License, or (at your option) any later version. | +# +-------------------------------------------------------------------------+ + +name: Plugin Integration Tests + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +env: + CACTI: 1.2.x + +jobs: + integration-test: + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4'] + os: [ubuntu-latest] + + services: + mariadb: + image: mariadb:10.6 + env: + MYSQL_ROOT_PASSWORD: cactiroot + MYSQL_DATABASE: cacti + MYSQL_USER: cactiuser + MYSQL_PASSWORD: cactiuser + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --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@v4 + with: + repository: Cacti/cacti + ref: ${{ env.CACTI }} + path: cacti + + - name: Checkout flowview Plugin + uses: actions/checkout@v4 + with: + path: cacti/plugins/flowview + + - name: Install PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + 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" + + - 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 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 + sudo find ${{ github.workspace }}/cacti -type d -exec chmod 775 {} \; + 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' + run: | + mysql $MYSQL_AUTH_USR -e 'CREATE DATABASE IF NOT EXISTS cacti;' + mysql $MYSQL_AUTH_USR -e "CREATE USER IF NOT EXISTS 'cactiuser'@'localhost' IDENTIFIED BY 'cactiuser';" + mysql $MYSQL_AUTH_USR -e "GRANT ALL PRIVILEGES ON cacti.* TO 'cactiuser'@'localhost';" + mysql $MYSQL_AUTH_USR -e "GRANT SELECT ON mysql.time_zone_name TO 'cactiuser'@'localhost';" + 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 + fi + + - name: Install Composer Dependencies + run: | + cd ${{ github.workspace }}/cacti + if [ -f composer.json ]; then + composer config --no-plugins allow-plugins.pestphp/pest-plugin true + composer require --dev --no-progress --no-interaction "pestphp/pest: ^3" "pestphp/pest-plugin-drift: ^3.0" + rm -f composer.lock + composer install --dev --no-progress + fi + + - name: Create Cacti config.php + run: | + cat ${{ github.workspace }}/cacti/include/config.php.dist | \ + sed -r "s/localhost/127.0.0.1/g" | \ + sed -r "s/'cacti'/'cacti'/g" | \ + 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 flowview Plugin + run: | + cd ${{ github.workspace }}/cacti + sudo php cli/plugin_manage.php --plugin=flowview --install --enable + + - name: Check PHP Syntax for Plugin + run: | + cd ${{ github.workspace }}/cacti/plugins/flowview + if find . -name '*.php' -exec php -l {} 2>&1 \; | grep -iv 'no syntax errors detected'; then + echo "Syntax errors found!" + exit 1 + fi + + - name: Set expected Cacti version for unit tests + run: echo -n "${{ env.CACTI }}" | sudo tee ${{ github.workspace }}/cacti/plugins/flowview/tests/.cacti-version > /dev/null + + - name: Run Pest Unit Tests + env: + COMPOSER_ROOT_VERSION: 1.3.0-dev + run: | + cd ${{ github.workspace }}/cacti + include/vendor/bin/pest --configuration=plugins/flowview/phpunit.xml \ + --coverage-clover=plugins/flowview/coverage/clover.xml \ + plugins/flowview/tests/Security plugins/flowview/tests/Unit + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-php${{ matrix.php }} + path: ${{ github.workspace }}/cacti/plugins/flowview/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 + if: ${{ env.CACTI != '1.2.x' }} + run: sudo chmod +x ${{ github.workspace }}/cacti/include/vendor/bin/* + working-directory: ${{ github.workspace }}/cacti + + - name: Run Linter on base code + if: ${{ env.CACTI != '1.2.x' }} + run: composer run-script lint ${{ github.workspace }}/cacti/plugins/flowview + working-directory: ${{ github.workspace }}/cacti + + - name: Checking coding standards on base code + if: ${{ env.CACTI != '1.2.x' }} + run: composer run-script phpcsfixer ${{ github.workspace }}/cacti/plugins/flowview + 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: ./include/vendor/bin/phpstan analyze --level 6 ${{ github.workspace }}/cacti/plugins/flowview + working-directory: ${{ github.workspace }}/cacti + + - name: Re-apply web user ownership before polling + run: | + p="${{ github.workspace }}" + while [ "$p" != "/" ]; do + sudo chmod o+x "$p" || true + p=$(dirname "$p") + done + sudo chown -R www-data:runner ${{ github.workspace }}/cacti + sudo find ${{ github.workspace }}/cacti -type d -exec chmod 775 {} \; + 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: Run Cacti Poller + 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 + + - name: View Cacti Logs + if: always() + run: | + if [ -f ${{ github.workspace }}/cacti/log/cacti.log ]; then + echo "=== Cacti Log ===" + sudo cat ${{ github.workspace }}/cacti/log/cacti.log + fi + + - name: Check Cacti Logs for Errors + if: always() + run: | + if [ -f ${{ github.workspace }}/cacti/log/cacti.log ]; then + if grep -E "FATAL:|ERROR:|WARNING:" ${{ github.workspace }}/cacti/log/cacti.log; then + echo "Found FATAL/ERROR/WARNING entries in Cacti log" + exit 1 + fi + fi diff --git a/.gitignore b/.gitignore index 5a322e8..2c3d780 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,12 @@ # +-------------------------------------------------------------------------+ .git* +!.github/ +!.github/workflows/ +!.github/workflows/*.yml +.phpunit.result.cache +.phpunit.cache/ +coverage/ config.php locales/po/*.mo .omc/ diff --git a/database.php b/database.php index 38c5707..60dfe11 100644 --- a/database.php +++ b/database.php @@ -241,9 +241,10 @@ function flowview_sql_save($array_items, $table_name, $key_cols = 'id', $autoinc function flowview_db_table_exists($table, $log = true, $cnn_id = false) { $flowview_cnn = flowview_get_connection($cnn_id); - preg_match("/([`]{0,1}(?[\w_]+)[`]{0,1}\.){0,1}[`]{0,1}(?[\w_]+)[`]{0,1}/", $table, $matches); - if ($matches !== false && array_key_exists('table', $matches)) { - $sql = 'SHOW TABLES LIKE \'' . $matches['table'] . '\''; + $matched = preg_match('/^(?:`?(?[A-Za-z0-9_]+)`?\.)?`?(?
[A-Za-z0-9_]+)`?$/D', $table, $matches); + if ($matched === 1) { + $table_pattern = addcslashes($matches['table'], '\\%_'); + $sql = 'SHOW TABLES LIKE \'' . $table_pattern . '\''; return (db_fetch_cell($sql, '', $log, $flowview_cnn) ? true : false); } diff --git a/functions.php b/functions.php index 4a35ddd..31bda91 100644 --- a/functions.php +++ b/functions.php @@ -2023,8 +2023,6 @@ function load_data_for_filter($id = 0, $start = false, $end = false) { * */ function get_numeric_filter($sql_where, &$sql_params, $value, $column) { - $values = []; - $sql_where = trim($sql_where); if (is_array($value)) { @@ -2045,7 +2043,11 @@ function get_numeric_filter($sql_where, &$sql_params, $value, $column) { } } - $sql_where .= ($sql_where != '' ? ' AND ':'WHERE ') . "`$column` IN ($instr)"; + if ($instr != '') { + $sql_where .= ($sql_where != '' ? ' AND ':'WHERE ') . "`$column` IN ($instr)"; + } else { + $sql_where .= ($sql_where != '' ? ' AND ':'WHERE ') . '1 = 0'; + } } return $sql_where; @@ -2068,11 +2070,8 @@ function get_ip_filter($sql_where, &$sql_params, $value, $column) { $sql_where = trim($sql_where); if ($value != '') { - $values = []; $parts = explode(',', $value); - $i = 0; - - $sql_where .= ($sql_where != '' ? ' AND ':'WHERE ') . '('; + $predicates = array(); foreach($parts as $part) { $part = trim($part); @@ -2082,32 +2081,34 @@ function get_ip_filter($sql_where, &$sql_params, $value, $column) { $addr = cacti_pton($part); if (isset($addr['subnet'])) { - // Example looking for IP's in the network to the right: 192.168.11.0/24 - // src_addr & inet6_aton('255.255.255.0') = inet6_aton('192.168.11.0') - $subnet = inet_ntop($addr['subnet']); $network = inet_ntop($addr['subnet'] & $addr['ip']); + $last = inet_ntop(($addr['subnet'] & $addr['ip']) | ~$addr['subnet']); - $sql_where .= ($i == 0 ? '':' OR ') . - "(`$column` & INET6_ATON(?) = INET6_ATON(?) OR `$column` & INET_ATON(?) = INET_ATON(?))"; + // VARBINARY values cannot be masked reliably with SQL's numeric + // bitwise operators. Match the address family and use the + // lexicographic range represented by the first and last address. + $predicates[] = "(OCTET_LENGTH(`$column`) = OCTET_LENGTH(INET6_ATON(?)) AND `$column` BETWEEN INET6_ATON(?) AND INET6_ATON(?))"; - $sql_params[] = $subnet; $sql_params[] = $network; - $sql_params[] = $subnet; $sql_params[] = $network; + $sql_params[] = $last; } else { raise_message('subnet_filter', __('Subnet Filter: %s is not a value CIDR format', $part, 'flowview'), MESSAGE_LEVEL_ERROR); } - } else { - $sql_where .= ($i == 0 ? '':' OR ') . "(`$column` = INET6_ATON(?) OR `$column` = INET_ATON(?)"; + } elseif (is_ipaddress($part)) { + $predicates[] = "(`$column` = INET6_ATON(?))"; $sql_params[] = $part; - $sql_params[] = $part; + } else { + raise_message('ip_filter', __('IP Filter: %s is not a valid IP address', $part, 'flowview'), MESSAGE_LEVEL_ERROR); } - - $i++; } - $sql_where .= ')'; + if (!empty($predicates)) { + $sql_where .= ($sql_where != '' ? ' AND ':'WHERE ') . '(' . implode(' OR ', $predicates) . ')'; + } else { + $sql_where .= ($sql_where != '' ? ' AND ':'WHERE ') . '1 = 0'; + } } return $sql_where; @@ -3570,7 +3571,11 @@ function run_flow_query($session, $query_id, $start, $end) { } function flowview_table_name_to_time($table, $type) { - $suffix = str_replace('plugin_flowview_raw_', '', $table); + if (!preg_match('/^plugin_flowview_raw_([0-9]{7})([0-9]{2})?$/', $table, $matches)) { + return false; + } + + $suffix = $matches[1] . (isset($matches[2]) ? $matches[2] : ''); $year = substr($suffix, 0, 4); $day = substr($suffix, 4, 3); @@ -3580,7 +3585,7 @@ function flowview_table_name_to_time($table, $type) { $hour = 0; } else { $gran = 'hours'; - $hour = substr($suffix, 8, 2); + $hour = substr($suffix, 7, 2); } $dates = flowview_convert_yeardayhour_to_date($gran, $year, $day, $hour); @@ -3589,16 +3594,19 @@ function flowview_table_name_to_time($table, $type) { } function flowview_convert_yeardayhour_to_date($range, $year, $day, $hour = 0) { - $datetime = new DateTime(); + $datetime = new DateTime(); + $datetime->setDate((int) $year, 1, 1)->setTime(0, 0, 0); + $datetime->modify('+' . (int) $day . ' days'); if ($range == 'days') { - $datetime->setTimestamp(mktime(0, 0, 0, 0, 0, $year) + ($day * 86400)); $start_date = $datetime->format('Y-m-d 00:00:00'); - $end_date = date('Y-m-d 00:00:00', strtotime($start_date)+86400); + $datetime->modify('+1 day'); + $end_date = $datetime->format('Y-m-d 00:00:00'); } else { - $datetime->setTimestamp(mktime(0, 0, 0, 0, 0, $year) + ($day * 86400) + ($hour * 3600)); + $datetime->modify('+' . (int) $hour . ' hours'); $start_date = $datetime->format('Y-m-d H:00:00'); - $end_date = date('Y-m-d H:00:00', strtotime($start_date) + ($hour * 3600)); + $datetime->modify('+1 hour'); + $end_date = $datetime->format('Y-m-d H:00:00'); } $start_time = strtotime($start_date); @@ -4766,11 +4774,7 @@ function flowview_explode($string) { } function removeWhiteSpace($string) { - $string = str_replace("\t", ' ', $string); - while (substr_count(' ',$string)) { - $string = str_replace(' ', ' ', $string); - } - return $string; + return preg_replace('/\s+/', ' ', $string); } function plugin_flowview_get_protocol($prot, $prot_hex) { @@ -6335,14 +6339,17 @@ function flowview_get_owner_from_arin($host) { } } -function flowview_get_domain($host, $domain = 'false') { - if ($domain == 'false') { +function flowview_get_domain($host, $domain = false) { + if ($domain === false || $domain === 'false') { return $host; } elseif (is_ipaddress($host)) { return $host; } else { $parts = explode('.', $host); $size = cacti_sizeof($parts); + if ($size < 2) { + return $host; + } return $parts[$size - 2] . '.' . $parts[$size - 1]; } } diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..9d37d57 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,35 @@ + + + + + + + + + + tests/Security + tests/Unit + + + + + + + setup.php + functions.php + database.php + + + diff --git a/tests/.cacti-version b/tests/.cacti-version new file mode 100644 index 0000000..ac6855b --- /dev/null +++ b/tests/.cacti-version @@ -0,0 +1 @@ +1.2.x \ No newline at end of file diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..c639e23 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,14 @@ + plugins/flowview/tests/.cacti-version + +include/vendor/bin/pest --configuration=plugins/flowview/phpunit.xml \ + plugins/flowview/tests/Security plugins/flowview/tests/Unit +``` + +Unit tests load the shipped `functions.php` and `database.php` through a +small, stateful Cacti API double defined in `tests/bootstrap-unit.php`. +Database responses can be queued with `plugin_test_queue_db_result()`, and +every SQL call is recorded in `$GLOBALS['__test_db_calls']` for parameter and +connection assertions. + +Security tests validate PHP 7.4 syntax compatibility and the plugin's +install/version/uninstall structure without executing plugin source. + diff --git a/tests/Security/Php74CompatibilityTest.php b/tests/Security/Php74CompatibilityTest.php new file mode 100644 index 0000000..4acf6ba --- /dev/null +++ b/tests/Security/Php74CompatibilityTest.php @@ -0,0 +1,118 @@ +getExtension() !== 'php') { + continue; + } + + $relativeFile = ltrim(str_replace($pluginRoot, '', $file->getPathname()), DIRECTORY_SEPARATOR); + $relativeFile = str_replace(DIRECTORY_SEPARATOR, '/', $relativeFile); + + if (strpos($relativeFile, 'tests/') === 0) { + continue; + } + + $files[] = $relativeFile; + } + + sort($files); + + it('does not use str_contains (PHP 8.0)', function () use ($files) { + foreach ($files as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + + if ($path === false) { + throw new RuntimeException("Unable to resolve required plugin source"); + } + + $contents = file_get_contents($path); + + if ($contents === false) { + throw new RuntimeException("Unable to read required plugin source"); + } + + expect(preg_match('/\bstr_contains\s*\(/', $contents))->toBe(0, + "{$relativeFile} uses str_contains() which requires PHP 8.0" + ); + } + }); + + it('does not use str_starts_with (PHP 8.0)', function () use ($files) { + foreach ($files as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + + if ($path === false) { + throw new RuntimeException("Unable to resolve required plugin source"); + } + + $contents = file_get_contents($path); + + if ($contents === false) { + throw new RuntimeException("Unable to read required plugin source"); + } + + expect(preg_match('/\bstr_starts_with\s*\(/', $contents))->toBe(0, + "{$relativeFile} uses str_starts_with() which requires PHP 8.0" + ); + } + }); + + it('does not use str_ends_with (PHP 8.0)', function () use ($files) { + foreach ($files as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + + if ($path === false) { + throw new RuntimeException("Unable to resolve required plugin source"); + } + + $contents = file_get_contents($path); + + if ($contents === false) { + throw new RuntimeException("Unable to read required plugin source"); + } + + expect(preg_match('/\bstr_ends_with\s*\(/', $contents))->toBe(0, + "{$relativeFile} uses str_ends_with() which requires PHP 8.0" + ); + } + }); + + it('does not use nullsafe operator (PHP 8.0)', function () use ($files) { + foreach ($files as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + + if ($path === false) { + throw new RuntimeException("Unable to resolve required plugin source"); + } + + $contents = file_get_contents($path); + + if ($contents === false) { + throw new RuntimeException("Unable to read required plugin source"); + } + + expect(preg_match('/\?->/', $contents))->toBe(0, + "{$relativeFile} uses nullsafe operator which requires PHP 8.0" + ); + } + }); diff --git a/tests/Security/SetupStructureTest.php b/tests/Security/SetupStructureTest.php new file mode 100644 index 0000000..059e7a6 --- /dev/null +++ b/tests/Security/SetupStructureTest.php @@ -0,0 +1,40 @@ +toContain('function plugin_flowview_install'); +}); + +it('defines plugin_flowview_version function', function () use ($source) { + expect($source)->toContain('function plugin_flowview_version'); +}); + +it('defines plugin_flowview_uninstall function', function () use ($source) { + expect($source)->toContain('function plugin_flowview_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/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..11a195f --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,44 @@ +toBe(' alpha beta gamma ') + ->and(flowview_explode(' alpha beta gamma '))->toBe(array('alpha', 'beta', 'gamma')) + ->and(flowview_explode(' '))->toBe(array()); +}); + +it('maps type-of-service bits to their precedence and service labels', function ($tos, $expected) { + expect(parse_tos($tos))->toBe($expected); +})->with(array( + array(0, 'Routine'), + array(16, 'Low Delay, Routine'), + array(40, 'Throughput, Priority'), + array(224, 'Net Control'), +)); + +it('formats display helpers deterministically', function () { + expect(display_domain(''))->toBe('unresolved') + ->and(display_domain('example.net'))->toBe('example.net') + ->and(get_column_alignment('Bytes'))->toBe('right') + ->and(get_column_alignment('Source'))->toBe('left') + ->and(flowview_altrow(0))->toBe('even') + ->and(flowview_altrow(3))->toBe('odd'); +}); + +it('serializes only report parameters', function () { + $_POST = array( + '__csrf_magic' => 'secret', + 'domains' => 'ignored', + 'table' => 'ignored', + 'view' => 'ignored', + 'bytes' => 'ignored', + 'packets' => 'ignored', + 'flows' => 'ignored', + 'sourceip' => '192.0.2.1', + 'protocols' => array('6', '17'), + ); + + expect(json_decode(get_json_params(), true))->toBe(array( + 'sourceip' => '192.0.2.1', + 'protocols' => array('6', '17'), + )); +}); + +it('derives domains without rewriting IP addresses', function () { + expect(flowview_get_rdomain_from_domain('host.eu.example.net'))->toBe('example.net') + ->and(flowview_get_rdomain_from_domain('localhost'))->toBe('') + ->and(flowview_get_domain('host.eu.example.net', true))->toBe('example.net') + ->and(flowview_get_domain('192.0.2.5', true))->toBe('192.0.2.5') + ->and(flowview_get_domain('host.eu.example.net'))->toBe('host.eu.example.net'); +}); + +it('chooses chart scale boundaries', function ($value, $expected) { + expect(flowview_autoscale($value))->toBe($expected); +})->with(array( + array(999, array(1, '')), + array(1000, array(1000, 'K')), + array(1000000, array(1000000, 'M')), + array(1000000000, array(1000000000, 'G')), + array(1000000000000, array(1000000000000, 'P')), +)); + +it('produces stable cache keys and expires only stale sessions', function () { + $key = get_flowview_session_key(7, 100, 200); + expect($key)->toMatch('/^7_[a-f0-9]{32}$/'); + + $_SESSION['sess_flowdata'] = array( + 'fresh' => array('timeout' => time() + 60), + 'stale' => array('timeout' => time() - 1), + ); + purge_flowview_sessions(); + + expect($_SESSION['sess_flowdata'])->toHaveKey('fresh') + ->and($_SESSION['sess_flowdata'])->not->toHaveKey('stale'); +}); + +it('converts daily partition names into exact UTC bounds', function () { + expect(flowview_table_name_to_time('plugin_flowview_raw_2024000', 'start_time'))->toBe(strtotime('2024-01-01 00:00:00 UTC')) + ->and(flowview_table_name_to_time('plugin_flowview_raw_2024000', 'end_time'))->toBe(strtotime('2024-01-02 00:00:00 UTC')) + ->and(flowview_table_name_to_time('invalid_table', 'start_time'))->toBeFalse(); +}); + +it('converts hourly partition names into one-hour bounds', function () { + expect(flowview_table_name_to_time('plugin_flowview_raw_202416412', 'start_time'))->toBe(strtotime('2024-06-13 12:00:00 UTC')) + ->and(flowview_table_name_to_time('plugin_flowview_raw_202416412', 'end_time'))->toBe(strtotime('2024-06-13 13:00:00 UTC')); +}); + +it('uses local calendar boundaries across daylight-saving changes', function ($timezone, $table, $expectedStart, $expectedEnd) { + date_default_timezone_set($timezone); + + expect(flowview_table_name_to_time($table, 'start_time'))->toBe(strtotime($expectedStart)) + ->and(flowview_table_name_to_time($table, 'end_time'))->toBe(strtotime($expectedEnd)); +})->with(array( + array('America/New_York', 'plugin_flowview_raw_202406901', '2024-03-10 01:00:00', '2024-03-10 03:00:00'), + array('America/New_York', 'plugin_flowview_raw_202430701', '2024-11-03 01:00:00 EDT', '2024-11-03 02:00:00 EST'), + array('Europe/Berlin', 'plugin_flowview_raw_202409001', '2024-03-31 01:00:00', '2024-03-31 03:00:00'), +)); diff --git a/tests/Unit/DatabaseAdapterTest.php b/tests/Unit/DatabaseAdapterTest.php new file mode 100644 index 0000000..c8c5373 --- /dev/null +++ b/tests/Unit/DatabaseAdapterTest.php @@ -0,0 +1,67 @@ + 'https')); + $row = flowview_db_fetch_row_prepared('SELECT service FROM ports WHERE port = ?', array(443)); + + expect($row)->toBe(array('service' => 'https')) + ->and($GLOBALS['__test_db_calls'])->toHaveCount(1) + ->and($GLOBALS['__test_db_calls'][0]['params'])->toBe(array(443)) + ->and($GLOBALS['__test_db_calls'][0]['conn'])->toBe('flowview-connection'); +}); + +it('honors an explicit connection override', function () { + plugin_test_queue_db_result('db_fetch_cell_prepared', 7); + expect(flowview_db_fetch_cell_prepared('SELECT ?', array(7), '', true, 'replica'))->toBe(7) + ->and($GLOBALS['__test_db_calls'][0]['conn'])->toBe('replica'); +}); + +it('accepts simple optionally-qualified table identifiers', function () { + plugin_test_queue_db_result('db_fetch_cell', 1); + expect(flowview_db_table_exists('flowdb.plugin_flowview_raw_2024000'))->toBeTrue() + ->and($GLOBALS['__test_db_calls'][0]['sql'])->toBe("SHOW TABLES LIKE 'plugin\\_flowview\\_raw\\_2024000'"); + +}); + +it('rejects an unsafe or incomplete table identifier', function ($table) { + expect(flowview_db_table_exists($table))->toBeFalse() + ->and($GLOBALS['__test_db_calls'])->toBe(array()); +})->with(array( + 'raw; DROP TABLE users', + 'raw table', + 'raw`table', + 'raw%table', + '', +)); + +it('generates table DDL with primary, regular, and unique indexes', function () { + plugin_test_queue_db_result('db_fetch_assoc', array()); + $data = array( + 'columns' => array( + array('name' => 'id', 'type' => 'int(11)', 'unsigned' => true, 'NULL' => false, 'auto_increment' => true), + array('name' => 'name', 'type' => 'varchar(64)', 'NULL' => false, 'default' => ''), + ), + 'primary' => 'id', + 'keys' => array(array('name' => 'name_idx', 'columns' => array('name'))), + 'unique_keys' => array(array('name' => 'name_unique', 'columns' => array('name'))), + 'type' => 'InnoDB', + 'charset' => 'utf8mb4', + ); + + flowview_db_table_create('plugin_flowview_test_ddl', $data); + $calls = array_values(array_filter($GLOBALS['__test_db_calls'], function ($call) { + return $call['fn'] === 'db_execute'; + })); + + expect($calls)->toHaveCount(1) + ->and($calls[0]['sql'])->toContain('CREATE TABLE IF NOT EXISTS `plugin_flowview_test_ddl`') + ->and($calls[0]['sql'])->toContain('PRIMARY KEY (`id`)') + ->and($calls[0]['sql'])->toContain('INDEX `name_idx` (`name`)') + ->and($calls[0]['sql'])->toContain('UNIQUE INDEX `name_unique` (`name`)') + ->and($calls[0]['sql'])->toContain('ENGINE = InnoDB DEFAULT CHARSET = utf8mb4'); +}); diff --git a/tests/Unit/QueryBuilderTest.php b/tests/Unit/QueryBuilderTest.php new file mode 100644 index 0000000..d4ff8a0 --- /dev/null +++ b/tests/Unit/QueryBuilderTest.php @@ -0,0 +1,87 @@ +toBe('WHERE `listener_id` = ? AND `protocol` IN (?, ?)') + ->and($params)->toBe(array('existing', '6', '17')) + ->and($where)->not->toContain('17'); +}); + +it('does not modify an empty numeric filter and fails closed on invalid input', function () { + $params = array(); + expect(get_numeric_filter('', $params, '', 'protocol'))->toBe('') + ->and(get_numeric_filter('', $params, 'invalid,also-invalid', 'protocol'))->toBe('WHERE 1 = 0') + ->and(get_numeric_filter('WHERE `listener_id` = ?', $params, 'invalid', 'protocol'))->toBe('WHERE `listener_id` = ? AND 1 = 0') + ->and($params)->toBe(array()); +}); + +it('builds address predicates with bound IPv4 and IPv6 values', function () { + $params = array(); + $where = get_ip_filter('', $params, '192.0.2.1,2001:db8::1', 'src_addr'); + + expect($where)->toBe('WHERE ((`src_addr` = INET6_ATON(?)) OR (`src_addr` = INET6_ATON(?)))') + ->and($params)->toBe(array('192.0.2.1', '2001:db8::1')); +}); + +it('rejects invalid addresses with a fail-closed predicate', function () { + $params = array(); + expect(get_ip_filter('', $params, 'not-an-address', 'src_addr'))->toBe('WHERE 1 = 0') + ->and(get_ip_filter('WHERE `listener_id` = ?', $params, 'also-invalid', 'src_addr'))->toBe('WHERE `listener_id` = ? AND 1 = 0') + ->and($params)->toBe(array()) + ->and($GLOBALS['__test_messages'])->toHaveCount(2); +}); + +it('keeps valid addresses when another list entry is invalid', function () { + $params = array(); + $where = get_ip_filter('', $params, '192.0.2.1,bogus', 'src_addr'); + + expect($where)->toBe('WHERE ((`src_addr` = INET6_ATON(?)))') + ->and($params)->toBe(array('192.0.2.1')) + ->and($GLOBALS['__test_messages'])->toHaveCount(1); +}); + +it('fails closed when a CIDR prefix is invalid', function () { + $params = array(); + + expect(get_ip_filter('', $params, '192.0.2.0/99', 'src_addr'))->toBe('WHERE 1 = 0') + ->and($params)->toBe(array()) + ->and($GLOBALS['__test_messages'])->toHaveCount(1); +}); + +it('binds CIDR masks and network addresses', function () { + $params = array(); + $where = get_ip_filter('', $params, '192.0.2.0/24', 'dst_addr'); + + expect($where)->toContain('OCTET_LENGTH(`dst_addr`) = OCTET_LENGTH(INET6_ATON(?))') + ->and($where)->toContain('`dst_addr` BETWEEN INET6_ATON(?) AND INET6_ATON(?)') + ->and($params)->toBe(array('192.0.2.0', '192.0.2.0', '192.0.2.255')); +}); + +it('computes the full IPv6 CIDR range', function () { + $params = array(); + get_ip_filter('', $params, '2001:db8::/64', 'dst_addr'); + + expect($params)->toBe(array('2001:db8::', '2001:db8::', '2001:db8::ffff:ffff:ffff:ffff')); +}); + +it('builds each supported temporal overlap contract', function ($rangeType, $expectedSql, $expectedCount) { + $params = array(); + $sql = get_date_filter('', $params, 1704067200, 1704070800, $rangeType); + + expect($sql)->toBe($expectedSql) + ->and($params)->toHaveCount($expectedCount) + ->and($params[0])->toBe('2024-01-01 00:00:00') + ->and($params[1])->toBe('2024-01-01 01:00:00'); +})->with(array( + array(1, '(`start_time` BETWEEN ? AND ? OR `end_time` BETWEEN ? AND ?)', 4), + array(2, '(`end_time` BETWEEN ? AND ?)', 2), + array(3, '(`start_time` BETWEEN ? AND ?)', 2), + array(4, '(`start_time` BETWEEN ? AND ? AND `end_time` BETWEEN ? AND ?)', 4), +)); diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php new file mode 100644 index 0000000..97d4bcc --- /dev/null +++ b/tests/bootstrap-unit.php @@ -0,0 +1,424 @@ + $cacti_root, + 'url_path' => '/cacti/', + 'cacti_version' => $cacti_version, + 'cacti_server_os' => 'unix', +); + +$GLOBALS['__test_db_calls'] = array(); +$GLOBALS['__test_db_results'] = array(); +$GLOBALS['__test_config'] = array(); +$GLOBALS['__test_messages'] = array(); + +if (!function_exists('plugin_test_reset')) { + function plugin_test_reset() { + $GLOBALS['__test_db_calls'] = array(); + $GLOBALS['__test_db_results'] = array(); + $GLOBALS['__test_config'] = array(); + $GLOBALS['__test_messages'] = array(); + $_GET = array(); + $_POST = array(); + $_REQUEST = array(); + $_SESSION = array(); + } +} + +if (!function_exists('plugin_test_queue_db_result')) { + function plugin_test_queue_db_result($function, $result) { + if (!isset($GLOBALS['__test_db_results'][$function])) { + $GLOBALS['__test_db_results'][$function] = array(); + } + $GLOBALS['__test_db_results'][$function][] = $result; + } +} + +if (!function_exists('plugin_test_db_result')) { + function plugin_test_db_result($function, $default) { + if (!empty($GLOBALS['__test_db_results'][$function])) { + return array_shift($GLOBALS['__test_db_results'][$function]); + } + return $default; + } +} + +if (!function_exists('db_execute')) { + function db_execute($sql, $log = true, $conn = false) { + $GLOBALS['__test_db_calls'][] = array('fn' => 'db_execute', 'sql' => $sql, 'params' => array(), 'conn' => $conn); + return true; + } +} + +if (!function_exists('db_execute_prepared')) { + function db_execute_prepared($sql, $params = array(), $log = true, $conn = false) { + $GLOBALS['__test_db_calls'][] = array('fn' => 'db_execute_prepared', 'sql' => $sql, 'params' => $params, 'conn' => $conn); + return true; + } +} + +if (!function_exists('db_fetch_assoc')) { + function db_fetch_assoc($sql, $log = true, $conn = false) { + $GLOBALS['__test_db_calls'][] = array('fn' => 'db_fetch_assoc', 'sql' => $sql, 'params' => array(), 'conn' => $conn); + return plugin_test_db_result('db_fetch_assoc', array()); + } +} + +if (!function_exists('db_fetch_assoc_prepared')) { + function db_fetch_assoc_prepared($sql, $params = array(), $log = true, $conn = false) { + $GLOBALS['__test_db_calls'][] = array('fn' => 'db_fetch_assoc_prepared', 'sql' => $sql, 'params' => $params, 'conn' => $conn); + return plugin_test_db_result('db_fetch_assoc_prepared', array()); + } +} + +if (!function_exists('db_fetch_row')) { + function db_fetch_row($sql, $log = true, $conn = false) { + $GLOBALS['__test_db_calls'][] = array('fn' => 'db_fetch_row', 'sql' => $sql, 'params' => array(), 'conn' => $conn); + return plugin_test_db_result('db_fetch_row', array()); + } +} + +if (!function_exists('db_fetch_row_prepared')) { + function db_fetch_row_prepared($sql, $params = array(), $log = true, $conn = false) { + $GLOBALS['__test_db_calls'][] = array('fn' => 'db_fetch_row_prepared', 'sql' => $sql, 'params' => $params, 'conn' => $conn); + return plugin_test_db_result('db_fetch_row_prepared', array()); + } +} + +if (!function_exists('db_fetch_cell')) { + function db_fetch_cell($sql, $column = '', $log = true, $conn = false) { + $GLOBALS['__test_db_calls'][] = array('fn' => 'db_fetch_cell', 'sql' => $sql, 'params' => array(), 'conn' => $conn); + return plugin_test_db_result('db_fetch_cell', ''); + } +} + +if (!function_exists('db_fetch_cell_prepared')) { + function db_fetch_cell_prepared($sql, $params = array(), $column = '', $log = true, $conn = false) { + $GLOBALS['__test_db_calls'][] = array('fn' => 'db_fetch_cell_prepared', 'sql' => $sql, 'params' => $params, 'conn' => $conn); + return plugin_test_db_result('db_fetch_cell_prepared', ''); + } +} + +if (!function_exists('db_index_exists')) { + function db_index_exists($table, $index) { + return false; + } +} + +if (!function_exists('db_column_exists')) { + function db_column_exists($table, $column) { + return false; + } +} + +if (!function_exists('api_plugin_db_add_column')) { + function api_plugin_db_add_column($plugin, $table, $data) { + return true; + } +} + +if (!function_exists('api_plugin_db_table_create')) { + function api_plugin_db_table_create($plugin, $table, $data) { + return true; + } +} + +if (!function_exists('read_config_option')) { + function read_config_option($name, $force = false) { + return isset($GLOBALS['__test_config'][$name]) ? $GLOBALS['__test_config'][$name] : ''; + } +} + +if (!function_exists('set_config_option')) { + function set_config_option($name, $value) { + $GLOBALS['__test_config'][$name] = $value; + } +} + +if (!function_exists('html_escape')) { + function html_escape($string) { + return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } +} + +if (!function_exists('__')) { + function __($text, $domain = '') { + return $text; + } +} + +if (!function_exists('__esc')) { + function __esc($text, ...$args) { + if (!empty($args)) { + array_pop($args); + } + if (!empty($args)) { + $text = vsprintf($text, $args); + } + return htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } +} + +if (!function_exists('cacti_log')) { + function cacti_log($message, $also_print = false, $log_type = '', $level = 0) { + } +} + +if (!function_exists('cacti_sizeof')) { + function cacti_sizeof($array) { + return is_array($array) ? count($array) : 0; + } +} + +if (!function_exists('is_realm_allowed')) { + function is_realm_allowed($realm) { + return true; + } +} + +if (!function_exists('raise_message')) { + function raise_message($id, $text = '', $level = 0) { + $GLOBALS['__test_messages'][] = array($id, $text, $level); + } +} + +if (!function_exists('get_request_var')) { + function get_request_var($name) { + return isset($_REQUEST[$name]) ? $_REQUEST[$name] : ''; + } +} + +if (!function_exists('get_nfilter_request_var')) { + function get_nfilter_request_var($name) { + return get_request_var($name); + } +} + +if (!function_exists('get_filter_request_var')) { + function get_filter_request_var($name) { + return get_request_var($name); + } +} + +if (!function_exists('isset_request_var')) { + function isset_request_var($name) { + return isset($_REQUEST[$name]); + } +} + +if (!function_exists('set_request_var')) { + function set_request_var($name, $value) { + $_REQUEST[$name] = $value; + } +} + +if (!function_exists('is_ipaddress')) { + function is_ipaddress($value) { + return filter_var($value, FILTER_VALIDATE_IP) !== false; + } +} + +if (!function_exists('cacti_pton')) { + function cacti_pton($cidr) { + $parts = explode('/', $cidr, 2); + if (count($parts) !== 2 || filter_var($parts[0], FILTER_VALIDATE_IP) === false) { + return array(); + } + $ip = inet_pton($parts[0]); + $bits = (int) $parts[1]; + $max = strlen($ip) * 8; + if ($bits < 0 || $bits > $max) { + return array(); + } + $mask = str_repeat("\xff", intdiv($bits, 8)); + if ($bits % 8) { + $mask .= chr((0xff << (8 - ($bits % 8))) & 0xff); + } + $mask = str_pad($mask, strlen($ip), "\0"); + return array('ip' => $ip, 'subnet' => $mask); + } +} + +if (!function_exists('db_format_index_create')) { + function db_format_index_create($columns) { + return implode(', ', array_map(function ($column) { + return '`' . $column . '`'; + }, (array) $columns)); + } +} + +if (!function_exists('db_get_global_variable')) { + function db_get_global_variable($name) { + return 'Barracuda'; + } +} + +if (!function_exists('form_input_validate')) { + function form_input_validate($value, $name, $regex, $optional, $error) { + return $value; + } +} + +if (!function_exists('is_error_message')) { + function is_error_message() { + return false; + } +} + +if (!function_exists('sql_save')) { + function sql_save($array, $table, $key = 'id') { + return isset($array['id']) ? $array['id'] : 1; + } +} + +if (!defined('CACTI_PATH_BASE')) { + define('CACTI_PATH_BASE', $GLOBALS['config']['base_path']); +} + +if (!defined('POLLER_VERBOSITY_LOW')) { + define('POLLER_VERBOSITY_LOW', 2); +} + +if (!defined('POLLER_VERBOSITY_MEDIUM')) { + define('POLLER_VERBOSITY_MEDIUM', 3); +} + +if (!defined('POLLER_VERBOSITY_DEBUG')) { + define('POLLER_VERBOSITY_DEBUG', 5); +} + +if (!defined('POLLER_VERBOSITY_NONE')) { + define('POLLER_VERBOSITY_NONE', 6); +} + +if (!defined('MESSAGE_LEVEL_ERROR')) { + define('MESSAGE_LEVEL_ERROR', 1); +} + +if (!defined('MESSAGE_LEVEL_INFO')) { + define('MESSAGE_LEVEL_INFO', 0); +} + +if (!function_exists('plugin_test_read_source')) { + function plugin_test_read_source($relative_file) { + $path = realpath(__DIR__ . '/../' . $relative_file); + if ($path === false) { + throw new RuntimeException("Unable to resolve required file: {$relative_file}"); + } + + $contents = file_get_contents($path); + if ($contents === false) { + throw new RuntimeException("Unable to read required file: {$relative_file}"); + } + + return $contents; + } +} + +/** + * Load a plugin source file at global scope. + * + * Some plugin files define data as file-scope variables that the rest of + * the plugin reads as globals, and they read $config while doing so. + * Requiring them from inside a method would make both halves of that + * method-local, so the require happens here and any variable the file + * introduced is published to $GLOBALS. + * + * @param string $path Absolute path to the file. + * + * @return void + */ +function flowview_test_load($path) { + global $config; + + $__before = get_defined_vars(); + + require_once $path; + + foreach (get_defined_vars() as $__name => $__value) { + if (!array_key_exists($__name, $__before) && strncmp($__name, '__', 2) !== 0) { + $GLOBALS[$__name] = $__value; + } + } +} + +require_once dirname(__DIR__) . '/functions.php'; +require_once dirname(__DIR__) . '/database.php';