mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-26 02:06:49 +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:
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user