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,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('<script>"&'</script>', $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 <script>failure</script>', $body);
|
||||
self::assertStringNotContainsString('<script>', (string) $response->getBody());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user