CleanupExpiredManualBlocksJobTest.php 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Jobs;
  4. use App\Application\Jobs\CleanupExpiredManualBlocksJob;
  5. use App\Domain\Audit\AuditAction;
  6. use App\Domain\Ip\IpAddress;
  7. use App\Domain\Jobs\JobContext;
  8. use App\Domain\Reputation\BlocklistBuilder;
  9. use App\Domain\Time\FixedClock;
  10. use App\Infrastructure\Allowlist\AllowlistRepository;
  11. use App\Infrastructure\Audit\AuditRepository;
  12. use App\Infrastructure\Audit\DbAuditEmitter;
  13. use App\Infrastructure\Category\CategoryRepository;
  14. use App\Infrastructure\ManualBlock\ManualBlockRepository;
  15. use App\Infrastructure\Reputation\BlocklistCache;
  16. use App\Infrastructure\Reputation\CidrEvaluatorFactory;
  17. use App\Infrastructure\Reputation\IpScoreRepository;
  18. use App\Tests\Integration\Support\AppTestCase;
  19. use Doctrine\DBAL\ParameterType;
  20. use Monolog\Handler\NullHandler;
  21. use Monolog\Logger;
  22. /**
  23. * SPEC §M14.5: expired manual blocks are filtered at read time AND pruned
  24. * by the daily cleanup job, with one audit row per row deleted.
  25. */
  26. final class CleanupExpiredManualBlocksJobTest extends AppTestCase
  27. {
  28. public function testExpiredBlocksAreFilteredAtReadTime(): void
  29. {
  30. $clock = FixedClock::at('2026-04-29T12:00:00Z');
  31. $repo = new ManualBlockRepository($this->db, $clock);
  32. $expiredAt = $clock->now()->modify('-1 hour');
  33. $futureAt = $clock->now()->modify('+1 day');
  34. $this->insertManualIp('203.0.113.10', 'expired', $expiredAt);
  35. $this->insertManualIp('203.0.113.11', 'future', $futureAt);
  36. $this->insertManualIp('203.0.113.12', 'no-expiry', null);
  37. $rows = $repo->list(null, null);
  38. self::assertCount(2, $rows, 'expired entry should be hidden');
  39. $reasons = array_map(static fn ($r) => $r->reason, $rows);
  40. self::assertContains('future', $reasons);
  41. self::assertContains('no-expiry', $reasons);
  42. self::assertNotContains('expired', $reasons);
  43. // Direct lookup also filters.
  44. $expired = $repo->findByIpBin(IpAddress::fromString('203.0.113.10')->binary());
  45. self::assertNull($expired);
  46. $live = $repo->findByIpBin(IpAddress::fromString('203.0.113.11')->binary());
  47. self::assertNotNull($live);
  48. // count() honours the filter too.
  49. self::assertSame(2, $repo->count());
  50. }
  51. public function testJobDeletesExpiredAndEmitsOneAuditPerRow(): void
  52. {
  53. $clock = FixedClock::at('2026-04-29T12:00:00Z');
  54. $repo = new ManualBlockRepository($this->db, $clock);
  55. $expiredAt = $clock->now()->modify('-1 hour');
  56. $this->insertManualIp('203.0.113.20', 'one', $expiredAt);
  57. $this->insertManualIp('203.0.113.21', 'two', $expiredAt);
  58. $this->insertManualIp('203.0.113.22', 'three-fresh', null);
  59. $job = $this->buildJob($repo, $clock);
  60. $logger = new Logger('test');
  61. $logger->pushHandler(new NullHandler());
  62. $result = $job->run(new JobContext($clock, $logger));
  63. self::assertSame(2, $result->itemsProcessed);
  64. // Surviving row stays.
  65. $remaining = (int) $this->db->fetchOne(
  66. "SELECT COUNT(*) FROM manual_blocks WHERE reason = 'three-fresh'"
  67. );
  68. self::assertSame(1, $remaining);
  69. // Expired rows gone.
  70. $remainingExpired = (int) $this->db->fetchOne(
  71. "SELECT COUNT(*) FROM manual_blocks WHERE reason IN ('one','two')"
  72. );
  73. self::assertSame(0, $remainingExpired);
  74. // Two audit rows for the deletions.
  75. $auditCount = (int) $this->db->fetchOne(
  76. 'SELECT COUNT(*) FROM audit_log WHERE action = :action',
  77. ['action' => AuditAction::MANUAL_BLOCK_DELETED]
  78. );
  79. self::assertSame(2, $auditCount);
  80. $rows = $this->db->fetchAllAssociative(
  81. 'SELECT actor_kind, target_type, details_json FROM audit_log WHERE action = :action',
  82. ['action' => AuditAction::MANUAL_BLOCK_DELETED]
  83. );
  84. foreach ($rows as $row) {
  85. self::assertSame('system', $row['actor_kind']);
  86. self::assertSame('manual_block', $row['target_type']);
  87. $payload = json_decode((string) $row['details_json'], true);
  88. self::assertSame('expired', $payload['reason']);
  89. self::assertSame(CleanupExpiredManualBlocksJob::NAME, $payload['job']);
  90. }
  91. }
  92. public function testNoExpiredEntriesIsAZeroNoOp(): void
  93. {
  94. $clock = FixedClock::at('2026-04-29T12:00:00Z');
  95. $repo = new ManualBlockRepository($this->db, $clock);
  96. $this->insertManualIp('203.0.113.30', 'fresh', null);
  97. $job = $this->buildJob($repo, $clock);
  98. $logger = new Logger('test');
  99. $logger->pushHandler(new NullHandler());
  100. $result = $job->run(new JobContext($clock, $logger));
  101. self::assertSame(0, $result->itemsProcessed);
  102. $auditCount = (int) $this->db->fetchOne(
  103. 'SELECT COUNT(*) FROM audit_log WHERE action = :action',
  104. ['action' => AuditAction::MANUAL_BLOCK_DELETED]
  105. );
  106. self::assertSame(0, $auditCount);
  107. }
  108. private function insertManualIp(string $ip, string $reason, ?\DateTimeImmutable $expiresAt): void
  109. {
  110. $this->db->insert('manual_blocks', [
  111. 'kind' => 'ip',
  112. 'ip_bin' => IpAddress::fromString($ip)->binary(),
  113. 'network_bin' => null,
  114. 'prefix_length' => null,
  115. 'reason' => $reason,
  116. 'expires_at' => $expiresAt?->format('Y-m-d H:i:s'),
  117. 'created_by_user_id' => null,
  118. ], ['ip_bin' => ParameterType::LARGE_OBJECT]);
  119. }
  120. private function buildJob(ManualBlockRepository $repo, FixedClock $clock): CleanupExpiredManualBlocksJob
  121. {
  122. $logger = new Logger('test');
  123. $logger->pushHandler(new NullHandler());
  124. $auditRepo = new AuditRepository($this->db, $clock);
  125. $audit = new DbAuditEmitter($auditRepo, $logger);
  126. $allowlist = new AllowlistRepository($this->db);
  127. $evaluator = new CidrEvaluatorFactory($repo, $allowlist, $clock, $logger, 60);
  128. $categories = new CategoryRepository($this->db);
  129. $ipScores = new IpScoreRepository($this->db);
  130. $builder = new BlocklistBuilder($ipScores, $categories, $evaluator, $clock);
  131. $blocklistCache = new BlocklistCache($builder, $clock, 30);
  132. return new CleanupExpiredManualBlocksJob($repo, $audit, $evaluator, $blocklistCache);
  133. }
  134. }