diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000..fd6a2e2
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,391 @@
+# GitHub Copilot Instructions
+
+## Priority Guidelines
+
+When generating code for this repository:
+
+1. **Version Compatibility**: This is a Cacti plugin (`evidence`, version 0.3) targeting Cacti 1.2.x
+2. **Context Files**: Prioritize patterns and standards defined in this file (`.github/copilot-instructions.md`)
+3. **Codebase Patterns**: When context files don't provide specific guidance, scan the codebase for established patterns
+4. **Architectural Consistency**: Maintain plugin-based architecture extending Cacti core
+5. **Code Quality**: Prioritize security, maintainability, and compatibility in all generated code
+
+## Technology Stack
+
+### Core Technologies
+- **PHP**: Minimum PHP 8.1
+- **Platform**: Cacti Plugin Architecture (Cacti 1.2.x)
+- **Database**: MySQL/MariaDB with InnoDB engine
+- **SNMP**: Cacti's SNMP library (`lib/snmp.php`, `cacti_snmp_get()`/`cacti_snmp_walk()`) for device polling
+
+### Key Dependencies
+- Cacti core framework (functions like `api_plugin_*`, `db_*`, `cacti_snmp_*`)
+- PHP extensions: `snmp`, `mysqli`
+- Optional: `gettext` for internationalization
+
+## Project Structure
+
+```
+evidence/ # Repository root (install to plugins/evidence/ in Cacti)
+├── include/
+│ ├── functions.php # Core polling, display and hook logic
+│ ├── database.php # Table creation and upgrade logic
+│ ├── settings.php # Plugin config_settings hook
+│ ├── arrays.php # Configuration arrays (entities, datatypes)
+│ └── index.php # Access protection
+├── data/ # SQL seed data (enterprise-numbers.sql) and prep scripts
+├── images/ # Tab icons and UI images
+├── evidence.php # Main standalone/console page
+├── evidence_tab.php # Device tab integration page
+├── evidence.js # Client-side JS for device edit page
+├── poller_evidence.php # Background poller entry point (CLI)
+├── setup.php # Plugin install/uninstall/upgrade hooks
+├── INFO # Plugin metadata (name, version, compat)
+├── README.md # Feature overview, installation and usage
+└── CHANGELOG.md # Version history
+```
+
+## Naming Conventions
+
+### Function Names
+
+#### Plugin Hook Functions
+Functions that integrate with Cacti's plugin system MUST be prefixed with `plugin_evidence_`:
+
+```php
+function plugin_evidence_install() { }
+function plugin_evidence_poller_bottom() { }
+function plugin_evidence_config_settings() { }
+function plugin_evidence_device_remove($device_id) { }
+```
+
+#### Internal/Display Functions
+Other functions MUST be prefixed with `evidence_`:
+
+```php
+function evidence_show_tab() { }
+function evidence_show_host_info($data, $host_id) { }
+```
+
+**IMPORTANT**: Match the existing prefix used by the function you are editing; do not introduce a third naming scheme.
+
+### Database Tables
+All database tables MUST be prefixed with `plugin_evidence_`:
+
+```
+plugin_evidence_organization
+plugin_evidence_snmp_info
+plugin_evidence_specific_query
+plugin_evidence_entity
+plugin_evidence_mac
+plugin_evidence_ip
+plugin_evidence_vendor_specific
+```
+
+### Variables and Constants
+- Use snake_case for variables: `$host_id`, `$evidence_records`, `$snmp_info`
+- Global configuration arrays use descriptive names: `$entities`, `$datatypes`
+
+## Code Style
+
+### Indentation and Formatting
+- **Tabs**: Use tabs (not spaces) for indentation throughout all PHP files
+- **Braces**: Opening brace on same line for functions and control structures
+- **Spacing**: Space after control structure keywords (`if`, `foreach`, `while`)
+
+```php
+function evidence_example($param) {
+ if ($param > 0) {
+ foreach ($items as $item) {
+ // code here
+ }
+ }
+}
+```
+
+### File Headers
+ALL PHP files MUST include the standard GPL v2 license header used throughout this repository:
+
+```php
+ 'host_id', 'type' => 'int(11)', 'NULL' => false);
+$data['columns'][] = array('name' => 'sysdescr', 'type' => 'varchar(255)', 'default' => null);
+$data['type'] = 'InnoDB';
+$data['comment'] = 'evidence snmp info';
+
+api_plugin_db_table_create('evidence', 'plugin_evidence_snmp_info', $data);
+```
+
+### Upgrade Handling
+Version-gate schema changes in `plugin_evidence_upgrade_database()` (`include/database.php`) using `cacti_version_compare()`, and always update the stored version at the end:
+
+```php
+function plugin_evidence_upgrade_database() {
+ global $config;
+
+ $info = parse_ini_file($config['base_path'] . '/plugins/evidence/INFO', true);
+ $info = $info['info'];
+ $current = $info['version'];
+ $oldv = db_fetch_cell('SELECT version FROM plugin_config WHERE directory = "evidence"');
+
+ if (!cacti_version_compare($oldv, $current, '=')) {
+ if (cacti_version_compare($oldv, '0.3', '<')) {
+ // create/alter tables here
+ }
+
+ db_execute_prepared("UPDATE plugin_config
+ SET version = ?, author = ?, webpage = ?
+ WHERE directory = 'evidence'",
+ array($info['version'], $info['author'], $info['homepage']));
+ }
+}
+```
+
+## Internationalization
+
+### Translation Wrapping
+ALL user-facing strings MUST use the `__()` function with the `'evidence'` text domain:
+
+```php
+// CORRECT
+print __('Disabled/down device. No actual data', 'evidence') . '
';
+$datatypes = array(
+ 'info' => __('SNMP info', 'evidence'),
+ 'entity' => __('Entity MIB', 'evidence'),
+);
+
+// WRONG - Never use plain strings for user-facing text
+print 'Disabled/down device'; // Missing translation
+```
+
+## Plugin Architecture
+
+### Plugin Hooks
+Register all plugin hooks in `plugin_evidence_install()` (`setup.php`):
+
+```php
+function plugin_evidence_install() {
+ api_plugin_register_hook('evidence', 'device_edit_top_links', 'plugin_evidence_device_edit_top_links', 'include/functions.php');
+ api_plugin_register_hook('evidence', 'top_header_tabs', 'evidence_show_tab', 'include/functions.php');
+ api_plugin_register_hook('evidence', 'top_graph_header_tabs', 'evidence_show_tab', 'include/functions.php');
+ api_plugin_register_hook('evidence', 'host_device_remove', 'plugin_evidence_device_remove', 'include/functions.php');
+ api_plugin_register_hook('evidence', 'config_settings', 'plugin_evidence_config_settings', 'include/settings.php');
+ api_plugin_register_hook('evidence', 'poller_bottom', 'plugin_evidence_poller_bottom', 'include/functions.php');
+ api_plugin_register_hook('evidence', 'host_edit_bottom', 'plugin_evidence_host_edit_bottom', 'include/functions.php');
+
+ api_plugin_register_realm('evidence', 'evidence.php,evidence_tab.php,', 'Plugin evidence - view', 1);
+
+ plugin_evidence_setup_database();
+}
+```
+
+### Poller Integration
+Background collection runs via `poller_evidence.php`, invoked from `plugin_evidence_poller_bottom()` using `exec_background()` and gated by `plugin_evidence_time_to_run()` (which honors the `evidence_frequency` and `evidence_base_time` settings):
+
+```php
+function plugin_evidence_poller_bottom() {
+ global $config;
+
+ if (plugin_evidence_time_to_run()) {
+ include_once($config['library_path'] . '/poller.php');
+ $command_string = trim(read_config_option('path_php_binary'));
+
+ if (trim($command_string) == '') {
+ $command_string = 'php';
+ }
+
+ $extra_args = ' -q ' . $config['base_path'] . '/plugins/evidence/poller_evidence.php --id=all';
+
+ exec_background($command_string, $extra_args);
+ }
+}
+```
+
+## Configuration Arrays
+
+Define configuration in `include/arrays.php` and settings in `include/settings.php`:
+
+```php
+// include/arrays.php
+$datatypes = array(
+ 'info' => __('SNMP info', 'evidence'),
+ 'entity' => __('Entity MIB', 'evidence'),
+ 'mac' => __('Mac addresses', 'evidence'),
+ 'ip' => __('IP addresses', 'evidence'),
+);
+
+// include/settings.php - registered via the config_settings hook
+$settings['evidence'] = array(
+ 'evidence_frequency' => array(
+ 'friendly_name' => 'How often gather data',
+ 'method' => 'drop_array',
+ 'array' => array('0' => 'Disabled', '6' => 'Every 6 hours', '24' => 'Every day', '168' => 'Every week'),
+ 'default' => '24',
+ ),
+);
+```
+
+## Best Practices
+
+### 1. Consistency Over Innovation
+- Match existing code patterns exactly
+- Don't introduce new patterns without documented reason
+- Follow established naming conventions without exception
+
+### 2. Security First
+- Always use prepared statements for SQL
+- Validate all user input using Cacti's validation functions (`get_filter_request_var()`, etc.)
+- Verify device access via `plugin_evidence_get_allowed_devices()` before displaying host data
+- Never trust user input in file operations
+
+### 3. Cacti Integration
+- Use Cacti's API functions (`api_plugin_*`, `db_*`, `cacti_snmp_*`)
+- Follow Cacti's plugin architecture requirements
+- Respect Cacti's configuration options and settings
+
+### 4. Internationalization
+- Wrap ALL user-facing strings with `__('text', 'evidence')`
+- Never use plain strings for labels, messages, or UI text
+- Keep text domain consistent (`evidence`)
+
+### 5. SNMP Resilience
+- Suppress and check SNMP calls (`@cacti_snmp_get()`/`@cacti_snmp_walk()`) since not all devices support every OID
+- Treat missing/failed SNMP data as "not available" rather than a hard error
+
+### 6. Performance
+- Limit history retention based on the `evidence_records` setting
+- Avoid unnecessary polling; respect `evidence_frequency`/`evidence_base_time`
+
+## Common Pitfalls to Avoid
+
+### ❌ NEVER Do This
+```php
+// Don't concatenate SQL queries
+$sql = "SELECT * FROM host WHERE id = $id"; // WRONG
+
+// Don't use hardcoded strings for UI
+print 'Permission issue'; // WRONG
+
+// Don't use spaces for indentation
+ if ($condition) { // WRONG (spaces used)
+
+// Don't skip input validation
+$id = $_GET['host_id']; // WRONG
+```
+
+### ✅ ALWAYS Do This
+```php
+// Use prepared statements
+$host = db_fetch_row_prepared('SELECT * FROM host WHERE id = ?', array($id)); // CORRECT
+
+// Translate all user-facing strings
+print __('Permission issue', 'evidence'); // CORRECT
+
+// Use tabs for indentation
+ if ($condition) { // CORRECT (tabs used)
+
+// Always validate input
+$id = get_filter_request_var('host_id'); // CORRECT
+```
+
+## Version Control
+
+### Changelog Maintenance
+Document all changes in `CHANGELOG.md`:
+
+```markdown
+--- 0.3 ---
+* Add generic snmp info
+```
+
+### Commit Messages
+Follow the established pattern from git history:
+- Use descriptive commit messages
+- Reference issue numbers when applicable
+- Group related changes logically
+
+## References
+
+- Cacti Plugin Development Guide
+- Cacti API Documentation
+- Project README.md for feature descriptions
+- CHANGELOG.md for version history
diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml
new file mode 100644
index 0000000..225e41c
--- /dev/null
+++ b/.github/workflows/plugin-ci-workflow.yml
@@ -0,0 +1,251 @@
+# +-------------------------------------------------------------------------+
+# | 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 evidence Plugin
+ uses: actions/checkout@v4
+ with:
+ path: cacti/plugins/evidence
+
+ - 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
+ env:
+ COMPOSER_ROOT_VERSION: 1.3.0-dev
+ run: |
+ cd ${{ github.workspace }}/cacti
+ if [ -f composer.json ]; then
+ sudo composer config --no-plugins allow-plugins.pestphp/pest-plugin true
+ sudo composer require --dev --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 evidence Plugin
+ run: |
+ cd ${{ github.workspace }}/cacti
+ sudo php cli/plugin_manage.php --plugin=evidence --install --enable
+
+ - name: Check PHP Syntax for Plugin
+ run: |
+ cd ${{ github.workspace }}/cacti/plugins/evidence
+ 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/evidence/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/evidence/phpunit.xml \
+ --coverage-clover=plugins/evidence/coverage/clover.xml \
+ plugins/evidence/tests/Security
+
+ - name: Upload coverage report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-php${{ matrix.php }}
+ path: ${{ github.workspace }}/cacti/plugins/evidence/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/evidence
+ 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/evidence
+ 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/evidence
+ working-directory: ${{ github.workspace }}/cacti
+
+ - name: Run Cacti Poller
+ run: |
+ cd ${{ github.workspace }}/cacti
+ sudo 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
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..5ccf98d
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+ tests/Security
+
+
+
+
+
+
+ setup.php
+ include/database.php
+ include/functions.php
+
+
+
+
diff --git a/tests/.cacti-version b/tests/.cacti-version
new file mode 100644
index 0000000..585c5bc
--- /dev/null
+++ b/tests/.cacti-version
@@ -0,0 +1 @@
+1.2.31
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 @@
+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..6b0acf6
--- /dev/null
+++ b/tests/Security/SetupStructureTest.php
@@ -0,0 +1,40 @@
+toContain('function plugin_evidence_install');
+});
+
+it('defines plugin_evidence_version function', function () use ($source) {
+ expect($source)->toContain('function plugin_evidence_version');
+});
+
+it('defines plugin_evidence_uninstall function', function () use ($source) {
+ expect($source)->toContain('function plugin_evidence_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..81addc5
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,44 @@
+ $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 '';
+ }
+}
+
+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;
+ }
+ }
+}