test: add Pest v1 security test infrastructure#29
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>
|
You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool. What Enabling Code Scanning Means:
For more information about GitHub Code Scanning, check out the documentation. |
There was a problem hiding this comment.
Pull request overview
Adds a Pest v1-based security/compatibility test scaffold for the Cycle Cacti plugin, plus repository automation configs intended to support ongoing security hygiene.
Changes:
- Introduces Pest bootstrap/config and three source-scanning security/compatibility tests.
- Adds a minimal
composer.jsonwith Pest as a dev dependency. - Adds CodeQL and Dependabot configuration under
.github/.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
tests/Security/SetupStructureTest.php |
Adds a setup.php structure test for required plugin hooks/version metadata. |
tests/Security/PreparedStatementConsistencyTest.php |
Adds a source scan intended to prevent regressions to raw DB helper usage. |
tests/Security/Php74CompatibilityTest.php |
Adds a source scan to detect a few PHP 8-only features. |
tests/Pest.php |
Pest entrypoint requiring the test bootstrap. |
tests/bootstrap.php |
Provides stubbed Cacti framework functions/constants for isolated testing. |
composer.json |
Adds Pest v1 dev dependency and autoload-dev bootstrap. |
.github/workflows/codeql.yml |
Adds CodeQL workflow (currently excluding PHP changes and scanning non-PHP languages). |
.github/dependabot.yml |
Adds Dependabot configuration (currently lacks Composer updates). |
💡 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 built from file_get_contents(realpath(...)) without checking for realpath() failure or unreadable files. If setup.php is missing or the path resolves to false, this will emit warnings and subsequent expectations will operate on false; consider asserting the file exists/is readable and failing the test early with a clear message.
| $source = file_get_contents(realpath(__DIR__ . '/../../setup.php')); | |
| $setupPath = realpath(__DIR__ . '/../../setup.php'); | |
| if ($setupPath === false) { | |
| throw new RuntimeException('Unable to resolve setup.php path for structure test.'); | |
| } | |
| if (!is_readable($setupPath)) { | |
| throw new RuntimeException(sprintf('setup.php is not readable: %s', $setupPath)); | |
| } | |
| $source = file_get_contents($setupPath); | |
| if ($source === false) { | |
| throw new RuntimeException(sprintf('Failed to read setup.php contents: %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 expectations for name/version keys are currently regexing for PHP array syntax ("name" =>), but plugin_cycle_version() returns parse_ini_file(...)["info"], and the INFO file uses name = / version = format. As written, these assertions will fail even when setup.php is correct; consider either parsing INFO directly in the test, or including setup.php and asserting plugin_cycle_version() returns an array with the expected keys.
| it('uses prepared DB helpers in all plugin files', function () { | ||
| $targetFiles = array( | ||
| 'cycle.php', | ||
| 'functions.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 enforces that cycle.php/functions.php/setup.php contain no raw db_execute/db_fetch_* calls, but the current plugin sources do contain raw calls (e.g., cycle.php uses db_fetch_cell(...), setup.php uses db_execute(...)). That means vendor/bin/pest will fail as soon as this lands unless the plugin code is migrated to *_prepared equivalents or the test is scoped to only the files/lines that have actually been migrated.
| it('uses prepared DB helpers in all plugin files', function () { | |
| $targetFiles = array( | |
| 'cycle.php', | |
| 'functions.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 fully migrated plugin files', function () { | |
| $targetFiles = array( | |
| 'cycle.php', | |
| 'functions.php', | |
| 'setup.php', | |
| ); | |
| /* | |
| * Only enforce the "prepared helpers exclusively" rule for files that | |
| * have completed migration. Add entries here as each file is converted | |
| * away from raw db_execute/db_fetch_* calls. | |
| */ | |
| $migratedFiles = array( | |
| ); | |
| $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, $migratedFiles, true)) { | |
| continue; | |
| } |
| continue; | ||
| } | ||
|
|
||
| $contents = file_get_contents($path); | ||
|
|
||
| if ($contents === false) { | ||
| continue; |
There was a problem hiding this comment.
When realpath() or file_get_contents() fails, the test currently continues, which can yield false positives (the check is silently skipped). Since these are required plugin entrypoints, it’s better to fail the test with a clear error if the file can’t be found/read so CI doesn’t pass while skipping coverage.
| continue; | |
| } | |
| $contents = file_get_contents($path); | |
| if ($contents === false) { | |
| continue; | |
| throw new RuntimeException( | |
| "Required plugin entrypoint file could not be resolved with realpath(): {$relativeFile}" | |
| ); | |
| } | |
| $contents = file_get_contents($path); | |
| if ($contents === false) { | |
| throw new RuntimeException( | |
| "Required plugin entrypoint file could not be read with file_get_contents(): {$relativeFile} ({$path})" | |
| ); |
| 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.
All checks continue when a target file can’t be resolved/read. That can silently skip compatibility verification and still pass the suite; consider failing the test when realpath()/file_get_contents() fails so missing files or permission issues are surfaced.
| 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); | |
| if ($path === false) { | |
| throw new RuntimeException(sprintf('Failed to resolve test target file: %s', $relativeFile)); | |
| } | |
| $contents = file_get_contents($path); | |
| if ($contents === false) { | |
| throw new RuntimeException(sprintf('Failed to read test target file: %s', $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); |
| }, | ||
| "config": { | ||
| "allow-plugins": { | ||
| "pestphp/pest-plugin": true |
There was a problem hiding this comment.
composer.json doesn’t constrain the PHP version (e.g. via require.php or config.platform.php). Since these tests are explicitly intended to verify PHP 7.4 compatibility, consider pinning the platform PHP version so dependency resolution and CI runs consistently reflect 7.4 constraints.
| "pestphp/pest-plugin": true | |
| "pestphp/pest-plugin": true | |
| }, | |
| "platform": { | |
| "php": "7.4.0" |
| paths-ignore: | ||
| - "**/*.php" | ||
| - "**/*.md" | ||
| pull_request: | ||
| branches: [main, master, develop, regression-audit] | ||
| paths-ignore: | ||
| - "**/*.php" | ||
| - "**/*.md" | ||
| schedule: |
There was a problem hiding this comment.
CodeQL is configured to ignore all **/*.php changes and doesn’t include php in the language matrix, so it won’t analyze the primary plugin code in this repo. If the intent is security coverage for the PHP plugin, remove the PHP paths-ignore and add php to the analyzed languages (or otherwise justify why PHP is excluded).
| version: 2 | ||
| updates: | ||
| - package-ecosystem: "npm" | ||
| directory: "/" | ||
| schedule: | ||
| interval: "weekly" | ||
| open-pull-requests-limit: 10 | ||
| - package-ecosystem: "github-actions" | ||
| directory: "/" | ||
| schedule: | ||
| interval: "weekly" | ||
| open-pull-requests-limit: 10 |
There was a problem hiding this comment.
Dependabot is configured for npm, but there’s no package.json/package-lock in the repo, and it doesn’t include composer even though composer.json is now present. Consider removing the unused npm ecosystem entry and adding a composer update config so Pest/dev dependencies get security updates.
…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 #27; will un-draft after that merges to avoid cross-PR merge conflicts. |
Summary
Test plan
composer install && vendor/bin/pestpasses