| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Integration\Admin;
- use App\Domain\Auth\Role;
- use App\Domain\Auth\TokenKind;
- use App\Domain\Ip\IpAddress;
- use App\Infrastructure\Reputation\CidrEvaluatorFactory;
- use App\Tests\Integration\Support\AppTestCase;
- /**
- * Covers SPEC §6: manual blocks CRUD with split RBAC (Viewer reads, Operator
- * writes) and CIDR canonicalization. Each test boots a clean DB and Slim
- * app — no shared state.
- */
- final class ManualBlocksControllerTest extends AppTestCase
- {
- public function testViewerCanList(): void
- {
- $token = $this->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<string, mixed> $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,
- );
- }
- }
|