bootApp(); // The first /app/* request in a process triggers session_start(), // which would clobber any $_SESSION values set here. Hit a public // route first so the session is already active by the time our // test request fires. $this->request('GET', '/healthz'); $_SESSION['_user'] = (new UserContext(1, 'Admin', 'admin', null, UserContext::SOURCE_LOCAL))->toArray(); $_SESSION['_last_active'] = time(); $_SESSION['_authenticated_at'] = time(); } public function testRendersEmptyFormWithoutQuery(): void { $resp = $this->request('GET', '/app/search'); self::assertSame(200, $resp->getStatusCode()); $body = (string) $resp->getBody(); self::assertStringContainsString('Enter an IP address or prefix', $body); self::assertStringContainsString('name="q"', $body); } public function testRendersResultsForQuery(): void { // 1) IPs $this->enqueueApiResponse(200, [ 'page' => 1, 'page_size' => 25, 'total' => 1, 'items' => [ [ 'ip' => '203.0.113.42', 'is_ipv4' => true, 'max_score' => 4.5, 'top_category' => 'brute_force', 'pair_count' => 1, 'last_report_at' => '2026-04-29T09:00:00Z', 'status' => 'scored', 'enrichment' => null, ], ], ]); // 2) Manual blocks $this->enqueueApiResponse(200, [ 'items' => [ ['id' => 1, 'kind' => 'subnet', 'cidr' => '203.0.113.0/24', 'reason' => 'edge', 'expires_at' => null, 'created_at' => '2026-04-29T10:00:00Z', 'created_by_user_id' => null], ['id' => 2, 'kind' => 'ip', 'ip' => '198.51.100.7', 'reason' => 'unrelated', 'expires_at' => null, 'created_at' => '2026-04-29T10:00:00Z', 'created_by_user_id' => null], ], 'total' => 2, ]); // 3) Allowlist $this->enqueueApiResponse(200, [ 'items' => [ ['id' => 9, 'kind' => 'ip', 'ip' => '203.0.113.42', 'reason' => 'office', 'created_at' => '2026-04-28T10:00:00Z', 'created_by_user_id' => null], ], 'total' => 1, ]); $resp = $this->request('GET', '/app/search?q=203.0.113'); self::assertSame(200, $resp->getStatusCode()); $body = (string) $resp->getBody(); // IP result row + link to filtered IPs page. self::assertStringContainsString('203.0.113.42', $body); self::assertStringContainsString('/app/ips?q=203.0.113', $body); // Manual block row that matched the query. self::assertStringContainsString('203.0.113.0/24', $body); // Unrelated manual-block entry must not render. self::assertStringNotContainsString('198.51.100.7', $body); // Section links to the index pages. self::assertStringContainsString('href="/app/manual-blocks"', $body); self::assertStringContainsString('href="/app/allowlist"', $body); } public function testHandlesPartialApiFailureGracefully(): void { // IPs succeeds, manual-blocks 500s (twice — ApiClient retries on 5xx), // allowlist succeeds. $this->enqueueApiResponse(200, ['page' => 1, 'page_size' => 25, 'total' => 0, 'items' => []]); $this->enqueueApiResponse(500, ['error' => 'boom']); $this->enqueueApiResponse(500, ['error' => 'boom']); $this->enqueueApiResponse(200, ['items' => [], 'total' => 0]); $resp = $this->request('GET', '/app/search?q=10.0.0.1'); self::assertSame(200, $resp->getStatusCode()); $body = (string) $resp->getBody(); self::assertStringContainsString('Failed to load manual blocks', $body); // The other sections still render their no-results state. self::assertStringContainsString('No IPs match this query.', $body); self::assertStringContainsString('No allowlist entries match this query.', $body); } public function testRedirectsAnonymousToLogin(): void { $_SESSION = []; $resp = $this->request('GET', '/app/search?q=foo'); self::assertSame(302, $resp->getStatusCode()); self::assertSame('/login', $resp->getHeaderLine('Location')); } }