Files
breadcrumb-the-shire/tests/Architecture/SecurityRemoteAssetContractTest.php

139 lines
4.2 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
/**
* GR-SEC-004: No external CDN/remote assets in app templates/pages/styles.
*/
final class SecurityRemoteAssetContractTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testNoRemoteScriptOrStylesheetTagsInAppTemplatesAndPages(): void
{
$files = array_merge(
$this->collectFilesByExtension('pages', ['php', 'phtml']),
$this->collectFilesByExtension('templates', ['php', 'phtml']),
$this->collectModuleFilesByExtension('pages', ['php', 'phtml']),
$this->collectModuleFilesByExtension('templates', ['php', 'phtml'])
);
$pattern = '/<(?:script|link)\b[^>]*\b(?:src|href)\s*=\s*["\']\s*(?:https?:)?\/\/(?!localhost|127\.0\.0\.1)/i';
$violations = $this->findPatternViolations($files, $pattern);
self::assertSame(
[],
$violations,
"Remote script/link asset references found (GR-SEC-004):\n" . implode("\n", $violations)
);
}
public function testNoRemoteCssImportsOrFontUrls(): void
{
$files = array_merge(
$this->collectFilesByExtension('web/css', ['css']),
$this->collectModuleFilesByExtension('web/css', ['css'])
);
$importPattern = '/@import\s+(?:url\()?["\']?\s*(?:https?:)?\/\/(?!localhost|127\.0\.0\.1)/i';
$fontPattern = '/url\(\s*["\']?\s*(?:https?:)?\/\/(?!localhost|127\.0\.0\.1).*?\.(?:woff2?|woff|ttf|otf|eot)\b/i';
$violations = array_merge(
$this->findPatternViolations($files, $importPattern),
$this->findPatternViolations($files, $fontPattern)
);
$violations = array_values(array_unique($violations));
sort($violations);
self::assertSame(
[],
$violations,
"Remote CSS imports/font URLs found (GR-SEC-004):\n" . implode("\n", $violations)
);
}
/**
* @param list<string> $extensions
* @return list<string>
*/
private function collectFilesByExtension(string $relativeDir, array $extensions): array
{
$root = $this->projectRootPath();
$dir = $root . '/' . $relativeDir;
if (!is_dir($dir)) {
return [];
}
$result = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (!$file->isFile()) {
continue;
}
if (!in_array(strtolower($file->getExtension()), $extensions, true)) {
continue;
}
$result[] = str_replace($root . '/', '', $file->getPathname());
}
sort($result);
return $result;
}
/**
* @param list<string> $extensions
* @return list<string>
*/
private function collectModuleFilesByExtension(string $moduleSubDir, array $extensions): array
{
$root = $this->projectRootPath();
$modulesDir = $root . '/modules';
if (!is_dir($modulesDir)) {
return [];
}
$result = [];
$entries = scandir($modulesDir) ?: [];
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$dir = $modulesDir . '/' . $entry . '/' . $moduleSubDir;
if (!is_dir($dir)) {
continue;
}
$result = array_merge($result, $this->collectFilesByExtension('modules/' . $entry . '/' . $moduleSubDir, $extensions));
}
$result = array_values(array_unique($result));
sort($result);
return $result;
}
/**
* @param list<string> $files
* @return list<string>
*/
private function findPatternViolations(array $files, string $pattern): array
{
$violations = [];
foreach ($files as $relativePath) {
$content = $this->readProjectFile($relativePath);
if (preg_match($pattern, $content)) {
$violations[] = $relativePath;
}
}
sort($violations);
return $violations;
}
}