1
0

JsonErrorHandlerTest.php 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Unit\Http;
  4. use App\Infrastructure\Http\JsonErrorHandler;
  5. use PHPUnit\Framework\TestCase;
  6. use Psr\Log\NullLogger;
  7. use RuntimeException;
  8. use Slim\Exception\HttpBadRequestException;
  9. use Slim\Exception\HttpException;
  10. use Slim\Exception\HttpForbiddenException;
  11. use Slim\Exception\HttpInternalServerErrorException;
  12. use Slim\Exception\HttpMethodNotAllowedException;
  13. use Slim\Exception\HttpNotFoundException;
  14. use Slim\Psr7\Factory\ResponseFactory;
  15. use Slim\Psr7\Factory\ServerRequestFactory;
  16. /**
  17. * Production responses must never leak attacker-influenced exception
  18. * messages (SEC_REVIEW F26). The handler maps statuses to a fixed token
  19. * set, clamps invalid codes, and only adds `detail` when expose is on.
  20. */
  21. final class JsonErrorHandlerTest extends TestCase
  22. {
  23. public function testNotFoundReturns404Token(): void
  24. {
  25. $request = $this->request();
  26. $handler = $this->handler(expose: false);
  27. $response = $handler($request, new HttpNotFoundException($request, 'attacker /etc/passwd'), false, false, false);
  28. self::assertSame(404, $response->getStatusCode());
  29. self::assertSame(['error' => 'not_found'], $this->json($response));
  30. }
  31. public function testMethodNotAllowedReturns405Token(): void
  32. {
  33. $request = $this->request();
  34. $handler = $this->handler(expose: false);
  35. $response = $handler($request, new HttpMethodNotAllowedException($request), false, false, false);
  36. self::assertSame(405, $response->getStatusCode());
  37. self::assertSame(['error' => 'method_not_allowed'], $this->json($response));
  38. }
  39. public function testHttpExceptionMessageNeverLeaksInProduction(): void
  40. {
  41. $request = $this->request();
  42. $handler = $this->handler(expose: false);
  43. // A controller that wrapped a DB error into an HttpBadRequestException
  44. // would otherwise echo the raw driver message verbatim.
  45. $exception = new HttpBadRequestException(
  46. $request,
  47. "SQLSTATE[23000]: duplicate key value violates unique constraint 'users_email_idx'",
  48. );
  49. $response = $handler($request, $exception, false, false, false);
  50. self::assertSame(400, $response->getStatusCode());
  51. self::assertSame(['error' => 'bad_request'], $this->json($response));
  52. }
  53. public function testHttpForbiddenMapsToToken(): void
  54. {
  55. $request = $this->request();
  56. $handler = $this->handler(expose: false);
  57. $response = $handler($request, new HttpForbiddenException($request, 'role missing: foo'), false, false, false);
  58. self::assertSame(403, $response->getStatusCode());
  59. self::assertSame(['error' => 'forbidden'], $this->json($response));
  60. }
  61. public function testHttp500ExceptionDoesNotLeakMessage(): void
  62. {
  63. $request = $this->request();
  64. $handler = $this->handler(expose: false);
  65. $response = $handler(
  66. $request,
  67. new HttpInternalServerErrorException($request, 'connection to db failed at 10.0.0.5:5432'),
  68. false,
  69. false,
  70. false,
  71. );
  72. self::assertSame(500, $response->getStatusCode());
  73. self::assertSame(['error' => 'internal_error'], $this->json($response));
  74. }
  75. public function testGenericThrowableIsAlways500AndNeverLeaksInProd(): void
  76. {
  77. $request = $this->request();
  78. $handler = $this->handler(expose: false);
  79. $response = $handler(
  80. $request,
  81. new RuntimeException('database connection refused: pgsql://app@10.0.0.5:5432'),
  82. false,
  83. false,
  84. false,
  85. );
  86. self::assertSame(500, $response->getStatusCode());
  87. self::assertSame(['error' => 'internal_error'], $this->json($response));
  88. }
  89. public function testGenericThrowableWithCodeInClientRangeStillReturns500(): void
  90. {
  91. $request = $this->request();
  92. $handler = $this->handler(expose: false);
  93. // Some PDO/DBAL/parse exceptions carry a numeric code that *happens*
  94. // to fall in 400-499. The handler must not honour that as an HTTP
  95. // status — non-HttpException Throwables collapse to 500.
  96. $exception = new RuntimeException('PDO: 42 invalid SQL syntax', 422);
  97. $response = $handler($request, $exception, false, false, false);
  98. self::assertSame(500, $response->getStatusCode());
  99. self::assertSame(['error' => 'internal_error'], $this->json($response));
  100. }
  101. public function testHttpExceptionWithOutOfRangeCodeClampsTo500(): void
  102. {
  103. $request = $this->request();
  104. $handler = $this->handler(expose: false);
  105. // Bare `new HttpException(...)` with the default code=0 must not
  106. // emit an invalid HTTP status to the wire.
  107. $exception = new HttpException($request, 'oops', 0);
  108. $response = $handler($request, $exception, false, false, false);
  109. self::assertSame(500, $response->getStatusCode());
  110. self::assertSame(['error' => 'internal_error'], $this->json($response));
  111. }
  112. public function testHttpExceptionWithUnmappedKnownStatusFallsBackToInternalError(): void
  113. {
  114. $request = $this->request();
  115. $handler = $this->handler(expose: false);
  116. // 418 is in the valid range but not in the canonical token map.
  117. $exception = new HttpException($request, 'I am a teapot', 418);
  118. $response = $handler($request, $exception, false, false, false);
  119. self::assertSame(418, $response->getStatusCode());
  120. self::assertSame(['error' => 'internal_error'], $this->json($response));
  121. }
  122. public function testExposeDetailsAddsDetailFieldInDevelopment(): void
  123. {
  124. $request = $this->request();
  125. $handler = $this->handler(expose: true);
  126. $response = $handler(
  127. $request,
  128. new RuntimeException('database connection refused'),
  129. false,
  130. false,
  131. false,
  132. );
  133. self::assertSame(500, $response->getStatusCode());
  134. $body = $this->json($response);
  135. self::assertSame('internal_error', $body['error']);
  136. self::assertIsArray($body['detail']);
  137. self::assertSame(RuntimeException::class, $body['detail']['exception']);
  138. self::assertSame('database connection refused', $body['detail']['message']);
  139. }
  140. public function testDisplayErrorDetailsFlagAlsoExposesDetail(): void
  141. {
  142. $request = $this->request();
  143. // Constructor flag is false (production), but Slim's per-request
  144. // $displayErrorDetails should still gate exposure on for debug paths.
  145. $handler = $this->handler(expose: false);
  146. $response = $handler(
  147. $request,
  148. new HttpBadRequestException($request, 'bespoke validation failure'),
  149. true,
  150. false,
  151. false,
  152. );
  153. self::assertSame(400, $response->getStatusCode());
  154. $body = $this->json($response);
  155. self::assertSame('bad_request', $body['error']);
  156. self::assertSame('bespoke validation failure', $body['detail']['message']);
  157. }
  158. public function testEmptyMessageInDetailIsNullNotEmptyString(): void
  159. {
  160. $request = $this->request();
  161. $handler = $this->handler(expose: true);
  162. $response = $handler($request, new RuntimeException(''), false, false, false);
  163. $body = $this->json($response);
  164. self::assertNull($body['detail']['message']);
  165. }
  166. public function testResponseAlwaysHasJsonContentType(): void
  167. {
  168. $request = $this->request();
  169. $handler = $this->handler(expose: false);
  170. $response = $handler($request, new RuntimeException('x'), false, false, false);
  171. self::assertSame('application/json', $response->getHeaderLine('Content-Type'));
  172. }
  173. private function handler(bool $expose): JsonErrorHandler
  174. {
  175. return new JsonErrorHandler(new ResponseFactory(), new NullLogger(), $expose);
  176. }
  177. private function request(): \Psr\Http\Message\ServerRequestInterface
  178. {
  179. return (new ServerRequestFactory())->createServerRequest('GET', 'https://api.test/x');
  180. }
  181. /**
  182. * @return array<string, mixed>
  183. */
  184. private function json(\Psr\Http\Message\ResponseInterface $response): array
  185. {
  186. $body = (string) $response->getBody();
  187. /** @var array<string, mixed> $decoded */
  188. $decoded = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
  189. return $decoded;
  190. }
  191. }