1
0

FatalErrorHandlerTest.php 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. <?php
  2. /*
  3. * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
  4. * SPDX-License-Identifier: Apache-2.0
  5. *
  6. * Licensed under the Apache License, Version 2.0 (the "License");
  7. * you may not use this file except in compliance with the License.
  8. * See the LICENSE file in the project root for the full license text.
  9. */
  10. declare(strict_types=1);
  11. namespace App\Tests\Http;
  12. use App\Http\FatalErrorHandler;
  13. use App\Tests\TestCase;
  14. use RuntimeException;
  15. /**
  16. * R01-N13: pin the front-controller safety net.
  17. *
  18. * The handler's two side-effecting concerns — emitting headers and
  19. * echoing the body — are both injected as callables, so we can capture
  20. * their arguments without touching real PHP `header()` or stdout.
  21. */
  22. final class FatalErrorHandlerTest extends TestCase
  23. {
  24. /** @var list<string> */
  25. private array $headers;
  26. private string $body;
  27. private bool $headersSent;
  28. private int $drainCalls;
  29. protected function setUp(): void
  30. {
  31. $this->headers = [];
  32. $this->body = '';
  33. $this->headersSent = false;
  34. $this->drainCalls = 0;
  35. }
  36. public function testEmitWritesMinimal500WithSecurityHeadersInProduction(): void
  37. {
  38. FatalErrorHandler::emit(
  39. new RuntimeException('database is on fire'),
  40. 'production',
  41. isHttps: true,
  42. emitHeader: $this->headerSink(),
  43. emitBody: $this->bodySink(),
  44. headersSent: fn(): bool => $this->headersSent,
  45. drainBuffers: $this->drainSink(),
  46. );
  47. self::assertSame(1, $this->drainCalls, 'emit must drain buffers exactly once');
  48. self::assertContains('HTTP/1.1 500 Internal Server Error', $this->headers);
  49. self::assertContains('Content-Type: text/html; charset=utf-8', $this->headers);
  50. self::assertContains('X-Content-Type-Options: nosniff', $this->headers);
  51. self::assertContains('X-Frame-Options: DENY', $this->headers);
  52. self::assertContains('Referrer-Policy: strict-origin-when-cross-origin', $this->headers);
  53. // R01-N25: Flash / Acrobat cross-domain policy lockout.
  54. self::assertContains('X-Permitted-Cross-Domain-Policies: none', $this->headers);
  55. self::assertContains(
  56. 'Strict-Transport-Security: max-age=31536000; includeSubDomains',
  57. $this->headers,
  58. 'HSTS must ride along when isHttps=true',
  59. );
  60. $cspLine = $this->findHeader('Content-Security-Policy');
  61. self::assertNotNull($cspLine);
  62. self::assertStringContainsString("default-src 'self'", $cspLine);
  63. self::assertStringContainsString("script-src 'self'", $cspLine);
  64. self::assertStringContainsString("frame-ancestors 'none'", $cspLine);
  65. self::assertStringContainsString(
  66. "form-action 'self' https://login.microsoftonline.com",
  67. $cspLine,
  68. 'OIDC redirect target must remain reachable from the error page',
  69. );
  70. self::assertStringContainsString(
  71. 'report-uri /csp-report',
  72. $cspLine,
  73. 'R01-N19: browsers must know where to send CSP violation reports',
  74. );
  75. self::assertStringContainsString('500 — Server error', $this->body);
  76. self::assertStringNotContainsString(
  77. 'database is on fire',
  78. $this->body,
  79. 'production must NOT leak the throwable message',
  80. );
  81. self::assertStringNotContainsString(
  82. 'RuntimeException',
  83. $this->body,
  84. 'production must NOT leak the throwable class',
  85. );
  86. }
  87. public function testEmitLeaksThrowableDetailInDevelopment(): void
  88. {
  89. FatalErrorHandler::emit(
  90. new RuntimeException('PDOException: no such table'),
  91. 'development',
  92. isHttps: false,
  93. emitHeader: $this->headerSink(),
  94. emitBody: $this->bodySink(),
  95. headersSent: fn(): bool => $this->headersSent,
  96. drainBuffers: $this->drainSink(),
  97. );
  98. self::assertStringContainsString('500 — Server error', $this->body);
  99. self::assertStringContainsString('RuntimeException', $this->body);
  100. self::assertStringContainsString('no such table', $this->body);
  101. }
  102. public function testEmitOmitsHstsWhenNotHttps(): void
  103. {
  104. FatalErrorHandler::emit(
  105. new RuntimeException('boom'),
  106. 'production',
  107. isHttps: false,
  108. emitHeader: $this->headerSink(),
  109. emitBody: $this->bodySink(),
  110. headersSent: fn(): bool => $this->headersSent,
  111. drainBuffers: $this->drainSink(),
  112. );
  113. foreach ($this->headers as $h) {
  114. self::assertStringStartsNotWith(
  115. 'Strict-Transport-Security:',
  116. $h,
  117. 'HSTS must not be emitted on plain HTTP',
  118. );
  119. }
  120. }
  121. public function testEmitSkipsHeadersWhenAlreadySent(): void
  122. {
  123. // Mid-flight fatal: PHP has already written status + some headers.
  124. // We must NOT try to set new ones (PHP would warn) — but the body
  125. // append should still happen so users see *something*.
  126. $this->headersSent = true;
  127. FatalErrorHandler::emit(
  128. new RuntimeException('mid-flight'),
  129. 'production',
  130. isHttps: true,
  131. emitHeader: $this->headerSink(),
  132. emitBody: $this->bodySink(),
  133. headersSent: fn(): bool => $this->headersSent,
  134. drainBuffers: $this->drainSink(),
  135. );
  136. self::assertSame([], $this->headers);
  137. self::assertStringContainsString('500 — Server error', $this->body);
  138. self::assertSame(
  139. 1,
  140. $this->drainCalls,
  141. 'buffer must still be drained even when headers are already sent — partial output is dangerous',
  142. );
  143. }
  144. public function testEmitInvokesDrainBeforeWriting(): void
  145. {
  146. // Pin the drain → write ordering: if the drain ran AFTER the
  147. // body sink, the partial render the buffer holds would be
  148. // emitted first, in front of (or interleaved with) our 500
  149. // page. Capture the call order via a closure that records the
  150. // sequence and assert drain comes first.
  151. $order = [];
  152. FatalErrorHandler::emit(
  153. new RuntimeException('boom'),
  154. 'production',
  155. isHttps: false,
  156. emitHeader: function (string $line, bool $replace) use (&$order): void {
  157. $order[] = 'header';
  158. },
  159. emitBody: function (string $body) use (&$order): void {
  160. $order[] = 'body';
  161. },
  162. headersSent: fn(): bool => false,
  163. drainBuffers: function () use (&$order): void {
  164. $order[] = 'drain';
  165. },
  166. );
  167. self::assertSame('drain', $order[0] ?? null, 'buffer drain must precede any header / body write');
  168. self::assertContains('body', $order);
  169. }
  170. public function testRenderBodyEscapesThrowableMessageInDev(): void
  171. {
  172. $body = FatalErrorHandler::renderBody(
  173. new RuntimeException('<script>alert("xss")</script>'),
  174. 'development',
  175. );
  176. self::assertStringNotContainsString(
  177. '<script>alert("xss")</script>',
  178. $body,
  179. 'dev message must be HTML-escaped before display',
  180. );
  181. self::assertStringContainsString('&lt;script&gt;', $body);
  182. }
  183. public function testSecurityHeadersHelperMatchesEmittedSet(): void
  184. {
  185. $h = FatalErrorHandler::securityHeaders(true);
  186. self::assertSame('nosniff', $h['X-Content-Type-Options']);
  187. self::assertSame('DENY', $h['X-Frame-Options']);
  188. self::assertSame('strict-origin-when-cross-origin', $h['Referrer-Policy']);
  189. self::assertSame('none', $h['X-Permitted-Cross-Domain-Policies']);
  190. self::assertSame('max-age=31536000; includeSubDomains', $h['Strict-Transport-Security']);
  191. self::assertArrayHasKey('Content-Security-Policy', $h);
  192. $hPlain = FatalErrorHandler::securityHeaders(false);
  193. self::assertArrayNotHasKey('Strict-Transport-Security', $hPlain);
  194. // R01-N25: the new header is unconditional — must ride along on
  195. // both HTTP and HTTPS responses, unlike HSTS.
  196. self::assertSame('none', $hPlain['X-Permitted-Cross-Domain-Policies']);
  197. }
  198. /** @return callable(string,bool):void */
  199. private function headerSink(): callable
  200. {
  201. return function (string $line, bool $replace): void {
  202. $this->headers[] = $line;
  203. };
  204. }
  205. /** @return callable(string):void */
  206. private function bodySink(): callable
  207. {
  208. return function (string $body): void {
  209. $this->body .= $body;
  210. };
  211. }
  212. /**
  213. * No-op drain that just counts calls. Lets us assert "drain was
  214. * invoked exactly once" without disturbing PHPUnit's own output
  215. * buffers (which it owns and forbids us from closing).
  216. *
  217. * @return callable():void
  218. */
  219. private function drainSink(): callable
  220. {
  221. return function (): void {
  222. $this->drainCalls++;
  223. };
  224. }
  225. private function findHeader(string $name): ?string
  226. {
  227. $needle = strtolower($name) . ':';
  228. foreach ($this->headers as $h) {
  229. if (str_starts_with(strtolower($h), $needle)) {
  230. return $h;
  231. }
  232. }
  233. return null;
  234. }
  235. }