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:
Anso
2026-05-29 21:09:57 -04:00
committed by GitHub
parent 69edb0dcbb
commit 7d4e61625f
6 changed files with 340 additions and 72 deletions
@@ -0,0 +1,49 @@
/**
* Unit tests for notification channel helpers. Focused on maskWebhookUrl,
* which must never let a channel's embedded auth token reach a log line.
*/
import { describe, it, expect } from 'vitest';
import { maskWebhookUrl } from '../helpers/notificationChannels';
describe('maskWebhookUrl', () => {
it('redacts the token-bearing path of a Discord webhook URL', () => {
const masked = maskWebhookUrl('https://discord.com/api/webhooks/123456789/SuP3rS3cr3tT0k3n');
expect(masked).toBe('https://discord.com/<redacted>');
expect(masked).not.toContain('SuP3rS3cr3tT0k3n');
});
it('redacts the path of a Slack webhook URL', () => {
const masked = maskWebhookUrl('https://hooks.slack.com/services/T000/B000/XXXXSECRET');
expect(masked).toBe('https://hooks.slack.com/<redacted>');
expect(masked).not.toContain('XXXXSECRET');
});
it('redacts a secret carried in the query string', () => {
const masked = maskWebhookUrl('https://example.com/?token=abc123secret');
expect(masked).toBe('https://example.com/<redacted>');
expect(masked).not.toContain('abc123secret');
});
it('returns the bare origin when there is no path or query to hide', () => {
expect(maskWebhookUrl('https://example.com')).toBe('https://example.com');
expect(maskWebhookUrl('https://example.com/')).toBe('https://example.com');
});
it('strips embedded userinfo credentials (origin omits user:pass@)', () => {
const masked = maskWebhookUrl('https://user:s3cr3t@example.com/');
expect(masked).toBe('https://example.com');
expect(masked).not.toContain('s3cr3t');
expect(masked).not.toContain('user');
});
it('returns a placeholder for empty or non-string input', () => {
expect(maskWebhookUrl('')).toBe('<no url>');
expect(maskWebhookUrl(undefined)).toBe('<no url>');
expect(maskWebhookUrl(null)).toBe('<no url>');
expect(maskWebhookUrl(42)).toBe('<no url>');
});
it('returns a placeholder for an unparseable URL', () => {
expect(maskWebhookUrl('not a url')).toBe('<invalid url>');
});
});
@@ -528,3 +528,77 @@ describe('DELETE /api/notifications/:id - validation', () => {
expect(res.body.error).toContain('Invalid');
});
});
// --- Notification history endpoints ---
describe('GET /api/notifications - history', () => {
afterEach(() => {
vi.restoreAllMocks();
// Restore the license spies the suite relies on after a full mock reset.
vi.spyOn(licenseService, 'getTier').mockReturnValue('paid');
vi.spyOn(licenseService, 'getVariant').mockReturnValue('admiral');
});
it('returns 200 with an array for an authenticated user', async () => {
const res = await request(app).get('/api/notifications').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('returns 401 without auth', async () => {
const res = await request(app).get('/api/notifications');
expect(res.status).toBe(401);
});
it('returns 500 and logs the error when the history read throws', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(DatabaseService.getInstance(), 'getNotificationHistory').mockImplementationOnce(() => {
throw new Error('database is locked');
});
const res = await request(app).get('/api/notifications').set('Cookie', authCookie);
expect(res.status).toBe(500);
expect(res.body.error).toBe('Failed to fetch notifications');
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch notifications:', expect.any(Error));
});
it('POST /read returns 500 and logs the error when the mark-read write throws', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(DatabaseService.getInstance(), 'markAllNotificationsRead').mockImplementationOnce(() => {
throw new Error('database is locked');
});
const res = await request(app).post('/api/notifications/read').set('Cookie', authCookie);
expect(res.status).toBe(500);
expect(res.body.error).toBe('Failed to mark notifications read');
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to mark notifications read:', expect.any(Error));
});
it('DELETE /:id returns 500 and logs the error when the delete throws', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(DatabaseService.getInstance(), 'deleteNotification').mockImplementationOnce(() => {
throw new Error('database is locked');
});
const res = await request(app).delete('/api/notifications/1').set('Cookie', authCookie);
expect(res.status).toBe(500);
expect(res.body.error).toBe('Failed to delete notification');
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to delete notification:', expect.any(Error));
});
it('DELETE / returns 500 and logs the error when the clear-all write throws', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(DatabaseService.getInstance(), 'deleteAllNotifications').mockImplementationOnce(() => {
throw new Error('database is locked');
});
const res = await request(app).delete('/api/notifications').set('Cookie', authCookie);
expect(res.status).toBe(500);
expect(res.body.error).toBe('Failed to clear notifications');
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to clear notifications:', expect.any(Error));
});
});
@@ -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']);
});
});