| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260 |
- <?php
- /*
- * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
- * SPDX-License-Identifier: Apache-2.0
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * See the LICENSE file in the project root for the full license text.
- */
- declare(strict_types=1);
- namespace App\Tests\Http;
- use App\Http\FatalErrorHandler;
- use App\Tests\TestCase;
- use RuntimeException;
- /**
- * R01-N13: pin the front-controller safety net.
- *
- * The handler's two side-effecting concerns — emitting headers and
- * echoing the body — are both injected as callables, so we can capture
- * their arguments without touching real PHP `header()` or stdout.
- */
- final class FatalErrorHandlerTest extends TestCase
- {
- /** @var list<string> */
- private array $headers;
- private string $body;
- private bool $headersSent;
- private int $drainCalls;
- protected function setUp(): void
- {
- $this->headers = [];
- $this->body = '';
- $this->headersSent = false;
- $this->drainCalls = 0;
- }
- public function testEmitWritesMinimal500WithSecurityHeadersInProduction(): void
- {
- FatalErrorHandler::emit(
- new RuntimeException('database is on fire'),
- 'production',
- isHttps: true,
- emitHeader: $this->headerSink(),
- emitBody: $this->bodySink(),
- headersSent: fn(): bool => $this->headersSent,
- drainBuffers: $this->drainSink(),
- );
- self::assertSame(1, $this->drainCalls, 'emit must drain buffers exactly once');
- self::assertContains('HTTP/1.1 500 Internal Server Error', $this->headers);
- self::assertContains('Content-Type: text/html; charset=utf-8', $this->headers);
- self::assertContains('X-Content-Type-Options: nosniff', $this->headers);
- self::assertContains('X-Frame-Options: DENY', $this->headers);
- self::assertContains('Referrer-Policy: strict-origin-when-cross-origin', $this->headers);
- // R01-N25: Flash / Acrobat cross-domain policy lockout.
- self::assertContains('X-Permitted-Cross-Domain-Policies: none', $this->headers);
- self::assertContains(
- 'Strict-Transport-Security: max-age=31536000; includeSubDomains',
- $this->headers,
- 'HSTS must ride along when isHttps=true',
- );
- $cspLine = $this->findHeader('Content-Security-Policy');
- self::assertNotNull($cspLine);
- self::assertStringContainsString("default-src 'self'", $cspLine);
- self::assertStringContainsString("script-src 'self'", $cspLine);
- self::assertStringContainsString("frame-ancestors 'none'", $cspLine);
- self::assertStringContainsString(
- "form-action 'self' https://login.microsoftonline.com",
- $cspLine,
- 'OIDC redirect target must remain reachable from the error page',
- );
- self::assertStringContainsString(
- 'report-uri /csp-report',
- $cspLine,
- 'R01-N19: browsers must know where to send CSP violation reports',
- );
- self::assertStringContainsString('500 — Server error', $this->body);
- self::assertStringNotContainsString(
- 'database is on fire',
- $this->body,
- 'production must NOT leak the throwable message',
- );
- self::assertStringNotContainsString(
- 'RuntimeException',
- $this->body,
- 'production must NOT leak the throwable class',
- );
- }
- public function testEmitLeaksThrowableDetailInDevelopment(): void
- {
- FatalErrorHandler::emit(
- new RuntimeException('PDOException: no such table'),
- 'development',
- isHttps: false,
- emitHeader: $this->headerSink(),
- emitBody: $this->bodySink(),
- headersSent: fn(): bool => $this->headersSent,
- drainBuffers: $this->drainSink(),
- );
- self::assertStringContainsString('500 — Server error', $this->body);
- self::assertStringContainsString('RuntimeException', $this->body);
- self::assertStringContainsString('no such table', $this->body);
- }
- public function testEmitOmitsHstsWhenNotHttps(): void
- {
- FatalErrorHandler::emit(
- new RuntimeException('boom'),
- 'production',
- isHttps: false,
- emitHeader: $this->headerSink(),
- emitBody: $this->bodySink(),
- headersSent: fn(): bool => $this->headersSent,
- drainBuffers: $this->drainSink(),
- );
- foreach ($this->headers as $h) {
- self::assertStringStartsNotWith(
- 'Strict-Transport-Security:',
- $h,
- 'HSTS must not be emitted on plain HTTP',
- );
- }
- }
- public function testEmitSkipsHeadersWhenAlreadySent(): void
- {
- // Mid-flight fatal: PHP has already written status + some headers.
- // We must NOT try to set new ones (PHP would warn) — but the body
- // append should still happen so users see *something*.
- $this->headersSent = true;
- FatalErrorHandler::emit(
- new RuntimeException('mid-flight'),
- 'production',
- isHttps: true,
- emitHeader: $this->headerSink(),
- emitBody: $this->bodySink(),
- headersSent: fn(): bool => $this->headersSent,
- drainBuffers: $this->drainSink(),
- );
- self::assertSame([], $this->headers);
- self::assertStringContainsString('500 — Server error', $this->body);
- self::assertSame(
- 1,
- $this->drainCalls,
- 'buffer must still be drained even when headers are already sent — partial output is dangerous',
- );
- }
- public function testEmitInvokesDrainBeforeWriting(): void
- {
- // Pin the drain → write ordering: if the drain ran AFTER the
- // body sink, the partial render the buffer holds would be
- // emitted first, in front of (or interleaved with) our 500
- // page. Capture the call order via a closure that records the
- // sequence and assert drain comes first.
- $order = [];
- FatalErrorHandler::emit(
- new RuntimeException('boom'),
- 'production',
- isHttps: false,
- emitHeader: function (string $line, bool $replace) use (&$order): void {
- $order[] = 'header';
- },
- emitBody: function (string $body) use (&$order): void {
- $order[] = 'body';
- },
- headersSent: fn(): bool => false,
- drainBuffers: function () use (&$order): void {
- $order[] = 'drain';
- },
- );
- self::assertSame('drain', $order[0] ?? null, 'buffer drain must precede any header / body write');
- self::assertContains('body', $order);
- }
- public function testRenderBodyEscapesThrowableMessageInDev(): void
- {
- $body = FatalErrorHandler::renderBody(
- new RuntimeException('<script>alert("xss")</script>'),
- 'development',
- );
- self::assertStringNotContainsString(
- '<script>alert("xss")</script>',
- $body,
- 'dev message must be HTML-escaped before display',
- );
- self::assertStringContainsString('<script>', $body);
- }
- public function testSecurityHeadersHelperMatchesEmittedSet(): void
- {
- $h = FatalErrorHandler::securityHeaders(true);
- self::assertSame('nosniff', $h['X-Content-Type-Options']);
- self::assertSame('DENY', $h['X-Frame-Options']);
- self::assertSame('strict-origin-when-cross-origin', $h['Referrer-Policy']);
- self::assertSame('none', $h['X-Permitted-Cross-Domain-Policies']);
- self::assertSame('max-age=31536000; includeSubDomains', $h['Strict-Transport-Security']);
- self::assertArrayHasKey('Content-Security-Policy', $h);
- $hPlain = FatalErrorHandler::securityHeaders(false);
- self::assertArrayNotHasKey('Strict-Transport-Security', $hPlain);
- // R01-N25: the new header is unconditional — must ride along on
- // both HTTP and HTTPS responses, unlike HSTS.
- self::assertSame('none', $hPlain['X-Permitted-Cross-Domain-Policies']);
- }
- /** @return callable(string,bool):void */
- private function headerSink(): callable
- {
- return function (string $line, bool $replace): void {
- $this->headers[] = $line;
- };
- }
- /** @return callable(string):void */
- private function bodySink(): callable
- {
- return function (string $body): void {
- $this->body .= $body;
- };
- }
- /**
- * No-op drain that just counts calls. Lets us assert "drain was
- * invoked exactly once" without disturbing PHPUnit's own output
- * buffers (which it owns and forbids us from closing).
- *
- * @return callable():void
- */
- private function drainSink(): callable
- {
- return function (): void {
- $this->drainCalls++;
- };
- }
- private function findHeader(string $name): ?string
- {
- $needle = strtolower($name) . ':';
- foreach ($this->headers as $h) {
- if (str_starts_with(strtolower($h), $needle)) {
- return $h;
- }
- }
- return null;
- }
- }
|