chore(agents): implement v2 hardening and enforcement-ready QA
This commit is contained in:
@@ -188,7 +188,7 @@ final class ModuleRegistryTest extends TestCase
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage("must define non-empty 'path' and 'target'");
|
||||
$this->expectExceptionMessage('Manifest schema validation failed');
|
||||
|
||||
ModuleRegistry::boot($this->fixturesDir, ['mod-a']);
|
||||
}
|
||||
@@ -205,7 +205,7 @@ final class ModuleRegistryTest extends TestCase
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage("must define a non-empty 'key'");
|
||||
$this->expectExceptionMessage('Manifest schema validation failed');
|
||||
|
||||
ModuleRegistry::boot($this->fixturesDir, ['mod-a']);
|
||||
}
|
||||
|
||||
183
tests/Architecture/AgentSystemEnforcementContractTest.php
Normal file
183
tests/Architecture/AgentSystemEnforcementContractTest.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class AgentSystemEnforcementContractTest extends TestCase
|
||||
{
|
||||
use AgentSystemContractSupport;
|
||||
use ProjectFileAssertionSupport;
|
||||
|
||||
public function testEnforcementPolicyReferencesOnlyKnownQualityGates(): void
|
||||
{
|
||||
$gates = $this->decodeAgentJson('.agents/checks/quality-gates.json');
|
||||
$policy = $this->decodeAgentJson('.agents/checks/enforcement-policy.json');
|
||||
|
||||
$knownGateIds = array_map(
|
||||
static fn (array $gate): string => (string) ($gate['id'] ?? ''),
|
||||
is_array($gates['gates'] ?? null) ? $gates['gates'] : []
|
||||
);
|
||||
|
||||
$required = is_array($policy['gate_policy']['required_gates_always'] ?? null)
|
||||
? $policy['gate_policy']['required_gates_always']
|
||||
: [];
|
||||
$manual = is_array($policy['gate_policy']['manual_non_blocking'] ?? null)
|
||||
? $policy['gate_policy']['manual_non_blocking']
|
||||
: [];
|
||||
$periodic = is_array($policy['gate_policy']['periodic_non_blocking'] ?? null)
|
||||
? $policy['gate_policy']['periodic_non_blocking']
|
||||
: [];
|
||||
|
||||
foreach (array_merge($required, $manual, $periodic) as $gateId) {
|
||||
$this->assertContains($gateId, $knownGateIds, 'Unknown gate id in enforcement-policy.json: ' . (string) $gateId);
|
||||
}
|
||||
}
|
||||
|
||||
public function testGuardEnforcementMapCoversAllGuardsExactlyOnce(): void
|
||||
{
|
||||
$catalog = $this->decodeAgentJson('.agents/checks/guard-catalog.json');
|
||||
$map = $this->decodeAgentJson('.agents/checks/guard-enforcement-map.json');
|
||||
|
||||
$knownGuardIds = array_map(
|
||||
static fn (array $guard): string => (string) ($guard['id'] ?? ''),
|
||||
is_array($catalog['guards'] ?? null) ? $catalog['guards'] : []
|
||||
);
|
||||
$entries = is_array($map['entries'] ?? null) ? $map['entries'] : [];
|
||||
$mappedGuardIds = array_map(
|
||||
static fn (array $entry): string => (string) ($entry['guard_id'] ?? ''),
|
||||
$entries
|
||||
);
|
||||
|
||||
$this->assertSame($mappedGuardIds, array_values(array_unique($mappedGuardIds)), 'Duplicate guard_id in guard-enforcement-map.json.');
|
||||
|
||||
foreach ($knownGuardIds as $guardId) {
|
||||
$this->assertContains($guardId, $mappedGuardIds, 'Missing guard mapping: ' . $guardId);
|
||||
}
|
||||
foreach ($mappedGuardIds as $guardId) {
|
||||
$this->assertContains($guardId, $knownGuardIds, 'Unknown guard_id in guard-enforcement-map.json: ' . $guardId);
|
||||
}
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$mode = (string) ($entry['enforcement_mode'] ?? '');
|
||||
$this->assertContains(
|
||||
$mode,
|
||||
['automated', 'hybrid', 'reviewer'],
|
||||
'Invalid enforcement_mode in guard-enforcement-map.json for ' . (string) ($entry['guard_id'] ?? '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testAutomatedGuardMappingsReferenceExistingTestFiles(): void
|
||||
{
|
||||
$map = $this->decodeAgentJson('.agents/checks/guard-enforcement-map.json');
|
||||
$entries = is_array($map['entries'] ?? null) ? $map['entries'] : [];
|
||||
$root = $this->projectRootPath();
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$guardId = (string) ($entry['guard_id'] ?? '');
|
||||
$mode = (string) ($entry['enforcement_mode'] ?? '');
|
||||
if ($mode !== 'automated') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sources = is_array($entry['evidence_source'] ?? null) ? $entry['evidence_source'] : [];
|
||||
$testPaths = array_values(array_filter(
|
||||
$sources,
|
||||
static fn ($source): bool => is_string($source) && str_starts_with($source, 'tests/')
|
||||
));
|
||||
|
||||
$this->assertNotSame([], $testPaths, "Automated guard {$guardId} must reference at least one test file in evidence_source.");
|
||||
|
||||
foreach ($testPaths as $testPath) {
|
||||
$this->assertFileExists($root . '/' . $testPath, "Automated guard {$guardId} references missing test file: {$testPath}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testGuardAndGateReferencesAcrossRepoUseKnownIds(): void
|
||||
{
|
||||
$catalog = $this->decodeAgentJson('.agents/checks/guard-catalog.json');
|
||||
$gates = $this->decodeAgentJson('.agents/checks/quality-gates.json');
|
||||
|
||||
$knownGuardIds = array_fill_keys(array_map(
|
||||
static fn (array $guard): string => (string) ($guard['id'] ?? ''),
|
||||
is_array($catalog['guards'] ?? null) ? $catalog['guards'] : []
|
||||
), true);
|
||||
$knownGateIds = array_fill_keys(array_map(
|
||||
static fn (array $gate): string => (string) ($gate['id'] ?? ''),
|
||||
is_array($gates['gates'] ?? null) ? $gates['gates'] : []
|
||||
), true);
|
||||
|
||||
$unknown = [];
|
||||
foreach ($this->idReferenceScanFiles() as $relativePath) {
|
||||
$content = $this->readProjectFile($relativePath);
|
||||
|
||||
preg_match_all('/\bGR-(CORE|TEST|UI|SEC)-[A-Z0-9]{3,}\b/', $content, $guardMatches);
|
||||
foreach (array_unique($guardMatches[0] ?? []) as $id) {
|
||||
if (!isset($knownGuardIds[$id])) {
|
||||
$unknown[] = $relativePath . ': ' . $id;
|
||||
}
|
||||
}
|
||||
|
||||
preg_match_all('/\bQG-[0-9]{3}\b/', $content, $gateMatches);
|
||||
foreach (array_unique($gateMatches[0] ?? []) as $id) {
|
||||
if (!isset($knownGateIds[$id])) {
|
||||
$unknown[] = $relativePath . ': ' . $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$unknown = array_values(array_unique($unknown));
|
||||
sort($unknown);
|
||||
|
||||
$this->assertSame(
|
||||
[],
|
||||
$unknown,
|
||||
"Unknown GR/QG references found in repository:\n" . implode("\n", $unknown)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function idReferenceScanFiles(): array
|
||||
{
|
||||
$root = $this->projectRootPath();
|
||||
$scanTargets = ['CLAUDE.md', '.agents', 'docs', 'tests', 'bin', '.gitea'];
|
||||
$extensions = ['md', 'json', 'php', 'sh', 'yml', 'yaml'];
|
||||
|
||||
$files = [];
|
||||
foreach ($scanTargets as $target) {
|
||||
$fullPath = $root . '/' . $target;
|
||||
if (is_file($fullPath)) {
|
||||
$files[] = $target;
|
||||
continue;
|
||||
}
|
||||
if (!is_dir($fullPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($fullPath, \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
foreach ($iterator as $file) {
|
||||
if (!$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ext = strtolower((string) $file->getExtension());
|
||||
if (!in_array($ext, $extensions, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relativePath = str_replace($root . '/', '', $file->getPathname());
|
||||
$files[] = $relativePath;
|
||||
}
|
||||
}
|
||||
|
||||
$files = array_values(array_unique($files));
|
||||
sort($files);
|
||||
return $files;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ class AgentSystemFileContractTest extends TestCase
|
||||
$jsonFiles = [
|
||||
'.agents/checks/guard-catalog.json',
|
||||
'.agents/checks/quality-gates.json',
|
||||
'.agents/checks/enforcement-policy.json',
|
||||
'.agents/checks/guard-enforcement-map.json',
|
||||
'.agents/contracts/analyst.schema.json',
|
||||
'.agents/contracts/planner.schema.json',
|
||||
'.agents/contracts/executor.schema.json',
|
||||
|
||||
87
tests/Architecture/ApiSessionIsolationContractTest.php
Normal file
87
tests/Architecture/ApiSessionIsolationContractTest.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* GR-SEC-007: API requests must not start session/cookie/remember-me web flows.
|
||||
*/
|
||||
final class ApiSessionIsolationContractTest extends TestCase
|
||||
{
|
||||
use ProjectFileAssertionSupport;
|
||||
|
||||
public function testApiActionsDoNotUseSessionOrWebGuardFlows(): void
|
||||
{
|
||||
$apiFiles = $this->collectFilesByExtension('pages/api/v1', ['php']);
|
||||
self::assertNotSame([], $apiFiles, 'Expected API actions under pages/api/v1.');
|
||||
|
||||
$patterns = [
|
||||
'/\bSession::/' => 'Session::*',
|
||||
'/\bGuard::/' => 'Guard::*',
|
||||
'/\bsetcookie\s*\(/i' => 'setcookie(...)',
|
||||
'/\bsession_start\s*\(/i' => 'session_start(...)',
|
||||
'/remember[-_ ]?me/i' => 'remember-me',
|
||||
'/session[_ ]?timeout/i' => 'session-timeout',
|
||||
];
|
||||
|
||||
$violations = [];
|
||||
foreach ($apiFiles as $relativePath) {
|
||||
$content = $this->readProjectFile($relativePath);
|
||||
foreach ($patterns as $regex => $label) {
|
||||
if (preg_match($regex, $content)) {
|
||||
$violations[] = "{$relativePath} uses {$label}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort($violations);
|
||||
self::assertSame(
|
||||
[],
|
||||
$violations,
|
||||
"API action files contain web-session flow references (GR-SEC-007):\n" . implode("\n", $violations)
|
||||
);
|
||||
}
|
||||
|
||||
public function testApiBootstrapDoesNotStartSessionOrRememberMeFlow(): void
|
||||
{
|
||||
$content = $this->readProjectFile('lib/Http/ApiBootstrap.php');
|
||||
|
||||
self::assertStringNotContainsString('Session::start(', $content);
|
||||
self::assertStringNotContainsString('RememberMe', $content);
|
||||
self::assertStringNotContainsString('CookieStore', $content);
|
||||
self::assertStringNotContainsString('session timeout', strtolower($content));
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ namespace MintyPHP\Tests\Architecture;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* GR-UI-015: Every key in i18n/default_de.json MUST exist in
|
||||
* GR-UI-I18N: Every key in i18n/default_de.json MUST exist in
|
||||
* i18n/default_en.json and vice versa.
|
||||
*/
|
||||
class I18nKeyCompletenessContractTest extends TestCase
|
||||
@@ -31,7 +31,7 @@ class I18nKeyCompletenessContractTest extends TestCase
|
||||
$this->assertSame(
|
||||
[],
|
||||
$missingInEn,
|
||||
"Keys in default_de.json missing from default_en.json (GR-UI-015):\n"
|
||||
"Keys in default_de.json missing from default_en.json (GR-UI-I18N):\n"
|
||||
. implode("\n", $missingInEn)
|
||||
);
|
||||
}
|
||||
@@ -46,7 +46,7 @@ class I18nKeyCompletenessContractTest extends TestCase
|
||||
$this->assertSame(
|
||||
[],
|
||||
$missingInDe,
|
||||
"Keys in default_en.json missing from default_de.json (GR-UI-015):\n"
|
||||
"Keys in default_en.json missing from default_de.json (GR-UI-I18N):\n"
|
||||
. implode("\n", $missingInDe)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* GR-UI-015 (module extension): Every module with an i18n_path must ship
|
||||
* GR-UI-I18N (module extension): Every module with an i18n_path must ship
|
||||
* matching default_de.json and default_en.json with identical keys and
|
||||
* non-empty values.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
use MintyPHP\App\Module\ModuleManifestSchemaValidator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
final class ModuleManifestSchemaValidationContractTest extends TestCase
|
||||
{
|
||||
use ProjectFileAssertionSupport;
|
||||
|
||||
public function testAllModuleManifestsValidateAgainstJsonSchema(): void
|
||||
{
|
||||
$root = $this->projectRootPath();
|
||||
$manifestFiles = glob($root . '/modules/*/module.php') ?: [];
|
||||
|
||||
self::assertNotSame([], $manifestFiles, 'Expected at least one module manifest under modules/*/module.php');
|
||||
|
||||
foreach ($manifestFiles as $manifestFile) {
|
||||
$rawManifest = require $manifestFile;
|
||||
self::assertIsArray($rawManifest, "Manifest {$manifestFile} must return array");
|
||||
|
||||
$moduleId = basename(dirname($manifestFile));
|
||||
$relativeManifestPath = 'modules/' . $moduleId . '/module.php';
|
||||
|
||||
ModuleManifestSchemaValidator::assertValid($rawManifest, $moduleId, $relativeManifestPath);
|
||||
self::addToAssertionCount(1);
|
||||
}
|
||||
}
|
||||
|
||||
public function testSchemaValidatorRejectsUnknownRootProperty(): void
|
||||
{
|
||||
$manifest = [
|
||||
'id' => 'schema-fixture',
|
||||
'version' => '1.0.0',
|
||||
'enabled_by_default' => false,
|
||||
'load_order' => 100,
|
||||
'routes' => [],
|
||||
'public_paths' => [],
|
||||
'container_registrars' => [],
|
||||
'ui_slots' => [],
|
||||
'search_resources' => [],
|
||||
'asset_groups' => [],
|
||||
'layout_context_providers' => [],
|
||||
'session_providers' => [],
|
||||
'authorization_policies' => [],
|
||||
'permissions' => [],
|
||||
'scheduler_jobs' => [],
|
||||
'event_listeners' => [],
|
||||
'deactivation_handler' => null,
|
||||
'migrations_path' => null,
|
||||
'i18n_path' => null,
|
||||
'unexpected_root_property' => true,
|
||||
];
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage("Manifest schema validation failed for module 'schema-fixture'");
|
||||
|
||||
ModuleManifestSchemaValidator::assertValid(
|
||||
$manifest,
|
||||
'schema-fixture',
|
||||
'modules/schema-fixture/module.php'
|
||||
);
|
||||
}
|
||||
}
|
||||
138
tests/Architecture/SecurityRemoteAssetContractTest.php
Normal file
138
tests/Architecture/SecurityRemoteAssetContractTest.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user