request(); $handler = $this->handler(expose: false); $response = $handler($request, new HttpNotFoundException($request, 'attacker /etc/passwd'), false, false, false); self::assertSame(404, $response->getStatusCode()); self::assertSame(['error' => 'not_found'], $this->json($response)); } public function testMethodNotAllowedReturns405Token(): void { $request = $this->request(); $handler = $this->handler(expose: false); $response = $handler($request, new HttpMethodNotAllowedException($request), false, false, false); self::assertSame(405, $response->getStatusCode()); self::assertSame(['error' => 'method_not_allowed'], $this->json($response)); } public function testHttpExceptionMessageNeverLeaksInProduction(): void { $request = $this->request(); $handler = $this->handler(expose: false); // A controller that wrapped a DB error into an HttpBadRequestException // would otherwise echo the raw driver message verbatim. $exception = new HttpBadRequestException( $request, "SQLSTATE[23000]: duplicate key value violates unique constraint 'users_email_idx'", ); $response = $handler($request, $exception, false, false, false); self::assertSame(400, $response->getStatusCode()); self::assertSame(['error' => 'bad_request'], $this->json($response)); } public function testHttpForbiddenMapsToToken(): void { $request = $this->request(); $handler = $this->handler(expose: false); $response = $handler($request, new HttpForbiddenException($request, 'role missing: foo'), false, false, false); self::assertSame(403, $response->getStatusCode()); self::assertSame(['error' => 'forbidden'], $this->json($response)); } public function testHttp500ExceptionDoesNotLeakMessage(): void { $request = $this->request(); $handler = $this->handler(expose: false); $response = $handler( $request, new HttpInternalServerErrorException($request, 'connection to db failed at 10.0.0.5:5432'), false, false, false, ); self::assertSame(500, $response->getStatusCode()); self::assertSame(['error' => 'internal_error'], $this->json($response)); } public function testGenericThrowableIsAlways500AndNeverLeaksInProd(): void { $request = $this->request(); $handler = $this->handler(expose: false); $response = $handler( $request, new RuntimeException('database connection refused: pgsql://app@10.0.0.5:5432'), false, false, false, ); self::assertSame(500, $response->getStatusCode()); self::assertSame(['error' => 'internal_error'], $this->json($response)); } public function testGenericThrowableWithCodeInClientRangeStillReturns500(): void { $request = $this->request(); $handler = $this->handler(expose: false); // Some PDO/DBAL/parse exceptions carry a numeric code that *happens* // to fall in 400-499. The handler must not honour that as an HTTP // status — non-HttpException Throwables collapse to 500. $exception = new RuntimeException('PDO: 42 invalid SQL syntax', 422); $response = $handler($request, $exception, false, false, false); self::assertSame(500, $response->getStatusCode()); self::assertSame(['error' => 'internal_error'], $this->json($response)); } public function testHttpExceptionWithOutOfRangeCodeClampsTo500(): void { $request = $this->request(); $handler = $this->handler(expose: false); // Bare `new HttpException(...)` with the default code=0 must not // emit an invalid HTTP status to the wire. $exception = new HttpException($request, 'oops', 0); $response = $handler($request, $exception, false, false, false); self::assertSame(500, $response->getStatusCode()); self::assertSame(['error' => 'internal_error'], $this->json($response)); } public function testHttpExceptionWithUnmappedKnownStatusFallsBackToInternalError(): void { $request = $this->request(); $handler = $this->handler(expose: false); // 418 is in the valid range but not in the canonical token map. $exception = new HttpException($request, 'I am a teapot', 418); $response = $handler($request, $exception, false, false, false); self::assertSame(418, $response->getStatusCode()); self::assertSame(['error' => 'internal_error'], $this->json($response)); } public function testExposeDetailsAddsDetailFieldInDevelopment(): void { $request = $this->request(); $handler = $this->handler(expose: true); $response = $handler( $request, new RuntimeException('database connection refused'), false, false, false, ); self::assertSame(500, $response->getStatusCode()); $body = $this->json($response); self::assertSame('internal_error', $body['error']); self::assertIsArray($body['detail']); self::assertSame(RuntimeException::class, $body['detail']['exception']); self::assertSame('database connection refused', $body['detail']['message']); } public function testDisplayErrorDetailsFlagAlsoExposesDetail(): void { $request = $this->request(); // Constructor flag is false (production), but Slim's per-request // $displayErrorDetails should still gate exposure on for debug paths. $handler = $this->handler(expose: false); $response = $handler( $request, new HttpBadRequestException($request, 'bespoke validation failure'), true, false, false, ); self::assertSame(400, $response->getStatusCode()); $body = $this->json($response); self::assertSame('bad_request', $body['error']); self::assertSame('bespoke validation failure', $body['detail']['message']); } public function testEmptyMessageInDetailIsNullNotEmptyString(): void { $request = $this->request(); $handler = $this->handler(expose: true); $response = $handler($request, new RuntimeException(''), false, false, false); $body = $this->json($response); self::assertNull($body['detail']['message']); } public function testResponseAlwaysHasJsonContentType(): void { $request = $this->request(); $handler = $this->handler(expose: false); $response = $handler($request, new RuntimeException('x'), false, false, false); self::assertSame('application/json', $response->getHeaderLine('Content-Type')); } private function handler(bool $expose): JsonErrorHandler { return new JsonErrorHandler(new ResponseFactory(), new NullLogger(), $expose); } private function request(): \Psr\Http\Message\ServerRequestInterface { return (new ServerRequestFactory())->createServerRequest('GET', 'https://api.test/x'); } /** * @return array */ private function json(\Psr\Http\Message\ResponseInterface $response): array { $body = (string) $response->getBody(); /** @var array $decoded */ $decoded = json_decode($body, true, flags: JSON_THROW_ON_ERROR); return $decoded; } }