ExceptionTrapTest.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
  5. * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
  6. *
  7. * Licensed under The MIT License
  8. * For full copyright and license information, please see the LICENSE.txt
  9. * Redistributions of files must retain the above copyright notice.
  10. *
  11. * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
  12. * @link https://cakephp.org CakePHP Project
  13. * @since 4.4.0
  14. * @license https://opensource.org/licenses/mit-license.php MIT License
  15. */
  16. namespace Cake\Test\TestCase\Error;
  17. use Cake\Console\TestSuite\StubConsoleOutput;
  18. use Cake\Error\ErrorLogger;
  19. use Cake\Error\ExceptionTrap;
  20. use Cake\Error\Renderer\ConsoleExceptionRenderer;
  21. use Cake\Error\Renderer\TextExceptionRenderer;
  22. use Cake\Error\Renderer\WebExceptionRenderer;
  23. use Cake\Http\Exception\MissingControllerException;
  24. use Cake\Http\Exception\NotFoundException;
  25. use Cake\Http\ServerRequest;
  26. use Cake\Log\Log;
  27. use Cake\TestSuite\TestCase;
  28. use Cake\Utility\Text;
  29. use InvalidArgumentException;
  30. use PHPUnit\Framework\Attributes\DataProvider;
  31. use PHPUnit\Framework\Attributes\PreserveGlobalState;
  32. use PHPUnit\Framework\Attributes\RunInSeparateProcess;
  33. use RuntimeException;
  34. use Throwable;
  35. class ExceptionTrapTest extends TestCase
  36. {
  37. /**
  38. * @var string
  39. */
  40. private $memoryLimit;
  41. private $triggered = false;
  42. public function setUp(): void
  43. {
  44. parent::setUp();
  45. $this->memoryLimit = ini_get('memory_limit');
  46. }
  47. public function tearDown(): void
  48. {
  49. parent::tearDown();
  50. Log::reset();
  51. ini_set('memory_limit', $this->memoryLimit);
  52. }
  53. public function testConfigExceptionRendererFallback(): void
  54. {
  55. $output = new StubConsoleOutput();
  56. $trap = new ExceptionTrap(['exceptionRenderer' => null, 'stderr' => $output]);
  57. $error = new InvalidArgumentException('nope');
  58. $this->assertInstanceOf(ConsoleExceptionRenderer::class, $trap->renderer($error));
  59. }
  60. public function testConfigExceptionRenderer(): void
  61. {
  62. $trap = new ExceptionTrap(['exceptionRenderer' => WebExceptionRenderer::class]);
  63. $error = new InvalidArgumentException('nope');
  64. $this->assertInstanceOf(WebExceptionRenderer::class, $trap->renderer($error));
  65. }
  66. public function testConfigExceptionRendererFactory(): void
  67. {
  68. $trap = new ExceptionTrap(['exceptionRenderer' => function ($err, $req) {
  69. return new WebExceptionRenderer($err, $req);
  70. }]);
  71. $error = new InvalidArgumentException('nope');
  72. $this->assertInstanceOf(WebExceptionRenderer::class, $trap->renderer($error));
  73. }
  74. public function testConfigRendererHandleUnsafeOverwrite(): void
  75. {
  76. $output = new StubConsoleOutput();
  77. $trap = new ExceptionTrap(['stderr' => $output]);
  78. $trap->setConfig('exceptionRenderer', null);
  79. $error = new InvalidArgumentException('nope');
  80. $this->assertInstanceOf(ConsoleExceptionRenderer::class, $trap->renderer($error));
  81. }
  82. public function testLoggerConfig(): void
  83. {
  84. $trap = new ExceptionTrap(['logger' => ErrorLogger::class]);
  85. $this->assertInstanceOf(ErrorLogger::class, $trap->logger());
  86. }
  87. public function testLoggerHandleUnsafeOverwrite(): void
  88. {
  89. $trap = new ExceptionTrap();
  90. $trap->setConfig('logger', null);
  91. $this->assertInstanceOf(ErrorLogger::class, $trap->logger());
  92. }
  93. public function testHandleExceptionText(): void
  94. {
  95. $trap = new ExceptionTrap([
  96. 'exceptionRenderer' => TextExceptionRenderer::class,
  97. ]);
  98. $error = new InvalidArgumentException('nope');
  99. ob_start();
  100. $trap->handleException($error);
  101. $out = ob_get_clean();
  102. $this->assertStringContainsString('nope', $out);
  103. $this->assertStringContainsString('ExceptionTrapTest', $out);
  104. }
  105. public function testHandleExceptionConsoleRenderingNoStack(): void
  106. {
  107. $output = new StubConsoleOutput();
  108. $trap = new ExceptionTrap([
  109. 'exceptionRenderer' => ConsoleExceptionRenderer::class,
  110. 'stderr' => $output,
  111. ]);
  112. $error = new InvalidArgumentException('nope');
  113. $trap->handleException($error);
  114. $out = $output->messages();
  115. $this->assertStringContainsString('nope', $out[0]);
  116. $this->assertStringNotContainsString('Stack', $out[0]);
  117. }
  118. public function testHandleExceptionConsoleRenderingWithStack(): void
  119. {
  120. $output = new StubConsoleOutput();
  121. $trap = new ExceptionTrap([
  122. 'exceptionRenderer' => ConsoleExceptionRenderer::class,
  123. 'stderr' => $output,
  124. 'trace' => true,
  125. ]);
  126. $error = new InvalidArgumentException('nope');
  127. $trap->handleException($error);
  128. $out = $output->messages();
  129. $this->assertStringContainsString('nope', $out[0]);
  130. $this->assertStringContainsString('Stack', $out[0]);
  131. $this->assertStringContainsString('->testHandleExceptionConsoleRenderingWithStack', $out[0]);
  132. }
  133. public function testHandleExceptionConsoleRenderingWithPrevious(): void
  134. {
  135. $output = new StubConsoleOutput();
  136. $trap = new ExceptionTrap([
  137. 'exceptionRenderer' => ConsoleExceptionRenderer::class,
  138. 'stderr' => $output,
  139. 'trace' => true,
  140. ]);
  141. $previous = new RuntimeException('underlying error');
  142. $error = new InvalidArgumentException('nope', 0, $previous);
  143. $trap->handleException($error);
  144. $out = $output->messages();
  145. $this->assertStringContainsString('nope', $out[0]);
  146. $this->assertStringContainsString('Caused by [RuntimeException] underlying error', $out[0]);
  147. $this->assertEquals(2, substr_count($out[0], 'Stack Trace'));
  148. }
  149. public function testHandleExceptionConsoleWithAttributes(): void
  150. {
  151. $output = new StubConsoleOutput();
  152. $trap = new ExceptionTrap([
  153. 'exceptionRenderer' => ConsoleExceptionRenderer::class,
  154. 'stderr' => $output,
  155. ]);
  156. $error = new MissingControllerException(['name' => 'Articles']);
  157. $trap->handleException($error);
  158. $out = $output->messages();
  159. $this->assertStringContainsString('Controller class `Articles`', $out[0]);
  160. $this->assertStringContainsString('Exception Attributes', $out[0]);
  161. $this->assertStringContainsString('Articles', $out[0]);
  162. }
  163. /**
  164. * Test integration with HTML exception rendering
  165. *
  166. * Run in a separate process because HTML output writes headers.
  167. */
  168. #[PreserveGlobalState(false)]
  169. #[RunInSeparateProcess]
  170. public function testHandleExceptionHtmlRendering(): void
  171. {
  172. $trap = new ExceptionTrap([
  173. 'exceptionRenderer' => WebExceptionRenderer::class,
  174. ]);
  175. $error = new InvalidArgumentException('nope');
  176. ob_start();
  177. $trap->handleException($error);
  178. $out = ob_get_clean();
  179. $this->assertStringContainsString('<!DOCTYPE', $out);
  180. $this->assertStringContainsString('<html', $out);
  181. $this->assertStringContainsString('nope', $out);
  182. $this->assertStringContainsString('class="stack-frame-header"', $out);
  183. $this->assertStringContainsString('Toggle Arguments', $out);
  184. }
  185. public function testLogException(): void
  186. {
  187. Log::setConfig('test_error', [
  188. 'className' => 'Array',
  189. ]);
  190. $trap = new ExceptionTrap();
  191. $error = new InvalidArgumentException('nope');
  192. $trap->logException($error);
  193. $logs = Log::engine('test_error')->read();
  194. $this->assertStringContainsString('nope', $logs[0]);
  195. }
  196. public function testLogExceptionConfigOff(): void
  197. {
  198. Log::setConfig('test_error', [
  199. 'className' => 'Array',
  200. ]);
  201. $trap = new ExceptionTrap(['log' => false]);
  202. $error = new InvalidArgumentException('nope');
  203. $trap->logException($error);
  204. $logs = Log::engine('test_error')->read();
  205. $this->assertEmpty($logs);
  206. }
  207. #[PreserveGlobalState(false)]
  208. #[RunInSeparateProcess]
  209. public function testSkipLogException(): void
  210. {
  211. Log::setConfig('test_error', [
  212. 'className' => 'Array',
  213. ]);
  214. $trap = new ExceptionTrap([
  215. 'exceptionRenderer' => WebExceptionRenderer::class,
  216. 'skipLog' => [InvalidArgumentException::class],
  217. ]);
  218. $trap->getEventManager()->on('Exception.beforeRender', function (): void {
  219. $this->triggered = true;
  220. });
  221. ob_start();
  222. $trap->handleException(new InvalidArgumentException('nope'));
  223. ob_get_clean();
  224. $logs = Log::engine('test_error')->read();
  225. $this->assertCount(1, $logs);
  226. $this->assertStringContainsString('MissingTemplateException - Failed to render', $logs[0]);
  227. $this->assertTrue($this->triggered, 'Should have triggered event when skipping logging.');
  228. }
  229. public function testEventTriggered(): void
  230. {
  231. $trap = new ExceptionTrap(['exceptionRenderer' => TextExceptionRenderer::class]);
  232. $trap->getEventManager()->on('Exception.beforeRender', function ($event, Throwable $error): void {
  233. $this->assertEquals(100, $error->getCode());
  234. $this->assertStringContainsString('nope', $error->getMessage());
  235. });
  236. $error = new InvalidArgumentException('nope', 100);
  237. ob_start();
  238. $trap->handleException($error);
  239. $out = ob_get_clean();
  240. $this->assertNotEmpty($out);
  241. }
  242. public function testBeforeRenderEventAborted(): void
  243. {
  244. $trap = new ExceptionTrap(['exceptionRenderer' => TextExceptionRenderer::class]);
  245. $trap->getEventManager()->on('Exception.beforeRender', function ($event, Throwable $error, ?ServerRequest $req): void {
  246. $this->assertEquals(100, $error->getCode());
  247. $this->assertStringContainsString('nope', $error->getMessage());
  248. $event->stopPropagation();
  249. });
  250. $error = new InvalidArgumentException('nope', 100);
  251. ob_start();
  252. $trap->handleException($error);
  253. $out = ob_get_clean();
  254. $this->assertSame('', $out);
  255. }
  256. public function testBeforeRenderEventExceptionChanged(): void
  257. {
  258. $trap = new ExceptionTrap(['exceptionRenderer' => TextExceptionRenderer::class]);
  259. $trap->getEventManager()->on('Exception.beforeRender', function ($event, Throwable $error, ?ServerRequest $req): void {
  260. $event->setData('exception', new NotFoundException());
  261. });
  262. $error = new InvalidArgumentException('nope', 100);
  263. ob_start();
  264. $trap->handleException($error);
  265. $out = ob_get_clean();
  266. $this->assertStringContainsString('404 : Not Found', $out);
  267. }
  268. public function testBeforeRenderEventReturnResponse(): void
  269. {
  270. $trap = new ExceptionTrap(['exceptionRenderer' => TextExceptionRenderer::class]);
  271. $trap->getEventManager()->on('Exception.beforeRender', function ($event, Throwable $error, ?ServerRequest $req) {
  272. return 'Here B Erroz';
  273. });
  274. ob_start();
  275. $trap->handleException(new NotFoundException());
  276. $out = ob_get_clean();
  277. $this->assertSame('Here B Erroz', $out);
  278. }
  279. public function testHandleShutdownNoOp(): void
  280. {
  281. $trap = new ExceptionTrap([
  282. 'exceptionRenderer' => TextExceptionRenderer::class,
  283. ]);
  284. ob_start();
  285. $trap->handleShutdown();
  286. $out = ob_get_clean();
  287. $this->assertEmpty($out);
  288. }
  289. public function testHandleFatalShutdownNoError(): void
  290. {
  291. $trap = new ExceptionTrap([
  292. 'exceptionRenderer' => TextExceptionRenderer::class,
  293. ]);
  294. error_clear_last();
  295. ob_start();
  296. $trap->handleShutdown();
  297. $out = ob_get_clean();
  298. $this->assertSame('', $out);
  299. }
  300. public function testHandleFatalErrorText(): void
  301. {
  302. $trap = new ExceptionTrap([
  303. 'exceptionRenderer' => TextExceptionRenderer::class,
  304. ]);
  305. ob_start();
  306. $trap->handleFatalError(E_USER_ERROR, 'Something bad', __FILE__, __LINE__);
  307. $out = ob_get_clean();
  308. $this->assertStringContainsString('500 : Fatal Error', $out);
  309. $this->assertStringContainsString('Something bad', $out);
  310. $this->assertStringContainsString(__FILE__, $out);
  311. }
  312. /**
  313. * Test integration with HTML rendering for fatal errors
  314. *
  315. * Run in a separate process because HTML output writes headers.
  316. */
  317. #[PreserveGlobalState(false)]
  318. #[RunInSeparateProcess]
  319. public function testHandleFatalErrorHtmlRendering(): void
  320. {
  321. $trap = new ExceptionTrap([
  322. 'exceptionRenderer' => WebExceptionRenderer::class,
  323. ]);
  324. ob_start();
  325. $trap->handleFatalError(E_USER_ERROR, 'Something bad', __FILE__, __LINE__);
  326. $out = ob_get_clean();
  327. $this->assertStringContainsString('<!DOCTYPE', $out);
  328. $this->assertStringContainsString('<html', $out);
  329. $this->assertStringContainsString('Something bad', $out);
  330. $this->assertStringContainsString(__FILE__, $out);
  331. }
  332. /**
  333. * Data provider for memory limit increase
  334. */
  335. public static function initialMemoryProvider(): array
  336. {
  337. return [
  338. ['256M'],
  339. ['1G'],
  340. ];
  341. }
  342. #[DataProvider('initialMemoryProvider')]
  343. public function testIncreaseMemoryLimit($initial): void
  344. {
  345. ini_set('memory_limit', $initial);
  346. $this->assertEquals($initial, ini_get('memory_limit'));
  347. $trap = new ExceptionTrap([
  348. 'exceptionRenderer' => TextExceptionRenderer::class,
  349. ]);
  350. $trap->increaseMemoryLimit(4 * 1024);
  351. $initialBytes = Text::parseFileSize($initial, false);
  352. $result = Text::parseFileSize(ini_get('memory_limit'), false);
  353. $this->assertWithinRange($initialBytes + (4 * 1024 * 1024), $result, 1024);
  354. }
  355. public function testSingleton(): void
  356. {
  357. $trap = new ExceptionTrap();
  358. $trap->register();
  359. $this->assertSame($trap, ExceptionTrap::instance());
  360. $trap->unregister();
  361. $this->assertNull(ExceptionTrap::instance());
  362. }
  363. }