test: add Pest v1 security test infrastructure#68
test: add Pest v1 security test infrastructure#68somethingwithproof wants to merge 3 commits intoCacti:developfrom
Conversation
Add source-scan tests verifying security patterns (prepared statements, output escaping, auth guards, PHP 7.4 compatibility) remain in place across refactors. Tests run with Pest v1 (PHP 7.3+) and stub the Cacti framework so plugins can be tested in isolation. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
There was a problem hiding this comment.
Pull request overview
Adds a Pest v1-based test scaffold and several “source scan” security/compatibility tests intended to enforce security hardening and PHP 7.4 compatibility for the webseer plugin.
Changes:
- Add Pest bootstrap/config to run tests without a full Cacti install.
- Add tests to validate
setup.phpstructure, prepared-statement usage patterns, and PHP 7.4-compatible syntax. - Add
composer.jsonwith Pest v1 as a dev dependency.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
composer.json |
Introduces dev dependency + autoload config for Pest test execution. |
tests/Pest.php |
Pest configuration entrypoint. |
tests/bootstrap.php |
Stubs key Cacti functions/constants to support isolated test runs. |
tests/Security/SetupStructureTest.php |
Verifies setup.php plugin hooks / version info presence (currently contains assertions that won’t match actual setup.php). |
tests/Security/PreparedStatementConsistencyTest.php |
Scans for raw DB helper calls (currently fails against existing plugin sources). |
tests/Security/Php74CompatibilityTest.php |
Scans for a few PHP 8.0+ constructs (skips missing/unreadable files). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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.
SetupStructureTest is reading setup.php and asserting it contains an array literal with 'name' and 'version' keys ("['"']name['"']\s*=>"), but setup.php's plugin_webseer_version() delegates to parse_ini_file() and does not contain those keys as "=>" in source. This will make the test fail even though the plugin is correct; consider asserting against INFO contents (or calling plugin_webseer_version() with a properly stubbed $config) instead.
| $source = file_get_contents(realpath(__DIR__ . '/../../setup.php')); | ||
|
|
There was a problem hiding this comment.
file_get_contents(realpath(...)) can return false (e.g., if realpath fails), and then later expectations operate on a boolean and/or emit warnings. Prefer asserting the path resolved and the file was read (fail 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 setup.php path for webseer structure test.'); | |
| } | |
| $source = file_get_contents($setupPath); | |
| if ($source === false) { | |
| throw new RuntimeException(sprintf('Failed to read setup.php for webseer structure test: %s', $setupPath)); | |
| } |
| it('uses prepared DB helpers in all plugin files', function () { | ||
| $targetFiles = array( | ||
| 'includes/functions.php', | ||
| 'poller_webseer.php', | ||
| 'remote.php', | ||
| 'setup.php', | ||
| 'webseer.php', | ||
| 'webseer_process.php', | ||
| 'webseer_proxies.php', |
There was a problem hiding this comment.
This test currently fails on the repository's current sources: several listed files (e.g., includes/functions.php, remote.php, setup.php, webseer_proxies.php) contain raw db_execute/db_fetch_* calls. Either (1) scope $targetFiles to only the files that have actually been migrated, or (2) update the plugin code in this PR so the test matches reality, or (3) explicitly exempt DDL/setup logic where prepared statements are not applicable.
| it('uses prepared DB helpers in all plugin files', function () { | |
| $targetFiles = array( | |
| 'includes/functions.php', | |
| 'poller_webseer.php', | |
| 'remote.php', | |
| 'setup.php', | |
| 'webseer.php', | |
| 'webseer_process.php', | |
| 'webseer_proxies.php', | |
| it('uses prepared DB helpers in migrated plugin files', function () { | |
| $targetFiles = array( | |
| 'poller_webseer.php', | |
| 'webseer.php', | |
| 'webseer_process.php', |
| 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 a target file can't be resolved/read, which can turn missing files into false-green results. Since these are expected plugin entrypoints, consider failing the test when realpath() or file_get_contents() fails so regressions are caught.
| if ($path === false) { | |
| continue; | |
| } | |
| $contents = file_get_contents($path); | |
| if ($contents === false) { | |
| continue; | |
| } | |
| expect($path)->not->toBeFalse("Expected plugin file {$relativeFile} to exist and resolve via realpath()"); | |
| $contents = file_get_contents($path); | |
| expect($contents)->not->toBeFalse("Expected plugin file {$relativeFile} to be readable"); |
| 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.
These checks silently continue if a file can't be resolved/read, which can hide missing/renamed files and still pass. Consider asserting the file exists/is readable (and failing with a helpful message) rather than skipping.
| 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 { | |
| $expectedPath = __DIR__ . '/../../' . $relativeFile; | |
| $path = realpath($expectedPath); | |
| expect($path)->not->toBeFalse("Could not resolve expected source file: {$relativeFile}"); | |
| $contents = file_get_contents($path); | |
| expect($contents)->not->toBeFalse("Could not read expected source 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); |
|
|
||
| require_once __DIR__ . '/bootstrap.php'; |
There was a problem hiding this comment.
tests/Pest.php already requires bootstrap.php, and composer.json also autoloads tests/bootstrap.php via autoload-dev. Keeping both is redundant and can mask ordering issues; consider choosing one mechanism (prefer Composer autoload-dev) to avoid double-includes.
| require_once __DIR__ . '/bootstrap.php'; |
…dabot - Throw RuntimeException when realpath/file_get_contents fails (previously silent continue hid unscanned files) - Fix Dependabot ecosystem from npm to composer - Remove committed .omc session artifacts, add .omc/ to .gitignore Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
|
Converted to draft to serialize the stack in this repo. Blocked by #65; will un-draft after that merges to avoid cross-PR merge conflicts. |
Summary
Test plan
composer install && vendor/bin/pestpasses