Files
sencho/backend/src/__tests__/scan-compare.test.ts
T
Anso ecf4dd5d52 feat: open security basics, manual fleet ops, and basic fleet management to Community (#930)
Realign tier guards to the user-stated philosophy: Community covers
deploy/monitor at scale plus security basics, Skipper adds automation
and advanced fleet management, Admiral keeps enterprise control.

Community now includes:
- Trivy install / uninstall / update from the Settings Hub (admin role)
- CVE suppressions CRUD (admin role; replicates fleet-wide)
- Manual image scan with vuln, secret, and misconfig results
- Stack-config scan, scan comparison
- Manual fleet snapshots: create, list, view, restore, delete
- Per-node Sencho self-update (Check Updates + per-node Update)
- Fleet Overview search, sort, filters, node-card expand, auto-refresh

Stays paid:
- Scan policies with block_on_deploy enforcement (Skipper+)
- SBOM (SPDX, CycloneDX), SARIF export (Skipper+)
- Bulk Update All across the fleet (Skipper+)
- Scheduled snapshot create (now Skipper, was Admiral)
- Trivy auto-update toggle, fleet-wide policy push (Admiral)

The Settings -> Security tab is unhidden by setting the registry tier to
null. The SecuritySection no longer early-returns a PaidGate; the policy
list, Add Policy button, and policy dialogs are wrapped in {isPaid && }.
The Fleet view drops isPaid gates on the Snapshots tab, Check Updates
button, per-node update handlers, OverviewToolbar grid controls, the
NodeCard expand affordance, and the auto-refresh notice. The
NodeUpdatesSheet receives a canBulkUpdate prop and gates the Update All
button on it. useFleetUpdateStatus and useFleetPolling drop their isPaid
guards so polling runs for Community; useFleetOverview drops the isPaid
wrap on the filter and sort path.

Backend route guards are flipped per the matrix above. The scheduler
tick and requireScheduledTaskTier add 'snapshot' to the Skipper+ branch.
Backend test assertions are inverted for the now-Community endpoints
and a positive Skipper-snapshot-task test is added.

Documentation across features/, api-reference/, and operations/ is
updated to reflect the new tier mapping.
2026-05-05 12:54:26 -04:00

310 lines
11 KiB
TypeScript

/**
* 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('is accessible on 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.body.code).not.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('flags truncated=true when either scan exceeds the 1000-row cap', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const a = seedScan({ scannedAt: 1000, totalVulnerabilities: 1500 });
const b = seedScan({ scannedAt: 2000, totalVulnerabilities: 10 });
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.truncated).toBe(true);
expect(res.body.row_limit).toBe(1000);
expect(res.body.scanA.total_vulnerabilities).toBe(1500);
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
it('returns truncated=false when both scans fit within the row cap', async () => {
const a = seedScan({ scannedAt: 1000, totalVulnerabilities: 5 });
const b = seedScan({ scannedAt: 2000, totalVulnerabilities: 10 });
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.truncated).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');
});
});