diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml new file mode 100644 index 0000000..0392c78 --- /dev/null +++ b/.github/workflows/plugin-ci-workflow.yml @@ -0,0 +1,272 @@ +# +-------------------------------------------------------------------------+ +# | 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. | +# | | +# | This program is distributed in the hope that it will be useful, | +# | but WITHOUT ANY WARRANTY; without even the implied warranty of | +# | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | +# | GNU General Public License for more details. | +# +-------------------------------------------------------------------------+ +# | Cacti: The Complete RRDtool-based Graphing Solution | +# +-------------------------------------------------------------------------+ +# | This code is designed, written, and maintained by the Cacti Group. See | +# | about.php and/or the AUTHORS file for specific developer information. | +# +-------------------------------------------------------------------------+ +# | http://www.cacti.net/ | +# +-------------------------------------------------------------------------+ + +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 quicktree Plugin + uses: actions/checkout@v4 + with: + path: cacti/plugins/quicktree + + - name: Install PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: xdebug + 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 rrdtool fping + + - 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 + 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: 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 quicktree Plugin + run: | + cd ${{ github.workspace }}/cacti + sudo php cli/plugin_manage.php --plugin=quicktree --install --enable + + - name: Check PHP Syntax for Plugin + run: | + cd ${{ github.workspace }}/cacti/plugins/quicktree + 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/quicktree/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/quicktree/phpunit.xml \ + --coverage-clover=plugins/quicktree/coverage/clover.xml \ + plugins/quicktree/tests + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-php${{ matrix.php }} + path: ${{ github.workspace }}/cacti/plugins/quicktree/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/* + + - name: Run Linter on base code + if: ${{ env.CACTI != '1.2.x' }} + run: composer run-script lint ${{ github.workspace }}/cacti/plugins/quicktree + 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/quicktree + 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/quicktree + working-directory: ${{ github.workspace }}/cacti + + - name: Re-apply web user ownership before polling + run: | + # Ancestor directories above the checkout (e.g. /home/runner, .../work) + # are typically 750 owned by the runner user, which blocks a non-root + # www-data process from traversing down to the checkout at all. + 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/CHANGELOG.md b/CHANGELOG.md index 53f3462..6e4ffca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ --- develop --- - issue: Quicktree continually registers a realm +- security: Migrate quicktree SQL helpers to prepared statements, normalize location handling, and ensure redirects terminate execution +- test: Migrate test suite to Pest with a Cacti-provided test harness and CI workflow --- 2.0 --- diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..88931d7 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,35 @@ + + + + + + + + + tests + + + + + + + quicktree.php + setup.php + quicktree_security.php + ui_helpers.php + + + + diff --git a/quicktree.php b/quicktree.php index a81a62d..57e696f 100644 --- a/quicktree.php +++ b/quicktree.php @@ -28,6 +28,7 @@ chdir('../../'); include_once('include/auth.php'); include_once('plugins/quicktree/ui_helpers.php'); +include_once('plugins/quicktree/quicktree_security.php'); define('QUICKTREE_BASE_URI', $config['url_path'] . 'plugins/quicktree/'); @@ -46,6 +47,7 @@ set_default_action(); $action = get_request_var('action'); +$location = quicktree_normalize_location(get_nfilter_request_var('location')); $user = $_SESSION['sess_user_id']; /* ================= input validation ================= */ @@ -56,8 +58,6 @@ $action = $code_actions[$drp_action]; } -header('action_3_new: '. $action); - switch ($action) { case 'add': $graph = 0; @@ -139,9 +139,10 @@ case 'add_branch': quicktree_action_form_begin($form_actions[$drp_action]); - $queryrows = db_fetch_assoc("SELECT g.id, g.name + $queryrows = db_fetch_assoc_prepared('SELECT g.id, g.name FROM graph_tree AS g - ORDER BY g.name"); + ORDER BY g.name', + array()); print ''; @@ -190,7 +191,8 @@ case 'clear': $SQL = db_execute_prepared('DELETE FROM quicktree_graphs WHERE userid = ?', array($user)); - header('Location: quicktree.php?header=false&drp_action=&action=&location=' . get_nfilter_request_var('location')); + header('Location: quicktree.php?header=false&drp_action=&action=&location=' . $location); + exit; break; case 'save': @@ -237,7 +239,9 @@ include_once($config['base_path'] . '/lib/api_tree.php'); if (empty($new_tree_id)) { - $seq = db_fetch_cell('SELECT MAX(sequence) FROM graph_tree'); + $seq = db_fetch_cell_prepared('SELECT MAX(sequence) + FROM graph_tree', + array()); if ($seq == NULL || $seq < 0) { $seq = 1; @@ -318,7 +322,8 @@ array($user, $graph)); } - header('Location: quicktree.php?location=' . get_nfilter_request_var('location')); + header('Location: quicktree.php?location=' . $location); + exit; break; case 'add_ajax': @@ -328,13 +333,13 @@ break; default: - if (get_nfilter_request_var('location') == 'console') { + if ($location == 'console') { top_header(); } else { general_header(); } - form_start('quicktree.php?location=' . get_nfilter_request_var('location'), 'quicktree_form'); + form_start('quicktree.php?location=' . $location, 'quicktree_form'); html_start_box(__('QuickTree', 'quicktree'), '100%', true, '3', 'center', ''); print "
@@ -396,7 +401,7 @@ print ''; diff --git a/quicktree_security.php b/quicktree_security.php new file mode 100644 index 0000000..0c9c956 --- /dev/null +++ b/quicktree_security.php @@ -0,0 +1,32 @@ +not->toBeFalse(); + + $contents = file_get_contents($path); + expect($contents)->not->toBeFalse(); + + expect($contents)->not->toContain("header('action_3_new: '. \$action);"); + }); +}); diff --git a/tests/Integration/QuicktreeRedirectSafetyTest.php b/tests/Integration/QuicktreeRedirectSafetyTest.php new file mode 100644 index 0000000..df6bc1f --- /dev/null +++ b/tests/Integration/QuicktreeRedirectSafetyTest.php @@ -0,0 +1,23 @@ +not->toBeFalse(); + + $contents = file_get_contents($path); + expect($contents)->not->toBeFalse(); + + expect($contents)->toContain("include_once('plugins/quicktree/quicktree_security.php');"); + expect($contents)->toContain("\$location = quicktree_normalize_location(get_nfilter_request_var('location'));"); + expect($contents)->toContain("header('Location: quicktree.php?location=' . \$location);"); + expect($contents)->toContain("form_start('quicktree.php?location=' . \$location, 'quicktree_form');"); + }); +}); 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 @@ +toBeTrue( + "File {$relativeFile} does not include auth.php or global.php" + ); + } + }); + + it('validates numeric IDs from request variables before DB queries', function () { + $uiFiles = array( + 'quicktree.php', + ); + + foreach ($uiFiles as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + expect($path)->not->toBeFalse(); + + $contents = file_get_contents($path); + expect($contents)->not->toBeFalse(); + + // 'id' must always be read through the filtered helper, never the raw one + expect(preg_match('/get_request_var\s*\(\s*[\'\"]id[\'\"]/', $contents))->toBe(0, + "File {$relativeFile} reads 'id' via get_request_var() without validation" + ); + + expect(preg_match('/get_filter_request_var\s*\(\s*[\'\"]id[\'\"]/', $contents))->toBeGreaterThan(0, + "File {$relativeFile} does not validate 'id' via get_filter_request_var()" + ); + } + }); +}); diff --git a/tests/Security/OutputEscapingTest.php b/tests/Security/OutputEscapingTest.php new file mode 100644 index 0000000..59f0ca8 --- /dev/null +++ b/tests/Security/OutputEscapingTest.php @@ -0,0 +1,68 @@ +toBe(0, + "File {$relativeFile} has unescaped variables in HTML attributes" + ); + } + }); + + it('uses html_escape or __esc for user-controlled output', function () { + $uiFiles = array( + 'quicktree.php', + ); + + $totalEscapeCalls = 0; + + foreach ($uiFiles as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + if ($path === false) continue; + $contents = file_get_contents($path); + if ($contents === false) continue; + + $totalEscapeCalls += preg_match_all('/html_escape|__esc\(|htmlspecialchars/', $contents); + } + + // At least some escaping should be present in UI files + expect($totalEscapeCalls)->toBeGreaterThan(0, + 'UI files should contain at least one html_escape/__esc call' + ); + }); +}); diff --git a/tests/Security/Php74CompatibilityTest.php b/tests/Security/Php74CompatibilityTest.php new file mode 100644 index 0000000..fb31227 --- /dev/null +++ b/tests/Security/Php74CompatibilityTest.php @@ -0,0 +1,91 @@ +toBe(0, "{$f} uses str_contains"); + } + }); + + it('does not use str_starts_with (PHP 8.0)', function () use ($files) { + foreach ($files as $f) { + $p = realpath(__DIR__ . '/../../' . $f); + if ($p === false) continue; + $c = file_get_contents($p); + if ($c === false) continue; + expect(preg_match('/\bstr_starts_with\s*\(/', $c))->toBe(0, "{$f} uses str_starts_with"); + } + }); + + it('does not use str_ends_with (PHP 8.0)', function () use ($files) { + foreach ($files as $f) { + $p = realpath(__DIR__ . '/../../' . $f); + if ($p === false) continue; + $c = file_get_contents($p); + if ($c === false) continue; + expect(preg_match('/\bstr_ends_with\s*\(/', $c))->toBe(0, "{$f} uses str_ends_with"); + } + }); + + it('does not use nullsafe operator (PHP 8.0)', function () use ($files) { + foreach ($files as $f) { + $p = realpath(__DIR__ . '/../../' . $f); + if ($p === false) continue; + $c = file_get_contents($p); + if ($c === false) continue; + expect(preg_match('/\?->/', $c))->toBe(0, "{$f} uses nullsafe operator"); + } + }); + + it('does not use match expression (PHP 8.0)', function () use ($files) { + foreach ($files as $f) { + $p = realpath(__DIR__ . '/../../' . $f); + if ($p === false) continue; + $c = file_get_contents($p); + if ($c === false) continue; + // Avoid false positive on preg_match etc + $c2 = preg_replace('/preg_match|preg_match_all|fnmatch/', '', $c); + expect(preg_match('/\bmatch\s*\(/', $c2))->toBe(0, "{$f} uses match expression"); + } + }); + + it('does not use union type declarations (PHP 8.0)', function () use ($files) { + foreach ($files as $f) { + $p = realpath(__DIR__ . '/../../' . $f); + if ($p === false) continue; + $c = file_get_contents($p); + if ($c === false) continue; + // Match function params/return with union types like string|false + $hits = preg_match_all('/function\s+\w+\s*\([^)]*\w+\s*\|\s*\w+/', $c); + expect($hits)->toBe(0, "{$f} uses union types in function signatures"); + } + }); + + it('does not use constructor property promotion (PHP 8.0)', function () use ($files) { + foreach ($files as $f) { + $p = realpath(__DIR__ . '/../../' . $f); + if ($p === false) continue; + $c = file_get_contents($p); + if ($c === false) continue; + expect(preg_match('/function\s+__construct\s*\([^)]*\b(public|private|protected|readonly)\s/', $c))->toBe(0, + "{$f} uses constructor promotion" + ); + } + }); +}); diff --git a/tests/Security/PreparedStatementConsistencyTest.php b/tests/Security/PreparedStatementConsistencyTest.php new file mode 100644 index 0000000..087fa91 --- /dev/null +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -0,0 +1,77 @@ +toBe(0, "File {$relativeFile} contains raw DB calls"); + } + }); + + it('uses parameterized placeholders not string interpolation in SQL', function () { + $targetFiles = array( + 'quicktree.php', + 'setup.php', + ); + + foreach ($targetFiles as $relativeFile) { + $path = realpath(__DIR__ . '/../../' . $relativeFile); + if ($path === false) continue; + $contents = file_get_contents($path); + if ($contents === false) continue; + + $lines = explode("\n", $contents); + $interpolatedSql = 0; + + foreach ($lines as $num => $line) { + $trimmed = ltrim($line); + if (strpos($trimmed, '//') === 0 || strpos($trimmed, '*') === 0) continue; + + if (preg_match('/_prepared\s*\(/', $line)) { + // Bound params passed via array($var) are legitimate, not + // interpolation, so only inspect the SQL literal before it. + $sqlPortion = preg_split('/,\s*array\s*\(/', $line, 2)[0]; + + if (preg_match('/\$[a-zA-Z_]/', $sqlPortion) + && preg_match('/(?:SELECT|INSERT|UPDATE|DELETE|WHERE|SET|FROM|JOIN).*\$/', $sqlPortion)) { + $interpolatedSql++; + } + } + } + + expect($interpolatedSql)->toBe(0, + "File {$relativeFile} may have SQL interpolation in prepared calls" + ); + } + }); +}); diff --git a/tests/Security/PreparedStatementsMigrationTest.php b/tests/Security/PreparedStatementsMigrationTest.php new file mode 100644 index 0000000..5e3ade6 --- /dev/null +++ b/tests/Security/PreparedStatementsMigrationTest.php @@ -0,0 +1,60 @@ +not->toBeFalse(); + expect($setup_contents)->not->toBeFalse(); + }); + + it('uses prepared graph tree list query', function () use ($quicktree_contents) { + expect(preg_match('/db_fetch_assoc_prepared\s*\(/', $quicktree_contents))->toBe(1); + expect(preg_match('/\bgraph_tree\b/', $quicktree_contents))->toBe(1); + }); + + it('uses prepared max sequence query', function () use ($quicktree_contents) { + expect(preg_match('/db_fetch_cell_prepared\s*\(/', $quicktree_contents))->toBe(1); + expect(preg_match('/MAX\s*\(\s*sequence\s*\)/i', $quicktree_contents))->toBe(1); + expect(preg_match('/\bgraph_tree\b/', $quicktree_contents))->toBe(1); + }); + + it('save flow uses prepared queue fetch and cleanup', function () use ($quicktree_contents) { + expect(preg_match('/\bdb_fetch_assoc_prepared\s*\(/', $quicktree_contents))->toBe(1); + expect(preg_match('/\bFROM\s+quicktree_graphs\b/i', $quicktree_contents))->toBe(1); + expect(preg_match('/\bdb_execute_prepared\s*\(/', $quicktree_contents))->toBe(1); + expect(preg_match('/\bDELETE\s+FROM\s+quicktree_graphs\b/i', $quicktree_contents))->toBe(1); + expect(preg_match('/\buserid\s*=\s*\?/', $quicktree_contents))->toBe(1); + }); + + it('existing-tree branch lookup is parameterized', function () use ($quicktree_contents) { + expect(preg_match('/\bdb_fetch_cell_prepared\s*\(/', $quicktree_contents))->toBe(1); + expect(preg_match('/\bFROM\s+graph_tree_items\b/i', $quicktree_contents))->toBe(1); + expect(preg_match('/\bgraph_tree_id\s*=\s*\?/', $quicktree_contents))->toBe(1); + expect(preg_match('/\btitle\s*=\s*\?/', $quicktree_contents))->toBe(1); + expect(preg_match('/\bLIMIT\s+1\b/i', $quicktree_contents))->toBe(1); + }); + + it('has no raw db_fetch_assoc calls', function () use ($quicktree_contents) { + expect(preg_match('/\bdb_fetch_assoc\s*\(/', $quicktree_contents))->toBe(0); + }); + + it('setup.php uses prepared plugin version lookup', function () use ($setup_contents) { + expect(preg_match('/db_fetch_cell_prepared\s*\(/', $setup_contents))->toBe(1); + expect(preg_match('/\bplugin_config\b/', $setup_contents))->toBe(1); + expect(preg_match('/\bdirectory\s*=\s*\?/', $setup_contents))->toBe(1); + }); + + it('setup.php has no raw db_fetch_cell calls', function () use ($setup_contents) { + expect(preg_match('/\bdb_fetch_cell\s*\(/', $setup_contents))->toBe(0); + }); +}); diff --git a/tests/Security/RedirectSafetyTest.php b/tests/Security/RedirectSafetyTest.php new file mode 100644 index 0000000..977682e --- /dev/null +++ b/tests/Security/RedirectSafetyTest.php @@ -0,0 +1,50 @@ +toBe(0, + "File {$relativeFile} has header(Location) without exit/die" + ); + } + }); +}); diff --git a/tests/Security/SetupStructureTest.php b/tests/Security/SetupStructureTest.php new file mode 100644 index 0000000..426db6b --- /dev/null +++ b/tests/Security/SetupStructureTest.php @@ -0,0 +1,37 @@ +toContain('function plugin_quicktree_install'); + }); + + it('defines plugin_quicktree_version function', function () use ($source) { + expect($source)->toContain('function plugin_quicktree_version'); + }); + + it('defines plugin_quicktree_uninstall function', function () use ($source) { + expect($source)->toContain('function plugin_quicktree_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'); + }); + + it('registers hooks in install function', function () use ($source) { + expect($source)->toContain('api_plugin_register_hook'); + }); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..4395961 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,44 @@ +toBe(array( + 'top_header', + 'form_start:quicktree.php:quicktree_form', + 'html_start_box:Tree Action', + 'html_end_box', + 'form_end', + )); + }); + + it('begin/end wrapper omits top header when header var is set', function () { + $GLOBALS['__form_wrapper_events'] = array(); + $GLOBALS['__test_request_vars']['header'] = 'false'; + + quicktree_action_form_begin('Branch Action'); + quicktree_action_form_end(); + + expect($GLOBALS['__form_wrapper_events'])->toBe(array( + 'form_start:quicktree.php:quicktree_form', + 'html_start_box:Branch Action', + 'html_end_box', + 'form_end', + )); + }); + + it('quicktree.php includes ui helper file', function () { + $source = file_get_contents(realpath(__DIR__ . '/../../quicktree.php')); + + expect(preg_match('/(?:include_once|require_once)\s*\(\s*[\'"]plugins\/quicktree\/ui_helpers\.php[\'"]\s*\)\s*;/', $source))->toBe(1); + }); + + it('quicktree.php uses begin wrapper in add-tree and add-branch', function () { + $source = file_get_contents(realpath(__DIR__ . '/../../quicktree.php')); + + expect(preg_match_all('/quicktree_action_form_begin\s*\(/', $source))->toBeGreaterThanOrEqual(2); + }); + + it('quicktree.php uses end wrapper in add-tree and add-branch', function () { + $source = file_get_contents(realpath(__DIR__ . '/../../quicktree.php')); + + expect(preg_match_all('/quicktree_action_form_end\s*\(\s*\)\s*;/', $source))->toBeGreaterThanOrEqual(2); + }); +}); diff --git a/tests/Unit/LocationNormalizationTest.php b/tests/Unit/LocationNormalizationTest.php new file mode 100644 index 0000000..7a0d175 --- /dev/null +++ b/tests/Unit/LocationNormalizationTest.php @@ -0,0 +1,19 @@ +toBe('console'); + expect(quicktree_normalize_location('tab'))->toBe('tab'); + expect(quicktree_normalize_location('console%0d%0aX-Test:1'))->toBe('tab'); + expect(quicktree_normalize_location('../../evil'))->toBe('tab'); + }); +}); diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php new file mode 100644 index 0000000..96d27a0 --- /dev/null +++ b/tests/bootstrap-unit.php @@ -0,0 +1,346 @@ + $cacti_root, + 'url_path' => '/cacti/', + 'cacti_version' => $cacti_version, + 'cacti_server_os' => 'unix', +); + +$GLOBALS['__test_db_calls'] = array(); + +if (!function_exists('db_execute')) { + function db_execute($sql) { + $GLOBALS['__test_db_calls'][] = array('fn' => 'db_execute', 'sql' => $sql, 'params' => array()); + return true; + } +} + +if (!function_exists('db_execute_prepared')) { + function db_execute_prepared($sql, $params = array()) { + $GLOBALS['__test_db_calls'][] = array('fn' => 'db_execute_prepared', 'sql' => $sql, 'params' => $params); + return true; + } +} + +if (!function_exists('db_fetch_assoc')) { + function db_fetch_assoc($sql) { + return array(); + } +} + +if (!function_exists('db_fetch_assoc_prepared')) { + function db_fetch_assoc_prepared($sql, $params = array()) { + return array(); + } +} + +if (!function_exists('db_fetch_row')) { + function db_fetch_row($sql) { + return array(); + } +} + +if (!function_exists('db_fetch_row_prepared')) { + function db_fetch_row_prepared($sql, $params = array()) { + return array(); + } +} + +if (!function_exists('db_fetch_cell')) { + function db_fetch_cell($sql) { + return ''; + } +} + +if (!function_exists('db_fetch_cell_prepared')) { + function db_fetch_cell_prepared($sql, $params = array()) { + return ''; + } +} + +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 ''; + } +} + +if (!function_exists('set_config_option')) { + function set_config_option($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, $domain = '') { + 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) { + } +} + +if (!function_exists('get_request_var')) { + function get_request_var($name) { + return ''; + } +} + +if (!function_exists('get_nfilter_request_var')) { + function get_nfilter_request_var($name) { + return isset($GLOBALS['__test_request_vars'][$name]) ? $GLOBALS['__test_request_vars'][$name] : ''; + } +} + +if (!function_exists('get_filter_request_var')) { + function get_filter_request_var($name) { + return ''; + } +} + +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 (!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 evidence_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; + } + } +} + +/* + * quicktree's ui_helpers.php calls these Cacti UI functions directly. They + * live here, in the single bootstrap entry point, rather than in individual + * specs, so a spec can never race another spec's own guarded declaration. + */ +if (!function_exists('top_header')) { + function top_header() { + $GLOBALS['__form_wrapper_events'][] = 'top_header'; + } +} + +if (!function_exists('form_start')) { + function form_start($action, $form) { + $GLOBALS['__form_wrapper_events'][] = "form_start:$action:$form"; + } +} + +if (!function_exists('html_start_box')) { + function html_start_box($title) { + $GLOBALS['__form_wrapper_events'][] = "html_start_box:$title"; + } +} + +if (!function_exists('html_end_box')) { + function html_end_box() { + $GLOBALS['__form_wrapper_events'][] = 'html_end_box'; + } +} + +if (!function_exists('form_end')) { + function form_end() { + $GLOBALS['__form_wrapper_events'][] = 'form_end'; + } +} diff --git a/tests/test_form_wrapper.php b/tests/test_form_wrapper.php deleted file mode 100644 index f1c7266..0000000 --- a/tests/test_form_wrapper.php +++ /dev/null @@ -1,103 +0,0 @@ - null); -quicktree_action_form_begin('Tree Action'); -quicktree_action_form_end(); - -assert_true( - 'begin/end wrapper includes top header when header var is null', - $events === array('top_header', 'form_start:quicktree.php:quicktree_form', 'html_start_box:Tree Action', 'html_end_box', 'form_end') -); - -$events = array(); -$request_vars = array('header' => 'false'); -quicktree_action_form_begin('Branch Action'); -quicktree_action_form_end(); - -assert_true( - 'begin/end wrapper omits top header when header var is set', - $events === array('form_start:quicktree.php:quicktree_form', 'html_start_box:Branch Action', 'html_end_box', 'form_end') -); - -$source = file_get_contents(__DIR__ . '/../quicktree.php'); -assert_true('quicktree.php is readable', $source !== false); -$source = ($source === false ? '' : $source); - -assert_true( - 'quicktree.php includes ui helper file', - preg_match('/(?:include_once|require_once)\s*\(\s*[\'"]plugins\/quicktree\/ui_helpers\.php[\'"]\s*\)\s*;/', $source) === 1 -); -assert_true( - 'quicktree.php uses begin wrapper in add-tree and add-branch', - preg_match_all('/quicktree_action_form_begin\s*\(/', $source, $begin_matches) >= 2 -); -assert_true( - 'quicktree.php uses end wrapper in add-tree and add-branch', - preg_match_all('/quicktree_action_form_end\s*\(\s*\)\s*;/', $source, $end_matches) >= 2 -); - -echo "\n"; -echo "Results: $pass passed, $fail failed\n"; - -exit($fail > 0 ? 1 : 0);
' . $graph_title; - print '  '; print '