| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368 |
- <?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']);
- }
- /**
- * SEC_REVIEW F32: the list endpoint now batches enrichment, top
- * category, and effective-status lookups so the per-row data surfaces
- * correctly without an extra DB round-trip per IP. Seed enough rows
- * that the previous N+1 loop would have made hundreds of round-trips.
- */
- public function testSearchBatchesPerRowLookups(): void
- {
- $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
- // 25 scored IPs, of which two also carry enrichment rows. The
- // assertion checks that the batch lookup correctly attaches
- // enrichment to exactly those IPs and nulls for the rest.
- for ($i = 0; $i < 25; ++$i) {
- $this->seedScored(sprintf('203.0.113.%d', 10 + $i), 'brute_force', 1.0 + ($i / 100));
- }
- $this->seedEnrichment('203.0.113.12', 'AA', 64500, 'AS-AA');
- $this->seedEnrichment('203.0.113.20', 'BB', 64501, 'AS-BB');
- $body = $this->decode($this->request('GET', '/api/v1/admin/ips?page=1&page_size=200', [
- 'Authorization' => 'Bearer ' . $token,
- ]));
- self::assertSame(25, $body['total']);
- $byIp = [];
- foreach ($body['items'] as $row) {
- $byIp[$row['ip']] = $row;
- }
- self::assertSame('AA', $byIp['203.0.113.12']['enrichment']['country_code']);
- self::assertSame(64500, $byIp['203.0.113.12']['enrichment']['asn']);
- self::assertSame('BB', $byIp['203.0.113.20']['enrichment']['country_code']);
- self::assertNull($byIp['203.0.113.10']['enrichment']);
- // top_category resolves via the batched score lookup.
- self::assertSame('brute_force', $byIp['203.0.113.10']['top_category']);
- self::assertSame('brute_force', $byIp['203.0.113.20']['top_category']);
- // Status falls out of `max_score > 0` without a per-row hasAnyScore.
- foreach ($body['items'] as $row) {
- self::assertSame('scored', $row['status']);
- }
- }
- /**
- * SEC_REVIEW F32: when an IP appears in the search results but its
- * `max_score` is 0 (e.g. score row exists but has decayed), the row
- * resolves to `clean` without a per-row `hasAnyScore` query.
- */
- public function testSearchStatusUsesMaxScoreColumn(): void
- {
- $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
- $this->seedScored('203.0.113.10', 'brute_force', 0.0);
- $body = $this->decode($this->request('GET', '/api/v1/admin/ips', [
- 'Authorization' => 'Bearer ' . $token,
- ]));
- self::assertSame(1, $body['total']);
- self::assertSame('203.0.113.10', $body['items'][0]['ip']);
- self::assertSame('clean', $body['items'][0]['status']);
- self::assertNull($body['items'][0]['top_category']);
- }
- 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());
- }
- /**
- * @return iterable<string, array{string}>
- */
- public static function nonIpShapedPaths(): iterable
- {
- // SEC_REVIEW F43: the {ip} route now uses a strict
- // `[0-9a-fA-F.:%]+` charset. Anything outside that — slashes,
- // dots-and-slashes traversal, query separators, spaces, dashes —
- // 404s at the route layer before `$args['ip']` is read by the
- // handler. Even though `IpAddress::fromString` would also
- // reject these inputs today, the tightened route protects
- // any future code that uses `$args['ip']` as a filename, log
- // key, or downstream URL component.
- yield 'path traversal' => ['/api/v1/admin/ips/..%2Fetc%2Fpasswd'];
- yield 'with subpath' => ['/api/v1/admin/ips/192.0.2.1/extra'];
- yield 'with query injection' => ['/api/v1/admin/ips/?injected'];
- yield 'with backslash' => ['/api/v1/admin/ips/192.0.2.1\\admin'];
- yield 'with space' => ['/api/v1/admin/ips/192.0.2.1 admin'];
- yield 'with dash' => ['/api/v1/admin/ips/not-an-ip'];
- yield 'with brackets' => ['/api/v1/admin/ips/[2001:db8::1]'];
- }
- #[\PHPUnit\Framework\Attributes\DataProvider('nonIpShapedPaths')]
- public function testDetailRejectsNonIpShapedPaths(string $path): void
- {
- $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
- $response = $this->request('GET', $path, [
- 'Authorization' => 'Bearer ' . $token,
- ]);
- self::assertSame(404, $response->getStatusCode(), "expected 404 for {$path}");
- }
- 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 seedEnrichment(string $ip, string $countryCode, int $asn, string $asOrg): void
- {
- $ipObj = IpAddress::fromString($ip);
- $stmt = $this->db->prepare(
- 'INSERT INTO ip_enrichment (ip_bin, country_code, asn, as_org, enriched_at) '
- . 'VALUES (:b, :c, :a, :o, :now)'
- );
- $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
- $stmt->bindValue('c', $countryCode);
- $stmt->bindValue('a', $asn, ParameterType::INTEGER);
- $stmt->bindValue('o', $asOrg);
- $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();
- }
- }
|