1
0

BlocklistControllerTest.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Public;
  4. use App\Domain\Auth\Role;
  5. use App\Domain\Auth\TokenKind;
  6. use App\Domain\Ip\Cidr;
  7. use App\Domain\Ip\IpAddress;
  8. use App\Tests\Integration\Support\AppTestCase;
  9. use Doctrine\DBAL\ParameterType;
  10. /**
  11. * Covers SPEC §M07.3: distribution endpoint.
  12. * - kind=consumer required (admin token forbidden)
  13. * - text/plain default; ?format=json
  14. * - allowlist excludes; manual blocks emit
  15. * - ETag round-trip → 304
  16. * - response headers populated
  17. */
  18. final class BlocklistControllerTest extends AppTestCase
  19. {
  20. public function testAdminTokenIsRejected(): void
  21. {
  22. $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
  23. $response = $this->request('GET', '/api/v1/blocklist', [
  24. 'Authorization' => 'Bearer ' . $token,
  25. ]);
  26. self::assertSame(401, $response->getStatusCode());
  27. }
  28. public function testConsumerTokenReturnsEmptyTextByDefault(): void
  29. {
  30. $token = $this->setupConsumerToken('moderate');
  31. $response = $this->request('GET', '/api/v1/blocklist', [
  32. 'Authorization' => 'Bearer ' . $token,
  33. ]);
  34. self::assertSame(200, $response->getStatusCode());
  35. self::assertSame('text/plain', $response->getHeaderLine('Content-Type'));
  36. self::assertSame('', (string) $response->getBody());
  37. self::assertSame('0', $response->getHeaderLine('X-Blocklist-Entries'));
  38. self::assertSame('moderate', $response->getHeaderLine('X-Blocklist-Policy'));
  39. self::assertNotSame('', $response->getHeaderLine('ETag'));
  40. self::assertNotSame('', $response->getHeaderLine('X-Blocklist-Generated-At'));
  41. }
  42. public function testManualBlockAppearsInTextBlocklist(): void
  43. {
  44. $token = $this->setupConsumerToken('moderate');
  45. $this->insertManualSubnet('198.51.100.0/24');
  46. $response = $this->request('GET', '/api/v1/blocklist', [
  47. 'Authorization' => 'Bearer ' . $token,
  48. ]);
  49. self::assertSame(200, $response->getStatusCode());
  50. self::assertStringContainsString('198.51.100.0/24', (string) $response->getBody());
  51. self::assertSame('1', $response->getHeaderLine('X-Blocklist-Entries'));
  52. }
  53. public function testJsonFormatReturnsStructuredEntries(): void
  54. {
  55. $token = $this->setupConsumerToken('moderate');
  56. $this->insertManualIp('203.0.113.7');
  57. $response = $this->request('GET', '/api/v1/blocklist?format=json', [
  58. 'Authorization' => 'Bearer ' . $token,
  59. ]);
  60. self::assertSame(200, $response->getStatusCode());
  61. self::assertSame('application/json', $response->getHeaderLine('Content-Type'));
  62. $body = json_decode((string) $response->getBody(), true);
  63. self::assertIsArray($body);
  64. self::assertCount(1, $body);
  65. self::assertSame('203.0.113.7', $body[0]['ip_or_cidr']);
  66. self::assertSame('manual', $body[0]['reason']);
  67. self::assertNull($body[0]['score']);
  68. }
  69. public function testEtagRoundTripReturns304(): void
  70. {
  71. $token = $this->setupConsumerToken('moderate');
  72. $this->insertManualSubnet('198.51.100.0/24');
  73. $first = $this->request('GET', '/api/v1/blocklist', [
  74. 'Authorization' => 'Bearer ' . $token,
  75. ]);
  76. $etag = $first->getHeaderLine('ETag');
  77. self::assertNotSame('', $etag);
  78. $second = $this->request('GET', '/api/v1/blocklist', [
  79. 'Authorization' => 'Bearer ' . $token,
  80. 'If-None-Match' => $etag,
  81. ]);
  82. self::assertSame(304, $second->getStatusCode());
  83. self::assertSame('', (string) $second->getBody());
  84. }
  85. public function testEtagDiffersBetweenTextAndJson(): void
  86. {
  87. $token = $this->setupConsumerToken('moderate');
  88. $this->insertManualSubnet('198.51.100.0/24');
  89. $text = $this->request('GET', '/api/v1/blocklist', [
  90. 'Authorization' => 'Bearer ' . $token,
  91. ]);
  92. $json = $this->request('GET', '/api/v1/blocklist?format=json', [
  93. 'Authorization' => 'Bearer ' . $token,
  94. ]);
  95. self::assertNotSame($text->getHeaderLine('ETag'), $json->getHeaderLine('ETag'));
  96. }
  97. public function testAllowlistedManualSubnetIsExcluded(): void
  98. {
  99. $token = $this->setupConsumerToken('moderate');
  100. $this->insertManualSubnet('198.51.100.0/24');
  101. // Allowlist a subnet that fully contains the manual block.
  102. $this->insertAllowSubnet('198.51.100.0/16');
  103. $response = $this->request('GET', '/api/v1/blocklist', [
  104. 'Authorization' => 'Bearer ' . $token,
  105. ]);
  106. self::assertSame(200, $response->getStatusCode());
  107. self::assertStringNotContainsString('198.51.100.0/24', (string) $response->getBody());
  108. }
  109. /**
  110. * Three policies with distinct thresholds yield distinct entry counts
  111. * over the same scored data — SPEC §M07 acceptance.
  112. */
  113. public function testThreePoliciesProduceDifferentBlocklists(): void
  114. {
  115. $strict = $this->setupConsumerToken('strict', 'consumer-strict');
  116. $moderate = $this->setupConsumerToken('moderate', 'consumer-moderate');
  117. $paranoid = $this->setupConsumerToken('paranoid', 'consumer-paranoid');
  118. $bruteForceId = (int) $this->db->fetchOne('SELECT id FROM categories WHERE slug = :s', ['s' => 'brute_force']);
  119. // Score chosen to fall between paranoid (0.3) and moderate (1.0).
  120. $this->insertScore('203.0.113.10', $bruteForceId, 0.5);
  121. // Score that paranoid + moderate catch but strict (2.5) misses.
  122. $this->insertScore('203.0.113.20', $bruteForceId, 1.5);
  123. // Score that all three policies catch.
  124. $this->insertScore('203.0.113.30', $bruteForceId, 3.0);
  125. $strictResp = $this->request('GET', '/api/v1/blocklist', [
  126. 'Authorization' => 'Bearer ' . $strict,
  127. ]);
  128. $moderateResp = $this->request('GET', '/api/v1/blocklist', [
  129. 'Authorization' => 'Bearer ' . $moderate,
  130. ]);
  131. $paranoidResp = $this->request('GET', '/api/v1/blocklist', [
  132. 'Authorization' => 'Bearer ' . $paranoid,
  133. ]);
  134. self::assertSame('1', $strictResp->getHeaderLine('X-Blocklist-Entries'));
  135. self::assertSame('2', $moderateResp->getHeaderLine('X-Blocklist-Entries'));
  136. self::assertSame('3', $paranoidResp->getHeaderLine('X-Blocklist-Entries'));
  137. }
  138. private function setupConsumerToken(string $policyName, string $consumerName = 'fw-test'): string
  139. {
  140. $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :n', ['n' => $policyName]);
  141. $this->db->insert('consumers', [
  142. 'name' => $consumerName,
  143. 'policy_id' => $policyId,
  144. 'is_active' => 1,
  145. ]);
  146. $consumerId = (int) $this->db->lastInsertId();
  147. return $this->createToken(TokenKind::Consumer, consumerId: $consumerId);
  148. }
  149. private function insertManualIp(string $ip): void
  150. {
  151. $bin = IpAddress::fromString($ip)->binary();
  152. $stmt = $this->db->prepare(
  153. 'INSERT INTO manual_blocks (kind, ip_bin, reason) VALUES (:kind, :ip_bin, :reason)'
  154. );
  155. $stmt->bindValue('kind', 'ip');
  156. $stmt->bindValue('ip_bin', $bin, ParameterType::LARGE_OBJECT);
  157. $stmt->bindValue('reason', 'test');
  158. $stmt->executeStatement();
  159. }
  160. private function insertManualSubnet(string $cidr): void
  161. {
  162. $c = Cidr::fromString($cidr);
  163. $stmt = $this->db->prepare(
  164. 'INSERT INTO manual_blocks (kind, network_bin, prefix_length, reason) VALUES (:kind, :net, :pl, :reason)'
  165. );
  166. $stmt->bindValue('kind', 'subnet');
  167. $stmt->bindValue('net', $c->network(), ParameterType::LARGE_OBJECT);
  168. $stmt->bindValue('pl', $c->prefixLength(), ParameterType::INTEGER);
  169. $stmt->bindValue('reason', 'test');
  170. $stmt->executeStatement();
  171. }
  172. private function insertAllowSubnet(string $cidr): void
  173. {
  174. $c = Cidr::fromString($cidr);
  175. $stmt = $this->db->prepare(
  176. 'INSERT INTO allowlist (kind, network_bin, prefix_length, reason) VALUES (:kind, :net, :pl, :reason)'
  177. );
  178. $stmt->bindValue('kind', 'subnet');
  179. $stmt->bindValue('net', $c->network(), ParameterType::LARGE_OBJECT);
  180. $stmt->bindValue('pl', $c->prefixLength(), ParameterType::INTEGER);
  181. $stmt->bindValue('reason', 'test');
  182. $stmt->executeStatement();
  183. }
  184. private function insertScore(string $ip, int $categoryId, float $score): void
  185. {
  186. $ipObj = IpAddress::fromString($ip);
  187. $stmt = $this->db->prepare(
  188. 'INSERT INTO ip_scores (ip_bin, ip_text, category_id, score, report_count_30d, last_report_at, recomputed_at) '
  189. . 'VALUES (:b, :t, :c, :s, 1, :now, :now)'
  190. );
  191. $stmt->bindValue('b', $ipObj->binary(), ParameterType::LARGE_OBJECT);
  192. $stmt->bindValue('t', $ipObj->text());
  193. $stmt->bindValue('c', $categoryId, ParameterType::INTEGER);
  194. $stmt->bindValue('s', number_format($score, 4, '.', ''));
  195. $stmt->bindValue('now', (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'));
  196. $stmt->executeStatement();
  197. }
  198. public function testBlocklistRenderIsCachedAcrossRequests(): void
  199. {
  200. // SEC_REVIEW F70: the rendered body and its sha256 ETag are
  201. // cached per (policy_id, format) per cache window. Two
  202. // sequential requests within the same window should produce
  203. // byte-identical bodies and ETags. (Cache hit → no rebuild,
  204. // no rehash; the test asserts the steady-state contract.)
  205. $token = $this->setupConsumerToken('moderate');
  206. $r1 = $this->request('GET', '/api/v1/blocklist?format=json', [
  207. 'Authorization' => 'Bearer ' . $token,
  208. ]);
  209. $r2 = $this->request('GET', '/api/v1/blocklist?format=json', [
  210. 'Authorization' => 'Bearer ' . $token,
  211. ]);
  212. self::assertSame(200, $r1->getStatusCode());
  213. self::assertSame(200, $r2->getStatusCode());
  214. self::assertSame((string) $r1->getBody(), (string) $r2->getBody());
  215. self::assertSame($r1->getHeaderLine('ETag'), $r2->getHeaderLine('ETag'));
  216. }
  217. public function testTextAndJsonRendersBothCachedIndependently(): void
  218. {
  219. // SEC_REVIEW F70: switching format must not invalidate the
  220. // other format's cached render. Each format has its own
  221. // entry in the cache.
  222. $token = $this->setupConsumerToken('moderate');
  223. $textA = $this->request('GET', '/api/v1/blocklist', [
  224. 'Authorization' => 'Bearer ' . $token,
  225. ]);
  226. $jsonA = $this->request('GET', '/api/v1/blocklist?format=json', [
  227. 'Authorization' => 'Bearer ' . $token,
  228. ]);
  229. $textB = $this->request('GET', '/api/v1/blocklist', [
  230. 'Authorization' => 'Bearer ' . $token,
  231. ]);
  232. $jsonB = $this->request('GET', '/api/v1/blocklist?format=json', [
  233. 'Authorization' => 'Bearer ' . $token,
  234. ]);
  235. self::assertSame((string) $textA->getBody(), (string) $textB->getBody());
  236. self::assertSame($textA->getHeaderLine('ETag'), $textB->getHeaderLine('ETag'));
  237. self::assertSame((string) $jsonA->getBody(), (string) $jsonB->getBody());
  238. self::assertSame($jsonA->getHeaderLine('ETag'), $jsonB->getHeaderLine('ETag'));
  239. // Text and JSON ETags must DIFFER (different bodies).
  240. self::assertNotSame($textA->getHeaderLine('ETag'), $jsonA->getHeaderLine('ETag'));
  241. }
  242. }