|
|
@@ -0,0 +1,194 @@
|
|
|
+<?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());
|
|
|
+ }
|
|
|
+
|
|
|
+ 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();
|
|
|
+ }
|
|
|
+}
|