| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Unit\Http;
- use App\Infrastructure\Http\JsonErrorHandler;
- use PHPUnit\Framework\TestCase;
- use Psr\Log\NullLogger;
- use RuntimeException;
- use Slim\Exception\HttpBadRequestException;
- use Slim\Exception\HttpException;
- use Slim\Exception\HttpForbiddenException;
- use Slim\Exception\HttpInternalServerErrorException;
- use Slim\Exception\HttpMethodNotAllowedException;
- use Slim\Exception\HttpNotFoundException;
- use Slim\Psr7\Factory\ResponseFactory;
- use Slim\Psr7\Factory\ServerRequestFactory;
- /**
- * Production responses must never leak attacker-influenced exception
- * messages (SEC_REVIEW F26). The handler maps statuses to a fixed token
- * set, clamps invalid codes, and only adds `detail` when expose is on.
- */
- final class JsonErrorHandlerTest extends TestCase
- {
- public function testNotFoundReturns404Token(): void
- {
- $request = $this->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<string, mixed>
- */
- private function json(\Psr\Http\Message\ResponseInterface $response): array
- {
- $body = (string) $response->getBody();
- /** @var array<string, mixed> $decoded */
- $decoded = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
- return $decoded;
- }
- }
|