mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 04:38:59 +00:00
test(security): add scan comparison coverage (#656)
Backend supertest suite for GET /api/security/compare covers tier gating, input validation, cross-node isolation, diff partitioning by vulnerability_id::pkg_name, suppression application, and cross-image comparison. Frontend vitest + React Testing Library scaffolding with component tests for ScanComparisonSheet (loading, error recovery, cross-image warning, filter pills, reload on id change) and SecurityHistoryView (mount fetch, selection cap, oldest-first baseline ordering, tier gating).
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Coverage for GET /api/security/compare (index.ts:7966-7999).
|
||||
*
|
||||
* Locks behavior before H-2 (truncation signal) and H-3 (scan-history
|
||||
* pagination) land. Scenarios cover tier gating, input validation, cross-node
|
||||
* isolation, diff partitioning, suppression application, and cross-image
|
||||
* comparison. Truncation-signal assertions are added alongside the H-2 fix.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import type { VulnSeverity } from '../services/DatabaseService';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let tierSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
const LOCAL_NODE = 1;
|
||||
const OTHER_NODE = 99;
|
||||
|
||||
function adminToken(): string {
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUserByUsername(TEST_USERNAME)!;
|
||||
return jwt.sign(
|
||||
{ username: TEST_USERNAME, role: 'admin', tv: user.token_version },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '1m' },
|
||||
);
|
||||
}
|
||||
|
||||
function seedScan(opts: {
|
||||
nodeId?: number;
|
||||
imageRef?: string;
|
||||
scannedAt?: number;
|
||||
totalVulnerabilities?: number;
|
||||
} = {}): number {
|
||||
const db = DatabaseService.getInstance();
|
||||
return db.createVulnerabilityScan({
|
||||
node_id: opts.nodeId ?? LOCAL_NODE,
|
||||
image_ref: opts.imageRef ?? 'alpine:3.19',
|
||||
image_digest: `sha256:${Math.random().toString(16).slice(2)}`,
|
||||
scanned_at: opts.scannedAt ?? Date.now(),
|
||||
total_vulnerabilities: opts.totalVulnerabilities ?? 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: 'alpine 3.19',
|
||||
trivy_version: '0.56.0',
|
||||
scan_duration_ms: 100,
|
||||
triggered_by: 'manual',
|
||||
status: 'completed',
|
||||
error: null,
|
||||
stack_context: null,
|
||||
});
|
||||
}
|
||||
|
||||
function seedVuln(
|
||||
scanId: number,
|
||||
cve: string,
|
||||
pkg: string,
|
||||
severity: VulnSeverity = 'HIGH',
|
||||
): void {
|
||||
DatabaseService.getInstance().insertVulnerabilityDetails(scanId, [
|
||||
{
|
||||
vulnerability_id: cve,
|
||||
pkg_name: pkg,
|
||||
installed_version: '1.0.0',
|
||||
fixed_version: '1.0.1',
|
||||
severity,
|
||||
title: `${cve} in ${pkg}`,
|
||||
description: null,
|
||||
primary_url: `https://example.com/${cve}`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function resetTables(): void {
|
||||
const db = DatabaseService.getInstance();
|
||||
// CASCADE on vulnerability_details FK wipes children too.
|
||||
(db as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db
|
||||
.prepare('DELETE FROM vulnerability_scans')
|
||||
.run();
|
||||
(db as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db
|
||||
.prepare('DELETE FROM cve_suppressions')
|
||||
.run();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue(null);
|
||||
|
||||
({ app } = await import('../index'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTables();
|
||||
tierSpy.mockReturnValue('paid');
|
||||
});
|
||||
|
||||
describe('GET /api/security/compare', () => {
|
||||
it('returns 403 for community tier', async () => {
|
||||
tierSpy.mockReturnValue('community');
|
||||
const a = seedScan();
|
||||
const b = seedScan({ scannedAt: Date.now() + 1000 });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/security/compare?scanId1=${a}&scanId2=${b}`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('returns 400 for non-finite scanId params', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/security/compare?scanId1=foo&scanId2=bar')
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/required/i);
|
||||
});
|
||||
|
||||
it('returns 400 when only one scanId is provided', async () => {
|
||||
const a = seedScan();
|
||||
const res = await request(app)
|
||||
.get(`/api/security/compare?scanId1=${a}`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 when either scan is missing', async () => {
|
||||
const a = seedScan();
|
||||
const res = await request(app)
|
||||
.get(`/api/security/compare?scanId1=${a}&scanId2=99999`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('returns 404 when scans belong to different nodes', async () => {
|
||||
const a = seedScan({ nodeId: LOCAL_NODE });
|
||||
const b = seedScan({ nodeId: OTHER_NODE });
|
||||
const res = await request(app)
|
||||
.get(`/api/security/compare?scanId1=${a}&scanId2=${b}`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 when a scan belongs to a different node than the request', async () => {
|
||||
const a = seedScan({ nodeId: OTHER_NODE });
|
||||
const b = seedScan({ nodeId: OTHER_NODE, scannedAt: Date.now() + 1000 });
|
||||
// request goes to LOCAL_NODE (default), so scans belong to OTHER_NODE are invisible
|
||||
const res = await request(app)
|
||||
.get(`/api/security/compare?scanId1=${a}&scanId2=${b}`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('partitions findings into added / removed / unchanged', async () => {
|
||||
const baseline = seedScan({ scannedAt: 1000 });
|
||||
seedVuln(baseline, 'CVE-2024-0001', 'openssl', 'CRITICAL'); // unchanged
|
||||
seedVuln(baseline, 'CVE-2024-0002', 'curl', 'HIGH'); // removed
|
||||
|
||||
const current = seedScan({ scannedAt: 2000 });
|
||||
seedVuln(current, 'CVE-2024-0001', 'openssl', 'CRITICAL'); // unchanged
|
||||
seedVuln(current, 'CVE-2024-0003', 'zlib', 'MEDIUM'); // added
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/security/compare?scanId1=${baseline}&scanId2=${current}`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.scanA.id).toBe(baseline);
|
||||
expect(res.body.scanB.id).toBe(current);
|
||||
expect(res.body.added).toHaveLength(1);
|
||||
expect(res.body.added[0].vulnerability_id).toBe('CVE-2024-0003');
|
||||
expect(res.body.removed).toHaveLength(1);
|
||||
expect(res.body.removed[0].vulnerability_id).toBe('CVE-2024-0002');
|
||||
expect(res.body.unchanged).toHaveLength(1);
|
||||
expect(res.body.unchanged[0].vulnerability_id).toBe('CVE-2024-0001');
|
||||
});
|
||||
|
||||
it('keys the diff by vulnerability_id::pkg_name (same CVE on different packages is not "unchanged")', async () => {
|
||||
const baseline = seedScan({ scannedAt: 1000 });
|
||||
seedVuln(baseline, 'CVE-2024-1000', 'libfoo', 'HIGH');
|
||||
|
||||
const current = seedScan({ scannedAt: 2000 });
|
||||
seedVuln(current, 'CVE-2024-1000', 'libbar', 'HIGH');
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/security/compare?scanId1=${baseline}&scanId2=${current}`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.added).toHaveLength(1);
|
||||
expect(res.body.removed).toHaveLength(1);
|
||||
expect(res.body.unchanged).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('applies cve_suppressions to all three buckets', async () => {
|
||||
const baseline = seedScan({ scannedAt: 1000 });
|
||||
seedVuln(baseline, 'CVE-2024-0100', 'openssl'); // unchanged
|
||||
seedVuln(baseline, 'CVE-2024-0101', 'curl'); // removed
|
||||
|
||||
const current = seedScan({ scannedAt: 2000 });
|
||||
seedVuln(current, 'CVE-2024-0100', 'openssl'); // unchanged
|
||||
seedVuln(current, 'CVE-2024-0102', 'zlib'); // added
|
||||
|
||||
DatabaseService.getInstance().createCveSuppression({
|
||||
cve_id: 'CVE-2024-0100',
|
||||
pkg_name: null,
|
||||
image_pattern: null,
|
||||
reason: 'false positive',
|
||||
created_by: TEST_USERNAME,
|
||||
created_at: Date.now(),
|
||||
expires_at: null,
|
||||
replicated_from_control: 0,
|
||||
});
|
||||
DatabaseService.getInstance().createCveSuppression({
|
||||
cve_id: 'CVE-2024-0101',
|
||||
pkg_name: null,
|
||||
image_pattern: null,
|
||||
reason: 'accepted risk',
|
||||
created_by: TEST_USERNAME,
|
||||
created_at: Date.now(),
|
||||
expires_at: null,
|
||||
replicated_from_control: 0,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/security/compare?scanId1=${baseline}&scanId2=${current}`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const unchanged0100 = res.body.unchanged.find(
|
||||
(v: { vulnerability_id: string }) => v.vulnerability_id === 'CVE-2024-0100',
|
||||
);
|
||||
expect(unchanged0100.suppressed).toBe(true);
|
||||
expect(unchanged0100.suppression_reason).toBe('false positive');
|
||||
|
||||
const removed0101 = res.body.removed.find(
|
||||
(v: { vulnerability_id: string }) => v.vulnerability_id === 'CVE-2024-0101',
|
||||
);
|
||||
expect(removed0101.suppressed).toBe(true);
|
||||
|
||||
const added0102 = res.body.added.find(
|
||||
(v: { vulnerability_id: string }) => v.vulnerability_id === 'CVE-2024-0102',
|
||||
);
|
||||
expect(added0102.suppressed).toBe(false);
|
||||
});
|
||||
|
||||
it('allows cross-image comparison on the same node and preserves distinct image refs', async () => {
|
||||
const a = seedScan({ imageRef: 'alpine:3.18', scannedAt: 1000 });
|
||||
const b = seedScan({ imageRef: 'alpine:3.19', scannedAt: 2000 });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/security/compare?scanId1=${a}&scanId2=${b}`)
|
||||
.set('Authorization', `Bearer ${adminToken()}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.scanA.image_ref).toBe('alpine:3.18');
|
||||
expect(res.body.scanB.image_ref).toBe('alpine:3.19');
|
||||
});
|
||||
});
|
||||
Generated
+1139
-1
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -8,7 +8,9 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
@@ -60,6 +62,10 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -68,9 +74,11 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"jsdom": "^29.0.2",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.1",
|
||||
"vite": "^8.0.8"
|
||||
"vite": "^8.0.8",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { afterEach, vi } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
class MockResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
globalThis.ResizeObserver = globalThis.ResizeObserver ?? MockResizeObserver;
|
||||
|
||||
if (typeof window !== 'undefined' && !window.matchMedia) {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Coverage for ScanComparisonSheet.
|
||||
*
|
||||
* Locks the Sheet's data-path behavior: loading, error recovery, cross-image
|
||||
* warning, filter pills, pagination clamp, and empty states. Complements the
|
||||
* backend compare handler tests by guarding the frontend rendering contract.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
const toastError = vi.fn();
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: {
|
||||
error: (...args: unknown[]) => toastError(...args),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../VulnerabilityScanSheet', () => ({
|
||||
SeverityChip: ({ severity }: { severity: string }) => (
|
||||
<span data-testid="severity-chip">{severity}</span>
|
||||
),
|
||||
VulnerabilityScanSheet: () => null,
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { ScanComparisonSheet } from '../ScanComparisonSheet';
|
||||
import type { ScanCompareResult, ScanCompareVulnerability } from '@/types/security';
|
||||
|
||||
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
function vuln(overrides: Partial<ScanCompareVulnerability> = {}): ScanCompareVulnerability {
|
||||
return {
|
||||
vulnerability_id: 'CVE-2024-0001',
|
||||
pkg_name: 'openssl',
|
||||
severity: 'HIGH',
|
||||
suppressed: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function result(overrides: Partial<ScanCompareResult> = {}): ScanCompareResult {
|
||||
return {
|
||||
scanA: { id: 1, image_ref: 'alpine:3.18', scanned_at: 1_700_000_000_000 },
|
||||
scanB: { id: 2, image_ref: 'alpine:3.18', scanned_at: 1_700_000_010_000 },
|
||||
added: [],
|
||||
removed: [],
|
||||
unchanged: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
toastError.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ScanComparisonSheet', () => {
|
||||
it('renders nothing when scan ids are null', () => {
|
||||
const { container } = render(
|
||||
<ScanComparisonSheet baselineScanId={null} currentScanId={null} onClose={() => {}} />,
|
||||
);
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows cross-image warning when scan image refs differ', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, result({
|
||||
scanB: { id: 2, image_ref: 'alpine:3.19', scanned_at: 1_700_000_010_000 },
|
||||
})),
|
||||
);
|
||||
|
||||
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/different image references/i)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not show cross-image warning for same image refs', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(jsonResponse(200, result()));
|
||||
|
||||
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Baseline/i)).toBeInTheDocument());
|
||||
expect(screen.queryByText(/different image references/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces a toast and closes the sheet on fetch error', async () => {
|
||||
const onClose = vi.fn();
|
||||
mockedFetch.mockResolvedValueOnce(jsonResponse(500, { error: 'boom' }));
|
||||
|
||||
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => expect(toastError).toHaveBeenCalledWith('boom'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('switches the visible rows when a filter pill is clicked', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, result({
|
||||
added: [vuln({ vulnerability_id: 'CVE-A', pkg_name: 'pa' })],
|
||||
removed: [vuln({ vulnerability_id: 'CVE-R', pkg_name: 'pr' })],
|
||||
unchanged: [vuln({ vulnerability_id: 'CVE-U', pkg_name: 'pu' })],
|
||||
})),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('CVE-A')).toBeInTheDocument());
|
||||
expect(screen.queryByText('CVE-R')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Removed \(1\)/ }));
|
||||
expect(screen.getByText('CVE-R')).toBeInTheDocument();
|
||||
expect(screen.queryByText('CVE-A')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Unchanged \(1\)/ }));
|
||||
expect(screen.getByText('CVE-U')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a bucket-specific empty state message', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(jsonResponse(200, result()));
|
||||
|
||||
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Nothing regressed between these scans/i)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('reloads when the scan ids change', async () => {
|
||||
mockedFetch.mockResolvedValue(jsonResponse(200, result()));
|
||||
|
||||
const { rerender } = render(
|
||||
<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1));
|
||||
|
||||
await act(async () => {
|
||||
rerender(<ScanComparisonSheet baselineScanId={3} currentScanId={4} onClose={() => {}} />);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(2));
|
||||
expect(mockedFetch).toHaveBeenLastCalledWith('/security/compare?scanId1=3&scanId2=4');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Coverage for SecurityHistoryView.
|
||||
*
|
||||
* Locks the scan history's selection and comparison-launch behavior: scans
|
||||
* fetched on mount, selection capped at two, oldest-first baseline ordering,
|
||||
* and selection reset on active-node change.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { VulnerabilityScan } from '@/types/security';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const licenseState = { isPaid: true };
|
||||
vi.mock('@/context/LicenseContext', () => ({
|
||||
useLicense: () => licenseState,
|
||||
}));
|
||||
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => ({ isAdmin: true }),
|
||||
}));
|
||||
|
||||
const nodesState: { activeNode: { id: number } | null } = { activeNode: { id: 1 } };
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => nodesState,
|
||||
}));
|
||||
|
||||
const compareProps: { baselineScanId: number | null; currentScanId: number | null }[] = [];
|
||||
vi.mock('../ScanComparisonSheet', () => ({
|
||||
ScanComparisonSheet: (props: { baselineScanId: number | null; currentScanId: number | null }) => {
|
||||
compareProps.push({ baselineScanId: props.baselineScanId, currentScanId: props.currentScanId });
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../VulnerabilityScanSheet', () => ({
|
||||
SeverityChip: ({ severity }: { severity: string }) => <span>{severity}</span>,
|
||||
VulnerabilityScanSheet: () => null,
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { SecurityHistoryView } from '../SecurityHistoryView';
|
||||
|
||||
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
function scan(overrides: Partial<VulnerabilityScan> = {}): VulnerabilityScan {
|
||||
return {
|
||||
id: 1,
|
||||
node_id: 1,
|
||||
image_ref: 'alpine:3.19',
|
||||
image_digest: null,
|
||||
scanned_at: 1_700_000_000_000,
|
||||
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: 'completed',
|
||||
error: null,
|
||||
stack_context: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function listResponse(items: VulnerabilityScan[]): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ items }),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
compareProps.length = 0;
|
||||
licenseState.isPaid = true;
|
||||
nodesState.activeNode = { id: 1 };
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('SecurityHistoryView', () => {
|
||||
it('fetches scans on mount', async () => {
|
||||
mockedFetch.mockResolvedValue(listResponse([scan()]));
|
||||
render(<SecurityHistoryView />);
|
||||
await waitFor(() =>
|
||||
expect(mockedFetch).toHaveBeenCalledWith('/security/scans?limit=200'),
|
||||
);
|
||||
});
|
||||
|
||||
it('re-fetches when activeNode.id changes', async () => {
|
||||
mockedFetch.mockResolvedValue(listResponse([scan()]));
|
||||
const { rerender } = render(<SecurityHistoryView />);
|
||||
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(1));
|
||||
|
||||
nodesState.activeNode = { id: 2 };
|
||||
rerender(<SecurityHistoryView key="remount-signal" />);
|
||||
await waitFor(() => expect(mockedFetch).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('caps selection at two scans, evicting the oldest', async () => {
|
||||
mockedFetch.mockResolvedValue(
|
||||
listResponse([
|
||||
scan({ id: 1, scanned_at: 1000 }),
|
||||
scan({ id: 2, scanned_at: 2000 }),
|
||||
scan({ id: 3, scanned_at: 3000 }),
|
||||
]),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<SecurityHistoryView />);
|
||||
|
||||
const checkboxes = await screen.findAllByRole('checkbox');
|
||||
expect(checkboxes).toHaveLength(3);
|
||||
|
||||
await user.click(checkboxes[0]);
|
||||
await user.click(checkboxes[1]);
|
||||
await user.click(checkboxes[2]);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Compare \(2\/2\)/ })).toBeEnabled();
|
||||
expect(checkboxes[0].getAttribute('aria-checked')).toBe('false');
|
||||
expect(checkboxes[1].getAttribute('aria-checked')).toBe('true');
|
||||
expect(checkboxes[2].getAttribute('aria-checked')).toBe('true');
|
||||
});
|
||||
|
||||
it('passes older scan as baseline and newer as current on compare', async () => {
|
||||
mockedFetch.mockResolvedValue(
|
||||
listResponse([
|
||||
scan({ id: 10, scanned_at: 3000 }),
|
||||
scan({ id: 20, scanned_at: 1000 }),
|
||||
]),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<SecurityHistoryView />);
|
||||
|
||||
const checkboxes = await screen.findAllByRole('checkbox');
|
||||
await user.click(checkboxes[0]);
|
||||
await user.click(checkboxes[1]);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Compare \(2\/2\)/ }));
|
||||
|
||||
const last = compareProps.at(-1);
|
||||
expect(last?.baselineScanId).toBe(20);
|
||||
expect(last?.currentScanId).toBe(10);
|
||||
});
|
||||
|
||||
it('disables Compare button for community tier', async () => {
|
||||
licenseState.isPaid = false;
|
||||
mockedFetch.mockResolvedValue(
|
||||
listResponse([
|
||||
scan({ id: 1, scanned_at: 1000 }),
|
||||
scan({ id: 2, scanned_at: 2000 }),
|
||||
]),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<SecurityHistoryView />);
|
||||
|
||||
const checkboxes = await screen.findAllByRole('checkbox');
|
||||
await user.click(checkboxes[0]);
|
||||
await user.click(checkboxes[1]);
|
||||
|
||||
const compareBtn = screen.getByRole('button', { name: /Compare \(2\/2\)/ });
|
||||
expect(compareBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/__tests__/setup.ts'],
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
css: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user