IpsControllerTest.php 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  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. public function testDetailReturnsScoresAndStatus(): void
  94. {
  95. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  96. $reporterId = $this->createReporter('rep-detail');
  97. $this->seedScored('203.0.113.42', 'brute_force', 2.0);
  98. $this->seedReport('203.0.113.42', 'brute_force', $reporterId);
  99. $response = $this->request('GET', '/api/v1/admin/ips/203.0.113.42', [
  100. 'Authorization' => 'Bearer ' . $token,
  101. ]);
  102. self::assertSame(200, $response->getStatusCode());
  103. $body = $this->decode($response);
  104. self::assertSame('203.0.113.42', $body['ip']);
  105. self::assertTrue($body['is_ipv4']);
  106. self::assertSame('scored', $body['status']);
  107. self::assertNotEmpty($body['scores']);
  108. self::assertSame('brute_force', $body['scores'][0]['category']);
  109. self::assertNotEmpty($body['history']);
  110. self::assertSame('report', $body['history'][0]['type']);
  111. }
  112. public function testDetailIncludesEnrichmentNullByDefault(): void
  113. {
  114. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  115. $this->seedScored('203.0.113.42', 'brute_force', 2.0);
  116. $body = $this->decode($this->request('GET', '/api/v1/admin/ips/203.0.113.42', [
  117. 'Authorization' => 'Bearer ' . $token,
  118. ]));
  119. self::assertNull($body['enrichment']['country_code']);
  120. self::assertNull($body['enrichment']['asn']);
  121. }
  122. public function testDetail404OnInvalidIp(): void
  123. {
  124. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  125. $response = $this->request('GET', '/api/v1/admin/ips/not-an-ip', [
  126. 'Authorization' => 'Bearer ' . $token,
  127. ]);
  128. self::assertSame(404, $response->getStatusCode());
  129. }
  130. public function testDetailRendersForUnknownIpWithCleanStatus(): void
  131. {
  132. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  133. $body = $this->decode($this->request('GET', '/api/v1/admin/ips/198.51.100.99', [
  134. 'Authorization' => 'Bearer ' . $token,
  135. ]));
  136. self::assertSame('clean', $body['status']);
  137. self::assertSame([], $body['scores']);
  138. self::assertSame([], $body['history']);
  139. }
  140. private function seedScored(string $ip, string $categorySlug, float $score): void
  141. {
  142. $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => $categorySlug]);
  143. $ipObj = IpAddress::fromString($ip);
  144. $stmt = $this->db->prepare(
  145. 'INSERT INTO ip_scores (ip_bin, ip_text, category_id, score, report_count_30d, last_report_at, recomputed_at) '
  146. . 'VALUES (:b, :t, :c, :s, 1, :now, :now)'
  147. );
  148. $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
  149. $stmt->bindValue('t', $ipObj->text());
  150. $stmt->bindValue('c', $catId, ParameterType::INTEGER);
  151. $stmt->bindValue('s', number_format($score, 4, '.', ''));
  152. $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
  153. $stmt->executeStatement();
  154. }
  155. private function seedReport(string $ip, string $categorySlug, int $reporterId): void
  156. {
  157. $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => $categorySlug]);
  158. $ipObj = IpAddress::fromString($ip);
  159. $stmt = $this->db->prepare(
  160. 'INSERT INTO reports (ip_bin, ip_text, category_id, reporter_id, weight_at_report, received_at, metadata_json) '
  161. . 'VALUES (:b, :t, :c, :r, :w, :now, NULL)'
  162. );
  163. $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
  164. $stmt->bindValue('t', $ipObj->text());
  165. $stmt->bindValue('c', $catId, ParameterType::INTEGER);
  166. $stmt->bindValue('r', $reporterId, ParameterType::INTEGER);
  167. $stmt->bindValue('w', '1.00');
  168. $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
  169. $stmt->executeStatement();
  170. }
  171. }