mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 01:14:14 +00:00
feat: add notification suppression rules (#1525)
* feat: add notification suppression rules * fix: restore label routing and routing test mocks for suppression * fix: allow bell mute shortcuts for history-only notification categories Suppression rule validation used the routable category whitelist, which rejected history-only categories such as update_started that appear in the bell during stack updates. * feat: expand Mute Rules UX with compose-first entry points and activity badges * fix: add missing NodeContext mocks for notification suppression tests
This commit is contained in:
@@ -120,6 +120,26 @@ describe('hubOnlyGuard', () => {
|
||||
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
|
||||
});
|
||||
|
||||
it('rejects /api/notification-suppression-rules with 403 when nodeId targets a remote node', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/notification-suppression-rules/')
|
||||
.set('Authorization', authHeader)
|
||||
.set('x-node-id', String(remoteNodeId));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
|
||||
});
|
||||
|
||||
it('rejects /api/notification-suppression-rules (no trailing slash) with 403 when nodeId targets a remote node', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/notification-suppression-rules')
|
||||
.set('Authorization', authHeader)
|
||||
.set('x-node-id', String(remoteNodeId));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
|
||||
});
|
||||
|
||||
// Regression for the Global Observability admin gate: the logs feed's
|
||||
// `requireAdmin` lives in the local route handler, which the proxy skips when
|
||||
// forwarding a remote nodeId. Without these prefixes the guard would let the
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
matchesNotificationFilters,
|
||||
ruleNeedsStackLabels,
|
||||
appliesToBell,
|
||||
appliesToExternal,
|
||||
} from '../helpers/notificationMatchers';
|
||||
import type { NotificationMatchContext } from '../helpers/notificationMatchers';
|
||||
|
||||
const baseCtx: NotificationMatchContext = {
|
||||
localNodeId: 1,
|
||||
stackName: 'my-app',
|
||||
category: 'monitor_alert',
|
||||
level: 'error',
|
||||
stackLabelIds: [10],
|
||||
};
|
||||
|
||||
describe('notificationMatchers', () => {
|
||||
it('matches when all non-empty filters pass', () => {
|
||||
expect(matchesNotificationFilters(baseCtx, {
|
||||
node_id: null,
|
||||
stack_patterns: ['my-app'],
|
||||
label_ids: [10],
|
||||
categories: ['monitor_alert'],
|
||||
levels: ['error'],
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when node_id does not match', () => {
|
||||
expect(matchesNotificationFilters(baseCtx, {
|
||||
node_id: 2,
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when stack pattern does not match', () => {
|
||||
expect(matchesNotificationFilters(baseCtx, {
|
||||
node_id: null,
|
||||
stack_patterns: ['other'],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when category does not match', () => {
|
||||
expect(matchesNotificationFilters(baseCtx, {
|
||||
node_id: null,
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: ['deploy_success'],
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when level does not match', () => {
|
||||
expect(matchesNotificationFilters(baseCtx, {
|
||||
node_id: null,
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: ['info'],
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('matches any when all matchers empty', () => {
|
||||
expect(matchesNotificationFilters(baseCtx, {
|
||||
node_id: null,
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('detects when stack labels are needed', () => {
|
||||
expect(ruleNeedsStackLabels([{ node_id: null, stack_patterns: [], label_ids: [1], categories: null }])).toBe(true);
|
||||
expect(ruleNeedsStackLabels([{ node_id: null, stack_patterns: [], label_ids: null, categories: null }])).toBe(false);
|
||||
});
|
||||
|
||||
it('applies_to helpers', () => {
|
||||
expect(appliesToBell('bell')).toBe(true);
|
||||
expect(appliesToBell('external')).toBe(false);
|
||||
expect(appliesToExternal('external')).toBe(true);
|
||||
expect(appliesToExternal('bell')).toBe(false);
|
||||
expect(appliesToBell('both')).toBe(true);
|
||||
expect(appliesToExternal('both')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const {
|
||||
mockGetEnabledNotificationRoutes,
|
||||
mockGetEnabledNotificationSuppressionRules,
|
||||
mockGetEnabledAgents,
|
||||
mockGetStackLabelIds,
|
||||
mockAddNotificationHistory,
|
||||
mockUpdateNotificationDispatchError,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]),
|
||||
mockGetEnabledNotificationSuppressionRules: vi.fn().mockReturnValue([]),
|
||||
mockGetEnabledAgents: vi.fn().mockReturnValue([]),
|
||||
mockGetStackLabelIds: vi.fn().mockReturnValue([]),
|
||||
mockAddNotificationHistory: vi.fn().mockReturnValue({
|
||||
@@ -30,6 +32,7 @@ vi.mock('../services/DatabaseService', () => ({
|
||||
DatabaseService: {
|
||||
getInstance: () => ({
|
||||
getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes,
|
||||
getEnabledNotificationSuppressionRules: mockGetEnabledNotificationSuppressionRules,
|
||||
getEnabledAgents: mockGetEnabledAgents,
|
||||
getStackLabelIds: mockGetStackLabelIds,
|
||||
addNotificationHistory: mockAddNotificationHistory,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Integration tests for notification suppression rules CRUD.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let authCookie: string;
|
||||
let viewerCookie: string;
|
||||
|
||||
const validBody = {
|
||||
name: 'Mute staging',
|
||||
stack_patterns: ['staging'],
|
||||
categories: ['monitor_alert'],
|
||||
levels: ['warning'],
|
||||
applies_to: 'both',
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
|
||||
({ app } = await import('../index'));
|
||||
authCookie = await loginAsTestAdmin(app);
|
||||
|
||||
const viewerHash = await bcrypt.hash('viewerpass', 1);
|
||||
DatabaseService.getInstance().addUser({ username: 'viewer', password_hash: viewerHash, role: 'viewer' });
|
||||
const viewerRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'viewer', password: 'viewerpass' });
|
||||
const cookies = viewerRes.headers['set-cookie'] as string | string[];
|
||||
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('Notification suppression - auth enforcement', () => {
|
||||
it('GET returns 401 without auth', async () => {
|
||||
const res = await request(app).get('/api/notification-suppression-rules');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('GET returns 403 for viewer', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/notification-suppression-rules')
|
||||
.set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('POST returns 403 for viewer', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/notification-suppression-rules')
|
||||
.set('Cookie', viewerCookie)
|
||||
.send(validBody);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Notification suppression - CRUD', () => {
|
||||
it('POST creates a rule on Community tier', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/notification-suppression-rules')
|
||||
.set('Cookie', authCookie)
|
||||
.send(validBody);
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.name).toBe('Mute staging');
|
||||
expect(res.body.applies_to).toBe('both');
|
||||
if (typeof res.body?.id === 'number') {
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(res.body.id);
|
||||
}
|
||||
});
|
||||
|
||||
it('GET lists rules', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/notification-suppression-rules')
|
||||
.set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
it('POST rejects invalid applies_to', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/notification-suppression-rules')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ ...validBody, applies_to: 'invalid' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST rejects invalid levels', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/notification-suppression-rules')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ ...validBody, levels: ['critical'] });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST accepts history-only category update_started (bell-visible)', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/notification-suppression-rules')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'Mute stack updates',
|
||||
stack_patterns: [],
|
||||
categories: ['update_started'],
|
||||
levels: null,
|
||||
applies_to: 'both',
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.categories).toEqual(['update_started']);
|
||||
if (typeof res.body?.id === 'number') {
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(res.body.id);
|
||||
}
|
||||
});
|
||||
|
||||
it('POST accepts routable category image_update_available', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/notification-suppression-rules')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'Mute image updates',
|
||||
stack_patterns: [],
|
||||
categories: ['image_update_available'],
|
||||
levels: null,
|
||||
applies_to: 'both',
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
if (typeof res.body?.id === 'number') {
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(res.body.id);
|
||||
}
|
||||
});
|
||||
|
||||
it('PUT updates a rule', async () => {
|
||||
const created = await request(app)
|
||||
.post('/api/notification-suppression-rules')
|
||||
.set('Cookie', authCookie)
|
||||
.send(validBody);
|
||||
const id = created.body.id as number;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/notification-suppression-rules/${id}`)
|
||||
.set('Cookie', authCookie)
|
||||
.send({ enabled: false });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.enabled).toBe(false);
|
||||
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(id);
|
||||
});
|
||||
|
||||
it('DELETE removes a rule', async () => {
|
||||
const created = await request(app)
|
||||
.post('/api/notification-suppression-rules')
|
||||
.set('Cookie', authCookie)
|
||||
.send(validBody);
|
||||
const id = created.body.id as number;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/notification-suppression-rules/${id}`)
|
||||
.set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Unit tests for notification suppression in NotificationService.dispatchAlert.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const {
|
||||
mockGetEnabledNotificationRoutes,
|
||||
mockGetEnabledAgents,
|
||||
mockGetStackLabelIds,
|
||||
mockAddNotificationHistory,
|
||||
mockUpdateNotificationDispatchError,
|
||||
mockGetEnabledNotificationSuppressionRules,
|
||||
mockUpdateNotificationSuppressionMatch,
|
||||
mockBroadcast,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]),
|
||||
mockGetEnabledAgents: vi.fn().mockReturnValue([]),
|
||||
mockGetStackLabelIds: vi.fn().mockReturnValue([]),
|
||||
mockAddNotificationHistory: vi.fn().mockReturnValue({
|
||||
id: 1,
|
||||
level: 'error',
|
||||
message: 'test',
|
||||
timestamp: Date.now(),
|
||||
is_read: 0,
|
||||
}),
|
||||
mockUpdateNotificationDispatchError: vi.fn(),
|
||||
mockGetEnabledNotificationSuppressionRules: vi.fn().mockReturnValue([]),
|
||||
mockUpdateNotificationSuppressionMatch: vi.fn(),
|
||||
mockBroadcast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/DatabaseService', () => ({
|
||||
DatabaseService: {
|
||||
getInstance: () => ({
|
||||
getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes,
|
||||
getEnabledAgents: mockGetEnabledAgents,
|
||||
getStackLabelIds: mockGetStackLabelIds,
|
||||
addNotificationHistory: mockAddNotificationHistory,
|
||||
updateNotificationDispatchError: mockUpdateNotificationDispatchError,
|
||||
getEnabledNotificationSuppressionRules: mockGetEnabledNotificationSuppressionRules,
|
||||
updateNotificationSuppressionMatch: mockUpdateNotificationSuppressionMatch,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/NodeRegistry', () => ({
|
||||
NodeRegistry: {
|
||||
getInstance: () => ({
|
||||
getDefaultNodeId: () => 1,
|
||||
getComposeDir: () => '/app/compose',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import { StackActivityMetricsService } from '../services/StackActivityMetricsService';
|
||||
|
||||
function makeSuppressionRule(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'Mute crashes',
|
||||
node_id: null as number | null,
|
||||
stack_patterns: [] as string[],
|
||||
label_ids: null as number[] | null,
|
||||
categories: ['monitor_alert'] as string[] | null,
|
||||
levels: null as string[] | null,
|
||||
applies_to: 'both' as const,
|
||||
enabled: true,
|
||||
expires_at: null as number | null,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRoute() {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'Prod Discord',
|
||||
node_id: null,
|
||||
stack_patterns: ['my-app'],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
channel_type: 'discord' as const,
|
||||
channel_url: 'https://discord.com/api/webhooks/123/abc',
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('NotificationService - suppression logic', () => {
|
||||
let svc: NotificationService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(NotificationService as unknown as { instance?: NotificationService }).instance = undefined;
|
||||
svc = NotificationService.getInstance();
|
||||
vi.spyOn(
|
||||
svc as unknown as { broadcastToSubscribers: (n: unknown) => void },
|
||||
'broadcastToSubscribers',
|
||||
).mockImplementation(mockBroadcast);
|
||||
vi.spyOn(StackActivityMetricsService.getInstance(), 'record').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('suppresses category via external dispatch', async () => {
|
||||
mockGetEnabledNotificationSuppressionRules.mockReturnValue([
|
||||
makeSuppressionRule({ categories: ['monitor_alert'], applies_to: 'external' }),
|
||||
]);
|
||||
mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]);
|
||||
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Container crashed', { stackName: 'my-app' });
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalled();
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('suppresses severity via bell only', async () => {
|
||||
mockGetEnabledNotificationSuppressionRules.mockReturnValue([
|
||||
makeSuppressionRule({ levels: ['error'], applies_to: 'bell', categories: null }),
|
||||
]);
|
||||
mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]);
|
||||
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Container crashed', { stackName: 'my-app' });
|
||||
|
||||
expect(mockBroadcast).not.toHaveBeenCalled();
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('suppresses both bell and external', async () => {
|
||||
mockGetEnabledNotificationSuppressionRules.mockReturnValue([
|
||||
makeSuppressionRule({ categories: ['monitor_alert'], applies_to: 'both' }),
|
||||
]);
|
||||
mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]);
|
||||
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Crash', { stackName: 'my-app' });
|
||||
|
||||
expect(mockBroadcast).not.toHaveBeenCalled();
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(mockUpdateNotificationSuppressionMatch).toHaveBeenCalledWith(1, {
|
||||
rules: [{ id: 1, name: 'Mute crashes' }],
|
||||
bellSuppressed: true,
|
||||
externalSuppressed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows dispatch when no suppression rules match', async () => {
|
||||
mockGetEnabledNotificationSuppressionRules.mockReturnValue([]);
|
||||
mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]);
|
||||
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Crash');
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalled();
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routing still works when suppression does not match', async () => {
|
||||
mockGetEnabledNotificationSuppressionRules.mockReturnValue([
|
||||
makeSuppressionRule({ categories: ['deploy_success'], applies_to: 'both' }),
|
||||
]);
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]);
|
||||
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Crash', { stackName: 'my-app' });
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalled();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://discord.com/api/webhooks/123/abc',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user