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>
257 lines
10 KiB
PHP
257 lines
10 KiB
PHP
<?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);
|
|
}
|
|
}
|