mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
42e8d3a78c
* feat(security): per-image scroll + retention cap in scan history Long scan histories for hot images used to monopolise the Scan history sheet: a single image with dozens of scans pushed every other image off screen, and the underlying vulnerability_scans table grew without bound. Each image group's table now renders inside its own ScrollArea capped at max-h-64 (~6 rows visible) so a busy image scrolls independently while the list of images stays navigable. A new global setting scan_history_per_image_limit (default 50, min 5, max 1000) backs both a window-function query that caps the response per image_ref and a prune step that runs on the existing MonitorService cleanup tick. The response now carries cappedImageRefs + perImageLimit so the UI can render a "Capped at N · older scans pruned" hint on groups sitting at the ceiling without a second settings round-trip. Single-image deep-dive (imageRef query param) bypasses the cap so a user clicking into one image can still see its full history. The prune uses self-contained subqueries to avoid SQLITE_MAX_VARIABLE_NUMBER issues on first-run installs with large backlogs, and explicitly deletes child rows from vulnerability_details, secret_findings, and misconfig_findings inside a transaction since FK cascade is not enabled at the connection level. Settings → Developer → Data retention gains a "Scan history per image" field. * fix(security): skip searchDraft debounce on mount to stop page-reset race The searchDraft debounce useEffect fires once on initial mount with the unchanged value and, 300ms later, unconditionally calls setPage(0). When a user (or a test) paginates inside that 300ms window, the pending debounce silently undoes the page advance. CI surfaced this as a flaky 3rd fetch in the "advances offset when the user pages forward" test once the per-image cap work added enough state-update overhead to push the click past the 300ms threshold on the slower Linux jsdom run. Track searchDraft with a ref and exit the effect when the value has not actually changed, so the debounce only runs in response to real user typing.
207 lines
7.6 KiB
TypeScript
207 lines
7.6 KiB
TypeScript
/**
|
|
* 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<{
|
|
node_id: number;
|
|
image_ref: string;
|
|
scanned_at: number;
|
|
status: 'completed' | 'in_progress' | 'failed';
|
|
}> = {}): number {
|
|
const db = DatabaseService.getInstance();
|
|
return db.createVulnerabilityScan({
|
|
node_id: overrides.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);
|
|
});
|
|
});
|
|
|
|
describe('getVulnerabilityScans per-image cap', () => {
|
|
it('caps rows per image_ref when no imageRef filter is set', () => {
|
|
const db = DatabaseService.getInstance();
|
|
db.updateGlobalSetting('scan_history_per_image_limit', '10');
|
|
|
|
for (let i = 0; i < 80; i++) seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i });
|
|
for (let i = 0; i < 5; i++) seedScan({ image_ref: 'cool:latest', scanned_at: 1000 + i });
|
|
|
|
const result = db.getVulnerabilityScans(1, { limit: 500 });
|
|
const hotRows = result.items.filter((s) => s.image_ref === 'hot:latest');
|
|
const coolRows = result.items.filter((s) => s.image_ref === 'cool:latest');
|
|
|
|
expect(hotRows).toHaveLength(10);
|
|
expect(coolRows).toHaveLength(5);
|
|
expect(result.total).toBe(15);
|
|
expect(result.cappedImageRefs).toEqual(['hot:latest']);
|
|
expect(result.perImageLimit).toBe(10);
|
|
});
|
|
|
|
it('bypasses the cap when imageRef targets a single image', () => {
|
|
const db = DatabaseService.getInstance();
|
|
db.updateGlobalSetting('scan_history_per_image_limit', '10');
|
|
|
|
for (let i = 0; i < 30; i++) seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i });
|
|
|
|
const result = db.getVulnerabilityScans(1, { imageRef: 'hot:latest', limit: 500 });
|
|
expect(result.items).toHaveLength(30);
|
|
expect(result.total).toBe(30);
|
|
expect(result.cappedImageRefs).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('pruneScanHistoryPerImage', () => {
|
|
it('keeps the newest N rows per (node_id, image_ref) and deletes the rest', () => {
|
|
const db = DatabaseService.getInstance();
|
|
for (let i = 0; i < 60; i++) seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i });
|
|
for (let i = 0; i < 5; i++) seedScan({ image_ref: 'cool:latest', scanned_at: 1000 + i });
|
|
|
|
const deleted = db.pruneScanHistoryPerImage(50);
|
|
expect(deleted).toBe(10);
|
|
|
|
const after = db.getVulnerabilityScans(1, { imageRef: 'hot:latest', limit: 500 });
|
|
expect(after.items).toHaveLength(50);
|
|
const oldest = Math.min(...after.items.map((s) => s.scanned_at));
|
|
expect(oldest).toBe(1010);
|
|
|
|
const cool = db.getVulnerabilityScans(1, { imageRef: 'cool:latest', limit: 500 });
|
|
expect(cool.items).toHaveLength(5);
|
|
});
|
|
|
|
it('is a no-op when no image exceeds the cap', () => {
|
|
const db = DatabaseService.getInstance();
|
|
for (let i = 0; i < 3; i++) seedScan({ image_ref: 'small:latest', scanned_at: 1000 + i });
|
|
|
|
const deleted = db.pruneScanHistoryPerImage(50);
|
|
expect(deleted).toBe(0);
|
|
});
|
|
|
|
it('partitions by node_id so two nodes scanning the same image keep independent histories', () => {
|
|
const db = DatabaseService.getInstance();
|
|
db.getDb()
|
|
.prepare(`INSERT INTO nodes (id, name, type, compose_dir, is_default, status, created_at)
|
|
VALUES (2, 'Peer', 'remote', '/tmp', 0, 'online', ?)`)
|
|
.run(Date.now());
|
|
|
|
for (let i = 0; i < 60; i++) seedScan({ node_id: 1, image_ref: 'alpine:3.19', scanned_at: 1000 + i });
|
|
for (let i = 0; i < 60; i++) seedScan({ node_id: 2, image_ref: 'alpine:3.19', scanned_at: 2000 + i });
|
|
|
|
const deleted = db.pruneScanHistoryPerImage(50);
|
|
expect(deleted).toBe(20);
|
|
|
|
const node1 = db.getVulnerabilityScans(1, { imageRef: 'alpine:3.19', limit: 500 });
|
|
const node2 = db.getVulnerabilityScans(2, { imageRef: 'alpine:3.19', limit: 500 });
|
|
expect(node1.items).toHaveLength(50);
|
|
expect(node2.items).toHaveLength(50);
|
|
});
|
|
|
|
it('deletes child vulnerability_details rows for pruned scans', () => {
|
|
const db = DatabaseService.getInstance();
|
|
const ids: number[] = [];
|
|
for (let i = 0; i < 60; i++) {
|
|
ids.push(seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i }));
|
|
}
|
|
const oldestScanId = ids[0];
|
|
db.insertVulnerabilityDetails(oldestScanId, [{
|
|
vulnerability_id: 'CVE-2020-0001',
|
|
pkg_name: 'libfoo',
|
|
installed_version: '1.0',
|
|
fixed_version: '1.1',
|
|
severity: 'HIGH',
|
|
title: 'Test',
|
|
description: null,
|
|
primary_url: null,
|
|
}]);
|
|
|
|
const beforeChildren = db.getDb()
|
|
.prepare('SELECT COUNT(*) as cnt FROM vulnerability_details WHERE scan_id = ?')
|
|
.get(oldestScanId) as { cnt: number };
|
|
expect(beforeChildren.cnt).toBe(1);
|
|
|
|
const deleted = db.pruneScanHistoryPerImage(50);
|
|
expect(deleted).toBe(10);
|
|
|
|
const afterChildren = db.getDb()
|
|
.prepare('SELECT COUNT(*) as cnt FROM vulnerability_details WHERE scan_id = ?')
|
|
.get(oldestScanId) as { cnt: number };
|
|
expect(afterChildren.cnt).toBe(0);
|
|
});
|
|
});
|