WebExceptionRendererTest.php 35 KB

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