mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
fix(security): server-driven pagination for scan history (#661)
Scan history fetched a fixed 200 most-recent rows and paginated them client-side, so older scans silently fell off mature nodes where baselining is most valuable. The list now fetches one page at a time via offset, with status=completed and imageRefLike filters applied server-side. Search input is debounced to avoid per-keystroke fetches.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<VulnerabilityScan[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchDraft, setSearchDraft] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<number[]>([]);
|
||||
const [compareIds, setCompareIds] = useState<[number, number] | null>(null);
|
||||
const [inspectScanId, setInspectScanId] = useState<number | null>(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}
|
||||
>
|
||||
<RefreshCw
|
||||
@@ -172,8 +186,8 @@ export function SecurityHistoryView() {
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
|
||||
<Input
|
||||
placeholder="Search by image..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
value={searchDraft}
|
||||
onChange={(e) => setSearchDraft(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
@@ -216,7 +230,7 @@ export function SecurityHistoryView() {
|
||||
) : (
|
||||
<ScrollArea className="max-h-[70vh]">
|
||||
<div className="space-y-5 pr-2">
|
||||
{pageGroups.map((group) => (
|
||||
{groups.map((group) => (
|
||||
<div key={group.image_ref}>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="font-mono text-sm truncate" title={group.image_ref}>
|
||||
|
||||
@@ -86,11 +86,11 @@ function scan(overrides: Partial<VulnerabilityScan> = {}): 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(<SecurityHistoryView />);
|
||||
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(<SecurityHistoryView />);
|
||||
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user