diff --git a/frontend/src/components/VulnerabilityScanSheet.tsx b/frontend/src/components/VulnerabilityScanSheet.tsx index 6880814f..f26d5a31 100644 --- a/frontend/src/components/VulnerabilityScanSheet.tsx +++ b/frontend/src/components/VulnerabilityScanSheet.tsx @@ -231,21 +231,22 @@ export function VulnerabilityScanSheet({ if (scanId == null) return; setLoading(true); try { - const [scanRes, detailsRes, secretsRes, misconfigsRes] = await Promise.all([ + // The vulnerability list is fetched in full (paging past the per-request + // cap), not a single capped page, so severity filtering, inspection, and + // suppression reach every finding rather than only the first page. + const [scanRes, allVulns, secretsRes, misconfigsRes] = await Promise.all([ apiFetch(`/security/scans/${scanId}`), - apiFetch(`/security/scans/${scanId}/vulnerabilities?limit=${DETAIL_FETCH_LIMIT}`), + fetchAllScanVulnerabilities(scanId), apiFetch(`/security/scans/${scanId}/secrets?limit=${DETAIL_FETCH_LIMIT}`), apiFetch(`/security/scans/${scanId}/misconfigs?limit=${DETAIL_FETCH_LIMIT}`), ]); if (!scanRes.ok) throw new Error('Failed to fetch scan'); - if (!detailsRes.ok) throw new Error('Failed to fetch vulnerabilities'); const scanData = (await scanRes.json()) as VulnerabilityScan; - const detailsData = await detailsRes.json(); const secretsData = secretsRes.ok ? await secretsRes.json() : { items: [] }; const misconfigsData = misconfigsRes.ok ? await misconfigsRes.json() : { items: [] }; setScan(scanData); - setDetails(Array.isArray(detailsData.items) ? detailsData.items : []); - setTotalDetails(typeof detailsData.total === 'number' ? detailsData.total : 0); + setDetails(allVulns); + setTotalDetails(allVulns.length); setSecrets(Array.isArray(secretsData.items) ? secretsData.items : []); setMisconfigs(Array.isArray(misconfigsData.items) ? misconfigsData.items : []); setPage(0); @@ -785,12 +786,6 @@ export function VulnerabilityScanSheet({ )} - {totalDetails > details.length && ( -
- Showing first {details.length} of {totalDetails}. Export CSV for the complete list. -
- )} - {pageItems.length === 0 ? (
diff --git a/frontend/src/components/__tests__/VulnerabilityScanSheet.load.test.tsx b/frontend/src/components/__tests__/VulnerabilityScanSheet.load.test.tsx new file mode 100644 index 00000000..9b64f5a6 --- /dev/null +++ b/frontend/src/components/__tests__/VulnerabilityScanSheet.load.test.tsx @@ -0,0 +1,63 @@ +/** + * The detail sheet must load EVERY vulnerability (paging past the per-request + * cap), not a single capped page, so severity filtering, inspection, and + * suppression reach findings beyond the first page. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); +vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn() } })); + +import { apiFetch } from '@/lib/api'; +import { VulnerabilityScanSheet } from '../VulnerabilityScanSheet'; + +const mockedFetch = vi.mocked(apiFetch); +const ok = (body: unknown) => ({ ok: true, json: async () => body }) as Response; + +function vuln(i: number) { + return { + id: i, scan_id: 1, vulnerability_id: `CVE-2026-${i}`, pkg_name: 'pkg', installed_version: '1', + fixed_version: null, severity: 'HIGH', title: null, description: null, primary_url: null, + }; +} + +const SCAN = { + id: 1, node_id: 1, image_ref: 'img:1', image_digest: null, scanned_at: Date.now(), + total_vulnerabilities: 1500, critical_count: 0, high_count: 1500, medium_count: 0, low_count: 0, + unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln', + highest_severity: 'HIGH', os_info: null, trivy_version: '0.50.0', scan_duration_ms: 1, triggered_by: 'manual', + status: 'completed', error: null, stack_context: null, policy_evaluation: null, +}; + +beforeEach(() => { + mockedFetch.mockReset(); + mockedFetch.mockImplementation(((url: string) => { + if (/\/vulnerabilities\?limit=1000&offset=0/.test(url)) { + return Promise.resolve(ok({ items: Array.from({ length: 1000 }, (_, i) => vuln(i)), total: 1500 })); + } + if (/\/vulnerabilities\?limit=1000&offset=1000/.test(url)) { + return Promise.resolve(ok({ items: Array.from({ length: 500 }, (_, i) => vuln(1000 + i)), total: 1500 })); + } + if (/\/secrets/.test(url) || /\/misconfigs/.test(url) || /\/security\/scans\?imageRef/.test(url)) { + return Promise.resolve(ok({ items: [] })); + } + if (/\/security\/scans\/1$/.test(url)) return Promise.resolve(ok(SCAN)); + return Promise.resolve(ok({ items: [] })); + }) as unknown as typeof apiFetch); +}); + +describe('VulnerabilityScanSheet load', () => { + it('pages the full vulnerability list instead of a single capped page', async () => { + render( {}} />); + await waitFor(() => { + const urls = mockedFetch.mock.calls.map((c) => String(c[0])); + // Both pages of the offset-based full fetch were requested. + expect(urls.some((u) => /vulnerabilities\?limit=1000&offset=0/.test(u))).toBe(true); + expect(urls.some((u) => /vulnerabilities\?limit=1000&offset=1000/.test(u))).toBe(true); + }); + // The old single capped page request is gone. + const urls = mockedFetch.mock.calls.map((c) => String(c[0])); + expect(urls.some((u) => /vulnerabilities\?limit=500/.test(u))).toBe(false); + }); +});