WebExceptionRendererTest.php 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957
  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(tm) Project
  13. * @since 2.0.0
  14. * @license https://opensource.org/licenses/mit-license.php MIT License
  15. */
  16. namespace Cake\Test\TestCase\Error;
  17. use Cake\Controller\Controller;
  18. use Cake\Controller\ErrorController;
  19. use Cake\Controller\Exception\InvalidParameterException;
  20. use Cake\Controller\Exception\MissingActionException;
  21. use Cake\Controller\Exception\MissingComponentException;
  22. use Cake\Core\Configure;
  23. use Cake\Core\Exception\CakeException;
  24. use Cake\Core\Exception\MissingPluginException;
  25. use Cake\Database\Driver;
  26. use Cake\Database\Exception\QueryException;
  27. use Cake\Database\Log\LoggedQuery;
  28. use Cake\Datasource\Exception\MissingDatasourceConfigException;
  29. use Cake\Datasource\Exception\MissingDatasourceException;
  30. use Cake\Error\Renderer\WebExceptionRenderer;
  31. use Cake\Event\EventInterface;
  32. use Cake\Event\EventManager;
  33. use Cake\Http\Exception\HttpException;
  34. use Cake\Http\Exception\InternalErrorException;
  35. use Cake\Http\Exception\MethodNotAllowedException;
  36. use Cake\Http\Exception\MissingControllerException;
  37. use Cake\Http\Exception\NotFoundException;
  38. use Cake\Http\Response;
  39. use Cake\Http\ServerRequest;
  40. use Cake\Mailer\Exception\MissingActionException as MissingMailerActionException;
  41. use Cake\ORM\Exception\MissingBehaviorException;
  42. use Cake\Routing\Router;
  43. use Cake\TestSuite\TestCase;
  44. use Cake\View\Exception\MissingHelperException;
  45. use Cake\View\Exception\MissingLayoutException;
  46. use Cake\View\Exception\MissingTemplateException;
  47. use Exception;
  48. use OutOfBoundsException;
  49. use PDOException;
  50. use PHPUnit\Framework\Attributes\DataProvider;
  51. use RuntimeException;
  52. use TestApp\Controller\Admin\ErrorController as PrefixErrorController;
  53. use TestApp\Error\Exception\MissingWidgetThing;
  54. use TestApp\Error\Exception\MissingWidgetThingException;
  55. use TestApp\Error\Renderer\MyCustomExceptionRenderer;
  56. use TestApp\Error\Renderer\TestAppsExceptionRenderer;
  57. use TestPlugin\Controller\ErrorController as PluginErrorController;
  58. use function Cake\Core\h;
  59. class WebExceptionRendererTest extends TestCase
  60. {
  61. /**
  62. * @var bool
  63. */
  64. protected $restoreError = false;
  65. /**
  66. * @var bool
  67. */
  68. protected $called;
  69. /**
  70. * setup create a request object to get out of router later.
  71. */
  72. public function setUp(): void
  73. {
  74. parent::setUp();
  75. Configure::write('Config.language', 'eng');
  76. Router::reload();
  77. $request = new ServerRequest(['base' => '']);
  78. Router::setRequest($request);
  79. Configure::write('debug', true);
  80. }
  81. /**
  82. * tearDown
  83. */
  84. public function tearDown(): void
  85. {
  86. parent::tearDown();
  87. $this->clearPlugins();
  88. if ($this->restoreError) {
  89. restore_error_handler();
  90. }
  91. }
  92. public function testControllerInstanceForPrefixedRequest(): void
  93. {
  94. $this->setAppNamespace('TestApp');
  95. $exception = new NotFoundException('Page not found');
  96. $request = new ServerRequest();
  97. $request = $request
  98. ->withParam('controller', 'Articles')
  99. ->withParam('prefix', 'Admin');
  100. $ExceptionRenderer = new MyCustomExceptionRenderer($exception, $request);
  101. $this->assertInstanceOf(
  102. PrefixErrorController::class,
  103. $ExceptionRenderer->__debugInfo()['controller']
  104. );
  105. }
  106. /**
  107. * Test that prefixed controllers in plugins use the plugin
  108. * error controller if it exists.
  109. *
  110. * @return void
  111. */
  112. public function testControllerInstanceForPluginPrefixedRequest(): void
  113. {
  114. $this->loadPlugins(['TestPlugin']);
  115. $this->setAppNamespace('TestApp');
  116. $exception = new NotFoundException('Page not found');
  117. $request = new ServerRequest();
  118. $request = $request
  119. ->withParam('controller', 'Comments')
  120. ->withParam('plugin', 'TestPlugin')
  121. ->withParam('prefix', 'Admin');
  122. $ExceptionRenderer = new MyCustomExceptionRenderer($exception, $request);
  123. $this->assertInstanceOf(
  124. PluginErrorController::class,
  125. $ExceptionRenderer->__debugInfo()['controller']
  126. );
  127. }
  128. /**
  129. * testTemplatePath
  130. */
  131. public function testTemplatePath(): void
  132. {
  133. $request = (new ServerRequest())
  134. ->withParam('controller', 'Foo')
  135. ->withParam('action', 'bar');
  136. $exception = new NotFoundException();
  137. $ExceptionRenderer = new WebExceptionRenderer($exception, $request);
  138. $ExceptionRenderer->render();
  139. $controller = $ExceptionRenderer->__debugInfo()['controller'];
  140. $this->assertSame('error400', $controller->viewBuilder()->getTemplate());
  141. $this->assertSame('Error', $controller->viewBuilder()->getTemplatePath());
  142. $request = $request->withParam('prefix', 'Admin');
  143. $exception = new MissingActionException(['controller' => 'Foo', 'action' => 'bar']);
  144. $ExceptionRenderer = new WebExceptionRenderer($exception, $request);
  145. $ExceptionRenderer->render();
  146. $controller = $ExceptionRenderer->__debugInfo()['controller'];
  147. $this->assertSame('missingAction', $controller->viewBuilder()->getTemplate());
  148. $this->assertSame('Error', $controller->viewBuilder()->getTemplatePath());
  149. Configure::write('debug', false);
  150. $ExceptionRenderer = new WebExceptionRenderer($exception, $request);
  151. $ExceptionRenderer->render();
  152. $controller = $ExceptionRenderer->__debugInfo()['controller'];
  153. $this->assertSame('error400', $controller->viewBuilder()->getTemplate());
  154. $this->assertSame(
  155. 'Admin' . DIRECTORY_SEPARATOR . 'Error',
  156. $controller->viewBuilder()->getTemplatePath()
  157. );
  158. }
  159. /**
  160. * test that methods declared in an WebExceptionRenderer subclass are not converted
  161. * into error400 when debug > 0
  162. */
  163. public function testSubclassMethodsNotBeingConvertedToError(): void
  164. {
  165. $exception = new MissingWidgetThingException('Widget not found');
  166. $ExceptionRenderer = new MyCustomExceptionRenderer($exception);
  167. $result = $ExceptionRenderer->render();
  168. $this->assertSame('widget thing is missing', (string)$result->getBody());
  169. }
  170. /**
  171. * test that subclass methods are not converted when debug = 0
  172. */
  173. public function testSubclassMethodsNotBeingConvertedDebug0(): void
  174. {
  175. Configure::write('debug', false);
  176. $exception = new MissingWidgetThingException('Widget not found');
  177. $ExceptionRenderer = new MyCustomExceptionRenderer($exception);
  178. $result = $ExceptionRenderer->render();
  179. $this->assertSame(
  180. 'missingWidgetThing',
  181. $ExceptionRenderer->__debugInfo()['method']
  182. );
  183. $this->assertSame(
  184. 'widget thing is missing',
  185. (string)$result->getBody(),
  186. 'Method declared in subclass converted to error400'
  187. );
  188. }
  189. /**
  190. * test that WebExceptionRenderer subclasses properly convert framework errors.
  191. */
  192. public function testSubclassConvertingFrameworkErrors(): void
  193. {
  194. Configure::write('debug', false);
  195. $exception = new MissingControllerException('PostsController');
  196. $ExceptionRenderer = new MyCustomExceptionRenderer($exception);
  197. $result = $ExceptionRenderer->render();
  198. $this->assertMatchesRegularExpression(
  199. '/Not Found/',
  200. (string)$result->getBody(),
  201. 'Method declared in error handler not converted to error400. %s'
  202. );
  203. }
  204. /**
  205. * test things in the constructor.
  206. */
  207. public function testConstruction(): void
  208. {
  209. $exception = new NotFoundException('Page not found');
  210. $ExceptionRenderer = new WebExceptionRenderer($exception);
  211. $this->assertInstanceOf(
  212. ErrorController::class,
  213. $ExceptionRenderer->__debugInfo()['controller']
  214. );
  215. $this->assertEquals($exception, $ExceptionRenderer->__debugInfo()['error']);
  216. }
  217. /**
  218. * test that exception message gets coerced when debug = 0
  219. */
  220. public function testExceptionMessageCoercion(): void
  221. {
  222. Configure::write('debug', false);
  223. $exception = new MissingActionException('Secret info not to be leaked');
  224. $ExceptionRenderer = new WebExceptionRenderer($exception);
  225. $this->assertInstanceOf(
  226. ErrorController::class,
  227. $ExceptionRenderer->__debugInfo()['controller']
  228. );
  229. $this->assertEquals($exception, $ExceptionRenderer->__debugInfo()['error']);
  230. $result = (string)$ExceptionRenderer->render()->getBody();
  231. $this->assertSame('error400', $ExceptionRenderer->__debugInfo()['template']);
  232. $this->assertStringContainsString('Not Found', $result);
  233. $this->assertStringNotContainsString('Secret info not to be leaked', $result);
  234. }
  235. /**
  236. * test that helpers in custom CakeErrorController are not lost
  237. */
  238. public function testCakeErrorHelpersNotLost(): void
  239. {
  240. static::setAppNamespace();
  241. $exception = new NotFoundException();
  242. $renderer = new TestAppsExceptionRenderer($exception);
  243. $result = $renderer->render();
  244. $this->assertStringContainsString('<b>peeled</b>', (string)$result->getBody());
  245. }
  246. /**
  247. * test that unknown exception types with valid status codes are treated correctly.
  248. */
  249. public function testUnknownExceptionTypeWithExceptionThatHasA400Code(): void
  250. {
  251. $exception = new MissingWidgetThingException('coding fail.');
  252. $ExceptionRenderer = new WebExceptionRenderer($exception);
  253. $response = $ExceptionRenderer->render();
  254. $this->assertSame(404, $response->getStatusCode());
  255. $this->assertFalse(method_exists($ExceptionRenderer, 'missingWidgetThing'), 'no method should exist.');
  256. $this->assertStringContainsString('coding fail', (string)$response->getBody(), 'Text should show up.');
  257. }
  258. /**
  259. * test that unknown exception types with valid status codes are treated correctly.
  260. */
  261. public function testUnknownExceptionTypeWithNoCodeIsA500(): void
  262. {
  263. $exception = new OutOfBoundsException('foul ball.');
  264. $ExceptionRenderer = new WebExceptionRenderer($exception);
  265. $result = $ExceptionRenderer->render();
  266. $this->assertSame(500, $result->getStatusCode());
  267. $this->assertStringContainsString('foul ball.', (string)$result->getBody(), 'Text should show up as its debug mode.');
  268. }
  269. /**
  270. * test that unknown exceptions have messages ignored.
  271. */
  272. public function testUnknownExceptionInProduction(): void
  273. {
  274. Configure::write('debug', false);
  275. $exception = new OutOfBoundsException('foul ball.');
  276. $ExceptionRenderer = new WebExceptionRenderer($exception);
  277. $response = $ExceptionRenderer->render();
  278. $result = (string)$response->getBody();
  279. $this->assertSame(500, $response->getStatusCode());
  280. $this->assertStringNotContainsString('foul ball.', $result, 'Text should no show up.');
  281. $this->assertStringContainsString('Internal Error', $result, 'Generic message only.');
  282. }
  283. /**
  284. * test that unknown exception types with valid status codes are treated correctly.
  285. */
  286. public function testUnknownExceptionTypeWithCodeHigherThan500(): void
  287. {
  288. $exception = new HttpException('foul ball.', 501);
  289. $ExceptionRenderer = new WebExceptionRenderer($exception);
  290. $response = $ExceptionRenderer->render();
  291. $result = (string)$response->getBody();
  292. $this->assertSame(501, $response->getStatusCode());
  293. $this->assertStringContainsString('foul ball.', $result, 'Text should show up as its debug mode.');
  294. }
  295. /**
  296. * testerror400 method
  297. */
  298. public function testError400(): void
  299. {
  300. Router::reload();
  301. $request = new ServerRequest(['url' => 'posts/view/1000']);
  302. Router::setRequest($request);
  303. $exception = new NotFoundException('Custom message');
  304. $ExceptionRenderer = new WebExceptionRenderer($exception);
  305. $response = $ExceptionRenderer->render();
  306. $result = (string)$response->getBody();
  307. $this->assertSame(404, $response->getStatusCode());
  308. $this->assertStringContainsString('<h2>Custom message</h2>', $result);
  309. $this->assertMatchesRegularExpression("/<strong>'.*?\/posts\/view\/1000'<\/strong>/", $result);
  310. }
  311. /**
  312. * testerror400 method when returning as JSON
  313. */
  314. public function testError400AsJson(): void
  315. {
  316. Router::reload();
  317. $request = new ServerRequest(['url' => 'posts/view/1000?sort=title&direction=desc']);
  318. $request = $request->withHeader('Accept', 'application/json');
  319. $request = $request->withHeader('Content-Type', 'application/json');
  320. Router::setRequest($request);
  321. $exception = new NotFoundException('Custom message');
  322. $exceptionLine = __LINE__ - 1;
  323. $ExceptionRenderer = new WebExceptionRenderer($exception);
  324. $response = $ExceptionRenderer->render();
  325. $result = (string)$response->getBody();
  326. $expected = [
  327. 'message' => 'Custom message',
  328. 'url' => '/posts/view/1000?sort=title&amp;direction=desc',
  329. 'code' => 404,
  330. 'file' => __FILE__,
  331. 'line' => $exceptionLine,
  332. ];
  333. $this->assertEquals($expected, json_decode($result, true));
  334. $this->assertSame(404, $response->getStatusCode());
  335. }
  336. /**
  337. * test that error400 only modifies the messages on Cake Exceptions.
  338. */
  339. public function testError400OnlyChangingCakeException(): void
  340. {
  341. Configure::write('debug', false);
  342. $exception = new NotFoundException('Custom message');
  343. $ExceptionRenderer = new WebExceptionRenderer($exception);
  344. $result = $ExceptionRenderer->render();
  345. $this->assertStringContainsString('Custom message', (string)$result->getBody());
  346. $exception = new MissingActionException(['controller' => 'PostsController', 'action' => 'index']);
  347. $ExceptionRenderer = new WebExceptionRenderer($exception);
  348. $result = $ExceptionRenderer->render();
  349. $this->assertStringContainsString('Not Found', (string)$result->getBody());
  350. }
  351. /**
  352. * test that error400 doesn't expose XSS
  353. */
  354. public function testError400NoInjection(): void
  355. {
  356. Router::reload();
  357. $request = new ServerRequest(['url' => 'pages/<span id=333>pink</span></id><script>document.body.style.background = t=document.getElementById(333).innerHTML;window.alert(t);</script>']);
  358. Router::setRequest($request);
  359. $exception = new NotFoundException('Custom message');
  360. $ExceptionRenderer = new WebExceptionRenderer($exception);
  361. $result = (string)$ExceptionRenderer->render()->getBody();
  362. $this->assertStringNotContainsString('<script>document', $result);
  363. $this->assertStringNotContainsString('alert(t);</script>', $result);
  364. }
  365. /**
  366. * testError500 method
  367. */
  368. public function testError500Message(): void
  369. {
  370. $exception = new InternalErrorException('An Internal Error Has Occurred.');
  371. $ExceptionRenderer = new WebExceptionRenderer($exception);
  372. $response = $ExceptionRenderer->render();
  373. $result = (string)$response->getBody();
  374. $this->assertSame(500, $response->getStatusCode());
  375. $this->assertStringContainsString('<h2>An Internal Error Has Occurred.</h2>', $result);
  376. $this->assertStringContainsString('An Internal Error Has Occurred.</p>', $result);
  377. }
  378. /**
  379. * testExceptionResponseHeader method
  380. */
  381. public function testExceptionResponseHeader(): void
  382. {
  383. $exception = new MethodNotAllowedException('Only allowing POST and DELETE');
  384. $exception->setHeader('Allow', ['POST', 'DELETE']);
  385. $ExceptionRenderer = new WebExceptionRenderer($exception);
  386. $result = $ExceptionRenderer->render();
  387. $this->assertTrue($result->hasHeader('Allow'));
  388. $this->assertSame('POST,DELETE', $result->getHeaderLine('Allow'));
  389. $exception->setHeaders(['Allow' => 'GET']);
  390. $result = $ExceptionRenderer->render();
  391. $this->assertTrue($result->hasHeader('Allow'));
  392. $this->assertSame('GET', $result->getHeaderLine('Allow'));
  393. }
  394. /**
  395. * testMissingController method
  396. */
  397. public function testMissingController(): void
  398. {
  399. $exception = new MissingControllerException([
  400. 'controller' => 'Posts',
  401. 'prefix' => '',
  402. 'plugin' => '',
  403. ]);
  404. $ExceptionRenderer = new MyCustomExceptionRenderer($exception);
  405. $result = (string)$ExceptionRenderer->render()->getBody();
  406. $this->assertSame(
  407. 'missingController',
  408. $ExceptionRenderer->__debugInfo()['template']
  409. );
  410. $this->assertStringContainsString('Missing Controller', $result);
  411. $this->assertStringContainsString('<em>PostsController</em>', $result);
  412. }
  413. /**
  414. * test missingController method
  415. */
  416. public function testMissingControllerLowerCase(): void
  417. {
  418. $exception = new MissingControllerException([
  419. 'controller' => 'posts',
  420. 'prefix' => '',
  421. 'plugin' => '',
  422. ]);
  423. $ExceptionRenderer = new MyCustomExceptionRenderer($exception);
  424. $result = (string)$ExceptionRenderer->render()->getBody();
  425. $this->assertSame(
  426. 'missingController',
  427. $ExceptionRenderer->__debugInfo()['template']
  428. );
  429. $this->assertStringContainsString('Missing Controller', $result);
  430. $this->assertStringContainsString('<em>PostsController</em>', $result);
  431. }
  432. /**
  433. * Returns an array of tests to run for the various Cake Exception classes.
  434. *
  435. * @return array
  436. */
  437. public static function exceptionProvider(): array
  438. {
  439. return [
  440. [
  441. new MissingActionException([
  442. 'controller' => 'PostsController',
  443. 'action' => 'index',
  444. 'prefix' => '',
  445. 'plugin' => '',
  446. ]),
  447. [
  448. '/Missing Method in PostsController/',
  449. '/<em>PostsController::index\(\)<\/em>/',
  450. ],
  451. 404,
  452. ],
  453. [
  454. new InvalidParameterException([
  455. 'template' => 'failed_coercion',
  456. 'passed' => 'test',
  457. 'type' => 'float',
  458. 'parameter' => 'age',
  459. 'controller' => 'TestController',
  460. 'action' => 'checkAge',
  461. 'prefix' => null,
  462. 'plugin' => null,
  463. ]),
  464. ['/The passed parameter or parameter type is invalid in <em>TestController::checkAge\(\)/'],
  465. 404,
  466. ],
  467. [
  468. new MissingActionException([
  469. 'controller' => 'PostsController',
  470. 'action' => 'index',
  471. 'prefix' => '',
  472. 'plugin' => '',
  473. ]),
  474. [
  475. '/Missing Method in PostsController/',
  476. '/<em>PostsController::index\(\)<\/em>/',
  477. ],
  478. 404,
  479. ],
  480. [
  481. new MissingTemplateException(['file' => '/posts/about.ctp']),
  482. [
  483. "/posts\/about.ctp/",
  484. ],
  485. 500,
  486. ],
  487. [
  488. new MissingLayoutException(['file' => 'layouts/my_layout.ctp']),
  489. [
  490. '/Missing Layout/',
  491. "/layouts\/my_layout.ctp/",
  492. ],
  493. 500,
  494. ],
  495. [
  496. new MissingHelperException(['class' => 'MyCustomHelper']),
  497. [
  498. '/Missing Helper/',
  499. '/<em>MyCustomHelper<\/em> could not be found./',
  500. '/Create the class <em>MyCustomHelper<\/em> below in file:/',
  501. '/(\/|\\\)MyCustomHelper.php/',
  502. ],
  503. 500,
  504. ],
  505. [
  506. new MissingBehaviorException(['class' => 'MyCustomBehavior']),
  507. [
  508. '/Missing Behavior/',
  509. '/Create the class <em>MyCustomBehavior<\/em> below in file:/',
  510. '/(\/|\\\)MyCustomBehavior.php/',
  511. ],
  512. 500,
  513. ],
  514. [
  515. new MissingComponentException(['class' => 'SideboxComponent']),
  516. [
  517. '/Missing Component/',
  518. '/Create the class <em>SideboxComponent<\/em> below in file:/',
  519. '/(\/|\\\)SideboxComponent.php/',
  520. ],
  521. 500,
  522. ],
  523. [
  524. new MissingDatasourceConfigException(['name' => 'MyDatasourceConfig']),
  525. [
  526. '/Missing Datasource Configuration/',
  527. '/<em>MyDatasourceConfig<\/em> was not found/',
  528. ],
  529. 500,
  530. ],
  531. [
  532. new MissingDatasourceException(['class' => 'MyDatasource', 'plugin' => 'MyPlugin']),
  533. [
  534. '/Missing Datasource/',
  535. '/<em>MyPlugin.MyDatasource<\/em> could not be found./',
  536. ],
  537. 500,
  538. ],
  539. [
  540. new MissingMailerActionException([
  541. 'mailer' => 'UserMailer',
  542. 'action' => 'welcome',
  543. 'prefix' => '',
  544. 'plugin' => '',
  545. ]),
  546. [
  547. '/Missing Method in UserMailer/',
  548. '/<em>UserMailer::welcome\(\)<\/em>/',
  549. ],
  550. 500,
  551. ],
  552. [
  553. new Exception('boom'),
  554. [
  555. '/Internal Error/',
  556. ],
  557. 500,
  558. ],
  559. [
  560. new RuntimeException('another boom'),
  561. [
  562. '/Internal Error/',
  563. ],
  564. 500,
  565. ],
  566. [
  567. new CakeException('base class'),
  568. ['/Internal Error/'],
  569. 500,
  570. ],
  571. [
  572. new HttpException('Network Authentication Required', 511),
  573. ['/Network Authentication Required/'],
  574. 511,
  575. ],
  576. ];
  577. }
  578. /**
  579. * Test the various Cake Exception sub classes
  580. */
  581. #[DataProvider('exceptionProvider')]
  582. public function testCakeExceptionHandling(Exception $exception, array $patterns, int $code): void
  583. {
  584. $exceptionRenderer = new WebExceptionRenderer($exception);
  585. $response = $exceptionRenderer->render();
  586. $this->assertEquals($code, $response->getStatusCode());
  587. $body = (string)$response->getBody();
  588. foreach ($patterns as $pattern) {
  589. $this->assertMatchesRegularExpression($pattern, $body);
  590. }
  591. }
  592. /**
  593. * Test that class names not ending in Exception are not mangled.
  594. */
  595. public function testExceptionNameMangling(): void
  596. {
  597. $exceptionRenderer = new MyCustomExceptionRenderer(new MissingWidgetThing());
  598. $result = (string)$exceptionRenderer->render()->getBody();
  599. $this->assertStringContainsString('widget thing is missing', $result);
  600. // Custom method should be called even when debug is off.
  601. Configure::write('debug', false);
  602. $exceptionRenderer = new MyCustomExceptionRenderer(new MissingWidgetThing());
  603. $result = (string)$exceptionRenderer->render()->getBody();
  604. $this->assertStringContainsString('widget thing is missing', $result);
  605. }
  606. /**
  607. * Test exceptions being raised when helpers are missing.
  608. */
  609. public function testMissingRenderSafe(): void
  610. {
  611. $exception = new MissingHelperException(['class' => 'Fail']);
  612. $ExceptionRenderer = new MyCustomExceptionRenderer($exception);
  613. $request = new ServerRequest();
  614. $controller = new class ($request) extends Controller {
  615. public function render(?string $template = null, ?string $layout = null): Response
  616. {
  617. throw new MissingHelperException(['class' => 'Fail']);
  618. }
  619. };
  620. $ExceptionRenderer->setController($controller);
  621. $response = $ExceptionRenderer->render();
  622. $helpers = $controller->viewBuilder()->getHelpers();
  623. sort($helpers);
  624. $this->assertEquals([], $helpers);
  625. $this->assertStringContainsString('Helper class `Fail`', (string)$response->getBody());
  626. }
  627. /**
  628. * Test that exceptions in beforeRender() are handled by outputMessageSafe
  629. */
  630. public function testRenderExceptionInBeforeRender(): void
  631. {
  632. $exception = new NotFoundException('Not there, sorry');
  633. $ExceptionRenderer = new MyCustomExceptionRenderer($exception);
  634. $request = new ServerRequest();
  635. $controller = new class ($request) extends Controller {
  636. public function beforeRender(EventInterface $event)
  637. {
  638. throw new NotFoundException('Not there, sorry');
  639. }
  640. };
  641. $ExceptionRenderer->setController($controller);
  642. $response = $ExceptionRenderer->render();
  643. $this->assertStringContainsString('Not there, sorry', (string)$response->getBody());
  644. }
  645. /**
  646. * Test that missing layoutPath don't cause other fatal errors.
  647. */
  648. public function testMissingLayoutPathRenderSafe(): void
  649. {
  650. $this->called = false;
  651. $exception = new NotFoundException();
  652. $ExceptionRenderer = new MyCustomExceptionRenderer($exception);
  653. $controller = new Controller(new ServerRequest());
  654. $controller->viewBuilder()->setHelpers(['Fail', 'Boom']);
  655. $controller->getEventManager()->on(
  656. 'Controller.beforeRender',
  657. function (EventInterface $event): void {
  658. $this->called = true;
  659. $event->getSubject()->viewBuilder()->setLayoutPath('boom');
  660. }
  661. );
  662. $controller->setRequest(new ServerRequest());
  663. $ExceptionRenderer->setController($controller);
  664. $response = $ExceptionRenderer->render();
  665. $this->assertSame('text/html', $response->getType());
  666. $this->assertStringContainsString('Not Found', (string)$response->getBody());
  667. $this->assertTrue($this->called, 'Listener added was not triggered.');
  668. $this->assertSame('', $controller->viewBuilder()->getLayoutPath());
  669. $this->assertSame('Error', $controller->viewBuilder()->getTemplatePath());
  670. }
  671. /**
  672. * Test that missing layout don't cause other fatal errors.
  673. */
  674. public function testMissingLayoutRenderSafe(): void
  675. {
  676. $this->called = false;
  677. $exception = new NotFoundException();
  678. $ExceptionRenderer = new MyCustomExceptionRenderer($exception);
  679. $controller = new Controller(new ServerRequest());
  680. $controller->getEventManager()->on(
  681. 'Controller.beforeRender',
  682. function (EventInterface $event): void {
  683. $this->called = true;
  684. $event->getSubject()->viewBuilder()->setTemplatePath('Error');
  685. $event->getSubject()->viewBuilder()->setLayout('does-not-exist');
  686. }
  687. );
  688. $controller->setRequest(new ServerRequest());
  689. $ExceptionRenderer->setController($controller);
  690. $response = $ExceptionRenderer->render();
  691. $this->assertSame('text/html', $response->getType());
  692. $this->assertStringContainsString('Not Found', (string)$response->getBody());
  693. $this->assertTrue($this->called, 'Listener added was not triggered.');
  694. $this->assertSame('', $controller->viewBuilder()->getLayoutPath());
  695. $this->assertSame('Error', $controller->viewBuilder()->getTemplatePath());
  696. }
  697. /**
  698. * Test that missing plugin disables Controller::$plugin if the two are the same plugin.
  699. */
  700. public function testMissingPluginRenderSafe(): void
  701. {
  702. $exception = new NotFoundException();
  703. $ExceptionRenderer = new MyCustomExceptionRenderer($exception);
  704. $request = new ServerRequest();
  705. $controller = new class ($request) extends Controller {
  706. public function render(?string $template = null, ?string $layout = null): Response
  707. {
  708. throw new MissingPluginException(['plugin' => 'TestPlugin']);
  709. }
  710. };
  711. $controller->setPlugin('TestPlugin');
  712. $ExceptionRenderer->setController($controller);
  713. $response = $ExceptionRenderer->render();
  714. $body = (string)$response->getBody();
  715. $this->assertStringNotContainsString('test plugin error500', $body);
  716. $this->assertStringContainsString('Not Found', $body);
  717. }
  718. /**
  719. * Test that missing plugin doesn't disable Controller::$plugin if the two aren't the same plugin.
  720. */
  721. public function testMissingPluginRenderSafeWithPlugin(): void
  722. {
  723. $this->loadPlugins(['TestPlugin']);
  724. $exception = new NotFoundException();
  725. $ExceptionRenderer = new MyCustomExceptionRenderer($exception);
  726. $request = new ServerRequest();
  727. $controller = new class ($request) extends Controller {
  728. public function render(?string $template = null, ?string $layout = null): Response
  729. {
  730. throw new MissingPluginException(['plugin' => 'TestPluginTwo']);
  731. }
  732. };
  733. $controller->setPlugin('TestPlugin');
  734. $ExceptionRenderer->setController($controller);
  735. $response = $ExceptionRenderer->render();
  736. $body = (string)$response->getBody();
  737. $this->assertStringContainsString('test plugin error500', $body);
  738. $this->assertStringContainsString('Not Found', $body);
  739. }
  740. /**
  741. * Test that exceptions can be rendered when a request hasn't been registered
  742. * with Router
  743. */
  744. public function testRenderWithNoRequest(): void
  745. {
  746. Router::reload();
  747. $this->assertNull(Router::getRequest());
  748. $exception = new Exception('Terrible');
  749. $ExceptionRenderer = new WebExceptionRenderer($exception);
  750. $result = $ExceptionRenderer->render();
  751. $this->assertStringContainsString('Internal Error', (string)$result->getBody());
  752. $this->assertSame(500, $result->getStatusCode());
  753. }
  754. /**
  755. * Test that router request parameters are applied when the passed
  756. * request has no params.
  757. */
  758. public function testRenderInheritRoutingParams(): void
  759. {
  760. $routerRequest = new ServerRequest([
  761. 'params' => [
  762. 'controller' => 'Articles',
  763. 'action' => 'index',
  764. 'plugin' => null,
  765. 'pass' => [],
  766. '_ext' => 'json',
  767. ],
  768. ]);
  769. // Simulate a request having routing applied and stored in router
  770. Router::setRequest($routerRequest);
  771. $exceptionRenderer = new WebExceptionRenderer(new Exception('Terrible'), new ServerRequest());
  772. $exceptionRenderer->render();
  773. $properties = $exceptionRenderer->__debugInfo();
  774. /** @var \Cake\Http\ServerRequest $request */
  775. $request = $properties['controller']->getRequest();
  776. foreach (['controller', 'action', '_ext'] as $key) {
  777. $this->assertSame($routerRequest->getParam($key), $request->getParam($key));
  778. }
  779. }
  780. /**
  781. * Test that rendering exceptions triggers shutdown events.
  782. */
  783. public function testRenderShutdownEvents(): void
  784. {
  785. $fired = [];
  786. $listener = function (EventInterface $event) use (&$fired): void {
  787. $fired[] = $event->getName();
  788. };
  789. $events = EventManager::instance();
  790. $events->on('Controller.shutdown', $listener);
  791. $exception = new Exception('Terrible');
  792. $renderer = new WebExceptionRenderer($exception);
  793. $renderer->render();
  794. $expected = ['Controller.shutdown'];
  795. $this->assertEquals($expected, $fired);
  796. }
  797. /**
  798. * test that subclass methods fire shutdown events.
  799. */
  800. public function testSubclassTriggerShutdownEvents(): void
  801. {
  802. $fired = [];
  803. $listener = function (EventInterface $event) use (&$fired): void {
  804. $fired[] = $event->getName();
  805. };
  806. $events = EventManager::instance();
  807. $events->on('Controller.shutdown', $listener);
  808. $exception = new MissingWidgetThingException('Widget not found');
  809. $renderer = new MyCustomExceptionRenderer($exception);
  810. $renderer->render();
  811. $expected = ['Controller.shutdown'];
  812. $this->assertEquals($expected, $fired);
  813. }
  814. /**
  815. * Tests the output of rendering a PDOException
  816. */
  817. public function testPDOException(): void
  818. {
  819. $loggedQuery = new LoggedQuery();
  820. $loggedQuery->setContext([
  821. 'query' => 'SELECT * from poo_query < 5 and :seven',
  822. 'driver' => $this->getMockBuilder(Driver::class)->getMock(),
  823. 'params' => ['seven' => 7],
  824. ]);
  825. $pdoException = $this->getMockBuilder(PDOException::class)->getMock();
  826. $exception = new QueryException($loggedQuery, $pdoException);
  827. $ExceptionRenderer = new WebExceptionRenderer($exception);
  828. $response = $ExceptionRenderer->render();
  829. $this->assertSame(500, $response->getStatusCode());
  830. $result = (string)$response->getBody();
  831. $this->assertStringContainsString('Database Error', $result);
  832. $this->assertStringContainsString('SQL Query', $result);
  833. $this->assertStringContainsString(h('SELECT * from poo_query < 5 and 7'), $result);
  834. }
  835. }