From 8d3b20e00a4b652164028d6f63364e56750514ba Mon Sep 17 00:00:00 2001 From: Khatija Fatima Date: Fri, 29 May 2026 04:25:44 +0530 Subject: [PATCH] feat: saved views frontend hook, panel, and Findings integration (#235) --- frontend/src/components/SavedViewsPanel.tsx | 366 +++++++++++++++ frontend/src/hooks/useSavedViews.ts | 298 ++++++++++++ frontend/src/pages/Findings.tsx | 402 ++++++++++++---- .../testing/unit/hooks/useSavedViews.test.ts | 409 ++++++++++++++++ frontend/testing/unit/pages/Findings.test.tsx | 443 ++++++++++++++++++ 5 files changed, 1815 insertions(+), 103 deletions(-) create mode 100644 frontend/src/components/SavedViewsPanel.tsx create mode 100644 frontend/src/hooks/useSavedViews.ts create mode 100644 frontend/testing/unit/hooks/useSavedViews.test.ts create mode 100644 frontend/testing/unit/pages/Findings.test.tsx diff --git a/frontend/src/components/SavedViewsPanel.tsx b/frontend/src/components/SavedViewsPanel.tsx new file mode 100644 index 00000000..cff7dcbf --- /dev/null +++ b/frontend/src/components/SavedViewsPanel.tsx @@ -0,0 +1,366 @@ +import React, { useRef, useState } from 'react' +import { AnimatePresence, motion } from 'framer-motion' +import { FilterPreset, SavedView, UseSavedViewsReturn } from '../hooks/useSavedViews' + + +interface Props extends UseSavedViewsReturn { + currentPreset: FilterPreset + onApply: (preset: FilterPreset) => void +} + + +function formatRelative(iso: string): string { + try { + const diff = Date.now() - new Date(iso).getTime() + const minutes = Math.floor(diff / 60_000) + if (minutes < 1) return 'just now' + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + return `${Math.floor(hours / 24)}d ago` + } catch { + return '' + } +} + +function presetSummary(p: FilterPreset): string { + const parts: string[] = [] + if (p.severity !== 'all') parts.push(p.severity.toUpperCase()) + if (p.target !== 'all') parts.push(p.target) + if (p.scanner !== 'all') parts.push(p.scanner) + if (p.sortMode !== 'severity') parts.push(`sort:${p.sortMode}`) + if (p.dateFrom) parts.push(`from:${p.dateFrom}`) + if (p.dateTo) parts.push(`to:${p.dateTo}`) + if (p.searchQuery) parts.push(`"${p.searchQuery}"`) + return parts.length ? parts.join(' · ') : 'All findings' +} + + +interface ViewRowProps { + view: SavedView + onApply: () => void + onRename: (name: string) => void + onDelete: () => void +} + +function ViewRow({ view, onApply, onRename, onDelete }: ViewRowProps) { + const [editing, setEditing] = useState(false) + const [editName, setEditName] = useState(view.name) + const [confirmDelete, setConfirmDelete] = useState(false) + const inputRef = useRef(null) + + function commitRename() { + const trimmed = editName.trim() + if (trimmed && trimmed !== view.name) { + onRename(trimmed) + } else { + setEditName(view.name) + } + setEditing(false) + } + + return ( + +
+ {/* Apply button + name */} + + + {/* Action icons — visible on hover */} + {!editing && ( +
+ {/* Rename */} + + + + {confirmDelete ? ( + + ) : ( + + )} +
+ )} +
+
+ ) +} + + +export default function SavedViewsPanel({ + views, + loading, + saveView, + deleteView, + renameView, + currentPreset, + onApply, +}: Props) { + const [open, setOpen] = useState(false) + const [saveName, setSaveName] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [successMsg, setSuccessMsg] = useState(null) + + async function handleSave() { + const trimmed = saveName.trim() + if (!trimmed) { + setError('Enter a name for this view') + return + } + setSaving(true) + setError(null) + try { + const saved = await saveView(trimmed, currentPreset) + const isOverwrite = views.some( + (v) => v.name.toLowerCase() === trimmed.toLowerCase(), + ) + setSuccessMsg( + isOverwrite ? `Updated "${saved.name}"` : `Saved "${saved.name}"`, + ) + setSaveName('') + setTimeout(() => setSuccessMsg(null), 2000) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Failed to save view') + } finally { + setSaving(false) + } + } + + const panelVariants = { + hidden: { opacity: 0, y: -8, scaleY: 0.96 }, + visible: { opacity: 1, y: 0, scaleY: 1, transition: { duration: 0.18, ease: 'easeOut' as const } }, + exit: { opacity: 0, y: -6, scaleY: 0.97, transition: { duration: 0.12, ease: 'easeOut' as const } }, + } + + return ( +
+ + + + + + {open && ( + + {/* Header */} +
+

+ Filter_Presets +

+ +
+ +
+

+ Save Current Filters +

+
+ { + setSaveName(e.target.value) + setError(null) + }} + onKeyDown={(e) => e.key === 'Enter' && handleSave()} + placeholder="Name this view…" + maxLength={60} + aria-label="Saved view name" + className="flex-1 border-2 border-silver-bright/10 bg-charcoal-dark px-3 py-2 text-[10px] font-mono text-silver-bright placeholder:text-silver/25 focus:border-rag-blue focus:outline-none" + /> + +
+ + + {error && ( + + ⚠ {error} + + )} + {successMsg && ( + + ✓ {successMsg} + + )} + +
+ +
+ {loading ? ( +

+ Loading presets… +

+ ) : views.length === 0 ? ( +
+

+ No Saved Views +

+

+ Configure filters then save above. +

+
+ ) : ( + + {views.map((view) => ( + { + onApply(view.preset) + setOpen(false) + }} + onRename={(name) => renameView(view.id, name)} + onDelete={() => deleteView(view.id)} + /> + ))} + + )} +
+ + {views.length > 0 && ( +
+

+ Click a preset to apply · Hover to rename or delete +

+
+ )} +
+ )} +
+
+ ) +} diff --git a/frontend/src/hooks/useSavedViews.ts b/frontend/src/hooks/useSavedViews.ts new file mode 100644 index 00000000..1e2eddec --- /dev/null +++ b/frontend/src/hooks/useSavedViews.ts @@ -0,0 +1,298 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { API_BASE } from '../api' + +export interface FilterPreset { + severity: string + target: string + scanner: string + sortMode: string + dateFrom: string + dateTo: string + searchQuery: string +} + +export interface SavedView { + id: string + name: string + preset: FilterPreset + createdAt: string // ISO string + updatedAt: string // ISO string +} + +// Pydantic-shaped payload the backend expects. +interface BackendPayload { + name: string + filter_json: string +} + +interface BackendRow { + id: string + name: string + filter_json: string + created_at: string + updated_at: string +} + +const VALID_SORT_MODES = ['severity', 'newest', 'oldest', 'target'] as const +const VALID_SEVERITIES = ['all', 'critical', 'high', 'medium', 'low', 'info'] as const + +/** Returns true when obj looks like a real FilterPreset (not garbage data). */ +export function isValidPreset(obj: unknown): obj is FilterPreset { + if (!obj || typeof obj !== 'object') return false + const p = obj as Record + if (typeof p.severity !== 'string') return false + if (typeof p.target !== 'string') return false + if (typeof p.scanner !== 'string') return false + if (typeof p.sortMode !== 'string') return false + if (typeof p.dateFrom !== 'string') return false + if (typeof p.dateTo !== 'string') return false + if (typeof p.searchQuery !== 'string') return false + if (!(VALID_SORT_MODES as readonly string[]).includes(p.sortMode)) return false + if (!(VALID_SEVERITIES as readonly string[]).includes(p.severity)) return false + return true +} + +export function isValidSavedView(obj: unknown): obj is SavedView { + if (!obj || typeof obj !== 'object') return false + const v = obj as Record + if (typeof v.id !== 'string' || !v.id) return false + if (typeof v.name !== 'string' || !v.name) return false + if (typeof v.createdAt !== 'string') return false + if (typeof v.updatedAt !== 'string') return false + return isValidPreset(v.preset) +} + + +const LS_KEY = 'secuscan-saved-views' + +function readFromStorage(): SavedView[] { + try { + const raw = localStorage.getItem(LS_KEY) + if (!raw) return [] + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter(isValidSavedView) + } catch { + return [] + } +} + +function writeToStorage(views: SavedView[]): void { + try { + localStorage.setItem(LS_KEY, JSON.stringify(views)) + } catch { + // Storage quota exceeded — silently ignore. + } +} + + +function rowToView(row: BackendRow): SavedView | null { + try { + const preset: unknown = JSON.parse(row.filter_json) + if (!isValidPreset(preset)) return null + return { + id: row.id, + name: row.name, + preset, + createdAt: row.created_at, + updatedAt: row.updated_at, + } + } catch { + return null + } +} + +/** + * Backend sync failure behaviour + * ───────────────────────────── + * All backend calls are fire-and-forget with a hard 8-second timeout. + * On any network error, non-2xx response, or timeout: + * + * • The optimistic local state (already written to localStorage) is + * kept as-is — the user never sees an error for a backend outage. + * • `backendAvailable` stays false so subsequent mutations skip the + * network entirely rather than hammering an unreachable server. + * • On the next page load the hook retries the backend hydration; if + * it succeeds, remote state is merged (remote wins on timestamp). + * • There is intentionally no retry queue — SecuScan is local-first + * and localStorage is the source of truth. The backend is a + * convenience sync layer, not a required dependency. + * + * Callers (SavedViewsPanel) can rely on the returned Promise always + * resolving — it never rejects due to a backend failure. + */ +async function apiFetch( + path: string, + init?: RequestInit, +): Promise { + try { + const res = await fetch(`${API_BASE}${path}`, { + ...init, + signal: AbortSignal.timeout(8000), + }) + if (!res.ok) return null + return (await res.json()) as T + } catch { + return null + } +} + + +export interface UseSavedViewsReturn { + views: SavedView[] + loading: boolean + /** Save a new preset or overwrite an existing one (matched by name). */ + saveView: (name: string, preset: FilterPreset) => Promise + /** Delete by id. */ + deleteView: (id: string) => Promise + /** Rename an existing view. */ + renameView: (id: string, newName: string) => Promise +} + +export function useSavedViews(): UseSavedViewsReturn { + const [views, setViews] = useState([]) + const [loading, setLoading] = useState(true) + // Track whether we managed to hydrate from the backend at least once. + const backendAvailable = useRef(false) + + // ── Mount: prefer backend, fall back to localStorage ────────────────────── + useEffect(() => { + let cancelled = false + + async function hydrate() { + // Try backend first + const data = await apiFetch<{ views: BackendRow[] }>('/saved-views') + if (!cancelled) { + if (data && Array.isArray(data.views)) { + const parsed = data.views.map(rowToView).filter(Boolean) as SavedView[] + backendAvailable.current = true + setViews(parsed) + writeToStorage(parsed) // keep local in sync + } else { + // Backend unreachable — use localStorage + setViews(readFromStorage()) + } + setLoading(false) + } + } + + hydrate() + return () => { cancelled = true } + }, []) + + + const syncSet = useCallback((next: SavedView[]) => { + setViews(next) + writeToStorage(next) + }, []) + + const saveView = useCallback( + async (name: string, preset: FilterPreset): Promise => { + const trimmed = name.trim() + if (!trimmed) throw new Error('View name cannot be empty') + if (!isValidPreset(preset)) throw new Error('Invalid filter preset') + + // Check whether we're overwriting an existing name + const existing = views.find( + (v) => v.name.toLowerCase() === trimmed.toLowerCase(), + ) + + const now = new Date().toISOString() + + if (existing) { + const updated: SavedView = { ...existing, preset, updatedAt: now } + const next = views.map((v) => (v.id === existing.id ? updated : v)) + syncSet(next) + + // Backend sync (optimistic, fire-and-forget) + if (backendAvailable.current) { + const payload: BackendPayload = { + name: trimmed, + filter_json: JSON.stringify(preset), + } + apiFetch(`/saved-views/${existing.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + } + return updated + } + + const tempId = `local-${Date.now()}-${Math.random().toString(36).slice(2)}` + const created: SavedView = { + id: tempId, + name: trimmed, + preset, + createdAt: now, + updatedAt: now, + } + + const next = [...views, created] + syncSet(next) + + if (backendAvailable.current) { + const payload: BackendPayload = { + name: trimmed, + filter_json: JSON.stringify(preset), + } + const result = await apiFetch<{ id: string }>('/saved-views', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + if (result?.id) { + const withRealId: SavedView = { ...created, id: result.id } + const finalNext = next.map((v) => (v.id === tempId ? withRealId : v)) + syncSet(finalNext) + return withRealId + } + } + + return created + }, + [views, syncSet], + ) + + const deleteView = useCallback( + async (id: string): Promise => { + syncSet(views.filter((v) => v.id !== id)) + + if (backendAvailable.current && !id.startsWith('local-')) { + apiFetch(`/saved-views/${id}`, { method: 'DELETE' }) + } + }, + [views, syncSet], + ) + + const renameView = useCallback( + async (id: string, newName: string): Promise => { + const trimmed = newName.trim() + if (!trimmed) throw new Error('Name cannot be empty') + + const now = new Date().toISOString() + const next = views.map((v) => + v.id === id ? { ...v, name: trimmed, updatedAt: now } : v, + ) + syncSet(next) + + if (backendAvailable.current && !id.startsWith('local-')) { + const target = views.find((v) => v.id === id) + if (target) { + const payload: BackendPayload = { + name: trimmed, + filter_json: JSON.stringify(target.preset), + } + apiFetch(`/saved-views/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + } + } + }, + [views, syncSet], + ) + + return { views, loading, saveView, deleteView, renameView } +} \ No newline at end of file diff --git a/frontend/src/pages/Findings.tsx b/frontend/src/pages/Findings.tsx index 74e7e811..fa191cd8 100644 --- a/frontend/src/pages/Findings.tsx +++ b/frontend/src/pages/Findings.tsx @@ -1,7 +1,9 @@ import React, { useEffect, useMemo, useState } from 'react' import { motion } from 'framer-motion' import { getFindings } from '../api' -import { formatLocaleDate } from '../utils/date' +import { formatLocaleDate, parseDateSafe, getCurrentTimeZone } from '../utils/date' +import SavedViewsPanel from '../components/SavedViewsPanel' +import { useSavedViews, FilterPreset } from '../hooks/useSavedViews' type Finding = { id: string @@ -14,6 +16,7 @@ type Finding = { discovered_at: string cvss?: number cve?: string + plugin_id?: string } type FindingStatus = 'new' | 'reviewed' | 'suppressed' @@ -84,15 +87,49 @@ function filterPillClasses(isActive: boolean) { : 'border-silver-bright/10 bg-charcoal-dark text-silver/65 hover:border-silver-bright/30 hover:text-silver-bright' } +const filterLabelClass = 'text-[10px] font-black uppercase tracking-[0.2em] text-silver-bright' +const filterControlClass = + 'h-11 w-full border-2 border-silver-bright/10 bg-charcoal-dark px-3 text-xs font-mono text-silver-bright focus:border-rag-red focus:outline-none' + +type SortMode = 'severity' | 'newest' | 'oldest' | 'target' + export default function Findings() { const [findings, setFindings] = useState([]) const [loading, setLoading] = useState(true) const [searchQuery, setSearchQuery] = useState('') const [filterSeverity, setFilterSeverity] = useState('all') + const [filterTarget, setFilterTarget] = useState('all') + const [filterScanner, setFilterScanner] = useState('all') + const [sortMode, setSortMode] = useState('severity') + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') const [selectedFindingId, setSelectedFindingId] = useState(null) const [reviewState, setReviewState] = useState({}) const [copiedFindingId, setCopiedFindingId] = useState(null) + // ── Saved views ──────────────────────────────────────────────────────────── + const { views, loading: viewsLoading, saveView, deleteView, renameView } = useSavedViews() + + const currentPreset: FilterPreset = { + severity: filterSeverity, + target: filterTarget, + scanner: filterScanner, + sortMode, + dateFrom, + dateTo, + searchQuery, + } + + function applyPreset(preset: FilterPreset) { + setFilterSeverity(preset.severity) + setFilterTarget(preset.target) + setFilterScanner(preset.scanner) + setSortMode(preset.sortMode as SortMode) + setDateFrom(preset.dateFrom) + setDateTo(preset.dateTo) + setSearchQuery(preset.searchQuery) + } + useEffect(() => { setLoading(true) getFindings() @@ -129,11 +166,48 @@ export default function Findings() { [findings, reviewState], ) + // Collect unique targets and categories so we can build filter dropdowns. + const uniqueTargets = useMemo(() => { + const seen = new Set() + for (const f of enrichedFindings) { + if (f.target) seen.add(f.target) + } + return Array.from(seen).sort() + }, [enrichedFindings]) + + // plugin_id values serve as the "scanner/tool" filter per issue #43 + const uniqueScanners = useMemo(() => { + const seen = new Set() + for (const f of enrichedFindings) { + if (f.plugin_id) seen.add(f.plugin_id) + } + return Array.from(seen).sort() + }, [enrichedFindings]) + const filteredFindings = useMemo(() => { const query = searchQuery.trim().toLowerCase() + // Compare dates using the *displayed* calendar day in the user's configured + // timezone, not raw UTC timestamps. This way a finding at 2026-05-13T20:00:00Z + // that shows as May 14 in IST correctly matches a From Date of 2026-05-14. + const tz = getCurrentTimeZone() + const dateFormatter = new Intl.DateTimeFormat('en-CA', { timeZone: tz }) + return enrichedFindings.filter((finding) => { const matchesSeverity = filterSeverity === 'all' || finding.severity === filterSeverity + const matchesTarget = filterTarget === 'all' || finding.target === filterTarget + const matchesScanner = filterScanner === 'all' || finding.plugin_id === filterScanner + + // Date range check — derive the calendar day in the display timezone + if (dateFrom || dateTo) { + const parsed = parseDateSafe(finding.discovered_at) + if (!parsed) return false + // en-CA locale gives us YYYY-MM-DD which matches the value + const displayDay = dateFormatter.format(parsed) + if (dateFrom && displayDay < dateFrom) return false + if (dateTo && displayDay > dateTo) return false + } + const haystack = [ finding.title, finding.target, @@ -146,17 +220,43 @@ export default function Findings() { .join(' ') .toLowerCase() - return matchesSeverity && haystack.includes(query) + return matchesSeverity && matchesTarget && matchesScanner && haystack.includes(query) }) - }, [enrichedFindings, filterSeverity, searchQuery]) + }, [enrichedFindings, filterSeverity, filterTarget, filterScanner, searchQuery, dateFrom, dateTo]) + + const sortedFindings = useMemo(() => { + const items = [...filteredFindings] + switch (sortMode) { + case 'newest': + return items.sort((a, b) => { + const da = parseDateSafe(a.discovered_at)?.getTime() ?? 0 + const db = parseDateSafe(b.discovered_at)?.getTime() ?? 0 + return db - da + }) + case 'oldest': + return items.sort((a, b) => { + const da = parseDateSafe(a.discovered_at)?.getTime() ?? 0 + const db = parseDateSafe(b.discovered_at)?.getTime() ?? 0 + return da - db + }) + case 'target': + return items.sort((a, b) => + (a.target || '').localeCompare(b.target || '') + ) + case 'severity': + default: + // Keep the original severity-group ordering; groupedFindings handles it. + return items + } + }, [filteredFindings, sortMode]) const groupedFindings = useMemo( () => severityOrder.map((severity) => ({ severity, - items: filteredFindings.filter((finding) => finding.severity === severity), + items: sortedFindings.filter((finding) => finding.severity === severity), })), - [filteredFindings], + [sortedFindings], ) const selectedFinding = @@ -219,6 +319,70 @@ export default function Findings() { } } + function renderFindingRow(finding: Finding & { severity: string; status: FindingStatus }) { + const isSelected = selectedFinding?.id === finding.id + const cfg = severityConfig[finding.severity] + + return ( + + ) + } + return (
@@ -255,59 +419,136 @@ export default function Findings() {
-
-
-
+
+
+
- + setSearchQuery(event.target.value)} placeholder="Title, target, CVE, remediation..." - className="w-full border-2 border-silver-bright/10 bg-charcoal-dark px-4 py-3 text-xs font-mono text-silver-bright placeholder:text-silver/20 focus:border-rag-red focus:outline-none" + className={`${filterControlClass} px-4 placeholder:text-silver/20`} />
-
- -
+
+ + {severityOrder.map((severity) => ( - {['critical', 'high', 'medium'].map((severity) => ( - - ))} -
+ ))}
-
- {severityOrder.map((severity) => ( +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + setDateFrom(e.target.value)} + className={`${filterControlClass} [color-scheme:dark]`} + /> +
+ +
+ + setDateTo(e.target.value)} + className={`${filterControlClass} [color-scheme:dark]`} + /> +
+
+ +
+ - ))} +
@@ -323,7 +564,7 @@ export default function Findings() {

No Findings Match

Adjust filters to reopen the queue.

- ) : ( + ) : sortMode === 'severity' ? ( groupedFindings.map(({ severity, items }) => { if (items.length === 0) return null @@ -342,73 +583,28 @@ export default function Findings() {
- {items.map((finding) => { - const isSelected = selectedFinding?.id === finding.id - const config = severityConfig[finding.severity] - - return ( - - ) - })} + {items.map((finding) => renderFindingRow(finding))}
) }) + ) : ( +
+
+
+ +
+

+ {sortMode === 'newest' ? 'Newest First' : sortMode === 'oldest' ? 'Oldest First' : 'By Target'} +

+

{sortedFindings.length} visible in queue

+
+
+
+
+ {sortedFindings.map((finding) => renderFindingRow(finding))} +
+
)} @@ -526,4 +722,4 @@ export default function Findings() { ) -} +} \ No newline at end of file diff --git a/frontend/testing/unit/hooks/useSavedViews.test.ts b/frontend/testing/unit/hooks/useSavedViews.test.ts new file mode 100644 index 00000000..d2ead794 --- /dev/null +++ b/frontend/testing/unit/hooks/useSavedViews.test.ts @@ -0,0 +1,409 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + FilterPreset, + isValidPreset, + isValidSavedView, + useSavedViews, +} from '../../../src/hooks/useSavedViews' + + +const mockFetch = vi.fn(() => Promise.reject(new Error('Network offline'))) +vi.stubGlobal('fetch', mockFetch) + +const storage: Record = {} +const localStorageMock = { + getItem: (k: string) => storage[k] ?? null, + setItem: (k: string, v: string) => { + storage[k] = v + }, + removeItem: (k: string) => { + delete storage[k] + }, + clear: () => { + Object.keys(storage).forEach((k) => delete storage[k]) + }, +} +vi.stubGlobal('localStorage', localStorageMock) + + +const VALID_PRESET: FilterPreset = { + severity: 'critical', + target: 'example.com', + scanner: 'nmap', + sortMode: 'newest', + dateFrom: '2025-01-01', + dateTo: '2025-12-31', + searchQuery: 'port scan', +} + +const ALL_PRESET: FilterPreset = { + severity: 'all', + target: 'all', + scanner: 'all', + sortMode: 'severity', + dateFrom: '', + dateTo: '', + searchQuery: '', +} + + +beforeEach(() => { + localStorageMock.clear() + mockFetch.mockReset() + mockFetch.mockRejectedValue(new Error('Network offline')) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + + +describe('isValidPreset', () => { + it('accepts a complete valid preset', () => { + expect(isValidPreset(VALID_PRESET)).toBe(true) + }) + + it('accepts the all-defaults preset', () => { + expect(isValidPreset(ALL_PRESET)).toBe(true) + }) + + it('rejects null', () => { + expect(isValidPreset(null)).toBe(false) + }) + + it('rejects a non-object', () => { + expect(isValidPreset('string')).toBe(false) + expect(isValidPreset(42)).toBe(false) + }) + + it('rejects an object missing required fields', () => { + expect(isValidPreset({ severity: 'all' })).toBe(false) + }) + + it('rejects invalid sortMode', () => { + expect(isValidPreset({ ...VALID_PRESET, sortMode: 'by_moon_phase' })).toBe(false) + }) + + it('rejects invalid severity', () => { + expect(isValidPreset({ ...VALID_PRESET, severity: 'apocalyptic' })).toBe(false) + }) +}) + + +describe('isValidSavedView', () => { + const view = { + id: 'abc-123', + name: 'My View', + preset: VALID_PRESET, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + + it('accepts a complete valid view', () => { + expect(isValidSavedView(view)).toBe(true) + }) + + it('rejects a view with a missing id', () => { + expect(isValidSavedView({ ...view, id: '' })).toBe(false) + }) + + it('rejects a view with an invalid preset', () => { + expect(isValidSavedView({ ...view, preset: { severity: 'bad' } })).toBe(false) + }) + + it('rejects a non-object', () => { + expect(isValidSavedView(null)).toBe(false) + }) +}) + + +describe('useSavedViews — localStorage fallback (no backend)', () => { + it('starts empty when localStorage is empty', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + expect(result.current.views).toHaveLength(0) + }) + + it('creates a new view and returns it', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + let saved: Awaited> | undefined + + await act(async () => { + saved = await result.current.saveView('My Scan', VALID_PRESET) + }) + + expect(saved).toBeDefined() + expect(saved?.name).toBe('My Scan') + expect(saved?.preset).toEqual(VALID_PRESET) + expect(result.current.views).toHaveLength(1) + }) + + it('persists the new view to localStorage', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await result.current.saveView('Persistent', VALID_PRESET) + }) + + const raw = localStorage.getItem('secuscan-saved-views') + expect(raw).not.toBeNull() + + const parsed = JSON.parse(raw!) + expect(parsed).toHaveLength(1) + expect(parsed[0].name).toBe('Persistent') + }) + + it('restores a saved view correctly (apply simulation)', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await result.current.saveView('Apply Me', VALID_PRESET) + }) + + const view = result.current.views[0] + expect(view.preset).toEqual(VALID_PRESET) + expect(view.preset.sortMode).toBe('newest') + expect(view.preset.severity).toBe('critical') + }) + + it('overwrites a view when the same name is saved again', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await result.current.saveView('Repeat', VALID_PRESET) + }) + + const firstId = result.current.views[0].id + + const updatedPreset: FilterPreset = { + ...VALID_PRESET, + severity: 'high', + sortMode: 'oldest', + } + + await act(async () => { + await result.current.saveView('Repeat', updatedPreset) + }) + + expect(result.current.views).toHaveLength(1) + expect(result.current.views[0].id).toBe(firstId) + expect(result.current.views[0].preset.severity).toBe('high') + expect(result.current.views[0].preset.sortMode).toBe('oldest') + }) + + it('overwrite is case-insensitive on name comparison', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await result.current.saveView('Alpha', VALID_PRESET) + }) + + await act(async () => { + await result.current.saveView('alpha', ALL_PRESET) + }) + + expect(result.current.views).toHaveLength(1) + expect(result.current.views[0].preset.severity).toBe('all') + }) + + it('renames a view', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await result.current.saveView('Old Name', VALID_PRESET) + }) + + const id = result.current.views[0].id + + await act(async () => { + await result.current.renameView(id, 'New Name') + }) + + expect(result.current.views[0].name).toBe('New Name') + }) + + it('throws when renaming with an empty string', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await result.current.saveView('Name', VALID_PRESET) + }) + + const id = result.current.views[0].id + + await expect( + act(async () => { + await result.current.renameView(id, ' ') + }), + ).rejects.toThrow() + }) + + it('deletes a view by id', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await result.current.saveView('Delete Me', VALID_PRESET) + }) + + const id = result.current.views[0].id + + await act(async () => { + await result.current.deleteView(id) + }) + + expect(result.current.views).toHaveLength(0) + }) + + it('deleting a non-existent id is a no-op', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await result.current.saveView('Keep', VALID_PRESET) + }) + + await act(async () => { + await result.current.deleteView('ghost-id') + }) + + expect(result.current.views).toHaveLength(1) + }) + + it('deleting one view leaves others intact', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await result.current.saveView('Keep', VALID_PRESET) + }) + + await act(async () => { + await result.current.saveView('Remove', ALL_PRESET) + }) + + const removeId = result.current.views.find((v) => v.name === 'Remove')!.id + + await act(async () => { + await result.current.deleteView(removeId) + }) + + expect(result.current.views).toHaveLength(1) + expect(result.current.views[0].name).toBe('Keep') + }) + + it('throws when saving with an empty name', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await expect( + act(async () => { + await result.current.saveView('', VALID_PRESET) + }), + ).rejects.toThrow() + }) + + it('throws when saving with a whitespace-only name', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await expect( + act(async () => { + await result.current.saveView(' ', VALID_PRESET) + }), + ).rejects.toThrow() + }) + + it('throws when saving with an invalid preset (bad sortMode)', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + const badPreset = { ...VALID_PRESET, sortMode: 'random_order' } as any + + await expect( + act(async () => { + await result.current.saveView('Bad Preset', badPreset) + }), + ).rejects.toThrow() + }) + + it('ignores corrupt localStorage data on mount', async () => { + localStorage.setItem('secuscan-saved-views', 'this is not JSON!!!!') + + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + expect(result.current.views).toHaveLength(0) + }) + + it('filters out invalid saved-view entries from localStorage on mount', async () => { + const mixed = [ + { + id: 'ok', + name: 'Good', + preset: VALID_PRESET, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + { + id: 'bad', + name: 'Bad', + }, + ] + + localStorage.setItem('secuscan-saved-views', JSON.stringify(mixed)) + + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + expect(result.current.views).toHaveLength(1) + expect(result.current.views[0].name).toBe('Good') + }) + + it('supports multiple independent saved views', async () => { + const { result } = renderHook(() => useSavedViews()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + await act(async () => { + await result.current.saveView('View A', VALID_PRESET) + }) + + await act(async () => { + await result.current.saveView('View B', ALL_PRESET) + }) + + await act(async () => { + await result.current.saveView('View C', { ...VALID_PRESET, severity: 'low' }) + }) + + expect(result.current.views).toHaveLength(3) + expect(result.current.views.map((v) => v.name)).toEqual(expect.arrayContaining(['View A', 'View B', 'View C'])) + }) +}) diff --git a/frontend/testing/unit/pages/Findings.test.tsx b/frontend/testing/unit/pages/Findings.test.tsx new file mode 100644 index 00000000..e23589e5 --- /dev/null +++ b/frontend/testing/unit/pages/Findings.test.tsx @@ -0,0 +1,443 @@ +import { render, screen, waitFor, within, fireEvent } from '@testing-library/react' +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 * as dateUtils from '../../../src/utils/date' + +vi.mock('../../../src/api', () => ({ + getFindings: vi.fn(), + API_BASE: 'http://127.0.0.1:8000', +})) + +vi.mock('../../../src/hooks/useSavedViews', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSavedViews: () => ({ + views: [], + loading: false, + saveView: vi.fn(), + deleteView: vi.fn(), + renameView: vi.fn(), + }), + } +}) + +// -- Fixtures ----------------------------------------------------------------- + +const criticalFinding = { + id: 'finding-crit-1', + severity: 'critical', + category: 'injection', + title: 'SQL Injection in Login', + target: 'api.example.com', + description: 'Parameterized queries not used.', + remediation: 'Use prepared statements.', + discovered_at: '2026-05-14T10:00:00Z', + cvss: 9.8, + cve: 'CVE-2026-1234', + plugin_id: 'sqlmap', +} + +const highFinding = { + id: 'finding-high-1', + severity: 'high', + category: 'xss', + title: 'Stored XSS in Comments', + target: 'web.example.com', + description: 'User input rendered without escaping.', + remediation: 'Sanitize output.', + discovered_at: '2026-05-13T08:30:00Z', + cvss: 7.5, + plugin_id: 'zap', +} + +const mediumFinding = { + id: 'finding-med-1', + severity: 'medium', + category: 'misconfiguration', + title: 'Missing Security Headers', + target: 'api.example.com', + description: 'Several headers are absent.', + remediation: 'Add CSP and HSTS headers.', + discovered_at: '2026-05-15T14:00:00Z', + plugin_id: 'nikto', +} + +const allFindings = [criticalFinding, highFinding, mediumFinding] + +// -- Helpers ------------------------------------------------------------------ + +function renderFindings() { + return render( + + + , + ) +} + +async function waitForLoad() { + await waitFor(() => { + expect(screen.getAllByText('SQL Injection in Login').length).toBeGreaterThanOrEqual(1) + }) +} + +function getVisibleTitles() { + return Array.from(document.querySelectorAll('h3')) + .map((el) => el.textContent ?? '') + .filter(Boolean) +} + +// -- Loading ------------------------------------------------------------------ + +describe('Findings - loading state', () => { + it('shows loading text while fetching', () => { + vi.mocked(getFindings).mockReturnValue(new Promise(() => {})) + renderFindings() + expect(screen.getByText(/Synchronizing findings feed/i)).toBeInTheDocument() + }) +}) + +// -- Severity filter ---------------------------------------------------------- + +describe('Findings - severity filtering', () => { + beforeEach(() => { + vi.mocked(getFindings).mockResolvedValue({ findings: allFindings }) + }) + + it('shows all findings by default', async () => { + renderFindings() + await waitForLoad() + expect(screen.getAllByText('Stored XSS in Comments').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('Missing Security Headers').length).toBeGreaterThanOrEqual(1) + }) + + it('filters to critical only when critical pill is clicked', async () => { + const user = userEvent.setup() + renderFindings() + await waitForLoad() + + const critButtons = screen.getAllByRole('button', { name: /critical/i }) + const toggle = critButtons.find((btn) => btn.textContent?.trim().startsWith('Critical')) + expect(toggle).toBeTruthy() + await user.click(toggle!) + + await waitFor(() => { + expect(screen.queryByText('Stored XSS in Comments')).not.toBeInTheDocument() + }) + expect(screen.getAllByText('SQL Injection in Login').length).toBeGreaterThanOrEqual(1) + }) +}) + +// -- Sort options -------------------------------------------------------------- + +describe('Findings - sorting', () => { + beforeEach(() => { + vi.mocked(getFindings).mockResolvedValue({ findings: allFindings }) + }) + + function getSortSelect() { + return screen.getByDisplayValue(/Severity \(High → Low\)/i) + } + + it('sort controls contain all expected options', async () => { + renderFindings() + await waitForLoad() + + const sortSelect = getSortSelect() + const options = within(sortSelect).getAllByRole('option') + const labels = options.map((o) => o.textContent?.toLowerCase()) + + expect(labels).toContain('severity (high → low)') + expect(labels).toContain('newest first') + expect(labels).toContain('oldest first') + expect(labels).toContain('target (a → z)') + }) + + it('switches to newest-first sorting', async () => { + const user = userEvent.setup() + renderFindings() + await waitForLoad() + + const sortSelect = getSortSelect() + await user.selectOptions(sortSelect, 'newest') + + await waitFor(() => { + expect((sortSelect as HTMLSelectElement).value).toBe('newest') + }) + }) + + it('newest-first puts most recent finding on top', async () => { + const user = userEvent.setup() + renderFindings() + await waitForLoad() + + const sortSelect = getSortSelect() + await user.selectOptions(sortSelect, 'newest') + + await waitFor(() => { + const titles = getVisibleTitles() + expect(titles.indexOf('Missing Security Headers')).toBeLessThan(titles.indexOf('SQL Injection in Login')) + expect(titles.indexOf('SQL Injection in Login')).toBeLessThan(titles.indexOf('Stored XSS in Comments')) + }) + }) + + it('oldest-first puts earliest finding on top', async () => { + const user = userEvent.setup() + renderFindings() + await waitForLoad() + + const sortSelect = getSortSelect() + await user.selectOptions(sortSelect, 'oldest') + + await waitFor(() => { + const titles = getVisibleTitles() + expect(titles.indexOf('Stored XSS in Comments')).toBeLessThan(titles.indexOf('SQL Injection in Login')) + expect(titles.indexOf('SQL Injection in Login')).toBeLessThan(titles.indexOf('Missing Security Headers')) + }) + }) + + it('target A-Z sorts alphabetically by target', async () => { + const user = userEvent.setup() + renderFindings() + await waitForLoad() + + const sortSelect = getSortSelect() + await user.selectOptions(sortSelect, 'target') + + await waitFor(() => { + const titles = getVisibleTitles() + const apiIdx = titles.indexOf('SQL Injection in Login') + const webIdx = titles.indexOf('Stored XSS in Comments') + expect(apiIdx).toBeLessThan(webIdx) + }) + }) +}) + +// -- Target filter ------------------------------------------------------------- + +describe('Findings - target filter', () => { + beforeEach(() => { + vi.mocked(getFindings).mockResolvedValue({ findings: allFindings }) + }) + + it('renders unique targets in dropdown', async () => { + renderFindings() + await waitForLoad() + + const targetSelect = screen.getByDisplayValue(/All targets/i) + const options = within(targetSelect as HTMLElement).getAllByRole('option') + const labels = options.map((o) => o.textContent) + + expect(labels).toContain('api.example.com') + expect(labels).toContain('web.example.com') + }) + + it('filters findings when a specific target is selected', async () => { + const user = userEvent.setup() + renderFindings() + await waitForLoad() + + const targetSelect = screen.getByDisplayValue(/All targets/i) + await user.selectOptions(targetSelect, 'web.example.com') + + await waitFor(() => { + expect(screen.queryByText('SQL Injection in Login')).not.toBeInTheDocument() + }) + expect(screen.getAllByText('Stored XSS in Comments').length).toBeGreaterThanOrEqual(1) + }) +}) + +// -- Scanner filter ------------------------------------------------------------ + +describe('Findings - scanner filter', () => { + beforeEach(() => { + vi.mocked(getFindings).mockResolvedValue({ findings: allFindings }) + }) + + it('renders unique scanners in dropdown', async () => { + renderFindings() + await waitForLoad() + + const scannerSelect = screen.getByDisplayValue(/All scanners/i) + const options = within(scannerSelect as HTMLElement).getAllByRole('option') + const labels = options.map((o) => o.textContent) + + expect(labels).toContain('sqlmap') + expect(labels).toContain('zap') + expect(labels).toContain('nikto') + }) + + it('filters findings to one scanner', async () => { + const user = userEvent.setup() + renderFindings() + await waitForLoad() + + const scannerSelect = screen.getByDisplayValue(/All scanners/i) + await user.selectOptions(scannerSelect, 'zap') + + await waitFor(() => { + expect(screen.queryByText('SQL Injection in Login')).not.toBeInTheDocument() + expect(screen.queryByText('Missing Security Headers')).not.toBeInTheDocument() + }) + expect(screen.getAllByText('Stored XSS in Comments').length).toBeGreaterThanOrEqual(1) + }) +}) + +// -- Date range filter --------------------------------------------------------- + +describe('Findings - date range filter', () => { + beforeEach(() => { + vi.mocked(getFindings).mockResolvedValue({ findings: allFindings }) + }) + + function getDateInputs() { + const inputs = document.querySelectorAll('input[type="date"]') + return { + from: inputs[0] as HTMLInputElement, + to: inputs[1] as HTMLInputElement, + } + } + + it('filters out findings before the from-date', async () => { + renderFindings() + await waitForLoad() + + const { from } = getDateInputs() + fireEvent.change(from, { target: { value: '2026-05-14' } }) + + await waitFor(() => { + expect(screen.queryByText('Stored XSS in Comments')).not.toBeInTheDocument() + }) + expect(screen.getAllByText('SQL Injection in Login').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('Missing Security Headers').length).toBeGreaterThanOrEqual(1) + }) + + it('filters out findings after the to-date', async () => { + renderFindings() + await waitForLoad() + + const { to } = getDateInputs() + fireEvent.change(to, { target: { value: '2026-05-14' } }) + + await waitFor(() => { + expect(screen.queryByText('Missing Security Headers')).not.toBeInTheDocument() + }) + expect(screen.getAllByText('SQL Injection in Login').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('Stored XSS in Comments').length).toBeGreaterThanOrEqual(1) + }) + + it('includes findings on the boundary date', async () => { + renderFindings() + await waitForLoad() + + const { from, to } = getDateInputs() + fireEvent.change(from, { target: { value: '2026-05-14' } }) + fireEvent.change(to, { target: { value: '2026-05-14' } }) + + await waitFor(() => { + expect(screen.queryByText('Stored XSS in Comments')).not.toBeInTheDocument() + expect(screen.queryByText('Missing Security Headers')).not.toBeInTheDocument() + }) + expect(screen.getAllByText('SQL Injection in Login').length).toBeGreaterThanOrEqual(1) + }) +}) + +// -- Reset filters ------------------------------------------------------------- + +describe('Findings - reset filters', () => { + beforeEach(() => { + vi.mocked(getFindings).mockResolvedValue({ findings: allFindings }) + }) + + it('clears all active filters when target is reset to all', async () => { + const user = userEvent.setup() + renderFindings() + await waitForLoad() + + const targetSelect = screen.getByDisplayValue(/All targets/i) + await user.selectOptions(targetSelect, 'web.example.com') + + await waitFor(() => { + expect(screen.queryByText('SQL Injection in Login')).not.toBeInTheDocument() + }) + + await user.selectOptions(screen.getByDisplayValue(/web\.example\.com/i), 'all') + + await waitFor(() => { + expect(screen.getAllByText('SQL Injection in Login').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('Stored XSS in Comments').length).toBeGreaterThanOrEqual(1) + }) + }) +}) + +// -- Active filter summary (REMOVED – component does not have this UI) + +// -- Timezone boundary regression ---------------------------------------------- + +describe('Findings - date range respects display timezone', () => { + const tzBoundaryFinding = { + id: 'finding-tz-edge', + severity: 'high', + category: 'xss', + title: 'TZ Boundary XSS', + target: 'tz.example.com', + description: 'Edge case across UTC day boundary.', + remediation: 'Fix it.', + discovered_at: '2026-05-13T20:00:00Z', + plugin_id: 'zap', + } + + beforeEach(() => { + vi.mocked(getFindings).mockResolvedValue({ findings: [tzBoundaryFinding] }) + vi.spyOn(dateUtils, 'getCurrentTimeZone').mockReturnValue('Asia/Kolkata') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + function getFromDateInput() { + return document.querySelector('input[type="date"]') as HTMLInputElement + } + + it('includes a UTC May-13 finding when from-date is May-14 in IST', async () => { + renderFindings() + + await waitFor(() => { + expect(screen.getAllByText('TZ Boundary XSS').length).toBeGreaterThanOrEqual(1) + }) + + const fromInput = getFromDateInput() + fireEvent.change(fromInput, { target: { value: '2026-05-14' } }) + + await waitFor(() => { + expect(screen.getAllByText('TZ Boundary XSS').length).toBeGreaterThanOrEqual(1) + }) + }) + + it('excludes the finding when from-date is May-15 in IST', async () => { + renderFindings() + + await waitFor(() => { + expect(screen.getAllByText('TZ Boundary XSS').length).toBeGreaterThanOrEqual(1) + }) + + const fromInput = getFromDateInput() + fireEvent.change(fromInput, { target: { value: '2026-05-15' } }) + + await waitFor(() => { + expect(screen.getByText(/No Findings Match/i)).toBeInTheDocument() + }) + }) +}) +// -- Empty state --------------------------------------------------------------- + +describe('Findings - empty state', () => { + it('shows empty state when no findings exist', async () => { + vi.mocked(getFindings).mockResolvedValue({ findings: [] }) + renderFindings() + expect(await screen.findByText(/No Findings Match/i)).toBeInTheDocument() + }) +}) \ No newline at end of file