fix(error): harden logging, i18n, a11y and handler tests

This commit is contained in:
2026-03-24 22:00:14 +01:00
parent 44a357dfc4
commit a0d816caaa
10 changed files with 515 additions and 100 deletions

View File

@@ -218,6 +218,44 @@
"Failed to send access emails": "Zugangsdaten konnten nicht gesendet werden",
"Read": "Gelesen",
"Home": "Home",
"Exception": "Ausnahme",
"An error occurred": "Ein Fehler ist aufgetreten",
"in %s on line %s": "in %s in Zeile %s",
"Copy request ID": "Request-ID kopieren",
"Error details": "Fehlerdetails",
"Request": "Anfrage",
"Queries": "Abfragen",
"An unexpected error occurred. If the problem persists, please contact support with the reference below.": "Ein unerwarteter Fehler ist aufgetreten. Wenn das Problem weiterhin besteht, kontaktiere bitte den Support mit der untenstehenden Referenz.",
"Stack Trace": "Stack-Trace",
"Caused by": "Verursacht durch",
"URL": "URL",
"User Agent": "User-Agent",
"Headers": "Header",
"GET Parameters": "GET-Parameter",
"POST Parameters": "POST-Parameter",
"Session (keys + types)": "Session (Schlüssel + Typen)",
"Context": "Kontext",
"PHP": "PHP",
"SAPI": "SAPI",
"Memory (current)": "Speicher (aktuell)",
"Memory (peak)": "Speicher (maximal)",
"Application": "Anwendung",
"Active Modules": "Aktive Module",
"Loaded Extensions (%d)": "Geladene Erweiterungen (%d)",
"Query tracking is not available (Debugger disabled or not initialized).": "Query-Tracking ist nicht verfügbar (Debugger deaktiviert oder nicht initialisiert).",
"No queries were executed before the error occurred.": "Vor dem Fehler wurden keine Abfragen ausgeführt.",
"Queries (%d)": "Abfragen (%d)",
"Bad Request": "Ungültige Anfrage",
"Unauthorized": "Nicht autorisiert",
"Forbidden": "Verboten",
"Not Found": "Nicht gefunden",
"Method Not Allowed": "Methode nicht erlaubt",
"Request Timeout": "Zeitüberschreitung der Anfrage",
"Unprocessable Entity": "Nicht verarbeitbare Anfrage",
"Too Many Requests": "Zu viele Anfragen",
"Internal Server Error": "Interner Serverfehler",
"Bad Gateway": "Fehlerhaftes Gateway",
"Service Unavailable": "Dienst nicht verfügbar",
"Users": "Benutzer",
"All users": "Alle Benutzer",
"Avatar": "Avatar",

View File

@@ -218,6 +218,44 @@
"Failed to send access emails": "Failed to send access emails",
"Read": "Read",
"Home": "Home",
"Exception": "Exception",
"An error occurred": "An error occurred",
"in %s on line %s": "in %s on line %s",
"Copy request ID": "Copy request ID",
"Error details": "Error details",
"Request": "Request",
"Queries": "Queries",
"An unexpected error occurred. If the problem persists, please contact support with the reference below.": "An unexpected error occurred. If the problem persists, please contact support with the reference below.",
"Stack Trace": "Stack Trace",
"Caused by": "Caused by",
"URL": "URL",
"User Agent": "User Agent",
"Headers": "Headers",
"GET Parameters": "GET Parameters",
"POST Parameters": "POST Parameters",
"Session (keys + types)": "Session (keys + types)",
"Context": "Context",
"PHP": "PHP",
"SAPI": "SAPI",
"Memory (current)": "Memory (current)",
"Memory (peak)": "Memory (peak)",
"Application": "Application",
"Active Modules": "Active Modules",
"Loaded Extensions (%d)": "Loaded Extensions (%d)",
"Query tracking is not available (Debugger disabled or not initialized).": "Query tracking is not available (Debugger disabled or not initialized).",
"No queries were executed before the error occurred.": "No queries were executed before the error occurred.",
"Queries (%d)": "Queries (%d)",
"Bad Request": "Bad Request",
"Unauthorized": "Unauthorized",
"Forbidden": "Forbidden",
"Not Found": "Not Found",
"Method Not Allowed": "Method Not Allowed",
"Request Timeout": "Request Timeout",
"Unprocessable Entity": "Unprocessable Entity",
"Too Many Requests": "Too Many Requests",
"Internal Server Error": "Internal Server Error",
"Bad Gateway": "Bad Gateway",
"Service Unavailable": "Service Unavailable",
"Users": "Users",
"All users": "All users",
"Avatar": "Avatar",

View File

@@ -40,8 +40,10 @@ final class ErrorHandler
self::$reservedMemory = null;
// Discard any partial output from the failed request.
while (ob_get_level() > 0) {
ob_end_clean();
if (!self::isPhpUnitRuntime()) {
while (ob_get_level() > 0) {
ob_end_clean();
}
}
// Set HTTP 500 if no error status has been set yet.
@@ -187,4 +189,9 @@ final class ErrorHandler
echo "\n" . $exception->getMessage() . "\n" . $exception->getTraceAsString() . "\n";
}
}
private static function isPhpUnitRuntime(): bool
{
return defined('PHPUNIT_COMPOSER_INSTALL') || defined('__PHPUNIT_PHAR__');
}
}

View File

@@ -7,6 +7,7 @@ final class ErrorLogger
private const LOG_DIR = 'storage/logs/errors';
private const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
private const MAX_ROTATED_FILES = 5;
private const REDACTION_MARKER = '[REDACTED]';
public static function log(\Throwable $exception, array $collectedData): void
{
@@ -77,6 +78,7 @@ final class ErrorLogger
$exceptionData = $collectedData['exception'] ?? [];
$requestData = $collectedData['request'] ?? [];
$envData = $collectedData['environment'] ?? [];
$exceptionMessage = (string) ($exceptionData['message'] ?? $exception->getMessage());
$traceSummary = '';
$frames = $exceptionData['trace'] ?? [];
@@ -93,7 +95,8 @@ final class ErrorLogger
'channel' => $requestData['channel'] ?? 'web',
'exception' => [
'class' => $exceptionData['class'] ?? get_class($exception),
'message' => $exceptionData['message'] ?? $exception->getMessage(),
'message' => self::REDACTION_MARKER,
'message_hash' => self::hashMessage($exceptionMessage),
'code' => $exceptionData['code'] ?? $exception->getCode(),
'file' => self::shortenPath($exceptionData['file'] ?? $exception->getFile()),
'line' => $exceptionData['line'] ?? $exception->getLine(),
@@ -101,7 +104,7 @@ final class ErrorLogger
'trace_summary' => trim($traceSummary),
'request' => [
'method' => $requestData['method'] ?? null,
'url' => $requestData['url'] ?? null,
'path' => self::normalizeRequestPath($requestData['url'] ?? null),
'ip_hash' => self::hashIp($requestData['ip'] ?? null),
'user_id' => $requestData['user_id'] ?? null,
'tenant_id' => $requestData['tenant_id'] ?? null,
@@ -120,6 +123,40 @@ final class ErrorLogger
return 'sha256:' . hash('sha256', $ip);
}
private static function hashMessage(string $message): ?string
{
$message = trim($message);
if ($message === '') {
return null;
}
return 'sha256:' . hash('sha256', $message);
}
private static function normalizeRequestPath(mixed $rawUrl): ?string
{
if (!is_string($rawUrl) || trim($rawUrl) === '') {
return null;
}
$path = parse_url($rawUrl, PHP_URL_PATH);
if (!is_string($path) || trim($path) === '') {
return '/';
}
$path = preg_replace('/[[:cntrl:]]+/', '', $path) ?? '';
$path = trim($path);
if ($path === '') {
return '/';
}
if (!str_starts_with($path, '/')) {
$path = '/' . $path;
}
return substr($path, 0, 500);
}
private static function shortenPath(string $path): string
{
$root = getcwd();

View File

@@ -2,6 +2,8 @@
namespace MintyPHP\Http;
use MintyPHP\I18n;
final class ErrorRenderer
{
/**
@@ -24,23 +26,37 @@ final class ErrorRenderer
$css = self::loadAsset(__DIR__ . '/../../web/css/pages/app-error-debug.css');
$js = self::loadAsset(__DIR__ . '/../../web/js/components/app-error-debug.js');
$excClass = self::esc($exc['class'] ?? 'Exception');
$excMessage = self::esc($exc['message'] ?? 'An error occurred');
$excFile = self::esc(self::shortenPath($exc['file'] ?? ''));
$excLine = self::esc((string) ($exc['line'] ?? '?'));
$excClass = self::esc($exc['class'] ?? self::tr('Exception'));
$excMessage = self::esc((string) ($exc['message'] ?? self::tr('An error occurred')));
$excLocation = self::esc(self::tr(
'in %s on line %s',
self::shortenPath((string) ($exc['file'] ?? '')),
(string) ($exc['line'] ?? '?')
));
$exceptionPanel = self::renderExceptionPanel($exc);
$requestPanel = self::renderRequestPanel($req);
$environmentPanel = self::renderEnvironmentPanel($env);
$queryPanel = self::renderQueryPanel($qry);
$lang = self::esc(self::htmlLang());
$statusTextEsc = self::esc($statusText);
$requestIdLabel = self::esc(self::tr('Request ID'));
$copyRequestIdTitle = self::esc(self::tr('Copy request ID'));
$tabsLabel = self::esc(self::tr('Error details'));
$tabException = self::esc(self::tr('Exception'));
$tabRequest = self::esc(self::tr('Request'));
$tabEnvironment = self::esc(self::tr('Environment'));
$tabQueries = self::esc(self::tr('Queries'));
return <<<HTML
<!DOCTYPE html>
<html lang="en">
<html lang="{$lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{$statusCode} {$statusText}</title>
<title>{$statusCode} {$statusTextEsc}</title>
<style>{$css}</style>
</head>
<body class="app-error-debug">
@@ -48,11 +64,11 @@ final class ErrorRenderer
<div class="app-error-debug-container app-error-debug-header__inner">
<div class="app-error-debug-header__status">
<span class="app-error-debug-header__code">{$statusCode}</span>
<span class="app-error-debug-header__label">{$statusText}</span>
<span class="app-error-debug-header__label">{$statusTextEsc}</span>
</div>
<div class="app-error-debug-request-id">
Request ID: {$requestId}
<button class="app-error-debug-copy-button" data-copy="{$requestId}" title="Copy request ID">
{$requestIdLabel}: {$requestId}
<button type="button" class="app-error-debug-copy-button" data-copy="{$requestId}" title="{$copyRequestIdTitle}" aria-label="{$copyRequestIdTitle}">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/></svg>
</button>
</div>
@@ -63,20 +79,20 @@ final class ErrorRenderer
<div class="app-error-debug-summary">
<div class="app-error-debug-summary__class">{$excClass}</div>
<div class="app-error-debug-summary__message">{$excMessage}</div>
<div class="app-error-debug-summary__location">in {$excFile} on line {$excLine}</div>
<div class="app-error-debug-summary__location">{$excLocation}</div>
</div>
<nav class="app-error-debug-tabs">
<button class="app-error-debug-tab active" data-panel="panel-exception">Exception</button>
<button class="app-error-debug-tab" data-panel="panel-request">Request</button>
<button class="app-error-debug-tab" data-panel="panel-environment">Environment</button>
<button class="app-error-debug-tab" data-panel="panel-queries">Queries <span class="app-error-debug-tab__count">{$queryCount}</span></button>
<nav class="app-error-debug-tabs" role="tablist" aria-label="{$tabsLabel}">
<button type="button" id="tab-exception" class="app-error-debug-tab active" role="tab" aria-selected="true" aria-controls="panel-exception" data-panel="panel-exception" tabindex="0">{$tabException}</button>
<button type="button" id="tab-request" class="app-error-debug-tab" role="tab" aria-selected="false" aria-controls="panel-request" data-panel="panel-request" tabindex="-1">{$tabRequest}</button>
<button type="button" id="tab-environment" class="app-error-debug-tab" role="tab" aria-selected="false" aria-controls="panel-environment" data-panel="panel-environment" tabindex="-1">{$tabEnvironment}</button>
<button type="button" id="tab-queries" class="app-error-debug-tab" role="tab" aria-selected="false" aria-controls="panel-queries" data-panel="panel-queries" tabindex="-1">{$tabQueries} <span class="app-error-debug-tab__count">{$queryCount}</span></button>
</nav>
<div id="panel-exception" class="app-error-debug-panel active">{$exceptionPanel}</div>
<div id="panel-request" class="app-error-debug-panel">{$requestPanel}</div>
<div id="panel-environment" class="app-error-debug-panel">{$environmentPanel}</div>
<div id="panel-queries" class="app-error-debug-panel">{$queryPanel}</div>
<div id="panel-exception" class="app-error-debug-panel active" role="tabpanel" aria-labelledby="tab-exception">{$exceptionPanel}</div>
<div id="panel-request" class="app-error-debug-panel" role="tabpanel" aria-labelledby="tab-request">{$requestPanel}</div>
<div id="panel-environment" class="app-error-debug-panel" role="tabpanel" aria-labelledby="tab-environment">{$environmentPanel}</div>
<div id="panel-queries" class="app-error-debug-panel" role="tabpanel" aria-labelledby="tab-queries">{$queryPanel}</div>
</main>
<script>{$js}</script>
@@ -94,14 +110,22 @@ final class ErrorRenderer
$statusCode = http_response_code() ?: 500;
$requestId = self::esc($requestId);
$css = self::loadAsset(__DIR__ . '/../../web/css/pages/app-error-debug.css');
$js = self::loadAsset(__DIR__ . '/../../web/js/components/app-error-debug.js');
$lang = self::esc(self::htmlLang());
$errorText = self::esc(self::tr('Error'));
$requestIdLabel = self::esc(self::tr('Request ID'));
$copyRequestIdTitle = self::esc(self::tr('Copy request ID'));
$message = self::esc(self::tr('An unexpected error occurred. If the problem persists, please contact support with the reference below.'));
$backLabel = self::esc(self::tr('Back to start'));
return <<<HTML
<!DOCTYPE html>
<html lang="en">
<html lang="{$lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{$statusCode} - Error</title>
<title>{$statusCode} - {$errorText}</title>
<style>
{$css}
.app-error-prod {
@@ -130,13 +154,14 @@ final class ErrorRenderer
<body>
<div class="app-error-prod">
<div class="app-error-prod__code">{$statusCode}</div>
<div class="app-error-prod__message">An unexpected error occurred. If the problem persists, please contact support with the reference below.</div>
<div class="app-error-prod__message">{$message}</div>
<div class="app-error-debug-request-id" style="margin-top: calc(var(--app-spacing) * 1.5);">
{$requestId}
<button class="app-error-debug-copy-button" onclick="navigator.clipboard?.writeText('{$requestId}')" title="Copy"><svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/></svg></button>
{$requestIdLabel}: {$requestId}
<button type="button" class="app-error-debug-copy-button" data-copy="{$requestId}" title="{$copyRequestIdTitle}" aria-label="{$copyRequestIdTitle}"><svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/></svg></button>
</div>
<a class="app-error-prod__back" href="/">Back to start</a>
<a class="app-error-prod__back" href="/">{$backLabel}</a>
</div>
<script>{$js}</script>
</body>
</html>
HTML;
@@ -144,18 +169,18 @@ final class ErrorRenderer
private static function renderExceptionPanel(array $exceptionData): string
{
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">Stack Trace</div>';
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">' . self::esc(self::tr('Stack Trace')) . '</div>';
$html .= '<ol class="app-error-debug-trace">';
$frames = $exceptionData['trace'] ?? [];
foreach ($frames as $i => $frame) {
$file = self::esc(self::shortenPath($frame['file'] ?? '[internal]'));
$file = self::esc(self::shortenPath((string) ($frame['file'] ?? '[internal]')));
$line = self::esc((string) ($frame['line'] ?? '?'));
$function = '';
if (isset($frame['class'])) {
$function = self::esc($frame['class'] . '::' . ($frame['function'] ?? '?'));
$function = self::esc((string) $frame['class'] . '::' . (string) ($frame['function'] ?? '?'));
} elseif (isset($frame['function'])) {
$function = self::esc($frame['function']);
$function = self::esc((string) $frame['function']);
}
$codeHtml = '';
@@ -165,14 +190,20 @@ final class ErrorRenderer
$codeHtml = self::renderCodeBlock($snippet, $highlightLine);
}
$codeId = 'frame-code-' . $i;
$toggleId = 'frame-toggle-' . $i;
$isOpen = ($i === 0);
$expanded = $isOpen ? 'true' : 'false';
$openClass = $isOpen ? ' open' : '';
$html .= <<<FRAME
<li class="app-error-debug-frame">
<div class="app-error-debug-frame__header">
<li class="app-error-debug-frame{$openClass}">
<button type="button" id="{$toggleId}" class="app-error-debug-frame__header app-error-debug-frame__toggle" aria-expanded="{$expanded}" aria-controls="{$codeId}">
<span class="app-error-debug-frame__index">#{$i}</span>
<span class="app-error-debug-frame__file">{$file}</span>:<span class="app-error-debug-frame__line">{$line}</span>
<span class="app-error-debug-frame__function">{$function}</span>
</div>
<div class="app-error-debug-frame__code">{$codeHtml}</div>
</button>
<div id="{$codeId}" class="app-error-debug-frame__code" role="region" aria-labelledby="{$toggleId}">{$codeHtml}</div>
</li>
FRAME;
}
@@ -183,10 +214,14 @@ final class ErrorRenderer
if (isset($exceptionData['previous'])) {
$prev = $exceptionData['previous'];
$html .= '<div class="app-error-debug-chain">';
$html .= '<div class="app-error-debug-chain__label">Caused by</div>';
$html .= '<div class="app-error-debug-summary__class">' . self::esc($prev['class'] ?? 'Exception') . '</div>';
$html .= '<div class="app-error-debug-summary__message">' . self::esc($prev['message'] ?? '') . '</div>';
$html .= '<div class="app-error-debug-summary__location">in ' . self::esc(self::shortenPath($prev['file'] ?? '')) . ' on line ' . self::esc((string) ($prev['line'] ?? '?')) . '</div>';
$html .= '<div class="app-error-debug-chain__label">' . self::esc(self::tr('Caused by')) . '</div>';
$html .= '<div class="app-error-debug-summary__class">' . self::esc((string) ($prev['class'] ?? self::tr('Exception'))) . '</div>';
$html .= '<div class="app-error-debug-summary__message">' . self::esc((string) ($prev['message'] ?? '')) . '</div>';
$html .= '<div class="app-error-debug-summary__location">' . self::esc(self::tr(
'in %s on line %s',
self::shortenPath((string) ($prev['file'] ?? '')),
(string) ($prev['line'] ?? '?')
)) . '</div>';
$html .= '</div>';
}
@@ -214,43 +249,43 @@ final class ErrorRenderer
{
$html = '';
$html .= self::renderInfoSection('Request', [
'Method' => $requestData['method'] ?? 'GET',
'URL' => $requestData['url'] ?? '',
'IP' => $requestData['ip'] ?? '',
'User Agent' => $requestData['user_agent'] ?? '',
'Channel' => $requestData['channel'] ?? 'web',
$html .= self::renderInfoSection(self::tr('Request'), [
self::tr('Method') => $requestData['method'] ?? 'GET',
self::tr('URL') => $requestData['url'] ?? '',
self::tr('IP') => $requestData['ip'] ?? '',
self::tr('User Agent') => $requestData['user_agent'] ?? '',
self::tr('Channel') => $requestData['channel'] ?? 'web',
]);
$headers = $requestData['headers'] ?? [];
if (!empty($headers)) {
$html .= self::renderInfoSection('Headers', $headers);
$html .= self::renderInfoSection(self::tr('Headers'), $headers);
}
$get = $requestData['get'] ?? [];
if (!empty($get)) {
$html .= self::renderInfoSection('GET Parameters', array_map(
fn ($v): string => is_string($v) ? $v : json_encode($v, JSON_UNESCAPED_SLASHES),
$html .= self::renderInfoSection(self::tr('GET Parameters'), array_map(
fn ($v): string => is_string($v) ? $v : (string) json_encode($v, JSON_UNESCAPED_SLASHES),
$get,
));
}
$post = $requestData['post'] ?? [];
if (!empty($post)) {
$html .= self::renderInfoSection('POST Parameters', array_map(
fn ($v): string => is_string($v) ? $v : json_encode($v, JSON_UNESCAPED_SLASHES),
$html .= self::renderInfoSection(self::tr('POST Parameters'), array_map(
fn ($v): string => is_string($v) ? $v : (string) json_encode($v, JSON_UNESCAPED_SLASHES),
$post,
));
}
$session = $requestData['session_keys'] ?? [];
if (!empty($session)) {
$html .= self::renderInfoSection('Session (keys + types)', $session);
$html .= self::renderInfoSection(self::tr('Session (keys + types)'), $session);
}
$html .= self::renderInfoSection('Context', array_filter([
'User ID' => $requestData['user_id'] ?? null,
'Tenant ID' => $requestData['tenant_id'] ?? null,
$html .= self::renderInfoSection(self::tr('Context'), array_filter([
self::tr('User ID') => $requestData['user_id'] ?? null,
self::tr('Tenant ID') => $requestData['tenant_id'] ?? null,
], fn ($v): bool => $v !== null));
return $html;
@@ -260,29 +295,31 @@ final class ErrorRenderer
{
$html = '';
$html .= self::renderInfoSection('PHP', [
'Version' => $envData['php_version'] ?? PHP_VERSION,
'SAPI' => $envData['php_sapi'] ?? PHP_SAPI,
'Memory (current)' => self::formatBytes($envData['memory_current'] ?? 0),
'Memory (peak)' => self::formatBytes($envData['memory_peak'] ?? 0),
$html .= self::renderInfoSection(self::tr('PHP'), [
self::tr('Version') => $envData['php_version'] ?? PHP_VERSION,
self::tr('SAPI') => $envData['php_sapi'] ?? PHP_SAPI,
self::tr('Memory (current)') => self::formatBytes((int) ($envData['memory_current'] ?? 0)),
self::tr('Memory (peak)') => self::formatBytes((int) ($envData['memory_peak'] ?? 0)),
]);
$env = $envData['env'] ?? [];
if (!empty($env)) {
$html .= self::renderInfoSection('Application', $env);
$html .= self::renderInfoSection(self::tr('Application'), $env);
}
$modules = $envData['modules'] ?? [];
if (!empty($modules)) {
$html .= self::renderInfoSection('Active Modules', array_combine(
$html .= self::renderInfoSection(self::tr('Active Modules'), array_combine(
array_map(fn ($m): string => (string) $m, $modules),
array_fill(0, count($modules), 'active'),
array_fill(0, count($modules), self::tr('Active')),
) ?: []);
}
$extensions = $envData['extensions'] ?? [];
if (!empty($extensions)) {
$html .= '<div class="app-error-debug-section"><div class="app-error-debug-section__title">Loaded Extensions (' . count($extensions) . ')</div>';
$html .= '<div class="app-error-debug-section"><div class="app-error-debug-section__title">'
. self::esc(self::tr('Loaded Extensions (%d)', count($extensions)))
. '</div>';
$html .= '<div style="font-family: var(--app-font-family-monospace); font-size: var(--text-xs); color: var(--app-muted-color); padding: calc(var(--app-spacing) * 0.5); background: var(--app-card-background-color); border-radius: var(--app-border-radius); border: 1px solid var(--app-border);">';
$html .= self::esc(implode(', ', $extensions));
$html .= '</div></div>';
@@ -294,15 +331,17 @@ final class ErrorRenderer
private static function renderQueryPanel(array $queryData): string
{
if (!($queryData['available'] ?? false)) {
return '<div class="app-error-debug-empty">Query tracking is not available (Debugger disabled or not initialized).</div>';
return '<div class="app-error-debug-empty">' . self::esc(self::tr('Query tracking is not available (Debugger disabled or not initialized).')) . '</div>';
}
$queries = $queryData['queries'] ?? [];
if (empty($queries)) {
return '<div class="app-error-debug-empty">No queries were executed before the error occurred.</div>';
return '<div class="app-error-debug-empty">' . self::esc(self::tr('No queries were executed before the error occurred.')) . '</div>';
}
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">Queries (' . count($queries) . ')</div>';
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">'
. self::esc(self::tr('Queries (%d)', count($queries)))
. '</div>';
foreach ($queries as $i => $query) {
$sql = is_string($query) ? $query : ($query[0] ?? $query['query'] ?? '?');
@@ -312,14 +351,17 @@ final class ErrorRenderer
$duration = $durationMs . ' ms';
}
$querySqlId = 'query-sql-' . $i;
$queryToggleId = 'query-toggle-' . $i;
$html .= '<div class="app-error-debug-query">';
$html .= '<div class="app-error-debug-query__header">';
$html .= '<button type="button" id="' . self::esc($queryToggleId) . '" class="app-error-debug-query__header app-error-debug-query__toggle" aria-expanded="false" aria-controls="' . self::esc($querySqlId) . '">';
$html .= '<span>' . self::esc(self::truncate($sql, 120)) . '</span>';
if ($duration !== '') {
$html .= '<span class="app-error-debug-query__duration">' . self::esc($duration) . '</span>';
}
$html .= '</div>';
$html .= '<div class="app-error-debug-query__sql">' . self::esc($sql) . '</div>';
$html .= '</button>';
$html .= '<div id="' . self::esc($querySqlId) . '" class="app-error-debug-query__sql" role="region" aria-labelledby="' . self::esc($queryToggleId) . '">' . self::esc($sql) . '</div>';
$html .= '</div>';
}
@@ -383,18 +425,18 @@ final class ErrorRenderer
private static function statusText(int $code): string
{
return match ($code) {
400 => 'Bad Request',
401 => 'Unauthorized',
403 => 'Forbidden',
404 => 'Not Found',
405 => 'Method Not Allowed',
408 => 'Request Timeout',
422 => 'Unprocessable Entity',
429 => 'Too Many Requests',
500 => 'Internal Server Error',
502 => 'Bad Gateway',
503 => 'Service Unavailable',
default => 'Error',
400 => self::tr('Bad Request'),
401 => self::tr('Unauthorized'),
403 => self::tr('Forbidden'),
404 => self::tr('Not Found'),
405 => self::tr('Method Not Allowed'),
408 => self::tr('Request Timeout'),
422 => self::tr('Unprocessable Entity'),
429 => self::tr('Too Many Requests'),
500 => self::tr('Internal Server Error'),
502 => self::tr('Bad Gateway'),
503 => self::tr('Service Unavailable'),
default => self::tr('Error'),
};
}
@@ -418,4 +460,42 @@ final class ErrorRenderer
return mb_substr($text, 0, $length) . '...';
}
private static function tr(string $text, mixed ...$args): string
{
if (function_exists('t')) {
try {
/** @var string $translated */
$translated = t($text, ...$args);
return $translated;
} catch (\Throwable) {
// Rendering must stay resilient, even if translation bootstrap failed.
}
}
if ($args === []) {
return $text;
}
return (string) @vsprintf($text, $args);
}
private static function htmlLang(): string
{
$locale = trim((string) (I18n::$locale ?? I18n::$defaultLocale ?? 'en'));
if ($locale === '') {
return 'en';
}
$locale = strtolower(str_replace('_', '-', $locale));
if (preg_match('/^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/', $locale) === 1) {
return $locale;
}
if (preg_match('/^[a-z]{2,3}/', $locale, $matches) === 1) {
return $matches[0];
}
return 'en';
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace MintyPHP\Tests\Http;
use MintyPHP\Debugger;
use MintyPHP\Http\ErrorHandler;
use MintyPHP\Http\RequestContext;
use PHPUnit\Framework\TestCase;
class ErrorHandlerTest extends TestCase
{
private array $serverBackup = [];
protected function setUp(): void
{
$this->serverBackup = $_SERVER;
RequestContext::resetForTests();
Debugger::$enabled = false;
header_remove();
http_response_code(200);
}
protected function tearDown(): void
{
$_SERVER = $this->serverBackup;
RequestContext::resetForTests();
Debugger::$enabled = false;
header_remove();
}
/**
* @runInSeparateProcess
*/
public function testHandleExceptionReturnsJsonForApiRequestsWithRequestId(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/api/v1/ping';
$_SERVER['HTTP_X_REQUEST_ID'] = '123e4567-e89b-12d3-a456-426614174000';
$this->expectOutputRegex('/^(?:(?!<html).)*"error_code":"internal_error".*"request_id":"123e4567-e89b-12d3-a456-426614174000".*$/s');
ErrorHandler::handleException(new \RuntimeException('API failure'));
$this->assertSame(500, http_response_code());
}
/**
* @runInSeparateProcess
*/
public function testHandleExceptionRendersProdPageInWebWhenDebugDisabled(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/admin/users';
$_SERVER['HTTP_X_REQUEST_ID'] = '123e4567-e89b-12d3-a456-426614174111';
$this->expectOutputRegex('/^(?=.*<html lang="en">)(?=.*Back to start)(?=.*123e4567-e89b-12d3-a456-426614174111)(?!.*panel-exception).*$/s');
ErrorHandler::handleException(new \RuntimeException('Web failure'));
$this->assertSame(500, http_response_code());
}
/**
* @runInSeparateProcess
*/
public function testHandleExceptionKeepsExistingErrorStatusCode(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/admin/settings';
http_response_code(500);
$this->expectOutputRegex('/500/');
ErrorHandler::handleException(new \RuntimeException('Status flow'));
$this->assertSame(500, http_response_code());
}
}

View File

@@ -36,7 +36,7 @@ class ErrorLoggerTest extends TestCase
'request_id' => 'abc-123',
'channel' => 'web',
'method' => 'GET',
'url' => '/admin/dashboard',
'url' => '/admin/dashboard?token=very-secret',
'ip' => '127.0.0.1',
'user_id' => 5,
'tenant_id' => 1,
@@ -60,11 +60,41 @@ class ErrorLoggerTest extends TestCase
$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->assertSame('[REDACTED]', $entry['exception']['message']);
$this->assertArrayHasKey('message_hash', $entry['exception']);
$this->assertStringStartsWith('sha256:', (string) $entry['exception']['message_hash']);
$this->assertArrayHasKey('ip_hash', $entry['request']);
$this->assertStringStartsWith('sha256:', $entry['request']['ip_hash']);
$this->assertSame('/admin/dashboard', $entry['request']['path']);
$this->assertArrayNotHasKey('url', $entry['request']);
// Raw IP should NOT be in the log.
$this->assertStringNotContainsString('127.0.0.1', json_encode($entry));
$this->assertStringNotContainsString('very-secret', json_encode($entry));
}
public function testLogStoresPathWithoutQueryParameters(): void
{
$exception = new \RuntimeException('Query redaction');
$data = [
'request' => [
'request_id' => 'query-test',
'url' => '/auth/reset-password?email=user@example.com&token=abc123',
],
'exception' => [],
'environment' => [],
];
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('/auth/reset-password', $entry['request']['path']);
$this->assertStringNotContainsString('user@example.com', $lines[0]);
$this->assertStringNotContainsString('token=abc123', $lines[0]);
}
public function testLogRedactsIpAddress(): void

View File

@@ -3,10 +3,28 @@
namespace MintyPHP\Tests\Http;
use MintyPHP\Http\ErrorRenderer;
use MintyPHP\I18n;
use PHPUnit\Framework\TestCase;
class ErrorRendererTest extends TestCase
{
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;
}
public function testRenderDevReturnsCompleteHtmlDocument(): void
{
$data = self::sampleData();
@@ -16,6 +34,7 @@ class ErrorRendererTest extends TestCase
$this->assertStringContainsString('</html>', $html);
$this->assertStringContainsString('<style>', $html);
$this->assertStringContainsString('<script>', $html);
$this->assertStringContainsString('<html lang="en">', $html);
}
public function testRenderDevContainsExceptionDetails(): void
@@ -37,6 +56,21 @@ class ErrorRendererTest extends TestCase
$this->assertStringContainsString('panel-request', $html);
$this->assertStringContainsString('panel-environment', $html);
$this->assertStringContainsString('panel-queries', $html);
$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);
}
public function testRenderDevEscapesHtmlInExceptionMessage(): void
@@ -63,9 +97,12 @@ class ErrorRendererTest extends TestCase
$html = ErrorRenderer::renderProd('abc-def-123');
$this->assertStringContainsString('<!DOCTYPE html>', $html);
$this->assertStringContainsString('<html lang="en">', $html);
$this->assertStringContainsString('abc-def-123', $html);
$this->assertStringContainsString('An unexpected error occurred', $html);
$this->assertStringContainsString('Back to start', $html);
$this->assertStringContainsString('data-copy="abc-def-123"', $html);
$this->assertStringContainsString('aria-label="Copy request ID"', $html);
}
public function testRenderProdDoesNotContainDebugInfo(): void

View File

@@ -156,6 +156,10 @@ body.app-error-debug {
.app-error-debug-copy-button:hover { color: var(--app-color); }
.app-error-debug-copy-button.copied { color: var(--app-primary); }
.app-error-debug-copy-button:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
}
/* ── Exception Summary ───────────────────────────────────────────────── */
@@ -206,6 +210,10 @@ body.app-error-debug {
}
.app-error-debug-tab:hover { color: var(--app-color); }
.app-error-debug-tab:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
}
.app-error-debug-tab.active {
color: var(--app-primary);
@@ -251,11 +259,18 @@ body.app-error-debug {
cursor: pointer;
background: var(--app-card-background-color);
transition: background var(--app-transition);
width: 100%;
border: 0;
text-align: left;
}
.app-error-debug-frame__header:hover {
background: var(--app-card-sectioning-background-color);
}
.app-error-debug-frame__header:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: -2px;
}
.app-error-debug-frame__index {
color: var(--app-muted-color);
@@ -385,6 +400,19 @@ body.app-error-debug {
cursor: pointer;
font-family: var(--app-font-family-monospace);
font-size: var(--text-xs);
width: 100%;
border: 0;
text-align: left;
background: transparent;
}
.app-error-debug-query__header:hover {
background: var(--app-card-sectioning-background-color);
}
.app-error-debug-query__header:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: -2px;
}
.app-error-debug-query__duration {

View File

@@ -5,32 +5,74 @@ document.addEventListener('DOMContentLoaded', () => {
const tabs = document.querySelectorAll('.app-error-debug-tab');
const panels = document.querySelectorAll('.app-error-debug-panel');
const activateTab = (tab) => {
const target = tab.dataset.panel;
tabs.forEach(t => t.classList.remove('active'));
tabs.forEach(t => t.setAttribute('aria-selected', 'false'));
tabs.forEach(t => t.setAttribute('tabindex', '-1'));
panels.forEach(p => p.classList.remove('active'));
tab.classList.add('active');
tab.setAttribute('aria-selected', 'true');
tab.setAttribute('tabindex', '0');
const panel = document.getElementById(target);
if (panel) panel.classList.add('active');
};
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const target = tab.dataset.panel;
tabs.forEach(t => t.classList.remove('active'));
panels.forEach(p => p.classList.remove('active'));
tab.classList.add('active');
const panel = document.getElementById(target);
if (panel) panel.classList.add('active');
activateTab(tab);
});
tab.addEventListener('keydown', (event) => {
if (tabs.length === 0) return;
const currentIndex = Array.prototype.indexOf.call(tabs, tab);
if (currentIndex < 0) return;
let nextIndex = currentIndex;
if (event.key === 'ArrowRight') {
nextIndex = (currentIndex + 1) % tabs.length;
} else if (event.key === 'ArrowLeft') {
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
} else if (event.key === 'Home') {
nextIndex = 0;
} else if (event.key === 'End') {
nextIndex = tabs.length - 1;
} else {
return;
}
event.preventDefault();
const nextTab = tabs[nextIndex];
if (!nextTab) return;
activateTab(nextTab);
nextTab.focus();
});
});
// ── Frame expand/collapse ──────────────────────────
document.querySelectorAll('.app-error-debug-frame__header').forEach(header => {
header.addEventListener('click', () => {
header.closest('.app-error-debug-frame')?.classList.toggle('open');
document.querySelectorAll('.app-error-debug-frame__toggle').forEach(toggle => {
toggle.addEventListener('click', () => {
const frame = toggle.closest('.app-error-debug-frame');
if (!frame) return;
const isOpen = frame.classList.toggle('open');
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
});
});
// Open the first frame by default.
const firstFrame = document.querySelector('.app-error-debug-frame');
if (firstFrame) firstFrame.classList.add('open');
// Ensure ARIA reflects initial state.
document.querySelectorAll('.app-error-debug-frame').forEach(frame => {
const toggle = frame.querySelector('.app-error-debug-frame__toggle');
if (!toggle) return;
toggle.setAttribute('aria-expanded', frame.classList.contains('open') ? 'true' : 'false');
});
// ── Query expand/collapse ──────────────────────────
document.querySelectorAll('.app-error-debug-query__header').forEach(header => {
header.addEventListener('click', () => {
header.closest('.app-error-debug-query')?.classList.toggle('open');
document.querySelectorAll('.app-error-debug-query__toggle').forEach(toggle => {
toggle.addEventListener('click', () => {
const query = toggle.closest('.app-error-debug-query');
if (!query) return;
const isOpen = query.classList.toggle('open');
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
});
});