Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
locales/po/*.mo
\!templates/**
.omc/
18 changes: 18 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "cacti/plugin_mikrotik",
"description": "plugin_mikrotik plugin for Cacti",
"license": "GPL-2.0-or-later",
"require-dev": {
"pestphp/pest": "^1.23"
},
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true
}
},
"autoload-dev": {
"files": [
"tests/bootstrap.php"
]
}
}
14 changes: 14 additions & 0 deletions tests/Pest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/

/*
* Pest configuration file.
*/

require_once __DIR__ . '/bootstrap.php';
103 changes: 103 additions & 0 deletions tests/Security/Php74CompatibilityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/

/*
* Verify plugin source files do not use PHP 8.0+ syntax.
* Cacti 1.2.x plugins must remain compatible with PHP 7.4.
*/

describe('PHP 7.4 compatibility in mikrotik', function () {
$files = array(
'mikrotik.php',
'mikrotik_users.php',
'poller_graphs.php',
'poller_mikrotik.php',
'setup.php',
);

it('does not use str_contains (PHP 8.0)', function () use ($files) {
foreach ($files as $relativeFile) {
$path = realpath(__DIR__ . '/../../' . $relativeFile);

if ($path === false) {
continue;
}

$contents = file_get_contents($path);

if ($contents === false) {
continue;
}

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) {
continue;
}

$contents = file_get_contents($path);

if ($contents === false) {
continue;
}

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) {
continue;
}

$contents = file_get_contents($path);

if ($contents === false) {
continue;
}

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) {
continue;
}

$contents = file_get_contents($path);

if ($contents === false) {
continue;
}

Comment on lines +24 to +97
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These checks silently continue when a target file can’t be resolved/read. That can make the suite pass while skipping the intended verification (e.g., wrong relative paths or missing files). Consider failing the test when a listed file is missing/unreadable so the compatibility guarantee is enforced.

Suggested change
it('does not use str_contains (PHP 8.0)', function () use ($files) {
foreach ($files as $relativeFile) {
$path = realpath(__DIR__ . '/../../' . $relativeFile);
if ($path === false) {
continue;
}
$contents = file_get_contents($path);
if ($contents === false) {
continue;
}
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) {
continue;
}
$contents = file_get_contents($path);
if ($contents === false) {
continue;
}
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) {
continue;
}
$contents = file_get_contents($path);
if ($contents === false) {
continue;
}
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) {
continue;
}
$contents = file_get_contents($path);
if ($contents === false) {
continue;
}
$readFileContents = function (string $relativeFile): string {
$path = realpath(__DIR__ . '/../../' . $relativeFile);
expect($path)->not->toBeFalse(
"Unable to resolve compatibility test target file: {$relativeFile}"
);
$contents = file_get_contents($path);
expect($contents)->not->toBeFalse(
"Unable to read compatibility test target file: {$relativeFile}"
);
return $contents;
};
it('does not use str_contains (PHP 8.0)', function () use ($files, $readFileContents) {
foreach ($files as $relativeFile) {
$contents = $readFileContents($relativeFile);
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, $readFileContents) {
foreach ($files as $relativeFile) {
$contents = $readFileContents($relativeFile);
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, $readFileContents) {
foreach ($files as $relativeFile) {
$contents = $readFileContents($relativeFile);
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, $readFileContents) {
foreach ($files as $relativeFile) {
$contents = $readFileContents($relativeFile);

Copilot uses AI. Check for mistakes.
expect(preg_match('/\?->/', $contents))->toBe(0,
"{$relativeFile} uses nullsafe operator which requires PHP 8.0"
);
}
});
});
61 changes: 61 additions & 0 deletions tests/Security/PreparedStatementConsistencyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/

/*
* Verify migrated files use prepared DB helpers exclusively.
* Catches regressions where raw db_execute/db_fetch_* calls creep back in.
*/

describe('prepared statement consistency in mikrotik', function () {
it('uses prepared DB helpers in all plugin files', function () {
$targetFiles = array(
'mikrotik.php',
'mikrotik_users.php',
'poller_graphs.php',
'poller_mikrotik.php',
'setup.php',
);
Comment on lines +16 to +23
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test currently enforces that the listed plugin files contain zero raw db_execute/db_fetch_* calls, but the repository still has many such calls (e.g., poller_mikrotik.php contains multiple db_execute() usages). As written, the new test suite will fail immediately; either migrate these files to *_prepared in the same PR, or narrow/phase the assertion (e.g., only enforce on already-migrated files or mark the check as skipped until the hardening changes land).

Copilot uses AI. Check for mistakes.

$rawPattern = '/\bdb_(?:execute|fetch_row|fetch_assoc|fetch_cell)\s*\(/';
$preparedPattern = '/\bdb_(?:execute|fetch_row|fetch_assoc|fetch_cell)_prepared\s*\(/';

foreach ($targetFiles as $relativeFile) {
$path = realpath(__DIR__ . '/../../' . $relativeFile);

if ($path === false) {
continue;
}

$contents = file_get_contents($path);

if ($contents === false) {
continue;
Comment on lines +32 to +38
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test continues when a file path can’t be resolved or read, which can let the suite pass without checking anything (e.g., if paths change). Consider making missing/unreadable target files a hard failure so the security regression check remains meaningful.

Suggested change
continue;
}
$contents = file_get_contents($path);
if ($contents === false) {
continue;
throw new RuntimeException("Failed to resolve target file for prepared statement consistency test: {$relativeFile}");
}
$contents = file_get_contents($path);
if ($contents === false) {
throw new RuntimeException("Failed to read target file for prepared statement consistency test: {$relativeFile} ({$path})");

Copilot uses AI. Check for mistakes.
}

$lines = explode("\n", $contents);
$rawCallsOutsideComments = 0;

foreach ($lines as $line) {
$trimmed = ltrim($line);

if (strpos($trimmed, '//') === 0 || strpos($trimmed, '*') === 0 || strpos($trimmed, '#') === 0) {
continue;
}

if (preg_match($rawPattern, $line) && !preg_match($preparedPattern, $line)) {
$rawCallsOutsideComments++;
}
}

Comment on lines +41 to +55
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scan skips only lines that start with //, * or #, but it does not treat lines starting with "/" (or inline comments / block-comment bodies) as comments. That can lead to false positives/negatives. Consider using token_get_all() to ignore comments/strings robustly, or at least expand the skip logic to handle "/" and block-comment state.

Suggested change
$lines = explode("\n", $contents);
$rawCallsOutsideComments = 0;
foreach ($lines as $line) {
$trimmed = ltrim($line);
if (strpos($trimmed, '//') === 0 || strpos($trimmed, '*') === 0 || strpos($trimmed, '#') === 0) {
continue;
}
if (preg_match($rawPattern, $line) && !preg_match($preparedPattern, $line)) {
$rawCallsOutsideComments++;
}
}
$code = '';
foreach (token_get_all($contents) as $token) {
if (is_array($token)) {
$tokenId = $token[0];
$tokenText = $token[1];
if (
$tokenId === T_COMMENT ||
$tokenId === T_DOC_COMMENT ||
$tokenId === T_CONSTANT_ENCAPSED_STRING ||
$tokenId === T_ENCAPSED_AND_WHITESPACE
) {
$code .= str_repeat(' ', strlen($tokenText));
continue;
}
$code .= $tokenText;
continue;
}
$code .= $token;
}
preg_match_all($rawPattern, $code, $rawMatches);
$rawCallsOutsideComments = count($rawMatches[0]);

Copilot uses AI. Check for mistakes.
expect($rawCallsOutsideComments)->toBe(0,
"File {$relativeFile} contains raw (unprepared) DB calls"
);
}
});
});
36 changes: 36 additions & 0 deletions tests/Security/SetupStructureTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/

/*
* Verify setup.php defines required plugin hooks and info function.
*/

describe('mikrotik setup.php structure', function () {
$source = file_get_contents(realpath(__DIR__ . '/../../setup.php'));

Comment on lines +15 to +16
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If realpath() fails (or setup.php is unreadable), file_get_contents() will emit a warning and $source will be false, which can make the failure mode noisy/unclear. Consider asserting the resolved path exists and that file_get_contents() returned a string before running the structural expectations, so the test fails with a clear message.

Suggested change
$source = file_get_contents(realpath(__DIR__ . '/../../setup.php'));
$setupFile = __DIR__ . '/../../setup.php';
$setupPath = realpath($setupFile);
if ($setupPath === false || !is_file($setupPath) || !is_readable($setupPath)) {
throw new RuntimeException("Unable to resolve readable setup.php at {$setupFile}");
}
$source = file_get_contents($setupPath);
if (!is_string($source)) {
throw new RuntimeException("Unable to read setup.php contents from {$setupPath}");
}

Copilot uses AI. Check for mistakes.
it('defines plugin_mikrotik_install function', function () use ($source) {
expect($source)->toContain('function plugin_mikrotik_install');
});

it('defines plugin_mikrotik_version function', function () use ($source) {
expect($source)->toContain('function plugin_mikrotik_version');
});

it('defines plugin_mikrotik_uninstall function', function () use ($source) {
expect($source)->toContain('function plugin_mikrotik_uninstall');
});

it('returns version array with name key', function () use ($source) {
expect($source)->toMatch('/[\'\""]name[\'\""]\s*=>/');
});

it('returns version array with version key', function () use ($source) {
expect($source)->toMatch('/[\'\""]version[\'\""]\s*=>/');
});
});
Loading