mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 09:46:47 +00:00
fix(notifications): harden alert dispatch crash-safety and redact webhook secrets in logs (#1255)
* fix(notifications): harden alert dispatch crash-safety and redact webhook secrets in logs Make NotificationService.dispatchAlert never reject so the many fire-and-forget callers (monitors, event streams, policy and image-update paths) cannot trigger an unhandledRejection on an unhealthy database. The whole dispatch body now sits inside a guard covering node resolution, the history insert, channel-table reads, and the WebSocket broadcast; a failure logs and drops the notification instead of crashing the process. An inner guard still splits the write-success and write-failure metrics. Also: - Redact webhook URLs in diagnostic logs via a new maskWebhookUrl helper; Discord/Slack/custom webhook URLs embed their token in the path, so only the origin is safe to emit. - Add error logging to four notification-history route handlers that previously swallowed database errors silently before returning 500. - Snapshot the subscriber set before broadcasting so a close/error handler firing mid-send cannot mutate the set during iteration. - Sanitize the admin-supplied route name in dispatch log lines. Adds tests for dispatch crash-safety (write failure, post-write routing failure, broadcast send failure), the success-path write metric, webhook URL masking, and history-route error logging. * fix(notifications): sanitize route name and patterns in create logs Apply sanitizeForLog to the admin-supplied route name and stack patterns in the route-creation log lines, matching the dispatch-site sanitization and closing the remaining log-injection path on this feature. Also extend tests: userinfo-stripping in maskWebhookUrl, subscriber-set snapshot behavior under an unsubscribe-during-send, and error logging on the mark-read, delete-one, and clear-all notification-history handlers.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* Unit tests for Notification Routing — CRUD operations on notification_routes,
|
||||
* routing logic in NotificationService, and edge cases.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// ── Hoisted mocks ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -55,6 +55,7 @@ const mockFetch = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import { StackActivityMetricsService } from '../services/StackActivityMetricsService';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -473,3 +474,100 @@ describe('NotificationService - routing logic', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationService - crash safety (dispatchAlert never rejects)', () => {
|
||||
let svc: NotificationService;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(NotificationService as any).instance = undefined;
|
||||
svc = NotificationService.getInstance();
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('resolves (does not reject) and skips dispatch when the history write throws', async () => {
|
||||
const recordSpy = vi.spyOn(StackActivityMetricsService.getInstance(), 'record');
|
||||
mockAddNotificationHistory.mockImplementationOnce(() => {
|
||||
throw new Error('database is locked');
|
||||
});
|
||||
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
||||
|
||||
await expect(
|
||||
svc.dispatchAlert('error', 'monitor_alert', 'Container crashed', { stackName: 'my-app' }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
// No external dispatch and no routing read when there is no persisted row.
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(mockGetEnabledNotificationRoutes).not.toHaveBeenCalled();
|
||||
// The write failure is recorded for metrics and logged.
|
||||
expect(recordSpy).toHaveBeenCalledWith(1, 'write', expect.any(Number), false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('[Notify] Failed to persist notification:', expect.any(Error));
|
||||
recordSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('records a successful write metric on the happy path', async () => {
|
||||
const recordSpy = vi.spyOn(StackActivityMetricsService.getInstance(), 'record');
|
||||
|
||||
await svc.dispatchAlert('info', 'system', 'Host rebooted');
|
||||
|
||||
expect(recordSpy).toHaveBeenCalledWith(1, 'write', expect.any(Number), true);
|
||||
recordSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('resolves (does not reject) when reading routes throws after a successful write', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockImplementationOnce(() => {
|
||||
throw new Error('database read failed');
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.dispatchAlert('error', 'monitor_alert', 'Container crashed', { stackName: 'my-app' }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
// The history row was still written; only routing failed, and it was logged.
|
||||
expect(mockAddNotificationHistory).toHaveBeenCalledTimes(1);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('[Notify] dispatchAlert failed:', expect.any(Error));
|
||||
});
|
||||
|
||||
it('resolves (does not reject) when a subscriber send throws during broadcast', async () => {
|
||||
// A subscriber whose socket reports OPEN but throws on send exercises the
|
||||
// broadcast leg of the outer guard.
|
||||
const throwingWs = {
|
||||
readyState: 1, // WebSocket.OPEN
|
||||
send: () => { throw new Error('socket write failed'); },
|
||||
} as unknown as import('ws').WebSocket;
|
||||
svc.subscribe(throwingWs);
|
||||
|
||||
await expect(
|
||||
svc.dispatchAlert('info', 'system', 'Host rebooted'),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(mockAddNotificationHistory).toHaveBeenCalledTimes(1);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('[Notify] dispatchAlert failed:', expect.any(Error));
|
||||
});
|
||||
|
||||
it('snapshots subscribers so an unsubscribe during a send does not skip later subscribers', async () => {
|
||||
const sent: string[] = [];
|
||||
let unsubscribeB: () => void = () => {};
|
||||
const wsB = {
|
||||
readyState: 1, // WebSocket.OPEN
|
||||
send: () => { sent.push('B'); },
|
||||
} as unknown as import('ws').WebSocket;
|
||||
// A's send removes B mid-iteration, mimicking a 'close' handler firing.
|
||||
const wsA = {
|
||||
readyState: 1,
|
||||
send: () => { sent.push('A'); unsubscribeB(); },
|
||||
} as unknown as import('ws').WebSocket;
|
||||
svc.subscribe(wsA);
|
||||
unsubscribeB = svc.subscribe(wsB);
|
||||
|
||||
await svc.dispatchAlert('info', 'system', 'Host rebooted');
|
||||
|
||||
// B still receives the broadcast because iteration runs over a snapshot.
|
||||
expect(sent).toEqual(['A', 'B']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user