ManualBlocksControllerTest.php 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Admin;
  4. use App\Domain\Auth\Role;
  5. use App\Domain\Auth\TokenKind;
  6. use App\Domain\Ip\IpAddress;
  7. use App\Infrastructure\Reputation\CidrEvaluatorFactory;
  8. use App\Tests\Integration\Support\AppTestCase;
  9. /**
  10. * Covers SPEC §6: manual blocks CRUD with split RBAC (Viewer reads, Operator
  11. * writes) and CIDR canonicalization. Each test boots a clean DB and Slim
  12. * app — no shared state.
  13. */
  14. final class ManualBlocksControllerTest extends AppTestCase
  15. {
  16. public function testViewerCanList(): void
  17. {
  18. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  19. $response = $this->request('GET', '/api/v1/admin/manual-blocks', [
  20. 'Authorization' => 'Bearer ' . $token,
  21. ]);
  22. self::assertSame(200, $response->getStatusCode());
  23. $body = $this->decode($response);
  24. self::assertArrayHasKey('items', $body);
  25. self::assertSame(0, $body['total']);
  26. }
  27. public function testViewerCannotCreate(): void
  28. {
  29. $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  30. $response = $this->request(
  31. 'POST',
  32. '/api/v1/admin/manual-blocks',
  33. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  34. json_encode(['kind' => 'ip', 'ip' => '203.0.113.5', 'reason' => 'x']) ?: null,
  35. );
  36. self::assertSame(403, $response->getStatusCode());
  37. }
  38. public function testOperatorCanCreateIpBlock(): void
  39. {
  40. $token = $this->createToken(TokenKind::Admin, role: Role::Operator);
  41. $response = $this->request(
  42. 'POST',
  43. '/api/v1/admin/manual-blocks',
  44. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  45. json_encode([
  46. 'kind' => 'ip',
  47. 'ip' => '198.51.100.5',
  48. 'reason' => 'manual block test',
  49. ]) ?: null,
  50. );
  51. self::assertSame(201, $response->getStatusCode());
  52. $body = $this->decode($response);
  53. self::assertSame('ip', $body['kind']);
  54. self::assertSame('198.51.100.5', $body['ip']);
  55. self::assertNull($body['cidr']);
  56. self::assertArrayNotHasKey('normalized_from', $body);
  57. }
  58. public function testOperatorCanCreateCanonicalSubnetBlock(): void
  59. {
  60. $token = $this->createToken(TokenKind::Admin, role: Role::Operator);
  61. $response = $this->request(
  62. 'POST',
  63. '/api/v1/admin/manual-blocks',
  64. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  65. json_encode(['kind' => 'subnet', 'cidr' => '198.51.100.0/24', 'reason' => 'subnet']) ?: null,
  66. );
  67. self::assertSame(201, $response->getStatusCode());
  68. $body = $this->decode($response);
  69. self::assertSame('subnet', $body['kind']);
  70. self::assertSame('198.51.100.0/24', $body['cidr']);
  71. self::assertSame(24, $body['prefix_length']);
  72. self::assertArrayNotHasKey('normalized_from', $body);
  73. }
  74. public function testNonCanonicalSubnetIsAutoNormalizedAndAnnounced(): void
  75. {
  76. $token = $this->createToken(TokenKind::Admin, role: Role::Operator);
  77. $response = $this->request(
  78. 'POST',
  79. '/api/v1/admin/manual-blocks',
  80. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  81. json_encode(['kind' => 'subnet', 'cidr' => '203.0.113.55/24', 'reason' => 'non-canonical']) ?: null,
  82. );
  83. self::assertSame(201, $response->getStatusCode());
  84. $body = $this->decode($response);
  85. self::assertSame('203.0.113.0/24', $body['cidr']);
  86. self::assertSame('203.0.113.55/24', $body['normalized_from']);
  87. }
  88. public function testV6SubnetIsAccepted(): void
  89. {
  90. $token = $this->createToken(TokenKind::Admin, role: Role::Operator);
  91. $response = $this->request(
  92. 'POST',
  93. '/api/v1/admin/manual-blocks',
  94. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  95. json_encode(['kind' => 'subnet', 'cidr' => '2001:db8::/32', 'reason' => 'v6']) ?: null,
  96. );
  97. self::assertSame(201, $response->getStatusCode());
  98. $body = $this->decode($response);
  99. self::assertSame('2001:db8::/32', $body['cidr']);
  100. self::assertSame(32, $body['prefix_length']);
  101. }
  102. public function testRejectsIpKindWithCidr(): void
  103. {
  104. $token = $this->createToken(TokenKind::Admin, role: Role::Operator);
  105. $response = $this->request(
  106. 'POST',
  107. '/api/v1/admin/manual-blocks',
  108. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  109. json_encode(['kind' => 'ip', 'ip' => '1.2.3.4', 'cidr' => '1.2.3.0/24']) ?: null,
  110. );
  111. self::assertSame(400, $response->getStatusCode());
  112. }
  113. public function testRejectsBadIp(): void
  114. {
  115. $token = $this->createToken(TokenKind::Admin, role: Role::Operator);
  116. $response = $this->request(
  117. 'POST',
  118. '/api/v1/admin/manual-blocks',
  119. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  120. json_encode(['kind' => 'ip', 'ip' => 'not-an-ip']) ?: null,
  121. );
  122. self::assertSame(400, $response->getStatusCode());
  123. }
  124. public function testListFiltersByKind(): void
  125. {
  126. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  127. $this->createBlock($token, ['kind' => 'ip', 'ip' => '10.0.0.1']);
  128. $this->createBlock($token, ['kind' => 'subnet', 'cidr' => '10.0.0.0/8']);
  129. $response = $this->request('GET', '/api/v1/admin/manual-blocks?kind=subnet', [
  130. 'Authorization' => 'Bearer ' . $token,
  131. ]);
  132. $body = $this->decode($response);
  133. self::assertSame(1, $body['total']);
  134. self::assertSame('subnet', $body['items'][0]['kind']);
  135. }
  136. public function testDeleteRemovesEntryAndInvalidatesEvaluator(): void
  137. {
  138. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  139. $payload = $this->decode($this->createBlock($token, ['kind' => 'subnet', 'cidr' => '10.0.0.0/16']));
  140. $id = (int) $payload['id'];
  141. /** @var CidrEvaluatorFactory $factory */
  142. $factory = $this->container->get(CidrEvaluatorFactory::class);
  143. $evaluator = $factory->get();
  144. self::assertCount(1, $evaluator->manualBlockedSubnets());
  145. self::assertTrue($evaluator->isManuallyBlocked(IpAddress::fromString('10.0.0.5')));
  146. $response = $this->request(
  147. 'DELETE',
  148. "/api/v1/admin/manual-blocks/{$id}",
  149. ['Authorization' => 'Bearer ' . $token],
  150. );
  151. self::assertSame(204, $response->getStatusCode());
  152. $evaluator2 = $factory->get();
  153. self::assertNotSame($evaluator, $evaluator2, 'evaluator should be rebuilt after invalidate()');
  154. self::assertSame([], $evaluator2->manualBlockedSubnets());
  155. self::assertFalse($evaluator2->isManuallyBlocked(IpAddress::fromString('10.0.0.5')));
  156. }
  157. public function testEvaluatorHandlesV4Slash16AsSingleCidr(): void
  158. {
  159. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  160. $this->createBlock($token, ['kind' => 'subnet', 'cidr' => '10.10.0.0/16']);
  161. /** @var CidrEvaluatorFactory $factory */
  162. $factory = $this->container->get(CidrEvaluatorFactory::class);
  163. $subnets = $factory->get()->manualBlockedSubnets();
  164. self::assertCount(1, $subnets);
  165. self::assertSame('10.10.0.0/16', $subnets[0]->text());
  166. }
  167. /**
  168. * @param array<string, mixed> $payload
  169. */
  170. private function createBlock(string $token, array $payload): \Psr\Http\Message\ResponseInterface
  171. {
  172. return $this->request(
  173. 'POST',
  174. '/api/v1/admin/manual-blocks',
  175. ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
  176. json_encode($payload) ?: null,
  177. );
  178. }
  179. }