diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 90a66524..6267a517 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -143,7 +143,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -505,7 +504,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -554,7 +552,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -564,7 +561,6 @@ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "license": "MIT", - "peer": true, "dependencies": { "@emotion/memoize": "^0.9.0" } @@ -1670,7 +1666,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -1793,7 +1790,6 @@ "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.21.0" } @@ -1824,7 +1820,6 @@ "integrity": "sha512-WeqMfGJLGuLCqHGYRGHxnKrXcTitc6L/nBUWfWPcTarG3t9PsquqUMuVeXZeca+mglY4Vo5GZjCi0A3Or2lnxA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -1836,7 +1831,6 @@ "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/react": "*" } @@ -1967,6 +1961,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -1977,6 +1972,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -2147,7 +2143,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2642,7 +2637,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dompurify": { "version": "3.4.2", @@ -3090,7 +3086,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -3259,6 +3254,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -3551,7 +3547,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3699,6 +3694,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -3713,7 +3709,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/punycode": { "version": "2.3.1", @@ -3761,7 +3758,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3774,7 +3770,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -3804,7 +3799,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -3935,8 +3929,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -4432,7 +4425,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4657,7 +4649,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -5294,7 +5285,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5846,7 +5836,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f4f6b39f..924be380 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,7 +10,7 @@ import Settings from './pages/Settings' import Scans from './pages/Scans' import TaskDetails from './pages/TaskDetails' import Workflows from './pages/Workflows' - +import AssetInventory from './pages/AssetInventory' import { ThemeProvider } from './components/ThemeContext' import { ToastProvider, ToastContainer } from './components/ToastContext' import { I18nProvider } from './components/I18nContext' @@ -26,7 +26,7 @@ export function AppRoutes() { } /> } /> } /> - } /> + } /> } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index a683815a..29be161a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -150,7 +150,9 @@ export function startTask(plugin_id: string, inputs: Record, co body: JSON.stringify({ plugin_id, inputs, consent_granted, preset }), }) } - +export function getAssets() { + return request('/assets') +} export function deleteTask(taskId: string) { return request<{ task_id: string; deleted: boolean }>(`/task/${taskId}`, { method: 'DELETE', diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 1c73e91b..8c6e59c4 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -54,7 +54,7 @@ export default function AppShell({ children }: AppShellProps) { { to: routes.reports, label: 'Reports' }, { to: routes.workflows, label: 'Workflows' }, { to: routes.toolkit, label: 'Toolkit' }, - { to: routes.settings, label: 'Settings' }, + { to: routes.inventory, label: 'Inventory' }, ] 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/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 99bf5e2b..cc6d692f 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -164,7 +164,7 @@ export default function Sidebar() { - + diff --git a/frontend/src/pages/AssetInventory.jsx b/frontend/src/pages/AssetInventory.jsx new file mode 100644 index 00000000..993cefe5 --- /dev/null +++ b/frontend/src/pages/AssetInventory.jsx @@ -0,0 +1,275 @@ +import { useState, useMemo, useEffect } from "react"; +import { getAssets } from "../api"; + +const SEV_ORDER = { critical: 0, high: 1, medium: 2, low: 3, none: 4 }; +const REF_DATE = new Date("2024-07-31"); + +const SEV_STYLES = { + critical: { bg: "#FCEBEB", color: "#A32D2D", label: "CRIT" }, + high: { bg: "#FAEEDA", color: "#854F0B", label: "HIGH" }, + medium: { bg: "#EAF3DE", color: "#3B6D11", label: "MED" }, + low: { bg: "#F1EFE8", color: "#5F5E5A", label: "LOW" }, + none: { bg: "#F1EFE8", color: "#888780", label: "—" }, +}; + +function relTime(d) { + const days = Math.floor((REF_DATE - new Date(d)) / 86400000); + if (days === 0) return "today"; + if (days === 1) return "1d ago"; + if (days < 30) return `${days}d ago`; + return `${Math.floor(days / 30)}mo ago`; +} + +function SevBadge({ severity }) { + const s = SEV_STYLES[severity] || SEV_STYLES.none; + return ( + {s.label} + ); +} + +function PortTag({ port }) { + return ( + {port} + ); +} + +function TagPill({ tag }) { + return ( + {tag} + ); +} + +export default function AssetInventory() { + const [assets, setAssets] = useState([]); +const [loading, setLoading] = useState(true); +const [error, setError] = useState(null); + +useEffect(() => { + getAssets() + .then(setAssets) + .catch(setError) + .finally(() => setLoading(false)); +}, []); + const [query, setQuery] = useState(""); + const [sevFilter, setSevFilter] = useState(""); + const [scannerFilter, setScannerFilter] = useState(""); + const [sortCol, setSortCol] = useState("host"); + const [selected, setSelected] = useState(null); + + const filtered = useMemo(() => { + const q = query.toLowerCase(); + let list = assets.filter(a => { + const matchQ = !q || a.host.includes(q) || a.ip.includes(q) + || a.ports.some(p => p.includes(q)) + || a.tags.some(t => t.includes(q)) + || a.scanner.includes(q); + const matchSev = !sevFilter || a.severity === sevFilter; + const matchScanner = !scannerFilter || a.scanner === scannerFilter; + return matchQ && matchSev && matchScanner; + }); + list = [...list].sort((a, b) => { + if (sortCol === "findings") return b.findings - a.findings; + if (sortCol === "severity") return SEV_ORDER[a.severity] - SEV_ORDER[b.severity]; + if (sortCol === "last_seen") return new Date(b.last) - new Date(a.last); + return a.host.localeCompare(b.host); + }); + return list; + }, [query, sevFilter, scannerFilter, sortCol]); + + const totalFindings = assets.reduce((s, a) => s + a.findings, 0); +const criticalCount = assets.filter(a => a.severity === "critical").length; +const totalPorts = new Set(assets.flatMap(a => a.ports)).size; + + const inputStyle = { + padding: "7px 10px", fontSize: 12, fontFamily: "monospace", + border: "0.5px solid #ccc", borderRadius: 6, + background: "#fff", color: "#111", outline: "none", + }; + + return ( +
+ + {/* Header */} +
+
+ + SecuScan / Asset Inventory + +
+ {filtered.length} asset{filtered.length !== 1 ? "s" : ""} found +
+
+
+ + +
+
+ + {/* Stat strip */} +
+ {[ + { label: "Total hosts", val: assets.length, sub: "across 3 scans" }, + { label: "Unique ports", val: totalPorts, sub: "open services" }, + { label: "Findings", val: totalFindings, sub: `${criticalCount} critical`, subColor: "#A32D2D" }, + { label: "Last scanned", val: "2h ago", sub: "scan #2024-07-31", num: false }, + ].map((s, i) => ( +
+
{s.label}
+
{s.val}
+
{s.sub}
+
+ ))} +
+ + {/* Filters */} +
+
+ + setQuery(e.target.value)} + placeholder="Filter by host, IP, port, tag…" + style={{ ...inputStyle, width: "100%", paddingLeft: 28 }} + /> +
+ {[ + { id: "sev", val: sevFilter, set: setSevFilter, opts: ["critical","high","medium","low","none"], placeholder: "All severities" }, + { id: "scn", val: scannerFilter, set: setScannerFilter, opts: ["nmap","nuclei","subfinder","httpx"], placeholder: "All scanners" }, + ].map(f => ( + + ))} + +
+ + {/* Table */} +
+ + + + {[ + ["Host / IP", "24%"], + ["Open ports", "20%"], + ["Scanner", "12%"], + ["Findings", "10%"], + ["Severity", "11%"], + ["First seen", "11%"], + ["Last seen", "12%"], + ].map(([h, w]) => ( + + ))} + + + + {filtered.length === 0 ? ( + + ) : filtered.map((a, i) => ( + setSelected(selected === a.host ? null : a.host)} + style={{ + borderBottom: i < filtered.length - 1 ? "0.5px solid #e8e8e4" : "none", + background: selected === a.host ? "#f5f5f0" : "#fff", + cursor: "pointer", + transition: "background 0.1s", + }} + onMouseEnter={e => { if (selected !== a.host) e.currentTarget.style.background = "#f9f9f6"; }} + onMouseLeave={e => { if (selected !== a.host) e.currentTarget.style.background = "#fff"; }} + > + + + + + + + + + ))} + +
+ {h} +
No assets match your filters.
+
{a.host}
+
{a.ip}
+
{a.tags.map(t => )}
+
+
+ {a.ports.slice(0, 4).map(p => )} + {a.ports.length > 4 && } +
+
{a.scanner} 0 ? "#111" : "#ccc" }}> + {a.findings > 0 ? a.findings : "—"} + {relTime(a.first)}{relTime(a.last)}
+
+ + {/* Detail panel */} + {selected && (() => { + const a = assets.find(x => x.host === selected); + if (!a) return null; + const s = SEV_STYLES[a.severity]; + return ( +
+
+
+
{a.host}
+
{a.ip}
+
+ +
+
+
+
All ports
+
{a.ports.map(p => )}
+
+
+
Tags
+
{a.tags.map(t => )}
+
+
+
Highest severity
+ +
+
+
Scanner / task
+ {a.scanner} +
+
+
Findings
+ {a.findings} linked +
+
+
Timeline
+ First {relTime(a.first)} · Last {relTime(a.last)} +
+
+
+ ); + })()} + +
+ ); +} \ No newline at end of file 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/src/routes.ts b/frontend/src/routes.ts index 38caa98f..653c6710 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -6,7 +6,7 @@ export const routes = { scans: '/scans', reports: '/reports', workflows: '/workflows', - settings: '/settings', + inventory: '/inventory', task: '/task/:taskId', } as const 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