* 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 */ 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(''), 'development', ); self::assertStringNotContainsString( '', $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; } }