bootApp(); $_SESSION['_user'] = (new UserContext(1, 'Admin', 'admin', null, UserContext::SOURCE_LOCAL))->toArray(); $_SESSION['_last_active'] = time(); $_SESSION['_authenticated_at'] = time(); } // ---- list pages render ---- public function testManualBlocksListRenders(): void { $this->enqueueApiResponse(200, ['items' => [ ['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], ], 'total' => 1]); $response = $this->request('GET', '/app/manual-blocks'); self::assertSame(200, $response->getStatusCode()); $body = (string) $response->getBody(); self::assertStringContainsString('192.0.2.0/24', $body); self::assertStringContainsString('Manual blocks', $body); } public function testSubnetsAliasFiltersToSubnets(): void { $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]); $response = $this->request('GET', '/app/subnets'); self::assertSame(200, $response->getStatusCode()); self::assertStringContainsString('Subnets', (string) $response->getBody()); } public function testAllowlistListRenders(): void { $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]); $response = $this->request('GET', '/app/allowlist'); self::assertSame(200, $response->getStatusCode()); self::assertStringContainsString('Allowlist', (string) $response->getBody()); } public function testPoliciesListRenders(): void { $this->enqueueApiResponse(200, ['items' => [ ['id' => 1, 'name' => 'moderate', 'description' => null, 'include_manual_blocks' => true, 'thresholds' => [], 'created_at' => '2026-04-29T10:00:00Z'], ], 'total' => 1]); $response = $this->request('GET', '/app/policies'); self::assertSame(200, $response->getStatusCode()); self::assertStringContainsString('moderate', (string) $response->getBody()); } public function testPolicyEditAlignsThresholdsToCategoryRows(): void { // Regression: |merge of {(int_id): val} renumbered the keys via // PHP array_merge, shifting each rendered value up one row and // leaving the last category blank. Use category IDs that are not // a 0-based sequence so any reindexing is observable. $this->enqueueApiResponse(200, [ 'id' => 1, 'name' => 'custom', 'description' => null, 'include_manual_blocks' => true, 'thresholds' => [ ['category_id' => 11, 'category_slug' => 'scanners', 'threshold' => 40.0], ['category_id' => 12, 'category_slug' => 'indexer', 'threshold' => 30.0], ['category_id' => 13, 'category_slug' => 'malicious', 'threshold' => 20.0], ['category_id' => 14, 'category_slug' => 'load', 'threshold' => 10.0], ], 'created_at' => '2026-04-29T10:00:00Z', ]); $this->enqueueApiResponse(200, ['items' => [ ['id' => 11, 'slug' => 'scanners', 'name' => 'Scanners', 'description' => null, 'decay_function' => 'exponential', 'decay_param' => 14.0, 'is_active' => true], ['id' => 12, 'slug' => 'indexer', 'name' => 'Indexer', 'description' => null, 'decay_function' => 'exponential', 'decay_param' => 14.0, 'is_active' => true], ['id' => 13, 'slug' => 'malicious', 'name' => 'Malicious', 'description' => null, 'decay_function' => 'exponential', 'decay_param' => 14.0, 'is_active' => true], ['id' => 14, 'slug' => 'load', 'name' => 'Load', 'description' => null, 'decay_function' => 'exponential', 'decay_param' => 14.0, 'is_active' => true], ], 'total' => 4]); $response = $this->request('GET', '/app/policies/1'); self::assertSame(200, $response->getStatusCode()); $body = (string) $response->getBody(); foreach ([ ['scanners', '40'], ['indexer', '30'], ['malicious', '20'], ['load', '10'], ] as [$slug, $val]) { self::assertMatchesRegularExpression( '/name="thresholds\[' . preg_quote($slug, '/') . '\]"[^>]*value="' . $val . '"/', $body, "row {$slug} must render value {$val}", ); } } public function testReportersListRenders(): void { $this->enqueueApiResponse(200, ['data' => [ ['id' => 1, 'name' => 'web-prod-01', 'description' => 'edge', 'trust_weight' => 1.0, 'is_active' => true, 'created_at' => '2026-04-29T10:00:00Z'], ], 'total' => 1, 'page' => 1, 'limit' => 200]); $response = $this->request('GET', '/app/reporters'); self::assertSame(200, $response->getStatusCode()); self::assertStringContainsString('web-prod-01', (string) $response->getBody()); } public function testConsumersListRenders(): void { // First call: listConsumers; second: listPolicies (for the dropdown). $this->enqueueApiResponse(200, ['data' => [ ['id' => 1, 'name' => 'fw-1', 'policy_id' => 5, 'description' => null, 'is_active' => true, 'created_at' => '2026-04-29T10:00:00Z', 'last_pulled_at' => null], ], 'total' => 1, 'page' => 1, 'limit' => 200]); $this->enqueueApiResponse(200, ['items' => [ ['id' => 5, 'name' => 'moderate', 'description' => null, 'include_manual_blocks' => true, 'thresholds' => [], 'created_at' => '2026-04-29T10:00:00Z'], ], 'total' => 1]); $response = $this->request('GET', '/app/consumers'); self::assertSame(200, $response->getStatusCode()); $body = (string) $response->getBody(); self::assertStringContainsString('fw-1', $body); self::assertStringContainsString('moderate', $body); } public function testTokensListRenders(): void { // listTokens, listReporters, listConsumers in that order. $this->enqueueApiResponse(200, ['data' => [ ['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'], ], 'total' => 1, 'page' => 1, 'limit' => 200]); $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]); $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]); $response = $this->request('GET', '/app/tokens'); self::assertSame(200, $response->getStatusCode()); self::assertStringContainsString('irdb_adm', (string) $response->getBody()); } public function testCategoriesListRenders(): void { $this->enqueueApiResponse(200, ['items' => [ ['id' => 1, 'slug' => 'brute_force', 'name' => 'Brute force', 'description' => null, 'decay_function' => 'exponential', 'decay_param' => 14.0, 'is_active' => true], ], 'total' => 1]); $response = $this->request('GET', '/app/categories'); self::assertSame(200, $response->getStatusCode()); self::assertStringContainsString('brute_force', (string) $response->getBody()); } // ---- happy + validation paths per resource ---- public function testManualBlockCreateHappyPath(): void { $this->enqueueApiResponse(201, ['id' => 99, 'kind' => 'subnet', 'cidr' => '192.0.2.0/24']); $token = $this->csrfFromManualBlocks(); $body = http_build_query(['csrf_token' => $token, 'kind' => 'subnet', 'cidr' => '192.0.2.0/24', 'reason' => 'test']); $response = $this->request('POST', '/app/manual-blocks', [], $body, 'application/x-www-form-urlencoded'); self::assertSame(303, $response->getStatusCode()); self::assertSame('/app/manual-blocks', $response->getHeaderLine('Location')); $flash = $_SESSION['_flash'] ?? []; self::assertNotEmpty($flash); self::assertSame('success', $flash[0]['type']); } public function testManualBlockCreateExpandsBareDateToEndOfDay(): void { // The form uses , which submits "YYYY-MM-DD". // The controller must expand that to T23:59:59Z so the block // covers the full selected calendar day. $this->enqueueApiResponse(201, ['id' => 100, 'kind' => 'ip', 'ip' => '203.0.113.5']); $token = $this->csrfFromManualBlocks(); $body = http_build_query([ 'csrf_token' => $token, 'kind' => 'ip', 'ip' => '203.0.113.5', 'reason' => 'eod test', 'expires_at' => '2026-12-31', ]); $response = $this->request('POST', '/app/manual-blocks', [], $body, 'application/x-www-form-urlencoded'); self::assertSame(303, $response->getStatusCode()); // The last captured outgoing call is the POST to the api; // earlier history entries are the GETs from csrfFromManualBlocks. $last = end($this->apiHistory); self::assertNotFalse($last); self::assertSame('POST', $last['request']->getMethod()); $payload = json_decode((string) $last['request']->getBody(), true); self::assertIsArray($payload); self::assertSame('2026-12-31T23:59:59Z', $payload['expires_at']); } public function testManualBlockCreateValidationErrorFlashesField(): void { // The MockHandler is FIFO: the GET inside csrfFromManualBlocks // would consume any earlier-queued response, so queue the 400 // AFTER the helper's GET has been served. $token = $this->csrfFromManualBlocks(); $this->enqueueApiResponse(400, [ 'error' => 'validation_failed', 'details' => ['cidr' => 'invalid format'], ]); $body = http_build_query(['csrf_token' => $token, 'kind' => 'subnet', 'cidr' => 'not-a-cidr']); $response = $this->request('POST', '/app/manual-blocks', [], $body, 'application/x-www-form-urlencoded'); self::assertSame(303, $response->getStatusCode()); $flash = $_SESSION['_flash'] ?? []; self::assertNotEmpty($flash); self::assertSame('error', $flash[0]['type']); self::assertStringContainsString('cidr', $flash[0]['message']); } public function testTokenCreateStashesRawTokenForOneTimeDisplay(): void { // GET /app/tokens warm-up to set CSRF + session. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]); $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]); $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]); $this->request('GET', '/app/tokens'); $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? ''); self::assertNotEmpty($token); // POST creates a new admin token. $this->enqueueApiResponse(201, [ 'id' => 42, 'kind' => 'admin', 'token_prefix' => 'irdb_adm', 'raw_token' => 'irdb_adm_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', 'role' => 'viewer', ]); $body = http_build_query(['csrf_token' => $token, 'kind' => 'admin', 'role' => 'viewer']); $createResp = $this->request('POST', '/app/tokens', [], $body, 'application/x-www-form-urlencoded'); self::assertSame(303, $createResp->getStatusCode()); // The follow-up GET surfaces the raw token in the response body // and clears the session slot afterwards. $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]); $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]); $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]); $listResp = $this->request('GET', '/app/tokens'); $html = (string) $listResp->getBody(); self::assertStringContainsString('irdb_adm_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', $html); self::assertArrayNotHasKey('_token_just_created', $_SESSION); } public function testCategoryCreateValidationFlash(): void { // Warm-up GET for CSRF token. $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]); $this->request('GET', '/app/categories'); $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? ''); $this->enqueueApiResponse(400, [ 'error' => 'validation_failed', 'details' => ['slug' => 'already exists'], ]); $body = http_build_query([ 'csrf_token' => $token, 'slug' => 'brute_force', 'name' => 'Dup', 'decay_function' => 'exponential', 'decay_param' => '14', ]); $response = $this->request('POST', '/app/categories', [], $body, 'application/x-www-form-urlencoded'); self::assertSame(303, $response->getStatusCode()); self::assertSame('/app/categories', $response->getHeaderLine('Location')); $flash = $_SESSION['_flash'] ?? []; self::assertNotEmpty($flash); self::assertStringContainsString('slug', $flash[0]['message']); } public function testPolicyDeleteForwardsToApi(): void { $this->enqueueApiResponse(200, ['items' => [ ['id' => 1, 'name' => 'moderate', 'description' => null, 'include_manual_blocks' => true, 'thresholds' => [], 'created_at' => '2026-04-29T10:00:00Z'], ], 'total' => 1]); $this->request('GET', '/app/policies'); $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? ''); $this->enqueueApiResponse(204, []); $body = http_build_query(['csrf_token' => $token]); $response = $this->request('POST', '/app/policies/1/delete', [], $body, 'application/x-www-form-urlencoded'); self::assertSame(303, $response->getStatusCode()); self::assertSame('/app/policies', $response->getHeaderLine('Location')); } public function testReporterCreateHappyPath(): void { $this->enqueueApiResponse(200, ['data' => [], 'total' => 0]); $this->request('GET', '/app/reporters'); $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? ''); $this->enqueueApiResponse(201, ['id' => 17, 'name' => 'test-rep', 'trust_weight' => 1.0, 'is_active' => true]); $body = http_build_query(['csrf_token' => $token, 'name' => 'test-rep', 'trust_weight' => '1.0']); $response = $this->request('POST', '/app/reporters', [], $body, 'application/x-www-form-urlencoded'); self::assertSame(303, $response->getStatusCode()); self::assertSame('/app/reporters/17', $response->getHeaderLine('Location')); } public function testIpDetailRendersActionButtonsForOperator(): void { $_SESSION['_user'] = (new UserContext(1, 'Op', 'operator', null, UserContext::SOURCE_LOCAL))->toArray(); $this->enqueueApiResponse(200, [ 'ip' => '203.0.113.10', 'is_ipv4' => true, 'status' => 'scored', 'scores' => [], 'enrichment' => ['country_code' => null, 'asn' => null, 'as_org' => null, 'enriched_at' => null], 'manual_block' => null, 'allowlist' => null, 'history' => [], 'has_more' => false, ]); $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]); $response = $this->request('GET', '/app/ips/203.0.113.10'); $body = (string) $response->getBody(); self::assertStringContainsString('Add to allowlist', $body); self::assertStringContainsString('Manually block', $body); } public function testIpDetailHidesActionButtonsForViewer(): void { $_SESSION['_user'] = (new UserContext(2, 'View', 'viewer', null, UserContext::SOURCE_LOCAL))->toArray(); $this->enqueueApiResponse(200, [ 'ip' => '203.0.113.10', 'is_ipv4' => true, 'status' => 'scored', 'scores' => [], 'enrichment' => ['country_code' => null, 'asn' => null, 'as_org' => null, 'enriched_at' => null], 'manual_block' => null, 'allowlist' => null, 'history' => [], 'has_more' => false, ]); $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]); $response = $this->request('GET', '/app/ips/203.0.113.10'); $body = (string) $response->getBody(); self::assertStringNotContainsString('Add to allowlist', $body); self::assertStringNotContainsString('Manually block', $body); } public function testManualBlockDeleteRejectsOpenRedirectInNext(): void { // SEC_REVIEW F10: an authenticated operator tricked into submitting // a forged form (or hit by a CSRF / sub-XSS auto-submit) MUST NOT // be redirected off-origin via the `next` field. The controller // routes the value through SessionManager::safeNextOrDefault, so // any non-local-path next falls back to the resource list. $token = $this->csrfFromManualBlocks(); $this->enqueueApiResponse(204, []); $body = http_build_query([ 'csrf_token' => $token, 'next' => '//evil.example.com/phish', ]); $response = $this->request('POST', '/app/manual-blocks/42/delete', [], $body, 'application/x-www-form-urlencoded'); self::assertSame(303, $response->getStatusCode()); self::assertSame('/app/manual-blocks', $response->getHeaderLine('Location')); } public function testManualBlockDeleteHonoursSafeNext(): void { // Sanity: a legitimate same-origin next (the in-app pattern, e.g. // returning to an IP detail page) is preserved. $token = $this->csrfFromManualBlocks(); $this->enqueueApiResponse(204, []); $body = http_build_query([ 'csrf_token' => $token, 'next' => '/app/ips/203.0.113.10', ]); $response = $this->request('POST', '/app/manual-blocks/42/delete', [], $body, 'application/x-www-form-urlencoded'); self::assertSame(303, $response->getStatusCode()); self::assertSame('/app/ips/203.0.113.10', $response->getHeaderLine('Location')); } public function testAllowlistDeleteRejectsOpenRedirectInNext(): void { // SEC_REVIEW F10 mirror on the allowlist controller. $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]); $this->request('GET', '/app/allowlist'); $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? ''); self::assertNotEmpty($token); $this->enqueueApiResponse(204, []); $body = http_build_query([ 'csrf_token' => $token, 'next' => 'https://evil.example.com/phish', ]); $response = $this->request('POST', '/app/allowlist/7/delete', [], $body, 'application/x-www-form-urlencoded'); self::assertSame(303, $response->getStatusCode()); self::assertSame('/app/allowlist', $response->getHeaderLine('Location')); } private function csrfFromManualBlocks(): string { // GET to set the CSRF token in the session. $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]); $this->request('GET', '/app/manual-blocks'); $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? ''); self::assertNotEmpty($token); return $token; } }