1
0

CrudPagesTest.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  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 testPolicyEditAlignsThresholdsToCategoryRows(): void
  59. {
  60. // Regression: |merge of {(int_id): val} renumbered the keys via
  61. // PHP array_merge, shifting each rendered value up one row and
  62. // leaving the last category blank. Use category IDs that are not
  63. // a 0-based sequence so any reindexing is observable.
  64. $this->enqueueApiResponse(200, [
  65. 'id' => 1, 'name' => 'custom', 'description' => null,
  66. 'include_manual_blocks' => true,
  67. 'thresholds' => [
  68. ['category_id' => 11, 'category_slug' => 'scanners', 'threshold' => 40.0],
  69. ['category_id' => 12, 'category_slug' => 'indexer', 'threshold' => 30.0],
  70. ['category_id' => 13, 'category_slug' => 'malicious', 'threshold' => 20.0],
  71. ['category_id' => 14, 'category_slug' => 'load', 'threshold' => 10.0],
  72. ],
  73. 'created_at' => '2026-04-29T10:00:00Z',
  74. ]);
  75. $this->enqueueApiResponse(200, ['items' => [
  76. ['id' => 11, 'slug' => 'scanners', 'name' => 'Scanners', 'description' => null, 'decay_function' => 'exponential', 'decay_param' => 14.0, 'is_active' => true],
  77. ['id' => 12, 'slug' => 'indexer', 'name' => 'Indexer', 'description' => null, 'decay_function' => 'exponential', 'decay_param' => 14.0, 'is_active' => true],
  78. ['id' => 13, 'slug' => 'malicious', 'name' => 'Malicious', 'description' => null, 'decay_function' => 'exponential', 'decay_param' => 14.0, 'is_active' => true],
  79. ['id' => 14, 'slug' => 'load', 'name' => 'Load', 'description' => null, 'decay_function' => 'exponential', 'decay_param' => 14.0, 'is_active' => true],
  80. ], 'total' => 4]);
  81. $response = $this->request('GET', '/app/policies/1');
  82. self::assertSame(200, $response->getStatusCode());
  83. $body = (string) $response->getBody();
  84. foreach ([
  85. ['scanners', '40'],
  86. ['indexer', '30'],
  87. ['malicious', '20'],
  88. ['load', '10'],
  89. ] as [$slug, $val]) {
  90. self::assertMatchesRegularExpression(
  91. '/name="thresholds\[' . preg_quote($slug, '/') . '\]"[^>]*value="' . $val . '"/',
  92. $body,
  93. "row {$slug} must render value {$val}",
  94. );
  95. }
  96. }
  97. public function testReportersListRenders(): void
  98. {
  99. $this->enqueueApiResponse(200, ['data' => [
  100. ['id' => 1, 'name' => 'web-prod-01', 'description' => 'edge', 'trust_weight' => 1.0, 'is_active' => true, 'created_at' => '2026-04-29T10:00:00Z'],
  101. ], 'total' => 1, 'page' => 1, 'limit' => 200]);
  102. $response = $this->request('GET', '/app/reporters');
  103. self::assertSame(200, $response->getStatusCode());
  104. self::assertStringContainsString('web-prod-01', (string) $response->getBody());
  105. }
  106. public function testConsumersListRenders(): void
  107. {
  108. // First call: listConsumers; second: listPolicies (for the dropdown).
  109. $this->enqueueApiResponse(200, ['data' => [
  110. ['id' => 1, 'name' => 'fw-1', 'policy_id' => 5, 'description' => null, 'is_active' => true, 'created_at' => '2026-04-29T10:00:00Z', 'last_pulled_at' => null],
  111. ], 'total' => 1, 'page' => 1, 'limit' => 200]);
  112. $this->enqueueApiResponse(200, ['items' => [
  113. ['id' => 5, 'name' => 'moderate', 'description' => null, 'include_manual_blocks' => true, 'thresholds' => [], 'created_at' => '2026-04-29T10:00:00Z'],
  114. ], 'total' => 1]);
  115. $response = $this->request('GET', '/app/consumers');
  116. self::assertSame(200, $response->getStatusCode());
  117. $body = (string) $response->getBody();
  118. self::assertStringContainsString('fw-1', $body);
  119. self::assertStringContainsString('moderate', $body);
  120. }
  121. public function testTokensListRenders(): void
  122. {
  123. // listTokens, listReporters, listConsumers in that order.
  124. $this->enqueueApiResponse(200, ['data' => [
  125. ['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'],
  126. ], 'total' => 1, 'page' => 1, 'limit' => 200]);
  127. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  128. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  129. $response = $this->request('GET', '/app/tokens');
  130. self::assertSame(200, $response->getStatusCode());
  131. self::assertStringContainsString('irdb_adm', (string) $response->getBody());
  132. }
  133. public function testCategoriesListRenders(): void
  134. {
  135. $this->enqueueApiResponse(200, ['items' => [
  136. ['id' => 1, 'slug' => 'brute_force', 'name' => 'Brute force', 'description' => null, 'decay_function' => 'exponential', 'decay_param' => 14.0, 'is_active' => true],
  137. ], 'total' => 1]);
  138. $response = $this->request('GET', '/app/categories');
  139. self::assertSame(200, $response->getStatusCode());
  140. self::assertStringContainsString('brute_force', (string) $response->getBody());
  141. }
  142. // ---- happy + validation paths per resource ----
  143. public function testManualBlockCreateHappyPath(): void
  144. {
  145. $this->enqueueApiResponse(201, ['id' => 99, 'kind' => 'subnet', 'cidr' => '192.0.2.0/24']);
  146. $token = $this->csrfFromManualBlocks();
  147. $body = http_build_query(['csrf_token' => $token, 'kind' => 'subnet', 'cidr' => '192.0.2.0/24', 'reason' => 'test']);
  148. $response = $this->request('POST', '/app/manual-blocks', [], $body, 'application/x-www-form-urlencoded');
  149. self::assertSame(303, $response->getStatusCode());
  150. self::assertSame('/app/manual-blocks', $response->getHeaderLine('Location'));
  151. $flash = $_SESSION['_flash'] ?? [];
  152. self::assertNotEmpty($flash);
  153. self::assertSame('success', $flash[0]['type']);
  154. }
  155. public function testManualBlockCreateExpandsBareDateToEndOfDay(): void
  156. {
  157. // The form uses <input type="date">, which submits "YYYY-MM-DD".
  158. // The controller must expand that to T23:59:59Z so the block
  159. // covers the full selected calendar day.
  160. $this->enqueueApiResponse(201, ['id' => 100, 'kind' => 'ip', 'ip' => '203.0.113.5']);
  161. $token = $this->csrfFromManualBlocks();
  162. $body = http_build_query([
  163. 'csrf_token' => $token,
  164. 'kind' => 'ip',
  165. 'ip' => '203.0.113.5',
  166. 'reason' => 'eod test',
  167. 'expires_at' => '2026-12-31',
  168. ]);
  169. $response = $this->request('POST', '/app/manual-blocks', [], $body, 'application/x-www-form-urlencoded');
  170. self::assertSame(303, $response->getStatusCode());
  171. // The last captured outgoing call is the POST to the api;
  172. // earlier history entries are the GETs from csrfFromManualBlocks.
  173. $last = end($this->apiHistory);
  174. self::assertNotFalse($last);
  175. self::assertSame('POST', $last['request']->getMethod());
  176. $payload = json_decode((string) $last['request']->getBody(), true);
  177. self::assertIsArray($payload);
  178. self::assertSame('2026-12-31T23:59:59Z', $payload['expires_at']);
  179. }
  180. public function testManualBlockCreateValidationErrorFlashesField(): void
  181. {
  182. // The MockHandler is FIFO: the GET inside csrfFromManualBlocks
  183. // would consume any earlier-queued response, so queue the 400
  184. // AFTER the helper's GET has been served.
  185. $token = $this->csrfFromManualBlocks();
  186. $this->enqueueApiResponse(400, [
  187. 'error' => 'validation_failed',
  188. 'details' => ['cidr' => 'invalid format'],
  189. ]);
  190. $body = http_build_query(['csrf_token' => $token, 'kind' => 'subnet', 'cidr' => 'not-a-cidr']);
  191. $response = $this->request('POST', '/app/manual-blocks', [], $body, 'application/x-www-form-urlencoded');
  192. self::assertSame(303, $response->getStatusCode());
  193. $flash = $_SESSION['_flash'] ?? [];
  194. self::assertNotEmpty($flash);
  195. self::assertSame('error', $flash[0]['type']);
  196. self::assertStringContainsString('cidr', $flash[0]['message']);
  197. }
  198. public function testTokenCreateStashesRawTokenForOneTimeDisplay(): void
  199. {
  200. // GET /app/tokens warm-up to set CSRF + session.
  201. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  202. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  203. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  204. $this->request('GET', '/app/tokens');
  205. $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
  206. self::assertNotEmpty($token);
  207. // POST creates a new admin token.
  208. $this->enqueueApiResponse(201, [
  209. 'id' => 42, 'kind' => 'admin', 'token_prefix' => 'irdb_adm',
  210. 'raw_token' => 'irdb_adm_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
  211. 'role' => 'viewer',
  212. ]);
  213. $body = http_build_query(['csrf_token' => $token, 'kind' => 'admin', 'role' => 'viewer']);
  214. $createResp = $this->request('POST', '/app/tokens', [], $body, 'application/x-www-form-urlencoded');
  215. self::assertSame(303, $createResp->getStatusCode());
  216. // The follow-up GET surfaces the raw token in the response body
  217. // and clears the session slot afterwards.
  218. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  219. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  220. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  221. $listResp = $this->request('GET', '/app/tokens');
  222. $html = (string) $listResp->getBody();
  223. self::assertStringContainsString('irdb_adm_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', $html);
  224. self::assertArrayNotHasKey('_token_just_created', $_SESSION);
  225. }
  226. public function testCategoryCreateValidationFlash(): void
  227. {
  228. // Warm-up GET for CSRF token.
  229. $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
  230. $this->request('GET', '/app/categories');
  231. $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
  232. $this->enqueueApiResponse(400, [
  233. 'error' => 'validation_failed',
  234. 'details' => ['slug' => 'already exists'],
  235. ]);
  236. $body = http_build_query([
  237. 'csrf_token' => $token,
  238. 'slug' => 'brute_force',
  239. 'name' => 'Dup',
  240. 'decay_function' => 'exponential',
  241. 'decay_param' => '14',
  242. ]);
  243. $response = $this->request('POST', '/app/categories', [], $body, 'application/x-www-form-urlencoded');
  244. self::assertSame(303, $response->getStatusCode());
  245. self::assertSame('/app/categories', $response->getHeaderLine('Location'));
  246. $flash = $_SESSION['_flash'] ?? [];
  247. self::assertNotEmpty($flash);
  248. self::assertStringContainsString('slug', $flash[0]['message']);
  249. }
  250. public function testPolicyDeleteForwardsToApi(): void
  251. {
  252. $this->enqueueApiResponse(200, ['items' => [
  253. ['id' => 1, 'name' => 'moderate', 'description' => null, 'include_manual_blocks' => true, 'thresholds' => [], 'created_at' => '2026-04-29T10:00:00Z'],
  254. ], 'total' => 1]);
  255. $this->request('GET', '/app/policies');
  256. $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
  257. $this->enqueueApiResponse(204, []);
  258. $body = http_build_query(['csrf_token' => $token]);
  259. $response = $this->request('POST', '/app/policies/1/delete', [], $body, 'application/x-www-form-urlencoded');
  260. self::assertSame(303, $response->getStatusCode());
  261. self::assertSame('/app/policies', $response->getHeaderLine('Location'));
  262. }
  263. public function testReporterCreateHappyPath(): void
  264. {
  265. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]);
  266. $this->request('GET', '/app/reporters');
  267. $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
  268. $this->enqueueApiResponse(201, ['id' => 17, 'name' => 'test-rep', 'trust_weight' => 1.0, 'is_active' => true]);
  269. $body = http_build_query(['csrf_token' => $token, 'name' => 'test-rep', 'trust_weight' => '1.0']);
  270. $response = $this->request('POST', '/app/reporters', [], $body, 'application/x-www-form-urlencoded');
  271. self::assertSame(303, $response->getStatusCode());
  272. self::assertSame('/app/reporters/17', $response->getHeaderLine('Location'));
  273. }
  274. public function testIpDetailRendersActionButtonsForOperator(): void
  275. {
  276. $_SESSION['_user'] = (new UserContext(1, 'Op', 'operator', null, UserContext::SOURCE_LOCAL))->toArray();
  277. $this->enqueueApiResponse(200, [
  278. 'ip' => '203.0.113.10',
  279. 'is_ipv4' => true,
  280. 'status' => 'scored',
  281. 'scores' => [],
  282. 'enrichment' => ['country_code' => null, 'asn' => null, 'as_org' => null, 'enriched_at' => null],
  283. 'manual_block' => null,
  284. 'allowlist' => null,
  285. 'history' => [],
  286. 'has_more' => false,
  287. ]);
  288. $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
  289. $response = $this->request('GET', '/app/ips/203.0.113.10');
  290. $body = (string) $response->getBody();
  291. self::assertStringContainsString('Add to allowlist', $body);
  292. self::assertStringContainsString('Manually block', $body);
  293. }
  294. public function testIpDetailHidesActionButtonsForViewer(): void
  295. {
  296. $_SESSION['_user'] = (new UserContext(2, 'View', 'viewer', null, UserContext::SOURCE_LOCAL))->toArray();
  297. $this->enqueueApiResponse(200, [
  298. 'ip' => '203.0.113.10',
  299. 'is_ipv4' => true,
  300. 'status' => 'scored',
  301. 'scores' => [],
  302. 'enrichment' => ['country_code' => null, 'asn' => null, 'as_org' => null, 'enriched_at' => null],
  303. 'manual_block' => null,
  304. 'allowlist' => null,
  305. 'history' => [],
  306. 'has_more' => false,
  307. ]);
  308. $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
  309. $response = $this->request('GET', '/app/ips/203.0.113.10');
  310. $body = (string) $response->getBody();
  311. self::assertStringNotContainsString('Add to allowlist', $body);
  312. self::assertStringNotContainsString('Manually block', $body);
  313. }
  314. private function csrfFromManualBlocks(): string
  315. {
  316. // GET to set the CSRF token in the session.
  317. $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]);
  318. $this->request('GET', '/app/manual-blocks');
  319. $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
  320. self::assertNotEmpty($token);
  321. return $token;
  322. }
  323. }