Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
301 changes: 216 additions & 85 deletions frontend/src/pages/Findings.tsx

Large diffs are not rendered by default.

73 changes: 73 additions & 0 deletions frontend/src/utils/exportUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
export function escapeCSV(val: any): string {
if (val === null || val === undefined) return ''
const str = String(val)
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}

export function serializeFindingsToCSV(findings: any[]): string {
const headers = [
'ID',
'Title',
'Severity',
'Category',
'Target',
'Discovered At',
'CVSS',
'CVE',
'Risk Score',
'Confidence',
'Validated',
'Analyst Status',
'Description',
'Remediation'
]

const rows = findings.map((f) => [
f.id || '',
f.title || '',
f.severity || '',
f.category || '',
f.target || '',
f.discovered_at || '',
f.cvss !== undefined && f.cvss !== null ? String(f.cvss) : '',
f.cve || '',
f.risk_score !== undefined && f.risk_score !== null ? String(f.risk_score) : '',
f.confidence !== undefined && f.confidence !== null ? String(f.confidence) : '',
f.validated ? 'true' : 'false',
f.analyst_status || '',
f.description || '',
f.remediation || ''
])

return [
headers.join(','),
...rows.map((row) => row.map(escapeCSV).join(','))
].join('\n')
}

export function downloadFile(content: string, filename: string, contentType: string): void {
const blob = new Blob([content], { type: contentType })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}

export function exportFindingsAsCSV(findings: any[]): void {
const csvContent = serializeFindingsToCSV(findings)
const dateStr = new Date().toISOString().split('T')[0]
downloadFile(csvContent, `secuscan_findings_${dateStr}.csv`, 'text/csv;charset=utf-8;')
}

export function exportFindingsAsJSON(findings: any[]): void {
const jsonContent = JSON.stringify(findings, null, 2)
const dateStr = new Date().toISOString().split('T')[0]
downloadFile(jsonContent, `secuscan_findings_${dateStr}.json`, 'application/json')
}
78 changes: 78 additions & 0 deletions frontend/testing/unit/pages/Findings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ vi.mock('../../../src/api', () => ({
getFindings: vi.fn(),
}))

vi.mock('../../../src/utils/exportUtils', () => ({
exportFindingsAsCSV: vi.fn(),
exportFindingsAsJSON: vi.fn(),
}))

import { exportFindingsAsCSV, exportFindingsAsJSON } from '../../../src/utils/exportUtils'

vi.mock('../../../src/utils/date', async (importOriginal: any) => {
const actual = await importOriginal() as typeof import('../../../src/utils/date')
return {
Expand Down Expand Up @@ -259,4 +266,75 @@ describe('Findings — virtualized list', () => {
const suppressedChips = screen.queryAllByText('suppressed')
expect(suppressedChips.length).toBeGreaterThan(0)
})

it('individual checkbox click selects finding for export but doesn\'t change selected finding details', async () => {
const findings = [
makeFinding({ id: 'f1', title: 'SQL Injection', severity: 'critical' }),
makeFinding({ id: 'f2', title: 'CSRF Vulnerability', severity: 'high' }),
]
vi.mocked(getFindings).mockResolvedValue({ findings })

render(<Findings />)
await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument())

const checkboxF2 = screen.getByLabelText('Select CSRF Vulnerability')
expect(checkboxF2).not.toBeChecked()

await userEvent.click(checkboxF2)
expect(checkboxF2).toBeChecked()

expect(screen.getByRole('button', { name: /Bulk Export/i })).toBeInTheDocument()

expect(screen.getByRole('heading', { name: /SQL Injection/i, level: 2 })).toBeInTheDocument()
})

it('select all checkbox toggles selection for all visible findings', async () => {
const findings = [
makeFinding({ id: 'f1', title: 'SQL Injection', severity: 'critical' }),
makeFinding({ id: 'f2', title: 'CSRF Vulnerability', severity: 'high' }),
]
vi.mocked(getFindings).mockResolvedValue({ findings })

render(<Findings />)
await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument())

const selectAllCheckbox = screen.getByLabelText(/Select All Visible/i)
await userEvent.click(selectAllCheckbox)

expect(screen.getByLabelText('Select SQL Injection')).toBeChecked()
expect(screen.getByLabelText('Select CSRF Vulnerability')).toBeChecked()

await userEvent.click(selectAllCheckbox)
expect(screen.getByLabelText('Select SQL Injection')).not.toBeChecked()
expect(screen.getByLabelText('Select CSRF Vulnerability')).not.toBeChecked()
})

it('trigger CSV and JSON bulk export calls utility function', async () => {
const findings = [
makeFinding({ id: 'f1', title: 'SQL Injection', severity: 'critical' }),
]
vi.mocked(getFindings).mockResolvedValue({ findings })

render(<Findings />)
await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument())

await userEvent.click(screen.getByLabelText('Select SQL Injection'))

const bulkExportBtn = screen.getByRole('button', { name: /Bulk Export/i })
await userEvent.click(bulkExportBtn)

const csvExportBtn = screen.getByRole('button', { name: /Export as CSV/i })
const jsonExportBtn = screen.getByRole('button', { name: /Export as JSON/i })

expect(csvExportBtn).toBeInTheDocument()
expect(jsonExportBtn).toBeInTheDocument()

await userEvent.click(csvExportBtn)
expect(exportFindingsAsCSV).toHaveBeenCalled()

await userEvent.click(bulkExportBtn)
const newJsonExportBtn = await screen.findByRole('button', { name: /Export as JSON/i })
await userEvent.click(newJsonExportBtn)
expect(exportFindingsAsJSON).toHaveBeenCalled()
})
})
6 changes: 3 additions & 3 deletions frontend/testing/unit/pages/Reports.preferredFormat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe('Reports — preferred export format', () => {
const user = userEvent.setup()
renderReports()

await user.click(await screen.findByRole('button', { name: /^pdf$/i }))
await user.click(await screen.findByRole('button', { name: /^pdf$/ }))

expect(localStorage.getItem('secuscan:preferred-export-format')).toBe('pdf')
})
Expand All @@ -60,7 +60,7 @@ describe('Reports — preferred export format', () => {
const user = userEvent.setup()
renderReports()

await user.click(await screen.findByRole('button', { name: /^pdf$/i }))
await user.click(await screen.findByRole('button', { name: /^pdf$/ }))
await user.click(screen.getByRole('button', { name: /^csv$/i }))

expect(localStorage.getItem('secuscan:preferred-export-format')).toBe('csv')
Expand All @@ -81,7 +81,7 @@ describe('Reports — preferred export format', () => {

await screen.findByRole('button', { name: /^csv$/i })

const buttons = screen.getAllByRole('button', { name: /^(pdf|html|csv)$/i })
const buttons = screen.getAllByRole('button', { name: /^(pdf|html|csv)$/ })
expect(buttons[0].textContent?.toLowerCase()).toBe('csv')
})

Expand Down
22 changes: 11 additions & 11 deletions frontend/testing/unit/pages/Reports.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,23 +129,23 @@ describe('Reports — export buttons on a ready report', () => {

it('shows PDF, HTML and CSV buttons for a ready report', async () => {
renderReports()
expect(await screen.findByRole('button', { name: /^pdf$/i })).toBeInTheDocument()
expect(await screen.findByRole('button', { name: /^pdf$/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^html$/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^csv$/i })).toBeInTheDocument()
})

it('export buttons are enabled for a ready report', async () => {
renderReports()
await screen.findByRole('button', { name: /^pdf$/i })
expect(screen.getByRole('button', { name: /^pdf$/i })).not.toBeDisabled()
await screen.findByRole('button', { name: /^pdf$/ })
expect(screen.getByRole('button', { name: /^pdf$/ })).not.toBeDisabled()
expect(screen.getByRole('button', { name: /^html$/i })).not.toBeDisabled()
expect(screen.getByRole('button', { name: /^csv$/i })).not.toBeDisabled()
})

it('clicking PDF opens the correct backend URL', async () => {
const user = userEvent.setup()
renderReports()
await user.click(await screen.findByRole('button', { name: /^pdf$/i }))
await user.click(await screen.findByRole('button', { name: /^pdf$/ }))
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining('/task/' + readyReport.task_id + '/report/pdf'), '_blank')
})
Expand All @@ -169,7 +169,7 @@ describe('Reports — export buttons on a ready report', () => {
it('does not use the old placeholder latest-report route', async () => {
const user = userEvent.setup()
renderReports()
await user.click(await screen.findByRole('button', { name: /^pdf$/i }))
await user.click(await screen.findByRole('button', { name: /^pdf$/ }))
expect(openSpy).not.toHaveBeenCalledWith(expect.stringContaining('latest'), expect.anything())
})
})
Expand Down Expand Up @@ -218,17 +218,17 @@ describe('Reports — export buttons on a generating report', () => {

it('export buttons are disabled when report is generating', async () => {
renderReports()
await screen.findByRole('button', { name: /^pdf$/i })
expect(screen.getByRole('button', { name: /^pdf$/i })).toBeDisabled()
await screen.findByRole('button', { name: /^pdf$/ })
expect(screen.getByRole('button', { name: /^pdf$/ })).toBeDisabled()
expect(screen.getByRole('button', { name: /^html$/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /^csv$/i })).toBeDisabled()
})

it('clicking a disabled button does not open any URL', async () => {
const user = userEvent.setup()
renderReports()
await screen.findByRole('button', { name: /^pdf$/i })
await user.click(screen.getByRole('button', { name: /^pdf$/i }))
await screen.findByRole('button', { name: /^pdf$/ })
await user.click(screen.getByRole('button', { name: /^pdf$/ }))
expect(openSpy).not.toHaveBeenCalled()
})
})
Expand All @@ -242,8 +242,8 @@ describe('Reports — export buttons on a failed report', () => {

it('export buttons are enabled for a failed report since backend supports it', async () => {
renderReports()
await screen.findByRole('button', { name: /^pdf$/i })
expect(screen.getByRole('button', { name: /^pdf$/i })).not.toBeDisabled()
await screen.findByRole('button', { name: /^pdf$/ })
expect(screen.getByRole('button', { name: /^pdf$/ })).not.toBeDisabled()
expect(screen.getByRole('button', { name: /^html$/i })).not.toBeDisabled()
expect(screen.getByRole('button', { name: /^csv$/i })).not.toBeDisabled()
})
Expand Down
66 changes: 66 additions & 0 deletions frontend/testing/unit/utils/exportUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, test, expect } from "vitest";
import { escapeCSV, serializeFindingsToCSV } from "../../../src/utils/exportUtils";

describe("exportUtils utility", () => {
test("escapeCSV handles standard inputs", () => {
expect(escapeCSV("hello")).toBe("hello");
expect(escapeCSV(123)).toBe("123");
expect(escapeCSV(null)).toBe("");
expect(escapeCSV(undefined)).toBe("");
});

test("escapeCSV escapes quotes, commas, and newlines", () => {
expect(escapeCSV('hello, world')).toBe('"hello, world"');
expect(escapeCSV('hello "world"')).toBe('"hello ""world"""');
expect(escapeCSV('hello\nworld')).toBe('"hello\nworld"');
});

test("serializeFindingsToCSV generates correct headers and mapped rows", () => {
const sampleFindings = [
{
id: "f-1",
title: "SQL Injection",
severity: "critical",
category: "Database",
target: "http://target1.local",
discovered_at: "2026-05-12T10:30:00Z",
cvss: 9.8,
cve: "CVE-2026-1234",
risk_score: 9.5,
confidence: 0.9,
validated: true,
analyst_status: "confirmed",
description: "An injection vulnerability in input parameter.",
remediation: "Use parameterized queries."
},
{
id: "f-2",
title: "Information Disclosure, Version Leak",
severity: "info",
category: "Information",
target: "http://target2.local",
discovered_at: "2026-05-12T10:35:00Z",
cvss: null,
cve: undefined,
risk_score: 1.0,
confidence: 1.0,
validated: false,
analyst_status: "new",
description: "Version string \"1.2.3\" disclosed.",
remediation: "Disable version banners."
}
];

const csvContent = serializeFindingsToCSV(sampleFindings);

// Header check
expect(csvContent).toContain("ID,Title,Severity,Category,Target,Discovered At,CVSS,CVE,Risk Score,Confidence,Validated,Analyst Status,Description,Remediation");

// Row checks
expect(csvContent).toContain("f-1,SQL Injection,critical,Database,http://target1.local,2026-05-12T10:30:00Z,9.8,CVE-2026-1234,9.5,0.9,true,confirmed,An injection vulnerability in input parameter.,Use parameterized queries.");

// Check comma escaping in title, quote escaping in description
expect(csvContent).toContain('"Information Disclosure, Version Leak"');
expect(csvContent).toContain('"Version string ""1.2.3"" disclosed."');
});
});
Loading