2026-03-24 20:49:22 +01:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace MintyPHP\Tests\Http;
|
|
|
|
|
|
|
|
|
|
use MintyPHP\Http\ErrorRenderer;
|
2026-03-24 22:00:14 +01:00
|
|
|
use MintyPHP\I18n;
|
2026-03-24 20:49:22 +01:00
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
|
|
|
|
|
|
class ErrorRendererTest extends TestCase
|
|
|
|
|
{
|
2026-03-24 22:00:14 +01:00
|
|
|
private string $localeBackup = '';
|
|
|
|
|
private string $defaultLocaleBackup = '';
|
|
|
|
|
|
|
|
|
|
protected function setUp(): void
|
|
|
|
|
{
|
|
|
|
|
$this->localeBackup = (string) (I18n::$locale ?? '');
|
|
|
|
|
$this->defaultLocaleBackup = (string) (I18n::$defaultLocale ?? 'en');
|
|
|
|
|
I18n::$defaultLocale = 'en';
|
|
|
|
|
I18n::$locale = 'en';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function tearDown(): void
|
|
|
|
|
{
|
|
|
|
|
I18n::$locale = $this->localeBackup;
|
|
|
|
|
I18n::$defaultLocale = $this->defaultLocaleBackup;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 20:49:22 +01:00
|
|
|
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);
|
2026-03-24 22:00:14 +01:00
|
|
|
$this->assertStringContainsString('<html lang="en">', $html);
|
2026-03-24 20:49:22 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-03-24 22:00:14 +01:00
|
|
|
$this->assertStringContainsString('role="tablist"', $html);
|
|
|
|
|
$this->assertStringContainsString('aria-controls="panel-exception"', $html);
|
|
|
|
|
$this->assertStringContainsString('role="tabpanel"', $html);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testRenderDevUsesAccessibleToggleButtons(): void
|
|
|
|
|
{
|
|
|
|
|
$data = self::sampleData();
|
|
|
|
|
$html = ErrorRenderer::renderDev($data);
|
|
|
|
|
|
|
|
|
|
$this->assertStringContainsString('app-error-debug-frame__toggle', $html);
|
|
|
|
|
$this->assertStringContainsString('app-error-debug-query__toggle', $html);
|
|
|
|
|
$this->assertStringContainsString('aria-expanded="true"', $html);
|
|
|
|
|
$this->assertStringContainsString('aria-controls="frame-code-0"', $html);
|
|
|
|
|
$this->assertStringContainsString('aria-label="Copy request ID"', $html);
|
2026-03-24 20:49:22 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-03-24 22:00:14 +01:00
|
|
|
$this->assertStringContainsString('<html lang="en">', $html);
|
2026-03-24 20:49:22 +01:00
|
|
|
$this->assertStringContainsString('abc-def-123', $html);
|
|
|
|
|
$this->assertStringContainsString('An unexpected error occurred', $html);
|
|
|
|
|
$this->assertStringContainsString('Back to start', $html);
|
2026-03-24 22:00:14 +01:00
|
|
|
$this->assertStringContainsString('data-copy="abc-def-123"', $html);
|
|
|
|
|
$this->assertStringContainsString('aria-label="Copy request ID"', $html);
|
2026-03-24 20:49:22 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|