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:
1 parent
c2747a5762
commit
d3e8cf8fae
39 files changed
+5291
-38
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user