|
@@ -0,0 +1,200 @@
|
|
|
|
|
+<?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,
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+}
|