|
@@ -0,0 +1,225 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+
|
|
|
|
|
+declare(strict_types=1);
|
|
|
|
|
+
|
|
|
|
|
+namespace App\Tests\Integration\Public;
|
|
|
|
|
+
|
|
|
|
|
+use App\Domain\Auth\Role;
|
|
|
|
|
+use App\Domain\Auth\TokenKind;
|
|
|
|
|
+use App\Domain\Ip\Cidr;
|
|
|
|
|
+use App\Domain\Ip\IpAddress;
|
|
|
|
|
+use App\Tests\Integration\Support\AppTestCase;
|
|
|
|
|
+use Doctrine\DBAL\ParameterType;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Covers SPEC §M07.3: distribution endpoint.
|
|
|
|
|
+ * - kind=consumer required (admin token forbidden)
|
|
|
|
|
+ * - text/plain default; ?format=json
|
|
|
|
|
+ * - allowlist excludes; manual blocks emit
|
|
|
|
|
+ * - ETag round-trip → 304
|
|
|
|
|
+ * - response headers populated
|
|
|
|
|
+ */
|
|
|
|
|
+final class BlocklistControllerTest extends AppTestCase
|
|
|
|
|
+{
|
|
|
|
|
+ public function testAdminTokenIsRejected(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
|
|
|
|
|
+ $response = $this->request('GET', '/api/v1/blocklist', [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ self::assertSame(401, $response->getStatusCode());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testConsumerTokenReturnsEmptyTextByDefault(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $token = $this->setupConsumerToken('moderate');
|
|
|
|
|
+
|
|
|
|
|
+ $response = $this->request('GET', '/api/v1/blocklist', [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ self::assertSame(200, $response->getStatusCode());
|
|
|
|
|
+ self::assertSame('text/plain', $response->getHeaderLine('Content-Type'));
|
|
|
|
|
+ self::assertSame('', (string) $response->getBody());
|
|
|
|
|
+ self::assertSame('0', $response->getHeaderLine('X-Blocklist-Entries'));
|
|
|
|
|
+ self::assertSame('moderate', $response->getHeaderLine('X-Blocklist-Policy'));
|
|
|
|
|
+ self::assertNotSame('', $response->getHeaderLine('ETag'));
|
|
|
|
|
+ self::assertNotSame('', $response->getHeaderLine('X-Blocklist-Generated-At'));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testManualBlockAppearsInTextBlocklist(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $token = $this->setupConsumerToken('moderate');
|
|
|
|
|
+ $this->insertManualSubnet('198.51.100.0/24');
|
|
|
|
|
+
|
|
|
|
|
+ $response = $this->request('GET', '/api/v1/blocklist', [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ self::assertSame(200, $response->getStatusCode());
|
|
|
|
|
+ self::assertStringContainsString('198.51.100.0/24', (string) $response->getBody());
|
|
|
|
|
+ self::assertSame('1', $response->getHeaderLine('X-Blocklist-Entries'));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testJsonFormatReturnsStructuredEntries(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $token = $this->setupConsumerToken('moderate');
|
|
|
|
|
+ $this->insertManualIp('203.0.113.7');
|
|
|
|
|
+
|
|
|
|
|
+ $response = $this->request('GET', '/api/v1/blocklist?format=json', [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ self::assertSame(200, $response->getStatusCode());
|
|
|
|
|
+ self::assertSame('application/json', $response->getHeaderLine('Content-Type'));
|
|
|
|
|
+ $body = json_decode((string) $response->getBody(), true);
|
|
|
|
|
+ self::assertIsArray($body);
|
|
|
|
|
+ self::assertCount(1, $body);
|
|
|
|
|
+ self::assertSame('203.0.113.7', $body[0]['ip_or_cidr']);
|
|
|
|
|
+ self::assertSame('manual', $body[0]['reason']);
|
|
|
|
|
+ self::assertNull($body[0]['score']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testEtagRoundTripReturns304(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $token = $this->setupConsumerToken('moderate');
|
|
|
|
|
+ $this->insertManualSubnet('198.51.100.0/24');
|
|
|
|
|
+
|
|
|
|
|
+ $first = $this->request('GET', '/api/v1/blocklist', [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $etag = $first->getHeaderLine('ETag');
|
|
|
|
|
+ self::assertNotSame('', $etag);
|
|
|
|
|
+
|
|
|
|
|
+ $second = $this->request('GET', '/api/v1/blocklist', [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
|
|
+ 'If-None-Match' => $etag,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ self::assertSame(304, $second->getStatusCode());
|
|
|
|
|
+ self::assertSame('', (string) $second->getBody());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testEtagDiffersBetweenTextAndJson(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $token = $this->setupConsumerToken('moderate');
|
|
|
|
|
+ $this->insertManualSubnet('198.51.100.0/24');
|
|
|
|
|
+
|
|
|
|
|
+ $text = $this->request('GET', '/api/v1/blocklist', [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $json = $this->request('GET', '/api/v1/blocklist?format=json', [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ self::assertNotSame($text->getHeaderLine('ETag'), $json->getHeaderLine('ETag'));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testAllowlistedManualSubnetIsExcluded(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $token = $this->setupConsumerToken('moderate');
|
|
|
|
|
+ $this->insertManualSubnet('198.51.100.0/24');
|
|
|
|
|
+ // Allowlist a subnet that fully contains the manual block.
|
|
|
|
|
+ $this->insertAllowSubnet('198.51.100.0/16');
|
|
|
|
|
+
|
|
|
|
|
+ $response = $this->request('GET', '/api/v1/blocklist', [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ self::assertSame(200, $response->getStatusCode());
|
|
|
|
|
+ self::assertStringNotContainsString('198.51.100.0/24', (string) $response->getBody());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Three policies with distinct thresholds yield distinct entry counts
|
|
|
|
|
+ * over the same scored data — SPEC §M07 acceptance.
|
|
|
|
|
+ */
|
|
|
|
|
+ public function testThreePoliciesProduceDifferentBlocklists(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $strict = $this->setupConsumerToken('strict', 'consumer-strict');
|
|
|
|
|
+ $moderate = $this->setupConsumerToken('moderate', 'consumer-moderate');
|
|
|
|
|
+ $paranoid = $this->setupConsumerToken('paranoid', 'consumer-paranoid');
|
|
|
|
|
+
|
|
|
|
|
+ $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
|
|
|
|
|
+ // Score chosen to fall between paranoid (0.3) and moderate (1.0).
|
|
|
|
|
+ $this->insertScore('203.0.113.10', $bruteForceId, 0.5);
|
|
|
|
|
+ // Score that paranoid + moderate catch but strict (2.5) misses.
|
|
|
|
|
+ $this->insertScore('203.0.113.20', $bruteForceId, 1.5);
|
|
|
|
|
+ // Score that all three policies catch.
|
|
|
|
|
+ $this->insertScore('203.0.113.30', $bruteForceId, 3.0);
|
|
|
|
|
+
|
|
|
|
|
+ $strictResp = $this->request('GET', '/api/v1/blocklist', [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $strict,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $moderateResp = $this->request('GET', '/api/v1/blocklist', [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $moderate,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $paranoidResp = $this->request('GET', '/api/v1/blocklist', [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $paranoid,
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ self::assertSame('1', $strictResp->getHeaderLine('X-Blocklist-Entries'));
|
|
|
|
|
+ self::assertSame('2', $moderateResp->getHeaderLine('X-Blocklist-Entries'));
|
|
|
|
|
+ self::assertSame('3', $paranoidResp->getHeaderLine('X-Blocklist-Entries'));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function setupConsumerToken(string $policyName, string $consumerName = 'fw-test'): string
|
|
|
|
|
+ {
|
|
|
|
|
+ $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :n', ['n' => $policyName]);
|
|
|
|
|
+ $this->db->insert('consumers', [
|
|
|
|
|
+ 'name' => $consumerName,
|
|
|
|
|
+ 'policy_id' => $policyId,
|
|
|
|
|
+ 'is_active' => 1,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $consumerId = (int) $this->db->lastInsertId();
|
|
|
|
|
+
|
|
|
|
|
+ return $this->createToken(TokenKind::Consumer, consumerId: $consumerId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function insertManualIp(string $ip): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $bin = IpAddress::fromString($ip)->binary();
|
|
|
|
|
+ $stmt = $this->db->prepare(
|
|
|
|
|
+ 'INSERT INTO manual_blocks (kind, ip_bin, reason) VALUES (:kind, :ip_bin, :reason)'
|
|
|
|
|
+ );
|
|
|
|
|
+ $stmt->bindValue('kind', 'ip');
|
|
|
|
|
+ $stmt->bindValue('ip_bin', $bin, ParameterType::LARGE_OBJECT);
|
|
|
|
|
+ $stmt->bindValue('reason', 'test');
|
|
|
|
|
+ $stmt->executeStatement();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function insertManualSubnet(string $cidr): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $c = Cidr::fromString($cidr);
|
|
|
|
|
+ $stmt = $this->db->prepare(
|
|
|
|
|
+ 'INSERT INTO manual_blocks (kind, network_bin, prefix_length, reason) VALUES (:kind, :net, :pl, :reason)'
|
|
|
|
|
+ );
|
|
|
|
|
+ $stmt->bindValue('kind', 'subnet');
|
|
|
|
|
+ $stmt->bindValue('net', $c->network(), ParameterType::LARGE_OBJECT);
|
|
|
|
|
+ $stmt->bindValue('pl', $c->prefixLength(), ParameterType::INTEGER);
|
|
|
|
|
+ $stmt->bindValue('reason', 'test');
|
|
|
|
|
+ $stmt->executeStatement();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function insertAllowSubnet(string $cidr): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $c = Cidr::fromString($cidr);
|
|
|
|
|
+ $stmt = $this->db->prepare(
|
|
|
|
|
+ 'INSERT INTO allowlist (kind, network_bin, prefix_length, reason) VALUES (:kind, :net, :pl, :reason)'
|
|
|
|
|
+ );
|
|
|
|
|
+ $stmt->bindValue('kind', 'subnet');
|
|
|
|
|
+ $stmt->bindValue('net', $c->network(), ParameterType::LARGE_OBJECT);
|
|
|
|
|
+ $stmt->bindValue('pl', $c->prefixLength(), ParameterType::INTEGER);
|
|
|
|
|
+ $stmt->bindValue('reason', 'test');
|
|
|
|
|
+ $stmt->executeStatement();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function insertScore(string $ip, int $categoryId, float $score): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $ipObj = IpAddress::fromString($ip);
|
|
|
|
|
+ $stmt = $this->db->prepare(
|
|
|
|
|
+ 'INSERT INTO ip_scores (ip_bin, ip_text, category_id, score, report_count_30d, last_report_at, recomputed_at) '
|
|
|
|
|
+ . 'VALUES (:b, :t, :c, :s, 1, :now, :now)'
|
|
|
|
|
+ );
|
|
|
|
|
+ $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
|
|
|
|
|
+ $stmt->bindValue('t', $ipObj->text());
|
|
|
|
|
+ $stmt->bindValue('c', $categoryId, ParameterType::INTEGER);
|
|
|
|
|
+ $stmt->bindValue('s', number_format($score, 4, '.', ''));
|
|
|
|
|
+ $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
|
|
|
|
|
+ $stmt->executeStatement();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|