fix: export the full vulnerability list to CSV (#1472)

The scan detail sheet fetches a capped page of vulnerabilities for
display, then told operators to "Export CSV for the complete list".
The CSV writer only serialized the rows already in memory, so for a
scan with more findings than the page cap the CSV silently dropped
everything past the cap: the recovery path the notice promised did not
exist.

Export now pages past the API's per-request cap and serializes every
row when the loaded set is short of the total, falling back to the
in-memory rows when they are already complete. The CSV action shows a
spinner and disables while the export runs.
This commit is contained in:
Anso
2026-06-26 15:34:57 -04:00
committed by GitHub
parent ca496c89dc
commit e3b3c3b857
3 changed files with 133 additions and 27 deletions
@@ -0,0 +1,60 @@
/**
* fetchAllScanVulnerabilities backs the scan sheet's CSV export. The detail
* table renders a capped page, but the CSV promises the complete list, so this
* helper must page past the backend's per-request cap (1000 rows) and collect
* every finding. A scan with thousands of CVEs is the case this guards.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
import { apiFetch } from '@/lib/api';
import { fetchAllScanVulnerabilities } from '../VulnerabilityScanSheet.export';
import type { VulnerabilityDetail } from '@/types/security';
const mockedFetch = vi.mocked(apiFetch);
function response(ok: boolean, body: unknown): Response {
return { ok, json: async () => body } as unknown as Response;
}
function rows(n: number, startId = 0): VulnerabilityDetail[] {
return Array.from({ length: n }, (_, i) => ({ id: startId + i }) as VulnerabilityDetail);
}
beforeEach(() => mockedFetch.mockReset());
describe('fetchAllScanVulnerabilities', () => {
it('fetches a single page when the total fits under the per-request cap', async () => {
mockedFetch.mockResolvedValueOnce(response(true, { items: rows(42), total: 42 }));
const result = await fetchAllScanVulnerabilities(7);
expect(result).toHaveLength(42);
expect(mockedFetch).toHaveBeenCalledTimes(1);
expect(mockedFetch).toHaveBeenCalledWith('/security/scans/7/vulnerabilities?limit=1000&offset=0');
});
it('pages past the cap until every row is collected', async () => {
mockedFetch
.mockResolvedValueOnce(response(true, { items: rows(1000, 0), total: 2500 }))
.mockResolvedValueOnce(response(true, { items: rows(1000, 1000), total: 2500 }))
.mockResolvedValueOnce(response(true, { items: rows(500, 2000), total: 2500 }));
const result = await fetchAllScanVulnerabilities(3);
expect(result).toHaveLength(2500);
expect(mockedFetch).toHaveBeenCalledTimes(3);
expect(mockedFetch).toHaveBeenNthCalledWith(1, '/security/scans/3/vulnerabilities?limit=1000&offset=0');
expect(mockedFetch).toHaveBeenNthCalledWith(2, '/security/scans/3/vulnerabilities?limit=1000&offset=1000');
expect(mockedFetch).toHaveBeenNthCalledWith(3, '/security/scans/3/vulnerabilities?limit=1000&offset=2000');
});
it('stops on a short page even when total over-reports (no infinite loop)', async () => {
mockedFetch.mockResolvedValueOnce(response(true, { items: rows(10), total: 99999 }));
const result = await fetchAllScanVulnerabilities(1);
expect(result).toHaveLength(10);
expect(mockedFetch).toHaveBeenCalledTimes(1);
});
it('throws when a page request fails rather than returning a partial list', async () => {
mockedFetch.mockResolvedValueOnce(response(false, {}));
await expect(fetchAllScanVulnerabilities(1)).rejects.toThrow('full vulnerability list');
});
});