diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 6ac583d9..ea8d0419 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -4,7 +4,7 @@
When generating code for this repository:
-1. **Version Compatibility**: This is a Cacti plugin (`mactrack`, "Device Tracking", version 4.9) targeting Cacti 1.2.14+; `tests/Security/Php74CompatibilityTest.php` guards `setup.php` against PHP 8.0+-only syntax as a legacy regression check.
+1. **Version Compatibility**: This is a Cacti plugin (`mactrack`, "Device Tracking", version 4.9) targeting Cacti 1.2.14+; `tests/Security/Php82CompatibilityTest.php` guards production sources against PHP 8.3+-only syntax as a regression check.
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
@@ -13,7 +13,7 @@ When generating code for this repository:
## Technology Stack
### Core Technologies
-- **PHP**: 7.4+ syntax floor (guarded by `tests/Security/Php74CompatibilityTest.php`); CI integration matrix runs against a pinned Cacti release
+- **PHP**: 8.2+ syntax floor (guarded by `tests/Security/Php82CompatibilityTest.php`); CI integration matrix runs PHP 8.2/8.3/8.4 against a pinned Cacti release
- **Platform**: Cacti Plugin Architecture (Cacti 1.2.14+)
- **Database**: MySQL/MariaDB via Cacti's DB abstraction layer
- **SNMP**: Bulk MAC/ARP/interface/VLAN discovery via Cacti's SNMP library
@@ -97,7 +97,7 @@ Use `html_escape()` / `htmlspecialchars()` for ALL output of DB/user values in H
Use `cacti_escapeshellarg()` for ALL shell command arguments.
### PHP Version Floor
-No PHP 8.0+ features (`str_contains()`, `match`, union types, named args) — target PHP 7.4. Use `??`/`??=` operators (PHP 7.4) instead of `isset()` ternary patterns.
+No PHP 8.3+ features (typed class constants, dynamic class constant fetch, the `#[Override]` attribute, `json_validate()`) — target PHP 8.2.
### Deserialization Safety
Metadata-only `unserialize()` calls must use `allowed_classes => false`; object payloads must use a minimal explicit class allowlist and validate the resulting type.
@@ -120,11 +120,15 @@ Register hooks in `plugin_mactrack_install()` (`setup.php`), including `top_head
## Testing
-Put executable standalone tests in `tests/Unit/` or `tests/Integration/`; the code-quality workflow runs every PHP file in those directories. Keep tests dependency-free so they run without Composer. Run `php -l` before committing.
+Pest tests live under `tests/Security/`, `tests/Unit/`, and `tests/Integration/` in PascalCase
+(e.g. `MacFormattingTest.php`) and run via `phpunit.xml`/`tests/Pest.php`, bootstrapped by
+`tests/bootstrap-unit.php` against the Cacti core checked out alongside the plugin in CI.
+Standalone, dependency-free scripts belong in `tests/e2e/` and are invoked directly with `php`
+against a live install. Run `php -l` before committing.
## Best Practices
-1. Never introduce PHP 8.0+-only syntax; the 7.4 floor is enforced by a dedicated regression test.
+1. Never introduce PHP 8.3+-only syntax; the 8.2 floor is enforced by a dedicated regression test.
2. Always use the `_prepared` DB helper variants for any query with variable input.
3. Validate/allowlist classes for any `unserialize()` call.
4. Wrap all user-facing strings with `__('text', 'mactrack')`.
@@ -132,11 +136,15 @@ Put executable standalone tests in `tests/Unit/` or `tests/Integration/`; the co
## Common Pitfalls to Avoid
```php
-// WRONG - PHP 8.0+ only syntax
-if (str_contains($haystack, $needle)) { }
-
-// CORRECT - PHP 7.4-compatible
-if (strpos($haystack, $needle) !== false) { }
+// WRONG - PHP 8.3+ only syntax
+class Foo {
+ const string BAR = 'baz';
+}
+
+// CORRECT - PHP 8.2-compatible
+class Foo {
+ const BAR = 'baz';
+}
// WRONG - unsafe unserialize
$data = unserialize($raw);
diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
deleted file mode 100644
index 04342697..00000000
--- a/.github/workflows/code-quality.yml
+++ /dev/null
@@ -1,131 +0,0 @@
-# +-------------------------------------------------------------------------+
-# | 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. |
-# +-------------------------------------------------------------------------+
-# | Cacti: The Complete RRDtool-based Graphing Solution |
-# +-------------------------------------------------------------------------+
-
-name: Code Quality
-
-on:
- push:
- branches:
- - main
- - develop
- pull_request:
- branches:
- - main
- - develop
-
-permissions:
- contents: read
-
-jobs:
- lint:
- name: PHP Lint (PHP ${{ matrix.php }})
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- php: ['7.4', '8.1', '8.2', '8.3', '8.4']
- steps:
- - name: Checkout plugin
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-
- - name: Install PHP ${{ matrix.php }}
- uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2
- with:
- php-version: ${{ matrix.php }}
- coverage: none
-
- # Lint everything, vendored code included, so a corrupt import fails on
- # arrival. php-cs-fixer and PHPStan still skip Net/, where reformatting
- # would destroy the provenance recorded in Net/README.md.
- - name: Lint plugin PHP
- run: |
- find . -path ./.git -prune \
- -o -name '*.php' -print0 \
- | xargs -0 -n1 -P4 php -l
-
- # The standalone tests need no dependencies. Without this step the
- # regression guards in tests/ are decorative.
- - name: Run standalone tests
- run: |
- rc=0
- for f in tests/Unit/*.php tests/Integration/*.php; do
- [ -f "$f" ] || continue
- printf '%s: ' "$f"
- php "$f" || rc=1
- done
- exit $rc
-
- coding-standards:
- name: Coding standards
- runs-on: ubuntu-latest
- steps:
- - name: Checkout plugin
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-
- - name: Install PHP
- uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2
- with:
- php-version: '8.1'
- coverage: none
- tools: composer:v2, php-cs-fixer:3.75
-
- # The ruleset is Cacti core's. Bundled Net_DNS2 and the test tree are
- # excluded by .php-cs-fixer.php, so only the plugin's own code is checked.
- - name: Check coding standards
- run: php-cs-fixer check --config=.php-cs-fixer.php --diff --show-progress=none
-
- phpstan:
- name: PHPStan level 6 (PHP ${{ matrix.php }})
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- php: ['8.1', '8.2', '8.3', '8.4']
- steps:
- - name: Checkout Cacti core
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- repository: Cacti/cacti
- ref: release/1.2.31
- path: cacti
-
- - name: Checkout plugin
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- path: cacti/plugins/mactrack
-
- - name: Install PHP ${{ matrix.php }}
- uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2
- with:
- php-version: ${{ matrix.php }}
- coverage: none
- tools: composer:v2, phpstan
-
- # Cacti core ships an incomplete vendor tree; its bootstrap needs the
- # runtime dependencies installed so core functions resolve for PHPStan.
- - name: Install Cacti core dependencies
- working-directory: cacti
- run: composer install --no-dev --no-interaction --no-progress
-
- # release/1.2.31 does not ship .phpstan-bootstrap.php (develop does).
- # Provide a no-op stub so the plugin config can load either core tree.
- - name: Ensure PHPStan bootstrap exists
- working-directory: cacti
- run: |
- if [ ! -f .phpstan-bootstrap.php ]; then
- printf '%s\n' ' .phpstan-bootstrap.php
- fi
-
- # Analysis runs against the plugin's own code only; the config excludes
- # Net/ and vendor/. The committed baseline pins the accepted findings.
- - name: Run PHPStan
- working-directory: cacti/plugins/mactrack
- run: phpstan analyse --configuration=.phpstan.neon --no-progress --memory-limit=2G
diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml
index de55df0d..3365987f 100644
--- a/.github/workflows/plugin-ci-workflow.yml
+++ b/.github/workflows/plugin-ci-workflow.yml
@@ -31,16 +31,20 @@ on:
- main
- develop
+env:
+ CACTI: 1.2.x
+ COMPOSER_ALLOW_SUPERUSER: 1
+
jobs:
integration-test:
runs-on: ${{ matrix.os }}
-
+
strategy:
fail-fast: false
matrix:
- php: ['8.1', '8.2', '8.3', '8.4']
+ php: ['8.2', '8.3', '8.4']
os: [ubuntu-latest]
-
+
services:
mariadb:
image: mariadb:10.6
@@ -56,43 +60,44 @@ jobs:
--health-interval=10s
--health-timeout=5s
--health-retries=3
-
+
name: PHP ${{ matrix.php }} Integration Test on ${{ matrix.os }}
-
+
steps:
- name: Checkout Cacti
- uses: actions/checkout@v7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: Cacti/cacti
- ref: release/1.2.31
+ ref: ${{ env.CACTI }}
path: cacti
-
- - name: Checkout mactrack Plugin
- uses: actions/checkout@v7
+
+ - name: Checkout MacTrack Plugin
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: cacti/plugins/mactrack
-
+
- name: Install PHP ${{ matrix.php }}
- uses: shivammathur/setup-php@v2
+ uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: ${{ matrix.php }}
- extensions: intl, mysql, gd, ldap, gmp, xml, curl, json, mbstring
+ extensions: intl, mysql, gd, ldap, gmp, xml, curl, json, mbstring, snmp
ini-values: "post_max_size=256M, max_execution_time=60, date.timezone=America/New_York"
-
+ coverage: xdebug
+
- name: Check PHP version
run: php -v
-
+
- name: Run apt-get update
run: sudo apt-get update
-
+
- name: Install System Dependencies
run: sudo apt-get install -y apache2 snmp snmpd rrdtool fping
-
+
- name: Start SNMPD Agent and Test
run: |
sudo systemctl start snmpd
sudo snmpwalk -c public -v2c -On localhost .1.3.6.1.2.1.1
-
+
- name: Setup Permissions
run: |
sudo chown -R www-data:runner ${{ github.workspace }}/cacti
@@ -100,12 +105,12 @@ jobs:
sudo find ${{ github.workspace }}/cacti -type f -exec chmod 664 {} \;
sudo chmod +x ${{ github.workspace }}/cacti/cmd.php
sudo chmod +x ${{ github.workspace }}/cacti/poller.php
-
+
- name: Create MySQL Config
run: |
echo -e "[client]\nuser = root\npassword = cactiroot\nhost = 127.0.0.1\n" > ~/.my.cnf
cat ~/.my.cnf
-
+
- name: Initialize Cacti Database
env:
MYSQL_AUTH_USR: '--defaults-file=~/.my.cnf'
@@ -117,21 +122,29 @@ jobs:
mysql $MYSQL_AUTH_USR -e "FLUSH PRIVILEGES;"
mysql $MYSQL_AUTH_USR cacti < ${{ github.workspace }}/cacti/cacti.sql
mysql $MYSQL_AUTH_USR -e "INSERT INTO settings (name, value) VALUES ('path_php_binary', '/usr/bin/php')" cacti
-
+
- name: Validate composer files
run: |
cd ${{ github.workspace }}/cacti
if [ -f composer.json ]; then
- composer validate --strict || true
+ sudo composer validate --strict || true
fi
-
+
- name: Install Composer Dependencies
run: |
cd ${{ github.workspace }}/cacti
if [ -f composer.json ]; then
- sudo composer install --prefer-dist --no-progress
+ sudo composer config --no-plugins allow-plugins.pestphp/pest-plugin true
+ sudo composer require --no-progress --no-interaction "pestphp/pest: ^3" "pestphp/pest-plugin-drift: ^3.0"
+ sudo rm -f composer.lock
+ sudo composer install --dev --no-progress
fi
-
+
+ - name: Restore vendor ownership for Pest
+ run: |
+ cd ${{ github.workspace }}/cacti
+ sudo chown -R runner:runner include/vendor composer.lock
+
- name: Create Cacti config.php
run: |
cat ${{ github.workspace }}/cacti/include/config.php.dist | \
@@ -140,96 +153,88 @@ jobs:
sed -r "s/'cactiuser'/'cactiuser'/g" | \
sed -r "s/'cactiuser'/'cactiuser'/g" > ${{ github.workspace }}/cacti/include/config.php
sudo chmod 664 ${{ github.workspace }}/cacti/include/config.php
-
+
- name: Configure Apache
run: |
cat << 'EOF' | sed 's#GITHUB_WORKSPACE#${{ github.workspace }}#g' > /tmp/cacti.conf
ServerAdmin webmaster@localhost
DocumentRoot GITHUB_WORKSPACE/cacti
-
+
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
-
+
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
EOF
sudo cp /tmp/cacti.conf /etc/apache2/sites-available/000-default.conf
sudo systemctl restart apache2
-
+
- name: Install Cacti via CLI
run: |
cd ${{ github.workspace }}/cacti
sudo php cli/install_cacti.php --accept-eula --install --force
- - name: Install Mactrack Composer Dependencies
- run: |
- cd ${{ github.workspace }}/cacti/plugins/mactrack
- if [ -f composer.json ]; then
- sudo composer install --no-dev --prefer-dist --no-progress --no-interaction
- fi
-
- - name: Install mactrack Plugin
+ - name: Install MacTrack Plugin
run: |
cd ${{ github.workspace }}/cacti
sudo php cli/plugin_manage.php --plugin=mactrack --install --enable
-# - name: import mactrack Plugin Sample Data
-# run: |
-# cd ${{ github.workspace }}/cacti/plugins/mactrack
-# sudo php cli_import.php --filename=.github/workflows/mactrack_sample_data.xml
-# if [ $? -ne 0 ]; then
-# echo "Failed to import Thold sample data"
-# exit 1
-# fi
-
- name: Check PHP Syntax for Plugin
run: |
cd ${{ github.workspace }}/cacti/plugins/mactrack
- find . -path './vendor' -prune -o -name '*.php' -print0 | xargs -0 -n1 php -l
+ if find . -name '*.php' -exec php -l {} 2>&1 \; | grep -iv 'no syntax errors detected'; then
+ echo "Syntax errors found!"
+ exit 1
+ fi
- # Cacti 1.2.31 has no .phpstan.neon / lint scripts; only touch them when present.
- - name: Remove the plugins directory exclusion from the .phpstan.neon
+ - name: Set expected Cacti version for unit tests
+ run: echo -n "${{ env.CACTI }}" | sudo tee ${{ github.workspace }}/cacti/plugins/mactrack/tests/.cacti-version > /dev/null
+
+ - name: Run Pest Unit Tests
+ env:
+ COMPOSER_ROOT_VERSION: 1.3.0-dev
run: |
- if [ -f .phpstan.neon ]; then
- sed '/plugins/d' -i .phpstan.neon
- else
- echo '.phpstan.neon not present; skipping'
- fi
+ cd ${{ github.workspace }}/cacti
+ include/vendor/bin/pest --configuration=plugins/mactrack/phpunit.xml \
+ --coverage-clover=plugins/mactrack/coverage/clover.xml
+
+ - name: Upload coverage report
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: coverage-php${{ matrix.php }}
+ path: ${{ github.workspace }}/cacti/plugins/mactrack/coverage/
+ if-no-files-found: warn
+
+ - name: Remove the plugins directory exclusion from the .phpstan.neon
+ if: ${{ env.CACTI != '1.2.x' }}
+ run: sed '/plugins/d' -i .phpstan.neon
working-directory: ${{ github.workspace }}/cacti
- name: Mark composer scripts executable
- run: |
- if [ -d "${{ github.workspace }}/cacti/include/vendor/bin" ]; then
- sudo find "${{ github.workspace }}/cacti/include/vendor/bin" -maxdepth 1 -type f -exec chmod +x {} +
- fi
+ if: ${{ env.CACTI != '1.2.x' }}
+ run: sudo chmod +x ${{ github.workspace }}/cacti/include/vendor/bin/*
- name: Run Linter on base code
- run: |
- if composer run-script --list | grep -qE '^ lint'; then
- composer run-script lint ${{ github.workspace }}/cacti/plugins/mactrack
- else
- echo 'Composer lint script is not defined; skipping.'
- fi
+ if: ${{ env.CACTI != '1.2.x' }}
+ run: sudo composer run-script lint ${{ github.workspace }}/cacti/plugins/mactrack
working-directory: ${{ github.workspace }}/cacti
- name: Checking coding standards on base code
- run: |
- if composer run-script --list | grep -qE '^ phpcsfixer'; then
- composer run-script phpcsfixer ${{ github.workspace }}/cacti/plugins/mactrack
- else
- echo 'Composer phpcsfixer script is not defined; skipping.'
- fi
+ if: ${{ env.CACTI != '1.2.x' }}
+ run: sudo composer run-script phpcsfixer ${{ github.workspace }}/cacti/plugins/mactrack
+ working-directory: ${{ github.workspace }}/cacti
+
+ - name: Run PHPStan at Level 6 on base code outside of Composer due to technical issues
+ if: ${{ env.CACTI != '1.2.x' }}
+ run: sudo ./include/vendor/bin/phpstan analyze --level 6 ${{ github.workspace }}/cacti/plugins/mactrack
working-directory: ${{ github.workspace }}/cacti
-# - name: Run PHPStan at Level 6 on base code outside of Composer due to technical issues
-# run: ./include/vendor/bin/phpstan analyze --level 6 ${{ github.workspace }}/cacti/plugins/mactrack
-# working-directory: ${{ github.workspace }}/cacti
-
- name: Re-apply web user ownership before polling
run: |
# Ancestor directories above the checkout (e.g. /home/runner, .../work)
@@ -251,23 +256,12 @@ jobs:
run: |
cd ${{ github.workspace }}/cacti
sudo -u www-data php poller.php --poller=1 --force --debug
+
if ! grep -q "SYSTEM STATS" log/cacti.log; then
echo "Cacti poller did not finish successfully"
cat log/cacti.log
exit 1
fi
-
- # The e2e scripts need the live Cacti and database this job already built.
- - name: Run plugin e2e tests
- run: |
- rc=0
- for f in tests/e2e/*.php; do
- [ -f "$f" ] || continue
- printf '%s: ' "$f"
- php "$f" || rc=1
- done
- exit $rc
- working-directory: ${{ github.workspace }}/cacti/plugins/mactrack
- name: View Cacti Logs
if: always()
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 60e70930..d5346853 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,8 +2,9 @@
--- develop ---
+* issue#357: Prevent repeated database setup from duplicating the Default site
* security: Restrict Net_DNS2 cache deserialization to DNS response classes
-* docs: Align the documented PHP floor with the PHP 7.4 compatibility contract
+* docs: Align the documented PHP floor with the PHP 8.2 CI/test contract
* issue: Return the unformatted MAC when mt_mac_format is unset rather than a null address
* issue: Call mactrack_format_mac() from the ARP export instead of a function that does not exist
* issue: Drop the mactrack_ajax.php graph-settings action, whose handler was removed in 2016
diff --git a/README.md b/README.md
index 6d102418..13933cf1 100644
--- a/README.md
+++ b/README.md
@@ -29,8 +29,7 @@ utilization is, where there are errors, etc within their network.
## Prerequisites
-Mactrack requires Cacti 1.2.14 or later and PHP 7.4 or greater. The plugin
-retains the PHP 7.4 compatibility contract used by the Cacti 1.2 line.
+Mactrack requires Cacti 1.2.14 or later and PHP 8.2 for its supported runtime and toolchain.
## Installation
diff --git a/includes/database.php b/includes/database.php
index 838bdcaf..1dbfb329 100644
--- a/includes/database.php
+++ b/includes/database.php
@@ -710,13 +710,138 @@ function mactrack_database_upgrade() {
db_execute("UPDATE mac_track_oui_database SET vendor_mac = REPLACE(vendor_mac, ':', '')");
}
- // default site must exist
- if (!db_fetch_cell('SELECT count(*) FROM mac_track_sites')) {
- db_execute("INSERT INTO mac_track_sites (site_name, site_info) VALUES ('Default','Default site')");
+ mactrack_ensure_default_site();
+}
+
+function mactrack_site_configuration_exists(): bool {
+ $site_count = db_fetch_cell_prepared('SELECT COUNT(*) FROM mac_track_sites');
+
+ return is_numeric($site_count) && (int) $site_count > 0;
+}
+
+function mactrack_seed_default_site(?int $lock_timeout = null): bool {
+ global $database_default;
+
+ if (mactrack_site_configuration_exists()) {
+ return true;
+ }
+
+ $lock_timeout = $lock_timeout ?? (PHP_SAPI === 'cli' ? 10 : 2);
+ $lock_timeout = max(0, $lock_timeout);
+ // This advisory lock improves the legacy check-then-insert behavior, but a
+ // reconnect can release it. Database-enforced name uniqueness needs the
+ // duplicate-safe legacy migration tracked in #360.
+ $lock_name = 'mactrack.default.' . sha1((string) $database_default);
+ $locked = db_fetch_cell_prepared('SELECT GET_LOCK(?, ?)', [$lock_name, $lock_timeout]);
+
+ if ((string) $locked !== '1') {
+ if (mactrack_site_configuration_exists()) {
+ return true;
+ }
+
+ cacti_log('Unable to acquire the MacTrack Default-site setup lock', false, 'MACTRACK');
+
+ return false;
+ }
+
+ try {
+ $inserted = (bool) db_execute_prepared(
+ 'INSERT INTO mac_track_sites (site_name, site_info)
+ SELECT ?, ? FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM mac_track_sites)',
+ ['Default', 'Default site']
+ );
+
+ if (mactrack_site_configuration_exists()) {
+ return true;
+ }
+
+ cacti_log($inserted ? 'MacTrack site seeding completed without creating a site' : 'Unable to insert the default MacTrack site', false, 'MACTRACK');
+
+ return false;
+ } finally {
+ $released = db_fetch_cell_prepared('SELECT RELEASE_LOCK(?)', [$lock_name]);
+
+ if ((string) $released !== '1') {
+ cacti_log('MacTrack Default-site setup lock was not owned when release was attempted', false, 'MACTRACK');
+ }
}
}
-function mactrack_setup_database() {
+function mactrack_reset_default_site_retry(): void {
+ set_config_option('mt_default_site_seed_pending', 'off');
+ set_config_option('mt_default_site_seed_attempts', '0');
+ set_config_option('mt_default_site_seed_next_retry', '0');
+}
+
+function mactrack_raise_default_site_error(int $attempts): void {
+ if ($attempts >= 5) {
+ $message = __('MacTrack could not initialize its Default site after repeated attempts. Review the Cacti log before continuing.', 'mactrack');
+ } else {
+ $message = __('MacTrack could not initialize its Default site. Review the Cacti log before continuing.', 'mactrack');
+ }
+
+ raise_message('mactrack_default_site_seed_failed', $message, MESSAGE_LEVEL_ERROR);
+}
+
+function mactrack_ensure_default_site(?bool $notify_operator = null, bool $notify_immediately = false): bool {
+ $notify_operator = $notify_operator ?? (PHP_SAPI !== 'cli');
+ $pending = read_config_option('mt_default_site_seed_pending', true) === 'on';
+ $attempts = max(0, (int) read_config_option('mt_default_site_seed_attempts', true));
+ $next_retry = max(0, (int) read_config_option('mt_default_site_seed_next_retry', true));
+
+ if ($pending && $next_retry > time()) {
+ // Another request or an administrator may have repaired the site while
+ // this worker was throttled. Clear stale retry state without taking the
+ // seed lock or attempting another insert.
+ if (mactrack_site_configuration_exists()) {
+ mactrack_reset_default_site_retry();
+
+ return true;
+ }
+
+ if ($notify_operator && ($notify_immediately || $attempts >= 5)) {
+ mactrack_raise_default_site_error($attempts);
+ }
+
+ return false;
+ }
+
+ try {
+ $seeded = mactrack_seed_default_site();
+ } catch (Throwable $exception) {
+ cacti_log('MacTrack Default-site initialization failed: ' . $exception->getMessage(), false, 'MACTRACK');
+ $seeded = false;
+ }
+
+ if ($seeded) {
+ mactrack_reset_default_site_retry();
+
+ return true;
+ }
+
+ $retry_delays = [60, 300, 900, 1800, 3600];
+ $attempts = min(5, $attempts + 1);
+ $delay = $retry_delays[$attempts - 1];
+ set_config_option('mt_default_site_seed_pending', 'on');
+ set_config_option('mt_default_site_seed_attempts', (string) $attempts);
+ set_config_option('mt_default_site_seed_next_retry', (string) (time() + $delay));
+
+ if ($notify_operator && ($notify_immediately || $attempts >= 5)) {
+ mactrack_raise_default_site_error($attempts);
+ }
+
+ return false;
+}
+
+function mactrack_retry_default_site(): bool {
+ if (read_config_option('mt_default_site_seed_pending', true) !== 'on') {
+ return true;
+ }
+
+ return mactrack_ensure_default_site();
+}
+
+function mactrack_setup_database(bool $notify_seed_failure = false) {
$data = [];
$data['columns'][] = ['name' => 'row_id', 'unsigned' => true, 'type' => 'int(10)', 'NULL' => false, 'auto_increment' => true];
$data['columns'][] = ['name' => 'site_id', 'unsigned' => true, 'type' => 'int(10)', 'NULL' => false, 'default' => '0'];
@@ -1121,9 +1246,6 @@ function mactrack_setup_database() {
$data['comment'] = '';
api_plugin_db_table_create('mactrack', 'mac_track_sites', $data);
- // default site must exist
- db_execute("INSERT INTO mac_track_sites (site_name, site_info) VALUES ('Default','Default site')");
-
$data = [];
$data['columns'][] = ['name' => 'id', 'unsigned' => true, 'type' => 'int(10)', 'NULL' => false, 'auto_increment' => true];
$data['columns'][] = ['name' => 'name', 'type' => 'varchar(100)', 'NULL' => false, 'default' => ''];
@@ -1380,4 +1502,7 @@ function mactrack_setup_database() {
(description, vendor, device_type, sysDescr_match, sysObjectID_match, scanning_function, ip_scanning_function, dot1x_scanning_function, serial_number_oid, lowPort, highPort, disabled)
VALUES ('92xx Switch-default','Cisco','1','*CAT9K_LITE_IOSXE*','','get_IOS_dot1dTpFdbEntry_ports','get_standard_arp_table','0','',0,0,'on')");
}
+
+ // Seed only after the complete schema and built-in device types exist.
+ return mactrack_ensure_default_site($notify_seed_failure, $notify_seed_failure);
}
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 00000000..6564cb42
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+ tests/Security
+ tests/Unit
+ tests/Integration
+
+
+
+
+
+
+ setup.php
+ includes/database.php
+ lib/mactrack_functions.php
+
+
+
diff --git a/poller_mactrack.php b/poller_mactrack.php
index 987f12ae..9f71a1da 100644
--- a/poller_mactrack.php
+++ b/poller_mactrack.php
@@ -47,8 +47,11 @@
include('./include/cli_check.php');
include_once($config['base_path'] . '/lib/poller.php');
+include_once($config['base_path'] . '/plugins/mactrack/includes/database.php');
include_once($config['base_path'] . '/plugins/mactrack/lib/mactrack_functions.php');
+mactrack_retry_default_site();
+
// install signal handlers for UNIX only
if (function_exists('pcntl_signal')) {
pcntl_signal(SIGTERM, 'sig_handler');
diff --git a/setup.php b/setup.php
index b24f3492..83bd1cd1 100644
--- a/setup.php
+++ b/setup.php
@@ -22,7 +22,7 @@
+-------------------------------------------------------------------------+
*/
-function plugin_mactrack_install() {
+function plugin_mactrack_install($operator_initiated = true) {
api_plugin_register_hook('mactrack', 'top_header_tabs', 'mactrack_show_tab', 'setup.php');
api_plugin_register_hook('mactrack', 'top_graph_header_tabs', 'mactrack_show_tab', 'setup.php');
api_plugin_register_hook('mactrack', 'config_arrays', 'mactrack_config_arrays', 'setup.php');
@@ -45,10 +45,21 @@ function plugin_mactrack_install() {
api_plugin_register_realm('mactrack', 'mactrack_view_ips.php,mactrack_view_arp.php,mactrack_view_macs.php,mactrack_view_dot1x.php,mactrack_view_sites.php,mactrack_view_devices.php,mactrack_view_interfaces.php,mactrack_view_graphs.php,mactrack_ajax.php', 'Mactrack Viewer', 1);
api_plugin_register_realm('mactrack', 'mactrack_ajax_admin.php,mactrack_devices.php,mactrack_snmp.php,mactrack_sites.php,mactrack_device_types.php,mactrack_utilities.php,mactrack_macwatch.php,mactrack_macauth.php,mactrack_vendormacs.php', 'Mactrack Administrator', 1);
- mactrack_setup_table_new();
+ $site_ready = mactrack_setup_table_new($operator_initiated);
+
+ if (!$site_ready && PHP_SAPI === 'cli') {
+ fwrite(STDERR, "WARNING: MacTrack installed without a Default site; review the Cacti log. The poller will retry with backoff.\n");
+ }
+
+ return $site_ready;
}
function plugin_mactrack_uninstall() {
+ db_execute_prepared(
+ 'DELETE FROM settings WHERE name IN (?, ?, ?)',
+ ['mt_default_site_seed_pending', 'mt_default_site_seed_attempts', 'mt_default_site_seed_next_retry']
+ );
+
return true;
}
@@ -94,7 +105,7 @@ function mactrack_check_upgrade() {
// if the plugin is installed and/or active
if (!cacti_sizeof($old) || $old['status'] == 1 || $old['status'] == 4) {
// re-register the hooks
- plugin_mactrack_install();
+ plugin_mactrack_install(false);
if (api_plugin_is_enabled('mactrack')) {
// may sound ridiculous, but enables new hooks
@@ -133,15 +144,18 @@ function mactrack_check_upgrade() {
// update the plugin information
$info = plugin_mactrack_version();
- $id = db_fetch_cell("SELECT id FROM plugin_config WHERE directory='mactrack'");
-
- db_execute("UPDATE plugin_config
- SET name='" . $info['longname'] . "',
- author='" . $info['author'] . "',
- webpage='" . $info['homepage'] . "',
- version='" . $info['version'] . "'
- WHERE id='$id'");
+ $id = db_fetch_cell_prepared('SELECT id FROM plugin_config WHERE directory = ?', ['mactrack']);
+
+ db_execute_prepared('UPDATE plugin_config
+ SET name = ?,
+ author = ?,
+ webpage = ?,
+ version = ?
+ WHERE id = ?',
+ [$info['longname'], $info['author'], $info['homepage'], $info['version'], $id]);
}
+
+ mactrack_retry_default_site();
}
function mactrack_db_table_exists($table) {
@@ -228,12 +242,18 @@ function mactrack_check_dependencies() {
return true;
}
-function mactrack_setup_table_new() {
+function mactrack_setup_table_new($operator_initiated = true) {
global $config;
include_once($config['base_path'] . '/plugins/mactrack/includes/database.php');
- mactrack_setup_database();
+ // Preserve a prior failed seed's backoff when an incomplete upgrade causes
+ // Cacti to re-enter this hook on a later request.
+ if ($operator_initiated || read_config_option('mt_default_site_seed_pending', true) !== 'on') {
+ mactrack_reset_default_site_retry();
+ }
+
+ return mactrack_setup_database(PHP_SAPI !== 'cli');
}
function mactrack_page_head() {
diff --git a/tests/.cacti-version b/tests/.cacti-version
new file mode 100644
index 00000000..c971a7a8
--- /dev/null
+++ b/tests/.cacti-version
@@ -0,0 +1 @@
+1.2.x
diff --git a/tests/Integration/DefaultSiteIdempotencyTest.php b/tests/Integration/DefaultSiteIdempotencyTest.php
new file mode 100644
index 00000000..f2562f31
--- /dev/null
+++ b/tests/Integration/DefaultSiteIdempotencyTest.php
@@ -0,0 +1,117 @@
+assertTrue(mactrack_setup_table_new(true));
+ $this->assertSame('off', read_config_option('mt_default_site_seed_pending'));
+
+ $inserted = array_filter(
+ array_column($GLOBALS['__test_db_calls'], 'sql'),
+ static fn ($sql) => strpos($sql, 'INSERT INTO mac_track_sites') !== false
+ );
+ $this->assertCount(1, $inserted, 'Exactly one Default-site insert should have been attempted');
+ }
+
+ /**
+ * @return void
+ */
+ public function testReenteringUpgradeAfterASuccessfulSeedDoesNotInsertAgain(): void {
+ // The site already exists (e.g. install already succeeded); a later
+ // automatic upgrade check must not attempt another insert.
+ mactrack_test_queue_return_for('db_fetch_cell_prepared', 'COUNT(*) FROM mac_track_sites', '1');
+
+ $this->assertTrue(mactrack_setup_table_new(false));
+
+ foreach ($GLOBALS['__test_db_calls'] as $call) {
+ $this->assertStringNotContainsString('INSERT INTO mac_track_sites', $call['sql']);
+ $this->assertStringNotContainsString('GET_LOCK', $call['sql']);
+ }
+ }
+
+ /**
+ * @return void
+ */
+ public function testAnOperatorInitiatedReinstallClearsAPriorBackoffEvenWhileThrottled(): void {
+ set_config_option('mt_default_site_seed_pending', 'on');
+ set_config_option('mt_default_site_seed_attempts', '4');
+ set_config_option('mt_default_site_seed_next_retry', (string) (time() + 3600));
+
+ mactrack_test_queue_return_for('db_fetch_cell_prepared', 'GET_LOCK', '1');
+ mactrack_test_queue_return_for('db_fetch_cell_prepared', 'RELEASE_LOCK', '1');
+ mactrack_test_queue_return('db_fetch_cell_prepared', '0');
+ mactrack_test_queue_return('db_fetch_cell_prepared', '1');
+ mactrack_test_queue_return('db_execute_prepared', true);
+
+ $this->assertTrue(mactrack_setup_table_new(true));
+ $this->assertSame('off', read_config_option('mt_default_site_seed_pending'));
+ }
+
+ /**
+ * @return void
+ */
+ public function testAnAutomaticUpgradePreservesAPriorBackoffWindow(): void {
+ $future = (string) (time() + 3600);
+ set_config_option('mt_default_site_seed_pending', 'on');
+ set_config_option('mt_default_site_seed_attempts', '2');
+ set_config_option('mt_default_site_seed_next_retry', $future);
+
+ mactrack_test_queue_return_for('db_fetch_cell_prepared', 'COUNT(*) FROM mac_track_sites', '0');
+
+ $this->assertFalse(mactrack_setup_table_new(false));
+ $this->assertSame('on', read_config_option('mt_default_site_seed_pending'));
+ $this->assertSame($future, read_config_option('mt_default_site_seed_next_retry'));
+
+ foreach ($GLOBALS['__test_db_calls'] as $call) {
+ $this->assertStringNotContainsString('GET_LOCK', $call['sql']);
+ }
+ }
+
+ /**
+ * @return void
+ */
+ public function testInstallReturnsTheSeedResultForTheCallerToAct(): void {
+ mactrack_test_queue_return_for('db_fetch_cell_prepared', 'GET_LOCK', '0');
+ mactrack_test_queue_return('db_fetch_cell_prepared', '0');
+
+ $this->assertFalse(plugin_mactrack_install());
+ $this->assertSame('on', read_config_option('mt_default_site_seed_pending'));
+ }
+}
diff --git a/tests/Integration/FilterOutputWiringTest.php b/tests/Integration/FilterOutputWiringTest.php
new file mode 100644
index 00000000..488b817b
--- /dev/null
+++ b/tests/Integration/FilterOutputWiringTest.php
@@ -0,0 +1,42 @@
+ [
+ "html_escape(\$site['site_name'])",
+ "html_escape(\$filter_device['device_name'] . '(' . \$filter_device['hostname'] . ')')",
+ ],
+ 'mactrack_device_types.php' => [
+ "html_escape(\$type['vendor'])",
+ ],
+ ];
+
+ foreach ($checks as $file => $patterns) {
+ foreach ($patterns as $pattern) {
+ it("renders {$pattern} in {$file}", function () use ($file, $pattern) {
+ $source = plugin_test_read_source($file);
+
+ expect($source)->toContain($pattern);
+ });
+ }
+ }
+});
diff --git a/tests/Integration/test_mactrack_filter_output_wiring.php b/tests/Integration/test_mactrack_filter_output_wiring.php
deleted file mode 100644
index 8738ef70..00000000
--- a/tests/Integration/test_mactrack_filter_output_wiring.php
+++ /dev/null
@@ -1,36 +0,0 @@
- [
- "html_escape(\$site['site_name'])",
- "html_escape(\$filter_device['device_name'] . '(' . \$filter_device['hostname'] . ')')",
- ],
- __DIR__ . '/../../mactrack_device_types.php' => [
- "html_escape(\$type['vendor'])",
- ],
-];
-
-foreach ($checks as $path => $patterns) {
- $contents = file_get_contents($path);
-
- if ($contents === false) {
- fwrite(STDERR, "Unable to read {$path}\n");
- exit(1);
- }
-
- foreach ($patterns as $pattern) {
- if (strpos($contents, $pattern) === false) {
- fwrite(STDERR, "Missing expected escaped output: {$pattern}\n");
- exit(1);
- }
- }
-}
-
-print "OK\n";
diff --git a/tests/Pest.php b/tests/Pest.php
new file mode 100644
index 00000000..c639e23b
--- /dev/null
+++ b/tests/Pest.php
@@ -0,0 +1,14 @@
+cache_serializer = 'serialize';
+ $this->cache_data[$key] = ['object' => serialize($value)];
+ }
+}
+
+class MactrackNetDns2UnserializeProbe {
+ public static $awakened = false;
+
+ public function __wakeup() {
+ self::$awakened = true;
+ }
+}
+
+describe('Net_DNS2 cache deserialization safety in mactrack', function () {
+ it('restores a valid DNS response payload from the cache', function () {
+ $cache = new MactrackNetDns2TestCache();
+ $reflection = new ReflectionClass('Net_DNS2_Packet_Response');
+ $response = $reflection->newInstanceWithoutConstructor();
+
+ $response->rdata = '';
+ $response->rdlength = 0;
+ $response->header = null;
+ $response->question = [];
+ $response->answer = [];
+ $response->authority = [];
+ $response->additional = [];
+
+ $cache->seed('response', $response);
+
+ expect($cache->get('response'))->toBeInstanceOf(Net_DNS2_Packet_Response::class);
+ });
+
+ it('refuses to instantiate an unexpected class from a cache payload', function () {
+ MactrackNetDns2UnserializeProbe::$awakened = false;
+
+ $cache = new MactrackNetDns2TestCache();
+ $cache->seed('probe', new MactrackNetDns2UnserializeProbe());
+
+ expect($cache->get('probe'))->toBeFalse();
+ expect(MactrackNetDns2UnserializeProbe::$awakened)->toBeFalse();
+ });
+
+ it('disables arbitrary class construction for both cache metadata loads', function () {
+ foreach (['Net/DNS2/Cache/File.php', 'Net/DNS2/Cache/Shm.php'] as $relative) {
+ $source = file_get_contents(__DIR__ . '/../../' . $relative);
+
+ expect($source)->not->toBeFalse("Unable to read {$relative}");
+ expect(substr_count($source, "['allowed_classes' => false]"))->toBe(2, "{$relative} must disable classes for both metadata loads");
+ }
+ });
+});
+
+describe('Net_DNS2 include-path precedence in mactrack', function () {
+ it('loads the bundled resolver even when a same-named class exists earlier on the include path', function () {
+ $temporary = sys_get_temp_dir() . '/mactrack_dns_shadow_' . getmypid();
+ $shadow = $temporary . '/Net/DNS2';
+
+ expect(mkdir($shadow, 0700, true))->toBeTrue('Unable to create the DNS shadow fixture directory');
+ expect(file_put_contents($shadow . '/Resolver.php', "not->toBeFalse();
+
+ $plugin_root = realpath(__DIR__ . '/../..');
+ set_include_path($plugin_root . PATH_SEPARATOR . $temporary . PATH_SEPARATOR . get_include_path());
+
+ try {
+ require_once $plugin_root . '/Net/DNS2.php';
+
+ expect(class_exists('Net_DNS2_Resolver'))->toBeTrue('Bundled Net_DNS2 resolver did not autoload');
+
+ $reflection = new ReflectionClass('Net_DNS2_Resolver');
+ $resolved = realpath((string) $reflection->getFileName());
+ $expected = realpath($plugin_root . '/Net/DNS2/Resolver.php');
+
+ expect($resolved)->toBe($expected, 'A shadowed path resolved the bundled resolver to the wrong file');
+ } finally {
+ @unlink($shadow . '/Resolver.php');
+ @rmdir($shadow);
+ @rmdir($temporary . '/Net');
+ @rmdir($temporary);
+ }
+ });
+});
diff --git a/tests/Security/Php82CompatibilityTest.php b/tests/Security/Php82CompatibilityTest.php
new file mode 100644
index 00000000..320482ea
--- /dev/null
+++ b/tests/Security/Php82CompatibilityTest.php
@@ -0,0 +1,109 @@
+toBe(0, "{$f} uses each() (removed in PHP 8.0)");
+ }
+ });
+
+ it('does not use create_function() (removed in PHP 8.0)', function () use ($files) {
+ foreach ($files as $f) {
+ $c = file_get_contents(__DIR__ . '/../../' . $f);
+ if ($c === false) continue;
+ expect(preg_match('/\bcreate_function\s*\(/', $c))->toBe(0, "{$f} uses create_function() (removed in PHP 8.0)");
+ }
+ });
+
+ it('does not use curly-brace string offset access (removed in PHP 8.0)', function () use ($files) {
+ foreach ($files as $f) {
+ $c = file_get_contents(__DIR__ . '/../../' . $f);
+ if ($c === false) continue;
+ expect(preg_match('/\$\w+\s*\{\s*\d+\s*\}/', $c))->toBe(0, "{$f} uses curly-brace string offset access (removed in PHP 8.0)");
+ }
+ });
+
+ it('does not use json_validate() (PHP 8.3)', function () use ($files) {
+ foreach ($files as $f) {
+ $c = file_get_contents(__DIR__ . '/../../' . $f);
+ if ($c === false) continue;
+ expect(preg_match('/\bjson_validate\s*\(/', $c))->toBe(0, "{$f} uses json_validate() (PHP 8.3)");
+ }
+ });
+
+ it('does not use dynamic class constant fetch syntax (PHP 8.3)', function () use ($files) {
+ foreach ($files as $f) {
+ $c = file_get_contents(__DIR__ . '/../../' . $f);
+ if ($c === false) continue;
+ expect(preg_match('/\w+::\{.+\}/', $c))->toBe(0, "{$f} uses dynamic class constant fetch (PHP 8.3)");
+ }
+ });
+
+ it('does not use typed class constants (PHP 8.3)', function () use ($files) {
+ foreach ($files as $f) {
+ $c = file_get_contents(__DIR__ . '/../../' . $f);
+ if ($c === false) continue;
+ expect(preg_match('/\b(?:public|private|protected|final)\s+const\s+\??[\w|]+\s+\w+\s*=/', $c))->toBe(0,
+ "{$f} uses typed class constants (PHP 8.3)"
+ );
+ }
+ });
+
+ it('does not use the #[Override] attribute (PHP 8.4)', function () use ($files) {
+ foreach ($files as $f) {
+ $c = file_get_contents(__DIR__ . '/../../' . $f);
+ if ($c === false) continue;
+ expect(preg_match('/#\[\s*\\\\?Override\s*\]/', $c))->toBe(0, "{$f} uses #[Override] attribute (PHP 8.4)");
+ }
+ });
+
+ it('does not use asymmetric visibility (PHP 8.4)', function () use ($files) {
+ foreach ($files as $f) {
+ $c = file_get_contents(__DIR__ . '/../../' . $f);
+ if ($c === false) continue;
+ expect(preg_match('/\b(?:public|protected)\s*\(\s*set\s*\)/', $c))->toBe(0,
+ "{$f} uses asymmetric visibility (PHP 8.4)"
+ );
+ }
+ });
+
+ it('does not use property hooks (PHP 8.4)', function () use ($files) {
+ foreach ($files as $f) {
+ $c = file_get_contents(__DIR__ . '/../../' . $f);
+ if ($c === false) continue;
+ expect(preg_match('/\b(?:get|set)\s*\{/', $c))->toBe(0, "{$f} uses property hooks (PHP 8.4)");
+ }
+ });
+
+ // Every tracked file's syntax is already gated by the CI workflow's
+ // dedicated "Check PHP Syntax for Plugin" step (php -l over every file),
+ // so it isn't duplicated here.
+});
diff --git a/tests/Security/PreparedStatementConsistencyTest.php b/tests/Security/PreparedStatementConsistencyTest.php
new file mode 100644
index 00000000..2efa2428
--- /dev/null
+++ b/tests/Security/PreparedStatementConsistencyTest.php
@@ -0,0 +1,80 @@
+ 141,
+ 'lib/mactrack_3com.php' => 1,
+ 'lib/mactrack_aruba_oscx.php' => 1,
+ 'lib/mactrack_cisco.php' => 5,
+ 'lib/mactrack_enterasys_N7.php' => 1,
+ 'lib/mactrack_extreme.php' => 1,
+ 'lib/mactrack_functions.php' => 28,
+ 'lib/mactrack_h3c_3com.php' => 1,
+ 'mactrack_actions.php' => 19,
+ 'mactrack_convert.php' => 9,
+ 'mactrack_device_types.php' => 11,
+ 'mactrack_devices.php' => 7,
+ 'mactrack_macauth.php' => 2,
+ 'mactrack_macwatch.php' => 2,
+ 'mactrack_resolver.php' => 3,
+ 'mactrack_scanner.php' => 1,
+ 'mactrack_sites.php' => 3,
+ 'mactrack_snmp.php' => 4,
+ 'mactrack_utilities.php' => 25,
+ 'mactrack_vendormacs.php' => 2,
+ 'mactrack_view_arp.php' => 7,
+ 'mactrack_view_devices.php' => 4,
+ 'mactrack_view_dot1x.php' => 6,
+ 'mactrack_view_graphs.php' => 2,
+ 'mactrack_view_interfaces.php' => 3,
+ 'mactrack_view_ips.php' => 3,
+ 'mactrack_view_macs.php' => 9,
+ 'mactrack_view_sites.php' => 3,
+ 'poller_mactrack.php' => 39,
+ 'setup.php' => 20,
+ ];
+
+ $root = realpath(__DIR__ . '/../..');
+ $pattern = '/\bdb_(?:execute|fetch_row|fetch_assoc|fetch_cell)\s*\(/i';
+
+ it('never increases raw (non-prepared) database calls beyond the recorded baseline', function () use ($baseline, $root, $pattern) {
+ $files = mactrack_test_production_php_files();
+
+ foreach ($files as $file) {
+ if (strpos($file, 'tests/') === 0) {
+ continue;
+ }
+
+ $source = file_get_contents($root . '/' . $file);
+
+ expect($source)->not->toBeFalse("Unable to read {$file}");
+
+ $count = preg_match_all($pattern, $source);
+
+ expect($count)->toBeLessThanOrEqual(
+ $baseline[$file] ?? 0,
+ "{$file} increased raw database calls from " . ($baseline[$file] ?? 0) . " to {$count}"
+ );
+ }
+ });
+});
diff --git a/tests/Security/SetupStructureTest.php b/tests/Security/SetupStructureTest.php
new file mode 100644
index 00000000..69dbc3ad
--- /dev/null
+++ b/tests/Security/SetupStructureTest.php
@@ -0,0 +1,71 @@
+toContain('function plugin_mactrack_install');
+ });
+
+ it('defines plugin_mactrack_uninstall function', function () use ($source) {
+ expect($source)->toContain('function plugin_mactrack_uninstall');
+ });
+
+ it('defines plugin_mactrack_version function', function () use ($source) {
+ expect($source)->toContain('function plugin_mactrack_version');
+ });
+
+ it('defines plugin_mactrack_check_config function', function () use ($source) {
+ expect($source)->toContain('function plugin_mactrack_check_config');
+ });
+
+ it('registers hooks via api_plugin_register_hook', function () use ($source) {
+ expect($source)->toContain("api_plugin_register_hook('mactrack'");
+ });
+
+ it('registers realms via api_plugin_register_realm', function () use ($source) {
+ expect($source)->toContain("api_plugin_register_realm('mactrack'");
+ });
+
+ it('cleans up Default-site retry tracking settings on uninstall', function () use ($source) {
+ expect($source)->toContain('mt_default_site_seed_pending');
+ expect($source)->toContain('mt_default_site_seed_attempts');
+ expect($source)->toContain('mt_default_site_seed_next_retry');
+ });
+
+ it('declares a plugin name in INFO', function () use ($info) {
+ expect($info)->toHaveKey('name');
+ expect($info['name'])->toBe('mactrack');
+ });
+
+ it('declares a plugin version in INFO', function () use ($info) {
+ expect($info)->toHaveKey('version');
+ expect($info['version'])->not->toBe('');
+ expect($info['version'])->toMatch('/^\d+\.\d+$/');
+ });
+});
diff --git a/tests/Security/SqlSafetyAndOutputEscapingTest.php b/tests/Security/SqlSafetyAndOutputEscapingTest.php
new file mode 100644
index 00000000..602d859a
--- /dev/null
+++ b/tests/Security/SqlSafetyAndOutputEscapingTest.php
@@ -0,0 +1,116 @@
+not->toContain("(mtdt.vendor='\" . get_request_var('vendor')");
+ expect($source)->toContain("mtdt.vendor = ' . db_qstr(get_request_var('vendor'))");
+ });
+
+ it('normalizes aggregated MAC bulk-action IDs and uses prepared deletion SQL', function () {
+ $source = plugin_test_read_source('mactrack_view_macs.php');
+
+ expect($source)->toContain('function mactrack_normalize_ids(array $ids): array');
+ expect($source)->toContain("db_execute_prepared('DELETE FROM mac_track_aggregated_ports WHERE row_id IN('");
+ });
+
+ it('escapes MAC authorization and action output instead of deserializing request input', function () {
+ $source = plugin_test_read_source('mactrack_view_macs.php');
+
+ expect($source)->not->toContain("unserialize(get_nfilter_request_var('selected_items')");
+ expect($source)->toContain("json_decode(get_nfilter_request_var('selected_items'), true)");
+ expect($source)->toContain('html_escape(json_encode($mac_address_array))');
+ expect($source)->not->toContain("sanitize_unserialize_selected_items(get_nfilter_request_var('selected_items'))");
+ expect($source)->toContain('html_escape(json_encode($row_array))');
+ expect($source)->toContain("html_escape((string) get_request_var('drp_action'))");
+ });
+
+ it('escapes the rescan executable and script paths before command execution', function () {
+ $source = plugin_test_read_source('lib/mactrack_functions.php');
+
+ expect($source)->toContain("cacti_escapeshellcmd(read_config_option('path_php_binary'))");
+ expect($source)->toContain('cacti_escapeshellarg($command_string)');
+ });
+
+ it('parameterizes stale-process site filtering and normalizes process IDs', function () {
+ $source = plugin_test_read_source('poller_mactrack.php');
+
+ expect($source)->toContain('site_id = ?');
+ expect($source)->toContain("intval(\$p['process_id'])");
+ });
+
+ it('escapes Cabletron SNMP command arguments', function () {
+ $source = plugin_test_read_source('lib/mactrack_cabletron.php');
+
+ expect($source)->toContain("cacti_escapeshellcmd(read_config_option('path_snmpgetnext'))");
+ expect($source)->toContain('cacti_escapeshellarg($device[\'hostname\'] . \':\' . intval($device[\'snmp_port\']))');
+ });
+
+ it('safely quotes the ignored-interfaces RLIKE pattern and normalizes numeric filters', function () {
+ $source = plugin_test_read_source('mactrack_view_interfaces.php');
+
+ expect($source)->toContain('mactrack_get_ignore_ports_predicate($sql_params)');
+ expect($source)->toContain('db_fetch_assoc_prepared($sql_query, $sql_params)');
+ expect($source)->toContain('db_fetch_cell_prepared($rows_query_string, $sql_params)');
+ expect($source)->not->toContain('db_qstr($match)');
+ expect($source)->not->toContain('db_qstr_rlike');
+ expect($source)->toContain("intval(get_filter_request_var('bwusage'))");
+ });
+
+ it('normalizes numeric filters on the device report views', function () {
+ foreach (['mactrack_view_devices.php', 'mactrack_devices.php'] as $file) {
+ $source = plugin_test_read_source($file);
+
+ expect($source)->toContain("intval(get_filter_request_var('status'))");
+ expect($source)->toContain("intval(get_filter_request_var('site_id'))");
+ }
+ });
+
+ it('validates the canonical local-file path before importing an OUI database', function () {
+ $source = plugin_test_read_source('mactrack_import_ouidb.php');
+
+ expect($source)->toContain('function mactrack_validate_oui_file(string $path): string');
+ expect($source)->toContain('is_file($resolved)');
+ expect($source)->toContain('is_readable($resolved)');
+ });
+
+ it('does not call undefined MAC-formatting or graph-settings compatibility handlers', function () {
+ $arpSource = plugin_test_read_source('mactrack_view_arp.php');
+ $ajaxSource = plugin_test_read_source('mactrack_ajax.php');
+
+ expect($arpSource)->toContain('mactrack_format_mac(');
+ expect($arpSource)->not->toContain('format_mac_address(');
+ expect($ajaxSource)->not->toContain('mactrack_save_graph_settings');
+ });
+
+ it('does not gate plugin enablement on the DNS resolver library', function () {
+ // DNS resolution is an optional collector feature, so a missing or
+ // unloadable Net_DNS2 must never leave the plugin stuck in "needs
+ // configuration".
+ $source = plugin_test_read_source('setup.php');
+
+ expect($source)->not->toContain('Net_DNS2');
+ expect($source)->not->toContain('Net/DNS2.php');
+ });
+});
diff --git a/tests/Support/CactiStubs.php b/tests/Support/CactiStubs.php
deleted file mode 100644
index a0938241..00000000
--- a/tests/Support/CactiStubs.php
+++ /dev/null
@@ -1,85 +0,0 @@
- $sql, 'params' => $params];
-
- return true;
-}
-function db_fetch_assoc($sql) {
- return [];
-}
-function db_fetch_assoc_prepared($sql, array $params = []) {
- return [];
-}
-function db_fetch_cell($sql) {
- return null;
-}
-function db_fetch_cell_prepared($sql, array $params = []) {
- return null;
-}
-function db_fetch_row_prepared($sql, array $params = []) {
- return [];
-}
-function db_qstr($value) {
- return "''";
-}
-function read_config_option($name, $force = false) {
- global $mactrack_test_config_options;
-
- return $mactrack_test_config_options[$name] ?? '';
-}
-function set_config_option($name, $value) {
- global $mactrack_test_config_options;
-
- $mactrack_test_config_options[$name] = $value;
-
- return true;
-}
-function get_request_var($name, $default = null) {
- return $default;
-}
-function get_filter_request_var($name, $default = null) {
- return $default;
-}
-function get_nfilter_request_var($name, $default = null) {
- return $default;
-}
-function isset_request_var($name) {
- return false;
-}
-function isempty_request_var($name) {
- return true;
-}
-function set_request_var($name, $value) {
-}
-function validate_store_request_vars(array $filters, $session_name) {
-}
-function form_input_validate($value, $field_name, $regex = '', $allow_null = false, $error_type = 3) {
- return $value;
-}
-function sql_save(array $save, $table, $key = null, $autoincrement = true) {
- return 0;
-}
-function cacti_log($message, $popup = false, $type = '') {
-}
-function cacti_escapeshellcmd($value) {
- return escapeshellcmd($value);
-}
-function cacti_escapeshellarg($value) {
- return escapeshellarg($value);
-}
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 00000000..7b35c3c3
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,48 @@
+assertFalse(mactrack_site_configuration_exists());
+
+ mactrack_test_queue_return('db_fetch_cell_prepared', '2');
+ $this->assertTrue(mactrack_site_configuration_exists());
+ }
+
+ /**
+ * @return void
+ */
+ public function testSeedDefaultSiteSkipsTheLockWhenASiteAlreadyExists(): void {
+ mactrack_test_queue_return_for('db_fetch_cell_prepared', 'COUNT(*) FROM mac_track_sites', '1');
+
+ $this->assertTrue(mactrack_seed_default_site());
+
+ foreach ($GLOBALS['__test_db_calls'] as $call) {
+ $this->assertStringNotContainsString('GET_LOCK', $call['sql']);
+ }
+ }
+
+ /**
+ * @return void
+ */
+ public function testSeedDefaultSiteAcquiresTheLockInsertsAndReleases(): void {
+ // db_fetch_cell_prepared() is called for the initial COUNT(*), then
+ // GET_LOCK, then the post-insert COUNT(*) recheck, then RELEASE_LOCK.
+ // GET_LOCK/RELEASE_LOCK are matched (sticky) since each occurs once;
+ // the two COUNT(*) calls need different answers, so they go through
+ // the plain FIFO queue instead (matched entries are checked first
+ // and never match the COUNT(*) SQL, so they don't interfere).
+ mactrack_test_queue_return_for('db_fetch_cell_prepared', 'GET_LOCK', '1');
+ mactrack_test_queue_return_for('db_fetch_cell_prepared', 'RELEASE_LOCK', '1');
+ mactrack_test_queue_return('db_fetch_cell_prepared', '0');
+ mactrack_test_queue_return('db_fetch_cell_prepared', '1');
+ mactrack_test_queue_return('db_execute_prepared', true);
+
+ $this->assertTrue(mactrack_seed_default_site());
+
+ $sqlCalls = array_column($GLOBALS['__test_db_calls'], 'sql');
+ $this->assertTrue((bool) array_filter($sqlCalls, static fn ($sql) => strpos($sql, 'GET_LOCK') !== false));
+ $this->assertTrue((bool) array_filter($sqlCalls, static fn ($sql) => strpos($sql, 'INSERT INTO mac_track_sites') !== false));
+ $this->assertTrue((bool) array_filter($sqlCalls, static fn ($sql) => strpos($sql, 'RELEASE_LOCK') !== false));
+ }
+
+ /**
+ * @return void
+ */
+ public function testSeedDefaultSiteFailsClosedWhenTheLockCannotBeAcquired(): void {
+ mactrack_test_queue_return_for('db_fetch_cell_prepared', 'COUNT(*) FROM mac_track_sites', '0');
+ mactrack_test_queue_return_for('db_fetch_cell_prepared', 'GET_LOCK', '0');
+
+ $this->assertFalse(mactrack_seed_default_site());
+
+ $sqlCalls = array_column($GLOBALS['__test_db_calls'], 'sql');
+ $this->assertFalse((bool) array_filter($sqlCalls, static fn ($sql) => strpos($sql, 'INSERT INTO mac_track_sites') !== false));
+ }
+
+ /**
+ * @return void
+ */
+ public function testSeedDefaultSiteRecoversIfAnotherWorkerWonTheRace(): void {
+ // First check: no site. Lock denied. Second check (inside the
+ // lock-denied branch): a concurrent worker already inserted one.
+ mactrack_test_queue_return('db_fetch_cell_prepared', '0');
+ mactrack_test_queue_return_for('db_fetch_cell_prepared', 'GET_LOCK', '0');
+ mactrack_test_queue_return('db_fetch_cell_prepared', '1');
+
+ $this->assertTrue(mactrack_seed_default_site());
+ }
+
+ /**
+ * @return void
+ */
+ public function testEnsureDefaultSiteResetsRetryStateOnSuccess(): void {
+ set_config_option('mt_default_site_seed_pending', 'on');
+ set_config_option('mt_default_site_seed_attempts', '2');
+ set_config_option('mt_default_site_seed_next_retry', (string) (time() - 10));
+
+ mactrack_test_queue_return_for('db_fetch_cell_prepared', 'COUNT(*) FROM mac_track_sites', '1');
+
+ $this->assertTrue(mactrack_ensure_default_site(false));
+
+ $this->assertSame('off', read_config_option('mt_default_site_seed_pending'));
+ $this->assertSame('0', read_config_option('mt_default_site_seed_attempts'));
+ $this->assertSame('0', read_config_option('mt_default_site_seed_next_retry'));
+ }
+
+ /**
+ * @return void
+ */
+ public function testEnsureDefaultSiteSchedulesIncreasingBackoffOnRepeatedFailure(): void {
+ mactrack_test_queue_return_for('db_fetch_cell_prepared', 'COUNT(*) FROM mac_track_sites', '0');
+ mactrack_test_queue_return_for('db_fetch_cell_prepared', 'GET_LOCK', '0');
+
+ $before = time();
+ $this->assertFalse(mactrack_ensure_default_site(false));
+
+ $this->assertSame('on', read_config_option('mt_default_site_seed_pending'));
+ $this->assertSame('1', read_config_option('mt_default_site_seed_attempts'));
+ $this->assertGreaterThanOrEqual($before + 60, (int) read_config_option('mt_default_site_seed_next_retry'));
+ }
+
+ /**
+ * @return void
+ */
+ public function testEnsureDefaultSiteDoesNotRetryBeforeTheBackoffWindowElapses(): void {
+ set_config_option('mt_default_site_seed_pending', 'on');
+ set_config_option('mt_default_site_seed_attempts', '1');
+ set_config_option('mt_default_site_seed_next_retry', (string) (time() + 3600));
+
+ mactrack_test_queue_return_for('db_fetch_cell_prepared', 'COUNT(*) FROM mac_track_sites', '0');
+
+ $this->assertFalse(mactrack_ensure_default_site(false));
+
+ foreach ($GLOBALS['__test_db_calls'] as $call) {
+ $this->assertStringNotContainsString('GET_LOCK', $call['sql'], 'A throttled retry must not attempt to take the seed lock');
+ }
+ }
+
+ /**
+ * @return void
+ */
+ public function testEnsureDefaultSiteClearsThrottleWhenTheSiteWasRepairedManually(): void {
+ set_config_option('mt_default_site_seed_pending', 'on');
+ set_config_option('mt_default_site_seed_attempts', '3');
+ set_config_option('mt_default_site_seed_next_retry', (string) (time() + 3600));
+
+ mactrack_test_queue_return_for('db_fetch_cell_prepared', 'COUNT(*) FROM mac_track_sites', '1');
+
+ $this->assertTrue(mactrack_ensure_default_site(false));
+ $this->assertSame('off', read_config_option('mt_default_site_seed_pending'));
+ }
+
+ /**
+ * @return void
+ */
+ public function testRetryDefaultSiteIsANoOpWhenNothingIsPending(): void {
+ set_config_option('mt_default_site_seed_pending', 'off');
+
+ $this->assertTrue(mactrack_retry_default_site());
+ $this->assertSame([], $GLOBALS['__test_db_calls']);
+ }
+
+ /**
+ * @return void
+ */
+ public function testRetryDefaultSiteAttemptsToSeedWhenPending(): void {
+ set_config_option('mt_default_site_seed_pending', 'on');
+ set_config_option('mt_default_site_seed_next_retry', '0');
+ mactrack_test_queue_return_for('db_fetch_cell_prepared', 'COUNT(*) FROM mac_track_sites', '1');
+
+ $this->assertTrue(mactrack_retry_default_site());
+ }
+}
diff --git a/tests/Unit/IgnorePortsPatternTest.php b/tests/Unit/IgnorePortsPatternTest.php
new file mode 100644
index 00000000..8467a034
--- /dev/null
+++ b/tests/Unit/IgnorePortsPatternTest.php
@@ -0,0 +1,152 @@
+
+ */
+ public static function validPatternProvider() {
+ return [
+ 'default' => ['(Vlan|Loopback|Null)'],
+ 'anchored' => ['^(Gi|Te)[0-9/]+$'],
+ 'literal tilde' => ['Port~Channel'],
+ 'escaped tilde' => ['Vlan\\~Trunk'],
+ ];
+ }
+
+ /**
+ * @dataProvider validPatternProvider
+ *
+ * @param string $valid
+ *
+ * @return void
+ */
+ public function testValidPatternsPassThroughUnchanged($valid): void {
+ $this->assertSame($valid, mactrack_validate_ignore_ports_pattern($valid));
+ }
+
+ /**
+ * @return array
+ */
+ public static function invalidPatternProvider() {
+ return [
+ 'empty' => [''],
+ 'null' => [null],
+ 'unclosed group' => ['(Vlan'],
+ 'unclosed class' => ['[a-'],
+ 'catastrophic backtracking' => ['(a+)+$'],
+ ];
+ }
+
+ /**
+ * @dataProvider invalidPatternProvider
+ *
+ * @param mixed $invalid
+ *
+ * @return void
+ */
+ public function testInvalidPatternsFallBackToTheDefault($invalid): void {
+ // A malformed pattern here deliberately makes preg_match() emit a
+ // compilation-failure E_WARNING; mactrack_validate_ignore_ports_pattern()
+ // already suppresses it with @, but PHPUnit's own error handler (this
+ // file's phpunit.xml sets failOnWarning="true") still turns it into a
+ // test failure unless we suspend it for the duration of this call.
+ set_error_handler(static function () {
+ return true;
+ }, E_WARNING);
+
+ try {
+ $this->assertSame('(Vlan|Loopback|Null)', mactrack_validate_ignore_ports_pattern($invalid));
+ } finally {
+ restore_error_handler();
+ }
+ }
+
+ /**
+ * @return void
+ */
+ public function testPredicateBindsTheConfiguredPatternTwice(): void {
+ set_config_option('mt_ignorePorts', '(Vlan|Loopback|Null)');
+
+ $params = [];
+ $predicate = mactrack_get_ignore_ports_predicate($params);
+
+ $this->assertSame('(ifName NOT RLIKE ? AND ifDescr NOT RLIKE ?)', $predicate);
+ $this->assertSame(['(Vlan|Loopback|Null)', '(Vlan|Loopback|Null)'], $params);
+ }
+
+ /**
+ * @return array
+ */
+ public static function needsIgnoreProvider() {
+ return [
+ '-4, no bandwidth filter' => ['-4', -1, true],
+ '-4, with bandwidth' => ['-4', 70, true],
+ '-3, no bandwidth filter' => ['-3', -1, true],
+ '-2, no bandwidth filter' => ['-2', -1, false],
+ '-2, with bandwidth' => ['-2', 70, false],
+ '-1' => ['-1', -1, true],
+ '0' => ['0', -1, true],
+ '1' => ['1', -1, true],
+ '2' => ['2', -1, true],
+ '3' => ['3', -1, true],
+ '7, no bandwidth filter' => ['7', -1, false],
+ '9, no bandwidth filter' => ['9', -1, false],
+ '9, with bandwidth' => ['9', 70, true],
+ '10, no bandwidth filter' => ['10', -1, false],
+ '10, with bandwidth' => ['10', 70, true],
+ '11, no bandwidth filter' => ['11', -1, false],
+ '11, with bandwidth' => ['11', 70, true],
+ ];
+ }
+
+ /**
+ * @dataProvider needsIgnoreProvider
+ *
+ * @param string $issues
+ * @param int $bwusage
+ * @param bool $expected
+ *
+ * @return void
+ */
+ public function testNeedsIgnoreMatchesTheIssuesAndBandwidthFilterCombination($issues, $bwusage, $expected): void {
+ $this->assertSame($expected, mactrack_interface_filter_needs_ignore($issues, $bwusage));
+ }
+
+ /**
+ * @return void
+ */
+ public function testInterfacesQueryUsesTheValidatedPatternAndPreparedSql(): void {
+ $source = plugin_test_read_source('mactrack_view_interfaces.php');
+
+ $this->assertStringContainsString('mactrack_get_ignore_ports_predicate($sql_params)', $source);
+ $this->assertStringContainsString('db_fetch_assoc_prepared($sql_query, $sql_params)', $source);
+ $this->assertStringContainsString('db_fetch_cell_prepared($rows_query_string, $sql_params)', $source);
+ }
+}
diff --git a/tests/Unit/MacFormattingTest.php b/tests/Unit/MacFormattingTest.php
new file mode 100644
index 00000000..883e74f1
--- /dev/null
+++ b/tests/Unit/MacFormattingTest.php
@@ -0,0 +1,67 @@
+
+ */
+ public static function macFormatProvider() {
+ return [
+ 'colon' => ['aa:bb:cc:dd:ee:ff', 'aa:bb:cc:dd:ee:ff'],
+ 'dash' => ['aa-bb-cc-dd-ee-ff', 'aa-bb-cc-dd-ee-ff'],
+ 'raw' => ['aabbccddeeff', 'aabbccddeeff'],
+ 'quad-dash' => ['aabb-ccdd-eeff', 'aabb-ccdd-eeff'],
+ 'dot' => ['aabb.ccdd.eeff', 'aabb.ccdd.eeff'],
+ 'unset' => ['', 'aabbccddeeff'],
+ 'unrecognised' => ['not-a-format', 'aabbccddeeff'],
+ ];
+ }
+
+ /**
+ * @dataProvider macFormatProvider
+ *
+ * @param string $format
+ * @param string $expected
+ *
+ * @return void
+ */
+ public function testFormatsAccordingToMtMacFormat($format, $expected): void {
+ set_config_option('mt_mac_format', $format);
+
+ $this->assertSame($expected, mactrack_format_mac('aabbccddeeff'));
+ }
+
+ /**
+ * @return void
+ */
+ public function testShortOrNullAddressIsReturnedUnchanged(): void {
+ foreach ([null, '', 'aabbcc'] as $short) {
+ $this->assertSame($short, mactrack_format_mac($short));
+ }
+ }
+}
diff --git a/tests/Unit/XformMacAddressTest.php b/tests/Unit/XformMacAddressTest.php
new file mode 100644
index 00000000..691f10dc
--- /dev/null
+++ b/tests/Unit/XformMacAddressTest.php
@@ -0,0 +1,77 @@
+assertSame('NOT USER', xform_mac_address(''));
+ $this->assertSame('NOT USER', xform_mac_address(' '));
+ }
+
+ /**
+ * @return void
+ */
+ public function testColonDelimitedAsciiAddressIsNormalized(): void {
+ $this->assertSame('AABBCCDDEEFF', xform_mac_address('aa:bb:cc:dd:ee:ff'));
+ }
+
+ /**
+ * @return void
+ */
+ public function testDashDelimitedAsciiAddressIsNormalized(): void {
+ $this->assertSame('AABBCCDDEEFF', xform_mac_address('aa-bb-cc-dd-ee-ff'));
+ }
+
+ /**
+ * @return void
+ */
+ public function testHexPrefixedAddressStripsThePrefix(): void {
+ $this->assertSame('AABBCCDDEEFF', xform_mac_address('HEX-00:aa:bb:cc:dd:ee:ff'));
+ $this->assertSame('AABBCCDDEEFF', xform_mac_address('HEX-aa:bb:cc:dd:ee:ff'));
+ }
+
+ /**
+ * @return void
+ */
+ public function testQuotedAndSpacedAddressIsCleaned(): void {
+ $this->assertSame('AABBCCDDEEFF', xform_mac_address('"aa bb cc dd ee ff"'));
+ }
+
+ /**
+ * @return void
+ */
+ public function testShortBinaryOctetsAreConvertedFromBinary(): void {
+ // 6 raw binary bytes (<= 10 chars), as SNMP returns them, rather than
+ // an already-ASCII-formatted address.
+ $binary = "\xaa\xbb\xcc\xdd\xee\xff";
+
+ $this->assertSame('AABBCCDDEEFF', xform_mac_address($binary));
+ }
+}
diff --git a/tests/Unit/test_device_type_sql_safety.php b/tests/Unit/test_device_type_sql_safety.php
deleted file mode 100644
index 1d25c759..00000000
--- a/tests/Unit/test_device_type_sql_safety.php
+++ /dev/null
@@ -1,161 +0,0 @@
-';
-$escaped = htmlspecialchars($payload, ENT_QUOTES, 'UTF-8');
-
-if (strpos($escaped, '