forked from fa/breadcrumb-the-shire
harden: restore gate baseline, split dev/prod PHP profile, atomic module runtime build
Phase A — Gate Reliability: - Fix typography token violations in app-breadcrumb.css - Switch QG-006 from composer cs:check to direct php-cs-fixer - Align all docs/skills with new gate command Phase B — Production Profile: - Multi-stage Dockerfile: dev (xdebug) / prod (no xdebug) - Compose files target explicit build stages Phase C — Module Runtime Hardening: - Atomic staging+swap build in ModuleRuntimePageBuilder - SHA-256 fingerprint from manifest content + page entries - 11 new regression tests for build safety and fingerprint drift Task: STARTERKIT-HARDENING-ONPREM-001 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
256
tests/App/Module/ModuleRuntimePageBuilderTest.php
Normal file
256
tests/App/Module/ModuleRuntimePageBuilderTest.php
Normal file
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\App\Module;
|
||||
|
||||
use MintyPHP\App\Module\ModuleManifest;
|
||||
use MintyPHP\App\Module\ModuleRuntimePageBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
final class ModuleRuntimePageBuilderTest extends TestCase
|
||||
{
|
||||
private string $tmpDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tmpDir = sys_get_temp_dir() . '/corecore-page-builder-test-' . uniqid();
|
||||
mkdir($this->tmpDir, 0777, true);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->removePath($this->tmpDir);
|
||||
}
|
||||
|
||||
// ── Atomic build / swap ─────────────────────────────────────────
|
||||
|
||||
public function testBuildCreatesRuntimeDirWithCoreAndModuleSymlinks(): void
|
||||
{
|
||||
$coreDir = $this->createCorePages(['admin', 'login']);
|
||||
$moduleA = $this->createModuleWithPages('mod-a', ['mod-a-page']);
|
||||
$runtimeDir = $this->tmpDir . '/runtime/pages';
|
||||
|
||||
$builder = new ModuleRuntimePageBuilder();
|
||||
$result = $builder->build($runtimeDir, $coreDir, ['mod-a' => $moduleA]);
|
||||
|
||||
self::assertSame(2, $result['core_entries']);
|
||||
self::assertSame(1, $result['module_entries']);
|
||||
self::assertDirectoryExists($runtimeDir);
|
||||
self::assertTrue(is_link($runtimeDir . '/admin'));
|
||||
self::assertTrue(is_link($runtimeDir . '/login'));
|
||||
self::assertTrue(is_link($runtimeDir . '/mod-a-page'));
|
||||
}
|
||||
|
||||
public function testBuildWithNoModulesCreatesSymlinkToCore(): void
|
||||
{
|
||||
$coreDir = $this->createCorePages(['admin']);
|
||||
$runtimeDir = $this->tmpDir . '/runtime/pages';
|
||||
mkdir(dirname($runtimeDir), 0777, true);
|
||||
|
||||
$builder = new ModuleRuntimePageBuilder();
|
||||
$result = $builder->build($runtimeDir, $coreDir, []);
|
||||
|
||||
self::assertSame(0, $result['core_entries']);
|
||||
self::assertSame(0, $result['module_entries']);
|
||||
self::assertTrue(is_link($runtimeDir));
|
||||
}
|
||||
|
||||
public function testBuildFailureDoesNotLeavePartialRuntimeState(): void
|
||||
{
|
||||
$coreDir = $this->createCorePages(['shared-page']);
|
||||
// Module has a page that collides with core — will cause RuntimeException
|
||||
$moduleA = $this->createModuleWithPages('mod-a', ['shared-page']);
|
||||
$runtimeDir = $this->tmpDir . '/runtime/pages';
|
||||
|
||||
// Pre-populate a valid runtime dir to ensure it survives the failed build
|
||||
mkdir($runtimeDir, 0777, true);
|
||||
file_put_contents($runtimeDir . '/sentinel.txt', 'existing');
|
||||
|
||||
$builder = new ModuleRuntimePageBuilder();
|
||||
|
||||
try {
|
||||
$builder->build($runtimeDir, $coreDir, ['mod-a' => $moduleA]);
|
||||
self::fail('Expected RuntimeException for page path collision');
|
||||
} catch (RuntimeException $e) {
|
||||
self::assertStringContainsString('collision', $e->getMessage());
|
||||
}
|
||||
|
||||
// The existing runtime dir must still be intact — atomic build
|
||||
// should not have corrupted it
|
||||
self::assertDirectoryExists($runtimeDir);
|
||||
self::assertFileExists($runtimeDir . '/sentinel.txt');
|
||||
self::assertSame('existing', file_get_contents($runtimeDir . '/sentinel.txt'));
|
||||
}
|
||||
|
||||
public function testBuildReplacesExistingRuntimeDir(): void
|
||||
{
|
||||
$coreDir = $this->createCorePages(['admin']);
|
||||
$moduleA = $this->createModuleWithPages('mod-a', ['mod-page']);
|
||||
$runtimeDir = $this->tmpDir . '/runtime/pages';
|
||||
|
||||
// First build
|
||||
$builder = new ModuleRuntimePageBuilder();
|
||||
$builder->build($runtimeDir, $coreDir, ['mod-a' => $moduleA]);
|
||||
self::assertTrue(is_link($runtimeDir . '/mod-page'));
|
||||
|
||||
// Second build with different module
|
||||
$moduleB = $this->createModuleWithPages('mod-b', ['mod-b-page']);
|
||||
$result = $builder->build($runtimeDir, $coreDir, ['mod-b' => $moduleB]);
|
||||
|
||||
self::assertSame(1, $result['core_entries']);
|
||||
self::assertSame(1, $result['module_entries']);
|
||||
self::assertTrue(is_link($runtimeDir . '/mod-b-page'));
|
||||
// Old module page should be gone
|
||||
self::assertFalse(is_link($runtimeDir . '/mod-page'));
|
||||
}
|
||||
|
||||
public function testNoStagingLeftoverOnSuccess(): void
|
||||
{
|
||||
$coreDir = $this->createCorePages(['admin']);
|
||||
$moduleA = $this->createModuleWithPages('mod-a', ['mod-page']);
|
||||
$runtimeDir = $this->tmpDir . '/runtime/pages';
|
||||
|
||||
$builder = new ModuleRuntimePageBuilder();
|
||||
$builder->build($runtimeDir, $coreDir, ['mod-a' => $moduleA]);
|
||||
|
||||
// Staging dir should be cleaned up
|
||||
$parentDir = dirname($runtimeDir);
|
||||
$entries = scandir($parentDir);
|
||||
self::assertNotFalse($entries);
|
||||
$stagingEntries = array_filter(
|
||||
$entries,
|
||||
static fn (string $e): bool => str_starts_with($e, '.pages-staging-'),
|
||||
);
|
||||
self::assertEmpty($stagingEntries, 'Staging directory should be removed after successful build');
|
||||
}
|
||||
|
||||
// ── Fingerprint ─────────────────────────────────────────────────
|
||||
|
||||
public function testFingerprintChangesWhenManifestContentChanges(): void
|
||||
{
|
||||
$moduleDir = $this->tmpDir . '/modules/mod-a';
|
||||
mkdir($moduleDir, 0777, true);
|
||||
file_put_contents($moduleDir . '/module.php', '<?php return ["id" => "mod-a"];');
|
||||
|
||||
$manifest = ModuleManifest::fromArray(['id' => 'mod-a'], $moduleDir);
|
||||
$fp1 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]);
|
||||
|
||||
// Change manifest content
|
||||
file_put_contents($moduleDir . '/module.php', '<?php return ["id" => "mod-a", "version" => "2.0.0"];');
|
||||
|
||||
$fp2 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]);
|
||||
|
||||
self::assertNotSame($fp1, $fp2, 'Fingerprint must change when manifest file content changes');
|
||||
}
|
||||
|
||||
public function testFingerprintChangesWhenPageEntriesChange(): void
|
||||
{
|
||||
$moduleDir = $this->tmpDir . '/modules/mod-a';
|
||||
mkdir($moduleDir . '/pages', 0777, true);
|
||||
file_put_contents($moduleDir . '/module.php', '<?php return ["id" => "mod-a"];');
|
||||
file_put_contents($moduleDir . '/pages/page-a.php', '<?php');
|
||||
|
||||
$manifest = ModuleManifest::fromArray(['id' => 'mod-a'], $moduleDir);
|
||||
$fp1 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]);
|
||||
|
||||
// Add a new page entry
|
||||
file_put_contents($moduleDir . '/pages/page-b.php', '<?php');
|
||||
|
||||
$fp2 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]);
|
||||
|
||||
self::assertNotSame($fp1, $fp2, 'Fingerprint must change when page entries change');
|
||||
}
|
||||
|
||||
public function testFingerprintStableForSameInputs(): void
|
||||
{
|
||||
$moduleDir = $this->tmpDir . '/modules/mod-a';
|
||||
mkdir($moduleDir . '/pages', 0777, true);
|
||||
file_put_contents($moduleDir . '/module.php', '<?php return ["id" => "mod-a"];');
|
||||
file_put_contents($moduleDir . '/pages/page-a.php', '<?php');
|
||||
|
||||
$manifest = ModuleManifest::fromArray(['id' => 'mod-a'], $moduleDir);
|
||||
$fp1 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]);
|
||||
$fp2 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]);
|
||||
|
||||
self::assertSame($fp1, $fp2, 'Fingerprint must be deterministic for same inputs');
|
||||
}
|
||||
|
||||
public function testFingerprintEmptyForNoModules(): void
|
||||
{
|
||||
$fp = ModuleRuntimePageBuilder::expectedFingerprint([]);
|
||||
self::assertSame('', $fp);
|
||||
}
|
||||
|
||||
public function testWriteAndReadFingerprintRoundtrip(): void
|
||||
{
|
||||
$runtimeDir = $this->tmpDir . '/runtime/pages';
|
||||
mkdir(dirname($runtimeDir), 0777, true);
|
||||
|
||||
$moduleDir = $this->tmpDir . '/modules/mod-a';
|
||||
mkdir($moduleDir, 0777, true);
|
||||
file_put_contents($moduleDir . '/module.php', '<?php return ["id" => "mod-a"];');
|
||||
|
||||
$manifest = ModuleManifest::fromArray(['id' => 'mod-a'], $moduleDir);
|
||||
$modules = ['mod-a' => $manifest];
|
||||
|
||||
ModuleRuntimePageBuilder::writeFingerprint($runtimeDir, $modules);
|
||||
|
||||
$expected = ModuleRuntimePageBuilder::expectedFingerprint($modules);
|
||||
$actual = ModuleRuntimePageBuilder::readFingerprint($runtimeDir);
|
||||
|
||||
self::assertSame($expected, $actual);
|
||||
}
|
||||
|
||||
public function testReadFingerprintReturnsEmptyWhenFileDoesNotExist(): void
|
||||
{
|
||||
$runtimeDir = $this->tmpDir . '/runtime/pages';
|
||||
self::assertSame('', ModuleRuntimePageBuilder::readFingerprint($runtimeDir));
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private function createCorePages(array $entries): string
|
||||
{
|
||||
$coreDir = $this->tmpDir . '/pages';
|
||||
mkdir($coreDir, 0777, true);
|
||||
foreach ($entries as $entry) {
|
||||
mkdir($coreDir . '/' . $entry, 0777, true);
|
||||
}
|
||||
return $coreDir;
|
||||
}
|
||||
|
||||
private function createModuleWithPages(string $id, array $pageEntries): ModuleManifest
|
||||
{
|
||||
$moduleDir = $this->tmpDir . '/modules/' . $id;
|
||||
mkdir($moduleDir . '/pages', 0777, true);
|
||||
file_put_contents($moduleDir . '/module.php', '<?php return ' . var_export(['id' => $id], true) . ';');
|
||||
foreach ($pageEntries as $entry) {
|
||||
mkdir($moduleDir . '/pages/' . $entry, 0777, true);
|
||||
}
|
||||
return ModuleManifest::fromArray(['id' => $id], $moduleDir);
|
||||
}
|
||||
|
||||
private function removePath(string $path): void
|
||||
{
|
||||
if (!file_exists($path) && !is_link($path)) {
|
||||
return;
|
||||
}
|
||||
if (is_link($path) || is_file($path)) {
|
||||
@unlink($path);
|
||||
return;
|
||||
}
|
||||
if (!is_dir($path)) {
|
||||
return;
|
||||
}
|
||||
$entries = scandir($path);
|
||||
if ($entries !== false) {
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$this->removePath($path . '/' . $entry);
|
||||
}
|
||||
}
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user