Files
sencho/backend/src/__tests__/scheduler-policy.test.ts
T
Anso 85842cc547 feat: add service-scoped stack alert rules (#1681)
* feat: add service-scoped stack alert rules

Stack alerts can target one Compose service or all services. Breach timers
are per container and cooldowns are per service so a healthy sibling no
longer clears another container's timer or silences a different service.

* fix: gate remote scoped alert creates without losing the body

Remote hops skip JSON parsing so the proxy stream stays pipeable, which
left service_name invisible to the capability gate. Buffer POST /alerts
bodies for inspection, fail closed when the remote lacks the capability,
and rewrite the buffered bytes on forward. Restore alert-panel alt text
to match the unchanged screenshot.

* fix: bound remote alert body buffer and reject encoded JSON

Cap proxied POST /alerts buffering at the local 100KB JSON limit with
structured 413 cleanup, reject non-identity Content-Encoding with 415 so
compressed scoped bodies cannot bypass the mixed-version gate, and cover
oversized, chunked, and gzip regressions.

* fix: harden service-scoped alert delete, cooldown, and proxy gates

Reject non-digit alert ids, dual-write last_fired_at for rollback safety,
gate cooldown on persisted notification history, fail-fast oversized proxy
bodies with 413, and clarify Not in compose UI semantics.

* test: expect dispatchAlert persisted result in crash-safety cases

Update notification-routing assertions for the new { persisted } return
shape so CI matches the cooldown-gating contract.
2026-07-23 17:57:04 -04:00

218 lines
7.1 KiB
TypeScript

/**
* Pins the scheduler's policy-alert fan-out.
*
* After `trivy.scanAllNodeImages` resolves with one or more policy
* violations, `SchedulerService.executeScan` must dispatch a warning-level
* notification for each violation so an operator can triage them. Scheduled
* scans never auto-quarantine in the current scope; any regression that
* silently swallows violations turns the feature back into post-hoc logging.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const {
mockGetDueScheduledTasks, mockCreateScheduledTaskRun, mockUpdateScheduledTaskRun,
mockUpdateScheduledTask, mockCleanupOldTaskRuns, mockGetScheduledTask, mockGetNodes, mockGetNode,
mockCreateSnapshot, mockInsertSnapshotFiles, mockClearStackUpdateStatus,
mockMarkStaleRunsAsFailed, mockDeleteOldScans,
mockGetTier,
mockDispatchAlert,
mockGetProxyTarget,
mockIsTrivyAvailable,
mockScanAllNodeImages,
} = vi.hoisted(() => ({
mockGetDueScheduledTasks: vi.fn().mockReturnValue([]),
mockCreateScheduledTaskRun: vi.fn().mockReturnValue(1),
mockUpdateScheduledTaskRun: vi.fn(),
mockUpdateScheduledTask: vi.fn(),
mockCleanupOldTaskRuns: vi.fn(),
mockGetScheduledTask: vi.fn(),
mockGetNodes: vi.fn().mockReturnValue([]),
mockGetNode: vi.fn().mockReturnValue({ id: 1, name: 'local', type: 'local', status: 'online' }),
mockCreateSnapshot: vi.fn().mockReturnValue(1),
mockInsertSnapshotFiles: vi.fn(),
mockClearStackUpdateStatus: vi.fn(),
mockMarkStaleRunsAsFailed: vi.fn().mockReturnValue(0),
mockDeleteOldScans: vi.fn().mockReturnValue(0),
mockGetTier: vi.fn().mockReturnValue('paid'),
mockDispatchAlert: vi.fn().mockResolvedValue({ persisted: true }),
mockGetProxyTarget: vi.fn().mockReturnValue(null),
mockIsTrivyAvailable: vi.fn().mockReturnValue(true),
mockScanAllNodeImages: vi.fn(),
}));
vi.mock('../services/DatabaseService', () => ({
DatabaseService: {
getInstance: () => ({
getDueScheduledTasks: mockGetDueScheduledTasks,
createScheduledTaskRun: mockCreateScheduledTaskRun,
updateScheduledTaskRun: mockUpdateScheduledTaskRun,
updateScheduledTask: mockUpdateScheduledTask,
cleanupOldTaskRuns: mockCleanupOldTaskRuns,
getScheduledTask: mockGetScheduledTask,
getNodes: mockGetNodes,
getNode: mockGetNode,
createSnapshot: mockCreateSnapshot,
insertSnapshotFiles: mockInsertSnapshotFiles,
clearStackUpdateStatus: mockClearStackUpdateStatus,
markStaleRunsAsFailed: mockMarkStaleRunsAsFailed,
deleteOldScans: mockDeleteOldScans,
}),
},
}));
vi.mock('../services/LicenseService', () => ({
LicenseService: {
getInstance: () => ({
getTier: mockGetTier,
}),
},
}));
vi.mock('../services/NotificationService', () => ({
NotificationService: {
getInstance: () => ({ dispatchAlert: mockDispatchAlert }),
},
}));
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getDefaultNodeId: () => 1,
getNode: mockGetNode,
getProxyTarget: mockGetProxyTarget,
}),
},
}));
vi.mock('../services/TrivyService', () => ({
default: {
getInstance: () => ({
isTrivyAvailable: mockIsTrivyAvailable,
scanAllNodeImages: mockScanAllNodeImages,
getSource: () => 'managed',
detectTrivy: vi.fn().mockResolvedValue(undefined),
}),
},
}));
import { SchedulerService } from '../services/SchedulerService';
function makeScanTask() {
return {
id: 300,
name: 'policy-scan',
action: 'scan',
cron_expression: '0 2 * * *',
enabled: true,
target_id: null,
node_id: 1,
created_by: 'admin',
last_status: null,
};
}
function summaryWith(violations: Array<{
imageRef: string;
policyId: number;
policyName: string;
maxSeverity: string;
severity: string;
scanId: number;
reasons: Array<'severity' | 'kev' | 'fixable'>;
}>) {
return {
scanned: violations.length,
skipped: 0,
failed: 0,
severity: { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 },
violations,
};
}
describe('SchedulerService - scheduled scan policy alerts', () => {
beforeEach(() => {
vi.clearAllMocks();
(SchedulerService as unknown as { instance?: SchedulerService }).instance = undefined;
});
it('dispatches a warning alert for every violated scan', async () => {
mockGetScheduledTask.mockReturnValue(makeScanTask());
mockScanAllNodeImages.mockResolvedValue(summaryWith([
{
imageRef: 'nginx:1.14',
policyId: 1,
policyName: 'prod-high-gate',
maxSeverity: 'HIGH',
severity: 'MEDIUM',
scanId: 42,
reasons: ['kev'],
},
{
imageRef: 'redis:6',
policyId: 1,
policyName: 'prod-high-gate',
maxSeverity: 'HIGH',
severity: 'HIGH',
scanId: 43,
reasons: ['severity'],
},
]));
const svc = SchedulerService.getInstance();
await svc.triggerTask(300);
const warningCalls = mockDispatchAlert.mock.calls.filter((c) => c[0] === 'warning');
expect(warningCalls).toHaveLength(2);
// The first violation matched KEV, so the alert names the known-exploited
// input and never the severity ceiling it did not enforce.
expect(warningCalls[0][2]).toContain('prod-high-gate');
expect(warningCalls[0][2]).toContain('nginx:1.14');
expect(warningCalls[0][2]).toContain('known-exploited CVE (KEV)');
expect(warningCalls[0][2]).not.toContain('HIGH');
// The second matched the severity threshold.
expect(warningCalls[1][2]).toContain('redis:6');
expect(warningCalls[1][2]).toContain('severity threshold');
});
it('does not dispatch any policy alert when no violations occur', async () => {
mockGetScheduledTask.mockReturnValue(makeScanTask());
mockScanAllNodeImages.mockResolvedValue(summaryWith([]));
const svc = SchedulerService.getInstance();
await svc.triggerTask(300);
const warningCalls = mockDispatchAlert.mock.calls.filter(
(c) => c[0] === 'warning' && typeof c[2] === 'string' && c[2].includes('Policy'),
);
expect(warningCalls).toHaveLength(0);
});
it('takes no quarantine action (alerts only, no stack lifecycle calls)', async () => {
// The current scope explicitly rejects auto-quarantine. The scheduler
// must never call DockerController / ComposeService off the scan path.
// We verify by asserting only the DB task-run + notification surfaces
// were touched, not any docker or compose mock.
mockGetScheduledTask.mockReturnValue(makeScanTask());
mockScanAllNodeImages.mockResolvedValue(summaryWith([
{
imageRef: 'nginx:1.14',
policyId: 1,
policyName: 'block-critical',
maxSeverity: 'CRITICAL',
severity: 'CRITICAL',
scanId: 44,
reasons: ['severity'],
},
]));
const svc = SchedulerService.getInstance();
await svc.triggerTask(300);
// The violation produced a warning alert.
const warningCalls = mockDispatchAlert.mock.calls.filter((c) => c[0] === 'warning');
expect(warningCalls.length).toBeGreaterThan(0);
// Task-run record written (happy path completion), not an error.
expect(mockCreateScheduledTaskRun).toHaveBeenCalled();
});
});