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

+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));
}
}