BlocklistBuilderTest.php 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Reputation;
  4. use App\Domain\Ip\Cidr;
  5. use App\Domain\Ip\IpAddress;
  6. use App\Domain\Reputation\BlocklistBuilder;
  7. use App\Infrastructure\Policy\PolicyRepository;
  8. use App\Tests\Integration\Support\AppTestCase;
  9. use Doctrine\DBAL\ParameterType;
  10. /**
  11. * Integration tests for the per-policy `BlocklistBuilder`.
  12. * - allowlist filters scored entries
  13. * - manual subnet covering a scored IP suppresses the single entry
  14. * - sort: IPv4 before IPv6, stable across rebuilds
  15. * - dedup: scored single + manual single → scored wins (carries categories)
  16. */
  17. final class BlocklistBuilderTest extends AppTestCase
  18. {
  19. public function testScoredIpAppearsInBlocklistWithCategorySlugs(): void
  20. {
  21. $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
  22. $this->insertScore('203.0.113.5', $bruteForceId, 2.0);
  23. $blocklist = $this->buildFor('paranoid');
  24. self::assertCount(1, $blocklist->entries);
  25. $entry = $blocklist->entries[0];
  26. self::assertSame('203.0.113.5', $entry->ipOrCidr);
  27. self::assertSame('scored', $entry->reason);
  28. self::assertContains('brute_force', $entry->categories);
  29. }
  30. public function testAllowlistedScoredIpIsExcluded(): void
  31. {
  32. $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
  33. $this->insertScore('203.0.113.5', $bruteForceId, 2.0);
  34. $this->insertAllowIp('203.0.113.5');
  35. $blocklist = $this->buildFor('paranoid');
  36. self::assertCount(0, $blocklist->entries);
  37. }
  38. public function testManualSubnetSuppressesScoredSingleIpInside(): void
  39. {
  40. $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
  41. $this->insertScore('198.51.100.42', $bruteForceId, 2.0);
  42. $this->insertManualSubnet('198.51.100.0/24');
  43. $blocklist = $this->buildFor('paranoid');
  44. self::assertCount(1, $blocklist->entries);
  45. self::assertSame('198.51.100.0/24', $blocklist->entries[0]->ipOrCidr);
  46. self::assertTrue($blocklist->entries[0]->isCidr);
  47. self::assertSame('manual', $blocklist->entries[0]->reason);
  48. }
  49. public function testV4EntriesSortedBeforeV6(): void
  50. {
  51. $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
  52. $this->insertScore('2001:db8::1', $bruteForceId, 2.0);
  53. $this->insertScore('203.0.113.5', $bruteForceId, 2.0);
  54. $blocklist = $this->buildFor('paranoid');
  55. self::assertCount(2, $blocklist->entries);
  56. self::assertTrue($blocklist->entries[0]->isIpv4);
  57. self::assertFalse($blocklist->entries[1]->isIpv4);
  58. }
  59. public function testManualSingleIpAndScoredSamePrefersScored(): void
  60. {
  61. $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
  62. $this->insertScore('203.0.113.7', $bruteForceId, 2.0);
  63. $this->insertManualIp('203.0.113.7');
  64. $blocklist = $this->buildFor('paranoid');
  65. self::assertCount(1, $blocklist->entries);
  66. self::assertSame('scored', $blocklist->entries[0]->reason);
  67. }
  68. private function buildFor(string $policyName): \App\Domain\Reputation\Blocklist
  69. {
  70. /** @var PolicyRepository $policies */
  71. $policies = $this->container->get(PolicyRepository::class);
  72. $policy = $policies->findByName($policyName);
  73. self::assertNotNull($policy);
  74. /** @var BlocklistBuilder $builder */
  75. $builder = $this->container->get(BlocklistBuilder::class);
  76. return $builder->build($policy);
  77. }
  78. private function insertManualIp(string $ip): void
  79. {
  80. $bin = IpAddress::fromString($ip)->binary();
  81. $stmt = $this->db->prepare(
  82. 'INSERT INTO manual_blocks (kind, ip_bin, reason) VALUES (:kind, :ip_bin, :reason)'
  83. );
  84. $stmt->bindValue('kind', 'ip');
  85. $stmt->bindValue('ip_bin', $bin, ParameterType::LARGE_OBJECT);
  86. $stmt->bindValue('reason', 't');
  87. $stmt->executeStatement();
  88. }
  89. private function insertManualSubnet(string $cidr): void
  90. {
  91. $c = Cidr::fromString($cidr);
  92. $stmt = $this->db->prepare(
  93. 'INSERT INTO manual_blocks (kind, network_bin, prefix_length, reason) VALUES (:kind, :net, :pl, :reason)'
  94. );
  95. $stmt->bindValue('kind', 'subnet');
  96. $stmt->bindValue('net', $c->network(), ParameterType::LARGE_OBJECT);
  97. $stmt->bindValue('pl', $c->prefixLength(), ParameterType::INTEGER);
  98. $stmt->bindValue('reason', 't');
  99. $stmt->executeStatement();
  100. }
  101. private function insertAllowIp(string $ip): void
  102. {
  103. $bin = IpAddress::fromString($ip)->binary();
  104. $stmt = $this->db->prepare(
  105. 'INSERT INTO allowlist (kind, ip_bin, reason) VALUES (:kind, :ip_bin, :reason)'
  106. );
  107. $stmt->bindValue('kind', 'ip');
  108. $stmt->bindValue('ip_bin', $bin, ParameterType::LARGE_OBJECT);
  109. $stmt->bindValue('reason', 't');
  110. $stmt->executeStatement();
  111. }
  112. private function insertScore(string $ip, int $categoryId, float $score): void
  113. {
  114. $ipObj = IpAddress::fromString($ip);
  115. $stmt = $this->db->prepare(
  116. 'INSERT INTO ip_scores (ip_bin, ip_text, category_id, score, report_count_30d, last_report_at, recomputed_at) '
  117. . 'VALUES (:b, :t, :c, :s, 1, :now, :now)'
  118. );
  119. $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
  120. $stmt->bindValue('t', $ipObj->text());
  121. $stmt->bindValue('c', $categoryId, ParameterType::INTEGER);
  122. $stmt->bindValue('s', number_format($score, 4, '.', ''));
  123. $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
  124. $stmt->executeStatement();
  125. }
  126. }