1
0

FatalErrorHandlerTest.php 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  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. self::assertContains(
  46. 'Strict-Transport-Security: max-age=31536000; includeSubDomains',
  47. $this->headers,
  48. 'HSTS must ride along when isHttps=true',
  49. );
  50. $cspLine = $this->findHeader('Content-Security-Policy');
  51. self::assertNotNull($cspLine);
  52. self::assertStringContainsString("default-src 'self'", $cspLine);
  53. self::assertStringContainsString("script-src 'self'", $cspLine);
  54. self::assertStringContainsString("frame-ancestors 'none'", $cspLine);
  55. self::assertStringContainsString(
  56. "form-action 'self' https://login.microsoftonline.com",
  57. $cspLine,
  58. 'OIDC redirect target must remain reachable from the error page',
  59. );
  60. self::assertStringContainsString('500 — Server error', $this->body);
  61. self::assertStringNotContainsString(
  62. 'database is on fire',
  63. $this->body,
  64. 'production must NOT leak the throwable message',
  65. );
  66. self::assertStringNotContainsString(
  67. 'RuntimeException',
  68. $this->body,
  69. 'production must NOT leak the throwable class',
  70. );
  71. }
  72. public function testEmitLeaksThrowableDetailInDevelopment(): void
  73. {
  74. FatalErrorHandler::emit(
  75. new RuntimeException('PDOException: no such table'),
  76. 'development',
  77. isHttps: false,
  78. emitHeader: $this->headerSink(),
  79. emitBody: $this->bodySink(),
  80. headersSent: fn(): bool => $this->headersSent,
  81. drainBuffers: $this->drainSink(),
  82. );
  83. self::assertStringContainsString('500 — Server error', $this->body);
  84. self::assertStringContainsString('RuntimeException', $this->body);
  85. self::assertStringContainsString('no such table', $this->body);
  86. }
  87. public function testEmitOmitsHstsWhenNotHttps(): void
  88. {
  89. FatalErrorHandler::emit(
  90. new RuntimeException('boom'),
  91. 'production',
  92. isHttps: false,
  93. emitHeader: $this->headerSink(),
  94. emitBody: $this->bodySink(),
  95. headersSent: fn(): bool => $this->headersSent,
  96. drainBuffers: $this->drainSink(),
  97. );
  98. foreach ($this->headers as $h) {
  99. self::assertStringStartsNotWith(
  100. 'Strict-Transport-Security:',
  101. $h,
  102. 'HSTS must not be emitted on plain HTTP',
  103. );
  104. }
  105. }
  106. public function testEmitSkipsHeadersWhenAlreadySent(): void
  107. {
  108. // Mid-flight fatal: PHP has already written status + some headers.
  109. // We must NOT try to set new ones (PHP would warn) — but the body
  110. // append should still happen so users see *something*.
  111. $this->headersSent = true;
  112. FatalErrorHandler::emit(
  113. new RuntimeException('mid-flight'),
  114. 'production',
  115. isHttps: true,
  116. emitHeader: $this->headerSink(),
  117. emitBody: $this->bodySink(),
  118. headersSent: fn(): bool => $this->headersSent,
  119. drainBuffers: $this->drainSink(),
  120. );
  121. self::assertSame([], $this->headers);
  122. self::assertStringContainsString('500 — Server error', $this->body);
  123. self::assertSame(
  124. 1,
  125. $this->drainCalls,
  126. 'buffer must still be drained even when headers are already sent — partial output is dangerous',
  127. );
  128. }
  129. public function testEmitInvokesDrainBeforeWriting(): void
  130. {
  131. // Pin the drain → write ordering: if the drain ran AFTER the
  132. // body sink, the partial render the buffer holds would be
  133. // emitted first, in front of (or interleaved with) our 500
  134. // page. Capture the call order via a closure that records the
  135. // sequence and assert drain comes first.
  136. $order = [];
  137. FatalErrorHandler::emit(
  138. new RuntimeException('boom'),
  139. 'production',
  140. isHttps: false,
  141. emitHeader: function (string $line, bool $replace) use (&$order): void {
  142. $order[] = 'header';
  143. },
  144. emitBody: function (string $body) use (&$order): void {
  145. $order[] = 'body';
  146. },
  147. headersSent: fn(): bool => false,
  148. drainBuffers: function () use (&$order): void {
  149. $order[] = 'drain';
  150. },
  151. );
  152. self::assertSame('drain', $order[0] ?? null, 'buffer drain must precede any header / body write');
  153. self::assertContains('body', $order);
  154. }
  155. public function testRenderBodyEscapesThrowableMessageInDev(): void
  156. {
  157. $body = FatalErrorHandler::renderBody(
  158. new RuntimeException('<script>alert("xss")</script>'),
  159. 'development',
  160. );
  161. self::assertStringNotContainsString(
  162. '<script>alert("xss")</script>',
  163. $body,
  164. 'dev message must be HTML-escaped before display',
  165. );
  166. self::assertStringContainsString('&lt;script&gt;', $body);
  167. }
  168. public function testSecurityHeadersHelperMatchesEmittedSet(): void
  169. {
  170. $h = FatalErrorHandler::securityHeaders(true);
  171. self::assertSame('nosniff', $h['X-Content-Type-Options']);
  172. self::assertSame('DENY', $h['X-Frame-Options']);
  173. self::assertSame('strict-origin-when-cross-origin', $h['Referrer-Policy']);
  174. self::assertSame('max-age=31536000; includeSubDomains', $h['Strict-Transport-Security']);
  175. self::assertArrayHasKey('Content-Security-Policy', $h);
  176. $hPlain = FatalErrorHandler::securityHeaders(false);
  177. self::assertArrayNotHasKey('Strict-Transport-Security', $hPlain);
  178. }
  179. /** @return callable(string,bool):void */
  180. private function headerSink(): callable
  181. {
  182. return function (string $line, bool $replace): void {
  183. $this->headers[] = $line;
  184. };
  185. }
  186. /** @return callable(string):void */
  187. private function bodySink(): callable
  188. {
  189. return function (string $body): void {
  190. $this->body .= $body;
  191. };
  192. }
  193. /**
  194. * No-op drain that just counts calls. Lets us assert "drain was
  195. * invoked exactly once" without disturbing PHPUnit's own output
  196. * buffers (which it owns and forbids us from closing).
  197. *
  198. * @return callable():void
  199. */
  200. private function drainSink(): callable
  201. {
  202. return function (): void {
  203. $this->drainCalls++;
  204. };
  205. }
  206. private function findHeader(string $name): ?string
  207. {
  208. $needle = strtolower($name) . ':';
  209. foreach ($this->headers as $h) {
  210. if (str_starts_with(strtolower($h), $needle)) {
  211. return $h;
  212. }
  213. }
  214. return null;
  215. }
  216. }