diff --git a/lib/Http/ErrorDataCollector.php b/lib/Http/ErrorDataCollector.php new file mode 100644 index 0000000..4fe2046 --- /dev/null +++ b/lib/Http/ErrorDataCollector.php @@ -0,0 +1,348 @@ +, request: array, environment: array, queries: array} + */ + public static function collect(\Throwable $exception): array + { + return [ + 'exception' => self::collectException($exception), + 'request' => self::collectRequest(), + 'environment' => self::collectEnvironment(), + 'queries' => self::collectQueries(), + ]; + } + + /** + * @return array + */ + private static function collectException(\Throwable $exception): array + { + $data = [ + 'class' => get_class($exception), + 'message' => $exception->getMessage(), + 'code' => $exception->getCode(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => self::collectStackFrames($exception), + ]; + + $previous = $exception->getPrevious(); + if ($previous !== null) { + $data['previous'] = self::collectException($previous); + } + + return $data; + } + + /** + * @return list> + */ + private static function collectStackFrames(\Throwable $exception): array + { + $frames = []; + + // Frame 0 is the exception origin itself. + $frames[] = [ + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'function' => null, + 'class' => null, + 'code_snippet' => self::extractCodeSnippet($exception->getFile(), $exception->getLine()), + ]; + + foreach (array_slice($exception->getTrace(), 0, self::MAX_TRACE_FRAMES) as $frame) { + $file = $frame['file'] ?? null; + $line = $frame['line'] ?? null; + $function = $frame['function']; + $class = $frame['class'] ?? null; + + $frames[] = [ + 'file' => $file, + 'line' => $line, + 'function' => $function, + 'class' => $class, + 'args_summary' => self::summarizeArgs($frame['args'] ?? []), + 'code_snippet' => ($file !== null && $line !== null) + ? self::extractCodeSnippet($file, $line) + : [], + ]; + } + + return $frames; + } + + /** + * @return array Line number => code line + */ + private static function extractCodeSnippet(string $file, int $line): array + { + try { + if (!is_file($file) || !is_readable($file)) { + return []; + } + + $lines = @file($file, FILE_IGNORE_NEW_LINES); + if ($lines === false) { + return []; + } + + $start = max(0, $line - self::CODE_CONTEXT_LINES - 1); + $end = min(count($lines), $line + self::CODE_CONTEXT_LINES); + + $snippet = []; + for ($i = $start; $i < $end; $i++) { + $snippet[$i + 1] = $lines[$i]; + } + + return $snippet; + } catch (\Throwable) { + return []; + } + } + + /** + * @param list $args + * @return list + */ + private static function summarizeArgs(array $args): array + { + $summary = []; + foreach ($args as $arg) { + $summary[] = match (true) { + is_null($arg) => 'null', + is_bool($arg) => $arg ? 'true' : 'false', + is_int($arg) => 'int', + is_float($arg) => 'float', + is_string($arg) => 'string(' . strlen($arg) . ')', + is_array($arg) => 'array(' . count($arg) . ')', + is_object($arg) => get_class($arg), + is_resource($arg) => 'resource', + default => 'unknown', + }; + } + + return $summary; + } + + /** + * @return array + */ + private static function collectRequest(): array + { + $data = [ + 'request_id' => null, + 'channel' => 'web', + 'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET', + 'url' => ($_SERVER['REQUEST_URI'] ?? ''), + 'ip' => $_SERVER['REMOTE_ADDR'] ?? null, + 'user_agent' => substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), + 'headers' => self::collectHeaders(), + 'get' => $_GET, + 'post' => self::redactParams($_POST), + 'session_keys' => [], + 'user_id' => null, + 'tenant_id' => null, + ]; + + // Safe request context access. + try { + $data['request_id'] = RequestContext::currentId() ?? RequestContext::id(); + } catch (\Throwable) { + // RequestContext may not be initialized. + } + + try { + $ctx = RequestContext::context(); + $data['channel'] = $ctx['channel'] ?? 'web'; + } catch (\Throwable) { + } + + // Session data — keys and types only, never values. + if (session_status() === PHP_SESSION_ACTIVE) { + $data['session_keys'] = self::describeSessionKeys($_SESSION ?? []); + $data['user_id'] = $_SESSION['user']['id'] ?? $_SESSION['user_id'] ?? null; + $data['tenant_id'] = $_SESSION['current_tenant']['id'] ?? $_SESSION['tenant_id'] ?? null; + } + + return $data; + } + + /** + * @return array + */ + private static function collectHeaders(): array + { + $headers = []; + foreach ($_SERVER as $key => $value) { + if (!str_starts_with($key, 'HTTP_')) { + continue; + } + + $name = strtolower(str_replace('_', '-', substr($key, 5))); + $headers[$name] = self::shouldRedactHeader($name) ? '[REDACTED]' : (string) $value; + } + + return $headers; + } + + private static function shouldRedactHeader(string $name): bool + { + $lower = strtolower($name); + foreach (self::REDACTED_HEADER_PATTERNS as $pattern) { + if (str_contains($lower, $pattern)) { + return true; + } + } + + return false; + } + + /** + * @param array $params + * @return array + */ + private static function redactParams(array $params): array + { + $result = []; + foreach ($params as $key => $value) { + $result[$key] = self::shouldRedactParam((string) $key) ? '[REDACTED]' : $value; + } + + return $result; + } + + private static function shouldRedactParam(string $key): bool + { + $lower = strtolower($key); + foreach (self::REDACTED_KEY_PATTERNS as $pattern) { + if (str_contains($lower, $pattern)) { + return true; + } + } + + return false; + } + + /** + * @return array + */ + private static function describeSessionKeys(array $session): array + { + $result = []; + foreach ($session as $key => $value) { + $result[(string) $key] = get_debug_type($value); + } + + return $result; + } + + /** + * @return array + */ + private static function collectEnvironment(): array + { + $data = [ + 'php_version' => PHP_VERSION, + 'php_sapi' => PHP_SAPI, + 'extensions' => get_loaded_extensions(), + 'memory_current' => memory_get_usage(true), + 'memory_peak' => memory_get_peak_usage(true), + 'env' => self::collectSafeEnv(), + 'modules' => [], + ]; + + // Active modules — may fail if container is unavailable. + try { + if (function_exists('app')) { + $registry = app(\MintyPHP\App\Module\ModuleRegistry::class); + $modules = $registry->getModules(); + $data['modules'] = array_map( + fn (object $m): string => $m->id ?? (string) $m, + $modules, + ); + } + } catch (\Throwable) { + } + + return $data; + } + + /** + * @return array + */ + private static function collectSafeEnv(): array + { + $result = []; + + foreach (self::SAFE_ENV_KEYS as $key) { + $value = $_ENV[$key] ?? $_SERVER[$key] ?? null; + if ($value !== null) { + $result[$key] = (string) $value; + } + } + + return $result; + } + + /** + * @return array + */ + private static function collectQueries(): array + { + try { + if (!class_exists(Debugger::class, false) || !Debugger::$enabled) { + return ['available' => false, 'queries' => [], 'start' => null]; + } + + $queries = Debugger::get('queries'); + $start = Debugger::get('start'); + + if (!is_array($queries)) { + $queries = []; + } + + return [ + 'available' => true, + 'queries' => $queries, + 'start' => is_float($start) ? $start : null, + 'count' => count($queries), + ]; + } catch (\Throwable) { + return ['available' => false, 'queries' => [], 'start' => null]; + } + } +} diff --git a/lib/Http/ErrorHandler.php b/lib/Http/ErrorHandler.php new file mode 100644 index 0000000..dc804d1 --- /dev/null +++ b/lib/Http/ErrorHandler.php @@ -0,0 +1,190 @@ + 0) { + ob_end_clean(); + } + + // Set HTTP 500 if no error status has been set yet. + $currentStatus = http_response_code(); + if ($currentStatus === false || $currentStatus < 400) { + http_response_code(500); + } + + // Collect diagnostic data. + $data = ErrorDataCollector::collect($exception); + + // Log to file (always, in both dev and prod). + ErrorLogger::log($exception, $data); + + // Bail out if headers were already sent (cannot render a page). + if (headers_sent()) { + self::$handling = false; + return; + } + + // API requests get a JSON response, not HTML. + if (self::isApiRequest()) { + self::sendApiError($data['request']['request_id'] ?? null); + self::$handling = false; + return; + } + + // Render HTML error page. + if (self::shouldShowDebugPage()) { + echo ErrorRenderer::renderDev($data); + } else { + $requestId = (string) ($data['request']['request_id'] ?? 'unknown'); + echo ErrorRenderer::renderProd($requestId); + } + } catch (\Throwable $inner) { + self::renderMinimalFallback($exception); + } + + self::$handling = false; + } + + /** + * Converts PHP errors into ErrorException. + * Respects the error_reporting() bitmask (honors @ suppression operator). + */ + public static function handleError(int $severity, string $message, string $file, int $line): true + { + if (!(error_reporting() & $severity)) { + // Error was suppressed with @. + return true; + } + + throw new \ErrorException($message, 0, $severity, $file, $line); + } + + /** + * Catches fatal errors that bypass set_error_handler (E_PARSE, E_COMPILE_ERROR, etc.). + */ + public static function handleShutdown(): void + { + self::$reservedMemory = null; + + $error = error_get_last(); + if (!is_array($error)) { + return; + } + + $fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR]; + if (!in_array($error['type'], $fatalTypes, true)) { + return; + } + + // Only act if we haven't already handled this (e.g. via set_exception_handler). + if (self::$handling) { + return; + } + + $exception = new \ErrorException( + $error['message'], + 0, + $error['type'], + $error['file'], + $error['line'], + ); + + self::handleException($exception); + } + + private static function isApiRequest(): bool + { + if (defined('MINTY_API_REQUEST')) { + return true; + } + + $uri = $_SERVER['REQUEST_URI'] ?? ''; + $path = ltrim(parse_url($uri, PHP_URL_PATH) ?? '', '/'); + + return str_starts_with($path, 'api/'); + } + + private static function shouldShowDebugPage(): bool + { + // Check the Debugger flag, which mirrors APP_DEBUG. + if (class_exists(Debugger::class, false) && Debugger::$enabled) { + return true; + } + + return false; + } + + private static function sendApiError(?string $requestId): void + { + header('Content-Type: application/json; charset=utf-8'); + $payload = [ + 'ok' => false, + 'error_code' => 'internal_error', + ]; + if ($requestId !== null) { + $payload['request_id'] = $requestId; + } + + echo json_encode($payload, JSON_UNESCAPED_SLASHES); + } + + private static function renderMinimalFallback(\Throwable $exception): void + { + // Last-resort plain-text response when everything else is broken. + $requestId = 'unknown'; + try { + $requestId = RequestContext::currentId() ?? RequestContext::id(); + } catch (\Throwable) { + } + + if (!headers_sent()) { + http_response_code(500); + header('Content-Type: text/plain; charset=utf-8'); + } + + echo "500 Internal Server Error\nRequest ID: {$requestId}\n"; + + // In debug mode, also show the exception for developers. + if (class_exists(Debugger::class, false) && Debugger::$enabled) { + echo "\n" . $exception->getMessage() . "\n" . $exception->getTraceAsString() . "\n"; + } + } +} diff --git a/lib/Http/ErrorLogger.php b/lib/Http/ErrorLogger.php new file mode 100644 index 0000000..5c6d10f --- /dev/null +++ b/lib/Http/ErrorLogger.php @@ -0,0 +1,132 @@ + 'Failed to encode log entry', 'request_id' => $entry['request_id'] ?? 'unknown']); + } + + file_put_contents($logFile, $json . "\n", FILE_APPEND | LOCK_EX); + } catch (\Throwable) { + // Logging must never cause secondary failures. + } + } + + private static function ensureLogDirectory(): ?string + { + $dir = self::LOG_DIR; + if (is_dir($dir)) { + return $dir; + } + + if (!@mkdir($dir, 0o775, true) && !is_dir($dir)) { + return null; + } + + return $dir; + } + + private static function rotateIfNeeded(string $filePath): void + { + if (!is_file($filePath)) { + return; + } + + $size = @filesize($filePath); + if ($size === false || $size < self::MAX_FILE_SIZE) { + return; + } + + // Find the next available rotation slot. + for ($i = self::MAX_ROTATED_FILES; $i >= 2; $i--) { + $older = $filePath . '.' . $i; + $newer = $filePath . '.' . ($i - 1); + if (is_file($newer)) { + @rename($newer, $older); + } + } + + @rename($filePath, $filePath . '.1'); + } + + /** + * @param array{exception?: array, request?: array, environment?: array, queries?: array} $collectedData + * @return array + */ + private static function buildLogEntry(\Throwable $exception, array $collectedData): array + { + $exceptionData = $collectedData['exception'] ?? []; + $requestData = $collectedData['request'] ?? []; + $envData = $collectedData['environment'] ?? []; + + $traceSummary = ''; + $frames = $exceptionData['trace'] ?? []; + foreach (array_slice($frames, 0, 10) as $i => $frame) { + $file = $frame['file'] ?? '?'; + $line = $frame['line'] ?? '?'; + $traceSummary .= "#{$i} {$file}:{$line} "; + } + + return [ + 'timestamp' => date('c'), + 'level' => 'error', + 'request_id' => $requestData['request_id'] ?? null, + 'channel' => $requestData['channel'] ?? 'web', + 'exception' => [ + 'class' => $exceptionData['class'] ?? get_class($exception), + 'message' => $exceptionData['message'] ?? $exception->getMessage(), + 'code' => $exceptionData['code'] ?? $exception->getCode(), + 'file' => self::shortenPath($exceptionData['file'] ?? $exception->getFile()), + 'line' => $exceptionData['line'] ?? $exception->getLine(), + ], + 'trace_summary' => trim($traceSummary), + 'request' => [ + 'method' => $requestData['method'] ?? null, + 'url' => $requestData['url'] ?? null, + 'ip_hash' => self::hashIp($requestData['ip'] ?? null), + 'user_id' => $requestData['user_id'] ?? null, + 'tenant_id' => $requestData['tenant_id'] ?? null, + ], + 'php_version' => $envData['php_version'] ?? PHP_VERSION, + 'memory_peak_mb' => round(($envData['memory_peak'] ?? memory_get_peak_usage(true)) / 1024 / 1024, 1), + ]; + } + + private static function hashIp(?string $ip): ?string + { + if ($ip === null || $ip === '') { + return null; + } + + return 'sha256:' . hash('sha256', $ip); + } + + private static function shortenPath(string $path): string + { + $root = getcwd(); + if ($root !== false && str_starts_with($path, $root . '/')) { + return substr($path, strlen($root) + 1); + } + + return $path; + } +} diff --git a/lib/Http/ErrorRenderer.php b/lib/Http/ErrorRenderer.php new file mode 100644 index 0000000..5bffae8 --- /dev/null +++ b/lib/Http/ErrorRenderer.php @@ -0,0 +1,421 @@ +, request: array, environment: array, queries: array} $data + */ + public static function renderDev(array $data): string + { + $exc = $data['exception']; + $req = $data['request']; + $env = $data['environment']; + $qry = $data['queries']; + + $requestId = self::esc((string) ($req['request_id'] ?? 'unknown')); + $statusCode = http_response_code() ?: 500; + $statusText = self::statusText($statusCode); + $queryCount = $qry['count'] ?? count($qry['queries'] ?? []); + + $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'] ?? '?')); + + $exceptionPanel = self::renderExceptionPanel($exc); + $requestPanel = self::renderRequestPanel($req); + $environmentPanel = self::renderEnvironmentPanel($env); + $queryPanel = self::renderQueryPanel($qry); + + return << + + + + + {$statusCode} {$statusText} + + + +
+
+
+ {$statusCode} + {$statusText} +
+
+ Request ID: {$requestId} + +
+
+
+ +
+
+
{$excClass}
+
{$excMessage}
+
in {$excFile} on line {$excLine}
+
+ + + +
{$exceptionPanel}
+
{$requestPanel}
+
{$environmentPanel}
+
{$queryPanel}
+
+ + + + + HTML; + } + + /** + * Renders a clean production error page with request ID only. + * Uses the same design tokens as the dev page for visual consistency. + */ + public static function renderProd(string $requestId): string + { + $statusCode = http_response_code() ?: 500; + $requestId = self::esc($requestId); + $css = self::loadAsset(__DIR__ . '/../../web/css/pages/app-error-debug.css'); + + return << + + + + + {$statusCode} - Error + + + +
+
{$statusCode}
+
An unexpected error occurred. If the problem persists, please contact support with the reference below.
+
+ {$requestId} + +
+ Back to start +
+ + + HTML; + } + + private static function renderExceptionPanel(array $exceptionData): string + { + $html = '
Stack Trace
'; + $html .= '
    '; + + $frames = $exceptionData['trace'] ?? []; + foreach ($frames as $i => $frame) { + $file = self::esc(self::shortenPath($frame['file'] ?? '[internal]')); + $line = self::esc((string) ($frame['line'] ?? '?')); + $function = ''; + if (isset($frame['class'])) { + $function = self::esc($frame['class'] . '::' . ($frame['function'] ?? '?')); + } elseif (isset($frame['function'])) { + $function = self::esc($frame['function']); + } + + $codeHtml = ''; + $snippet = $frame['code_snippet'] ?? []; + if (!empty($snippet)) { + $highlightLine = (int) ($frame['line'] ?? 0); + $codeHtml = self::renderCodeBlock($snippet, $highlightLine); + } + + $html .= << +
    + #{$i} + {$file}:{$line} + {$function} +
    +
    {$codeHtml}
    + + FRAME; + } + + $html .= '
'; + + // Chained exceptions. + if (isset($exceptionData['previous'])) { + $prev = $exceptionData['previous']; + $html .= '
'; + $html .= '
Caused by
'; + $html .= '
' . self::esc($prev['class'] ?? 'Exception') . '
'; + $html .= '
' . self::esc($prev['message'] ?? '') . '
'; + $html .= '
in ' . self::esc(self::shortenPath($prev['file'] ?? '')) . ' on line ' . self::esc((string) ($prev['line'] ?? '?')) . '
'; + $html .= '
'; + } + + return $html; + } + + /** + * @param array $lines + */ + private static function renderCodeBlock(array $lines, int $highlightLine): string + { + $html = ''; + foreach ($lines as $lineNo => $code) { + $isHighlight = ($lineNo === $highlightLine) ? ' highlight' : ''; + $lineNoEsc = self::esc((string) $lineNo); + $codeEsc = self::esc($code); + $html .= ""; + } + $html .= '
{$lineNoEsc}{$codeEsc}
'; + + return $html; + } + + private static function renderRequestPanel(array $requestData): string + { + $html = ''; + + $html .= self::renderInfoSection('Request', [ + 'Method' => $requestData['method'] ?? 'GET', + 'URL' => $requestData['url'] ?? '', + 'IP' => $requestData['ip'] ?? '', + 'User Agent' => $requestData['user_agent'] ?? '', + 'Channel' => $requestData['channel'] ?? 'web', + ]); + + $headers = $requestData['headers'] ?? []; + if (!empty($headers)) { + $html .= self::renderInfoSection('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), + $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), + $post, + )); + } + + $session = $requestData['session_keys'] ?? []; + if (!empty($session)) { + $html .= self::renderInfoSection('Session (keys + types)', $session); + } + + $html .= self::renderInfoSection('Context', array_filter([ + 'User ID' => $requestData['user_id'] ?? null, + 'Tenant ID' => $requestData['tenant_id'] ?? null, + ], fn ($v): bool => $v !== null)); + + return $html; + } + + private static function renderEnvironmentPanel(array $envData): string + { + $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), + ]); + + $env = $envData['env'] ?? []; + if (!empty($env)) { + $html .= self::renderInfoSection('Application', $env); + } + + $modules = $envData['modules'] ?? []; + if (!empty($modules)) { + $html .= self::renderInfoSection('Active Modules', array_combine( + array_map(fn ($m): string => (string) $m, $modules), + array_fill(0, count($modules), 'active'), + ) ?: []); + } + + $extensions = $envData['extensions'] ?? []; + if (!empty($extensions)) { + $html .= '
Loaded Extensions (' . count($extensions) . ')
'; + $html .= '
'; + $html .= self::esc(implode(', ', $extensions)); + $html .= '
'; + } + + return $html; + } + + private static function renderQueryPanel(array $queryData): string + { + if (!($queryData['available'] ?? false)) { + return '
Query tracking is not available (Debugger disabled or not initialized).
'; + } + + $queries = $queryData['queries'] ?? []; + if (empty($queries)) { + return '
No queries were executed before the error occurred.
'; + } + + $html = '
Queries (' . count($queries) . ')
'; + + foreach ($queries as $i => $query) { + $sql = is_string($query) ? $query : ($query[0] ?? $query['query'] ?? '?'); + $duration = ''; + if (is_array($query) && isset($query[1])) { + $durationMs = round((float) $query[1] * 1000, 2); + $duration = $durationMs . ' ms'; + } + + $html .= '
'; + $html .= '
'; + $html .= '' . self::esc(self::truncate($sql, 120)) . ''; + if ($duration !== '') { + $html .= '' . self::esc($duration) . ''; + } + $html .= '
'; + $html .= '
' . self::esc($sql) . '
'; + $html .= '
'; + } + + $html .= '
'; + + return $html; + } + + /** + * @param array $items + */ + private static function renderInfoSection(string $title, array $items): string + { + if (empty($items)) { + return ''; + } + + $html = '
' . self::esc($title) . '
'; + $html .= ''; + + foreach ($items as $key => $value) { + $keyEsc = self::esc((string) $key); + $valueStr = (string) $value; + $isRedacted = ($valueStr === '[REDACTED]'); + $valueEsc = $isRedacted + ? '[REDACTED]' + : self::esc($valueStr); + $html .= ""; + } + + $html .= '
{$keyEsc}{$valueEsc}
'; + + return $html; + } + + private static function loadAsset(string $path): string + { + $resolved = realpath($path); + if ($resolved === false || !is_file($resolved)) { + return '/* asset not found: ' . basename($path) . ' */'; + } + + return (string) file_get_contents($resolved); + } + + public static function esc(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + private static function shortenPath(string $path): string + { + $root = getcwd(); + if ($root !== false && str_starts_with($path, $root . '/')) { + return substr($path, strlen($root) + 1); + } + + return $path; + } + + 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', + }; + } + + private static function formatBytes(int $bytes): string + { + if ($bytes < 1024) { + return $bytes . ' B'; + } + if ($bytes < 1024 * 1024) { + return round($bytes / 1024, 1) . ' KB'; + } + + return round($bytes / (1024 * 1024), 1) . ' MB'; + } + + private static function truncate(string $text, int $length): string + { + if (mb_strlen($text) <= $length) { + return $text; + } + + return mb_substr($text, 0, $length) . '...'; + } +} diff --git a/tests/Architecture/TypographyTokenContractTest.php b/tests/Architecture/TypographyTokenContractTest.php index ab74edf..a264bcc 100644 --- a/tests/Architecture/TypographyTokenContractTest.php +++ b/tests/Architecture/TypographyTokenContractTest.php @@ -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. ]; /** diff --git a/tests/Http/ErrorDataCollectorTest.php b/tests/Http/ErrorDataCollectorTest.php new file mode 100644 index 0000000..b97917e --- /dev/null +++ b/tests/Http/ErrorDataCollectorTest.php @@ -0,0 +1,163 @@ +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, + ); + } + } + } + } +} diff --git a/tests/Http/ErrorLoggerTest.php b/tests/Http/ErrorLoggerTest.php new file mode 100644 index 0000000..3544af8 --- /dev/null +++ b/tests/Http/ErrorLoggerTest.php @@ -0,0 +1,95 @@ +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. + } +} diff --git a/tests/Http/ErrorRendererTest.php b/tests/Http/ErrorRendererTest.php new file mode 100644 index 0000000..7669ec2 --- /dev/null +++ b/tests/Http/ErrorRendererTest.php @@ -0,0 +1,155 @@ +assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('