|
|
@@ -170,6 +170,69 @@ final class IpsControllerTest extends AppTestCase
|
|
|
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);
|
|
|
@@ -239,6 +302,21 @@ final class IpsControllerTest extends AppTestCase
|
|
|
$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]);
|