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:
2026-03-24 20:49:22 +01:00
parent c34e62d729
commit ad9101df8e
11 changed files with 1996 additions and 0 deletions

View File

@@ -31,6 +31,7 @@ class TypographyTokenContractTest extends TestCase
'web/css/vendor-overrides/',
'web/css/base/variables.base.css',
'web/css/base/typography.tokens.css',
'web/css/pages/app-error-debug.css', // Self-contained error page — inlined by ErrorRenderer, cannot use app tokens.
];
/**

View File

@@ -0,0 +1,163 @@
<?php
namespace MintyPHP\Tests\Http;
use MintyPHP\Http\ErrorDataCollector;
use MintyPHP\Http\RequestContext;
use PHPUnit\Framework\TestCase;
class ErrorDataCollectorTest extends TestCase
{
private array $serverBackup = [];
protected function setUp(): void
{
$this->serverBackup = $_SERVER;
RequestContext::resetForTests();
}
protected function tearDown(): void
{
$_SERVER = $this->serverBackup;
RequestContext::resetForTests();
}
public function testCollectReturnsAllFourSections(): void
{
$exception = new \RuntimeException('Test exception', 99);
$result = ErrorDataCollector::collect($exception);
$this->assertArrayHasKey('exception', $result);
$this->assertArrayHasKey('request', $result);
$this->assertArrayHasKey('environment', $result);
$this->assertArrayHasKey('queries', $result);
}
public function testExceptionSectionContainsCorrectDetails(): void
{
$exception = new \LogicException('Something wrong', 7);
$result = ErrorDataCollector::collect($exception);
$exc = $result['exception'];
$this->assertSame('LogicException', $exc['class']);
$this->assertSame('Something wrong', $exc['message']);
$this->assertSame(7, $exc['code']);
$this->assertStringContainsString('ErrorDataCollectorTest.php', $exc['file']);
$this->assertIsInt($exc['line']);
$this->assertIsArray($exc['trace']);
$this->assertNotEmpty($exc['trace']);
}
public function testChainedExceptionsAreCollected(): void
{
$inner = new \InvalidArgumentException('inner cause');
$outer = new \RuntimeException('outer', 0, $inner);
$result = ErrorDataCollector::collect($outer);
$this->assertArrayHasKey('previous', $result['exception']);
$this->assertSame('InvalidArgumentException', $result['exception']['previous']['class']);
$this->assertSame('inner cause', $result['exception']['previous']['message']);
}
public function testCodeSnippetIsExtractedForKnownFile(): void
{
$exception = new \RuntimeException('snippet test');
$result = ErrorDataCollector::collect($exception);
$firstFrame = $result['exception']['trace'][0] ?? null;
$this->assertNotNull($firstFrame);
$this->assertArrayHasKey('code_snippet', $firstFrame);
// The snippet should contain lines around the throw site.
$this->assertNotEmpty($firstFrame['code_snippet']);
}
public function testRequestSectionContainsBasicData(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/admin/users/edit?id=5';
$_SERVER['REMOTE_ADDR'] = '10.0.0.1';
$_SERVER['HTTP_USER_AGENT'] = 'TestAgent/1.0';
$exception = new \RuntimeException('request test');
$result = ErrorDataCollector::collect($exception);
$req = $result['request'];
$this->assertSame('POST', $req['method']);
$this->assertSame('/admin/users/edit?id=5', $req['url']);
$this->assertSame('10.0.0.1', $req['ip']);
$this->assertSame('TestAgent/1.0', $req['user_agent']);
}
public function testHeadersAreRedacted(): void
{
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer secret-token';
$_SERVER['HTTP_COOKIE'] = 'session=abc123';
$_SERVER['HTTP_X_API_KEY'] = 'my-key';
$_SERVER['HTTP_ACCEPT'] = 'text/html';
$exception = new \RuntimeException('header redaction');
$result = ErrorDataCollector::collect($exception);
$headers = $result['request']['headers'];
$this->assertSame('[REDACTED]', $headers['authorization']);
$this->assertSame('[REDACTED]', $headers['cookie']);
$this->assertSame('[REDACTED]', $headers['x-api-key']);
$this->assertSame('text/html', $headers['accept']);
}
public function testPostParamsAreRedacted(): void
{
$_POST = [
'username' => 'admin',
'password' => 'secret123',
'csrf_token' => 'abc',
'email' => 'test@example.com',
];
$exception = new \RuntimeException('post redaction');
$result = ErrorDataCollector::collect($exception);
$post = $result['request']['post'];
$this->assertSame('admin', $post['username']);
$this->assertSame('[REDACTED]', $post['password']);
$this->assertSame('[REDACTED]', $post['csrf_token']);
$this->assertSame('test@example.com', $post['email']);
$_POST = [];
}
public function testEnvironmentSectionContainsPhpInfo(): void
{
$exception = new \RuntimeException('env test');
$result = ErrorDataCollector::collect($exception);
$env = $result['environment'];
$this->assertSame(PHP_VERSION, $env['php_version']);
$this->assertSame(PHP_SAPI, $env['php_sapi']);
$this->assertIsArray($env['extensions']);
$this->assertNotEmpty($env['extensions']);
$this->assertIsInt($env['memory_current']);
$this->assertIsInt($env['memory_peak']);
}
public function testArgsAreSummarizedByTypeOnly(): void
{
// Trace frames should contain type summaries, not actual argument values.
$exception = new \RuntimeException('args test');
$result = ErrorDataCollector::collect($exception);
foreach ($result['exception']['trace'] as $frame) {
if (isset($frame['args_summary'])) {
foreach ($frame['args_summary'] as $summary) {
$this->assertIsString($summary);
// Should be type descriptions, not raw values.
$this->assertMatchesRegularExpression(
'/^(null|true|false|int|float|string\(\d+\)|array\(\d+\)|resource|unknown|[A-Z][\w\\\\]*)$/',
$summary,
);
}
}
}
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace MintyPHP\Tests\Http;
use MintyPHP\Http\ErrorLogger;
use PHPUnit\Framework\TestCase;
class ErrorLoggerTest extends TestCase
{
private string $logFile;
protected function setUp(): void
{
$this->logFile = 'storage/logs/errors/error-' . date('Y-m-d') . '.log';
// Remove the file so each test starts clean.
if (is_file($this->logFile)) {
@unlink($this->logFile);
}
}
public function testLogWritesJsonLineEntry(): void
{
$exception = new \RuntimeException('Test error', 42);
$data = [
'exception' => [
'class' => 'RuntimeException',
'message' => 'Test error',
'code' => 42,
'file' => '/app/web/index.php',
'line' => 10,
'trace' => [
['file' => '/app/web/index.php', 'line' => 10],
],
],
'request' => [
'request_id' => 'abc-123',
'channel' => 'web',
'method' => 'GET',
'url' => '/admin/dashboard',
'ip' => '127.0.0.1',
'user_id' => 5,
'tenant_id' => 1,
],
'environment' => [
'php_version' => '8.5.0',
'memory_peak' => 12 * 1024 * 1024,
],
'queries' => [],
];
ErrorLogger::log($exception, $data);
$this->assertFileExists($this->logFile);
$lines = array_filter(explode("\n", (string) file_get_contents($this->logFile)));
$this->assertCount(1, $lines);
$entry = json_decode($lines[0], true);
$this->assertIsArray($entry);
$this->assertSame('error', $entry['level']);
$this->assertSame('abc-123', $entry['request_id']);
$this->assertSame('RuntimeException', $entry['exception']['class']);
$this->assertSame('Test error', $entry['exception']['message']);
$this->assertArrayHasKey('ip_hash', $entry['request']);
$this->assertStringStartsWith('sha256:', $entry['request']['ip_hash']);
// Raw IP should NOT be in the log.
$this->assertStringNotContainsString('127.0.0.1', json_encode($entry));
}
public function testLogRedactsIpAddress(): void
{
$exception = new \RuntimeException('IP test');
$data = [
'request' => ['ip' => '192.168.1.42', 'request_id' => 'test-id'],
'exception' => [],
'environment' => [],
];
ErrorLogger::log($exception, $data);
$this->assertFileExists($this->logFile);
$contents = (string) file_get_contents($this->logFile);
$this->assertStringNotContainsString('192.168.1.42', $contents);
$this->assertStringContainsString('sha256:', $contents);
}
public function testLogNeverThrowsOnFailure(): void
{
// Even with broken data, log() must not throw.
$exception = new \RuntimeException('safe test');
ErrorLogger::log($exception, []);
$this->assertTrue(true); // If we get here, no exception was thrown.
}
}

View 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('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;', $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('&lt;script&gt;', $html);
}
public function testEscMethodEscapesSpecialChars(): void
{
$this->assertSame('&lt;b&gt;test&lt;/b&gt;', ErrorRenderer::esc('<b>test</b>'));
$this->assertSame('a &amp; b', ErrorRenderer::esc('a & b'));
$this->assertSame('&quot;quoted&quot;', ErrorRenderer::esc('"quoted"'));
$this->assertSame('it&#039;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,
],
];
}
}