| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Integration\Admin;
- use App\Domain\Auth\Role;
- use App\Domain\Auth\TokenKind;
- use App\Domain\Ip\IpAddress;
- use App\Tests\Integration\Support\AppTestCase;
- use Doctrine\DBAL\ParameterType;
- /**
- * Covers SPEC §M09.1: admin IP search + detail. Seeds ip_scores +
- * reports + manual_blocks + allowlist directly to drive the controller.
- */
- final class IpsControllerTest extends AppTestCase
- {
- public function testSearchListsScoredIps(): void
- {
- $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
- $this->seedScored('203.0.113.10', 'brute_force', 1.5);
- $this->seedScored('203.0.113.11', 'brute_force', 0.5);
- $response = $this->request('GET', '/api/v1/admin/ips', [
- 'Authorization' => 'Bearer ' . $token,
- ]);
- self::assertSame(200, $response->getStatusCode());
- $body = $this->decode($response);
- self::assertSame(2, $body['total']);
- $ips = array_map(static fn (array $r): string => $r['ip'], $body['items']);
- self::assertContains('203.0.113.10', $ips);
- self::assertContains('203.0.113.11', $ips);
- }
- public function testSearchSortsByMaxScoreDescending(): void
- {
- $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
- $this->seedScored('203.0.113.10', 'brute_force', 0.5);
- $this->seedScored('203.0.113.11', 'brute_force', 5.0);
- $response = $this->request('GET', '/api/v1/admin/ips', [
- 'Authorization' => 'Bearer ' . $token,
- ]);
- $body = $this->decode($response);
- self::assertSame('203.0.113.11', $body['items'][0]['ip']);
- self::assertSame('203.0.113.10', $body['items'][1]['ip']);
- }
- public function testSearchFiltersByPrefix(): void
- {
- $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
- $this->seedScored('203.0.113.10', 'brute_force', 1.0);
- $this->seedScored('198.51.100.5', 'brute_force', 1.0);
- $this->seedScored('2001:db8::1', 'brute_force', 1.0);
- $response = $this->request('GET', '/api/v1/admin/ips?q=2001', [
- 'Authorization' => 'Bearer ' . $token,
- ]);
- $body = $this->decode($response);
- self::assertSame(1, $body['total']);
- self::assertSame('2001:db8::1', $body['items'][0]['ip']);
- self::assertFalse($body['items'][0]['is_ipv4']);
- }
- public function testSearchFiltersByCategory(): void
- {
- $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
- $this->seedScored('203.0.113.10', 'brute_force', 1.0);
- $this->seedScored('203.0.113.11', 'spam', 1.0);
- $response = $this->request('GET', '/api/v1/admin/ips?category=spam', [
- 'Authorization' => 'Bearer ' . $token,
- ]);
- $body = $this->decode($response);
- self::assertSame(1, $body['total']);
- self::assertSame('203.0.113.11', $body['items'][0]['ip']);
- }
- public function testSearchPaginates(): void
- {
- $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
- for ($i = 0; $i < 5; ++$i) {
- $this->seedScored(sprintf('203.0.113.%d', 10 + $i), 'brute_force', 1.0 + ($i / 10));
- }
- $page1 = $this->decode($this->request('GET', '/api/v1/admin/ips?page=1&page_size=2', [
- 'Authorization' => 'Bearer ' . $token,
- ]));
- $page2 = $this->decode($this->request('GET', '/api/v1/admin/ips?page=2&page_size=2', [
- 'Authorization' => 'Bearer ' . $token,
- ]));
- self::assertSame(5, $page1['total']);
- self::assertCount(2, $page1['items']);
- self::assertCount(2, $page2['items']);
- self::assertNotSame($page1['items'][0]['ip'], $page2['items'][0]['ip']);
- }
- public function testSearchRejectsUnknownCategory(): void
- {
- $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
- $response = $this->request('GET', '/api/v1/admin/ips?category=bogus', [
- 'Authorization' => 'Bearer ' . $token,
- ]);
- self::assertSame(400, $response->getStatusCode());
- }
- /**
- * SEC_REVIEW F30: the substring fallback (`%q%`) used to drive a
- * full-table scan on `ip_scores`. The controller now rejects any `q`
- * that isn't IP-shaped so the repo only ever runs an anchored
- * `LIKE 'q%'` prefix scan.
- */
- public function testSearchRejectsNonIpShapedQuery(): void
- {
- $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
- // Each of these would previously have hit the `%q%` substring path.
- $bad = ['foo', '203.0.113.10 OR 1=1', '%X%', 'g00d', '203.0.113.10%', '_____'];
- foreach ($bad as $q) {
- $response = $this->request('GET', '/api/v1/admin/ips?q=' . rawurlencode($q), [
- 'Authorization' => 'Bearer ' . $token,
- ]);
- self::assertSame(400, $response->getStatusCode(), "expected 400 for q={$q}");
- $body = $this->decode($response);
- self::assertSame('validation_failed', $body['error']);
- self::assertArrayHasKey('q', $body['details']);
- }
- }
- /**
- * SEC_REVIEW F30: cap on `q` length so even an IP-shaped value cannot
- * push a multi-megabyte LIKE parameter through the repository.
- */
- public function testSearchRejectsOverlongQuery(): void
- {
- $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
- $tooLong = str_repeat('1', 65); // 64 is the cap.
- $response = $this->request('GET', '/api/v1/admin/ips?q=' . $tooLong, [
- 'Authorization' => 'Bearer ' . $token,
- ]);
- self::assertSame(400, $response->getStatusCode());
- $body = $this->decode($response);
- self::assertArrayHasKey('q', $body['details']);
- }
- /**
- * SEC_REVIEW F30: an IP-shaped `q` is still treated as an anchored
- * prefix — the substring path is gone, so a value that previously
- * matched mid-string no longer matches at all.
- */
- public function testSearchQueryIsPrefixAnchoredNotSubstring(): void
- {
- $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
- $this->seedScored('203.0.113.10', 'brute_force', 1.0);
- $this->seedScored('198.51.100.5', 'brute_force', 1.0);
- // "113" is a substring of 203.0.113.10 but not a prefix; with the
- // substring path removed the result is empty.
- $body = $this->decode($this->request('GET', '/api/v1/admin/ips?q=113', [
- 'Authorization' => 'Bearer ' . $token,
- ]));
- self::assertSame(0, $body['total']);
- // The legitimate prefix still matches.
- $body = $this->decode($this->request('GET', '/api/v1/admin/ips?q=203.0.113', [
- 'Authorization' => 'Bearer ' . $token,
- ]));
- self::assertSame(1, $body['total']);
- self::assertSame('203.0.113.10', $body['items'][0]['ip']);
- }
- public function testDetailReturnsScoresAndStatus(): void
- {
- $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
- $reporterId = $this->createReporter('rep-detail');
- $this->seedScored('203.0.113.42', 'brute_force', 2.0);
- $this->seedReport('203.0.113.42', 'brute_force', $reporterId);
- $response = $this->request('GET', '/api/v1/admin/ips/203.0.113.42', [
- 'Authorization' => 'Bearer ' . $token,
- ]);
- self::assertSame(200, $response->getStatusCode());
- $body = $this->decode($response);
- self::assertSame('203.0.113.42', $body['ip']);
- self::assertTrue($body['is_ipv4']);
- self::assertSame('scored', $body['status']);
- self::assertNotEmpty($body['scores']);
- self::assertSame('brute_force', $body['scores'][0]['category']);
- self::assertNotEmpty($body['history']);
- self::assertSame('report', $body['history'][0]['type']);
- }
- public function testDetailIncludesEnrichmentNullByDefault(): void
- {
- $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
- $this->seedScored('203.0.113.42', 'brute_force', 2.0);
- $body = $this->decode($this->request('GET', '/api/v1/admin/ips/203.0.113.42', [
- 'Authorization' => 'Bearer ' . $token,
- ]));
- self::assertNull($body['enrichment']['country_code']);
- self::assertNull($body['enrichment']['asn']);
- }
- public function testDetail404OnInvalidIp(): void
- {
- $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
- $response = $this->request('GET', '/api/v1/admin/ips/not-an-ip', [
- 'Authorization' => 'Bearer ' . $token,
- ]);
- self::assertSame(404, $response->getStatusCode());
- }
- public function testDetailRendersForUnknownIpWithCleanStatus(): void
- {
- $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
- $body = $this->decode($this->request('GET', '/api/v1/admin/ips/198.51.100.99', [
- 'Authorization' => 'Bearer ' . $token,
- ]));
- self::assertSame('clean', $body['status']);
- self::assertSame([], $body['scores']);
- self::assertSame([], $body['history']);
- }
- private function seedScored(string $ip, string $categorySlug, float $score): void
- {
- $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => $categorySlug]);
- $ipObj = IpAddress::fromString($ip);
- $stmt = $this->db->prepare(
- 'INSERT INTO ip_scores (ip_bin, ip_text, category_id, score, report_count_30d, last_report_at, recomputed_at) '
- . 'VALUES (:b, :t, :c, :s, 1, :now, :now)'
- );
- $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
- $stmt->bindValue('t', $ipObj->text());
- $stmt->bindValue('c', $catId, ParameterType::INTEGER);
- $stmt->bindValue('s', number_format($score, 4, '.', ''));
- $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
- $stmt->executeStatement();
- }
- private function seedReport(string $ip, string $categorySlug, int $reporterId): void
- {
- $catId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => $categorySlug]);
- $ipObj = IpAddress::fromString($ip);
- $stmt = $this->db->prepare(
- 'INSERT INTO reports (ip_bin, ip_text, category_id, reporter_id, weight_at_report, received_at, metadata_json) '
- . 'VALUES (:b, :t, :c, :r, :w, :now, NULL)'
- );
- $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
- $stmt->bindValue('t', $ipObj->text());
- $stmt->bindValue('c', $catId, ParameterType::INTEGER);
- $stmt->bindValue('r', $reporterId, ParameterType::INTEGER);
- $stmt->bindValue('w', '1.00');
- $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
- $stmt->executeStatement();
- }
- }
|