FatalErrorHandlerTest.php 9.1 KB

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