UncaughtExceptionTest.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. <?php
  2. namespace Drupal\FunctionalTests\Bootstrap;
  3. use Drupal\Component\Render\FormattableMarkup;
  4. use Drupal\Tests\BrowserTestBase;
  5. /**
  6. * Tests kernel panic when things are really messed up.
  7. *
  8. * @group system
  9. */
  10. class UncaughtExceptionTest extends BrowserTestBase {
  11. /**
  12. * Last cURL response.
  13. *
  14. * @var string
  15. */
  16. protected $response = '';
  17. /**
  18. * Last cURL info.
  19. *
  20. * @var array
  21. */
  22. protected $info = [];
  23. /**
  24. * Exceptions thrown by site under test that contain this text are ignored.
  25. *
  26. * @var string
  27. */
  28. protected $expectedExceptionMessage;
  29. /**
  30. * Modules to enable.
  31. *
  32. * @var array
  33. */
  34. public static $modules = ['error_service_test', 'error_test'];
  35. /**
  36. * {@inheritdoc}
  37. */
  38. protected $defaultTheme = 'stark';
  39. /**
  40. * {@inheritdoc}
  41. */
  42. protected function setUp() {
  43. parent::setUp();
  44. $settings_filename = $this->siteDirectory . '/settings.php';
  45. chmod($settings_filename, 0777);
  46. $settings_php = file_get_contents($settings_filename);
  47. $settings_php .= "\ninclude_once 'core/tests/Drupal/FunctionalTests/Bootstrap/ErrorContainer.php';\n";
  48. $settings_php .= "\ninclude_once 'core/tests/Drupal/FunctionalTests/Bootstrap/ExceptionContainer.php';\n";
  49. file_put_contents($settings_filename, $settings_php);
  50. $settings = [];
  51. $settings['config']['system.logging']['error_level'] = (object) [
  52. 'value' => ERROR_REPORTING_DISPLAY_VERBOSE,
  53. 'required' => TRUE,
  54. ];
  55. $this->writeSettings($settings);
  56. }
  57. /**
  58. * Tests uncaught exception handling when system is in a bad state.
  59. */
  60. public function testUncaughtException() {
  61. $this->expectedExceptionMessage = 'Oh oh, bananas in the instruments.';
  62. \Drupal::state()->set('error_service_test.break_bare_html_renderer', TRUE);
  63. $this->config('system.logging')
  64. ->set('error_level', ERROR_REPORTING_HIDE)
  65. ->save();
  66. $settings = [];
  67. $settings['config']['system.logging']['error_level'] = (object) [
  68. 'value' => ERROR_REPORTING_HIDE,
  69. 'required' => TRUE,
  70. ];
  71. $this->writeSettings($settings);
  72. $this->drupalGet('');
  73. $this->assertResponse(500);
  74. $this->assertText('The website encountered an unexpected error. Please try again later.');
  75. $this->assertNoText($this->expectedExceptionMessage);
  76. $this->config('system.logging')
  77. ->set('error_level', ERROR_REPORTING_DISPLAY_ALL)
  78. ->save();
  79. $settings = [];
  80. $settings['config']['system.logging']['error_level'] = (object) [
  81. 'value' => ERROR_REPORTING_DISPLAY_ALL,
  82. 'required' => TRUE,
  83. ];
  84. $this->writeSettings($settings);
  85. $this->drupalGet('');
  86. $this->assertResponse(500);
  87. $this->assertText('The website encountered an unexpected error. Please try again later.');
  88. $this->assertText($this->expectedExceptionMessage);
  89. $this->assertErrorLogged($this->expectedExceptionMessage);
  90. }
  91. /**
  92. * Tests displaying an uncaught fatal error.
  93. */
  94. public function testUncaughtFatalError() {
  95. $fatal_error = [
  96. '%type' => 'TypeError',
  97. '@message' => 'Argument 1 passed to Drupal\error_test\Controller\ErrorTestController::Drupal\error_test\Controller\{closure}() must be of the type array, string given, called in ' . \Drupal::root() . '/core/modules/system/tests/modules/error_test/src/Controller/ErrorTestController.php on line 62',
  98. '%function' => 'Drupal\error_test\Controller\ErrorTestController->Drupal\error_test\Controller\{closure}()',
  99. ];
  100. $this->drupalGet('error-test/generate-fatals');
  101. $this->assertResponse(500);
  102. $message = new FormattableMarkup('%type: @message in %function (line ', $fatal_error);
  103. $this->assertRaw((string) $message);
  104. $this->assertRaw('<pre class="backtrace">');
  105. // Ensure we are escaping but not double escaping.
  106. $this->assertRaw('&#039;');
  107. $this->assertNoRaw('&amp;#039;');
  108. }
  109. /**
  110. * Tests uncaught exception handling with custom exception handler.
  111. */
  112. public function testUncaughtExceptionCustomExceptionHandler() {
  113. $settings_filename = $this->siteDirectory . '/settings.php';
  114. chmod($settings_filename, 0777);
  115. $settings_php = file_get_contents($settings_filename);
  116. $settings_php .= "\n";
  117. $settings_php .= "set_exception_handler(function() {\n";
  118. $settings_php .= " header('HTTP/1.1 418 I\'m a teapot');\n";
  119. $settings_php .= " print('Oh oh, flying teapots');\n";
  120. $settings_php .= "});\n";
  121. file_put_contents($settings_filename, $settings_php);
  122. \Drupal::state()->set('error_service_test.break_bare_html_renderer', TRUE);
  123. $this->drupalGet('');
  124. $this->assertResponse(418);
  125. $this->assertNoText('The website encountered an unexpected error. Please try again later.');
  126. $this->assertNoText('Oh oh, bananas in the instruments');
  127. $this->assertText('Oh oh, flying teapots');
  128. }
  129. /**
  130. * Tests a missing dependency on a service.
  131. */
  132. public function testMissingDependency() {
  133. if (version_compare(PHP_VERSION, '7.1') < 0) {
  134. $this->expectedExceptionMessage = 'Argument 1 passed to Drupal\error_service_test\LonelyMonkeyClass::__construct() must be an instance of Drupal\Core\Database\Connection, non';
  135. }
  136. else {
  137. $this->expectedExceptionMessage = 'Too few arguments to function Drupal\error_service_test\LonelyMonkeyClass::__construct(), 0 passed';
  138. }
  139. $this->drupalGet('broken-service-class');
  140. $this->assertResponse(500);
  141. $this->assertRaw('The website encountered an unexpected error.');
  142. $this->assertRaw($this->expectedExceptionMessage);
  143. $this->assertErrorLogged($this->expectedExceptionMessage);
  144. }
  145. /**
  146. * Tests a missing dependency on a service with a custom error handler.
  147. */
  148. public function testMissingDependencyCustomErrorHandler() {
  149. $settings_filename = $this->siteDirectory . '/settings.php';
  150. chmod($settings_filename, 0777);
  151. $settings_php = file_get_contents($settings_filename);
  152. $settings_php .= "\n";
  153. $settings_php .= "set_error_handler(function() {\n";
  154. $settings_php .= " header('HTTP/1.1 418 I\'m a teapot');\n";
  155. $settings_php .= " print('Oh oh, flying teapots');\n";
  156. $settings_php .= " exit();\n";
  157. $settings_php .= "});\n";
  158. $settings_php .= "\$settings['teapots'] = TRUE;\n";
  159. file_put_contents($settings_filename, $settings_php);
  160. $this->drupalGet('broken-service-class');
  161. $this->assertResponse(418);
  162. $this->assertSame('Oh oh, flying teapots', $this->response);
  163. }
  164. /**
  165. * Tests a container which has an error.
  166. */
  167. public function testErrorContainer() {
  168. $settings = [];
  169. $settings['settings']['container_base_class'] = (object) [
  170. 'value' => '\Drupal\FunctionalTests\Bootstrap\ErrorContainer',
  171. 'required' => TRUE,
  172. ];
  173. $this->writeSettings($settings);
  174. \Drupal::service('kernel')->invalidateContainer();
  175. $this->expectedExceptionMessage = 'Argument 1 passed to Drupal\FunctionalTests\Bootstrap\ErrorContainer::Drupal\FunctionalTests\Bootstrap\{closur';
  176. $this->drupalGet('');
  177. $this->assertResponse(500);
  178. $this->assertRaw($this->expectedExceptionMessage);
  179. $this->assertErrorLogged($this->expectedExceptionMessage);
  180. }
  181. /**
  182. * Tests a container which has an exception really early.
  183. */
  184. public function testExceptionContainer() {
  185. $settings = [];
  186. $settings['settings']['container_base_class'] = (object) [
  187. 'value' => '\Drupal\FunctionalTests\Bootstrap\ExceptionContainer',
  188. 'required' => TRUE,
  189. ];
  190. $this->writeSettings($settings);
  191. \Drupal::service('kernel')->invalidateContainer();
  192. $this->expectedExceptionMessage = 'Thrown exception during Container::get';
  193. $this->drupalGet('');
  194. $this->assertResponse(500);
  195. $this->assertRaw('The website encountered an unexpected error');
  196. $this->assertRaw($this->expectedExceptionMessage);
  197. $this->assertErrorLogged($this->expectedExceptionMessage);
  198. }
  199. /**
  200. * Tests the case when the database connection is gone.
  201. */
  202. public function testLostDatabaseConnection() {
  203. $incorrect_username = $this->randomMachineName(16);
  204. switch ($this->container->get('database')->driver()) {
  205. case 'pgsql':
  206. case 'mysql':
  207. $this->expectedExceptionMessage = $incorrect_username;
  208. break;
  209. default:
  210. // We can not carry out this test.
  211. $this->markTestSkipped('Unable to run \Drupal\system\Tests\System\UncaughtExceptionTest::testLostDatabaseConnection for this database type.');
  212. }
  213. // We simulate a broken database connection by rewrite settings.php to no
  214. // longer have the proper data.
  215. $settings['databases']['default']['default']['username'] = (object) [
  216. 'value' => $incorrect_username,
  217. 'required' => TRUE,
  218. ];
  219. $settings['databases']['default']['default']['password'] = (object) [
  220. 'value' => $this->randomMachineName(16),
  221. 'required' => TRUE,
  222. ];
  223. $this->writeSettings($settings);
  224. $this->drupalGet('');
  225. $this->assertResponse(500);
  226. $this->assertRaw('DatabaseAccessDeniedException');
  227. $this->assertErrorLogged($this->expectedExceptionMessage);
  228. }
  229. /**
  230. * Tests fallback to PHP error log when an exception is thrown while logging.
  231. */
  232. public function testLoggerException() {
  233. // Ensure the test error log is empty before these tests.
  234. $this->assertNoErrorsLogged();
  235. $this->expectedExceptionMessage = 'Deforestation';
  236. \Drupal::state()->set('error_service_test.break_logger', TRUE);
  237. $this->drupalGet('');
  238. $this->assertResponse(500);
  239. $this->assertText('The website encountered an unexpected error. Please try again later.');
  240. $this->assertRaw($this->expectedExceptionMessage);
  241. // Find fatal error logged to the error.log
  242. $errors = file(\Drupal::root() . '/' . $this->siteDirectory . '/error.log');
  243. $this->assertCount(8, $errors, 'The error + the error that the logging service is broken has been written to the error log.');
  244. $this->assertStringContainsString('Failed to log error', $errors[0], 'The error handling logs when an error could not be logged to the logger.');
  245. $expected_path = \Drupal::root() . '/core/modules/system/tests/modules/error_service_test/src/MonkeysInTheControlRoom.php';
  246. $expected_line = 59;
  247. $expected_entry = "Failed to log error: Exception: Deforestation in Drupal\\error_service_test\\MonkeysInTheControlRoom->handle() (line ${expected_line} of ${expected_path})";
  248. $this->assertStringContainsString($expected_entry, $errors[0], 'Original error logged to the PHP error log when an exception is thrown by a logger');
  249. // The exception is expected. Do not interpret it as a test failure. Not
  250. // using File API; a potential error must trigger a PHP warning.
  251. unlink(\Drupal::root() . '/' . $this->siteDirectory . '/error.log');
  252. }
  253. /**
  254. * Asserts that a specific error has been logged to the PHP error log.
  255. *
  256. * @param string $error_message
  257. * The expected error message.
  258. *
  259. * @see \Drupal\simpletest\TestBase::prepareEnvironment()
  260. * @see \Drupal\Core\DrupalKernel::bootConfiguration()
  261. */
  262. protected function assertErrorLogged($error_message) {
  263. $error_log_filename = DRUPAL_ROOT . '/' . $this->siteDirectory . '/error.log';
  264. $this->assertFileExists($error_log_filename);
  265. $content = file_get_contents($error_log_filename);
  266. $rows = explode(PHP_EOL, $content);
  267. // We iterate over the rows in order to be able to remove the logged error
  268. // afterwards.
  269. $found = FALSE;
  270. foreach ($rows as $row_index => $row) {
  271. if (strpos($content, $error_message) !== FALSE) {
  272. $found = TRUE;
  273. unset($rows[$row_index]);
  274. }
  275. }
  276. file_put_contents($error_log_filename, implode("\n", $rows));
  277. $this->assertTrue($found, sprintf('The %s error message was logged.', $error_message));
  278. }
  279. /**
  280. * Asserts that no errors have been logged to the PHP error.log thus far.
  281. *
  282. * @see \Drupal\simpletest\TestBase::prepareEnvironment()
  283. * @see \Drupal\Core\DrupalKernel::bootConfiguration()
  284. */
  285. protected function assertNoErrorsLogged() {
  286. // Since PHP only creates the error.log file when an actual error is
  287. // triggered, it is sufficient to check whether the file exists.
  288. $this->assertFileNotExists(DRUPAL_ROOT . '/' . $this->siteDirectory . '/error.log');
  289. }
  290. /**
  291. * Retrieves a Drupal path or an absolute path.
  292. *
  293. * Executes a cURL request for processing errors and exceptions.
  294. *
  295. * @param string|\Drupal\Core\Url $path
  296. * Request path.
  297. * @param array $extra_options
  298. * (optional) Curl options to pass to curl_setopt()
  299. * @param array $headers
  300. * (optional) Not used.
  301. */
  302. protected function drupalGet($path, array $extra_options = [], array $headers = []) {
  303. $url = $this->buildUrl($path, ['absolute' => TRUE]);
  304. $ch = curl_init();
  305. curl_setopt($ch, CURLOPT_URL, $url);
  306. curl_setopt($ch, CURLOPT_HEADER, FALSE);
  307. curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
  308. curl_setopt($ch, CURLOPT_USERAGENT, drupal_generate_test_ua($this->databasePrefix));
  309. $this->response = curl_exec($ch);
  310. $this->info = curl_getinfo($ch);
  311. curl_close($ch);
  312. }
  313. /**
  314. * {@inheritdoc}
  315. */
  316. protected function assertResponse($code) {
  317. $this->assertSame($code, $this->info['http_code']);
  318. }
  319. /**
  320. * {@inheritdoc}
  321. */
  322. protected function assertText($text) {
  323. $this->assertStringContainsString($text, $this->response);
  324. }
  325. /**
  326. * {@inheritdoc}
  327. */
  328. protected function assertNoText($text) {
  329. $this->assertStringNotContainsString($text, $this->response);
  330. }
  331. /**
  332. * {@inheritdoc}
  333. */
  334. protected function assertRaw($text) {
  335. $this->assertText($text);
  336. }
  337. /**
  338. * {@inheritdoc}
  339. */
  340. protected function assertNoRaw($text) {
  341. $this->assertNoText($text);
  342. }
  343. }