diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..27751fd1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git +.phpunit.result.cache +vendor diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index e0023163..8234abb2 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -25,6 +25,16 @@ permissions: contents: read jobs: + runtime-coverage: + name: Docker tests and runtime coverage + runs-on: ubuntu-latest + steps: + - name: Checkout plugin + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run tests and enforce 100% runtime coverage in PHP 8.3 + run: tests/docker/run-tests.sh + lint: name: PHP Lint (PHP ${{ matrix.php }}) runs-on: ubuntu-latest diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 12ae5e0d..da974990 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -32,6 +32,28 @@ on: - develop jobs: + production-path: + name: Production scanner path + runs-on: ubuntu-latest + steps: + - name: Checkout Cacti develop + uses: actions/checkout@v7 + with: + repository: Cacti/cacti + ref: develop + path: cacti + + - name: Checkout mactrack plugin + uses: actions/checkout@v7 + with: + path: cacti/plugins/mactrack + + - name: Run installed Cacti, MariaDB, SNMP, scanner, and poller + env: + CACTI_SOURCE: ${{ github.workspace }}/cacti + MACTRACK_SOURCE: ${{ github.workspace }}/cacti/plugins/mactrack + run: cacti/plugins/mactrack/tests/e2e/run-mactrack-e2e.sh + integration-test: runs-on: ${{ matrix.os }} @@ -107,16 +129,14 @@ jobs: 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 + run: | + mysql --defaults-file="$HOME/.my.cnf" -e 'CREATE DATABASE IF NOT EXISTS cacti;' + mysql --defaults-file="$HOME/.my.cnf" -e "CREATE USER IF NOT EXISTS 'cactiuser'@'localhost' IDENTIFIED BY 'cactiuser';" + mysql --defaults-file="$HOME/.my.cnf" -e "GRANT ALL PRIVILEGES ON cacti.* TO 'cactiuser'@'localhost';" + mysql --defaults-file="$HOME/.my.cnf" -e "GRANT SELECT ON mysql.time_zone_name TO 'cactiuser'@'localhost';" + mysql --defaults-file="$HOME/.my.cnf" -e "FLUSH PRIVILEGES;" + mysql --defaults-file="$HOME/.my.cnf" cacti < "${{ github.workspace }}/cacti/cacti.sql" + mysql --defaults-file="$HOME/.my.cnf" -e "INSERT INTO settings (name, value) VALUES ('path_php_binary', '/usr/bin/php')" cacti - name: Validate composer files run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index ab1009c2..ecd54ba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ --- develop --- +* issue#343: Fail closed when Composer dependencies are missing and test the production scanner path against live SNMP * compat: Replace vendored Net_DNS2 with Composer-managed mikepultz/netdns2 1.5, retaining the established resolver API * issue: Harden device-type vendor filtering against SQL injection * issue: Validate aggregated MAC bulk-action IDs and use prepared deletion SQL diff --git a/composer.json b/composer.json index 0bd410bb..e9a67894 100644 --- a/composer.json +++ b/composer.json @@ -3,6 +3,11 @@ "description": "Cacti Mactrack plugin test tooling", "type": "cacti-plugin", "license": "GPL-2.0-or-later", + "autoload": { + "psr-4": { + "Cacti\\Mactrack\\": "src/" + } + }, "require-dev": { "pestphp/pest": "^1.23", "phpunit/phpunit": "^9.6", @@ -14,6 +19,7 @@ "test:integration": "pest tests/Pest/Integration", "test:e2e:static": "pest tests/Pest/E2E", "test:coverage": "pest --coverage", + "test:coverage:runtime": "pest --configuration=phpunit.runtime.xml --coverage --min=100", "analyse": "psalm --no-progress" }, "config": { diff --git a/mactrack_resolver.php b/mactrack_resolver.php index ed344219..0f19f703 100644 --- a/mactrack_resolver.php +++ b/mactrack_resolver.php @@ -42,10 +42,17 @@ include('../../include/cli_check.php'); include_once($config['base_path'] . '/plugins/mactrack/lib/mactrack_functions.php'); -require_once $config['base_path'] . '/plugins/mactrack/vendor/autoload.php'; +require_once $config['base_path'] . '/plugins/mactrack/src/Runtime/DependencyBootstrap.php'; -if (!class_exists('Net_DNS2_Resolver')) { - fwrite(STDERR, "Mactrack DNS dependency is unavailable. Run composer install --no-dev in the plugin directory.\n"); +$dependenciesLoaded = \Cacti\Mactrack\Runtime\DependencyBootstrap::load( + $config['base_path'] . '/plugins/mactrack/vendor/autoload.php', + ['Net_DNS2_Resolver'], + static function (string $message): void { + fwrite(STDERR, $message . "\n"); + } +); + +if (!$dependenciesLoaded) { exit(1); } diff --git a/phpunit.runtime.xml b/phpunit.runtime.xml new file mode 100644 index 00000000..e8617f8e --- /dev/null +++ b/phpunit.runtime.xml @@ -0,0 +1,13 @@ + + + + + tests/Pest/Unit/Runtime + + + + + src/Runtime + + + diff --git a/setup.php b/setup.php index f31cca5a..6318a532 100644 --- a/setup.php +++ b/setup.php @@ -62,18 +62,17 @@ function plugin_mactrack_version() { function plugin_mactrack_check_config() { global $config; - $autoload = $config['base_path'] . '/plugins/mactrack/vendor/autoload.php'; + require_once $config['base_path'] . '/plugins/mactrack/src/Runtime/DependencyBootstrap.php'; - if (!is_file($autoload)) { - cacti_log('ERROR: Mactrack requires Composer dependencies. Run composer install --no-dev in plugins/mactrack before enabling the plugin.', false, 'MACTRACK'); - - return false; - } - - require_once $autoload; + $loaded = \Cacti\Mactrack\Runtime\DependencyBootstrap::load( + $config['base_path'] . '/plugins/mactrack/vendor/autoload.php', + ['Net_DNS2_Resolver'], + static function (string $message): void { + cacti_log('ERROR: ' . $message, false, 'MACTRACK'); + } + ); - if (!class_exists('Net_DNS2_Resolver')) { - cacti_log('ERROR: Mactrack DNS dependency is unavailable. Run composer install --no-dev in plugins/mactrack before enabling the plugin.', false, 'MACTRACK'); + if (!$loaded) { return false; } diff --git a/src/Runtime/DependencyBootstrap.php b/src/Runtime/DependencyBootstrap.php new file mode 100644 index 00000000..f1eb270b --- /dev/null +++ b/src/Runtime/DependencyBootstrap.php @@ -0,0 +1,46 @@ + $requiredClasses + * @param null|callable(string):void $reporter + */ + public static function load( + string $autoloadPath, + array $requiredClasses = ['Net_DNS2_Resolver'], + ?callable $reporter = null + ): bool { + $reporter ??= static function (string $_message): void { + }; + + if (!is_file($autoloadPath)) { + $reporter('Mactrack requires Composer dependencies. Run composer install --no-dev in the plugin directory.'); + + return false; + } + + try { + require_once $autoloadPath; + } catch (Throwable $error) { + $reporter('Mactrack could not load Composer dependencies: ' . $error->getMessage()); + + return false; + } + + foreach ($requiredClasses as $requiredClass) { + if (!class_exists($requiredClass)) { + $reporter('Mactrack Composer dependency is unavailable: ' . $requiredClass . '. Run composer install --no-dev in the plugin directory.'); + + return false; + } + } + + return true; + } +} diff --git a/tests/Pest/Unit/Runtime/DependencyBootstrapTest.php b/tests/Pest/Unit/Runtime/DependencyBootstrapTest.php new file mode 100644 index 00000000..aa272fc1 --- /dev/null +++ b/tests/Pest/Unit/Runtime/DependencyBootstrapTest.php @@ -0,0 +1,75 @@ +toBeFalse() + ->and($messages)->toBe([ + 'Mactrack requires Composer dependencies. Run composer install --no-dev in the plugin directory.', + ]); +}); + +it('fails quietly when no reporter is supplied', function (): void { + expect(DependencyBootstrap::load( + __DIR__ . '/fixtures/does-not-exist.php', + ['MactrackMissingDependency'] + ))->toBeFalse(); +}); + +it('reports an autoloader failure', function (): void { + $messages = []; + + $loaded = DependencyBootstrap::load( + __DIR__ . '/fixtures/throwing-autoload.php', + ['MactrackThrowingDependency'], + static function (string $message) use (&$messages): void { + $messages[] = $message; + } + ); + + expect($loaded)->toBeFalse() + ->and($messages)->toBe(['Mactrack could not load Composer dependencies: fixture failure']); +}); + +it('reports an unavailable required class', function (): void { + $messages = []; + + $loaded = DependencyBootstrap::load( + __DIR__ . '/fixtures/empty-autoload.php', + ['MactrackUnavailableDependency'], + static function (string $message) use (&$messages): void { + $messages[] = $message; + } + ); + + expect($loaded)->toBeFalse() + ->and($messages)->toBe([ + 'Mactrack Composer dependency is unavailable: MactrackUnavailableDependency. Run composer install --no-dev in the plugin directory.', + ]); +}); + +it('loads every required dependency', function (): void { + $loaded = DependencyBootstrap::load( + __DIR__ . '/fixtures/working-autoload.php', + ['MactrackFixtureDependency'] + ); + + expect($loaded)->toBeTrue() + ->and(class_exists('MactrackFixtureDependency'))->toBeTrue(); +}); diff --git a/tests/Pest/Unit/Runtime/fixtures/empty-autoload.php b/tests/Pest/Unit/Runtime/fixtures/empty-autoload.php new file mode 100644 index 00000000..174d7fd7 --- /dev/null +++ b/tests/Pest/Unit/Runtime/fixtures/empty-autoload.php @@ -0,0 +1,3 @@ + /usr/local/etc/php/conf.d/mactrack-e2e.ini + +COPY --from=composer /usr/bin/composer /usr/local/bin/composer diff --git a/tests/e2e/bootstrap-mactrack.sh b/tests/e2e/bootstrap-mactrack.sh index f764d059..e6ae797f 100755 --- a/tests/e2e/bootstrap-mactrack.sh +++ b/tests/e2e/bootstrap-mactrack.sh @@ -17,16 +17,57 @@ sed -i \ -e "s/\$database_password *=.*/\$database_password = 'mactrack-test';/" \ "$CACTI_PATH/include/config.php" +composer install \ + --working-dir="$CACTI_PATH" \ + --no-dev \ + --prefer-dist \ + --no-progress \ + --no-interaction +composer install \ + --working-dir="$CACTI_PATH/plugins/mactrack" \ + --no-dev \ + --prefer-dist \ + --no-progress \ + --no-interaction test -f "$CACTI_PATH/plugins/mactrack/vendor/autoload.php" -# The plugin lifecycle does not need Cacti's optional device-template imports. -# Explicitly skip them to keep this disposable install focused and fast enough -# for CI while preserving the normal core install and plugin-management paths. -template_args=() -for template in "$CACTI_PATH"/install/templates/*.xml.gz; do - template_args+=("--template=$(basename "$template"):0") -done - -php "$CACTI_PATH/cli/install_cacti.php" --accept-eula --install --force "${template_args[@]}" +php "$CACTI_PATH/cli/install_cacti.php" --accept-eula --install --force php "$CACTI_PATH/cli/plugin_manage.php" --plugin=mactrack --install --enable --allperms php "$CACTI_PATH/plugins/mactrack/tests/e2e/mactrack_smoke.php" + +# Prove the installed plugin can reach a real SNMP agent through the same +# scanner entry point launched by the Mactrack poller in production. +snmpget -v2c -c public -On snmp-agent .1.3.6.1.2.1.1.2.0 +device_id="$(php "$CACTI_PATH/plugins/mactrack/tests/e2e/mactrack_production_probe.php" seed)" +php "$CACTI_PATH/plugins/mactrack/mactrack_scanner.php" "-id=$device_id" --debug -t +php "$CACTI_PATH/plugins/mactrack/tests/e2e/mactrack_production_probe.php" assert "$device_id" + +# The master poller must launch the scanner successfully too; this checks the +# actual scheduler-to-worker boundary rather than a test-only function call. +php "$CACTI_PATH/plugins/mactrack/poller_mactrack.php" -sid=1 --force --debug +php "$CACTI_PATH/plugins/mactrack/tests/e2e/mactrack_production_probe.php" assert "$device_id" + +# Exercise the resolver entry point from an installed-tree copy without +# Composer output. It must fail closed with an actionable error, never fatal. +missing_tree=/tmp/cacti-mactrack-missing-dependencies +cp -a "$CACTI_PATH" "$missing_tree" +rm -rf "$missing_tree/plugins/mactrack/vendor" +set +e +resolver_output="$(cd "$missing_tree/plugins/mactrack" && php mactrack_resolver.php 2>&1)" +resolver_status=$? +set -e + +if [ "$resolver_status" -ne 1 ]; then + printf 'Resolver returned %s instead of 1 without Composer dependencies\n%s\n' "$resolver_status" "$resolver_output" >&2 + exit 1 +fi + +if ! grep -q 'requires Composer dependencies' <<<"$resolver_output"; then + printf 'Resolver did not emit the dependency remediation message\n%s\n' "$resolver_output" >&2 + exit 1 +fi + +if grep -q 'Fatal error' <<<"$resolver_output"; then + printf 'Resolver fatally crashed without Composer dependencies\n%s\n' "$resolver_output" >&2 + exit 1 +fi diff --git a/tests/e2e/docker-compose.yml b/tests/e2e/docker-compose.yml index 50838280..b72593bd 100644 --- a/tests/e2e/docker-compose.yml +++ b/tests/e2e/docker-compose.yml @@ -40,6 +40,21 @@ services: depends_on: db: condition: service_healthy + snmp-agent: + condition: service_healthy + + snmp-agent: + build: + context: ${CACTI_SOURCE:?Set CACTI_SOURCE to a Cacti checkout} + dockerfile: ${MACTRACK_SOURCE:?Set MACTRACK_SOURCE to this plugin checkout}/tests/e2e/Dockerfile + command: ["snmpd", "-f", "-Lo", "-C", "-c", "/etc/snmp/mactrack-snmpd.conf"] + volumes: + - ./snmpd.conf:/etc/snmp/mactrack-snmpd.conf:ro + healthcheck: + test: ["CMD", "snmpget", "-v2c", "-c", "public", "-On", "localhost", ".1.3.6.1.2.1.1.2.0"] + interval: 2s + timeout: 2s + retries: 15 volumes: mactrack_cache: diff --git a/tests/e2e/mactrack_production_probe.php b/tests/e2e/mactrack_production_probe.php new file mode 100644 index 00000000..f395556e --- /dev/null +++ b/tests/e2e/mactrack_production_probe.php @@ -0,0 +1,71 @@ + 0 + && ($device['last_rundate'] ?? '0000-00-00 00:00:00') !== '0000-00-00 00:00:00'; + + if (!$passed) { + fwrite(STDERR, 'Production scanner assertion failed: ' . json_encode($device, JSON_THROW_ON_ERROR) . "\n"); + exit(1); + } + + print "Mactrack production scanner passed against the SNMP agent\n"; + exit(0); +} + +fwrite(STDERR, "Usage: mactrack_production_probe.php seed|assert [device-id]\n"); +exit(2); diff --git a/tests/e2e/snmpd.conf b/tests/e2e/snmpd.conf new file mode 100644 index 00000000..5b87f733 --- /dev/null +++ b/tests/e2e/snmpd.conf @@ -0,0 +1,7 @@ +agentaddress udp:161 +view all included .1 +rocommunity public default -V all +sysLocation Mactrack production-path fixture +sysContact Cacti CI +sysName mactrack-snmp-agent +dontLogTCPWrappersConnects yes