forked from fa/breadcrumb-the-shire
feat: add developer error page with request ID tracking and structured logging
Custom-built error handler for the web channel that shows a tabbed debug dashboard (exception, request context, environment, SQL queries) in dev mode and a clean branded error page with copyable request ID in prod mode. All errors are logged as JSON lines to storage/logs/errors/ for lookup by request ID. Uses the project's design tokens for visual consistency. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
155
tests/Http/ErrorRendererTest.php
Normal file
155
tests/Http/ErrorRendererTest.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Http;
|
||||
|
||||
use MintyPHP\Http\ErrorRenderer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ErrorRendererTest extends TestCase
|
||||
{
|
||||
public function testRenderDevReturnsCompleteHtmlDocument(): void
|
||||
{
|
||||
$data = self::sampleData();
|
||||
$html = ErrorRenderer::renderDev($data);
|
||||
|
||||
$this->assertStringContainsString('<!DOCTYPE html>', $html);
|
||||
$this->assertStringContainsString('</html>', $html);
|
||||
$this->assertStringContainsString('<style>', $html);
|
||||
$this->assertStringContainsString('<script>', $html);
|
||||
}
|
||||
|
||||
public function testRenderDevContainsExceptionDetails(): void
|
||||
{
|
||||
$data = self::sampleData();
|
||||
$html = ErrorRenderer::renderDev($data);
|
||||
|
||||
$this->assertStringContainsString('RuntimeException', $html);
|
||||
$this->assertStringContainsString('Test error message', $html);
|
||||
$this->assertStringContainsString('test-request-id-123', $html);
|
||||
}
|
||||
|
||||
public function testRenderDevContainsAllFourTabs(): void
|
||||
{
|
||||
$data = self::sampleData();
|
||||
$html = ErrorRenderer::renderDev($data);
|
||||
|
||||
$this->assertStringContainsString('panel-exception', $html);
|
||||
$this->assertStringContainsString('panel-request', $html);
|
||||
$this->assertStringContainsString('panel-environment', $html);
|
||||
$this->assertStringContainsString('panel-queries', $html);
|
||||
}
|
||||
|
||||
public function testRenderDevEscapesHtmlInExceptionMessage(): void
|
||||
{
|
||||
$data = self::sampleData();
|
||||
$data['exception']['message'] = '<script>alert("xss")</script>';
|
||||
$html = ErrorRenderer::renderDev($data);
|
||||
|
||||
$this->assertStringNotContainsString('<script>alert("xss")</script>', $html);
|
||||
$this->assertStringContainsString('<script>alert("xss")</script>', $html);
|
||||
}
|
||||
|
||||
public function testRenderDevShowsRedactedValues(): void
|
||||
{
|
||||
$data = self::sampleData();
|
||||
$data['request']['headers']['authorization'] = '[REDACTED]';
|
||||
$html = ErrorRenderer::renderDev($data);
|
||||
|
||||
$this->assertStringContainsString('[REDACTED]', $html);
|
||||
}
|
||||
|
||||
public function testRenderProdShowsRequestIdOnly(): void
|
||||
{
|
||||
$html = ErrorRenderer::renderProd('abc-def-123');
|
||||
|
||||
$this->assertStringContainsString('<!DOCTYPE html>', $html);
|
||||
$this->assertStringContainsString('abc-def-123', $html);
|
||||
$this->assertStringContainsString('An unexpected error occurred', $html);
|
||||
$this->assertStringContainsString('Back to start', $html);
|
||||
}
|
||||
|
||||
public function testRenderProdDoesNotContainDebugInfo(): void
|
||||
{
|
||||
$html = ErrorRenderer::renderProd('request-id');
|
||||
|
||||
// Prod page must not render actual debug HTML elements (panels, stack frames).
|
||||
// Note: CSS class names may appear in the inlined stylesheet — we check for HTML attributes only.
|
||||
$this->assertStringNotContainsString('id="panel-exception"', $html);
|
||||
$this->assertStringNotContainsString('id="panel-request"', $html);
|
||||
$this->assertStringNotContainsString('data-panel=', $html);
|
||||
}
|
||||
|
||||
public function testRenderProdEscapesRequestId(): void
|
||||
{
|
||||
$html = ErrorRenderer::renderProd('<script>alert(1)</script>');
|
||||
|
||||
$this->assertStringNotContainsString('<script>alert(1)</script>', $html);
|
||||
$this->assertStringContainsString('<script>', $html);
|
||||
}
|
||||
|
||||
public function testEscMethodEscapesSpecialChars(): void
|
||||
{
|
||||
$this->assertSame('<b>test</b>', ErrorRenderer::esc('<b>test</b>'));
|
||||
$this->assertSame('a & b', ErrorRenderer::esc('a & b'));
|
||||
$this->assertSame('"quoted"', ErrorRenderer::esc('"quoted"'));
|
||||
$this->assertSame('it's', ErrorRenderer::esc("it's"));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{exception: array<string, mixed>, request: array<string, mixed>, environment: array<string, mixed>, queries: array<string, mixed>}
|
||||
*/
|
||||
private static function sampleData(): array
|
||||
{
|
||||
return [
|
||||
'exception' => [
|
||||
'class' => 'RuntimeException',
|
||||
'message' => 'Test error message',
|
||||
'code' => 0,
|
||||
'file' => '/app/web/index.php',
|
||||
'line' => 45,
|
||||
'trace' => [
|
||||
[
|
||||
'file' => '/app/web/index.php',
|
||||
'line' => 45,
|
||||
'function' => null,
|
||||
'class' => null,
|
||||
'code_snippet' => [
|
||||
44 => ' $expected = "abc";',
|
||||
45 => ' throw new RuntimeException("Test");',
|
||||
46 => ' // unreachable',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'request' => [
|
||||
'request_id' => 'test-request-id-123',
|
||||
'channel' => 'web',
|
||||
'method' => 'GET',
|
||||
'url' => '/admin/dashboard',
|
||||
'ip' => '127.0.0.1',
|
||||
'user_agent' => 'TestAgent/1.0',
|
||||
'headers' => ['accept' => 'text/html'],
|
||||
'get' => [],
|
||||
'post' => [],
|
||||
'session_keys' => [],
|
||||
'user_id' => null,
|
||||
'tenant_id' => null,
|
||||
],
|
||||
'environment' => [
|
||||
'php_version' => '8.5.0',
|
||||
'php_sapi' => 'cli',
|
||||
'extensions' => ['Core', 'json', 'mbstring'],
|
||||
'memory_current' => 8 * 1024 * 1024,
|
||||
'memory_peak' => 12 * 1024 * 1024,
|
||||
'env' => ['APP_DEBUG' => 'true', 'APP_ENV' => 'dev'],
|
||||
'modules' => [],
|
||||
],
|
||||
'queries' => [
|
||||
'available' => true,
|
||||
'queries' => [],
|
||||
'start' => microtime(true),
|
||||
'count' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user