build: M1 Dockerized development stack with PHP skeleton, config/logging, Tailwind assets, and wrappers

Implements MeshVault M1 (issues #6–11):

- Four-service Docker Compose stack (nginx, PHP-FPM 8.3, PostgreSQL 17, Node assets)
- Slim 4 + PHP-DI routing with escaped PHP views and production/dev error handling
- Immutable validated AppConfig with redacted diagnostics
- JSON stdout Monolog logging with job-scoped context
- Tailwind CSS v4 deterministic locked builds with template scanning
- POSIX and PowerShell dev wrappers (up/down/test/lint/assets/watch)
- Startup config validation fails before FPM serves as unprivileged www-data
- migrate/seed explicitly unavailable until M2 (#12); #10 remains open

Verified via clean-start acceptance on remote host (keith@192.168.2.23).
This commit is contained in:
Keith Solomon committed 2026-10-11 16:45:45 -05:00
1 parent c2747a5762
commit d3e8cf8fae
39 files changed
+5291 -38

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Http;
use MeshVault\Config\AppConfig;
use MeshVault\Http\AppFactory;
use MeshVault\Http\HomeHandler;
use MeshVault\Http\View;
use PHPUnit\Framework\TestCase;
use Slim\Psr7\Factory\ServerRequestFactory;
use Slim\Psr7\Response;
final class AppTest extends TestCase
{
private function app(string $environment = 'production'): \Slim\App
{
self::assertTrue(class_exists(AppFactory::class), 'HTTP AppFactory must exist');
return AppFactory::create(AppConfig::fromEnvironment([
'MESHVAULT_ENV' => $environment,
'MESHVAULT_LIBRARY_DIR' => sys_get_temp_dir(),
'MESHVAULT_DB_HOST' => 'db',
'MESHVAULT_DB_NAME' => 'meshvault',
'MESHVAULT_DB_USER' => 'meshvault',
'MESHVAULT_DB_PASSWORD' => 'private-secret',
]));
}
public function testHomeUsesContainerResolvedHandlerAndHtmlView(): void
{
$app = $this->app();
self::assertInstanceOf(\DI\Container::class, $app->getContainer());
self::assertInstanceOf(HomeHandler::class, $app->getContainer()->get(HomeHandler::class));
$app->getContainer()->set(View::class, new View(dirname(__DIR__, 2) . '/templates'));
$response = $app->handle((new ServerRequestFactory())->createServerRequest('GET', '/'));
self::assertSame(200, $response->getStatusCode());
self::assertSame('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
self::assertStringContainsString('<h1 class="text-blue-600">MeshVault</h1>', (string) $response->getBody());
self::assertSame(HomeHandler::class, array_values($app->getRouteCollector()->getRoutes())[0]->getCallable());
}
public function testViewEscapesDynamicTextAndQuotes(): void
{
self::assertTrue(class_exists(View::class), 'PHP View must exist');
$html = (new View(dirname(__DIR__, 2) . '/templates'))->render('home', ['title' => '<script>"&\'</script>']);
self::assertStringContainsString('&lt;script&gt;&quot;&amp;&#039;&lt;/script&gt;', $html);
self::assertStringNotContainsString('<script>', $html);
}
public function testViewRejectsTraversalBeforeStartingOutputBuffer(): void
{
$level = ob_get_level();
try {
(new View(dirname(__DIR__, 2) . '/templates'))->render('../config/container', []);
self::fail('Traversal must be rejected');
} catch (\InvalidArgumentException $exception) {
self::assertSame('Unknown view.', $exception->getMessage());
self::assertSame($level, ob_get_level());
}
}
public function testViewRestoresOutputBufferWhenTemplateThrows(): void
{
$level = ob_get_level();
try {
(new View(dirname(__DIR__, 2) . '/templates'))->render('home', ['title' => new \stdClass()]);
self::fail('Invalid view value must throw');
} catch (\TypeError) {
self::assertSame($level, ob_get_level());
}
}
public function testNotFound(): void
{
$response = $this->app()->handle((new ServerRequestFactory())->createServerRequest('GET', '/missing'));
self::assertSame(404, $response->getStatusCode());
self::assertStringContainsString('Not Found', (string) $response->getBody());
}
public function testMethodNotAllowed(): void
{
$response = $this->app()->handle((new ServerRequestFactory())->createServerRequest('POST', '/'));
self::assertSame(405, $response->getStatusCode());
self::assertStringContainsString('GET', $response->getHeaderLine('Allow'));
}
public function testProductionExceptionIsGeneric(): void
{
$app = $this->app();
$app->get('/test-only', function (): never {
throw new \RuntimeException('private-secret <script>failure</script>');
});
$response = $app->handle((new ServerRequestFactory())->createServerRequest('GET', '/test-only'));
self::assertSame(500, $response->getStatusCode());
self::assertStringContainsString('Internal Server Error', (string) $response->getBody());
self::assertStringNotContainsString('private-secret', (string) $response->getBody());
self::assertStringNotContainsString('AppTest', (string) $response->getBody());
}
public function testDevelopmentExceptionIsReadableAndEscaped(): void
{
$app = $this->app('development');
$app->get('/test-only', function (): never {
throw new \RuntimeException('Readable <script>failure</script>');
});
$response = $app->handle((new ServerRequestFactory())->createServerRequest('GET', '/test-only'));
self::assertSame(500, $response->getStatusCode());
$body = (string) $response->getBody();
self::assertStringContainsString('Readable &lt;script&gt;failure&lt;/script&gt;', $body);
self::assertStringNotContainsString('<script>', (string) $response->getBody());
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Http;
use PHPUnit\Framework\TestCase;
final class BootstrapTest extends TestCase
{
private function runPhp(string $file, string $mode, bool $valid): array
{
$env = array_merge(getenv(), [
'MESHVAULT_ENV' => $mode,
'MESHVAULT_LIBRARY_DIR' => sys_get_temp_dir(),
'MESHVAULT_DB_HOST' => $valid ? 'db' : '',
'MESHVAULT_DB_NAME' => 'meshvault',
'MESHVAULT_DB_USER' => 'meshvault',
'MESHVAULT_DB_PASSWORD' => 'bootstrap-secret',
]);
$code = 'register_shutdown_function(function () { fwrite(STDERR, "STATUS=" . http_response_code()); });'
. 'require ' . var_export($file, true) . ';';
$process = proc_open([PHP_BINARY, '-r', $code], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, $env);
$out = stream_get_contents($pipes[1]);
$err = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($process), $out, $err];
}
public function testProductionBootstrapFailureIsGeneric500(): void
{
[$exit, $out, $err] = $this->runPhp('public/index.php', 'production', false);
self::assertStringContainsString('Internal Server Error', $out);
self::assertStringContainsString('STATUS=500', $err);
self::assertStringNotContainsString('MESHVAULT_DB_HOST', $out);
self::assertStringNotContainsString('bootstrap-secret', $out . $err);
self::assertStringContainsString('MESHVAULT_DB_HOST', $err);
}
public function testDevelopmentBootstrapFailureIsReadable(): void
{
[$exit, $out, $err] = $this->runPhp('public/index.php', 'development', false);
self::assertStringContainsString('MESHVAULT_DB_HOST', $out);
self::assertStringContainsString('STATUS=500', $err);
self::assertStringNotContainsString('bootstrap-secret', $out . $err);
}
public function testConfigCliSucceedsOnlyForValidConfig(): void
{
self::assertFileExists('bin/check-config.php');
[$exit, $out, $err] = $this->runPhp('bin/check-config.php', 'production', true);
self::assertSame(0, $exit, $err);
[$exit, $out, $err] = $this->runPhp('bin/check-config.php', 'production', false);
self::assertSame(1, $exit);
self::assertStringContainsString('MESHVAULT_DB_HOST', $err);
self::assertStringNotContainsString('bootstrap-secret', $out . $err);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Http;
use PHPUnit\Framework\TestCase;
final class DeploymentTest extends TestCase
{
public function testFpmConfigExistsAndForwardsUndecoratedWorkerLogs(): void
{
$path = dirname(__DIR__, 2) . '/docker/php/zz-meshvault.conf';
self::assertFileExists($path);
$config = file_get_contents($path);
self::assertStringContainsString('clear_env = no', $config);
self::assertStringContainsString('catch_workers_output = yes', $config);
self::assertStringContainsString('decorate_workers_output = no', $config);
}
public function testComposeSuppliesEveryApplicationSettingAndViewDirectory(): void
{
$compose = file_get_contents(dirname(__DIR__, 2) . '/docker-compose.yml');
$app = explode(' db:', explode(' app:', $compose, 2)[1], 2)[0];
foreach (['ENV', 'LOG_LEVEL', 'DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASSWORD'] as $key) {
self::assertStringContainsString('MESHVAULT_' . $key . ':', $app);
}
foreach (['config', 'templates'] as $directory) {
self::assertStringContainsString('./' . $directory . ':/var/www/html/' . $directory . ':ro', $app);
}
}
public function testBuildContextIncludesDependencyAndStartupInputs(): void
{
$ignore = file_get_contents(dirname(__DIR__, 2) . '/.dockerignore');
foreach (['composer.json', 'composer.lock', 'bin/', 'bin/**', 'docker/php/**'] as $input) {
self::assertStringContainsString('!' . $input, $ignore);
}
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use PHPUnit\Framework\TestCase;
final class TaskRunnerTest extends TestCase
{
public function testCommandsFromAnotherWorkingDirectory(): void
{
$root = dirname(__DIR__, 2);
$temporary = sys_get_temp_dir() . '/meshvault-runner-' . bin2hex(random_bytes(6));
mkdir($temporary);
$windows = PHP_OS_FAMILY === 'Windows';
$fake = $temporary . ($windows ? '/docker.cmd' : '/docker');
file_put_contents($fake, $windows
? "@echo off\r\necho cwd=%CD%\r\necho %*\r\nexit /b 37\r\n"
: "#!/bin/sh\nprintf 'cwd=%s\\n' \"\$PWD\"\nprintf '%s\\n' \"\$@\"\nexit 37\n");
chmod($fake, 0755);
$environment = getenv();
$environment[$windows ? 'Path' : 'PATH'] = $temporary . PATH_SEPARATOR . getenv('PATH');
$runner = $windows
? ['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $root . '/dev.ps1']
: ['sh', $root . '/dev.sh'];
try {
foreach (['up', 'down', 'test', 'lint', 'assets', 'watch'] as $command) {
[$code, $output] = $this->invoke(
[...$runner, $command, '--probe', 'two words'],
$temporary,
$environment
);
self::assertSame(37, $code, $command . ': ' . $output);
self::assertStringContainsString('compose', $output);
self::assertStringContainsString('--probe', $output);
self::assertStringContainsString('two words', $output);
self::assertStringContainsString(str_replace('\\', '/', $root), str_replace('\\', '/', $output));
}
foreach (['unknown', 'migrate', 'seed'] as $command) {
[$code, $output] = $this->invoke([...$runner, $command], $temporary, $environment);
self::assertNotSame(0, $code);
self::assertStringNotContainsString('cwd=', $output);
}
foreach (['-v', '--volumes', '--volumes=true'] as $flag) {
[$code, $output] = $this->invoke([...$runner, 'down', $flag], $temporary, $environment);
self::assertNotSame(0, $code);
self::assertStringNotContainsString('cwd=', $output);
}
} finally {
unlink($fake);
rmdir($temporary);
}
}
private function invoke(array $command, string $cwd, array $environment): array
{
$process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd, $environment);
self::assertIsResource($process);
$output = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($process), $output];
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit;
use InvalidArgumentException;
use MeshVault\Config\AppConfig;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class ConfigTest extends TestCase
{
public static function environment(): array
{
return [
'MESHVAULT_LIBRARY_DIR' => sys_get_temp_dir(),
'MESHVAULT_DB_HOST' => 'db',
'MESHVAULT_DB_NAME' => 'meshvault',
'MESHVAULT_DB_USER' => 'meshvault',
'MESHVAULT_DB_PASSWORD' => ' private-secret ',
];
}
public function testDefaultsAndImmutableExplicitInput(): void
{
self::assertTrue(class_exists(AppConfig::class), 'AppConfig must exist');
$config = AppConfig::fromEnvironment(self::environment());
self::assertSame('production', $config->environment);
self::assertSame('info', $config->logLevel);
self::assertSame(5432, $config->dbPort);
self::assertSame(sys_get_temp_dir(), $config->libraryPath);
self::assertSame('db', $config->dbHost);
self::assertSame('meshvault', $config->dbName);
self::assertSame('meshvault', $config->dbUser);
self::assertSame(' private-secret ', $config->dbPassword);
self::assertTrue((new \ReflectionClass($config))->isReadOnly());
$this->expectException(\Error::class);
$config->dbPort = 1234;
}
public static function invalidValues(): iterable
{
foreach (array_keys(self::environment()) as $key) {
foreach ([null, '', ' ', false, 42, [], new \stdClass()] as $value) {
yield [$key, $value];
}
}
foreach (['0', '65536', '-1', '+5432', '1.2', '1e3', ' 5432', '5432x', '', null, 5432] as $port) {
yield ['MESHVAULT_DB_PORT', $port];
}
foreach (['', 'prod', 'Production', 'development ', null, []] as $env) {
yield ['MESHVAULT_ENV', $env];
}
foreach (['', 'verbose', 'INFO', null, false] as $level) {
yield ['MESHVAULT_LOG_LEVEL', $level];
}
foreach ([__FILE__, __DIR__ . '/absent-library', 'relative/path', "bad\0path"] as $path) {
yield ['MESHVAULT_LIBRARY_DIR', $path];
}
yield ['MESHVAULT_DB_HOST', "db\nsecret"];
yield ['MESHVAULT_DB_NAME', "name\0secret"];
yield ['MESHVAULT_DB_USER', ' user '];
}
#[DataProvider('invalidValues')]
public function testInvalidValuesAreRejectedWithoutEchoingInput(string $key, mixed $value): void
{
self::assertTrue(class_exists(AppConfig::class), 'AppConfig must exist');
$environment = self::environment();
$environment[$key] = $value;
try {
AppConfig::fromEnvironment($environment);
self::fail('Expected invalid configuration: ' . $key);
} catch (InvalidArgumentException $error) {
self::assertStringContainsString($key, $error->getMessage());
self::assertStringNotContainsString('private-secret', (string) $error);
if (is_string($value) && strlen($value) > 5) {
self::assertStringNotContainsString($value, $error->getMessage());
}
}
}
public function testRequiredKeysMustBePresent(): void
{
self::assertTrue(class_exists(AppConfig::class), 'AppConfig must exist');
foreach (array_keys(self::environment()) as $key) {
$environment = self::environment();
unset($environment[$key]);
try {
AppConfig::fromEnvironment($environment);
self::fail('Missing key accepted');
} catch (InvalidArgumentException $error) {
self::assertStringContainsString($key, $error->getMessage());
}
}
}
public function testSupportedEnvironmentsLevelsAndPortBoundaries(): void
{
self::assertTrue(class_exists(AppConfig::class), 'AppConfig must exist');
foreach (['development', 'test', 'production'] as $env) {
foreach (['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'] as $level) {
foreach (['1', '65535'] as $port) {
$config = AppConfig::fromEnvironment(self::environment() + [
'MESHVAULT_ENV' => $env, 'MESHVAULT_LOG_LEVEL' => $level, 'MESHVAULT_DB_PORT' => $port,
]);
self::assertSame($env, $config->environment);
self::assertSame($level, $config->logLevel);
self::assertSame((int) $port, $config->dbPort);
}
}
}
}
public function testDiagnosticsAndSerializationDoNotExposePassword(): void
{
self::assertTrue(class_exists(AppConfig::class), 'AppConfig must exist');
$config = AppConfig::fromEnvironment(self::environment());
self::assertStringNotContainsString('private-secret', json_encode($config, JSON_THROW_ON_ERROR));
self::assertStringNotContainsString('private-secret', print_r($config, true));
self::assertStringNotContainsString('private-secret', serialize($config));
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit;
use MeshVault\Logging\LoggerFactory;
use PHPUnit\Framework\TestCase;
use Psr\Log\AbstractLogger;
use Stringable;
final class LoggingTest extends TestCase
{
public function testJsonStdoutFilteringAndIsolatedJobContext(): void
{
self::assertTrue(class_exists(LoggerFactory::class), 'LoggerFactory must exist');
$code = <<<'PHP'
<?php
require 'vendor/autoload.php';
$config = \MeshVault\Config\AppConfig::fromEnvironment([
'MESHVAULT_LIBRARY_DIR' => sys_get_temp_dir(),
'MESHVAULT_DB_HOST' => 'db',
'MESHVAULT_DB_NAME' => 'meshvault',
'MESHVAULT_DB_USER' => 'meshvault',
'MESHVAULT_DB_PASSWORD' => 'private-secret',
'MESHVAULT_LOG_LEVEL' => 'warning',
]);
$base = \MeshVault\Logging\LoggerFactory::create($config);
$a = \MeshVault\Logging\LoggerFactory::forJob($base, 'job-a');
$b = \MeshVault\Logging\LoggerFactory::forJob($base, 'job-b');
$base->info('filtered');
$a->debug('filtered job');
$a->warning("first\nline", ['file' => 'part.stl', 'job_id' => 'spoofed', 'config' => $config]);
$b->error('second', ['count' => 2]);
$base->warning('base');
$a->critical('again');
\MeshVault\Logging\LoggerFactory::forJob($a, 'job-c')->alert('nested');
PHP;
$process = proc_open([PHP_BINARY], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
self::assertIsResource($process);
fwrite($pipes[0], $code);
fclose($pipes[0]);
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
self::assertSame(0, proc_close($process), $stderr);
self::assertSame('', $stderr);
self::assertStringNotContainsString('private-secret', $stdout);
self::assertStringNotContainsString('filtered', $stdout);
$lines = explode("\n", rtrim($stdout, "\r\n"));
self::assertCount(5, $lines);
$records = array_map(
static fn (string $line): array => json_decode($line, true, 512, JSON_THROW_ON_ERROR),
$lines,
);
self::assertSame("first\nline", $records[0]['message']);
self::assertSame('WARNING', $records[0]['level_name']);
self::assertSame('job-a', $records[0]['context']['job_id']);
self::assertSame('part.stl', $records[0]['context']['file']);
self::assertSame('job-b', $records[1]['context']['job_id']);
self::assertSame(2, $records[1]['context']['count']);
self::assertArrayNotHasKey('job_id', $records[2]['context']);
self::assertSame('job-a', $records[3]['context']['job_id']);
self::assertSame('job-c', $records[4]['context']['job_id']);
self::assertArrayHasKey('datetime', $records[0]);
}
public function testJobScopeWorksWithAnyPsrLoggerAndRetainsMessage(): void
{
self::assertTrue(class_exists(LoggerFactory::class), 'LoggerFactory must exist');
$base = new class extends AbstractLogger {
public array $records = [];
public function log($level, string|Stringable $message, array $context = []): void
{
$this->records[] = [$level, $message, $context];
}
};
$message = new class implements Stringable {
public function __toString(): string
{
return 'message';
}
};
LoggerFactory::forJob($base, 'job-a')->notice($message, ['file' => 'part.stl']);
self::assertSame(['notice', $message, ['file' => 'part.stl', 'job_id' => 'job-a']], $base->records[0]);
}
}