ExceptionTrapTest.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  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 RuntimeException;
  31. use Throwable;
  32. class ExceptionTrapTest extends TestCase
  33. {
  34. /**
  35. * @var string
  36. */
  37. private $memoryLimit;
  38. private $triggered = false;
  39. public function setUp(): void
  40. {
  41. parent::setUp();
  42. $this->memoryLimit = ini_get('memory_limit');
  43. }
  44. public function tearDown(): void
  45. {
  46. parent::tearDown();
  47. Log::reset();
  48. ini_set('memory_limit', $this->memoryLimit);
  49. }
  50. public function testConfigExceptionRendererFallback()
  51. {
  52. $output = new StubConsoleOutput();
  53. $trap = new ExceptionTrap(['exceptionRenderer' => null, 'stderr' => $output]);
  54. $error = new InvalidArgumentException('nope');
  55. $this->assertInstanceOf(ConsoleExceptionRenderer::class, $trap->renderer($error));
  56. }
  57. public function testConfigExceptionRenderer()
  58. {
  59. $trap = new ExceptionTrap(['exceptionRenderer' => WebExceptionRenderer::class]);
  60. $error = new InvalidArgumentException('nope');
  61. $this->assertInstanceOf(WebExceptionRenderer::class, $trap->renderer($error));
  62. }
  63. public function testConfigExceptionRendererFactory()
  64. {
  65. $trap = new ExceptionTrap(['exceptionRenderer' => function ($err, $req) {
  66. return new WebExceptionRenderer($err, $req);
  67. }]);
  68. $error = new InvalidArgumentException('nope');
  69. $this->assertInstanceOf(WebExceptionRenderer::class, $trap->renderer($error));
  70. }
  71. public function testConfigRendererHandleUnsafeOverwrite()
  72. {
  73. $output = new StubConsoleOutput();
  74. $trap = new ExceptionTrap(['stderr' => $output]);
  75. $trap->setConfig('exceptionRenderer', null);
  76. $error = new InvalidArgumentException('nope');
  77. $this->assertInstanceOf(ConsoleExceptionRenderer::class, $trap->renderer($error));
  78. }
  79. public function testLoggerConfig()
  80. {
  81. $trap = new ExceptionTrap(['logger' => ErrorLogger::class]);
  82. $this->assertInstanceOf(ErrorLogger::class, $trap->logger());
  83. }
  84. public function testLoggerHandleUnsafeOverwrite()
  85. {
  86. $trap = new ExceptionTrap();
  87. $trap->setConfig('logger', null);
  88. $this->assertInstanceOf(ErrorLogger::class, $trap->logger());
  89. }
  90. public function testHandleExceptionText()
  91. {
  92. $trap = new ExceptionTrap([
  93. 'exceptionRenderer' => TextExceptionRenderer::class,
  94. ]);
  95. $error = new InvalidArgumentException('nope');
  96. ob_start();
  97. $trap->handleException($error);
  98. $out = ob_get_clean();
  99. $this->assertStringContainsString('nope', $out);
  100. $this->assertStringContainsString('ExceptionTrapTest', $out);
  101. }
  102. public function testHandleExceptionConsoleRenderingNoStack()
  103. {
  104. $output = new StubConsoleOutput();
  105. $trap = new ExceptionTrap([
  106. 'exceptionRenderer' => ConsoleExceptionRenderer::class,
  107. 'stderr' => $output,
  108. ]);
  109. $error = new InvalidArgumentException('nope');
  110. $trap->handleException($error);
  111. $out = $output->messages();
  112. $this->assertStringContainsString('nope', $out[0]);
  113. $this->assertStringNotContainsString('Stack', $out[0]);
  114. }
  115. public function testHandleExceptionConsoleRenderingWithStack()
  116. {
  117. $output = new StubConsoleOutput();
  118. $trap = new ExceptionTrap([
  119. 'exceptionRenderer' => ConsoleExceptionRenderer::class,
  120. 'stderr' => $output,
  121. 'trace' => true,
  122. ]);
  123. $error = new InvalidArgumentException('nope');
  124. $trap->handleException($error);
  125. $out = $output->messages();
  126. $this->assertStringContainsString('nope', $out[0]);
  127. $this->assertStringContainsString('Stack', $out[0]);
  128. $this->assertStringContainsString('->testHandleExceptionConsoleRenderingWithStack', $out[0]);
  129. }
  130. public function testHandleExceptionConsoleRenderingWithPrevious()
  131. {
  132. $output = new StubConsoleOutput();
  133. $trap = new ExceptionTrap([
  134. 'exceptionRenderer' => ConsoleExceptionRenderer::class,
  135. 'stderr' => $output,
  136. 'trace' => true,
  137. ]);
  138. $previous = new RuntimeException('underlying error');
  139. $error = new InvalidArgumentException('nope', 0, $previous);
  140. $trap->handleException($error);
  141. $out = $output->messages();
  142. $this->assertStringContainsString('nope', $out[0]);
  143. $this->assertStringContainsString('Caused by [RuntimeException] underlying error', $out[0]);
  144. $this->assertEquals(2, substr_count($out[0], 'Stack Trace'));
  145. }
  146. public function testHandleExceptionConsoleWithAttributes()
  147. {
  148. $output = new StubConsoleOutput();
  149. $trap = new ExceptionTrap([
  150. 'exceptionRenderer' => ConsoleExceptionRenderer::class,
  151. 'stderr' => $output,
  152. ]);
  153. $error = new MissingControllerException(['name' => 'Articles']);
  154. $trap->handleException($error);
  155. $out = $output->messages();
  156. $this->assertStringContainsString('Controller class `Articles`', $out[0]);
  157. $this->assertStringContainsString('Exception Attributes', $out[0]);
  158. $this->assertStringContainsString('Articles', $out[0]);
  159. }
  160. /**
  161. * Test integration with HTML exception rendering
  162. *
  163. * Run in a separate process because HTML output writes headers.
  164. *
  165. * @preserveGlobalState disabled
  166. * @runInSeparateProcess
  167. */
  168. public function testHandleExceptionHtmlRendering()
  169. {
  170. $trap = new ExceptionTrap([
  171. 'exceptionRenderer' => WebExceptionRenderer::class,
  172. ]);
  173. $error = new InvalidArgumentException('nope');
  174. ob_start();
  175. $trap->handleException($error);
  176. $out = ob_get_clean();
  177. $this->assertStringContainsString('<!DOCTYPE', $out);
  178. $this->assertStringContainsString('<html', $out);
  179. $this->assertStringContainsString('nope', $out);
  180. $this->assertStringContainsString('class="stack-frame-header"', $out);
  181. $this->assertStringContainsString('Toggle Arguments', $out);
  182. }
  183. public function testLogException()
  184. {
  185. Log::setConfig('test_error', [
  186. 'className' => 'Array',
  187. ]);
  188. $trap = new ExceptionTrap();
  189. $error = new InvalidArgumentException('nope');
  190. $trap->logException($error);
  191. $logs = Log::engine('test_error')->read();
  192. $this->assertStringContainsString('nope', $logs[0]);
  193. }
  194. public function testLogExceptionConfigOff()
  195. {
  196. Log::setConfig('test_error', [
  197. 'className' => 'Array',
  198. ]);
  199. $trap = new ExceptionTrap(['log' => false]);
  200. $error = new InvalidArgumentException('nope');
  201. $trap->logException($error);
  202. $logs = Log::engine('test_error')->read();
  203. $this->assertEmpty($logs);
  204. }
  205. /**
  206. * @preserveGlobalState disabled
  207. * @runInSeparateProcess
  208. */
  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 () {
  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()
  230. {
  231. $trap = new ExceptionTrap(['exceptionRenderer' => TextExceptionRenderer::class]);
  232. $trap->getEventManager()->on('Exception.beforeRender', function ($event, Throwable $error) {
  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) {
  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) {
  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()
  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()
  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()
  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 disabled
  318. * @runInSeparateProcess
  319. */
  320. public function testHandleFatalErrorHtmlRendering()
  321. {
  322. $trap = new ExceptionTrap([
  323. 'exceptionRenderer' => WebExceptionRenderer::class,
  324. ]);
  325. ob_start();
  326. $trap->handleFatalError(E_USER_ERROR, 'Something bad', __FILE__, __LINE__);
  327. $out = ob_get_clean();
  328. $this->assertStringContainsString('<!DOCTYPE', $out);
  329. $this->assertStringContainsString('<html', $out);
  330. $this->assertStringContainsString('Something bad', $out);
  331. $this->assertStringContainsString(__FILE__, $out);
  332. }
  333. /**
  334. * Data provider for memory limit increase
  335. */
  336. public static function initialMemoryProvider(): array
  337. {
  338. return [
  339. ['256M'],
  340. ['1G'],
  341. ];
  342. }
  343. /**
  344. * @dataProvider initialMemoryProvider
  345. */
  346. public function testIncreaseMemoryLimit($initial)
  347. {
  348. ini_set('memory_limit', $initial);
  349. $this->assertEquals($initial, ini_get('memory_limit'));
  350. $trap = new ExceptionTrap([
  351. 'exceptionRenderer' => TextExceptionRenderer::class,
  352. ]);
  353. $trap->increaseMemoryLimit(4 * 1024);
  354. $initialBytes = Text::parseFileSize($initial, false);
  355. $result = Text::parseFileSize(ini_get('memory_limit'), false);
  356. $this->assertWithinRange($initialBytes + (4 * 1024 * 1024), $result, 1024);
  357. }
  358. public function testSingleton()
  359. {
  360. $trap = new ExceptionTrap();
  361. $trap->register();
  362. $this->assertSame($trap, ExceptionTrap::instance());
  363. $trap->unregister();
  364. $this->assertNull(ExceptionTrap::instance());
  365. }
  366. }