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:
163
tests/Http/ErrorDataCollectorTest.php
Normal file
163
tests/Http/ErrorDataCollectorTest.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user