mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +00:00
feat(notifications): add structured category enum to dispatcher and history (#774)
Introduce a NotificationCategory string-literal union (11 values) and thread it through dispatchAlert as a required second argument. All callers (DockerEventService, AutoHealService, ImageUpdateService, MonitorService, PolicyEnforcement, policyGate, SchedulerService, imageUpdates route) pass an explicit category at every call site, giving TypeScript compile-time enforcement that no new emit site can be added without choosing a category. DatabaseService gains an idempotent migration that adds a nullable category TEXT column to notification_history; existing rows keep category=NULL (displayed as Uncategorized in the UI). The getNotificationHistory method accepts an optional category filter that is forwarded from the GET /api/notifications/history route via a ?category= query param. NotificationPanel gains a category Select dropdown so users can filter history by category. The frontend types mirror the backend union so API responses are type-safe end-to-end. All 75 test files (1410 tests) updated to the new 4-arg dispatchAlert signature and passing.
This commit is contained in:
@@ -124,9 +124,9 @@ describe('DockerEventService - die classification', () => {
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'monitor_alert',
|
||||
expect.stringContaining('Container Crash Detected'),
|
||||
undefined,
|
||||
'web',
|
||||
expect.objectContaining({ containerName: 'web' }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -149,7 +149,7 @@ describe('DockerEventService - die classification', () => {
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
|
||||
const crashCall = mockDispatchAlert.mock.calls.find(c =>
|
||||
typeof c[1] === 'string' && c[1].includes('Crash'));
|
||||
typeof c[2] === 'string' && c[2].includes('Crash'));
|
||||
expect(crashCall).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -174,7 +174,7 @@ describe('DockerEventService - die classification', () => {
|
||||
await vi.advanceTimersByTimeAsync(400); // total > 500ms
|
||||
|
||||
const crashCall = mockDispatchAlert.mock.calls.find(c =>
|
||||
typeof c[1] === 'string' && c[1].includes('Crash'));
|
||||
typeof c[2] === 'string' && c[2].includes('Crash'));
|
||||
expect(crashCall).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -192,9 +192,9 @@ describe('DockerEventService - die classification', () => {
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'monitor_alert',
|
||||
expect.stringContaining('OOM Kill'),
|
||||
undefined,
|
||||
'hog',
|
||||
expect.objectContaining({ containerName: 'hog' }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -225,9 +225,9 @@ describe('DockerEventService - die classification', () => {
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'monitor_alert',
|
||||
expect.stringContaining('Healthcheck failed'),
|
||||
undefined,
|
||||
'api',
|
||||
expect.objectContaining({ containerName: 'api' }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -291,15 +291,15 @@ describe('DockerEventService - rate limiting', () => {
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
|
||||
const crashCalls = mockDispatchAlert.mock.calls.filter(c =>
|
||||
typeof c[1] === 'string' && c[1].includes('Crash'));
|
||||
typeof c[2] === 'string' && c[2].includes('Crash'));
|
||||
expect(crashCalls).toHaveLength(20);
|
||||
|
||||
// After the rate window, a summary warning fires.
|
||||
await vi.advanceTimersByTimeAsync(61_000);
|
||||
const summaryCalls = mockDispatchAlert.mock.calls.filter(c =>
|
||||
typeof c[1] === 'string' && c[1].includes('additional containers crashed'));
|
||||
typeof c[2] === 'string' && c[2].includes('additional containers crashed'));
|
||||
expect(summaryCalls).toHaveLength(1);
|
||||
expect(summaryCalls[0][1]).toContain('2 additional');
|
||||
expect(summaryCalls[0][2]).toContain('2 additional');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -320,9 +320,9 @@ describe('DockerEventService - malformed payloads', () => {
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'monitor_alert',
|
||||
expect.stringContaining('Container Crash Detected'),
|
||||
undefined,
|
||||
'ok',
|
||||
expect.objectContaining({ containerName: 'ok' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -375,7 +375,7 @@ describe('DockerEventService - reconciliation', () => {
|
||||
await Promise.resolve();
|
||||
|
||||
const massCall = mockDispatchAlert.mock.calls.find(c =>
|
||||
typeof c[1] === 'string' && c[1].includes('daemon interruption'));
|
||||
typeof c[2] === 'string' && c[2].includes('daemon interruption'));
|
||||
expect(massCall).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -415,9 +415,9 @@ describe('DockerEventService - reconciliation', () => {
|
||||
await Promise.resolve();
|
||||
|
||||
const crashCall = mockDispatchAlert.mock.calls.find(c =>
|
||||
typeof c[1] === 'string' && c[1].includes('crashed-app'));
|
||||
typeof c[2] === 'string' && c[2].includes('crashed-app'));
|
||||
expect(crashCall).toBeDefined();
|
||||
expect(crashCall?.[2]).toBe('my-stack');
|
||||
expect(crashCall?.[3]).toMatchObject({ stackName: 'my-stack' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -443,8 +443,8 @@ describe('DockerEventService - reconnect', () => {
|
||||
|
||||
const warn = mockDispatchAlert.mock.calls.find(c => c[0] === 'warning');
|
||||
const info = mockDispatchAlert.mock.calls.find(c => c[0] === 'info');
|
||||
expect(warn?.[1]).toContain('Lost connection');
|
||||
expect(info?.[1]).toContain('Reconnected');
|
||||
expect(warn?.[2]).toContain('Lost connection');
|
||||
expect(info?.[2]).toContain('Reconnected');
|
||||
});
|
||||
|
||||
it('shutdown cancels pending reconnect', async () => {
|
||||
@@ -504,9 +504,9 @@ describe('DockerEventService - hardening', () => {
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'monitor_alert',
|
||||
expect.stringContaining('Container Crash Detected'),
|
||||
undefined,
|
||||
'app',
|
||||
expect.objectContaining({ containerName: 'app' }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -532,9 +532,9 @@ describe('DockerEventService - hardening', () => {
|
||||
await Promise.resolve();
|
||||
|
||||
const oomCall = mockDispatchAlert.mock.calls.find(c =>
|
||||
typeof c[1] === 'string' && c[1].includes('OOM Kill'));
|
||||
typeof c[2] === 'string' && c[2].includes('OOM Kill'));
|
||||
const crashCall = mockDispatchAlert.mock.calls.find(c =>
|
||||
typeof c[1] === 'string' && c[1].includes('Crash Detected'));
|
||||
typeof c[2] === 'string' && c[2].includes('Crash Detected'));
|
||||
expect(oomCall).toBeDefined();
|
||||
expect(crashCall).toBeUndefined();
|
||||
});
|
||||
@@ -558,9 +558,9 @@ describe('DockerEventService - hardening', () => {
|
||||
// Inspect failed, so classification stays as the original 'crash'.
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'monitor_alert',
|
||||
expect.stringContaining('Container Crash Detected'),
|
||||
undefined,
|
||||
'ephemeral',
|
||||
expect.objectContaining({ containerName: 'ephemeral' }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -585,9 +585,9 @@ describe('DockerEventService - hardening', () => {
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
|
||||
const crashCalls = mockDispatchAlert.mock.calls.filter(c =>
|
||||
typeof c[1] === 'string' && c[1].includes('Crash Detected'));
|
||||
typeof c[2] === 'string' && c[2].includes('Crash Detected'));
|
||||
expect(crashCalls).toHaveLength(1);
|
||||
expect(crashCalls[0][1]).toContain('Code: 2');
|
||||
expect(crashCalls[0][2]).toContain('Code: 2');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -385,8 +385,9 @@ services:
|
||||
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'image_update_available',
|
||||
expect.stringContaining('stackA'),
|
||||
'stackA',
|
||||
{ stackName: 'stackA' },
|
||||
);
|
||||
expect(mockUpsertStackUpdateStatus).toHaveBeenCalledWith(1, 'stackA', true, expect.any(Number));
|
||||
});
|
||||
@@ -413,7 +414,7 @@ services:
|
||||
await (service as any).checkNode(1, 'local', fakeDb());
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledTimes(2);
|
||||
const dispatched = mockDispatchAlert.mock.calls.map(call => call[2]);
|
||||
const dispatched = mockDispatchAlert.mock.calls.map(call => (call[3] as any)?.stackName);
|
||||
expect(dispatched).toEqual(expect.arrayContaining(['stackA', 'stackB']));
|
||||
expect(mockSetSystemState).toHaveBeenCalledWith('image_update_notifications_backfilled', '1');
|
||||
|
||||
|
||||
@@ -274,7 +274,7 @@ describe('MonitorService - evaluateGlobalSettings', () => {
|
||||
const svc = MonitorService.getInstance();
|
||||
await (svc as any).evaluateGlobalSettings({ host_cpu_limit: '50' });
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', expect.stringContaining('CPU'), undefined);
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('CPU'), { stackName: undefined });
|
||||
});
|
||||
|
||||
it('does not dispatch when CPU below threshold', async () => {
|
||||
@@ -283,7 +283,7 @@ describe('MonitorService - evaluateGlobalSettings', () => {
|
||||
const svc = MonitorService.getInstance();
|
||||
await (svc as any).evaluateGlobalSettings({ host_cpu_limit: '50' });
|
||||
|
||||
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', expect.stringContaining('CPU'), undefined);
|
||||
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('CPU'), { stackName: undefined });
|
||||
});
|
||||
|
||||
it('dispatches RAM warning when over threshold', async () => {
|
||||
@@ -292,7 +292,7 @@ describe('MonitorService - evaluateGlobalSettings', () => {
|
||||
const svc = MonitorService.getInstance();
|
||||
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', expect.stringContaining('Memory'), undefined);
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Memory'), { stackName: undefined });
|
||||
});
|
||||
|
||||
it('dispatches disk warning when over threshold', async () => {
|
||||
@@ -301,7 +301,7 @@ describe('MonitorService - evaluateGlobalSettings', () => {
|
||||
const svc = MonitorService.getInstance();
|
||||
await (svc as any).evaluateGlobalSettings({ host_disk_limit: '90' });
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', expect.stringContaining('Disk'), undefined);
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Disk'), { stackName: undefined });
|
||||
});
|
||||
|
||||
it('skips host limits when threshold is 0 or NaN', async () => {
|
||||
@@ -309,10 +309,10 @@ describe('MonitorService - evaluateGlobalSettings', () => {
|
||||
|
||||
const svc = MonitorService.getInstance();
|
||||
await (svc as any).evaluateGlobalSettings({ host_cpu_limit: '0' });
|
||||
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', expect.stringContaining('CPU'), undefined);
|
||||
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('CPU'), { stackName: undefined });
|
||||
|
||||
await (svc as any).evaluateGlobalSettings({ host_cpu_limit: 'abc' });
|
||||
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', expect.stringContaining('CPU'), undefined);
|
||||
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('CPU'), { stackName: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -352,7 +352,7 @@ describe('MonitorService - breach state machine', () => {
|
||||
const svc = MonitorService.getInstance();
|
||||
await (svc as any).evaluate();
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', expect.stringContaining('CPU'), 'my-stack');
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('CPU'), { stackName: 'my-stack' });
|
||||
expect(mockUpdateStackAlertLastFired).toHaveBeenCalledWith(1, expect.any(Number));
|
||||
});
|
||||
|
||||
@@ -526,7 +526,7 @@ describe('MonitorService - restart_count metric', () => {
|
||||
|
||||
expect(mockGetContainerRestartCount).toHaveBeenCalledWith('c1');
|
||||
// restart_count=5 > threshold=3, should fire
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', expect.stringContaining('Restart count'), 'my-stack');
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Restart count'), { stackName: 'my-stack' });
|
||||
});
|
||||
|
||||
it('skips Docker inspect when no restart_count rules exist', async () => {
|
||||
@@ -545,7 +545,7 @@ describe('MonitorService - restart_count metric', () => {
|
||||
await (svc as any).evaluate();
|
||||
|
||||
expect(mockGetContainerRestartCount).toHaveBeenCalledWith('c1');
|
||||
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', expect.stringContaining('Restart count'), expect.anything());
|
||||
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Restart count'), expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -577,9 +577,9 @@ describe('MonitorService - Sencho version check', () => {
|
||||
(svc as any).lastVersionCheckAt = 0;
|
||||
await (svc as any).evaluate();
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('info', expect.stringContaining('0.46.0'));
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'system', expect.stringContaining('0.46.0'));
|
||||
// Message must include the real running version, not "0.0.0".
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('info', expect.stringContaining('currently running 0.45.0'));
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'system', expect.stringContaining('currently running 0.45.0'));
|
||||
expect(mockSetSystemState).toHaveBeenCalledWith('last_sencho_update_notified_version', '0.46.0');
|
||||
});
|
||||
|
||||
@@ -593,7 +593,7 @@ describe('MonitorService - Sencho version check', () => {
|
||||
(svc as any).lastVersionCheckAt = 0;
|
||||
await (svc as any).evaluate();
|
||||
|
||||
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', expect.stringContaining('0.46.0'));
|
||||
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'system', expect.stringContaining('0.46.0'));
|
||||
});
|
||||
|
||||
it('handles version check failure gracefully', async () => {
|
||||
@@ -605,7 +605,7 @@ describe('MonitorService - Sencho version check', () => {
|
||||
|
||||
// Should not throw
|
||||
await expect((svc as any).evaluate()).resolves.toBeUndefined();
|
||||
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', expect.stringContaining('available'));
|
||||
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'system', expect.stringContaining('available'));
|
||||
});
|
||||
|
||||
it('respects the 6-hour cooldown interval', async () => {
|
||||
@@ -632,7 +632,7 @@ describe('MonitorService - Sencho version check', () => {
|
||||
(svc as any).lastVersionCheckAt = 0;
|
||||
await (svc as any).evaluate();
|
||||
|
||||
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', expect.stringContaining('0.46.0'));
|
||||
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'system', expect.stringContaining('0.46.0'));
|
||||
expect(mockSetSystemState).not.toHaveBeenCalledWith('last_sencho_update_notified_version', expect.anything());
|
||||
// Should not have even attempted the lookup.
|
||||
expect(mockGetLatestVersion).not.toHaveBeenCalled();
|
||||
@@ -675,7 +675,7 @@ describe('MonitorService - Sencho version check', () => {
|
||||
expect(mockGetLatestVersion).not.toHaveBeenCalled();
|
||||
// Exactly one dispatch across both evals.
|
||||
const availabilityDispatches = mockDispatchAlert.mock.calls.filter(
|
||||
(args: unknown[]) => typeof args[1] === 'string' && args[1].includes('available'),
|
||||
(args: unknown[]) => typeof args[2] === 'string' && args[2].includes('available'),
|
||||
);
|
||||
expect(availabilityDispatches).toHaveLength(1);
|
||||
});
|
||||
@@ -691,7 +691,7 @@ describe('MonitorService - Sencho version check', () => {
|
||||
(svc as any).lastVersionCheckAt = 0;
|
||||
await (svc as any).evaluate();
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('info', expect.stringContaining('0.47.0'));
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'system', expect.stringContaining('0.47.0'));
|
||||
expect(store.last_sencho_update_notified_version).toBe('0.47.0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('NotificationService - routing logic', () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]);
|
||||
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
||||
|
||||
await svc.dispatchAlert('error', 'Container crashed', 'my-app');
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Container crashed', { stackName: 'my-app' });
|
||||
|
||||
// Should have called fetch with discord webhook URL
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
@@ -114,7 +114,7 @@ describe('NotificationService - routing logic', () => {
|
||||
]);
|
||||
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
||||
|
||||
await svc.dispatchAlert('error', 'Container crashed', 'my-app');
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Container crashed', { stackName: 'my-app' });
|
||||
|
||||
// Should NOT have called the route's discord channel
|
||||
expect(mockFetch).not.toHaveBeenCalledWith(
|
||||
@@ -132,7 +132,7 @@ describe('NotificationService - routing logic', () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]);
|
||||
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
||||
|
||||
await svc.dispatchAlert('warning', 'Host CPU high');
|
||||
await svc.dispatchAlert('warning', 'monitor_alert', 'Host CPU high');
|
||||
|
||||
// Should have called global agent (no stackName means skip routing)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
@@ -148,7 +148,7 @@ describe('NotificationService - routing logic', () => {
|
||||
]);
|
||||
mockGetEnabledAgents.mockReturnValue([]);
|
||||
|
||||
await svc.dispatchAlert('error', 'Test', 'my-app');
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Test', { stackName: 'my-app' });
|
||||
|
||||
// Both routes match, both should be dispatched (all matching routes fire)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
@@ -169,7 +169,7 @@ describe('NotificationService - routing logic', () => {
|
||||
]);
|
||||
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
||||
|
||||
await svc.dispatchAlert('error', 'Test', 'production-app');
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Test', { stackName: 'production-app' });
|
||||
|
||||
// Route should not fire
|
||||
expect(mockFetch).not.toHaveBeenCalledWith(
|
||||
@@ -189,7 +189,7 @@ describe('NotificationService - routing logic', () => {
|
||||
]);
|
||||
mockGetEnabledAgents.mockReturnValue([]);
|
||||
|
||||
await svc.dispatchAlert('info', 'Update complete', 'app-b');
|
||||
await svc.dispatchAlert('info', 'image_update_applied', 'Update complete', { stackName: 'app-b' });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://discord.com/api/webhooks/123/abc',
|
||||
@@ -202,14 +202,14 @@ describe('NotificationService - routing logic', () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network timeout'));
|
||||
|
||||
// Should not throw
|
||||
await expect(svc.dispatchAlert('error', 'Crash', 'my-app')).resolves.toBeUndefined();
|
||||
await expect(svc.dispatchAlert('error', 'monitor_alert', 'Crash', { stackName: 'my-app' })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not dispatch to global agents when routes array is empty and no stackName', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([]);
|
||||
mockGetEnabledAgents.mockReturnValue([]);
|
||||
|
||||
await svc.dispatchAlert('info', 'Test');
|
||||
await svc.dispatchAlert('info', 'system', 'Test');
|
||||
|
||||
// No routes, no agents — just logs and broadcasts
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
@@ -219,10 +219,11 @@ describe('NotificationService - routing logic', () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([]);
|
||||
mockGetEnabledAgents.mockReturnValue([]);
|
||||
|
||||
await svc.dispatchAlert('info', 'Should be logged');
|
||||
await svc.dispatchAlert('info', 'system', 'Should be logged');
|
||||
|
||||
expect(mockAddNotificationHistory).toHaveBeenCalledWith(1, {
|
||||
level: 'info',
|
||||
category: 'system',
|
||||
message: 'Should be logged',
|
||||
timestamp: expect.any(Number),
|
||||
stack_name: undefined,
|
||||
@@ -234,10 +235,11 @@ describe('NotificationService - routing logic', () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([]);
|
||||
mockGetEnabledAgents.mockReturnValue([]);
|
||||
|
||||
await svc.dispatchAlert('warning', 'Restarted', 'my-app', 'my-app-web-1');
|
||||
await svc.dispatchAlert('warning', 'autoheal_triggered', 'Restarted', { stackName: 'my-app', containerName: 'my-app-web-1' });
|
||||
|
||||
expect(mockAddNotificationHistory).toHaveBeenCalledWith(1, {
|
||||
level: 'warning',
|
||||
category: 'autoheal_triggered',
|
||||
message: 'Restarted',
|
||||
timestamp: expect.any(Number),
|
||||
stack_name: 'my-app',
|
||||
@@ -250,7 +252,7 @@ describe('NotificationService - routing logic', () => {
|
||||
makeRoute({ channel_type: 'slack', channel_url: 'https://hooks.slack.com/services/route-specific' }),
|
||||
]);
|
||||
|
||||
await svc.dispatchAlert('warning', 'Alert', 'my-app');
|
||||
await svc.dispatchAlert('warning', 'monitor_alert', 'Alert', { stackName: 'my-app' });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://hooks.slack.com/services/route-specific',
|
||||
@@ -266,7 +268,7 @@ describe('NotificationService - routing logic', () => {
|
||||
makeRoute({ channel_type: 'webhook', channel_url: 'https://example.com/hook' }),
|
||||
]);
|
||||
|
||||
await svc.dispatchAlert('error', 'Critical failure', 'my-app');
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Critical failure', { stackName: 'my-app' });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://example.com/hook',
|
||||
@@ -281,7 +283,7 @@ describe('NotificationService - routing logic', () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]);
|
||||
mockFetch.mockRejectedValueOnce(new Error('Connection refused'));
|
||||
|
||||
await svc.dispatchAlert('error', 'Test', 'my-app');
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Test', { stackName: 'my-app' });
|
||||
|
||||
expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith(
|
||||
1, // notification id from mock
|
||||
@@ -294,7 +296,7 @@ describe('NotificationService - routing logic', () => {
|
||||
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
||||
mockFetch.mockRejectedValueOnce(new Error('Timeout'));
|
||||
|
||||
await svc.dispatchAlert('warning', 'Host alert');
|
||||
await svc.dispatchAlert('warning', 'monitor_alert', 'Host alert');
|
||||
|
||||
expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith(
|
||||
1,
|
||||
@@ -306,7 +308,7 @@ describe('NotificationService - routing logic', () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]);
|
||||
mockFetch.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
await svc.dispatchAlert('info', 'All good', 'my-app');
|
||||
await svc.dispatchAlert('info', 'system', 'All good', { stackName: 'my-app' });
|
||||
|
||||
expect(mockUpdateNotificationDispatchError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -161,7 +161,8 @@ describe('enforcePolicyPreDeploy', () => {
|
||||
expect(result.violations).toEqual([]);
|
||||
expect(notificationStub.dispatchAlert).toHaveBeenCalledTimes(1);
|
||||
expect(notificationStub.dispatchAlert.mock.calls[0][0]).toBe('warning');
|
||||
expect(notificationStub.dispatchAlert.mock.calls[0][1]).toContain('Trivy not installed');
|
||||
expect(notificationStub.dispatchAlert.mock.calls[0][1]).toBe('scan_finding');
|
||||
expect(notificationStub.dispatchAlert.mock.calls[0][2]).toContain('Trivy not installed');
|
||||
expect(composeStub.listStackImages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -162,11 +162,11 @@ describe('SchedulerService - scheduled scan policy alerts', () => {
|
||||
|
||||
const warningCalls = mockDispatchAlert.mock.calls.filter((c) => c[0] === 'warning');
|
||||
expect(warningCalls).toHaveLength(2);
|
||||
expect(warningCalls[0][1]).toContain('prod-high-gate');
|
||||
expect(warningCalls[0][1]).toContain('nginx:1.14');
|
||||
expect(warningCalls[0][1]).toContain('CRITICAL');
|
||||
expect(warningCalls[0][1]).toContain('HIGH');
|
||||
expect(warningCalls[1][1]).toContain('redis:6');
|
||||
expect(warningCalls[0][2]).toContain('prod-high-gate');
|
||||
expect(warningCalls[0][2]).toContain('nginx:1.14');
|
||||
expect(warningCalls[0][2]).toContain('CRITICAL');
|
||||
expect(warningCalls[0][2]).toContain('HIGH');
|
||||
expect(warningCalls[1][2]).toContain('redis:6');
|
||||
});
|
||||
|
||||
it('does not dispatch any policy alert when no violations occur', async () => {
|
||||
@@ -177,7 +177,7 @@ describe('SchedulerService - scheduled scan policy alerts', () => {
|
||||
await svc.triggerTask(300);
|
||||
|
||||
const warningCalls = mockDispatchAlert.mock.calls.filter(
|
||||
(c) => c[0] === 'warning' && typeof c[1] === 'string' && c[1].includes('Policy'),
|
||||
(c) => c[0] === 'warning' && typeof c[2] === 'string' && c[2].includes('Policy'),
|
||||
);
|
||||
expect(warningCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -870,7 +870,7 @@ describe('SchedulerService - error handling', () => {
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(91);
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('error', expect.stringContaining('failed'), undefined);
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('error', 'system', expect.stringContaining('failed'), { stackName: undefined });
|
||||
});
|
||||
|
||||
it('dispatches recovery notification when previous status was failure', async () => {
|
||||
@@ -890,7 +890,7 @@ describe('SchedulerService - error handling', () => {
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(92);
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('info', expect.stringContaining('recovered'), 'my-stack');
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'system', expect.stringContaining('recovered'), { stackName: 'my-stack' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -946,13 +946,15 @@ describe('SchedulerService - scheduled scan notifications', () => {
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'scan_finding',
|
||||
expect.stringContaining('nightly-scan'),
|
||||
undefined,
|
||||
{ stackName: undefined },
|
||||
);
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'scan_finding',
|
||||
expect.stringContaining('Scanned 3 image(s)'),
|
||||
undefined,
|
||||
{ stackName: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -965,8 +967,9 @@ describe('SchedulerService - scheduled scan notifications', () => {
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
'scan_finding',
|
||||
expect.stringContaining('2 failed'),
|
||||
undefined,
|
||||
{ stackName: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -979,8 +982,9 @@ describe('SchedulerService - scheduled scan notifications', () => {
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'scan_finding',
|
||||
expect.stringContaining('completed'),
|
||||
'web-stack',
|
||||
{ stackName: 'web-stack' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -998,8 +1002,9 @@ describe('SchedulerService - scheduled scan notifications', () => {
|
||||
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'scan_finding',
|
||||
expect.stringContaining('recovered-scan'),
|
||||
undefined,
|
||||
{ stackName: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1032,8 +1037,9 @@ describe('SchedulerService - scheduled scan notifications', () => {
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'system',
|
||||
expect.stringMatching(/failed.*Trivy/i),
|
||||
'payment-stack',
|
||||
{ stackName: 'payment-stack' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1046,7 +1052,7 @@ describe('SchedulerService - scheduled scan notifications', () => {
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(206);
|
||||
|
||||
const message = mockDispatchAlert.mock.calls[0][1] as string;
|
||||
const message = mockDispatchAlert.mock.calls[0][2] as string;
|
||||
expect(message).toContain('2 critical');
|
||||
expect(message).toContain('5 high');
|
||||
expect(message).toContain('10 medium');
|
||||
@@ -1061,8 +1067,9 @@ describe('SchedulerService - scheduled scan notifications', () => {
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'scan_finding',
|
||||
expect.stringContaining('No images to scan'),
|
||||
undefined,
|
||||
{ stackName: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1075,8 +1082,9 @@ describe('SchedulerService - scheduled scan notifications', () => {
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'scan_finding',
|
||||
expect.stringContaining('All 12 image(s) already scanned recently'),
|
||||
undefined,
|
||||
{ stackName: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1196,8 +1204,9 @@ describe('SchedulerService - invalid cron at execution time', () => {
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'system',
|
||||
expect.stringContaining('failed'),
|
||||
undefined
|
||||
{ stackName: undefined },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user