db, $clock); $expiredAt = $clock->now()->modify('-1 hour'); $futureAt = $clock->now()->modify('+1 day'); $this->insertManualIp('203.0.113.10', 'expired', $expiredAt); $this->insertManualIp('203.0.113.11', 'future', $futureAt); $this->insertManualIp('203.0.113.12', 'no-expiry', null); $rows = $repo->list(null, null); self::assertCount(2, $rows, 'expired entry should be hidden'); $reasons = array_map(static fn ($r) => $r->reason, $rows); self::assertContains('future', $reasons); self::assertContains('no-expiry', $reasons); self::assertNotContains('expired', $reasons); // Direct lookup also filters. $expired = $repo->findByIpBin(IpAddress::fromString('203.0.113.10')->binary()); self::assertNull($expired); $live = $repo->findByIpBin(IpAddress::fromString('203.0.113.11')->binary()); self::assertNotNull($live); // count() honours the filter too. self::assertSame(2, $repo->count()); } public function testJobDeletesExpiredAndEmitsOneAuditPerRow(): void { $clock = FixedClock::at('2026-04-29T12:00:00Z'); $repo = new ManualBlockRepository($this->db, $clock); $expiredAt = $clock->now()->modify('-1 hour'); $this->insertManualIp('203.0.113.20', 'one', $expiredAt); $this->insertManualIp('203.0.113.21', 'two', $expiredAt); $this->insertManualIp('203.0.113.22', 'three-fresh', null); $job = $this->buildJob($repo, $clock); $logger = new Logger('test'); $logger->pushHandler(new NullHandler()); $result = $job->run(new JobContext($clock, $logger)); self::assertSame(2, $result->itemsProcessed); // Surviving row stays. $remaining = (int) $this->db->fetchOne( "SELECT COUNT(*) FROM manual_blocks WHERE reason = 'three-fresh'" ); self::assertSame(1, $remaining); // Expired rows gone. $remainingExpired = (int) $this->db->fetchOne( "SELECT COUNT(*) FROM manual_blocks WHERE reason IN ('one','two')" ); self::assertSame(0, $remainingExpired); // Two audit rows for the deletions. $auditCount = (int) $this->db->fetchOne( 'SELECT COUNT(*) FROM audit_log WHERE action = :action', ['action' => AuditAction::MANUAL_BLOCK_DELETED] ); self::assertSame(2, $auditCount); $rows = $this->db->fetchAllAssociative( 'SELECT actor_kind, target_type, details_json FROM audit_log WHERE action = :action', ['action' => AuditAction::MANUAL_BLOCK_DELETED] ); foreach ($rows as $row) { self::assertSame('system', $row['actor_kind']); self::assertSame('manual_block', $row['target_type']); $payload = json_decode((string) $row['details_json'], true); self::assertSame('expired', $payload['reason']); self::assertSame(CleanupExpiredManualBlocksJob::NAME, $payload['job']); } } public function testNoExpiredEntriesIsAZeroNoOp(): void { $clock = FixedClock::at('2026-04-29T12:00:00Z'); $repo = new ManualBlockRepository($this->db, $clock); $this->insertManualIp('203.0.113.30', 'fresh', null); $job = $this->buildJob($repo, $clock); $logger = new Logger('test'); $logger->pushHandler(new NullHandler()); $result = $job->run(new JobContext($clock, $logger)); self::assertSame(0, $result->itemsProcessed); $auditCount = (int) $this->db->fetchOne( 'SELECT COUNT(*) FROM audit_log WHERE action = :action', ['action' => AuditAction::MANUAL_BLOCK_DELETED] ); self::assertSame(0, $auditCount); } private function insertManualIp(string $ip, string $reason, ?\DateTimeImmutable $expiresAt): void { $this->db->insert('manual_blocks', [ 'kind' => 'ip', 'ip_bin' => IpAddress::fromString($ip)->binary(), 'network_bin' => null, 'prefix_length' => null, 'reason' => $reason, 'expires_at' => $expiresAt?->format('Y-m-d H:i:s'), 'created_by_user_id' => null, ], ['ip_bin' => ParameterType::LARGE_OBJECT]); } private function buildJob(ManualBlockRepository $repo, FixedClock $clock): CleanupExpiredManualBlocksJob { $logger = new Logger('test'); $logger->pushHandler(new NullHandler()); $auditRepo = new AuditRepository($this->db, $clock); $audit = new DbAuditEmitter($auditRepo, $logger); $allowlist = new AllowlistRepository($this->db); $evaluator = new CidrEvaluatorFactory($repo, $allowlist, $clock, $logger, 60); $categories = new CategoryRepository($this->db); $ipScores = new IpScoreRepository($this->db); $builder = new BlocklistBuilder($ipScores, $categories, $evaluator, $clock); $blocklistCache = new BlocklistCache($builder, $clock, 30); return new CleanupExpiredManualBlocksJob($repo, $audit, $evaluator, $blocklistCache); } }