From f403a6704f29be6093fbcde57f4b1c3448a129d4 Mon Sep 17 00:00:00 2001 From: fs Date: Tue, 10 Mar 2026 21:20:08 +0100 Subject: [PATCH] feat(tests): automate 5 remaining guards as contract tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract findPatternMatchesIn*Files() to ProjectFileAssertionSupport trait for reuse. Add contract tests for: - GR-SEC-005: Encryption centralized in Crypto.php - GR-SEC-006: File storage in storage/, not web/ - GR-SEC-002: No PII in error_log() calls - GR-CORE-012: POST-Redirect-GET pattern enforcement - GR-UI-015: i18n key completeness de ↔ en Brings automated guard coverage from 24 to 29 test files. Co-Authored-By: Claude Opus 4.6 --- .../CoreStarterkitContractTest.php | 43 --------- .../Architecture/FileStorageContractTest.php | 85 +++++++++++++++++ .../I18nKeyCompletenessContractTest.php | 86 ++++++++++++++++++ .../PostRedirectGetContractTest.php | 83 +++++++++++++++++ .../ProjectFileAssertionSupport.php | 42 +++++++++ .../SecurityCryptoContractTest.php | 54 +++++++++++ .../SecurityLoggingContractTest.php | 91 +++++++++++++++++++ 7 files changed, 441 insertions(+), 43 deletions(-) create mode 100644 tests/Architecture/FileStorageContractTest.php create mode 100644 tests/Architecture/I18nKeyCompletenessContractTest.php create mode 100644 tests/Architecture/PostRedirectGetContractTest.php create mode 100644 tests/Architecture/SecurityCryptoContractTest.php create mode 100644 tests/Architecture/SecurityLoggingContractTest.php diff --git a/tests/Architecture/CoreStarterkitContractTest.php b/tests/Architecture/CoreStarterkitContractTest.php index b11ff47..986d2bb 100644 --- a/tests/Architecture/CoreStarterkitContractTest.php +++ b/tests/Architecture/CoreStarterkitContractTest.php @@ -3,8 +3,6 @@ namespace MintyPHP\Tests\Architecture; use PHPUnit\Framework\TestCase; -use RecursiveDirectoryIterator; -use RecursiveIteratorIterator; class CoreStarterkitContractTest extends TestCase { @@ -140,45 +138,4 @@ class CoreStarterkitContractTest extends TestCase } } - /** - * @return list - */ - private function findPatternMatchesInPhpFiles(string $relativeDirectory, string $pattern): array - { - return $this->findPatternMatchesInFiles($relativeDirectory, $pattern, ['php']); - } - - /** - * @param list $extensions - * @return list - */ - private function findPatternMatchesInFiles(string $relativeDirectory, string $pattern, array $extensions): 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() || !in_array($file->getExtension(), $extensions, true)) { - continue; - } - - $content = file_get_contents($file->getPathname()); - $this->assertNotFalse($content, 'Could not read file: ' . $file->getPathname()); - - if (!preg_match($pattern, $content)) { - continue; - } - - $relativePath = str_replace($root . '/', '', $file->getPathname()); - $violations[] = $relativePath; - } - - sort($violations); - return $violations; - } } diff --git a/tests/Architecture/FileStorageContractTest.php b/tests/Architecture/FileStorageContractTest.php new file mode 100644 index 0000000..5e01b7d --- /dev/null +++ b/tests/Architecture/FileStorageContractTest.php @@ -0,0 +1,85 @@ +projectRootPath(); + $serviceDir = $root . '/lib/Service'; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($serviceDir)); + + $violations = []; + + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $relativePath = str_replace($root . '/', '', $file->getPathname()); + + if (in_array($relativePath, self::WEB_WRITE_EXEMPT_FILES, true)) { + continue; + } + + $content = (string) file_get_contents($file->getPathname()); + + $hasFileWrite = preg_match('/\bmove_uploaded_file\s*\(/', $content) + || preg_match('/\bfile_put_contents\s*\(/', $content); + + if (!$hasFileWrite) { + continue; + } + + $hasWebPath = preg_match('/[\'"]web\//', $content) + || preg_match('/\/web\/[a-z]/', $content); + + if ($hasWebPath) { + $violations[] = $relativePath; + } + } + + sort($violations); + + $this->assertSame( + [], + $violations, + "Service files writing to web/ directory (GR-SEC-006):\n" . implode("\n", $violations) + ); + } + + public function testNoPagesWriteFilesToFilesystem(): void + { + $violations = array_merge( + $this->findPatternMatchesInPhpFiles('pages', '/\bmove_uploaded_file\s*\(/'), + $this->findPatternMatchesInPhpFiles('pages', '/\bfile_put_contents\s*\(/') + ); + $violations = array_values(array_unique($violations)); + sort($violations); + + $this->assertSame( + [], + $violations, + "Pages must not write files directly — delegate to Service layer (GR-SEC-006):\n" . implode("\n", $violations) + ); + } +} diff --git a/tests/Architecture/I18nKeyCompletenessContractTest.php b/tests/Architecture/I18nKeyCompletenessContractTest.php new file mode 100644 index 0000000..c99d026 --- /dev/null +++ b/tests/Architecture/I18nKeyCompletenessContractTest.php @@ -0,0 +1,86 @@ +readProjectFile('i18n/default_de.json'); + $enContent = $this->readProjectFile('i18n/default_en.json'); + + $this->assertIsArray(json_decode($deContent, true), 'i18n/default_de.json is not valid JSON'); + $this->assertIsArray(json_decode($enContent, true), 'i18n/default_en.json is not valid JSON'); + } + + public function testAllGermanKeysExistInEnglish(): void + { + $de = $this->loadTranslationKeys('i18n/default_de.json'); + $en = $this->loadTranslationKeys('i18n/default_en.json'); + + $missingInEn = array_values(array_diff($de, $en)); + + $this->assertSame( + [], + $missingInEn, + "Keys in default_de.json missing from default_en.json (GR-UI-015):\n" + . implode("\n", $missingInEn) + ); + } + + public function testAllEnglishKeysExistInGerman(): void + { + $de = $this->loadTranslationKeys('i18n/default_de.json'); + $en = $this->loadTranslationKeys('i18n/default_en.json'); + + $missingInDe = array_values(array_diff($en, $de)); + + $this->assertSame( + [], + $missingInDe, + "Keys in default_en.json missing from default_de.json (GR-UI-015):\n" + . implode("\n", $missingInDe) + ); + } + + public function testTranslationValuesAreNonEmpty(): void + { + $deContent = $this->readProjectFile('i18n/default_de.json'); + $enContent = $this->readProjectFile('i18n/default_en.json'); + + $de = json_decode($deContent, true); + $en = json_decode($enContent, true); + + $this->assertIsArray($de); + $this->assertIsArray($en); + + $emptyDe = array_keys(array_filter($de, static fn ($v): bool => trim((string) $v) === '')); + $emptyEn = array_keys(array_filter($en, static fn ($v): bool => trim((string) $v) === '')); + + $this->assertSame([], $emptyDe, "Empty values in default_de.json:\n" . implode("\n", $emptyDe)); + $this->assertSame([], $emptyEn, "Empty values in default_en.json:\n" . implode("\n", $emptyEn)); + } + + /** + * @return list + */ + private function loadTranslationKeys(string $path): array + { + $content = $this->readProjectFile($path); + $data = json_decode($content, true); + $this->assertIsArray($data, $path . ' is not valid JSON'); + + $keys = array_keys($data); + sort($keys, SORT_STRING); + + return $keys; + } +} diff --git a/tests/Architecture/PostRedirectGetContractTest.php b/tests/Architecture/PostRedirectGetContractTest.php new file mode 100644 index 0000000..9bfabc8 --- /dev/null +++ b/tests/Architecture/PostRedirectGetContractTest.php @@ -0,0 +1,83 @@ +projectRootPath() . '/pages'; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($pagesDir)); + + $missing = []; + + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $relativePath = 'pages/' . ltrim(str_replace($pagesDir, '', $file->getPathname()), '/'); + + if ($this->isPrgExempt($relativePath)) { + continue; + } + + $content = (string) file_get_contents($file->getPathname()); + + if (!$this->handlesPostData($content)) { + continue; + } + + // Router::redirect (PRG) or Router::json (AJAX) are both valid responses. + if (!str_contains($content, 'Router::redirect') && !str_contains($content, 'Router::json')) { + $missing[] = $relativePath; + } + } + + sort($missing); + + $this->assertSame( + [], + $missing, + "POST handlers missing Router::redirect() — PRG pattern required (GR-CORE-012):\n" . implode("\n", $missing) + ); + } + + private function isPrgExempt(string $path): bool + { + foreach (self::PRG_EXEMPT_PATTERNS as $pattern) { + if (preg_match($pattern, $path)) { + return true; + } + } + + return false; + } + + private function handlesPostData(string $content): bool + { + return str_contains($content, "isMethod('POST')") + || str_contains($content, 'hasBody(') + || str_contains($content, 'bodyAll()') + || preg_match('/->body\(/', $content) === 1; + } +} diff --git a/tests/Architecture/ProjectFileAssertionSupport.php b/tests/Architecture/ProjectFileAssertionSupport.php index 415e835..ca09f82 100644 --- a/tests/Architecture/ProjectFileAssertionSupport.php +++ b/tests/Architecture/ProjectFileAssertionSupport.php @@ -23,4 +23,46 @@ trait ProjectFileAssertionSupport return $content; } + + /** + * @return list + */ + private function findPatternMatchesInPhpFiles(string $relativeDirectory, string $pattern): array + { + return $this->findPatternMatchesInFiles($relativeDirectory, $pattern, ['php']); + } + + /** + * @param list $extensions + * @return list + */ + private function findPatternMatchesInFiles(string $relativeDirectory, string $pattern, array $extensions): 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() || !in_array($file->getExtension(), $extensions, true)) { + continue; + } + + $content = file_get_contents($file->getPathname()); + $this->assertNotFalse($content, 'Could not read file: ' . $file->getPathname()); + + if (!preg_match($pattern, $content)) { + continue; + } + + $relativePath = str_replace($root . '/', '', $file->getPathname()); + $violations[] = $relativePath; + } + + sort($violations); + return $violations; + } } diff --git a/tests/Architecture/SecurityCryptoContractTest.php b/tests/Architecture/SecurityCryptoContractTest.php new file mode 100644 index 0000000..0ba822c --- /dev/null +++ b/tests/Architecture/SecurityCryptoContractTest.php @@ -0,0 +1,54 @@ +findPatternMatchesInPhpFiles('lib', '/\bopenssl_encrypt\s*\(/'), + $this->findPatternMatchesInPhpFiles('pages', '/\bopenssl_encrypt\s*\(/') + ); + + $violations = array_values(array_filter( + $violations, + static fn (string $path): bool => $path !== self::CRYPTO_FILE + )); + + $this->assertSame( + [], + $violations, + "Raw openssl_encrypt() found outside Crypto.php (GR-SEC-005):\n" . implode("\n", $violations) + ); + } + + public function testNoRawOpensslDecryptOutsideCrypto(): void + { + $violations = array_merge( + $this->findPatternMatchesInPhpFiles('lib', '/\bopenssl_decrypt\s*\(/'), + $this->findPatternMatchesInPhpFiles('pages', '/\bopenssl_decrypt\s*\(/') + ); + + $violations = array_values(array_filter( + $violations, + static fn (string $path): bool => $path !== self::CRYPTO_FILE + )); + + $this->assertSame( + [], + $violations, + "Raw openssl_decrypt() found outside Crypto.php (GR-SEC-005):\n" . implode("\n", $violations) + ); + } +} diff --git a/tests/Architecture/SecurityLoggingContractTest.php b/tests/Architecture/SecurityLoggingContractTest.php new file mode 100644 index 0000000..dc4dcc5 --- /dev/null +++ b/tests/Architecture/SecurityLoggingContractTest.php @@ -0,0 +1,91 @@ +projectRootPath(); + $violations = []; + + foreach (['lib', 'pages'] as $dir) { + $basePath = $root . '/' . $dir; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($basePath)); + + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $content = (string) file_get_contents($file->getPathname()); + $relativePath = str_replace($root . '/', '', $file->getPathname()); + + if (preg_match_all('/^.*\berror_log\s*\(.*$/m', $content, $matches)) { + foreach ($matches[0] as $line) { + foreach (self::PII_VARIABLE_PATTERNS as $piiPattern) { + if (preg_match($piiPattern, $line)) { + $violations[] = $relativePath . ': ' . trim($line); + break; + } + } + } + } + } + } + + sort($violations); + + $this->assertSame( + [], + $violations, + "error_log() calls with potential PII variables (GR-SEC-002):\n" . implode("\n", $violations) + ); + } + + public function testErrorLogUsageIsMinimal(): void + { + $violations = array_merge( + $this->findPatternMatchesInPhpFiles('lib', '/\berror_log\s*\(/'), + $this->findPatternMatchesInPhpFiles('pages', '/\berror_log\s*\(/') + ); + $violations = array_values(array_unique($violations)); + sort($violations); + + $this->assertLessThanOrEqual( + 3, + count($violations), + "error_log() usage growing — consider structured logging (GR-SEC-002). Found in:\n" + . implode("\n", $violations) + ); + } +}