Add architecture test ViewLayerOutputEscapingContractTest that flags raw <?php echo in .phtml files. Legitimate uses (pre-built ARIA attrs from navActive(), hardcoded role values, pre-rendered HTML fragments) must carry an inline // raw-html-ok: reason marker. Annotated all 11 existing raw echo sites with justification markers. Added guard to catalog, enforcement map (automated), CLAUDE.md security section, and never-do-this list. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
124 lines
4.1 KiB
PHP
124 lines
4.1 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Tests\Architecture;
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* Views (.phtml) MUST use e() for HTML output — raw echo is forbidden (GR-SEC-010).
|
|
*
|
|
* Allowed exceptions (must be marked with an inline comment):
|
|
* <?php echo $var; ?> // raw-html-ok: reason
|
|
*
|
|
* Typical justified uses: pre-escaped ARIA attributes from navActive(), hardcoded
|
|
* HTML-safe ternaries (e.g. 'aria-current="page"'), or pre-built HTML fragments
|
|
* from shared helpers. Each raw echo MUST carry the "raw-html-ok" marker.
|
|
*/
|
|
class ViewLayerOutputEscapingContractTest extends TestCase
|
|
{
|
|
use ProjectFileAssertionSupport;
|
|
|
|
/**
|
|
* Regex matching <?php echo (with optional whitespace variations).
|
|
* Does NOT match <?php e( which is the escaped helper.
|
|
*/
|
|
private const ECHO_PATTERN = '/<\?php\s+echo\b/';
|
|
|
|
/** Inline marker that excuses a raw echo on the same line. */
|
|
private const EXCEPTION_MARKER = 'raw-html-ok';
|
|
|
|
public function testCorePagesViewsUseEscapedOutput(): void
|
|
{
|
|
$violations = $this->scanForRawEcho('pages');
|
|
self::assertSame(
|
|
[],
|
|
$violations,
|
|
"Raw <?php echo found in core pages views without '" . self::EXCEPTION_MARKER . "' marker:\n" . implode("\n", $violations),
|
|
);
|
|
}
|
|
|
|
public function testCoreTemplatesUseEscapedOutput(): void
|
|
{
|
|
$violations = $this->scanForRawEcho('templates');
|
|
self::assertSame(
|
|
[],
|
|
$violations,
|
|
"Raw <?php echo found in core templates without '" . self::EXCEPTION_MARKER . "' marker:\n" . implode("\n", $violations),
|
|
);
|
|
}
|
|
|
|
public function testModuleViewsUseEscapedOutput(): void
|
|
{
|
|
$root = $this->projectRootPath();
|
|
$modulesDir = $root . '/modules';
|
|
if (!is_dir($modulesDir)) {
|
|
self::markTestSkipped('modules/ directory missing.');
|
|
}
|
|
|
|
$allViolations = [];
|
|
foreach (scandir($modulesDir) ?: [] as $entry) {
|
|
if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) {
|
|
continue;
|
|
}
|
|
foreach (['pages', 'templates'] as $subDir) {
|
|
$rel = 'modules/' . $entry . '/' . $subDir;
|
|
if (is_dir($root . '/' . $rel)) {
|
|
$allViolations = array_merge($allViolations, $this->scanForRawEcho($rel));
|
|
}
|
|
}
|
|
}
|
|
|
|
sort($allViolations);
|
|
self::assertSame(
|
|
[],
|
|
$allViolations,
|
|
"Raw <?php echo found in module views without '" . self::EXCEPTION_MARKER . "' marker:\n" . implode("\n", $allViolations),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Scan .phtml files for raw echo statements, returning violations as "file:line: content".
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
private function scanForRawEcho(string $relativeDirectory): array
|
|
{
|
|
$root = $this->projectRootPath();
|
|
$basePath = $root . '/' . $relativeDirectory;
|
|
$this->assertDirectoryExists($basePath, 'Directory not found: ' . $relativeDirectory);
|
|
|
|
$iterator = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($basePath),
|
|
);
|
|
|
|
$violations = [];
|
|
|
|
/** @var \SplFileInfo $file */
|
|
foreach ($iterator as $file) {
|
|
if (!$file->isFile() || $file->getExtension() !== 'phtml') {
|
|
continue;
|
|
}
|
|
|
|
$content = file_get_contents($file->getPathname());
|
|
$this->assertNotFalse($content, 'Could not read file: ' . $file->getPathname());
|
|
|
|
$lines = explode("\n", $content);
|
|
foreach ($lines as $lineNumber => $line) {
|
|
if (!preg_match(self::ECHO_PATTERN, $line)) {
|
|
continue;
|
|
}
|
|
|
|
if (str_contains($line, self::EXCEPTION_MARKER)) {
|
|
continue;
|
|
}
|
|
|
|
$relativePath = str_replace($root . '/', '', $file->getPathname());
|
|
$violations[] = sprintf('%s:%d: %s', $relativePath, $lineNumber + 1, trim($line));
|
|
}
|
|
}
|
|
|
|
sort($violations);
|
|
return $violations;
|
|
}
|
|
}
|