|
@@ -0,0 +1,241 @@
|
|
|
|
|
+<?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;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|