mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 20:29:15 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user