IpsControllerTest.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  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. /**
  153. * SEC_REVIEW F32: the list endpoint now batches enrichment, top
  154. * category, and effective-status lookups so the per-row data surfaces
  155. * correctly without an extra DB round-trip per IP. Seed enough rows
  156. * that the previous N+1 loop would have made hundreds of round-trips.
  157. */
  158. public function testSearchBatchesPerRowLookups(): void
  159. {
  160. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  161. // 25 scored IPs, of which two also carry enrichment rows. The
  162. // assertion checks that the batch lookup correctly attaches
  163. // enrichment to exactly those IPs and nulls for the rest.
  164. for ($i = 0; $i < 25; ++$i) {
  165. $this->seedScored(sprintf('203.0.113.%d', 10 + $i), 'brute_force', 1.0 + ($i / 100));
  166. }
  167. $this->seedEnrichment('203.0.113.12', 'AA', 64500, 'AS-AA');
  168. $this->seedEnrichment('203.0.113.20', 'BB', 64501, 'AS-BB');
  169. $body = $this->decode($this->request('GET', '/api/v1/admin/ips?page=1&page_size=200', [
  170. 'Authorization' => 'Bearer ' . $token,
  171. ]));
  172. self::assertSame(25, $body['total']);
  173. $byIp = [];
  174. foreach ($body['items'] as $row) {
  175. $byIp[$row['ip']] = $row;
  176. }
  177. self::assertSame('AA', $byIp['203.0.113.12']['enrichment']['country_code']);
  178. self::assertSame(64500, $byIp['203.0.113.12']['enrichment']['asn']);
  179. self::assertSame('BB', $byIp['203.0.113.20']['enrichment']['country_code']);
  180. self::assertNull($byIp['203.0.113.10']['enrichment']);
  181. // top_category resolves via the batched score lookup.
  182. self::assertSame('brute_force', $byIp['203.0.113.10']['top_category']);
  183. self::assertSame('brute_force', $byIp['203.0.113.20']['top_category']);
  184. // Status falls out of `max_score > 0` without a per-row hasAnyScore.
  185. foreach ($body['items'] as $row) {
  186. self::assertSame('scored', $row['status']);
  187. }
  188. }
  189. /**
  190. * SEC_REVIEW F32: when an IP appears in the search results but its
  191. * `max_score` is 0 (e.g. score row exists but has decayed), the row
  192. * resolves to `clean` without a per-row `hasAnyScore` query.
  193. */
  194. public function testSearchStatusUsesMaxScoreColumn(): void
  195. {
  196. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  197. $this->seedScored('203.0.113.10', 'brute_force', 0.0);
  198. $body = $this->decode($this->request('GET', '/api/v1/admin/ips', [
  199. 'Authorization' => 'Bearer ' . $token,
  200. ]));
  201. self::assertSame(1, $body['total']);
  202. self::assertSame('203.0.113.10', $body['items'][0]['ip']);
  203. self::assertSame('clean', $body['items'][0]['status']);
  204. self::assertNull($body['items'][0]['top_category']);
  205. }
  206. public function testDetailReturnsScoresAndStatus(): void
  207. {
  208. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  209. $reporterId = $this->createReporter('rep-detail');
  210. $this->seedScored('203.0.113.42', 'brute_force', 2.0);
  211. $this->seedReport('203.0.113.42', 'brute_force', $reporterId);
  212. $response = $this->request('GET', '/api/v1/admin/ips/203.0.113.42', [
  213. 'Authorization' => 'Bearer ' . $token,
  214. ]);
  215. self::assertSame(200, $response->getStatusCode());
  216. $body = $this->decode($response);
  217. self::assertSame('203.0.113.42', $body['ip']);
  218. self::assertTrue($body['is_ipv4']);
  219. self::assertSame('scored', $body['status']);
  220. self::assertNotEmpty($body['scores']);
  221. self::assertSame('brute_force', $body['scores'][0]['category']);
  222. self::assertNotEmpty($body['history']);
  223. self::assertSame('report', $body['history'][0]['type']);
  224. }
  225. public function testDetailIncludesEnrichmentNullByDefault(): void
  226. {
  227. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  228. $this->seedScored('203.0.113.42', 'brute_force', 2.0);
  229. $body = $this->decode($this->request('GET', '/api/v1/admin/ips/203.0.113.42', [
  230. 'Authorization' => 'Bearer ' . $token,
  231. ]));
  232. self::assertNull($body['enrichment']['country_code']);
  233. self::assertNull($body['enrichment']['asn']);
  234. }
  235. public function testDetail404OnInvalidIp(): void
  236. {
  237. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  238. $response = $this->request('GET', '/api/v1/admin/ips/not-an-ip', [
  239. 'Authorization' => 'Bearer ' . $token,
  240. ]);
  241. self::assertSame(404, $response->getStatusCode());
  242. }
  243. /**
  244. * @return iterable<string, array{string}>
  245. */
  246. public static function nonIpShapedPaths(): iterable
  247. {
  248. // SEC_REVIEW F43: the {ip} route now uses a strict
  249. // `[0-9a-fA-F.:%]+` charset. Anything outside that — slashes,
  250. // dots-and-slashes traversal, query separators, spaces, dashes —
  251. // 404s at the route layer before `$args['ip']` is read by the
  252. // handler. Even though `IpAddress::fromString` would also
  253. // reject these inputs today, the tightened route protects
  254. // any future code that uses `$args['ip']` as a filename, log
  255. // key, or downstream URL component.
  256. yield 'path traversal' => ['/api/v1/admin/ips/..%2Fetc%2Fpasswd'];
  257. yield 'with subpath' => ['/api/v1/admin/ips/192.0.2.1/extra'];
  258. yield 'with query injection' => ['/api/v1/admin/ips/?injected'];
  259. yield 'with backslash' => ['/api/v1/admin/ips/192.0.2.1\\admin'];
  260. yield 'with space' => ['/api/v1/admin/ips/192.0.2.1 admin'];
  261. yield 'with dash' => ['/api/v1/admin/ips/not-an-ip'];
  262. yield 'with brackets' => ['/api/v1/admin/ips/[2001:db8::1]'];
  263. // SEC_REVIEW F72: oversized path. Even within the strict
  264. // charset, an 81-char string isn't a real IP. The route
  265. // length cap kicks in BEFORE `rawurldecode` runs on the
  266. // value — defends against the multi-megabyte case the
  267. // SEC_REVIEW called out.
  268. yield 'oversized digits' => ['/api/v1/admin/ips/' . str_repeat('1', 81)];
  269. yield 'oversized hex' => ['/api/v1/admin/ips/' . str_repeat('a', 200)];
  270. }
  271. #[\PHPUnit\Framework\Attributes\DataProvider('nonIpShapedPaths')]
  272. public function testDetailRejectsNonIpShapedPaths(string $path): void
  273. {
  274. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  275. $response = $this->request('GET', $path, [
  276. 'Authorization' => 'Bearer ' . $token,
  277. ]);
  278. self::assertSame(404, $response->getStatusCode(), "expected 404 for {$path}");
  279. }
  280. public function testDetailRendersForUnknownIpWithCleanStatus(): void
  281. {
  282. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  283. $body = $this->decode($this->request('GET', '/api/v1/admin/ips/198.51.100.99', [
  284. 'Authorization' => 'Bearer ' . $token,
  285. ]));
  286. self::assertSame('clean', $body['status']);
  287. self::assertSame([], $body['scores']);
  288. self::assertSame([], $body['history']);
  289. }
  290. private function seedScored(string $ip, string $categorySlug, float $score): void
  291. {
  292. $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => $categorySlug]);
  293. $ipObj = IpAddress::fromString($ip);
  294. $stmt = $this->db->prepare(
  295. 'INSERT INTO ip_scores (ip_bin, ip_text, category_id, score, report_count_30d, last_report_at, recomputed_at) '
  296. . 'VALUES (:b, :t, :c, :s, 1, :now, :now)'
  297. );
  298. $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
  299. $stmt->bindValue('t', $ipObj->text());
  300. $stmt->bindValue('c', $catId, ParameterType::INTEGER);
  301. $stmt->bindValue('s', number_format($score, 4, '.', ''));
  302. $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
  303. $stmt->executeStatement();
  304. }
  305. private function seedEnrichment(string $ip, string $countryCode, int $asn, string $asOrg): void
  306. {
  307. $ipObj = IpAddress::fromString($ip);
  308. $stmt = $this->db->prepare(
  309. 'INSERT INTO ip_enrichment (ip_bin, country_code, asn, as_org, enriched_at) '
  310. . 'VALUES (:b, :c, :a, :o, :now)'
  311. );
  312. $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
  313. $stmt->bindValue('c', $countryCode);
  314. $stmt->bindValue('a', $asn, ParameterType::INTEGER);
  315. $stmt->bindValue('o', $asOrg);
  316. $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
  317. $stmt->executeStatement();
  318. }
  319. private function seedReport(string $ip, string $categorySlug, int $reporterId): void
  320. {
  321. $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => $categorySlug]);
  322. $ipObj = IpAddress::fromString($ip);
  323. $stmt = $this->db->prepare(
  324. 'INSERT INTO reports (ip_bin, ip_text, category_id, reporter_id, weight_at_report, received_at, metadata_json) '
  325. . 'VALUES (:b, :t, :c, :r, :w, :now, NULL)'
  326. );
  327. $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
  328. $stmt->bindValue('t', $ipObj->text());
  329. $stmt->bindValue('c', $catId, ParameterType::INTEGER);
  330. $stmt->bindValue('r', $reporterId, ParameterType::INTEGER);
  331. $stmt->bindValue('w', '1.00');
  332. $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
  333. $stmt->executeStatement();
  334. }
  335. }