ReportControllerTest.php 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Public;
  4. use App\Domain\Auth\Role;
  5. use App\Domain\Auth\TokenKind;
  6. use App\Tests\Integration\Support\AppTestCase;
  7. final class ReportControllerTest extends AppTestCase
  8. {
  9. public function testValidReportInsertedAndScoreUpdated(): void
  10. {
  11. $reporterId = $this->createReporter('web-prod');
  12. $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
  13. $resp = $this->request(
  14. 'POST',
  15. '/api/v1/report',
  16. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  17. json_encode(['ip' => '203.0.113.42', 'category' => 'brute_force']) ?: null,
  18. );
  19. self::assertSame(202, $resp->getStatusCode());
  20. $body = $this->decode($resp);
  21. self::assertArrayHasKey('report_id', $body);
  22. self::assertSame('203.0.113.42', $body['ip']);
  23. self::assertArrayHasKey('received_at', $body);
  24. // ip_scores must have a row > 0.
  25. $score = $this->db->fetchOne(
  26. "SELECT score FROM ip_scores WHERE ip_text = '203.0.113.42'"
  27. );
  28. self::assertNotFalse($score);
  29. self::assertGreaterThan(0.0, (float) $score);
  30. }
  31. public function testManyReportsAccumulateMonotonically(): void
  32. {
  33. $reporterId = $this->createReporter('web-many');
  34. $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
  35. for ($i = 0; $i < 5; $i++) {
  36. $this->request(
  37. 'POST',
  38. '/api/v1/report',
  39. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  40. json_encode(['ip' => '198.51.100.7', 'category' => 'scanner']) ?: null,
  41. );
  42. }
  43. $count = (int) $this->db->fetchOne(
  44. "SELECT COUNT(*) FROM reports WHERE ip_text = '198.51.100.7'"
  45. );
  46. self::assertSame(5, $count);
  47. $score = (float) $this->db->fetchOne(
  48. "SELECT score FROM ip_scores WHERE ip_text = '198.51.100.7'"
  49. );
  50. // 5 fresh reports × weight 1.0 × decay~1.0 should be ~5.0.
  51. self::assertEqualsWithDelta(5.0, $score, 0.05);
  52. }
  53. public function testWrongKindTokenRejected(): void
  54. {
  55. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  56. $resp = $this->request(
  57. 'POST',
  58. '/api/v1/report',
  59. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  60. json_encode(['ip' => '1.2.3.4', 'category' => 'spam']) ?: null,
  61. );
  62. self::assertSame(401, $resp->getStatusCode());
  63. }
  64. public function testInvalidIpReturns400(): void
  65. {
  66. $reporterId = $this->createReporter('web-bad-ip');
  67. $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
  68. $resp = $this->request(
  69. 'POST',
  70. '/api/v1/report',
  71. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  72. json_encode(['ip' => 'not-an-ip', 'category' => 'spam']) ?: null,
  73. );
  74. self::assertSame(400, $resp->getStatusCode());
  75. $body = $this->decode($resp);
  76. self::assertSame('validation_failed', $body['error']);
  77. self::assertArrayHasKey('ip', $body['details']);
  78. }
  79. public function testUnknownCategoryReturns400(): void
  80. {
  81. $reporterId = $this->createReporter('web-bad-cat');
  82. $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
  83. $resp = $this->request(
  84. 'POST',
  85. '/api/v1/report',
  86. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  87. json_encode(['ip' => '1.2.3.4', 'category' => 'no-such']) ?: null,
  88. );
  89. self::assertSame(400, $resp->getStatusCode());
  90. self::assertArrayHasKey('category', $this->decode($resp)['details']);
  91. }
  92. public function testMetadataMustBeObject(): void
  93. {
  94. $reporterId = $this->createReporter('web-bad-meta');
  95. $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
  96. $resp = $this->request(
  97. 'POST',
  98. '/api/v1/report',
  99. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  100. json_encode(['ip' => '1.2.3.4', 'category' => 'spam', 'metadata' => [1, 2, 3]]) ?: null,
  101. );
  102. self::assertSame(400, $resp->getStatusCode());
  103. }
  104. public function testMetadataExceedingLimitRejected(): void
  105. {
  106. $reporterId = $this->createReporter('web-big-meta');
  107. $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
  108. $big = ['blob' => str_repeat('A', 5000)];
  109. $resp = $this->request(
  110. 'POST',
  111. '/api/v1/report',
  112. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  113. json_encode(['ip' => '1.2.3.4', 'category' => 'spam', 'metadata' => $big]) ?: null,
  114. );
  115. self::assertSame(400, $resp->getStatusCode());
  116. }
  117. public function testInactiveReporterTokenRejected(): void
  118. {
  119. $reporterId = $this->createReporter('web-disabled');
  120. $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
  121. $this->db->update('reporters', ['is_active' => 0], ['id' => $reporterId]);
  122. $resp = $this->request(
  123. 'POST',
  124. '/api/v1/report',
  125. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  126. json_encode(['ip' => '1.2.3.4', 'category' => 'spam']) ?: null,
  127. );
  128. self::assertSame(401, $resp->getStatusCode());
  129. }
  130. public function testOversizedRequestBodyIsRejectedWith413(): void
  131. {
  132. // SEC_REVIEW F69: a Reporter token can no longer POST a
  133. // multi-megabyte body that gets `getContents()`-ed into
  134. // memory by Slim's BodyParsingMiddleware. The global
  135. // RequestBodySizeLimitMiddleware (256 KiB cap) returns 413
  136. // BEFORE the body is read.
  137. $reporterId = $this->createReporter('web-oversize');
  138. $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
  139. // 512 KiB of plaintext, well past the 256 KiB cap.
  140. $bigBody = json_encode([
  141. 'ip' => '203.0.113.42',
  142. 'category' => 'spam',
  143. 'metadata' => ['blob' => str_repeat('A', 512 * 1024)],
  144. ]) ?: '';
  145. $resp = $this->request(
  146. 'POST',
  147. '/api/v1/report',
  148. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  149. $bigBody,
  150. );
  151. self::assertSame(413, $resp->getStatusCode());
  152. self::assertSame('payload_too_large', $this->decode($resp)['error']);
  153. }
  154. public function testDeeplyNestedJsonDoesNotBlowTheStack(): void
  155. {
  156. // SEC_REVIEW F69: bounded `json_decode` depth (32). A
  157. // 100-level nested object would otherwise hit PHP's default
  158. // 512-deep recursion limit and risk a stack issue on top of
  159. // wasting CPU. Past the cap, json_decode throws —
  160. // `ReportController::jsonBody` catches and treats as empty,
  161. // so the request continues and fails the regular `ip
  162. // required` validation.
  163. $reporterId = $this->createReporter('web-deepjson');
  164. $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
  165. // Build a 100-deep nested object.
  166. $payload = '{}';
  167. for ($i = 0; $i < 100; $i++) {
  168. $payload = '{"x":' . $payload . '}';
  169. }
  170. $resp = $this->request(
  171. 'POST',
  172. '/api/v1/report',
  173. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  174. $payload,
  175. );
  176. // Slim's BodyParsingMiddleware uses its own json_decode
  177. // (depth 512), so it succeeds — but then the controller's
  178. // validation runs and rejects "ip required" with 400. The
  179. // important guarantee is "no 500, no exception leaked"; the
  180. // exact response code isn't the point.
  181. self::assertContains($resp->getStatusCode(), [400, 413]);
  182. }
  183. }