createToken(TokenKind::Admin, role: Role::Viewer); $response = $this->request('GET', '/api/v1/admin/manual-blocks', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $response->getStatusCode()); $body = $this->decode($response); self::assertArrayHasKey('items', $body); self::assertSame(0, $body['total']); } public function testViewerCannotCreate(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); $response = $this->request( 'POST', '/api/v1/admin/manual-blocks', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['kind' => 'ip', 'ip' => '203.0.113.5', 'reason' => 'x']) ?: null, ); self::assertSame(403, $response->getStatusCode()); } public function testOperatorCanCreateIpBlock(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Operator); $response = $this->request( 'POST', '/api/v1/admin/manual-blocks', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode([ 'kind' => 'ip', 'ip' => '198.51.100.5', 'reason' => 'manual block test', ]) ?: null, ); self::assertSame(201, $response->getStatusCode()); $body = $this->decode($response); self::assertSame('ip', $body['kind']); self::assertSame('198.51.100.5', $body['ip']); self::assertNull($body['cidr']); self::assertArrayNotHasKey('normalized_from', $body); } public function testOperatorCanCreateCanonicalSubnetBlock(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Operator); $response = $this->request( 'POST', '/api/v1/admin/manual-blocks', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['kind' => 'subnet', 'cidr' => '198.51.100.0/24', 'reason' => 'subnet']) ?: null, ); self::assertSame(201, $response->getStatusCode()); $body = $this->decode($response); self::assertSame('subnet', $body['kind']); self::assertSame('198.51.100.0/24', $body['cidr']); self::assertSame(24, $body['prefix_length']); self::assertArrayNotHasKey('normalized_from', $body); } public function testNonCanonicalSubnetIsAutoNormalizedAndAnnounced(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Operator); $response = $this->request( 'POST', '/api/v1/admin/manual-blocks', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['kind' => 'subnet', 'cidr' => '203.0.113.55/24', 'reason' => 'non-canonical']) ?: null, ); self::assertSame(201, $response->getStatusCode()); $body = $this->decode($response); self::assertSame('203.0.113.0/24', $body['cidr']); self::assertSame('203.0.113.55/24', $body['normalized_from']); } public function testV6SubnetIsAccepted(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Operator); $response = $this->request( 'POST', '/api/v1/admin/manual-blocks', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['kind' => 'subnet', 'cidr' => '2001:db8::/32', 'reason' => 'v6']) ?: null, ); self::assertSame(201, $response->getStatusCode()); $body = $this->decode($response); self::assertSame('2001:db8::/32', $body['cidr']); self::assertSame(32, $body['prefix_length']); } public function testRejectsIpKindWithCidr(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Operator); $response = $this->request( 'POST', '/api/v1/admin/manual-blocks', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['kind' => 'ip', 'ip' => '1.2.3.4', 'cidr' => '1.2.3.0/24']) ?: null, ); self::assertSame(400, $response->getStatusCode()); } public function testRejectsBadIp(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Operator); $response = $this->request( 'POST', '/api/v1/admin/manual-blocks', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode(['kind' => 'ip', 'ip' => 'not-an-ip']) ?: null, ); self::assertSame(400, $response->getStatusCode()); } public function testListFiltersByKind(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $this->createBlock($token, ['kind' => 'ip', 'ip' => '10.0.0.1']); $this->createBlock($token, ['kind' => 'subnet', 'cidr' => '10.0.0.0/8']); $response = $this->request('GET', '/api/v1/admin/manual-blocks?kind=subnet', [ 'Authorization' => 'Bearer ' . $token, ]); $body = $this->decode($response); self::assertSame(1, $body['total']); self::assertSame('subnet', $body['items'][0]['kind']); } public function testDeleteRemovesEntryAndInvalidatesEvaluator(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $payload = $this->decode($this->createBlock($token, ['kind' => 'subnet', 'cidr' => '10.0.0.0/16'])); $id = (int) $payload['id']; /** @var CidrEvaluatorFactory $factory */ $factory = $this->container->get(CidrEvaluatorFactory::class); $evaluator = $factory->get(); self::assertCount(1, $evaluator->manualBlockedSubnets()); self::assertTrue($evaluator->isManuallyBlocked(IpAddress::fromString('10.0.0.5'))); $response = $this->request( 'DELETE', "/api/v1/admin/manual-blocks/{$id}", ['Authorization' => 'Bearer ' . $token], ); self::assertSame(204, $response->getStatusCode()); $evaluator2 = $factory->get(); self::assertNotSame($evaluator, $evaluator2, 'evaluator should be rebuilt after invalidate()'); self::assertSame([], $evaluator2->manualBlockedSubnets()); self::assertFalse($evaluator2->isManuallyBlocked(IpAddress::fromString('10.0.0.5'))); } public function testEvaluatorHandlesV4Slash16AsSingleCidr(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $this->createBlock($token, ['kind' => 'subnet', 'cidr' => '10.10.0.0/16']); /** @var CidrEvaluatorFactory $factory */ $factory = $this->container->get(CidrEvaluatorFactory::class); $subnets = $factory->get()->manualBlockedSubnets(); self::assertCount(1, $subnets); self::assertSame('10.10.0.0/16', $subnets[0]->text()); } /** * @param array $payload */ private function createBlock(string $token, array $payload): \Psr\Http\Message\ResponseInterface { return $this->request( 'POST', '/api/v1/admin/manual-blocks', ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'], json_encode($payload) ?: null, ); } }