1
0

CrudPagesTest.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  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 testManualBlockCreateExpandsBareDateToEndOfDay(): void
  117. {
  118. // The form uses <input type="date">, which submits "YYYY-MM-DD".
  119. // The controller must expand that to T23:59:59Z so the block
  120. // covers the full selected calendar day.
  121. $this->enqueueApiResponse(201, ['id' => 100, 'kind' => 'ip', 'ip' => '203.0.113.5']);
  122. $token = $this->csrfFromManualBlocks();
  123. $body = http_build_query([
  124. 'csrf_token' => $token,
  125. 'kind' => 'ip',
  126. 'ip' => '203.0.113.5',
  127. 'reason' => 'eod test',
  128. 'expires_at' => '2026-12-31',
  129. ]);
  130. $response = $this->request('POST', '/app/manual-blocks', [], $body, 'application/x-www-form-urlencoded');
  131. self::assertSame(303, $response->getStatusCode());
  132. // The last captured outgoing call is the POST to the api;
  133. // earlier history entries are the GETs from csrfFromManualBlocks.
  134. $last = end($this->apiHistory);
  135. self::assertNotFalse($last);
  136. self::assertSame('POST', $last['request']->getMethod());
  137. $payload = json_decode((string) $last['request']->getBody(), true);
  138. self::assertIsArray($payload);
  139. self::assertSame('2026-12-31T23:59:59Z', $payload['expires_at']);
  140. }
  141. public function testManualBlockCreateValidationErrorFlashesField(): void
  142. {
  143. // The MockHandler is FIFO: the GET inside csrfFromManualBlocks
  144. // would consume any earlier-queued response, so queue the 400
  145. // AFTER the helper's GET has been served.
  146. $token = $this->csrfFromManualBlocks();
  147. $this->enqueueApiResponse(400, [
  148. 'error' => 'validation_failed',
  149. 'details' => ['cidr' => 'invalid format'],
  150. ]);
  151. $body = http_build_query(['csrf_token' => $token, 'kind' => 'subnet', 'cidr' => 'not-a-cidr']);
  152. $response = $this->request('POST', '/app/manual-blocks', [], $body, 'application/x-www-form-urlencoded');
  153. self::assertSame(303, $response->getStatusCode());
  154. $flash = $_SESSION['_flash'] ?? [];
  155. self::assertNotEmpty($flash);
  156. self::assertSame('error', $flash[0]['type']);
  157. self::assertStringContainsString('cidr', $flash[0]['message']);
  158. }
  159. public function testTokenCreateStashesRawTokenForOneTimeDisplay(): void
  160. {
  161. // GET /app/tokens warm-up to set CSRF + session.
  162. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  163. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  164. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  165. $this->request('GET', '/app/tokens');
  166. $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
  167. self::assertNotEmpty($token);
  168. // POST creates a new admin token.
  169. $this->enqueueApiResponse(201, [
  170. 'id' => 42, 'kind' => 'admin', 'token_prefix' => 'irdb_adm',
  171. 'raw_token' => 'irdb_adm_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
  172. 'role' => 'viewer',
  173. ]);
  174. $body = http_build_query(['csrf_token' => $token, 'kind' => 'admin', 'role' => 'viewer']);
  175. $createResp = $this->request('POST', '/app/tokens', [], $body, 'application/x-www-form-urlencoded');
  176. self::assertSame(303, $createResp->getStatusCode());
  177. // The follow-up GET surfaces the raw token in the response body
  178. // and clears the session slot afterwards.
  179. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  180. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  181. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  182. $listResp = $this->request('GET', '/app/tokens');
  183. $html = (string) $listResp->getBody();
  184. self::assertStringContainsString('irdb_adm_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', $html);
  185. self::assertArrayNotHasKey('_token_just_created', $_SESSION);
  186. }
  187. public function testCategoryCreateValidationFlash(): void
  188. {
  189. // Warm-up GET for CSRF token.
  190. $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
  191. $this->request('GET', '/app/categories');
  192. $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
  193. $this->enqueueApiResponse(400, [
  194. 'error' => 'validation_failed',
  195. 'details' => ['slug' => 'already exists'],
  196. ]);
  197. $body = http_build_query([
  198. 'csrf_token' => $token,
  199. 'slug' => 'brute_force',
  200. 'name' => 'Dup',
  201. 'decay_function' => 'exponential',
  202. 'decay_param' => '14',
  203. ]);
  204. $response = $this->request('POST', '/app/categories', [], $body, 'application/x-www-form-urlencoded');
  205. self::assertSame(303, $response->getStatusCode());
  206. self::assertSame('/app/categories', $response->getHeaderLine('Location'));
  207. $flash = $_SESSION['_flash'] ?? [];
  208. self::assertNotEmpty($flash);
  209. self::assertStringContainsString('slug', $flash[0]['message']);
  210. }
  211. public function testPolicyDeleteForwardsToApi(): void
  212. {
  213. $this->enqueueApiResponse(200, ['items' => [
  214. ['id' => 1, 'name' => 'moderate', 'description' => null, 'include_manual_blocks' => true, 'thresholds' => [], 'created_at' => '2026-04-29T10:00:00Z'],
  215. ], 'total' => 1]);
  216. $this->request('GET', '/app/policies');
  217. $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
  218. $this->enqueueApiResponse(204, []);
  219. $body = http_build_query(['csrf_token' => $token]);
  220. $response = $this->request('POST', '/app/policies/1/delete', [], $body, 'application/x-www-form-urlencoded');
  221. self::assertSame(303, $response->getStatusCode());
  222. self::assertSame('/app/policies', $response->getHeaderLine('Location'));
  223. }
  224. public function testReporterCreateHappyPath(): void
  225. {
  226. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  227. $this->request('GET', '/app/reporters');
  228. $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
  229. $this->enqueueApiResponse(201, ['id' => 17, 'name' => 'test-rep', 'trust_weight' => 1.0, 'is_active' => true]);
  230. $body = http_build_query(['csrf_token' => $token, 'name' => 'test-rep', 'trust_weight' => '1.0']);
  231. $response = $this->request('POST', '/app/reporters', [], $body, 'application/x-www-form-urlencoded');
  232. self::assertSame(303, $response->getStatusCode());
  233. self::assertSame('/app/reporters/17', $response->getHeaderLine('Location'));
  234. }
  235. public function testIpDetailRendersActionButtonsForOperator(): void
  236. {
  237. $_SESSION['_user'] = (new UserContext(1, 'Op', 'operator', null, UserContext::SOURCE_LOCAL))->toArray();
  238. $this->enqueueApiResponse(200, [
  239. 'ip' => '203.0.113.10',
  240. 'is_ipv4' => true,
  241. 'status' => 'scored',
  242. 'scores' => [],
  243. 'enrichment' => ['country_code' => null, 'asn' => null, 'as_org' => null, 'enriched_at' => null],
  244. 'manual_block' => null,
  245. 'allowlist' => null,
  246. 'history' => [],
  247. 'has_more' => false,
  248. ]);
  249. $response = $this->request('GET', '/app/ips/203.0.113.10');
  250. $body = (string) $response->getBody();
  251. self::assertStringContainsString('Add to allowlist', $body);
  252. self::assertStringContainsString('Manually block', $body);
  253. }
  254. public function testIpDetailHidesActionButtonsForViewer(): void
  255. {
  256. $_SESSION['_user'] = (new UserContext(2, 'View', 'viewer', null, UserContext::SOURCE_LOCAL))->toArray();
  257. $this->enqueueApiResponse(200, [
  258. 'ip' => '203.0.113.10',
  259. 'is_ipv4' => true,
  260. 'status' => 'scored',
  261. 'scores' => [],
  262. 'enrichment' => ['country_code' => null, 'asn' => null, 'as_org' => null, 'enriched_at' => null],
  263. 'manual_block' => null,
  264. 'allowlist' => null,
  265. 'history' => [],
  266. 'has_more' => false,
  267. ]);
  268. $response = $this->request('GET', '/app/ips/203.0.113.10');
  269. $body = (string) $response->getBody();
  270. self::assertStringNotContainsString('Add to allowlist', $body);
  271. self::assertStringNotContainsString('Manually block', $body);
  272. }
  273. private function csrfFromManualBlocks(): string
  274. {
  275. // GET to set the CSRF token in the session.
  276. $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
  277. $this->request('GET', '/app/manual-blocks');
  278. $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
  279. self::assertNotEmpty($token);
  280. return $token;
  281. }
  282. }