diff --git a/backend/src/__tests__/database-scan-list.test.ts b/backend/src/__tests__/database-scan-list.test.ts new file mode 100644 index 00000000..9d61acef --- /dev/null +++ b/backend/src/__tests__/database-scan-list.test.ts @@ -0,0 +1,95 @@ +/** + * Coverage for `getVulnerabilityScans` filtering + pagination, used by + * the scan-history page's server-driven pagination. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; + +let tmpDir: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +function seedScan(overrides: Partial<{ + image_ref: string; + scanned_at: number; + status: 'completed' | 'in_progress' | 'failed'; +}> = {}): number { + const db = DatabaseService.getInstance(); + return db.createVulnerabilityScan({ + node_id: 1, + image_ref: overrides.image_ref ?? 'alpine:3.19', + image_digest: `sha256:${Math.random().toString(16).slice(2)}`, + scanned_at: overrides.scanned_at ?? Date.now(), + total_vulnerabilities: 0, + critical_count: 0, + high_count: 0, + medium_count: 0, + low_count: 0, + unknown_count: 0, + fixable_count: 0, + secret_count: 0, + misconfig_count: 0, + scanners_used: 'vuln', + highest_severity: null, + os_info: null, + trivy_version: null, + scan_duration_ms: null, + triggered_by: 'manual', + status: overrides.status ?? 'completed', + error: null, + stack_context: null, + }); +} + +function resetTable(): void { + (DatabaseService.getInstance() as unknown as { + db: { prepare: (s: string) => { run: () => void } }; + }).db.prepare('DELETE FROM vulnerability_scans').run(); +} + +beforeEach(() => resetTable()); + +describe('getVulnerabilityScans filters and pagination', () => { + it('filters by status=completed', () => { + const db = DatabaseService.getInstance(); + seedScan({ status: 'completed', scanned_at: 1 }); + seedScan({ status: 'in_progress', scanned_at: 2 }); + seedScan({ status: 'failed', scanned_at: 3 }); + + const result = db.getVulnerabilityScans(1, { status: 'completed' }); + expect(result.total).toBe(1); + expect(result.items).toHaveLength(1); + expect(result.items[0].status).toBe('completed'); + }); + + it('filters by imageRefLike substring, case-sensitive', () => { + const db = DatabaseService.getInstance(); + seedScan({ image_ref: 'alpine:3.18', scanned_at: 1 }); + seedScan({ image_ref: 'alpine:3.19', scanned_at: 2 }); + seedScan({ image_ref: 'nginx:1.25', scanned_at: 3 }); + + const result = db.getVulnerabilityScans(1, { imageRefLike: 'alpine' }); + expect(result.total).toBe(2); + expect(result.items.every((s) => s.image_ref.startsWith('alpine'))).toBe(true); + }); + + it('returns total independent of limit for pagination', () => { + const db = DatabaseService.getInstance(); + for (let i = 0; i < 5; i++) seedScan({ scanned_at: i * 1000 }); + + const page1 = db.getVulnerabilityScans(1, { limit: 2, offset: 0 }); + const page2 = db.getVulnerabilityScans(1, { limit: 2, offset: 2 }); + + expect(page1.total).toBe(5); + expect(page2.total).toBe(5); + expect(page1.items).toHaveLength(2); + expect(page2.items).toHaveLength(2); + expect(page1.items[0].id).not.toBe(page2.items[0].id); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 5ca18f04..a6f04e3d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -7544,10 +7544,21 @@ app.post('/api/security/scan/stack', authMiddleware, async (req: Request, res: R app.get('/api/security/scans', authMiddleware, (req: Request, res: Response) => { try { const imageRef = typeof req.query.imageRef === 'string' ? req.query.imageRef : undefined; + const imageRefLike = + typeof req.query.imageRefLike === 'string' && req.query.imageRefLike.trim() + ? req.query.imageRefLike.trim() + : undefined; + const statusParam = typeof req.query.status === 'string' ? req.query.status : undefined; + const status = + statusParam === 'completed' || statusParam === 'in_progress' || statusParam === 'failed' + ? statusParam + : undefined; const limit = req.query.limit ? Number(req.query.limit) : undefined; const offset = req.query.offset ? Number(req.query.offset) : undefined; const result = DatabaseService.getInstance().getVulnerabilityScans(req.nodeId, { imageRef, + imageRefLike, + status, limit, offset, }); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index fd1b62b8..3495219c 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -2242,7 +2242,7 @@ export class DatabaseService { public getVulnerabilityScans( nodeId: number, - opts: { imageRef?: string; limit?: number; offset?: number } = {}, + opts: { imageRef?: string; imageRefLike?: string; status?: VulnScanStatus; limit?: number; offset?: number } = {}, ): { items: VulnerabilityScan[]; total: number } { const limit = Math.max(1, Math.min(opts.limit ?? 50, 500)); const offset = Math.max(0, opts.offset ?? 0); @@ -2252,6 +2252,14 @@ export class DatabaseService { where.push('image_ref = ?'); params.push(opts.imageRef); } + if (opts.imageRefLike) { + where.push('image_ref LIKE ?'); + params.push(`%${opts.imageRefLike}%`); + } + if (opts.status) { + where.push('status = ?'); + params.push(opts.status); + } const whereSql = where.join(' AND '); const total = ( this.db diff --git a/frontend/src/components/SecurityHistoryView.tsx b/frontend/src/components/SecurityHistoryView.tsx index 6bc96e1f..055ac0ff 100644 --- a/frontend/src/components/SecurityHistoryView.tsx +++ b/frontend/src/components/SecurityHistoryView.tsx @@ -32,7 +32,7 @@ import { useAuth } from '@/context/AuthContext'; import { useNodes } from '@/context/NodeContext'; import type { VulnerabilityScan } from '@/types/security'; -const PAGE_SIZE = 25; +const PAGE_SIZE = 100; interface GroupedScans { image_ref: string; @@ -60,21 +60,30 @@ export function SecurityHistoryView() { const { isAdmin } = useAuth(); const { activeNode } = useNodes(); const [scans, setScans] = useState([]); + const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); + const [searchDraft, setSearchDraft] = useState(''); const [search, setSearch] = useState(''); const [selected, setSelected] = useState([]); const [compareIds, setCompareIds] = useState<[number, number] | null>(null); const [inspectScanId, setInspectScanId] = useState(null); const [page, setPage] = useState(0); - const load = useCallback(async () => { + const load = useCallback(async (pageToLoad: number, searchTerm: string) => { setLoading(true); try { - const res = await apiFetch('/security/scans?limit=200'); + const params = new URLSearchParams({ + status: 'completed', + limit: String(PAGE_SIZE), + offset: String(pageToLoad * PAGE_SIZE), + }); + if (searchTerm.trim()) params.set('imageRefLike', searchTerm.trim()); + const res = await apiFetch(`/security/scans?${params.toString()}`); if (!res.ok) throw new Error('Failed to load scans'); const body = await res.json(); const items: VulnerabilityScan[] = Array.isArray(body?.items) ? body.items : []; - setScans(items.filter((s) => s.status === 'completed')); + setScans(items); + setTotal(typeof body?.total === 'number' ? body.total : items.length); } catch (err) { toast.error((err as Error)?.message || 'Could not load scan history'); } finally { @@ -83,22 +92,27 @@ export function SecurityHistoryView() { }, []); useEffect(() => { - load(); + load(page, search); + }, [load, page, search, activeNode?.id]); + + useEffect(() => { setSelected([]); - }, [load, activeNode?.id]); + setPage(0); + }, [activeNode?.id]); - const filteredScans = useMemo(() => { - if (!search.trim()) return scans; - const q = search.toLowerCase(); - return scans.filter((s) => s.image_ref.toLowerCase().includes(q)); - }, [scans, search]); + useEffect(() => { + const t = setTimeout(() => { + setSearch(searchDraft); + setPage(0); + }, 300); + return () => clearTimeout(t); + }, [searchDraft]); - const groups = useMemo(() => groupByImage(filteredScans), [filteredScans]); + const groups = useMemo(() => groupByImage(scans), [scans]); - const totalPages = Math.max(1, Math.ceil(groups.length / PAGE_SIZE)); + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const safePage = Math.min(page, totalPages - 1); - const pageGroups = groups.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE); - const needsPagination = groups.length > PAGE_SIZE; + const needsPagination = total > PAGE_SIZE; const toggleSelect = (scanId: number) => { setSelected((prev) => { @@ -151,7 +165,7 @@ export function SecurityHistoryView() { variant="outline" size="sm" className="border-border" - onClick={load} + onClick={() => load(safePage, search)} disabled={loading} > { setSearch(e.target.value); setPage(0); }} + value={searchDraft} + onChange={(e) => setSearchDraft(e.target.value)} className="pl-8" /> @@ -216,7 +230,7 @@ export function SecurityHistoryView() { ) : (
- {pageGroups.map((group) => ( + {groups.map((group) => (
diff --git a/frontend/src/components/__tests__/SecurityHistoryView.test.tsx b/frontend/src/components/__tests__/SecurityHistoryView.test.tsx index 5fe4cc87..f5d288ef 100644 --- a/frontend/src/components/__tests__/SecurityHistoryView.test.tsx +++ b/frontend/src/components/__tests__/SecurityHistoryView.test.tsx @@ -86,11 +86,11 @@ function scan(overrides: Partial = {}): VulnerabilityScan { }; } -function listResponse(items: VulnerabilityScan[]): Response { +function listResponse(items: VulnerabilityScan[], total?: number): Response { return { ok: true, status: 200, - json: async () => ({ items }), + json: async () => ({ items, total: total ?? items.length }), } as unknown as Response; } @@ -104,12 +104,33 @@ beforeEach(() => { afterEach(() => vi.clearAllMocks()); describe('SecurityHistoryView', () => { - it('fetches scans on mount', async () => { + it('fetches completed scans on mount with server-driven pagination params', async () => { mockedFetch.mockResolvedValue(listResponse([scan()])); render(); - await waitFor(() => - expect(mockedFetch).toHaveBeenCalledWith('/security/scans?limit=200'), + await waitFor(() => expect(mockedFetch).toHaveBeenCalled()); + const url = mockedFetch.mock.calls[0][0] as string; + expect(url).toMatch(/^\/security\/scans\?/); + expect(url).toContain('status=completed'); + expect(url).toContain('offset=0'); + expect(url).toMatch(/limit=\d+/); + }); + + it('advances offset when the user pages forward', async () => { + mockedFetch.mockResolvedValue(listResponse([scan()], 250)); + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1)); + + const nextBtn = screen.getAllByRole('button').find( + (b) => b.querySelector('.lucide-chevron-right'), ); + expect(nextBtn).toBeDefined(); + await user.click(nextBtn!); + + await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(2)); + const secondUrl = mockedFetch.mock.calls[1][0] as string; + expect(secondUrl).toContain('offset=100'); }); it('re-fetches when activeNode.id changes', async () => {