IpsControllerTest.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Admin;
  4. use App\Domain\Auth\Role;
  5. use App\Domain\Auth\TokenKind;
  6. use App\Domain\Ip\IpAddress;
  7. use App\Tests\Integration\Support\AppTestCase;
  8. use Doctrine\DBAL\ParameterType;
  9. /**
  10. * Covers SPEC §M09.1: admin IP search + detail. Seeds ip_scores +
  11. * reports + manual_blocks + allowlist directly to drive the controller.
  12. */
  13. final class IpsControllerTest extends AppTestCase
  14. {
  15. public function testSearchListsScoredIps(): void
  16. {
  17. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  18. $this->seedScored('203.0.113.10', 'brute_force', 1.5);
  19. $this->seedScored('203.0.113.11', 'brute_force', 0.5);
  20. $response = $this->request('GET', '/api/v1/admin/ips', [
  21. 'Authorization' => 'Bearer ' . $token,
  22. ]);
  23. self::assertSame(200, $response->getStatusCode());
  24. $body = $this->decode($response);
  25. self::assertSame(2, $body['total']);
  26. $ips = array_map(static fn (array $r): string => $r['ip'], $body['items']);
  27. self::assertContains('203.0.113.10', $ips);
  28. self::assertContains('203.0.113.11', $ips);
  29. }
  30. public function testSearchSortsByMaxScoreDescending(): void
  31. {
  32. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  33. $this->seedScored('203.0.113.10', 'brute_force', 0.5);
  34. $this->seedScored('203.0.113.11', 'brute_force', 5.0);
  35. $response = $this->request('GET', '/api/v1/admin/ips', [
  36. 'Authorization' => 'Bearer ' . $token,
  37. ]);
  38. $body = $this->decode($response);
  39. self::assertSame('203.0.113.11', $body['items'][0]['ip']);
  40. self::assertSame('203.0.113.10', $body['items'][1]['ip']);
  41. }
  42. public function testSearchFiltersByPrefix(): void
  43. {
  44. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  45. $this->seedScored('203.0.113.10', 'brute_force', 1.0);
  46. $this->seedScored('198.51.100.5', 'brute_force', 1.0);
  47. $this->seedScored('2001:db8::1', 'brute_force', 1.0);
  48. $response = $this->request('GET', '/api/v1/admin/ips?q=2001', [
  49. 'Authorization' => 'Bearer ' . $token,
  50. ]);
  51. $body = $this->decode($response);
  52. self::assertSame(1, $body['total']);
  53. self::assertSame('2001:db8::1', $body['items'][0]['ip']);
  54. self::assertFalse($body['items'][0]['is_ipv4']);
  55. }
  56. public function testSearchFiltersByCategory(): void
  57. {
  58. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  59. $this->seedScored('203.0.113.10', 'brute_force', 1.0);
  60. $this->seedScored('203.0.113.11', 'spam', 1.0);
  61. $response = $this->request('GET', '/api/v1/admin/ips?category=spam', [
  62. 'Authorization' => 'Bearer ' . $token,
  63. ]);
  64. $body = $this->decode($response);
  65. self::assertSame(1, $body['total']);
  66. self::assertSame('203.0.113.11', $body['items'][0]['ip']);
  67. }
  68. public function testSearchPaginates(): void
  69. {
  70. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  71. for ($i = 0; $i < 5; ++$i) {
  72. $this->seedScored(sprintf('203.0.113.%d', 10 + $i), 'brute_force', 1.0 + ($i / 10));
  73. }
  74. $page1 = $this->decode($this->request('GET', '/api/v1/admin/ips?page=1&page_size=2', [
  75. 'Authorization' => 'Bearer ' . $token,
  76. ]));
  77. $page2 = $this->decode($this->request('GET', '/api/v1/admin/ips?page=2&page_size=2', [
  78. 'Authorization' => 'Bearer ' . $token,
  79. ]));
  80. self::assertSame(5, $page1['total']);
  81. self::assertCount(2, $page1['items']);
  82. self::assertCount(2, $page2['items']);
  83. self::assertNotSame($page1['items'][0]['ip'], $page2['items'][0]['ip']);
  84. }
  85. public function testSearchRejectsUnknownCategory(): void
  86. {
  87. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  88. $response = $this->request('GET', '/api/v1/admin/ips?category=bogus', [
  89. 'Authorization' => 'Bearer ' . $token,
  90. ]);
  91. self::assertSame(400, $response->getStatusCode());
  92. }
  93. /**
  94. * SEC_REVIEW F30: the substring fallback (`%q%`) used to drive a
  95. * full-table scan on `ip_scores`. The controller now rejects any `q`
  96. * that isn't IP-shaped so the repo only ever runs an anchored
  97. * `LIKE 'q%'` prefix scan.
  98. */
  99. public function testSearchRejectsNonIpShapedQuery(): void
  100. {
  101. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  102. // Each of these would previously have hit the `%q%` substring path.
  103. $bad = ['foo', '203.0.113.10 OR 1=1', '%X%', 'g00d', '203.0.113.10%', '_____'];
  104. foreach ($bad as $q) {
  105. $response = $this->request('GET', '/api/v1/admin/ips?q=' . rawurlencode($q), [
  106. 'Authorization' => 'Bearer ' . $token,
  107. ]);
  108. self::assertSame(400, $response->getStatusCode(), "expected 400 for q={$q}");
  109. $body = $this->decode($response);
  110. self::assertSame('validation_failed', $body['error']);
  111. self::assertArrayHasKey('q', $body['details']);
  112. }
  113. }
  114. /**
  115. * SEC_REVIEW F30: cap on `q` length so even an IP-shaped value cannot
  116. * push a multi-megabyte LIKE parameter through the repository.
  117. */
  118. public function testSearchRejectsOverlongQuery(): void
  119. {
  120. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  121. $tooLong = str_repeat('1', 65); // 64 is the cap.
  122. $response = $this->request('GET', '/api/v1/admin/ips?q=' . $tooLong, [
  123. 'Authorization' => 'Bearer ' . $token,
  124. ]);
  125. self::assertSame(400, $response->getStatusCode());
  126. $body = $this->decode($response);
  127. self::assertArrayHasKey('q', $body['details']);
  128. }
  129. /**
  130. * SEC_REVIEW F30: an IP-shaped `q` is still treated as an anchored
  131. * prefix — the substring path is gone, so a value that previously
  132. * matched mid-string no longer matches at all.
  133. */
  134. public function testSearchQueryIsPrefixAnchoredNotSubstring(): void
  135. {
  136. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  137. $this->seedScored('203.0.113.10', 'brute_force', 1.0);
  138. $this->seedScored('198.51.100.5', 'brute_force', 1.0);
  139. // "113" is a substring of 203.0.113.10 but not a prefix; with the
  140. // substring path removed the result is empty.
  141. $body = $this->decode($this->request('GET', '/api/v1/admin/ips?q=113', [
  142. 'Authorization' => 'Bearer ' . $token,
  143. ]));
  144. self::assertSame(0, $body['total']);
  145. // The legitimate prefix still matches.
  146. $body = $this->decode($this->request('GET', '/api/v1/admin/ips?q=203.0.113', [
  147. 'Authorization' => 'Bearer ' . $token,
  148. ]));
  149. self::assertSame(1, $body['total']);
  150. self::assertSame('203.0.113.10', $body['items'][0]['ip']);
  151. }
  152. public function testDetailReturnsScoresAndStatus(): void
  153. {
  154. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  155. $reporterId = $this->createReporter('rep-detail');
  156. $this->seedScored('203.0.113.42', 'brute_force', 2.0);
  157. $this->seedReport('203.0.113.42', 'brute_force', $reporterId);
  158. $response = $this->request('GET', '/api/v1/admin/ips/203.0.113.42', [
  159. 'Authorization' => 'Bearer ' . $token,
  160. ]);
  161. self::assertSame(200, $response->getStatusCode());
  162. $body = $this->decode($response);
  163. self::assertSame('203.0.113.42', $body['ip']);
  164. self::assertTrue($body['is_ipv4']);
  165. self::assertSame('scored', $body['status']);
  166. self::assertNotEmpty($body['scores']);
  167. self::assertSame('brute_force', $body['scores'][0]['category']);
  168. self::assertNotEmpty($body['history']);
  169. self::assertSame('report', $body['history'][0]['type']);
  170. }
  171. public function testDetailIncludesEnrichmentNullByDefault(): void
  172. {
  173. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  174. $this->seedScored('203.0.113.42', 'brute_force', 2.0);
  175. $body = $this->decode($this->request('GET', '/api/v1/admin/ips/203.0.113.42', [
  176. 'Authorization' => 'Bearer ' . $token,
  177. ]));
  178. self::assertNull($body['enrichment']['country_code']);
  179. self::assertNull($body['enrichment']['asn']);
  180. }
  181. public function testDetail404OnInvalidIp(): void
  182. {
  183. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  184. $response = $this->request('GET', '/api/v1/admin/ips/not-an-ip', [
  185. 'Authorization' => 'Bearer ' . $token,
  186. ]);
  187. self::assertSame(404, $response->getStatusCode());
  188. }
  189. public function testDetailRendersForUnknownIpWithCleanStatus(): void
  190. {
  191. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  192. $body = $this->decode($this->request('GET', '/api/v1/admin/ips/198.51.100.99', [
  193. 'Authorization' => 'Bearer ' . $token,
  194. ]));
  195. self::assertSame('clean', $body['status']);
  196. self::assertSame([], $body['scores']);
  197. self::assertSame([], $body['history']);
  198. }
  199. private function seedScored(string $ip, string $categorySlug, float $score): void
  200. {
  201. $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => $categorySlug]);
  202. $ipObj = IpAddress::fromString($ip);
  203. $stmt = $this->db->prepare(
  204. 'INSERT INTO ip_scores (ip_bin, ip_text, category_id, score, report_count_30d, last_report_at, recomputed_at) '
  205. . 'VALUES (:b, :t, :c, :s, 1, :now, :now)'
  206. );
  207. $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
  208. $stmt->bindValue('t', $ipObj->text());
  209. $stmt->bindValue('c', $catId, ParameterType::INTEGER);
  210. $stmt->bindValue('s', number_format($score, 4, '.', ''));
  211. $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
  212. $stmt->executeStatement();
  213. }
  214. private function seedReport(string $ip, string $categorySlug, int $reporterId): void
  215. {
  216. $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => $categorySlug]);
  217. $ipObj = IpAddress::fromString($ip);
  218. $stmt = $this->db->prepare(
  219. 'INSERT INTO reports (ip_bin, ip_text, category_id, reporter_id, weight_at_report, received_at, metadata_json) '
  220. . 'VALUES (:b, :t, :c, :r, :w, :now, NULL)'
  221. );
  222. $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
  223. $stmt->bindValue('t', $ipObj->text());
  224. $stmt->bindValue('c', $catId, ParameterType::INTEGER);
  225. $stmt->bindValue('r', $reporterId, ParameterType::INTEGER);
  226. $stmt->bindValue('w', '1.00');
  227. $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
  228. $stmt->executeStatement();
  229. }
  230. }