diff --git a/frontend/src/components/ScanHistory.tsx b/frontend/src/components/ScanHistory.tsx new file mode 100644 index 00000000..6da4e8cd --- /dev/null +++ b/frontend/src/components/ScanHistory.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react"; +import { getTasks } from "../api"; + +interface ScanMeta { + task_id: string; + tool_name: string; + target: string; + status: string; + created_at: string; + duration_seconds?: number; +} + +interface Props { + onSelect: (taskId: string) => void; + activeTaskId?: string; +} + +export function ScanHistory({ onSelect, activeTaskId }: Props) { + const [history, setHistory] = useState([]); + + useEffect(() => { + const params = new URLSearchParams({ per_page: "20", page: "1" }); + getTasks(params) + .then((data: any) => setHistory(data.tasks || [])) + .catch(console.error); + }, []); + + if (history.length === 0) { + return ( +
+ No past scans found. +
+ ); + } + + return ( +
+

+ Load Past Scan +

+ {history.map((scan) => ( + + ))} +
+ ); +} diff --git a/frontend/src/pages/Findings.tsx b/frontend/src/pages/Findings.tsx index fb9f849b..6af443da 100644 --- a/frontend/src/pages/Findings.tsx +++ b/frontend/src/pages/Findings.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react' import { motion } from 'framer-motion' -import { getFindings } from '../api' +import { getFindings, getTaskResult } from '../api' +import { ScanHistory } from '../components/ScanHistory' import { formatLocaleDate, parseDateSafe, getCurrentTimeZone } from '../utils/date' type RiskFactor = { factor: string @@ -118,6 +119,7 @@ export default function Findings() { const [selectedFindingId, setSelectedFindingId] = useState(null) const [reviewState, setReviewState] = useState({}) const [copiedFindingId, setCopiedFindingId] = useState(null) + const [activeTaskId, setActiveTaskId] = useState() useEffect(() => { setLoading(true) @@ -130,6 +132,17 @@ export default function Findings() { .finally(() => setLoading(false)) }, []) + useEffect(() => { + if (!activeTaskId) return + getTaskResult(activeTaskId) + .then((data: any) => { + const nextFindings = data.findings || [] + setFindings(nextFindings) + setSelectedFindingId(nextFindings[0]?.id ?? null) // reset to first finding of new scan + }) + .catch(console.error) +}, [activeTaskId]) + useEffect(() => { try { const saved = localStorage.getItem('secuscan-finding-review-state') @@ -397,7 +410,11 @@ export default function Findings() { return (
-
+
+ +
Triage Workspace v5.1 @@ -788,7 +805,8 @@ export default function Findings() {
+
) -} +} \ No newline at end of file diff --git a/frontend/testing/unit/AppRoutes.test.tsx b/frontend/testing/unit/AppRoutes.test.tsx index 031ac013..fa0f3be4 100644 --- a/frontend/testing/unit/AppRoutes.test.tsx +++ b/frontend/testing/unit/AppRoutes.test.tsx @@ -3,6 +3,8 @@ import { MemoryRouter, useLocation } from 'react-router-dom' import { AppRoutes } from '../../src/App' vi.mock('../../src/api', () => ({ + getTasks: vi.fn(() => Promise.resolve({ tasks: [] })), + getTaskResult: vi.fn(() => Promise.resolve({ findings: [] })), getHealth: vi.fn().mockResolvedValue({ status: 'operational' }), getDashboardSummary: vi.fn().mockResolvedValue({ total_findings: 0, diff --git a/frontend/testing/unit/pages/Findings.test.tsx b/frontend/testing/unit/pages/Findings.test.tsx index 561ae086..c4731a60 100644 --- a/frontend/testing/unit/pages/Findings.test.tsx +++ b/frontend/testing/unit/pages/Findings.test.tsx @@ -2,10 +2,12 @@ import { render, screen, waitFor, within, fireEvent } from '@testing-library/rea import userEvent from '@testing-library/user-event' import { MemoryRouter } from 'react-router-dom' import Findings from '../../../src/pages/Findings' -import { getFindings } from '../../../src/api' +import { getFindings, getTasks, getTaskResult } from '../../../src/api' import * as dateUtils from '../../../src/utils/date' vi.mock('../../../src/api', () => ({ + getTasks: vi.fn(() => Promise.resolve({ tasks: [] })), + getTaskResult: vi.fn(() => Promise.resolve({ findings: [] })), getFindings: vi.fn(), API_BASE: 'http://127.0.0.1:8000', })) @@ -97,7 +99,7 @@ describe('Findings — loading state', () => { // ── Severity filter ─────────────────────────────────────────────────────────── describe('Findings — severity filtering', () => { - beforeEach(() => { + beforeEach(() => { vi.mocked(getFindings).mockResolvedValue({ findings: allFindings }) }) @@ -578,3 +580,89 @@ describe('Findings — risk score display', () => { expect(screen.queryByText('Risk Score')).not.toBeInTheDocument() }) }) +// ── ScanHistory — selecting a scan loads its findings ───────────────────────── + +describe('Findings — ScanHistory scan selection', () => { + const pastTasks = [ + { + task_id: 'task-abc-1', + tool_name: 'sqlmap', + target: 'old.example.com', + status: 'completed', + created_at: '2026-05-10T09:00:00Z', + }, + { + task_id: 'task-abc-2', + tool_name: 'zap', + target: 'other.example.com', + status: 'completed', + created_at: '2026-05-11T12:00:00Z', + }, + ] + + const taskFindings = [ + { + id: 'task-finding-1', + severity: 'high', + category: 'xss', + title: 'XSS From Past Scan', + target: 'old.example.com', + description: 'Found in historical scan.', + remediation: 'Sanitize input.', + discovered_at: '2026-05-10T09:00:00Z', + plugin_id: 'sqlmap', + }, + ] + + beforeEach(() => { + vi.mocked(getFindings).mockResolvedValue({ findings: allFindings }) + vi.mocked(getTasks).mockResolvedValue({ tasks: pastTasks }) + vi.mocked(getTaskResult).mockResolvedValue({ findings: taskFindings }) + }) + + it('renders past scans in the sidebar', async () => { + renderFindings() + await waitForLoad() + + await waitFor(() => { + expect(screen.getByText('old.example.com')).toBeInTheDocument() + expect(screen.getByText('other.example.com')).toBeInTheDocument() + }) + }) + + it('loads findings for the selected scan when a sidebar entry is clicked', async () => { + const user = userEvent.setup() + renderFindings() + await waitForLoad() + + await waitFor(() => { + expect(screen.getByText('old.example.com')).toBeInTheDocument() + }) + + await user.click(screen.getByText('old.example.com')) + + await waitFor(() => { + expect(screen.getAllByText('XSS From Past Scan').length).toBeGreaterThanOrEqual(1) + }) + + // Original findings should be replaced + expect(screen.queryByText('SQL Injection in Login')).not.toBeInTheDocument() + }) + + it('highlights the active scan in the sidebar after selection', async () => { + const user = userEvent.setup() + renderFindings() + await waitForLoad() + + await waitFor(() => { + expect(screen.getByText('old.example.com')).toBeInTheDocument() + }) + + const scanButton = screen.getByText('old.example.com').closest('button')! + await user.click(scanButton) + + await waitFor(() => { + expect(scanButton.className).toContain('border-rag-red') + }) + }) +}) \ No newline at end of file