feat: stack glob patterns and route severity levels (#1651)

* feat: add stack glob patterns and route severity levels

Operators can filter notification routes and mute rules with anchored * globs, and routes can target info, warning, or error. Matching is fail-closed for unsafe stored patterns; write paths keep partial-PUT semantics and ReDoS caps.

* test: split mute and routing chip tests to avoid dialog race

* fix: move stack pattern client validator out of PatternChips

* fix: bound stack glob matching and atomic pattern chip saves
This commit is contained in:
Anso
2026-07-19 14:54:48 -04:00
committed by GitHub
parent 63213c0960
commit 972f2b9483
26 changed files with 1158 additions and 118 deletions
@@ -89,6 +89,7 @@ describe('Apprise secrets at rest (downgrade-safe)', () => {
stack_patterns: [],
label_ids: null,
categories: null,
levels: null,
channel_type: 'apprise',
channel_url: `http://apprise.local/notify/${keySecret}`,
config: '{}',
@@ -173,6 +174,7 @@ describe('Apprise secrets at rest (downgrade-safe)', () => {
stack_patterns: [],
label_ids: null,
categories: null,
levels: null,
channel_type: 'discord',
channel_url: 'https://discord.com/api/webhooks/1/token',
config: null,
@@ -187,6 +189,7 @@ describe('Apprise secrets at rest (downgrade-safe)', () => {
stack_patterns: [],
label_ids: null,
categories: null,
levels: null,
channel_type: 'apprise',
channel_url: 'http://apprise.local/notify/route-key',
config: '{}',
@@ -69,6 +69,7 @@ function makeAppriseRoute(overrides: Record<string, unknown> = {}) {
stack_patterns: ['my-app'],
label_ids: null,
categories: null,
levels: null,
channel_type: 'apprise' as const,
channel_url: KEYED,
config: '{}',
@@ -134,6 +134,7 @@ describe('Apprise channel helpers', () => {
stack_patterns: [],
label_ids: null,
categories: null,
levels: null,
channel_type: 'apprise',
channel_url: 'http://apprise.local/notify/path-secret',
config: JSON.stringify({ tags: 'ops' }),
@@ -73,6 +73,36 @@ describe('notificationMatchers', () => {
})).toBe(true);
});
it('matches stack globs with OR across patterns', () => {
expect(matchesNotificationFilters(baseCtx, {
node_id: null,
stack_patterns: ['prod-*', 'my-app'],
label_ids: null,
categories: null,
})).toBe(true);
expect(matchesNotificationFilters({ ...baseCtx, stackName: 'prod-web' }, {
node_id: null,
stack_patterns: ['prod-*'],
label_ids: null,
categories: null,
})).toBe(true);
expect(matchesNotificationFilters(baseCtx, {
node_id: null,
stack_patterns: ['prod-*'],
label_ids: null,
categories: null,
})).toBe(false);
});
it('fails closed on invalid stored stack patterns', () => {
expect(matchesNotificationFilters(baseCtx, {
node_id: null,
stack_patterns: ['****'],
label_ids: null,
categories: null,
})).toBe(false);
});
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);
@@ -0,0 +1,83 @@
/**
* Additive `levels` column on notification_routes.
* Exercises production DatabaseService startup against a pre-levels schema.
*/
import { describe, it, expect, afterEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import Database from 'better-sqlite3';
import { DatabaseService } from '../services/DatabaseService';
function resetDatabaseSingleton(): void {
const holder = DatabaseService as unknown as { instance?: DatabaseService };
const existing = holder.instance;
if (existing) {
try {
existing.getDb().close();
} catch {
// already closed
}
holder.instance = undefined;
}
}
describe('notification route levels column migration', () => {
let scratchDir: string | null = null;
afterEach(() => {
resetDatabaseSingleton();
if (scratchDir) {
try {
fs.rmSync(scratchDir, { recursive: true, force: true });
} catch {
// best-effort
}
scratchDir = null;
}
});
it('adds levels via DatabaseService startup; legacy rows load as null; reopen is idempotent', { timeout: 60_000 }, () => {
scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-route-levels-mig-'));
const dbPath = path.join(scratchDir, 'sencho.db');
const seed = new Database(dbPath);
try {
seed.exec(`
CREATE TABLE notification_routes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
channel_type TEXT NOT NULL,
channel_url TEXT NOT NULL,
stack_patterns TEXT NOT NULL DEFAULT '[]',
priority INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
INSERT INTO notification_routes
(name, channel_type, channel_url, stack_patterns, priority, enabled, created_at, updated_at)
VALUES ('Legacy', 'slack', 'https://hooks.slack.com/services/legacy', '[]', 0, 1, 1, 1);
`);
} finally {
seed.close();
}
process.env.DATA_DIR = scratchDir;
resetDatabaseSingleton();
const db = DatabaseService.getInstance();
const routeCols = db.getDb().prepare('PRAGMA table_info(notification_routes)').all() as Array<{ name: string }>;
expect(routeCols.filter((c) => c.name === 'levels')).toHaveLength(1);
const route = db.getNotificationRoutes().find((r) => r.name === 'Legacy');
expect(route).toBeDefined();
expect(route!.levels).toBeNull();
resetDatabaseSingleton();
process.env.DATA_DIR = scratchDir;
const db2 = DatabaseService.getInstance();
const routeCols2 = db2.getDb().prepare('PRAGMA table_info(notification_routes)').all() as Array<{ name: string }>;
expect(routeCols2.filter((c) => c.name === 'levels')).toHaveLength(1);
expect(db2.getNotificationRoutes().find((r) => r.name === 'Legacy')!.levels).toBeNull();
});
});
@@ -824,3 +824,85 @@ describe('GET /api/notifications - history', () => {
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to clear notifications:', expect.any(Error));
});
});
describe('notification routes - glob patterns and levels', () => {
it('POST omits stack_patterns and levels to defaults', async () => {
const res = await request(app)
.post('/api/notification-routes')
.set('Cookie', authCookie)
.send({
name: 'defaults',
channel_type: 'discord',
channel_url: 'https://discord.com/api/webhooks/1/abc',
});
expect(res.status).toBe(201);
expect(res.body.stack_patterns).toEqual([]);
expect(res.body.levels).toBeNull();
DatabaseService.getInstance().deleteNotificationRoute(res.body.id);
});
it('POST rejects null stack_patterns and ReDoS patterns', async () => {
const nullRes = await request(app)
.post('/api/notification-routes')
.set('Cookie', authCookie)
.send({
name: 'bad-null',
stack_patterns: null,
channel_type: 'discord',
channel_url: 'https://discord.com/api/webhooks/1/abc',
});
expect(nullRes.status).toBe(400);
const redos = await request(app)
.post('/api/notification-routes')
.set('Cookie', authCookie)
.send({
name: 'bad-redos',
stack_patterns: ['****'],
channel_type: 'discord',
channel_url: 'https://discord.com/api/webhooks/1/abc',
});
expect(redos.status).toBe(400);
});
it('POST/GET/PUT levels round-trip; invalid levels 400', async () => {
const created = await request(app)
.post('/api/notification-routes')
.set('Cookie', authCookie)
.send({
name: 'level-route',
stack_patterns: ['prod-*'],
levels: ['error'],
channel_type: 'discord',
channel_url: 'https://discord.com/api/webhooks/1/abc',
});
expect(created.status).toBe(201);
expect(created.body.levels).toEqual(['error']);
expect(created.body.stack_patterns).toEqual(['prod-*']);
const id = created.body.id as number;
const bad = await request(app)
.put(`/api/notification-routes/${id}`)
.set('Cookie', authCookie)
.send({ levels: ['critical'] });
expect(bad.status).toBe(400);
const partial = await request(app)
.put(`/api/notification-routes/${id}`)
.set('Cookie', authCookie)
.send({ name: 'level-route-renamed' });
expect(partial.status).toBe(200);
expect(partial.body.levels).toEqual(['error']);
expect(partial.body.stack_patterns).toEqual(['prod-*']);
const cleared = await request(app)
.put(`/api/notification-routes/${id}`)
.set('Cookie', authCookie)
.send({ levels: null, stack_patterns: [] });
expect(cleared.status).toBe(200);
expect(cleared.body.levels).toBeNull();
expect(cleared.body.stack_patterns).toEqual([]);
DatabaseService.getInstance().deleteNotificationRoute(id);
});
});
@@ -70,6 +70,7 @@ function makeRoute(overrides: Record<string, unknown> = {}) {
stack_patterns: ['my-app'],
label_ids: null as number[] | null,
categories: null as string[] | null,
levels: null as ('info' | 'warning' | 'error')[] | null,
channel_type: 'discord' as const,
channel_url: 'https://discord.com/api/webhooks/123/abc',
priority: 0,
@@ -370,6 +371,53 @@ describe('NotificationService - routing logic', () => {
);
});
it('routes by severity when levels is set; mismatches fall back to agents', async () => {
mockGetEnabledNotificationRoutes.mockReturnValue([
makeRoute({ stack_patterns: [], levels: ['error'] }),
]);
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
await svc.dispatchAlert('info', 'monitor_alert', 'Info only', { stackName: 'my-app' });
expect(mockFetch).not.toHaveBeenCalledWith(
'https://discord.com/api/webhooks/123/abc',
expect.anything(),
);
expect(mockFetch).toHaveBeenCalledWith(
'https://hooks.slack.com/services/global',
expect.objectContaining({ method: 'POST' }),
);
vi.clearAllMocks();
mockGetEnabledNotificationRoutes.mockReturnValue([
makeRoute({ stack_patterns: [], levels: ['error'] }),
]);
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
await svc.dispatchAlert('error', 'monitor_alert', 'Error route', { stackName: 'my-app' });
expect(mockFetch).toHaveBeenCalledWith(
'https://discord.com/api/webhooks/123/abc',
expect.objectContaining({ method: 'POST' }),
);
});
it('fails closed on invalid stored route patterns without blocking agent fallback', async () => {
mockGetEnabledNotificationRoutes.mockReturnValue([
makeRoute({ stack_patterns: ['****'] }),
]);
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
await svc.dispatchAlert('error', 'monitor_alert', 'Bad pattern', { stackName: 'my-app' });
expect(mockFetch).not.toHaveBeenCalledWith(
'https://discord.com/api/webhooks/123/abc',
expect.anything(),
);
expect(mockFetch).toHaveBeenCalledWith(
'https://hooks.slack.com/services/global',
expect.objectContaining({ method: 'POST' }),
);
});
it('fires a category-only route when category matches', async () => {
mockGetEnabledNotificationRoutes.mockReturnValue([
makeRoute({ stack_patterns: [], categories: ['deploy_failure'] }),
@@ -173,4 +173,126 @@ describe('Notification suppression - CRUD', () => {
.set('Cookie', authCookie);
expect(res.status).toBe(200);
});
it('POST omits stack_patterns to []; rejects malformed and ReDoS patterns', async () => {
const omitted = await request(app)
.post('/api/notification-suppression-rules')
.set('Cookie', authCookie)
.send({
name: 'Mute omit',
applies_to: 'both',
});
expect(omitted.status).toBe(201);
expect(omitted.body.stack_patterns).toEqual([]);
DatabaseService.getInstance().deleteNotificationSuppressionRule(omitted.body.id);
const bad = await request(app)
.post('/api/notification-suppression-rules')
.set('Cookie', authCookie)
.send({ ...validBody, name: 'bad', stack_patterns: null });
expect(bad.status).toBe(400);
const redos = await request(app)
.post('/api/notification-suppression-rules')
.set('Cookie', authCookie)
.send({ ...validBody, name: 'redos', stack_patterns: ['****'] });
expect(redos.status).toBe(400);
});
it('PUT enabled-only preserves patterns; explicit [] clears', async () => {
const created = await request(app)
.post('/api/notification-suppression-rules')
.set('Cookie', authCookie)
.send(validBody);
const id = created.body.id as number;
const partial = await request(app)
.put(`/api/notification-suppression-rules/${id}`)
.set('Cookie', authCookie)
.send({ enabled: false });
expect(partial.status).toBe(200);
expect(partial.body.enabled).toBe(false);
expect(partial.body.stack_patterns).toEqual(['staging']);
expect(partial.body.levels).toEqual(['warning']);
const cleared = await request(app)
.put(`/api/notification-suppression-rules/${id}`)
.set('Cookie', authCookie)
.send({ stack_patterns: [] });
expect(cleared.status).toBe(200);
expect(cleared.body.stack_patterns).toEqual([]);
DatabaseService.getInstance().deleteNotificationSuppressionRule(id);
});
it('replica requires and validates stack_patterns', async () => {
const jwt = await import('jsonwebtoken');
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
const missing = await request(app)
.post('/api/notification-suppression-rules/replica')
.set('Authorization', `Bearer ${token}`)
.send({
rule: {
id: 9001,
name: 'replica',
applies_to: 'both',
node_id: null,
label_ids: null,
categories: null,
levels: null,
enabled: true,
expires_at: null,
created_at: 1,
updated_at: 1,
},
});
expect(missing.status).toBe(400);
const redos = await request(app)
.post('/api/notification-suppression-rules/replica')
.set('Authorization', `Bearer ${token}`)
.send({
rule: {
id: 9001,
name: 'replica',
applies_to: 'both',
stack_patterns: ['****'],
node_id: null,
label_ids: null,
categories: null,
levels: null,
enabled: true,
expires_at: null,
created_at: 1,
updated_at: 1,
},
});
expect(redos.status).toBe(400);
expect(DatabaseService.getInstance().getNotificationSuppressionRule(9001)).toBeUndefined();
const ok = await request(app)
.post('/api/notification-suppression-rules/replica')
.set('Authorization', `Bearer ${token}`)
.send({
rule: {
id: 9001,
name: 'replica',
applies_to: 'both',
stack_patterns: ['prod-*'],
node_id: null,
label_ids: null,
categories: null,
levels: null,
enabled: true,
expires_at: null,
created_at: 1,
updated_at: 1,
},
});
expect(ok.status).toBe(200);
expect(DatabaseService.getInstance().getNotificationSuppressionRule(9001)?.stack_patterns).toEqual(['prod-*']);
DatabaseService.getInstance().deleteNotificationSuppressionRule(9001);
});
});
@@ -84,6 +84,7 @@ function makeRoute() {
stack_patterns: ['my-app'],
label_ids: null,
categories: null,
levels: null,
channel_type: 'discord' as const,
channel_url: 'https://discord.com/api/webhooks/123/abc',
priority: 0,
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import {
stackPatternMatches,
validateStackPatternForRedos,
parseStackPatternsInput,
cleanStackPatterns,
} from '../helpers/stackPattern';
describe('stackPattern', () => {
it('matches exact names without wildcards', () => {
expect(stackPatternMatches('prod-api', 'prod-api')).toBe(true);
expect(stackPatternMatches('Prod-api', 'prod-api')).toBe(false);
});
it('matches anchored case-sensitive globs', () => {
expect(stackPatternMatches('prod-api', 'prod-*')).toBe(true);
expect(stackPatternMatches('staging-api', 'prod-*')).toBe(false);
expect(stackPatternMatches('prod-api-extra', 'prod-*')).toBe(true);
expect(stackPatternMatches('xprod-api', 'prod-*')).toBe(false);
});
it('treats ? and regex metacharacters as literal', () => {
expect(stackPatternMatches('a?b', 'a?b')).toBe(true);
expect(stackPatternMatches('ab', 'a?b')).toBe(false);
expect(stackPatternMatches('a.b', 'a.b')).toBe(true);
expect(stackPatternMatches('axb', 'a.b')).toBe(false);
expect(stackPatternMatches('a+b', 'a+b')).toBe(true);
});
it('fails closed on write-cap rejected patterns without throwing', () => {
expect(() => stackPatternMatches('anything', '****')).not.toThrow();
expect(stackPatternMatches('anything', '****')).toBe(false);
expect(stackPatternMatches('x', 'a'.repeat(201))).toBe(false);
expect(stackPatternMatches('x', `${'a*'.repeat(9)}`)).toBe(false);
});
it('matches accepted separated-star patterns without RegExp backtracking', () => {
const pattern = '*a*a*a*a*a*a*a*a';
expect(validateStackPatternForRedos(pattern)).toBeNull();
expect(stackPatternMatches('aaaaaaaa', pattern)).toBe(true);
const nonMatch = `${'a'.repeat(80)}b`;
const started = performance.now();
expect(stackPatternMatches(nonMatch, pattern)).toBe(false);
expect(performance.now() - started).toBeLessThan(50);
});
it('OR semantics are call-site: any pattern may match', () => {
const patterns = ['staging-*', 'prod-api'];
expect(patterns.some((p) => stackPatternMatches('prod-api', p))).toBe(true);
expect(patterns.some((p) => stackPatternMatches('staging-web', p))).toBe(true);
expect(patterns.some((p) => stackPatternMatches('dev-web', p))).toBe(false);
});
it('validateStackPatternForRedos rejects unsafe inputs', () => {
expect(validateStackPatternForRedos('ok-*')).toBeNull();
expect(validateStackPatternForRedos('****')).toMatch(/consecutive/);
expect(validateStackPatternForRedos('a'.repeat(201))).toMatch(/too long/);
expect(validateStackPatternForRedos('*a*a*a*a*a*a*a*a')).toBeNull();
});
it('parseStackPatternsInput rejects non-arrays and non-strings', () => {
expect(parseStackPatternsInput(null).ok).toBe(false);
expect(parseStackPatternsInput('prod-*').ok).toBe(false);
expect(parseStackPatternsInput([1]).ok).toBe(false);
expect(parseStackPatternsInput(['prod-*', '****']).ok).toBe(false);
expect(parseStackPatternsInput([' prod-* ', 'prod-*'])).toEqual({
ok: true,
patterns: ['prod-*'],
});
});
it('cleanStackPatterns trims, drops blanks, dedupes', () => {
expect(cleanStackPatterns([' a ', '', 'a', 'b'])).toEqual(['a', 'b']);
});
});