createToken(TokenKind::Admin, role: Role::Operator); $response = $this->request( 'POST', '/api/v1/admin/allowlist', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['kind' => 'ip', 'ip' => '198.51.100.5', 'reason' => 'monitor']) ?: null, ); self::assertSame(201, $response->getStatusCode()); $body = $this->decode($response); self::assertSame('198.51.100.5', $body['ip']); } public function testOperatorCanCreateSubnetAllowlistEntry(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Operator); $response = $this->request( 'POST', '/api/v1/admin/allowlist', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['kind' => 'subnet', 'cidr' => '10.0.0.0/8', 'reason' => 'private']) ?: null, ); self::assertSame(201, $response->getStatusCode()); $body = $this->decode($response); self::assertSame('10.0.0.0/8', $body['cidr']); self::assertSame(8, $body['prefix_length']); } public function testViewerCannotCreate(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); $response = $this->request( 'POST', '/api/v1/admin/allowlist', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['kind' => 'ip', 'ip' => '1.2.3.4']) ?: null, ); self::assertSame(403, $response->getStatusCode()); } public function testAllowlistedIpIsAllowlistedRegardlessOfManualBlock(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); // Same IP on both lists — log should warn; evaluator reports both // memberships independently; SPEC precedence is the // EffectiveStatusService's job. $this->request( 'POST', '/api/v1/admin/manual-blocks', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['kind' => 'ip', 'ip' => '198.51.100.5']) ?: null, ); $this->request( 'POST', '/api/v1/admin/allowlist', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['kind' => 'ip', 'ip' => '198.51.100.5']) ?: null, ); // Inject a TestHandler-backed logger before the evaluator builds. $logger = new Logger('test'); $logger->pushHandler($handler = new TestHandler(Level::Warning)); /** @var \DI\Container $container */ $container = $this->container; $container->set(LoggerInterface::class, $logger); // Rebuild the factory with the new logger so the warning is captured. $container->set(CidrEvaluatorFactory::class, new CidrEvaluatorFactory( $container->get(\App\Infrastructure\ManualBlock\ManualBlockRepository::class), $container->get(\App\Infrastructure\Allowlist\AllowlistRepository::class), $container->get(\App\Domain\Time\Clock::class), $logger, 0, )); /** @var CidrEvaluatorFactory $factory */ $factory = $this->container->get(CidrEvaluatorFactory::class); $evaluator = $factory->get(); $ip = IpAddress::fromString('198.51.100.5'); self::assertTrue($evaluator->isAllowlisted($ip)); self::assertTrue($evaluator->isManuallyBlocked($ip)); $found = false; foreach ($handler->getRecords() as $record) { if (str_contains($record->message, 'allowlist takes precedence')) { $found = true; break; } } self::assertTrue($found, 'expected overlap warning to be logged'); } public function testDeleteInvalidatesEvaluator(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $created = $this->request( 'POST', '/api/v1/admin/allowlist', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['kind' => 'subnet', 'cidr' => '10.0.0.0/8']) ?: null, ); $id = (int) $this->decode($created)['id']; /** @var CidrEvaluatorFactory $factory */ $factory = $this->container->get(CidrEvaluatorFactory::class); self::assertTrue($factory->get()->isAllowlisted(IpAddress::fromString('10.0.0.1'))); $delete = $this->request( 'DELETE', "/api/v1/admin/allowlist/{$id}", ['Authorization' => 'Bearer ' . $token], ); self::assertSame(204, $delete->getStatusCode()); self::assertFalse($factory->get()->isAllowlisted(IpAddress::fromString('10.0.0.1'))); } }