test: add Pest v1 security test infrastructure#95
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a Pest-based security test scaffold for the MikroTik plugin, aiming to add lightweight static checks over key plugin files without requiring a full Cacti runtime.
Changes:
- Adds a Pest bootstrap/config entrypoint (
tests/Pest.php,tests/bootstrap.php) with basic Cacti function stubs. - Adds security-oriented Pest tests to validate
setup.phpstructure, enforce prepared-statement usage, and guard against PHP 8+ syntax usage.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Security/SetupStructureTest.php | Adds assertions about required setup.php functions and version metadata presence. |
| tests/Security/PreparedStatementConsistencyTest.php | Adds a static scan intended to forbid raw db_execute/db_fetch_* calls. |
| tests/Security/Php74CompatibilityTest.php | Adds static scans for a small set of PHP 8.0+ syntax/API usage. |
| tests/Pest.php | Adds Pest entrypoint requiring the test bootstrap. |
| tests/bootstrap.php | Adds a test bootstrap with stubs for selected Cacti/DB helper functions/constants. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| $source = file_get_contents(realpath(__DIR__ . '/../../setup.php')); | ||
|
|
There was a problem hiding this comment.
$source is assigned from file_get_contents(realpath(...)) without checking for realpath()/file_get_contents() failure. If setup.php can’t be resolved/read, this will emit warnings and the expectations will run against false, which can produce confusing failures. Consider asserting the path is not false and that file_get_contents returned a string (or failing the test with a clear message) before running string/regex assertions.
| $source = file_get_contents(realpath(__DIR__ . '/../../setup.php')); | |
| $setupPath = realpath(__DIR__ . '/../../setup.php'); | |
| if ($setupPath === false) { | |
| throw new RuntimeException('Failed to resolve path to setup.php'); | |
| } | |
| $source = file_get_contents($setupPath); | |
| if ($source === false) { | |
| throw new RuntimeException(sprintf('Failed to read setup.php at "%s"', $setupPath)); | |
| } |
| 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*=>/'); | ||
| }); |
There was a problem hiding this comment.
The “name/version key” assertions currently look for a literal associative array syntax ("'name' =>" / "'version' =>") inside setup.php. In this codebase, plugin_mikrotik_version() reads INFO via parse_ini_file and setup.php contains no "'version' =>" entries, so this test will fail even when the plugin version info is correct. Suggestion: validate INFO (e.g., parse_ini_file on INFO and assert required keys), or execute plugin_mikrotik_version() in a controlled stubbed environment and assert the returned array has the expected keys.
| 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', | ||
| ); | ||
|
|
||
| $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) { |
There was a problem hiding this comment.
This test currently enforces that the listed plugin files contain zero raw db_execute/db_fetch_* calls. In the current repository state, those files still contain many raw db_execute(...) calls (e.g., setup.php and poller_mikrotik.php), so this test will fail immediately and block CI. If the migration to *_prepared isn’t part of this PR, consider narrowing the target list to already-migrated files, adding an allowlist for known legacy call sites, or temporarily skipping/marking this test incomplete until the migration lands.
| 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', | |
| ); | |
| $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) { | |
| it('uses prepared DB helpers in migrated plugin files', function () { | |
| $targetFiles = array( | |
| 'mikrotik.php', | |
| 'mikrotik_users.php', | |
| 'poller_graphs.php', | |
| ); | |
| $legacyFiles = array( | |
| 'poller_mikrotik.php', | |
| 'setup.php', | |
| ); | |
| $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) { | |
| if (in_array($relativeFile, $legacyFiles, true)) { | |
| continue; | |
| } |
| if ($path === false) { | ||
| continue; | ||
| } | ||
|
|
||
| $contents = file_get_contents($path); | ||
|
|
||
| if ($contents === false) { | ||
| continue; | ||
| } |
There was a problem hiding this comment.
The test silently continues when realpath() or file_get_contents() fails. That means missing/unreadable files will cause the test to pass and can hide regressions (false negatives). Prefer failing the test with a clear message when a target file can’t be found/read, since these are security consistency checks.
| if ($path === false) { | |
| continue; | |
| } | |
| $contents = file_get_contents($path); | |
| if ($contents === false) { | |
| continue; | |
| } | |
| expect($path)->not->toBeFalse( | |
| "Expected target file {$relativeFile} to exist and be resolvable for prepared statement consistency checks" | |
| ); | |
| $contents = file_get_contents($path); | |
| expect($contents)->not->toBeFalse( | |
| "Expected target file {$relativeFile} at {$path} to be readable for prepared statement consistency checks" | |
| ); |
| 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; | ||
| } | ||
|
|
There was a problem hiding this comment.
Each check silently continues if realpath() or file_get_contents() fails, which can let the PHP-compatibility test pass even if a plugin file is missing/unreadable (false negative). Consider failing with an explicit expectation/message when a file can’t be resolved/read so the test suite remains meaningful.
| 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; | |
| } | |
| $getPluginFileContents = function (string $relativeFile): string { | |
| $path = realpath(__DIR__ . '/../../' . $relativeFile); | |
| expect($path)->not->toBeFalse("Failed to resolve path for {$relativeFile}"); | |
| $contents = file_get_contents($path); | |
| expect($contents)->not->toBeFalse("Failed to read contents of {$relativeFile}"); | |
| return $contents; | |
| }; | |
| it('does not use str_contains (PHP 8.0)', function () use ($files, $getPluginFileContents) { | |
| foreach ($files as $relativeFile) { | |
| $contents = $getPluginFileContents($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, $getPluginFileContents) { | |
| foreach ($files as $relativeFile) { | |
| $contents = $getPluginFileContents($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, $getPluginFileContents) { | |
| foreach ($files as $relativeFile) { | |
| $contents = $getPluginFileContents($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, $getPluginFileContents) { | |
| foreach ($files as $relativeFile) { | |
| $contents = $getPluginFileContents($relativeFile); |
Separates the Pest security test infrastructure into its own PR as requested.