*/
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);
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('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('max-age=31536000; includeSubDomains', $h['Strict-Transport-Security']);
self::assertArrayHasKey('Content-Security-Policy', $h);
$hPlain = FatalErrorHandler::securityHeaders(false);
self::assertArrayNotHasKey('Strict-Transport-Security', $hPlain);
}
/** @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;
}
}