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).
125 lines
5.1 KiB
PHP
125 lines
5.1 KiB
PHP
<?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));
|
|
}
|
|
}
|