createToken(TokenKind::Admin, role: Role::Admin); $response = $this->request('GET', '/api/v1/blocklist', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(401, $response->getStatusCode()); } public function testConsumerTokenReturnsEmptyTextByDefault(): void { $token = $this->setupConsumerToken('moderate'); $response = $this->request('GET', '/api/v1/blocklist', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $response->getStatusCode()); self::assertSame('text/plain', $response->getHeaderLine('Content-Type')); self::assertSame('', (string) $response->getBody()); self::assertSame('0', $response->getHeaderLine('X-Blocklist-Entries')); self::assertSame('moderate', $response->getHeaderLine('X-Blocklist-Policy')); self::assertNotSame('', $response->getHeaderLine('ETag')); self::assertNotSame('', $response->getHeaderLine('X-Blocklist-Generated-At')); } public function testManualBlockAppearsInTextBlocklist(): void { $token = $this->setupConsumerToken('moderate'); $this->insertManualSubnet('198.51.100.0/24'); $response = $this->request('GET', '/api/v1/blocklist', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $response->getStatusCode()); self::assertStringContainsString('198.51.100.0/24', (string) $response->getBody()); self::assertSame('1', $response->getHeaderLine('X-Blocklist-Entries')); } public function testJsonFormatReturnsStructuredEntries(): void { $token = $this->setupConsumerToken('moderate'); $this->insertManualIp('203.0.113.7'); $response = $this->request('GET', '/api/v1/blocklist?format=json', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $response->getStatusCode()); self::assertSame('application/json', $response->getHeaderLine('Content-Type')); $body = json_decode((string) $response->getBody(), true); self::assertIsArray($body); self::assertCount(1, $body); self::assertSame('203.0.113.7', $body[0]['ip_or_cidr']); self::assertSame('manual', $body[0]['reason']); self::assertNull($body[0]['score']); } public function testEtagRoundTripReturns304(): void { $token = $this->setupConsumerToken('moderate'); $this->insertManualSubnet('198.51.100.0/24'); $first = $this->request('GET', '/api/v1/blocklist', [ 'Authorization' => 'Bearer ' . $token, ]); $etag = $first->getHeaderLine('ETag'); self::assertNotSame('', $etag); $second = $this->request('GET', '/api/v1/blocklist', [ 'Authorization' => 'Bearer ' . $token, 'If-None-Match' => $etag, ]); self::assertSame(304, $second->getStatusCode()); self::assertSame('', (string) $second->getBody()); } public function testEtagDiffersBetweenTextAndJson(): void { $token = $this->setupConsumerToken('moderate'); $this->insertManualSubnet('198.51.100.0/24'); $text = $this->request('GET', '/api/v1/blocklist', [ 'Authorization' => 'Bearer ' . $token, ]); $json = $this->request('GET', '/api/v1/blocklist?format=json', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertNotSame($text->getHeaderLine('ETag'), $json->getHeaderLine('ETag')); } public function testAllowlistedManualSubnetIsExcluded(): void { $token = $this->setupConsumerToken('moderate'); $this->insertManualSubnet('198.51.100.0/24'); // Allowlist a subnet that fully contains the manual block. $this->insertAllowSubnet('198.51.100.0/16'); $response = $this->request('GET', '/api/v1/blocklist', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $response->getStatusCode()); self::assertStringNotContainsString('198.51.100.0/24', (string) $response->getBody()); } /** * Three policies with distinct thresholds yield distinct entry counts * over the same scored data — SPEC §M07 acceptance. */ public function testThreePoliciesProduceDifferentBlocklists(): void { $strict = $this->setupConsumerToken('strict', 'consumer-strict'); $moderate = $this->setupConsumerToken('moderate', 'consumer-moderate'); $paranoid = $this->setupConsumerToken('paranoid', 'consumer-paranoid'); $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']); // Score chosen to fall between paranoid (0.3) and moderate (1.0). $this->insertScore('203.0.113.10', $bruteForceId, 0.5); // Score that paranoid + moderate catch but strict (2.5) misses. $this->insertScore('203.0.113.20', $bruteForceId, 1.5); // Score that all three policies catch. $this->insertScore('203.0.113.30', $bruteForceId, 3.0); $strictResp = $this->request('GET', '/api/v1/blocklist', [ 'Authorization' => 'Bearer ' . $strict, ]); $moderateResp = $this->request('GET', '/api/v1/blocklist', [ 'Authorization' => 'Bearer ' . $moderate, ]); $paranoidResp = $this->request('GET', '/api/v1/blocklist', [ 'Authorization' => 'Bearer ' . $paranoid, ]); self::assertSame('1', $strictResp->getHeaderLine('X-Blocklist-Entries')); self::assertSame('2', $moderateResp->getHeaderLine('X-Blocklist-Entries')); self::assertSame('3', $paranoidResp->getHeaderLine('X-Blocklist-Entries')); } private function setupConsumerToken(string $policyName, string $consumerName = 'fw-test'): string { $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :n', ['n' => $policyName]); $this->db->insert('consumers', [ 'name' => $consumerName, 'policy_id' => $policyId, 'is_active' => 1, ]); $consumerId = (int) $this->db->lastInsertId(); return $this->createToken(TokenKind::Consumer, consumerId: $consumerId); } private function insertManualIp(string $ip): void { $bin = IpAddress::fromString($ip)->binary(); $stmt = $this->db->prepare( 'INSERT INTO manual_blocks (kind, ip_bin, reason) VALUES (:kind, :ip_bin, :reason)' ); $stmt->bindValue('kind', 'ip'); $stmt->bindValue('ip_bin', $bin, ParameterType::LARGE_OBJECT); $stmt->bindValue('reason', 'test'); $stmt->executeStatement(); } private function insertManualSubnet(string $cidr): void { $c = Cidr::fromString($cidr); $stmt = $this->db->prepare( 'INSERT INTO manual_blocks (kind, network_bin, prefix_length, reason) VALUES (:kind, :net, :pl, :reason)' ); $stmt->bindValue('kind', 'subnet'); $stmt->bindValue('net', $c->network(), ParameterType::LARGE_OBJECT); $stmt->bindValue('pl', $c->prefixLength(), ParameterType::INTEGER); $stmt->bindValue('reason', 'test'); $stmt->executeStatement(); } private function insertAllowSubnet(string $cidr): void { $c = Cidr::fromString($cidr); $stmt = $this->db->prepare( 'INSERT INTO allowlist (kind, network_bin, prefix_length, reason) VALUES (:kind, :net, :pl, :reason)' ); $stmt->bindValue('kind', 'subnet'); $stmt->bindValue('net', $c->network(), ParameterType::LARGE_OBJECT); $stmt->bindValue('pl', $c->prefixLength(), ParameterType::INTEGER); $stmt->bindValue('reason', 'test'); $stmt->executeStatement(); } private function insertScore(string $ip, int $categoryId, float $score): void { $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', $categoryId, ParameterType::INTEGER); $stmt->bindValue('s', number_format($score, 4, '.', '')); $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s')); $stmt->executeStatement(); } public function testBlocklistRenderIsCachedAcrossRequests(): void { // SEC_REVIEW F70: the rendered body and its sha256 ETag are // cached per (policy_id, format) per cache window. Two // sequential requests within the same window should produce // byte-identical bodies and ETags. (Cache hit → no rebuild, // no rehash; the test asserts the steady-state contract.) $token = $this->setupConsumerToken('moderate'); $r1 = $this->request('GET', '/api/v1/blocklist?format=json', [ 'Authorization' => 'Bearer ' . $token, ]); $r2 = $this->request('GET', '/api/v1/blocklist?format=json', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $r1->getStatusCode()); self::assertSame(200, $r2->getStatusCode()); self::assertSame((string) $r1->getBody(), (string) $r2->getBody()); self::assertSame($r1->getHeaderLine('ETag'), $r2->getHeaderLine('ETag')); } public function testCacheIsLruBoundedAtSixteenEntries(): void { // SEC_REVIEW F71: BlocklistCache is per-process and grew // monotonically with no LRU. With unbounded admin policy // creation, memory usage was effectively unbounded over the // worker's lifetime. Cache now caps at MAX_ENTRIES (16) and // evicts the least-recently-used entry when full. // // The default test fixture sets `blocklist_cache_ttl_seconds=0` // (no caching) for deterministic per-test builds. Rebuild // the cache with a real TTL so we exercise the LRU path. if (method_exists($this->container, 'set')) { /** @var \DI\Container $c */ $c = $this->container; $c->set( \App\Infrastructure\Reputation\BlocklistCache::class, new \App\Infrastructure\Reputation\BlocklistCache( builder: $c->get(\App\Domain\Reputation\BlocklistBuilder::class), clock: $c->get(\App\Domain\Time\Clock::class), ttlSeconds: 30, ), ); // BlocklistController is autowired and singleton-scoped, so // it captured the OLD cache reference at first resolution. // Rebuild it so it picks up the new cache binding. $c->set( \App\Application\Public\BlocklistController::class, $c->make(\App\Application\Public\BlocklistController::class), ); $this->app = \App\App\AppFactory::build($this->container); } /** @var \App\Infrastructure\Reputation\BlocklistCache $cache */ $cache = $this->container->get(\App\Infrastructure\Reputation\BlocklistCache::class); // Create 18 policies and a consumer-token per policy. Hit // /blocklist for each so each policy ends up in the cache. // The LRU should evict the first 2 by the time we reach the // last (cache cap is 16). $policyIds = []; for ($i = 1; $i <= 18; $i++) { $this->db->insert('policies', [ 'name' => 'lru-policy-' . $i, 'description' => null, 'include_manual_blocks' => 1, ]); $policyId = (int) $this->db->lastInsertId(); $policyIds[] = $policyId; $this->db->insert('consumers', [ 'name' => 'lru-consumer-' . $i, 'policy_id' => $policyId, 'is_active' => 1, ]); $consumerId = (int) $this->db->lastInsertId(); $token = $this->createToken(TokenKind::Consumer, consumerId: $consumerId); $resp = $this->request('GET', '/api/v1/blocklist', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $resp->getStatusCode(), "consumer {$i} should succeed"); } // Cache must never exceed the MAX_ENTRIES bound. self::assertLessThanOrEqual( \App\Infrastructure\Reputation\BlocklistCache::MAX_ENTRIES, $cache->entryCount(), 'cache must not grow past MAX_ENTRIES policies', ); // The two oldest policies (first two created) must have been // evicted; the most recently used must still be present. $remaining = $cache->cachedPolicyIds(); self::assertNotContains($policyIds[0], $remaining, 'oldest policy must have been evicted'); self::assertNotContains($policyIds[1], $remaining, 'second-oldest policy must have been evicted'); self::assertContains(end($policyIds), $remaining, 'most recent policy must still be cached'); } public function testTextAndJsonRendersBothCachedIndependently(): void { // SEC_REVIEW F70: switching format must not invalidate the // other format's cached render. Each format has its own // entry in the cache. $token = $this->setupConsumerToken('moderate'); $textA = $this->request('GET', '/api/v1/blocklist', [ 'Authorization' => 'Bearer ' . $token, ]); $jsonA = $this->request('GET', '/api/v1/blocklist?format=json', [ 'Authorization' => 'Bearer ' . $token, ]); $textB = $this->request('GET', '/api/v1/blocklist', [ 'Authorization' => 'Bearer ' . $token, ]); $jsonB = $this->request('GET', '/api/v1/blocklist?format=json', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame((string) $textA->getBody(), (string) $textB->getBody()); self::assertSame($textA->getHeaderLine('ETag'), $textB->getHeaderLine('ETag')); self::assertSame((string) $jsonA->getBody(), (string) $jsonB->getBody()); self::assertSame($jsonA->getHeaderLine('ETag'), $jsonB->getHeaderLine('ETag')); // Text and JSON ETags must DIFFER (different bodies). self::assertNotSame($textA->getHeaderLine('ETag'), $jsonA->getHeaderLine('ETag')); } }