1
0

CrudPagesTest.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Crud;
  4. use App\Auth\UserContext;
  5. use App\Http\CsrfMiddleware;
  6. use App\Tests\Integration\Support\AppTestCase;
  7. /**
  8. * SPEC §M10 acceptance: every list page renders and at least one
  9. * happy-path + one validation-error path per resource is covered.
  10. *
  11. * Each test queues responses for the AdminClient calls the controller
  12. * fires; the AppTestCase's MockHandler returns them in FIFO order.
  13. */
  14. final class CrudPagesTest extends AppTestCase
  15. {
  16. protected function setUp(): void
  17. {
  18. $this->bootApp();
  19. $_SESSION['_user'] = (new UserContext(1, 'Admin', 'admin', null, UserContext::SOURCE_LOCAL))->toArray();
  20. $_SESSION['_last_active'] = time();
  21. $_SESSION['_authenticated_at'] = time();
  22. }
  23. // ---- list pages render ----
  24. public function testManualBlocksListRenders(): void
  25. {
  26. $this->enqueueApiResponse(200, ['items' => [
  27. ['id' => 1, 'kind' => 'subnet', 'cidr' => '192.0.2.0/24', 'reason' => 'edge', 'expires_at' => null, 'created_at' => '2026-04-29T10:00:00Z', 'created_by_user_id' => null],
  28. ], 'total' => 1]);
  29. $response = $this->request('GET', '/app/manual-blocks');
  30. self::assertSame(200, $response->getStatusCode());
  31. $body = (string) $response->getBody();
  32. self::assertStringContainsString('192.0.2.0/24', $body);
  33. self::assertStringContainsString('Manual blocks', $body);
  34. }
  35. public function testSubnetsAliasFiltersToSubnets(): void
  36. {
  37. $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
  38. $response = $this->request('GET', '/app/subnets');
  39. self::assertSame(200, $response->getStatusCode());
  40. self::assertStringContainsString('Subnets', (string) $response->getBody());
  41. }
  42. public function testAllowlistListRenders(): void
  43. {
  44. $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
  45. $response = $this->request('GET', '/app/allowlist');
  46. self::assertSame(200, $response->getStatusCode());
  47. self::assertStringContainsString('Allowlist', (string) $response->getBody());
  48. }
  49. public function testPoliciesListRenders(): void
  50. {
  51. $this->enqueueApiResponse(200, ['items' => [
  52. ['id' => 1, 'name' => 'moderate', 'description' => null, 'include_manual_blocks' => true, 'thresholds' => [], 'created_at' => '2026-04-29T10:00:00Z'],
  53. ], 'total' => 1]);
  54. $response = $this->request('GET', '/app/policies');
  55. self::assertSame(200, $response->getStatusCode());
  56. self::assertStringContainsString('moderate', (string) $response->getBody());
  57. }
  58. public function testReportersListRenders(): void
  59. {
  60. $this->enqueueApiResponse(200, ['data' => [
  61. ['id' => 1, 'name' => 'web-prod-01', 'description' => 'edge', 'trust_weight' => 1.0, 'is_active' => true, 'created_at' => '2026-04-29T10:00:00Z'],
  62. ], 'total' => 1, 'page' => 1, 'limit' => 200]);
  63. $response = $this->request('GET', '/app/reporters');
  64. self::assertSame(200, $response->getStatusCode());
  65. self::assertStringContainsString('web-prod-01', (string) $response->getBody());
  66. }
  67. public function testConsumersListRenders(): void
  68. {
  69. // First call: listConsumers; second: listPolicies (for the dropdown).
  70. $this->enqueueApiResponse(200, ['data' => [
  71. ['id' => 1, 'name' => 'fw-1', 'policy_id' => 5, 'description' => null, 'is_active' => true, 'created_at' => '2026-04-29T10:00:00Z', 'last_pulled_at' => null],
  72. ], 'total' => 1, 'page' => 1, 'limit' => 200]);
  73. $this->enqueueApiResponse(200, ['items' => [
  74. ['id' => 5, 'name' => 'moderate', 'description' => null, 'include_manual_blocks' => true, 'thresholds' => [], 'created_at' => '2026-04-29T10:00:00Z'],
  75. ], 'total' => 1]);
  76. $response = $this->request('GET', '/app/consumers');
  77. self::assertSame(200, $response->getStatusCode());
  78. $body = (string) $response->getBody();
  79. self::assertStringContainsString('fw-1', $body);
  80. self::assertStringContainsString('moderate', $body);
  81. }
  82. public function testTokensListRenders(): void
  83. {
  84. // listTokens, listReporters, listConsumers in that order.
  85. $this->enqueueApiResponse(200, ['data' => [
  86. ['id' => 1, 'kind' => 'admin', 'token_prefix' => 'irdb_adm', 'role' => 'viewer', 'reporter_id' => null, 'consumer_id' => null, 'expires_at' => null, 'revoked_at' => null, 'last_used_at' => null, 'created_at' => '2026-04-29T10:00:00Z'],
  87. ], 'total' => 1, 'page' => 1, 'limit' => 200]);
  88. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  89. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  90. $response = $this->request('GET', '/app/tokens');
  91. self::assertSame(200, $response->getStatusCode());
  92. self::assertStringContainsString('irdb_adm', (string) $response->getBody());
  93. }
  94. public function testCategoriesListRenders(): void
  95. {
  96. $this->enqueueApiResponse(200, ['items' => [
  97. ['id' => 1, 'slug' => 'brute_force', 'name' => 'Brute force', 'description' => null, 'decay_function' => 'exponential', 'decay_param' => 14.0, 'is_active' => true],
  98. ], 'total' => 1]);
  99. $response = $this->request('GET', '/app/categories');
  100. self::assertSame(200, $response->getStatusCode());
  101. self::assertStringContainsString('brute_force', (string) $response->getBody());
  102. }
  103. // ---- happy + validation paths per resource ----
  104. public function testManualBlockCreateHappyPath(): void
  105. {
  106. $this->enqueueApiResponse(201, ['id' => 99, 'kind' => 'subnet', 'cidr' => '192.0.2.0/24']);
  107. $token = $this->csrfFromManualBlocks();
  108. $body = http_build_query(['csrf_token' => $token, 'kind' => 'subnet', 'cidr' => '192.0.2.0/24', 'reason' => 'test']);
  109. $response = $this->request('POST', '/app/manual-blocks', [], $body, 'application/x-www-form-urlencoded');
  110. self::assertSame(303, $response->getStatusCode());
  111. self::assertSame('/app/manual-blocks', $response->getHeaderLine('Location'));
  112. $flash = $_SESSION['_flash'] ?? [];
  113. self::assertNotEmpty($flash);
  114. self::assertSame('success', $flash[0]['type']);
  115. }
  116. public function testManualBlockCreateValidationErrorFlashesField(): void
  117. {
  118. // The MockHandler is FIFO: the GET inside csrfFromManualBlocks
  119. // would consume any earlier-queued response, so queue the 400
  120. // AFTER the helper's GET has been served.
  121. $token = $this->csrfFromManualBlocks();
  122. $this->enqueueApiResponse(400, [
  123. 'error' => 'validation_failed',
  124. 'details' => ['cidr' => 'invalid format'],
  125. ]);
  126. $body = http_build_query(['csrf_token' => $token, 'kind' => 'subnet', 'cidr' => 'not-a-cidr']);
  127. $response = $this->request('POST', '/app/manual-blocks', [], $body, 'application/x-www-form-urlencoded');
  128. self::assertSame(303, $response->getStatusCode());
  129. $flash = $_SESSION['_flash'] ?? [];
  130. self::assertNotEmpty($flash);
  131. self::assertSame('error', $flash[0]['type']);
  132. self::assertStringContainsString('cidr', $flash[0]['message']);
  133. }
  134. public function testTokenCreateStashesRawTokenForOneTimeDisplay(): void
  135. {
  136. // GET /app/tokens warm-up to set CSRF + session.
  137. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  138. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  139. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  140. $this->request('GET', '/app/tokens');
  141. $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
  142. self::assertNotEmpty($token);
  143. // POST creates a new admin token.
  144. $this->enqueueApiResponse(201, [
  145. 'id' => 42, 'kind' => 'admin', 'token_prefix' => 'irdb_adm',
  146. 'raw_token' => 'irdb_adm_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
  147. 'role' => 'viewer',
  148. ]);
  149. $body = http_build_query(['csrf_token' => $token, 'kind' => 'admin', 'role' => 'viewer']);
  150. $createResp = $this->request('POST', '/app/tokens', [], $body, 'application/x-www-form-urlencoded');
  151. self::assertSame(303, $createResp->getStatusCode());
  152. // The follow-up GET surfaces the raw token in the response body
  153. // and clears the session slot afterwards.
  154. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  155. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  156. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  157. $listResp = $this->request('GET', '/app/tokens');
  158. $html = (string) $listResp->getBody();
  159. self::assertStringContainsString('irdb_adm_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', $html);
  160. self::assertArrayNotHasKey('_token_just_created', $_SESSION);
  161. }
  162. public function testCategoryCreateValidationFlash(): void
  163. {
  164. // Warm-up GET for CSRF token.
  165. $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
  166. $this->request('GET', '/app/categories');
  167. $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
  168. $this->enqueueApiResponse(400, [
  169. 'error' => 'validation_failed',
  170. 'details' => ['slug' => 'already exists'],
  171. ]);
  172. $body = http_build_query([
  173. 'csrf_token' => $token,
  174. 'slug' => 'brute_force',
  175. 'name' => 'Dup',
  176. 'decay_function' => 'exponential',
  177. 'decay_param' => '14',
  178. ]);
  179. $response = $this->request('POST', '/app/categories', [], $body, 'application/x-www-form-urlencoded');
  180. self::assertSame(303, $response->getStatusCode());
  181. self::assertSame('/app/categories', $response->getHeaderLine('Location'));
  182. $flash = $_SESSION['_flash'] ?? [];
  183. self::assertNotEmpty($flash);
  184. self::assertStringContainsString('slug', $flash[0]['message']);
  185. }
  186. public function testPolicyDeleteForwardsToApi(): void
  187. {
  188. $this->enqueueApiResponse(200, ['items' => [
  189. ['id' => 1, 'name' => 'moderate', 'description' => null, 'include_manual_blocks' => true, 'thresholds' => [], 'created_at' => '2026-04-29T10:00:00Z'],
  190. ], 'total' => 1]);
  191. $this->request('GET', '/app/policies');
  192. $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
  193. $this->enqueueApiResponse(204, []);
  194. $body = http_build_query(['csrf_token' => $token]);
  195. $response = $this->request('POST', '/app/policies/1/delete', [], $body, 'application/x-www-form-urlencoded');
  196. self::assertSame(303, $response->getStatusCode());
  197. self::assertSame('/app/policies', $response->getHeaderLine('Location'));
  198. }
  199. public function testReporterCreateHappyPath(): void
  200. {
  201. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  202. $this->request('GET', '/app/reporters');
  203. $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
  204. $this->enqueueApiResponse(201, ['id' => 17, 'name' => 'test-rep', 'trust_weight' => 1.0, 'is_active' => true]);
  205. $body = http_build_query(['csrf_token' => $token, 'name' => 'test-rep', 'trust_weight' => '1.0']);
  206. $response = $this->request('POST', '/app/reporters', [], $body, 'application/x-www-form-urlencoded');
  207. self::assertSame(303, $response->getStatusCode());
  208. self::assertSame('/app/reporters/17', $response->getHeaderLine('Location'));
  209. }
  210. public function testIpDetailRendersActionButtonsForOperator(): void
  211. {
  212. $_SESSION['_user'] = (new UserContext(1, 'Op', 'operator', null, UserContext::SOURCE_LOCAL))->toArray();
  213. $this->enqueueApiResponse(200, [
  214. 'ip' => '203.0.113.10',
  215. 'is_ipv4' => true,
  216. 'status' => 'scored',
  217. 'scores' => [],
  218. 'enrichment' => ['country_code' => null, 'asn' => null, 'as_org' => null, 'enriched_at' => null],
  219. 'manual_block' => null,
  220. 'allowlist' => null,
  221. 'history' => [],
  222. 'has_more' => false,
  223. ]);
  224. $response = $this->request('GET', '/app/ips/203.0.113.10');
  225. $body = (string) $response->getBody();
  226. self::assertStringContainsString('Add to allowlist', $body);
  227. self::assertStringContainsString('Manually block', $body);
  228. }
  229. public function testIpDetailHidesActionButtonsForViewer(): void
  230. {
  231. $_SESSION['_user'] = (new UserContext(2, 'View', 'viewer', null, UserContext::SOURCE_LOCAL))->toArray();
  232. $this->enqueueApiResponse(200, [
  233. 'ip' => '203.0.113.10',
  234. 'is_ipv4' => true,
  235. 'status' => 'scored',
  236. 'scores' => [],
  237. 'enrichment' => ['country_code' => null, 'asn' => null, 'as_org' => null, 'enriched_at' => null],
  238. 'manual_block' => null,
  239. 'allowlist' => null,
  240. 'history' => [],
  241. 'has_more' => false,
  242. ]);
  243. $response = $this->request('GET', '/app/ips/203.0.113.10');
  244. $body = (string) $response->getBody();
  245. self::assertStringNotContainsString('Add to allowlist', $body);
  246. self::assertStringNotContainsString('Manually block', $body);
  247. }
  248. private function csrfFromManualBlocks(): string
  249. {
  250. // GET to set the CSRF token in the session.
  251. $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
  252. $this->request('GET', '/app/manual-blocks');
  253. $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
  254. self::assertNotEmpty($token);
  255. return $token;
  256. }
  257. }